From 9e84c816a0d055b21109ed8b95ae7e52137f8dfe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 19:39:39 +0000 Subject: [PATCH 1/3] fix: hide the upstream Claude Agent provider Keep the SDK tree on disk for sync, but do not register it or show it in connect, catalog, settings, TUI, or CLI menus. Anthropic stays on ClaudePlugin. --- AGENTS.md | 10 ++ PLAN-fresh-v2-rewrite.md | 5 +- .../app/src/providers/catalog/integrations.ts | 3 +- .../app/src/providers/catalog/order.test.ts | 26 +++ packages/app/src/providers/catalog/order.ts | 34 +++- .../app/src/providers/catalog/providers.ts | 4 +- .../app/src/providers/connect/controller.ts | 2 - packages/app/src/providers/connect/dialog.tsx | 153 +----------------- packages/app/src/runtime/i18n/en.ts | 11 -- .../app/src/settings/providers/providers.tsx | 1 - packages/app/src/voice/tools.ts | 5 +- .../cli/src/commands/handlers/auth/login.ts | 3 +- .../cli/src/commands/handlers/auth/shared.ts | 5 +- packages/cli/test/auth-login-choices.test.ts | 9 ++ packages/core/src/plugin/provider.ts | 8 +- .../src/plugin/provider/claude-agent/index.ts | 10 +- .../test/plugin/bundled-subscriptions.test.ts | 21 +++ .../tui/src/component/dialog-integration.tsx | 17 +- packages/tui/src/component/dialog-model.tsx | 5 +- .../cli/cmd/tui/integration-options.test.ts | 9 ++ 20 files changed, 160 insertions(+), 181 deletions(-) create mode 100644 packages/app/src/providers/catalog/order.test.ts diff --git a/AGENTS.md b/AGENTS.md index 98e5fa179e67..a6b7df802564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,16 @@ identity facts when merging or refactoring: single bookmark, so channel inference can be ambiguous or unavailable. The build fails closed in that case; the explicit channel pins the intended database and service identity. +- The upstream Claude Agent SDK provider stays on disk + (`packages/core/src/plugin/provider/claude-agent/**` and its tests, plus the + optional `@anthropic-ai/claude-agent-sdk` peer and `#claude-agent-sdk` + alias) so later syncs do not resurrect a deleted tree. It is not a Shuvcode + provider. Do not register `ClaudeAgentPlugin` or `make(...)` in + `ProviderPlugins`, and do not restore connect, catalog, settings, popular, + TUI, or CLI entries for `claude-agent` / "Claude Agent". Anthropic in the + product is `@shuvcode/claude-plugin` (`ClaudePlugin`). When merging + `upstream/dev`, drop any reintroduced registration and user-facing surface + for that provider. ## Live V2 TUI Testing diff --git a/PLAN-fresh-v2-rewrite.md b/PLAN-fresh-v2-rewrite.md index cee165a49fad..37367afa71fb 100644 --- a/PLAN-fresh-v2-rewrite.md +++ b/PLAN-fresh-v2-rewrite.md @@ -54,7 +54,10 @@ still `2.0.3`). Re-fetch again at kickoff and re-run the watchlist checks. 3. **Plugin rule:** if it can be a plugin, it is a plugin, **bundled and enabled** (Core `ProviderPlugins` / TUI `builtins`). Disable by config id (`-` in `plugins`). Do not require a user `plugins` array for the - shipped set. + shipped set. Exception: the upstream Claude Agent SDK provider + (`packages/core/src/plugin/provider/claude-agent/**`) stays on disk for + sync and stays unregistered. Do not re-enable it. Anthropic remains + `@shuvcode/claude-plugin`. See `AGENTS.md` Shuvcode Fork. 4. **Core may change only for:** identity, default port, XDG app id, one-shot DB convert script, OAuth refresh single-flight, host seams plugins cannot exist without, and watchlist items whose check **fails** at kickoff. diff --git a/packages/app/src/providers/catalog/integrations.ts b/packages/app/src/providers/catalog/integrations.ts index f8bf0d7f1d65..df59d4217b15 100644 --- a/packages/app/src/providers/catalog/integrations.ts +++ b/packages/app/src/providers/catalog/integrations.ts @@ -1,6 +1,7 @@ import { useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" import { createEffect, type Accessor } from "solid-js" +import { withoutHiddenProviders } from "./order" export function useIntegrations(directory: Accessor) { const serverSDK = useServerSDK() @@ -25,6 +26,6 @@ export function useIntegrations(directory: Accessor) { return { ready: () => data.location.integration.list(location()) !== undefined, - list: () => data.location.integration.list(location()) ?? [], + list: () => withoutHiddenProviders(data.location.integration.list(location()) ?? []), } } diff --git a/packages/app/src/providers/catalog/order.test.ts b/packages/app/src/providers/catalog/order.test.ts new file mode 100644 index 000000000000..b3fad851d747 --- /dev/null +++ b/packages/app/src/providers/catalog/order.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { connectPopular, omitHiddenProviderCatalog, popularProviders, withoutHiddenProviders } from "./order" + +test("keeps Anthropic visible and drops Claude Agent from provider menus", () => { + expect(popularProviders).toContain("anthropic") + expect(connectPopular).toContain("anthropic") + expect(popularProviders).not.toContain("claude-agent") + expect(connectPopular).not.toContain("claude-agent") + + expect(withoutHiddenProviders([{ id: "claude-agent" }, { id: "anthropic" }]).map((item) => item.id)).toEqual([ + "anthropic", + ]) + + const catalog = omitHiddenProviderCatalog({ + all: new Map([ + ["claude-agent", { name: "Claude Agent" }], + ["anthropic", { name: "Anthropic" }], + ]), + connected: ["claude-agent", "anthropic"], + default: { "claude-agent": "sonnet", anthropic: "claude-sonnet" }, + }) + + expect([...catalog.all.keys()]).toEqual(["anthropic"]) + expect(catalog.connected).toEqual(["anthropic"]) + expect(catalog.default).toEqual({ anthropic: "claude-sonnet" }) +}) diff --git a/packages/app/src/providers/catalog/order.ts b/packages/app/src/providers/catalog/order.ts index ca3b975f9863..e7e0c738f39c 100644 --- a/packages/app/src/providers/catalog/order.ts +++ b/packages/app/src/providers/catalog/order.ts @@ -2,10 +2,42 @@ export const popularProviders = [ "opencode-go", "opencode", "anthropic", - "claude-agent", "github-copilot", "openai", "google", "openrouter", "vercel", ] + +// Connect dialog group order. Matches the TUI priority list. +export const connectPopular = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "google"] + +// Upstream Claude Agent stays out of every Shuvcode catalog, even when an +// older server still returns the integration. +const hiddenProviderIDs = new Set(["claude-agent"]) + +export function isHiddenProvider(id: string) { + return hiddenProviderIDs.has(id) +} + +export function withoutHiddenProviders(items: readonly T[]) { + return items.filter((item) => !isHiddenProvider(item.id)) +} + +export function omitHiddenProviderCatalog(catalog: { + all: Map + connected: readonly string[] + default: Record +}) { + if (![...catalog.all.keys()].some(isHiddenProvider) && catalog.connected.every((id) => !isHiddenProvider(id))) { + return catalog + } + + const all = new Map([...catalog.all].filter(([id]) => !isHiddenProvider(id))) + + return { + all, + connected: catalog.connected.filter((id) => !isHiddenProvider(id)), + default: Object.fromEntries(Object.entries(catalog.default).filter(([id]) => !isHiddenProvider(id))), + } +} diff --git a/packages/app/src/providers/catalog/providers.ts b/packages/app/src/providers/catalog/providers.ts index 45033d6ffaff..66745a7aa056 100644 --- a/packages/app/src/providers/catalog/providers.ts +++ b/packages/app/src/providers/catalog/providers.ts @@ -7,7 +7,7 @@ import { Iterable, pipe } from "effect" import { createEffect, createMemo, type Accessor } from "solid-js" import type { ProviderListResponse } from "@/runtime/server/types" import { useIntegrations } from "./integrations" -import { popularProviders } from "./order" +import { omitHiddenProviderCatalog, popularProviders } from "./order" export { popularProviders } from "./order" @@ -49,7 +49,7 @@ export function useProviders(directory: Accessor) { if (!provider || !model) return emptyProviderCatalog - return normalizeProviderList(provider, model) + return omitHiddenProviderCatalog(normalizeProviderList(provider, model)) }) const ready = () => { diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index 80764377f129..22e3c74ec381 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -32,8 +32,6 @@ type Polling = { // and connects with its own methods, the same as the TUI. export const CONSOLE_INTEGRATION = "opencode" -export const CLAUDE_AGENT_INTEGRATION = "claude-agent" - // Same order as the TUI: browser and command sign-ins first, API keys last. export function connectMethods(integration: IntegrationInfo) { return integration.methods diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 6932a67e2284..8bd6af2e718a 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -21,7 +21,6 @@ import { onMount, Show, Switch, - untrack, } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" @@ -32,12 +31,12 @@ import { useData } from "@/runtime/server/current" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" import { useProviders } from "@/providers/catalog/providers" +import { connectPopular } from "@/providers/catalog/order" import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console" import { useIntegrations } from "@/providers/catalog/integrations" import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group" import type { ModelSelection } from "@/providers/models/selection" import { decode64 } from "@/runtime/persistence/base64" -import { ExternalLink } from "@/runtime/platform/external-link" import { SettingsList } from "@/settings/list" import { useTabs } from "@/shell/tabs/tabs" import { Persist, persisted } from "@/runtime/persistence/storage" @@ -45,7 +44,6 @@ import { Persistence } from "@/runtime/persistence/schema" import { Schema } from "effect" import type { FormAnswer, FormField, FormValue, IntegrationInfo } from "@opencode/client/promise" import { - CLAUDE_AGENT_INTEGRATION, CONSOLE_INTEGRATION, connectMethods, createProviderConnectionController, @@ -268,12 +266,8 @@ export const DialogConnectProvider: Component<{ ) } -const CLAUDE_CODE_SETUP_URL = "https://code.claude.com/docs/en/setup" - -const CLAUDE_AGENT_DOCS_URL = "https://opencode.ai/v2/docs/providers#claude-agent" - -// The TUI's integration groups and order, plus Claude Agent, with connected integrations pulled up into their own group first. -const POPULAR = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "claude-agent", "google"] +// The TUI's integration groups and order, with connected integrations pulled up into their own group first. +const POPULAR: readonly string[] = connectPopular function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) { const integrations = useIntegrations(() => props.directory) @@ -518,7 +512,6 @@ function ProviderConnection(props: { const accountActions = useProviderAccountActions(directory) const integrationID = props.provider const isConsole = props.provider === CONSOLE_INTEGRATION - const isClaudeAgent = props.provider === CLAUDE_AGENT_INTEGRATION const remote = isConsole && authServerName(sdk.server) !== undefined const [state, setState] = createStore<{ @@ -535,7 +528,6 @@ function ProviderConnection(props: { switching?: string activating?: string renaming?: string - claudeAgent?: "checking" | "confirming" | "timeout" }>({ adding: false, direct: false, @@ -565,21 +557,12 @@ function ProviderConnection(props: { const controller = createProviderConnectionController({ provider: () => integrationID, directory, - // Claude Agent explains its setup before connecting, so its lone method never starts on its own. - manual: () => isClaudeAgent || (!!props.accounts && accounts().length > 0), + manual: () => !!props.accounts && accounts().length > 0, prepare: isConsole ? prepareConsoleCatalog : undefined, pollInterval: isConsole ? 500 : undefined, onComplete: () => { const method = controller.currentMethod() props.onConnected?.(method?.type === "oauth" ? method.id : undefined) - - // Saving the connection only records the choice; wait for the server's check of `claude`. - if (isClaudeAgent) { - setState("claudeAgent", "checking") - - return - } - showConnected() }, }) @@ -647,58 +630,6 @@ function ProviderConnection(props: { const managedProviders = createMemo(() => (isConsole ? consoleProviderGroup(connectionProviders()) : undefined)) - // The server lists the active account first, and its status is the latest check of `claude`. - const claudeAgentProblem = () => (isClaudeAgent ? serverAccounts()[0]?.status : undefined) - - // A healthy connection keeps the usual accounts list; anything else shows the setup and its latest check. - const claudeAgentSetup = () => - isClaudeAgent && - controller.auth.state() !== "error" && - (!showAccounts() || !!state.claudeAgent || !!claudeAgentProblem() || controller.methodIndex() !== undefined) - - // A check that outlives the timeout can still finish, so a late result replaces the timeout message. - createEffect(() => { - if (!state.claudeAgent) return - - if (claudeAgentProblem()) return setState("claudeAgent", undefined) - - if (state.claudeAgent === "confirming" || connectionGroups().length === 0) return - setState("claudeAgent", "confirming") - void untrack(confirmClaudeAgent) - }) - - // A signed-out `claude` still lists its models, and the login problem arrives in a separate event, - // so read the connection again before treating the models as a working login. - async function confirmClaudeAgent() { - const ref = location() - data.location.integration.invalidate(ref) - await data.location.integration.sync(ref).catch(() => undefined) - - if (state.claudeAgent !== "confirming" || claudeAgentProblem()) return - setState("claudeAgent", undefined) - showConnected() - } - - createEffect(() => { - if (state.claudeAgent !== "checking") return - // The server allows 30 seconds for `claude` to start and 30 more to read each model's context size. - const timer = setTimeout(() => setState("claudeAgent", "timeout"), 65_000) - onCleanup(() => clearTimeout(timer)) - }) - - // Connecting again is what makes the server check `claude` again. - const checkClaudeAgent = async () => { - const previous = serverAccounts().map((account) => account.id) - setState("claudeAgent", undefined) - await controller.auth.select(0) - - if (controller.auth.state() !== "ready") return - // Each connect saves a new account, so drop the ones this check replaces. - await Promise.all( - previous.map((credentialID) => sdk.api.credential.remove({ credentialID }).catch(() => undefined)), - ) - } - // The server loads the Console workspace's providers after the grant lands, so the first refresh // can still show only the free catalog. Poll briefly for the workspace providers before moving on. async function prepareConsoleCatalog(active: () => boolean) { @@ -793,7 +724,7 @@ function ProviderConnection(props: { controller.authorization()?.mode === "auto" createEffect(() => { - if (showAccounts() && !claudeAgentSetup()) return props.onTitle(provider().name) + if (showAccounts()) return props.onTitle(provider().name) if (isConsole && controller.auth.state() === "error") return props.onTitle(language.t("provider.connect.opencode.errorTitle")) @@ -1215,7 +1146,6 @@ function ProviderConnection(props: { props.onAccounts( showAccounts() && controller.methodIndex() === undefined && - !claudeAgentSetup() && !(state.noModels && controller.auth.state() !== "error"), ), ) @@ -1480,8 +1410,8 @@ function ProviderConnection(props: { const addAccount = () => { setState({ adding: true, direct: false, confirming: undefined }) - // Like the TUI: a lone method starts directly, several show the method list. Claude Agent shows its setup first. - if (isClaudeAgent || controller.methods().length !== 1) return + // Like the TUI: a lone method starts directly, several show the method list. + if (controller.methods().length !== 1) return setState("direct", true) void controller.auth.select(0) } @@ -1634,72 +1564,6 @@ function ProviderConnection(props: { ) } - function ClaudeAgentSetup() { - const problem = () => - claudeAgentProblem()?.message ?? - (state.claudeAgent === "timeout" ? language.t("provider.connect.claudeAgent.timeout") : undefined) - - const checking = () => - state.claudeAgent === "checking" || - state.claudeAgent === "confirming" || - controller.auth.state() === "pending" || - controller.auth.state() === "refreshing" - - return ( -
-

- {language.t("provider.connect.claudeAgent.description")}{" "} - {language.t("provider.connect.claudeAgent.learnMore")} -

- - {(message) => ( -
- - {message()} -
- )} -
- - {language.t("provider.connect.claudeAgent.checking")}} - > -
- - -
-
-
- ) - } - function ConsoleNoModels() { return (
@@ -1911,9 +1775,6 @@ function ProviderConnection(props: { />
- - - diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 3ea280e06667..9708688912c1 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -148,7 +148,6 @@ export const dict = { "dialog.provider.opencode.tagline": "Reliable optimized models", "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", "dialog.provider.anthropic.note": "Direct access to Claude models via API key", - "dialog.provider.claudeAgent.note": "Use your Claude plan or another Claude Code login", "dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", "dialog.provider.google.note": "Gemini models for fast, structured responses", @@ -229,16 +228,6 @@ export const dict = { "provider.connect.remote.description": "Your OpenCode credentials will be stored on this server. Models will be available through this server.", "provider.connect.title.anthropicProMax": "Login with Anthropic", - "provider.connect.claudeAgent.description": - "Use your Claude plan or another Claude Code login. Install Claude Code and sign in first.", - "provider.connect.claudeAgent.learnMore": "Learn more", - "provider.connect.claudeAgent.install": "Install Claude Code", - "provider.connect.claudeAgent.signIn.command": "Sign-in command", - "provider.connect.claudeAgent.connect": "Connect", - "provider.connect.claudeAgent.checkAgain": "Check again", - "provider.connect.claudeAgent.checking": "Checking Claude Code…", - "provider.connect.claudeAgent.timeout": - "Couldn't confirm that Claude Code is ready. Make sure it's installed and signed in, then check again.", "provider.connect.command.waiting": "Waiting for command to finish…", "provider.connect.command.expired": "Authentication expired", "provider.connect.form.required": "Answer required", diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index b6484445e2f5..ab613683de35 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -31,7 +31,6 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, - { match: (id: string) => id === "claude-agent", key: "dialog.provider.claudeAgent.note" }, { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, diff --git a/packages/app/src/voice/tools.ts b/packages/app/src/voice/tools.ts index 1b9e2de2afd0..75e5c0e06cc6 100644 --- a/packages/app/src/voice/tools.ts +++ b/packages/app/src/voice/tools.ts @@ -1,5 +1,6 @@ import type { SessionInfo, SessionMessageInfo } from "@opencode/client/promise" import { SessionMessage } from "@opencode/schema/session-message" +import { isHiddenProvider } from "@/providers/catalog/order" import type { ServerCtx } from "@/runtime/server/runtime" import { decodeArguments, type ToolCall } from "./protocol" import { ReplyWatch } from "./replies" @@ -242,7 +243,7 @@ export class VoiceTools { const current = session.model const models = matchModels( - catalog.data.filter((model) => model.enabled), + catalog.data.filter((model) => model.enabled && !isHiddenProvider(model.providerID)), args.query, ) @@ -270,7 +271,7 @@ export class VoiceTools { if (!wanted) throw new Error("model required") const session = await api.session.get({ sessionID }, options) const catalog = await api.model.list({ location: { directory: session.location.directory } }, options) - const enabled = catalog.data.filter((item) => item.enabled) + const enabled = catalog.data.filter((item) => item.enabled && !isHiddenProvider(item.providerID)) const model = enabled.find((item) => `${item.providerID}/${item.id}` === wanted) ?? diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index 82b31b581685..35a4ad7322dc 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -77,7 +77,8 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien export function loginChoices(integrations: IntegrationInfo[]): IntegrationChoice[] { return integrations - .filter((integration) => connectMethods(integration).length > 0) + // Shuvcode does not offer the upstream Claude Agent provider. + .filter((integration) => integration.id !== "claude-agent" && connectMethods(integration).length > 0) .toSorted( (a, b) => Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts index 737bffab56bd..df490a2bd654 100644 --- a/packages/cli/src/commands/handlers/auth/shared.ts +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -19,7 +19,10 @@ export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (cl yield* request((signal) => client.model.default({ location }, { signal })) return yield* request((signal) => client.integration.list({ location }, { signal })).pipe( Effect.map((response) => - response.data.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")), + response.data + // Shuvcode does not offer the upstream Claude Agent provider. + .filter((integration) => integration.id !== "claude-agent") + .toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")), ), ) }) diff --git a/packages/cli/test/auth-login-choices.test.ts b/packages/cli/test/auth-login-choices.test.ts index fc1f071861ff..aabeadf4b4d4 100644 --- a/packages/cli/test/auth-login-choices.test.ts +++ b/packages/cli/test/auth-login-choices.test.ts @@ -8,6 +8,15 @@ const integration = (value: Partial & Pick { + expect( + loginChoices([ + integration({ id: "claude-agent", name: "Claude Agent" }), + integration({ id: "anthropic", name: "Anthropic" }), + ]).map((choice) => choice.value), + ).toEqual(["anthropic"]) +}) + test("groups the CLI choices like /connect while keeping stable login IDs", () => { expect( loginChoices([ diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 59b697bb73e8..a6367b7df7b0 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -5,7 +5,6 @@ import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js" import { AzurePlugin } from "./provider/azure.js" import { CerebrasPlugin } from "./provider/cerebras.js" import { ChatGPTPlugin } from "./provider/chatgpt.js" -import { ClaudeAgentPlugin } from "./provider/claude-agent/index.js" import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js" import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js" import { DatabricksPlugin } from "./provider/databricks.js" @@ -34,6 +33,10 @@ import { XAIPlugin } from "./provider/xai.js" import { ZenmuxPlugin } from "./provider/zenmux.js" import type { PluginInternal } from "./internal.js" +// Shuvcode keeps the upstream Claude Agent SDK tree on disk for sync and does +// not load it. Anthropic in the product is ClaudePlugin. Drop a reintroduced +// `opencode.provider.claude-agent` registration so restoring the import is not +// enough to publish the provider. export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AmazonBedrockPlugin, ClaudePlugin, @@ -69,5 +72,4 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ DynamicProviderPlugin, // Reads the active subscription connections registered above; order it last. QuotaPlugin, - ClaudeAgentPlugin, -] +].filter((plugin) => plugin.id !== "opencode.provider.claude-agent") diff --git a/packages/core/src/plugin/provider/claude-agent/index.ts b/packages/core/src/plugin/provider/claude-agent/index.ts index ea217f5e29d0..b2157f228705 100644 --- a/packages/core/src/plugin/provider/claude-agent/index.ts +++ b/packages/core/src/plugin/provider/claude-agent/index.ts @@ -45,4 +45,12 @@ export const make = (io: ClaudeIO.Interface, registry = ClaudeSessions.registry( }), } satisfies PluginInternal.InternalPlugin) -export const ClaudeAgentPlugin = make(ClaudeIO.real) +// Product import. `make` still builds the upstream plugin for the on-disk tests. +// This export does not register a provider, integration, or model. +export const ClaudeAgentPlugin = define({ + id: ID, + effect: Effect.fn("ClaudeAgent.Plugin")(function* () { + // Registered plugins with this id are also filtered out of ProviderPlugins. + yield* Effect.void + }), +}) diff --git a/packages/core/test/plugin/bundled-subscriptions.test.ts b/packages/core/test/plugin/bundled-subscriptions.test.ts index a7ef25cee6d6..d3fcf0fbbc14 100644 --- a/packages/core/test/plugin/bundled-subscriptions.test.ts +++ b/packages/core/test/plugin/bundled-subscriptions.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { ClaudePlugin } from "@shuvcode/claude-plugin" +import { ClaudeAgentPlugin } from "@opencode/core/plugin/provider/claude-agent/index" import { GoogleAntigravityPlugin } from "@shuvcode/antigravity-plugin" import { Integration } from "@opencode/core/integration" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { Provider } from "@opencode/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -23,6 +25,25 @@ describe("bundled subscription providers", () => { } }) + test("keeps Claude Pro/Max and does not register Claude Agent", () => { + expect(ProviderPlugins).toContain(ClaudePlugin) + expect(ProviderPlugins.filter((plugin) => plugin.id === ClaudePlugin.id)).toHaveLength(1) + expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.claude-agent") + expect(ProviderPlugins).not.toContain(ClaudeAgentPlugin) + }) + + it.effect("does not let the product Claude Agent export register a provider", () => + Effect.gen(function* () { + const integration = yield* Integration.Service + const provider = yield* Provider.Service + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* ClaudeAgentPlugin.effect(host) + expect(yield* integration.get(Integration.ID.make("claude-agent"))).toBeUndefined() + expect(yield* provider.get(Provider.ID.claudeAgent)).toBeUndefined() + }), + ) + it.effect("registers both login methods without external plugin configuration", () => Effect.gen(function* () { const integration = yield* Integration.Service diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 17a5143af19b..e6d23b91ed88 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -67,13 +67,16 @@ const SUBMIT = Symbol("submit") const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/ export function integrationOptions(list: IntegrationInfo[]) { - return list.toSorted( - (a, b) => - Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || - (INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) || - a.name.localeCompare(b.name) || - a.id.localeCompare(b.id), - ) + // Shuvcode does not offer the upstream Claude Agent provider. + return list + .filter((item) => item.id !== "claude-agent") + .toSorted( + (a, b) => + Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || + (INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) || + a.name.localeCompare(b.name) || + a.id.localeCompare(b.id), + ) } export function connectMethods(integration: IntegrationInfo): ConnectMethod[] { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ea8f970469b5..1b66872a24f1 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -95,7 +95,10 @@ function DialogConnectedModels(props: { const providers = createMemo( () => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])), ) - const models = createMemo(() => data.location.model.list(location.ref) ?? []) + // Shuvcode does not offer the upstream Claude Agent provider. + const models = createMemo(() => + (data.location.model.list(location.ref) ?? []).filter((model) => model.providerID !== "claude-agent"), + ) const showExtra = createMemo(() => connected() && !props.providerID) diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index 30c35f2f8d92..e8cc3b28803c 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -14,6 +14,15 @@ const integration = (value: Partial & Pick { + test("hides the upstream Claude Agent integration", () => { + expect( + integrationOptions([ + integration({ id: "claude-agent", name: "Claude Agent" }), + integration({ id: "anthropic", name: "Anthropic" }), + ]).map((item) => item.id), + ).toEqual(["anthropic"]) + }) + test("keeps popular integrations first and sorts the rest alphabetically", () => { expect( integrationOptions([ From f59414adcd89e1d4c8b81e682708d881c56386fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 19:54:13 +0000 Subject: [PATCH 2/3] fix: block Claude Agent in config and every catalog list Skip claude-agent when config builds providers, and share isHiddenProvider across app, TUI, and CLI lists so an old server cannot show it. --- AGENTS.md | 14 ++++--- PLAN-fresh-v2-rewrite.md | 3 +- packages/app/src/composer/selection.ts | 3 +- packages/app/src/providers/catalog/order.ts | 21 ++++------ packages/app/src/providers/connect/dialog.tsx | 11 +++-- .../runtime/server/global-sync/child-store.ts | 3 +- packages/cli/src/acp/catalog.ts | 7 +++- .../cli/src/commands/handlers/auth/login.ts | 5 ++- .../cli/src/commands/handlers/auth/shared.ts | 5 ++- packages/cli/src/commands/handlers/models.ts | 2 + packages/core/src/config/plugin/provider.ts | 6 ++- packages/core/test/config/provider.test.ts | 40 +++++++++++++++++++ .../test/plugin/bundled-subscriptions.test.ts | 11 ++++- .../tui/src/component/dialog-integration.tsx | 4 +- .../tui/src/component/dialog-model-free.tsx | 3 +- packages/tui/src/component/dialog-model.tsx | 11 +++-- packages/tui/src/component/usage.tsx | 12 +++++- packages/tui/src/context/local.tsx | 7 +++- packages/tui/src/mini/catalog.shared.ts | 3 ++ packages/tui/src/routes/session/index.tsx | 5 ++- packages/tui/test/mini/catalog.shared.test.ts | 22 ++++++++++ packages/util/src/hidden-provider.test.ts | 8 ++++ packages/util/src/hidden-provider.ts | 6 +++ 23 files changed, 168 insertions(+), 44 deletions(-) create mode 100644 packages/util/src/hidden-provider.test.ts create mode 100644 packages/util/src/hidden-provider.ts diff --git a/AGENTS.md b/AGENTS.md index a6b7df802564..db253168b54e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,16 @@ identity facts when merging or refactoring: (`packages/core/src/plugin/provider/claude-agent/**` and its tests, plus the optional `@anthropic-ai/claude-agent-sdk` peer and `#claude-agent-sdk` alias) so later syncs do not resurrect a deleted tree. It is not a Shuvcode - provider. Do not register `ClaudeAgentPlugin` or `make(...)` in - `ProviderPlugins`, and do not restore connect, catalog, settings, popular, - TUI, or CLI entries for `claude-agent` / "Claude Agent". Anthropic in the + provider. The guarantee is three layers, not the bundled registry alone: + `ProviderPlugins` does not load it, `ConfigProviderPlugin` skips provider + ids `claude-agent` and `opencode.provider.claude-agent`, and every client + list of integrations, providers, or models calls + `isHiddenProvider` from `@opencode/util/hidden-provider`. Do not register + `ClaudeAgentPlugin` or `make(...)`, do not let config recreate the id, and + do not add a list surface that omits the predicate. Anthropic in the product is `@shuvcode/claude-plugin` (`ClaudePlugin`). When merging - `upstream/dev`, drop any reintroduced registration and user-facing surface - for that provider. + `upstream/dev`, drop any reintroduced registration, config path, or + user-facing surface for that provider. ## Live V2 TUI Testing diff --git a/PLAN-fresh-v2-rewrite.md b/PLAN-fresh-v2-rewrite.md index 37367afa71fb..4dcdad430cc8 100644 --- a/PLAN-fresh-v2-rewrite.md +++ b/PLAN-fresh-v2-rewrite.md @@ -56,7 +56,8 @@ still `2.0.3`). Re-fetch again at kickoff and re-run the watchlist checks. (`-` in `plugins`). Do not require a user `plugins` array for the shipped set. Exception: the upstream Claude Agent SDK provider (`packages/core/src/plugin/provider/claude-agent/**`) stays on disk for - sync and stays unregistered. Do not re-enable it. Anthropic remains + sync and stays unregistered. Config must not recreate it, and client + lists must keep filtering it. Do not re-enable it. Anthropic remains `@shuvcode/claude-plugin`. See `AGENTS.md` Shuvcode Fork. 4. **Core may change only for:** identity, default port, XDG app id, one-shot DB convert script, OAuth refresh single-flight, host seams plugins cannot diff --git a/packages/app/src/composer/selection.ts b/packages/app/src/composer/selection.ts index 2013ee5bbf72..db9f403d0d9c 100644 --- a/packages/app/src/composer/selection.ts +++ b/packages/app/src/composer/selection.ts @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import type { ComposerControls } from "./adapter" import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection" import { useWorkspaceLocation } from "@/workspaces/location" +import { omitHiddenProviderCatalog } from "@/providers/catalog/order" import { useProviders } from "@/providers/catalog/providers" import { useData } from "@/runtime/server/current" import { normalizeAgentList, normalizeProviderList } from "@/runtime/server/global-sync/utils" @@ -55,7 +56,7 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) { const catalog = data.location.model.list(location) if (!provider || !catalog) return - const normalized = normalizeProviderList(provider, catalog) + const normalized = omitHiddenProviderCatalog(normalizeProviderList(provider, catalog)) return { all: normalized.all, connected: normalized.connected } }) diff --git a/packages/app/src/providers/catalog/order.ts b/packages/app/src/providers/catalog/order.ts index e7e0c738f39c..3e83a4047852 100644 --- a/packages/app/src/providers/catalog/order.ts +++ b/packages/app/src/providers/catalog/order.ts @@ -1,3 +1,7 @@ +import { isHiddenProvider } from "@opencode/util/hidden-provider" + +export { isHiddenProvider } + export const popularProviders = [ "opencode-go", "opencode", @@ -12,14 +16,8 @@ export const popularProviders = [ // Connect dialog group order. Matches the TUI priority list. export const connectPopular = ["opencode", "opencode-go", "openai", "github-copilot", "anthropic", "google"] -// Upstream Claude Agent stays out of every Shuvcode catalog, even when an -// older server still returns the integration. -const hiddenProviderIDs = new Set(["claude-agent"]) - -export function isHiddenProvider(id: string) { - return hiddenProviderIDs.has(id) -} - +// App catalogs that call these helpers omit the upstream Claude Agent id. +// Other list surfaces call isHiddenProvider themselves. export function withoutHiddenProviders(items: readonly T[]) { return items.filter((item) => !isHiddenProvider(item.id)) } @@ -29,15 +27,12 @@ export function omitHiddenProviderCatalog(catalog: { connected: readonly string[] default: Record }) { - if (![...catalog.all.keys()].some(isHiddenProvider) && catalog.connected.every((id) => !isHiddenProvider(id))) { - return catalog - } - const all = new Map([...catalog.all].filter(([id]) => !isHiddenProvider(id))) + const connected = catalog.connected.filter((id) => !isHiddenProvider(id)) return { all, - connected: catalog.connected.filter((id) => !isHiddenProvider(id)), + connected, default: Object.fromEntries(Object.entries(catalog.default).filter(([id]) => !isHiddenProvider(id))), } } diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 8bd6af2e718a..78ecdf9d54d5 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -31,7 +31,7 @@ import { useData } from "@/runtime/server/current" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" import { useProviders } from "@/providers/catalog/providers" -import { connectPopular } from "@/providers/catalog/order" +import { connectPopular, isHiddenProvider } from "@/providers/catalog/order" import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console" import { useIntegrations } from "@/providers/catalog/integrations" import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group" @@ -608,7 +608,8 @@ function ProviderConnection(props: { const connectionProviders = createMemo(() => (data.location.provider.list(location()) ?? []).filter( - (provider) => provider.id === props.provider || provider.integrationID === integrationID, + (provider) => + !isHiddenProvider(provider.id) && (provider.id === props.provider || provider.integrationID === integrationID), ), ) @@ -616,7 +617,11 @@ function ProviderConnection(props: { const ids = new Set(connectionProviders().map((provider) => provider.id)) return (data.location.model.list(location()) ?? []).filter( - (model) => ids.has(model.providerID) && model.enabled && model.status !== "deprecated", + (model) => + !isHiddenProvider(model.providerID) && + ids.has(model.providerID) && + model.enabled && + model.status !== "deprecated", ) }) diff --git a/packages/app/src/runtime/server/global-sync/child-store.ts b/packages/app/src/runtime/server/global-sync/child-store.ts index b463e342646e..ad1ebb3ec7eb 100644 --- a/packages/app/src/runtime/server/global-sync/child-store.ts +++ b/packages/app/src/runtime/server/global-sync/child-store.ts @@ -17,6 +17,7 @@ import { QueryOptionsApi } from "../sync" import { directoryKey, type DirectoryKey } from "./utils" import type { ServerScope } from "@/runtime/server/scope" import type { Data } from "@opencode/client/solid" +import { omitHiddenProviderCatalog } from "@/providers/catalog/order" import { normalizeAgentList, normalizeProviderList } from "./utils" import { ProjectState, VcsState } from "../persistence" @@ -213,7 +214,7 @@ export function createChildStoreManager(input: { if (!provider || !model) return { all: new Map(), connected: [], default: {} } - return normalizeProviderList(provider, model) + return omitHiddenProviderCatalog(normalizeProviderList(provider, model)) }, config: {}, get path() { diff --git a/packages/cli/src/acp/catalog.ts b/packages/cli/src/acp/catalog.ts index 37eb1ee0e9c5..3ce7759424fa 100644 --- a/packages/cli/src/acp/catalog.ts +++ b/packages/cli/src/acp/catalog.ts @@ -3,6 +3,7 @@ import type { Agent } from "@opencode/schema/agent" import type { Command } from "@opencode/schema/command" import type { Model } from "@opencode/schema/model" import { FSUtil } from "@opencode/util/fs-util" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { Cause, Deferred, Effect, Exit, Schedule, Semaphore, Stream, SubscriptionRef } from "effect" import { ACPError } from "./error" @@ -147,10 +148,12 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) { ], { concurrency: "unbounded" }, ).pipe(Effect.mapError((cause) => new ACPError.CatalogLoadError({ cause }))) - const models = modelResult.data.filter((model) => model.enabled) + const models = modelResult.data.filter((model) => model.enabled && !isHiddenProvider(model.providerID)) const preferred = defaultResult.data // The parallel default read can name a model missing from this list. - const defaultModel = preferred ? findModel(models, preferred) : models[0] + // A hidden Claude Agent default falls through to the first visible model. + const defaultModel = + preferred && !isHiddenProvider(preferred.providerID) ? findModel(models, preferred) : models[0] if (!defaultModel) return yield* new ACPError.CatalogNotReadyError({ reason: "models" }) const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden) // Core lists its resolved default agent first, the same one a new session runs. diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index 35a4ad7322dc..67bf0e4d359f 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -1,6 +1,7 @@ import { intro, log, outro, select, spinner, text } from "@clack/prompts" -import { Effect, Option } from "effect" import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode/client" +import { isHiddenProvider } from "@opencode/util/hidden-provider" +import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker" @@ -78,7 +79,7 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien export function loginChoices(integrations: IntegrationInfo[]): IntegrationChoice[] { return integrations // Shuvcode does not offer the upstream Claude Agent provider. - .filter((integration) => integration.id !== "claude-agent" && connectMethods(integration).length > 0) + .filter((integration) => !isHiddenProvider(integration.id) && connectMethods(integration).length > 0) .toSorted( (a, b) => Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts index df490a2bd654..8706d1913182 100644 --- a/packages/cli/src/commands/handlers/auth/shared.ts +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -1,6 +1,7 @@ -import { Effect } from "effect" import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode/client" import { Service } from "@opencode/client/effect/service" +import { isHiddenProvider } from "@opencode/util/hidden-provider" +import { Effect } from "effect" import { ServerConnection } from "../../../services/server-connection" export const location = { directory: process.cwd() } @@ -21,7 +22,7 @@ export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (cl Effect.map((response) => response.data // Shuvcode does not offer the upstream Claude Agent provider. - .filter((integration) => integration.id !== "claude-agent") + .filter((integration) => !isHiddenProvider(integration.id)) .toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")), ), ) diff --git a/packages/cli/src/commands/handlers/models.ts b/packages/cli/src/commands/handlers/models.ts index f7a66bd7d0d5..9846bea0b01d 100644 --- a/packages/cli/src/commands/handlers/models.ts +++ b/packages/cli/src/commands/handlers/models.ts @@ -1,5 +1,6 @@ import { OpenCode } from "@opencode/client" import { Service } from "@opencode/client/effect/service" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { Effect } from "effect" import { EOL } from "node:os" import { Commands } from "../commands" @@ -16,6 +17,7 @@ export default Runtime.handler( }) const response = yield* Effect.promise(() => client.model.list({ location: { directory: process.cwd() } })) const models = response.data + .filter((model) => !isHiddenProvider(model.providerID)) .map((model) => `${model.providerID}/${model.id}`) .toSorted((a, b) => a.localeCompare(b)) if (models.length > 0) process.stdout.write(models.join(EOL) + EOL) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index e9d2d6d51a16..a6f818bdf30a 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -6,6 +6,7 @@ import { define } from "@opencode/plugin/effect/plugin" import { Document, type Entry } from "@opencode/schema/config" import { ConfigProvider } from "@opencode/schema/config/provider" import { Money } from "@opencode/schema/money" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { Effect } from "effect" import { Config } from "../../config.js" import { Model } from "../../model.js" @@ -109,7 +110,8 @@ export const Plugin = define({ // model transforms. They can recreate or re-enable a model within an available provider. yield* ctx.model.transform((models) => { const configuredDefault = sources.defaultModel - if (configuredDefault !== undefined) models.default.set(configuredDefault.providerID, configuredDefault.model) + if (configuredDefault !== undefined && !isHiddenProvider(configuredDefault.providerID)) + models.default.set(configuredDefault.providerID, configuredDefault.model) for (const [item, definition] of sources.models) { const providerID = definition.providerID for (const [id, config] of Object.entries(item.models ?? {})) { @@ -159,6 +161,7 @@ function configuredProviders(entries: readonly Entry[]) { return entries .filter((entry): entry is Document => entry.type === "document" && !(entry instanceof ConfigIntegration.Document)) .flatMap((file) => Object.entries(file.info.providers ?? {})) + .filter(([id]) => !isHiddenProvider(id)) } /** @@ -168,6 +171,7 @@ function configuredProviders(entries: readonly Entry[]) { */ export function integrate(providers: ProviderEditor, document: ConfigIntegration.Document) { for (const [providerID, item] of Object.entries(document.info.providers ?? {})) { + if (isHiddenProvider(providerID)) continue const { source } = overlayProvider(providers, providerID, item, document.integrationID) // An integration lists the models it routes: each starts again from the catalog and takes the provider's // package and URL. Its models are complete here, so later model transforms and config apply on top. diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index ad4f1ae32f71..e008cb32055c 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -343,6 +343,46 @@ describe("ConfigProviderPlugin.Plugin", () => { ) } + it.effect("does not create Claude Agent from a configured provider id", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const providers = yield* Provider.Service + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + "claude-agent": { + name: "Claude Agent", + package: "aisdk:@ai-sdk/openai-compatible", + models: { chat: {} }, + }, + "opencode.provider.claude-agent": { + package: "aisdk:@ai-sdk/openai-compatible", + models: { chat: {} }, + }, + litellm: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { chat: {} }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries) + + expect(yield* integrations.get(Integration.ID.make("claude-agent"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("opencode.provider.claude-agent"))).toBeUndefined() + expect(yield* providers.get(Provider.ID.claudeAgent)).toBeUndefined() + expect(yield* providers.get(Provider.ID.make("opencode.provider.claude-agent"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({ + id: "litellm", + methods: [{ type: "key", label: "Manually enter API Key" }], + }) + }), + ) + it.effect("adds key auth for custom providers without env credentials", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/bundled-subscriptions.test.ts b/packages/core/test/plugin/bundled-subscriptions.test.ts index d3fcf0fbbc14..43b9958bb05e 100644 --- a/packages/core/test/plugin/bundled-subscriptions.test.ts +++ b/packages/core/test/plugin/bundled-subscriptions.test.ts @@ -32,15 +32,22 @@ describe("bundled subscription providers", () => { expect(ProviderPlugins).not.toContain(ClaudeAgentPlugin) }) - it.effect("does not let the product Claude Agent export register a provider", () => + it.effect("loads bundled providers without registering Claude Agent", () => Effect.gen(function* () { const integration = yield* Integration.Service const provider = yield* Provider.Service const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) - yield* ClaudeAgentPlugin.effect(host) + for (const entry of ProviderPlugins) { + // Bundled plugins ask for more services than this test's effect type lists. + // The fixture layer provides them; the cast only widens the checker. + yield* entry.effect(host) as Effect.Effect + } expect(yield* integration.get(Integration.ID.make("claude-agent"))).toBeUndefined() expect(yield* provider.get(Provider.ID.claudeAgent)).toBeUndefined() + expect((yield* integration.get(Integration.ID.make("anthropic")))?.methods).toContainEqual( + expect.objectContaining({ id: "claude-pro-max", type: "oauth" }), + ) }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index e6d23b91ed88..73539a0e8b53 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -12,6 +12,7 @@ import type { FormValue, LocationRef, } from "@opencode/client" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { openUrl } from "@opencode/util/open" import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" @@ -67,9 +68,8 @@ const SUBMIT = Symbol("submit") const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/ export function integrationOptions(list: IntegrationInfo[]) { - // Shuvcode does not offer the upstream Claude Agent provider. return list - .filter((item) => item.id !== "claude-agent") + .filter((item) => !isHiddenProvider(item.id)) .toSorted( (a, b) => Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || diff --git a/packages/tui/src/component/dialog-model-free.tsx b/packages/tui/src/component/dialog-model-free.tsx index b419753138c3..f9b56e251663 100644 --- a/packages/tui/src/component/dialog-model-free.tsx +++ b/packages/tui/src/component/dialog-model-free.tsx @@ -1,3 +1,4 @@ +import { isHiddenProvider } from "@opencode/util/hidden-provider" import * as fuzzysort from "fuzzysort" import { createMemo, createSignal } from "solid-js" import { useData } from "../context/data" @@ -25,7 +26,7 @@ export function DialogFreeModels(props: { const options = createMemo(() => { const all = (data.location.model.list(location.ref) ?? []) - .filter((model) => model.status !== "deprecated" && isFreeModel(model)) + .filter((model) => model.status !== "deprecated" && isFreeModel(model) && !isHiddenProvider(model.providerID)) .map((model) => { const value = { providerID: model.providerID, modelID: model.id } return { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 1b66872a24f1..21ff3abcb4fe 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,4 +1,5 @@ import type { ModelInfo } from "@opencode/client" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { createMemo, createSignal, onMount, Show } from "solid-js" import { useLocal } from "../context/local" import { DialogSelect } from "../ui/dialog-select" @@ -93,11 +94,15 @@ function DialogConnectedModels(props: { const connected = useConnected() const providers = createMemo( - () => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])), + () => + new Map( + (data.location.provider.list(location.ref) ?? []) + .filter((item) => !isHiddenProvider(item.id)) + .map((item) => [item.id, item]), + ), ) - // Shuvcode does not offer the upstream Claude Agent provider. const models = createMemo(() => - (data.location.model.list(location.ref) ?? []).filter((model) => model.providerID !== "claude-agent"), + (data.location.model.list(location.ref) ?? []).filter((model) => !isHiddenProvider(model.providerID)), ) const showExtra = createMemo(() => connected() && !props.providerID) diff --git a/packages/tui/src/component/usage.tsx b/packages/tui/src/component/usage.tsx index 63b3174dbcf9..52d014f6a1a2 100644 --- a/packages/tui/src/component/usage.tsx +++ b/packages/tui/src/component/usage.tsx @@ -1,4 +1,5 @@ import type { ModelInfo, SessionMessageAssistant } from "@opencode/client" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { TextAttributes } from "@opentui/core" import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useData } from "../context/data" @@ -33,7 +34,14 @@ export function useUsageLimits() { const data = useData() const location = useLocation() const ref = () => location.ref ?? data.location.default() - const providers = createMemo(() => new Map((data.location.provider.list(ref()) ?? []).map((item) => [item.id, item]))) + const providers = createMemo( + () => + new Map( + (data.location.provider.list(ref()) ?? []) + .filter((item) => !isHiddenProvider(item.id)) + .map((item) => [item.id, item]), + ), + ) const info = () => data.location.usage.limits(ref()) const usage = (model: ModelInfo) => { const value = info() @@ -46,7 +54,7 @@ export function useUsageLimits() { if (refresh) data.location.usage.invalidate(ref()) void data.location.usage.sync(ref()).catch(() => undefined) }, - models: () => data.location.model.list(ref()) ?? [], + models: () => (data.location.model.list(ref()) ?? []).filter((model) => !isHiddenProvider(model.providerID)), usage, served: (providerID: string) => servedByConsole(providers().get(providerID)), /** A paid model OpenCode Console serves without any limit on it. */ diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index d3304eb3390b..9a720215a835 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -25,6 +25,7 @@ import { useRoute } from "./route" import { useData } from "./data" import { usePermission } from "./permission" import { useLocation } from "./location" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import { parse } from "../util/model" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ @@ -41,8 +42,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const permission = usePermission() const location = useLocation() - const models = () => data.location.model.list(location.ref) - const providers = () => data.location.provider.list(location.ref) + const models = () => + data.location.model.list(location.ref)?.filter((model) => !isHiddenProvider(model.providerID)) + const providers = () => + data.location.provider.list(location.ref)?.filter((provider) => !isHiddenProvider(provider.id)) function isModelValid(model: ModelPreferenceModel) { return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index d825c9869c00..1a2817996cb2 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -6,6 +6,7 @@ import type { OpenCodeClient, ProviderListOutput, } from "@opencode/client/promise" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" type CurrentAgent = AgentListOutput["data"][number] @@ -48,6 +49,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ const grouped = new Map() for (const provider of providers) { + if (isHiddenProvider(provider.id)) continue grouped.set(provider.id, { id: provider.id, name: provider.name, @@ -56,6 +58,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ } for (const model of models) { + if (isHiddenProvider(model.providerID)) continue const provider = grouped.get(model.providerID) ?? { id: model.providerID, name: model.providerID, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 70116df4341f..df5ea08851dc 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -12,6 +12,7 @@ import { Switch, type Accessor, } from "solid-js" +import { isHiddenProvider } from "@opencode/util/hidden-provider" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -262,7 +263,9 @@ export function Session(props: { commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: props.onToggleSidebar }], })) const contentWidth = createMemo(() => (props.width ?? dimensions().width - props.verticalTabsWidth) - 4) - const models = createMemo(() => data.location.model.list(location()) ?? []) + const models = createMemo( + () => (data.location.model.list(location()) ?? []).filter((model) => !isHiddenProvider(model.providerID)), + ) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const toast = useToast() diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 3d255781c353..4e0b2c712930 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/packages/tui/test/mini/catalog.shared.test.ts @@ -73,4 +73,26 @@ describe("run catalog shared", () => { }, ]) }) + + test("drops the upstream Claude Agent provider from the footer catalog", () => { + expect( + runProviders( + [catalogProvider("claude-agent", "Claude Agent"), catalogProvider("anthropic", "Anthropic")], + [ + catalogModel({ + id: "sonnet", + modelID: "sonnet", + providerID: "claude-agent", + name: "Claude Agent Sonnet", + }), + catalogModel({ + id: "claude-sonnet", + modelID: "claude-sonnet", + providerID: "anthropic", + name: "Claude Sonnet", + }), + ], + ).map((provider) => provider.id), + ).toEqual(["anthropic"]) + }) }) diff --git a/packages/util/src/hidden-provider.test.ts b/packages/util/src/hidden-provider.test.ts new file mode 100644 index 000000000000..4be2f933161c --- /dev/null +++ b/packages/util/src/hidden-provider.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { isHiddenProvider } from "./hidden-provider.js" + +test("hides the upstream Claude Agent ids and keeps Anthropic", () => { + expect(isHiddenProvider("claude-agent")).toBe(true) + expect(isHiddenProvider("opencode.provider.claude-agent")).toBe(true) + expect(isHiddenProvider("anthropic")).toBe(false) +}) diff --git a/packages/util/src/hidden-provider.ts b/packages/util/src/hidden-provider.ts new file mode 100644 index 000000000000..51c483af8296 --- /dev/null +++ b/packages/util/src/hidden-provider.ts @@ -0,0 +1,6 @@ +// Shuvcode keeps the upstream Claude Agent SDK on disk and does not offer it. +const hiddenProviderIDs = new Set(["claude-agent", "opencode.provider.claude-agent"]) + +export function isHiddenProvider(id: string) { + return hiddenProviderIDs.has(id) +} From 9d90755c7cc284df95709583dd3d2eeb2882f165 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 19:54:24 +0000 Subject: [PATCH 3/3] test(core): justify the bundled plugin effect cast --- packages/core/test/plugin/bundled-subscriptions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/plugin/bundled-subscriptions.test.ts b/packages/core/test/plugin/bundled-subscriptions.test.ts index 43b9958bb05e..1637c100ff56 100644 --- a/packages/core/test/plugin/bundled-subscriptions.test.ts +++ b/packages/core/test/plugin/bundled-subscriptions.test.ts @@ -39,8 +39,8 @@ describe("bundled subscription providers", () => { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) for (const entry of ProviderPlugins) { - // Bundled plugins ask for more services than this test's effect type lists. - // The fixture layer provides them; the cast only widens the checker. + // SAFETY: PluginTestLayer provides the services these plugins read. The assertion + // only hides requirements this test effect's type does not list. yield* entry.effect(host) as Effect.Effect } expect(yield* integration.get(Integration.ID.make("claude-agent"))).toBeUndefined()