Merge remote-tracking branch 'origin/dev' into fix/cli-run-premature-exit-with-background-tasks
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Verce
|
||||
OpenAI Native openai/ models (GPT-5.4 for Oracle)
|
||||
Gemini Native google/ models (Gemini 3.1 Pro, Flash)
|
||||
Copilot github-copilot/ models (fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-7, etc.)
|
||||
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
|
||||
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
|
||||
Vercel vercel/ models (universal proxy, always last fallback)
|
||||
|
||||
@@ -26,8 +26,8 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
//#then
|
||||
expect([
|
||||
"github-copilot/claude-opus-4.6",
|
||||
"github-copilot/claude-opus-4-6",
|
||||
"github-copilot/claude-opus-4.7",
|
||||
"github-copilot/claude-opus-4-7",
|
||||
]).toContain((result.agents as Record<string, { model: string }>).sisyphus.model)
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
//#then
|
||||
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("uses native OpenAI models when only ChatGPT available", () => {
|
||||
@@ -131,7 +131,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
}>
|
||||
|
||||
//#then
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
|
||||
expect(agents.sisyphus.fallback_models).toEqual([
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
@@ -141,7 +141,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
expect(categories.deep.model).toBe("openai/gpt-5.4")
|
||||
expect(categories.deep.fallback_models).toEqual([
|
||||
{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
])
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# src/cli/doctor/ — Health Diagnostics (25 Check Files)
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
`bunx oh-my-opencode doctor` — parallel diagnostic checks across 4 categories (System, Config, Tools, Models). Catches broken installs, config typos, missing dependencies, provider misconfigurations before they become runtime errors.
|
||||
|
||||
## COMMAND FLAGS
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode doctor # Full diagnostics (all 4 categories)
|
||||
bunx oh-my-opencode doctor --status # Compact dashboard (status only)
|
||||
bunx oh-my-opencode doctor --verbose # Deep details (model resolution traces)
|
||||
bunx oh-my-opencode doctor --json # Machine-readable output
|
||||
```
|
||||
|
||||
## CHECK CATEGORIES
|
||||
|
||||
| Category | File | Validates |
|
||||
|----------|------|-----------|
|
||||
| **SYSTEM** | `checks/system.ts` | OpenCode binary found + version ≥1.0.150, plugin registered in opencode.json, loaded plugin version matches installed |
|
||||
| **CONFIG** | `checks/config.ts` | JSONC validity, Zod schema passes, no unknown keys, model override syntax correct |
|
||||
| **TOOLS** | `checks/tools.ts` | AST-Grep CLI + NAPI, comment-checker binary, LSP servers reachable, GitHub CLI auth, built-in MCP reachability |
|
||||
| **MODELS** | `checks/model-resolution.ts` | models.json cache exists, per-agent fallback resolution, category overrides valid, provider availability |
|
||||
|
||||
## SUPPORTING CHECK FILES (25 total)
|
||||
|
||||
```
|
||||
checks/
|
||||
├── index.ts # Registration
|
||||
├── system.ts # Main System aggregator
|
||||
├── system-binary.ts # OpenCode binary discovery (PATH + desktop app)
|
||||
├── system-plugin.ts # opencode.json plugin entry detection
|
||||
├── system-loaded-version.ts # Cache vs npm latest
|
||||
├── config.ts # Main Config aggregator
|
||||
├── tools.ts # Main Tools aggregator
|
||||
├── dependencies.ts # AST-Grep CLI/NAPI + comment-checker presence
|
||||
├── tools-gh.ts # gh cli install + auth status
|
||||
├── tools-lsp.ts # LSP server enumeration
|
||||
├── tools-mcp.ts # Built-in + user MCP reachability
|
||||
├── model-resolution.ts # Main Models aggregator
|
||||
├── model-resolution-cache.ts # models.json presence + freshness
|
||||
├── model-resolution-config.ts # oh-my-opencode.jsonc parse
|
||||
├── model-resolution-effective-model.ts # Per-agent fallback chain trace
|
||||
├── model-resolution-variant.ts # Model variant (max, high, medium) handling
|
||||
├── model-resolution-details.ts # Verbose output formatter
|
||||
└── model-resolution-types.ts # Shared types
|
||||
```
|
||||
|
||||
## EXECUTION FLOW
|
||||
|
||||
```
|
||||
doctor command
|
||||
→ runner.ts: parallel check execution with 30s per-check timeout
|
||||
→ checks/index.ts registers all 4 category checks
|
||||
→ each check returns: { status: "ok" | "warn" | "error", detail: string }
|
||||
→ formatter.ts: render to stdout (text/status/json)
|
||||
→ exit code: 0 (all ok) | 1 (errors) | 2 (warnings only)
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | CLI command entry, flag parsing |
|
||||
| `runner.ts` | Parallel `Promise.allSettled()` orchestration, 30s timeout per check |
|
||||
| `formatter.ts` | Pretty printing: colored status, hierarchical output |
|
||||
| `types.ts` | `DoctorCheck`, `CheckResult`, `DoctorReport` types |
|
||||
|
||||
## HOW TO ADD A CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/{name}.ts` exporting check function matching `DoctorCheck`
|
||||
2. Register in `checks/index.ts`
|
||||
3. Category-level aggregator (system/config/tools/model-resolution) invokes it
|
||||
4. Return `{ status, detail }` — no throws, all errors caught by runner
|
||||
|
||||
## EXIT CODES
|
||||
|
||||
- `0`: All checks passed (or only info messages)
|
||||
- `1`: One or more errors — plugin will likely not work
|
||||
- `2`: Warnings only — plugin works with degraded features
|
||||
@@ -34,7 +34,7 @@ describe("loadAvailableModelsFromCache", () => {
|
||||
join(tempDir, "cache", "opencode", "models.json"),
|
||||
JSON.stringify({
|
||||
openai: { models: { "gpt-5.4": {} } },
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } },
|
||||
anthropic: { models: { "claude-opus-4-7": {}, "claude-sonnet-4-6": {} } },
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("model-resolution check", () => {
|
||||
// then: Should have agent entries
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-7")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("model-resolution check", () => {
|
||||
// given: User has override for oracle agent
|
||||
const mockConfig = {
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-6" },
|
||||
oracle: { model: "anthropic/claude-opus-4-7" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("model-resolution check", () => {
|
||||
// then: Oracle should show the override
|
||||
const oracle = info.agents.find((a) => a.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-6")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-6")
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-7")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
it("shows user override for category when configured", async () => {
|
||||
@@ -169,13 +169,13 @@ describe("model-resolution check", () => {
|
||||
|
||||
const info = getModelResolutionInfoWithOverrides({
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-6-thinking" },
|
||||
oracle: { model: "anthropic/claude-opus-4-7-thinking" },
|
||||
},
|
||||
})
|
||||
|
||||
const oracle = info.agents.find((agent) => agent.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-6-thinking")
|
||||
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-7-thinking")
|
||||
expect(oracle!.capabilityDiagnostics).toMatchObject({
|
||||
resolutionMode: "alias-backed",
|
||||
canonicalization: {
|
||||
|
||||
@@ -355,9 +355,9 @@ describe("generateModelConfig", () => {
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should use native OpenAI model
|
||||
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4")
|
||||
expect(result.agents?.explore?.variant).toBe("medium")
|
||||
// #then explore should use native OpenAI mini-fast (primary model)
|
||||
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4-mini-fast")
|
||||
expect(result.agents?.explore?.variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("explore uses gpt-5-mini when only Copilot available", () => {
|
||||
@@ -381,7 +381,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("Sisyphus is created when multiple fallback providers are available", () => {
|
||||
@@ -398,7 +398,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("Sisyphus resolves to gpt-5.4 medium when only OpenAI is available", () => {
|
||||
@@ -553,15 +553,15 @@ describe("generateModelConfig", () => {
|
||||
})
|
||||
|
||||
describe("special-case agents include fallback_models", () => {
|
||||
test("explore includes fallback_models when Copilot and Claude are both available", () => {
|
||||
// #given both Copilot and Claude are available
|
||||
const config = createConfig({ hasCopilot: true, hasClaude: true })
|
||||
test("explore includes fallback_models when OpenAI and Claude are both available", () => {
|
||||
// #given both OpenAI and Claude are available
|
||||
const config = createConfig({ hasOpenAI: true, hasClaude: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should have fallback_models from the remaining chain entries
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4-mini-fast")
|
||||
expect(result.agents?.explore?.fallback_models).toBeDefined()
|
||||
expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -573,37 +573,33 @@ describe("generateModelConfig", () => {
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then explore should not have fallback_models (only one chain entry matches)
|
||||
// #then explore should not have fallback_models (only one distinct chain entry matches)
|
||||
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(result.agents?.explore?.fallback_models).toEqual([
|
||||
{
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
},
|
||||
])
|
||||
expect(result.agents?.explore?.fallback_models).toBeUndefined()
|
||||
})
|
||||
|
||||
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
|
||||
// #given opencode-go and Claude are available
|
||||
const config = createConfig({ hasOpencodeGo: true, hasClaude: true })
|
||||
test("librarian includes fallback_models when OpenAI and opencode-go are both available", () => {
|
||||
// #given OpenAI and opencode-go are available
|
||||
const config = createConfig({ hasOpenAI: true, hasOpencodeGo: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then librarian should have fallback_models
|
||||
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
|
||||
expect(result.agents?.librarian?.model).toBe("openai/gpt-5.4-mini-fast")
|
||||
expect(result.agents?.librarian?.fallback_models).toBeDefined()
|
||||
expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("librarian omits fallback_models when only one provider matches", () => {
|
||||
// #given only opencode-go is available
|
||||
const config = createConfig({ hasOpencodeGo: true })
|
||||
test("librarian omits fallback_models when only ZAI is available", () => {
|
||||
// #given only ZAI is available
|
||||
const config = createConfig({ hasZaiCodingPlan: true })
|
||||
|
||||
// #when generateModelConfig is called
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then librarian should not have fallback_models
|
||||
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
|
||||
expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7")
|
||||
expect(result.agents?.librarian?.fallback_models).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -672,7 +668,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then should prefer native anthropic over gateway
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -127,7 +127,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
|
||||
if (role === "librarian") {
|
||||
let agentConfig: AgentConfig | undefined
|
||||
if (avail.opencodeGo) {
|
||||
if (avail.native.openai) {
|
||||
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
|
||||
} else if (avail.opencodeGo) {
|
||||
agentConfig = { model: "opencode-go/minimax-m2.7" }
|
||||
} else if (avail.zai) {
|
||||
agentConfig = { model: ZAI_MODEL }
|
||||
@@ -142,7 +144,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
|
||||
if (role === "explore") {
|
||||
let agentConfig: AgentConfig
|
||||
if (avail.native.claude) {
|
||||
if (avail.native.openai) {
|
||||
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
|
||||
} else if (avail.native.claude) {
|
||||
agentConfig = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else if (avail.opencodeZen) {
|
||||
agentConfig = { model: "opencode/claude-haiku-4-5" }
|
||||
|
||||
@@ -28,8 +28,8 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.explore).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
|
||||
expect(result.agents?.librarian).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
|
||||
expect(result.agents?.explore).toEqual({ model: "openai/gpt-5.4-mini-fast" })
|
||||
expect(result.agents?.librarian).toEqual({ model: "openai/gpt-5.4-mini-fast" })
|
||||
})
|
||||
|
||||
test("fills remaining OpenAI-only category gaps with OpenAI models", () => {
|
||||
@@ -54,8 +54,10 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.explore).toMatchObject({ model: "opencode-go/minimax-m2.7" })
|
||||
expect(result.agents?.librarian).toMatchObject({ model: "opencode-go/minimax-m2.7" })
|
||||
expect(result.agents?.explore).toMatchObject({ model: "openai/gpt-5.4-mini-fast" })
|
||||
expect(result.agents?.librarian).toMatchObject({ model: "openai/gpt-5.4-mini-fast" })
|
||||
expect(result.agents?.explore).not.toMatchObject({ variant: "medium" })
|
||||
expect(result.agents?.librarian).not.toMatchObject({ variant: "medium" })
|
||||
expect(result.categories?.quick).toMatchObject({ model: "openai/gpt-5.4-mini" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig, ProviderAvailability } from "./model-fallback-types"
|
||||
|
||||
const OPENAI_ONLY_AGENT_OVERRIDES: Record<string, AgentConfig> = {
|
||||
explore: { model: "openai/gpt-5.4", variant: "medium" },
|
||||
librarian: { model: "openai/gpt-5.4", variant: "medium" },
|
||||
explore: { model: "openai/gpt-5.4-mini-fast" },
|
||||
librarian: { model: "openai/gpt-5.4-mini-fast" },
|
||||
}
|
||||
|
||||
const OPENAI_ONLY_CATEGORY_OVERRIDES: Record<string, CategoryConfig> = {
|
||||
|
||||
@@ -5,16 +5,16 @@ import { transformModelForProvider as transformSharedModelForProvider } from "..
|
||||
|
||||
describe("transformModelForProvider", () => {
|
||||
describe("github-copilot provider", () => {
|
||||
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
|
||||
// #given github-copilot provider and claude-opus-4-6 model
|
||||
test("transforms claude-opus-4-7 to claude-opus-4.7", () => {
|
||||
// #given github-copilot provider and claude-opus-4-7 model
|
||||
const provider = "github-copilot"
|
||||
const model = "claude-opus-4-6"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-opus-4.6
|
||||
expect(result).toBe("claude-opus-4.6")
|
||||
// #then should transform to claude-opus-4.7
|
||||
expect(result).toBe("claude-opus-4.7")
|
||||
})
|
||||
|
||||
test("transforms claude-sonnet-4-5 to claude-sonnet-4.5", () => {
|
||||
@@ -152,32 +152,32 @@ describe("transformModelForProvider", () => {
|
||||
})
|
||||
|
||||
test("does not transform claude models for google provider", () => {
|
||||
// #given google provider and claude-opus-4-6 model
|
||||
// #given google provider and claude-opus-4-7 model
|
||||
const provider = "google"
|
||||
const model = "claude-opus-4-6"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should pass through unchanged (google doesn't use claude)
|
||||
expect(result).toBe("claude-opus-4-6")
|
||||
expect(result).toBe("claude-opus-4-7")
|
||||
})
|
||||
})
|
||||
|
||||
describe("anthropic provider", () => {
|
||||
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
|
||||
// #given anthropic provider and claude-opus-4-6 model
|
||||
test("preserves hyphenated claude-opus-4-7 for config output (regression: installer must not write dotted IDs)", () => {
|
||||
// #given anthropic provider and claude-opus-4-7 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-opus-4-6"
|
||||
const model = "claude-opus-4-7"
|
||||
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-opus-4.6
|
||||
expect(result).toBe("claude-opus-4.6")
|
||||
// #then should keep hyphenated form so Anthropic provider resolution succeeds on fresh installs
|
||||
expect(result).toBe("claude-opus-4-7")
|
||||
})
|
||||
|
||||
test("transforms claude-sonnet-4-6 to claude-sonnet-4.6", () => {
|
||||
test("preserves hyphenated claude-sonnet-4-6 for config output", () => {
|
||||
// #given anthropic provider and claude-sonnet-4-6 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-sonnet-4-6"
|
||||
@@ -185,11 +185,11 @@ describe("transformModelForProvider", () => {
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-sonnet-4.6
|
||||
expect(result).toBe("claude-sonnet-4.6")
|
||||
// #then should keep hyphenated form
|
||||
expect(result).toBe("claude-sonnet-4-6")
|
||||
})
|
||||
|
||||
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
|
||||
test("preserves hyphenated claude-haiku-4-5 for config output", () => {
|
||||
// #given anthropic provider and claude-haiku-4-5 model
|
||||
const provider = "anthropic"
|
||||
const model = "claude-haiku-4-5"
|
||||
@@ -197,19 +197,19 @@ describe("transformModelForProvider", () => {
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider(provider, model)
|
||||
|
||||
// #then should transform to claude-haiku-4.5
|
||||
expect(result).toBe("claude-haiku-4.5")
|
||||
// #then should keep hyphenated form
|
||||
expect(result).toBe("claude-haiku-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("vercel provider", () => {
|
||||
test("prepends anthropic/ and applies anthropic transform for claude models", () => {
|
||||
// #given vercel provider and claude-opus-4-6 model
|
||||
// #given vercel provider and claude-opus-4-7 model
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "claude-opus-4-6")
|
||||
const result = transformModelForProvider("vercel", "claude-opus-4-7")
|
||||
|
||||
// #then should produce anthropic/claude-opus-4.6
|
||||
expect(result).toBe("anthropic/claude-opus-4.6")
|
||||
// #then should produce anthropic/claude-opus-4.7
|
||||
expect(result).toBe("anthropic/claude-opus-4.7")
|
||||
})
|
||||
|
||||
test("prepends anthropic/ and applies anthropic transform for claude-sonnet", () => {
|
||||
@@ -267,12 +267,12 @@ describe("transformModelForProvider", () => {
|
||||
})
|
||||
|
||||
test("delegates to sub-provider when model already has sub-provider prefix", () => {
|
||||
// #given vercel provider and anthropic/claude-opus-4-6 (already prefixed)
|
||||
// #given vercel provider and anthropic/claude-opus-4-7 (already prefixed)
|
||||
// #when transformModelForProvider is called
|
||||
const result = transformModelForProvider("vercel", "anthropic/claude-opus-4-6")
|
||||
const result = transformModelForProvider("vercel", "anthropic/claude-opus-4-7")
|
||||
|
||||
// #then should apply anthropic transform within the prefix
|
||||
expect(result).toBe("anthropic/claude-opus-4.6")
|
||||
expect(result).toBe("anthropic/claude-opus-4.7")
|
||||
})
|
||||
|
||||
test("prepends minimax/ for minimax models", () => {
|
||||
@@ -338,14 +338,16 @@ describe("transformModelForProvider", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses a CLI-local transform implementation", () => {
|
||||
// #given
|
||||
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-6")
|
||||
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-6")
|
||||
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
|
||||
// #given the CLI transform (used by the installer) and the shared runtime transform
|
||||
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-7")
|
||||
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-7")
|
||||
|
||||
// #when
|
||||
// #when both are called with the same anthropic claude input
|
||||
// #then the CLI preserves hyphenated form for config output,
|
||||
// the shared runtime transform converts dash→dot for API calls
|
||||
expect(transformModelForProvider).not.toBe(transformSharedModelForProvider)
|
||||
expect(cliResult).toBe("claude-opus-4.6")
|
||||
expect(sharedResult).toBe("claude-opus-4.6")
|
||||
expect(cliResult).toBe("claude-opus-4-7")
|
||||
expect(sharedResult).toBe("claude-opus-4.7")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,12 @@ export function transformModelForProvider(provider: string, model: string): stri
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return claudeVersionDot(model)
|
||||
// Installer writes hyphenated IDs (claude-opus-4-7) to the config. The
|
||||
// runtime provider-model-id-transform converts dash→dot when calling the
|
||||
// Anthropic API. Keeping the dotted form in the config breaks fresh
|
||||
// installs with ProviderModelNotFoundError because Anthropic's provider
|
||||
// registers models under hyphenated IDs.
|
||||
return model
|
||||
}
|
||||
|
||||
return model
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("refreshModelCapabilities", () => {
|
||||
sourceUrl: "https://override.example/api.json",
|
||||
models: {
|
||||
"gpt-5.4": { id: "gpt-5.4" },
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6" },
|
||||
"claude-opus-4-7": { id: "claude-opus-4-7" },
|
||||
},
|
||||
}))
|
||||
let stdout = ""
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("message.part.delta handling", () => {
|
||||
sessionID: "ses_main",
|
||||
role: "assistant",
|
||||
agent: "Sisyphus - Ultraworker",
|
||||
modelID: "claude-opus-4-6",
|
||||
modelID: "claude-opus-4-7",
|
||||
variant: "max",
|
||||
},
|
||||
},
|
||||
@@ -113,7 +113,7 @@ describe("message.part.delta handling", () => {
|
||||
//#then
|
||||
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
|
||||
expect(rendered).toContain("\u001b[38;2;0;206;209m")
|
||||
expect(rendered).toContain("claude-opus-4-6 (max)")
|
||||
expect(rendered).toContain("claude-opus-4-7 (max)")
|
||||
expect(rendered).toContain("└─")
|
||||
expect(rendered).toContain("Sisyphus - Ultraworker")
|
||||
stdoutSpy.mockRestore()
|
||||
@@ -128,7 +128,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,7 +187,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,7 +242,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -309,7 +309,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -353,7 +353,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6", variant: "max" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7", variant: "max" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -388,7 +388,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -410,7 +410,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -619,7 +619,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -634,7 +634,7 @@ describe("message.part.delta handling", () => {
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
|
||||
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun
|
||||
import * as originalSdk from "@opencode-ai/sdk"
|
||||
import * as originalPortUtils from "../../shared/port-utils"
|
||||
import * as originalBinaryResolver from "./opencode-binary-resolver"
|
||||
import * as originalServerAuth from "../../shared/opencode-server-auth"
|
||||
|
||||
const originalConsole = globalThis.console
|
||||
|
||||
@@ -13,11 +14,15 @@ const mockCreateOpencode = mock(() =>
|
||||
server: { url: "http://127.0.0.1:4096", close: mockServerClose },
|
||||
})
|
||||
)
|
||||
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
||||
const mockCreateOpencodeClient = mock((options?: { baseUrl?: string }) => ({
|
||||
session: {},
|
||||
baseUrl: options?.baseUrl,
|
||||
}))
|
||||
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
||||
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
||||
const mockConsoleLog = mock(() => {})
|
||||
const mockWithWorkingOpencodePath = mock((startServer: () => Promise<unknown>) => startServer())
|
||||
const mockInjectServerAuthIntoClient = mock(() => {})
|
||||
|
||||
mock.module("@opencode-ai/sdk", () => ({
|
||||
createOpencode: mockCreateOpencode,
|
||||
@@ -34,10 +39,15 @@ mock.module("./opencode-binary-resolver", () => ({
|
||||
withWorkingOpencodePath: mockWithWorkingOpencodePath,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-server-auth", () => ({
|
||||
injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.module("@opencode-ai/sdk", () => originalSdk)
|
||||
mock.module("../../shared/port-utils", () => originalPortUtils)
|
||||
mock.module("./opencode-binary-resolver", () => originalBinaryResolver)
|
||||
mock.module("../../shared/opencode-server-auth", () => originalServerAuth)
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
@@ -52,6 +62,7 @@ describe("createServerConnection", () => {
|
||||
mockServerClose.mockClear()
|
||||
mockConsoleLog.mockClear()
|
||||
mockWithWorkingOpencodePath.mockClear()
|
||||
mockInjectServerAuthIntoClient.mockClear()
|
||||
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
||||
})
|
||||
|
||||
@@ -59,6 +70,49 @@ describe("createServerConnection", () => {
|
||||
globalThis.console = originalConsole
|
||||
})
|
||||
|
||||
it("attach mode injects auth only for loopback URLs", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
|
||||
// when
|
||||
const localhostResult = await createServerConnection({ attach: "http://localhost:8080", signal })
|
||||
const loopbackResult = await createServerConnection({ attach: "http://127.0.0.1:8080", signal })
|
||||
const anyBindResult = await createServerConnection({ attach: "http://0.0.0.0:8080", signal })
|
||||
const remoteResult = await createServerConnection({ attach: "https://example.com", signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://localhost:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://0.0.0.0:8080" })
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "https://example.com" })
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenCalledTimes(3)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(1, localhostResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(2, loopbackResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(3, anyBindResult.client)
|
||||
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalledWith(remoteResult.client)
|
||||
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
|
||||
localhostResult.cleanup()
|
||||
loopbackResult.cleanup()
|
||||
anyBindResult.cleanup()
|
||||
remoteResult.cleanup()
|
||||
expect(mockServerClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("attach mode skips auth injection for invalid attach URLs", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
const attachUrl = "not-a-url"
|
||||
|
||||
// when
|
||||
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalled()
|
||||
result.cleanup()
|
||||
expect(mockServerClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("attach mode returns client with no-op cleanup", async () => {
|
||||
// given
|
||||
const signal = new AbortController().signal
|
||||
@@ -68,8 +122,6 @@ describe("createServerConnection", () => {
|
||||
const result = await createServerConnection({ attach: attachUrl, signal })
|
||||
|
||||
// then
|
||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
|
||||
expect(result.client).toBeDefined()
|
||||
expect(result.cleanup).toBeDefined()
|
||||
result.cleanup()
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import pc from "picocolors"
|
||||
import type { ServerConnection } from "./types"
|
||||
import { injectServerAuthIntoClient } from "../../shared/opencode-server-auth"
|
||||
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
||||
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"])
|
||||
|
||||
function isLoopbackAttachUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return LOOPBACK_HOSTS.has(parsed.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPortStartFailure(error: unknown, port: number): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
@@ -40,6 +52,9 @@ export async function createServerConnection(options: {
|
||||
if (attach !== undefined) {
|
||||
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
|
||||
const client = createOpencodeClient({ baseUrl: attach })
|
||||
if (isLoopbackAttachUrl(attach)) {
|
||||
injectServerAuthIntoClient(client)
|
||||
}
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
@@ -66,12 +81,14 @@ export async function createServerConnection(options: {
|
||||
|
||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
}
|
||||
|
||||
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
@@ -93,6 +110,7 @@ export async function createServerConnection(options: {
|
||||
|
||||
console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
|
||||
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
|
||||
injectServerAuthIntoClient(client)
|
||||
return { client, cleanup: () => {} }
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
|
||||
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.4, etc." },
|
||||
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-7, opencode/gpt-5.4, etc." },
|
||||
],
|
||||
initialValue: initial.opencodeZen,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user