diff --git a/BUGS.md b/BUGS.md index f25a0a5fa..f34703614 100644 --- a/BUGS.md +++ b/BUGS.md @@ -16,7 +16,7 @@ If a test, typecheck, lint, or runtime failure appears during upstream backports ## Open Test Failures -No confirmed open test failures. A sandboxed full-suite run failed on 2026-06-06 with socket, watcher, dependency-resolution, and MCP OAuth browser symptoms, but the user-approved unsandboxed rerun passed with `1557 pass`, `8 skip`, `0 fail`. +No confirmed open test failures. A sandboxed full-suite run failed on 2026-06-06 with socket, watcher, dependency-resolution, and MCP OAuth browser symptoms, but the latest user-approved unsandboxed rerun passed with `1565 pass`, `8 skip`, `0 fail`. ## Open Edge Cases @@ -47,7 +47,8 @@ The upstream shell truncation stream cleanup `e26abd8da` was skipped because Fra ## Fixed Summary - PR 1 June upstream sync: prompt tool enables already present, `context_length_exceeded` overflow parsing already present, compaction transforms already present, LiteLLM `_noop` discouragement, subagent `todowrite` permissions, Bun `ZlibError` retryability, configured `model.limit.input`, `Tool.define()` wrapper mutation, read permission relative paths, and Plan Mode subagent deny inheritance. -- PR 2 June upstream sync: TypeScript LSP native `tsserver` args, MCP cleanup on failed connection/tool listing/refresh, MCP tolerant tool listing for invalid `outputSchema`, and webfetch timeout cleanup already present. +- PR 2 June upstream sync, merged as #39: TypeScript LSP native `tsserver` args, MCP cleanup on failed connection/tool listing/refresh, MCP tolerant tool listing for invalid `outputSchema`, and webfetch timeout cleanup already present. +- Phase 3 CLI/plugin sync: non-interactive `mcp add`, searchable/provider-name `auth logout`, and plugin `dispose` hook. - Security fixed: S1 symlink containment bypass, S2 command injection in GitHub open flow, S4 unauthenticated non-loopback server, S5 sensitive `.env` read exposure. - QA fixed: B53-B64, including CAS transaction/reference safety, edit graph transactions, synthetic ID collisions, plugin trigger errors, objective prompt escaping, MCP add return shape, text timing preservation, ripgrep JSON parse handling, and untracked line counts. - Earlier fixed bugs: PRs #10-#33 in git history. diff --git a/DO_NEXT.md b/DO_NEXT.md index eb0f0d760..9cfe92a47 100644 --- a/DO_NEXT.md +++ b/DO_NEXT.md @@ -1,30 +1,31 @@ # Frankencode Do Next -Use this file as the first handoff target in fresh sessions. It described only the next actionable work after the PR 2 reliability slice. +Use this file as the first handoff target in fresh sessions. It described only the next actionable work after the Phase 3 CLI/plugin slice. ## Immediate Task -Start Phase 3 from `PLAN.md`: evaluate small feature candidates that had direct CLI, MCP, provider, plugin, or TUI value and did not depend on upstream's V2 runtime or package split. +Continue Phase 3 from `PLAN.md` with one contained provider or TUI slice. The best next provider candidate was: -Best first candidates: - -- `ba57718b0` / #31054: non-interactive `mcp add`. -- `3f0ef9b71` / #31053: search in auth logout command. -- `519d34447` / #29493: plugin dispose hook. - `f965db9e1` / #29484: provider `headerTimeout` config. +Keep these dedicated work items separate unless the chosen PR explicitly focused on session or compaction behavior: + +- `e76cf967e` / #27254: interrupted assistant finalization. +- `ca28dd02e` / #27145: compaction tail restoration. + ## Current State - PR #37 merged at `e6c148f54`. - PR #38 merged at `9d8296e32` and established bundled continuity-doc updates. -- PR 2 reliability work ran on `fix/upstream-reliability-batch-2`. -- PR 2 ported TypeScript LSP native `tsserver` args and MCP cleanup/schema tolerance. -- PR 2 confirmed webfetch timeout cleanup was already present. -- PR 2 skipped upstream shell truncation cleanup as not applicable to Frankencode's current architecture. -- PR 2 deferred interrupted assistant finalization and compaction tail restoration for dedicated session/compaction work. +- PR #39 merged at `d10c548a7` and ported TypeScript LSP native `tsserver` args plus MCP cleanup/schema tolerance. +- PR #39 confirmed webfetch timeout cleanup was already present. +- PR #39 skipped upstream shell truncation cleanup as not applicable to Frankencode's current architecture. +- PR #39 deferred interrupted assistant finalization and compaction tail restoration for dedicated session/compaction work. +- The Phase 3 CLI/plugin slice ported non-interactive `mcp add`, searchable/provider-name `auth logout`, and plugin `dispose`. - `cd packages/opencode && bun typecheck` passed on 2026-06-06. -- `cd packages/opencode && bun test test/lsp/server.test.ts test/mcp/lifecycle.test.ts test/tool/webfetch.test.ts` passed on 2026-06-06 with `6 pass`, `0 fail`. -- User-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed on 2026-06-06 with `1557 pass`, `8 skip`, `0 fail`. +- `cd packages/plugin && bun typecheck` passed on 2026-06-06. +- `cd packages/opencode && bun test test/cli/mcp-add.test.ts test/cli/plugin-auth-picker.test.ts test/plugin/dispose.test.ts` passed on 2026-06-06 with `18 pass`, `0 fail`. +- User-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed on 2026-06-06 with `1565 pass`, `8 skip`, `0 fail`. ## Next Commands diff --git a/GAP_ANALYSIS.md b/GAP_ANALYSIS.md index d0b64a72b..7fc4c13f6 100644 --- a/GAP_ANALYSIS.md +++ b/GAP_ANALYSIS.md @@ -7,6 +7,7 @@ | Gap | Priority | Status | Next action | | ---------------------------------- | -------- | ------------------------------------ | ----------------------------------------------------------------------------------------- | | S3 workspace trust prompt | Medium | Warning mitigation remained in place | Design a workspace trust prompt before loading local MCP/plugin config | +| Provider `headerTimeout` config | Medium | Phase 3 candidate remained | Port upstream #29484 in a provider-focused PR with retry/error coverage | | Interrupted assistant finalization | Medium | Deferred from PR 2 | Port upstream #27254 only with focused session interruption coverage | | Compaction tail restoration | Medium | Deferred from PR 2 | Port upstream #27145 only after mapping Frankencode's current compaction flow | | TUI edit indicators | Low | Backlog | Add visual indicators for hidden/replaced/annotated parts when touching TUI context views | @@ -23,6 +24,7 @@ | March Phase 6 | Effect behavioral analysis | Done; 0 reimplementations needed | | June PR 1 | Low-risk upstream bugfix backports | Done and merged as #37 | | June PR 2 | Reliability fixes with more coupling | Done; portable LSP/MCP fixes landed and divergent session/compaction items were deferred | +| June PR 3 | Small CLI/plugin feature backports | Done; non-interactive `mcp add`, auth logout search, and plugin disposal were ported | ## Permanently Out Of Scope diff --git a/PLAN.md b/PLAN.md index c9bb0a726..9efa20eb3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,15 +6,15 @@ Frankencode is a fork of OpenCode that adds context editing, content-addressable Resync with upstream `anomalyco/opencode` by porting selected fixes and features from `upstream/dev` into Frankencode. -**Snapshot:** 2026-06-06 after PR #38 merged +**Snapshot:** 2026-06-06 after PR #39 merged -| Item | State | -| --------------------------- | -------------------------------------------- | -| Frankencode branch | `dev` at `9d8296e32` before PR 2 branch work | -| Upstream branch | `upstream/dev` at `4519a1da3` | -| Divergence | `34 ahead / 3613 behind` | -| Upstream package version | `packages/opencode` `1.16.2` | -| Frankencode package version | `packages/opencode` `1.2.27` | +| Item | State | +| --------------------------- | ---------------------------------------- | +| Frankencode branch | `dev` at `d10c548a7` before Phase 3 work | +| Upstream branch | `upstream/dev` at `4519a1da3` | +| Divergence | `34 ahead / 3613 behind` | +| Upstream package version | `packages/opencode` `1.16.2` | +| Frankencode package version | `packages/opencode` `1.2.27` | ## Strategy @@ -79,23 +79,41 @@ Exit criteria: - `cd packages/opencode && bun test test/lsp/server.test.ts test/mcp/lifecycle.test.ts test/tool/webfetch.test.ts` passed on 2026-06-06. - User-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed on 2026-06-06 with `1557 pass`, `8 skip`, `0 fail`. -## Active Phase: Phase 3, Feature Candidates +PR 2 merged as #39 on 2026-06-06. + +## Completed Phase: PR 3, Small CLI And Plugin Features + +PR 3 ported the Phase 3 candidates that were useful and contained without touching upstream's V2 runtime, package split, session, or compaction architecture. + +| SHA | Upstream PR | Area | Feature | Status | +| ----------- | ----------- | -------- | ----------------------------- | ------------------------------------------------------------------------ | +| `ba57718b0` | #31054 | CLI/MCP | Non-interactive `mcp add` | Ported with argument/config builder coverage | +| `3f0ef9b71` | #31053 | CLI/Auth | Search in auth logout command | Ported with provider id/name resolution coverage | +| `519d34447` | #29493 | Plugin | Plugin dispose hook | Ported with real local plugin disposal coverage through instance dispose | + +Exit criteria: + +- The selected CLI/plugin features were ported manually into Frankencode's current command and plugin architecture. +- Upstream help snapshot changes were skipped because Frankencode did not have the upstream help snapshot suite. +- `cd packages/opencode && bun typecheck` passed on 2026-06-06. +- `cd packages/plugin && bun typecheck` passed on 2026-06-06. +- `cd packages/opencode && bun test test/cli/mcp-add.test.ts test/cli/plugin-auth-picker.test.ts test/plugin/dispose.test.ts` passed on 2026-06-06 with `18 pass`, `0 fail`. +- User-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed on 2026-06-06 with `1565 pass`, `8 skip`, `0 fail`. + +## Active Phase: Phase 3, Remaining Feature Candidates Evaluate only after the bugfix phases. Prefer features with direct CLI/provider/plugin value and low architectural coupling. -| SHA | Upstream PR | Area | Feature | Notes | -| ----------------------- | ---------------------- | --------------- | ---------------------------------------- | --------------------------- | -| `ba57718b0` | #31054 | CLI/MCP | Non-interactive `mcp add` | Likely useful and contained | -| `3f0ef9b71` | #31053 | CLI/Auth | Search in auth logout command | Small UX improvement | -| `519d34447` | #29493 | Plugin | Plugin dispose hook | Useful for cleanup | -| `f965db9e1` | #29484 | Provider | `headerTimeout` config | Reliability feature | -| `2859ce6e7` | #29901 | Provider | Snowflake Cortex provider | Provider expansion | -| `d34a0194e` | #27394 | Provider | NVIDIA endpoints origin header | Small provider correctness | -| `159964b17` | #26095 | Provider/plugin | DigitalOcean OAuth and inference routers | Medium size | -| `0de5f1ff3` | #28255 | TUI | Configurable prompt size | Small TUI UX | -| `bba76009a` | #29710 | TUI | Wide-character paste safety | Bugfix-grade TUI item | -| `5fb85a6aa` | #28664 | TUI | Wrapped inline tool row layout | Bugfix-grade TUI item | -| `17d66ee4f` + followups | #28476, #28728, #30935 | TUI | Diff viewer and hunk navigation | Larger feature set | +| SHA | Upstream PR | Area | Feature | Notes | +| ----------------------- | ---------------------- | --------------- | ---------------------------------------- | -------------------------- | +| `f965db9e1` | #29484 | Provider | `headerTimeout` config | Reliability feature | +| `2859ce6e7` | #29901 | Provider | Snowflake Cortex provider | Provider expansion | +| `d34a0194e` | #27394 | Provider | NVIDIA endpoints origin header | Small provider correctness | +| `159964b17` | #26095 | Provider/plugin | DigitalOcean OAuth and inference routers | Medium size | +| `0de5f1ff3` | #28255 | TUI | Configurable prompt size | Small TUI UX | +| `bba76009a` | #29710 | TUI | Wide-character paste safety | Bugfix-grade TUI item | +| `5fb85a6aa` | #28664 | TUI | Wrapped inline tool row layout | Bugfix-grade TUI item | +| `17d66ee4f` + followups | #28476, #28728, #30935 | TUI | Diff viewer and hunk navigation | Larger feature set | ## Deferred Architecture Work diff --git a/STATUS.md b/STATUS.md index 3d8579608..3978b8215 100644 --- a/STATUS.md +++ b/STATUS.md @@ -4,25 +4,26 @@ ## Current State -Frankencode completed the PR 2 reliability slice of the June upstream maintenance plan. The old March roadmap stayed complete and was no longer the active plan. - -| Item | Value | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Implementation branch | `fix/upstream-reliability-batch-2` | -| Base branch | `dev` | -| Default branch | `dev` | -| Current upstream target | `upstream/dev` | -| Upstream commit reviewed | `4519a1da3` | -| Divergence after fetch | `34 ahead / 3613 behind` | -| Latest merged continuity baseline | #38 at `9d8296e32` | -| Last full verified baseline | 2026-03-22: 1512 pass, 0 fail, 8 skip, 0 tsgo errors | -| Current typecheck | 2026-06-06: `cd packages/opencode && bun typecheck` passed | -| Current focused tests | 2026-06-06: `cd packages/opencode && bun test test/lsp/server.test.ts test/mcp/lifecycle.test.ts test/tool/webfetch.test.ts` passed with `6 pass`, `0 fail` | -| Current full package tests | 2026-06-06: approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed with `1557 pass`, `8 skip`, `0 fail` | +Frankencode completed PR #39 and then ported the first Phase 3 CLI/plugin feature slice. The old March roadmap stayed complete and was no longer the active plan. + +| Item | Value | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Implementation branch | `feat/upstream-small-cli-features` | +| Base branch | `dev` | +| Default branch | `dev` | +| Current upstream target | `upstream/dev` | +| Upstream commit reviewed | `4519a1da3` | +| Divergence after fetch | `34 ahead / 3613 behind` | +| Latest merged upstream-sync PR | #39 at `d10c548a7` | +| Last full verified baseline | 2026-03-22: 1512 pass, 0 fail, 8 skip, 0 tsgo errors | +| Current typecheck | 2026-06-06: `cd packages/opencode && bun typecheck` passed | +| Current plugin typecheck | 2026-06-06: `cd packages/plugin && bun typecheck` passed | +| Current focused tests | 2026-06-06: `cd packages/opencode && bun test test/cli/mcp-add.test.ts test/cli/plugin-auth-picker.test.ts test/plugin/dispose.test.ts` passed with `18 pass`, `0 fail` | +| Current full package tests | 2026-06-06: approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed with `1565 pass`, `8 skip`, `0 fail` | ## Active Work -Start Phase 3 from `PLAN.md` after the PR 2 implementation merged. The best next candidates were small CLI/MCP/auth/provider/plugin features that did not require upstream's package split or V2 runtime. +Continue Phase 3 from `PLAN.md` after the small CLI/plugin feature slice merged. The best next contained candidate was provider `headerTimeout` config; the deferred session and compaction fixes from PR #39 remained separate dedicated tasks. ## Fresh Session Checklist @@ -48,8 +49,9 @@ Run these before implementation work: ## Validation Notes - `bun test test/session/retry.test.ts` and the full package suite require local server binds. In the sandbox they failed with `EADDRINUSE`; rerunning outside the sandbox passed. -- Full package test count increased from the March baseline due existing repository changes plus PR 1 and PR 2 tests; current verified result is `1557 pass`, `8 skip`, `0 fail`. +- Full package test count increased from the March baseline due existing repository changes plus upstream-sync tests; current verified result is `1565 pass`, `8 skip`, `0 fail`. - PR #37 merged on 2026-06-06. - PR #38 merged on 2026-06-06 and established the rule that continuity docs were updated in implementation PRs, not separate docs-only PRs. -- PR 2 focused validation passed on 2026-06-06 for TypeScript LSP args, MCP schema tolerance, and existing webfetch behavior. -- PR 2 full-suite validation passed on 2026-06-06 after user-approved unsandboxed execution. +- PR #39 merged on 2026-06-06 and landed the PR 2 TypeScript LSP and MCP reliability slice. +- The Phase 3 CLI/plugin slice passed focused validation on 2026-06-06 for non-interactive `mcp add`, auth logout provider matching, and plugin disposal. +- The Phase 3 CLI/plugin slice full-suite validation passed on 2026-06-06 after user-approved unsandboxed execution. diff --git a/WHAT_WE_DID.md b/WHAT_WE_DID.md index f2085caeb..25cf37da7 100644 --- a/WHAT_WE_DID.md +++ b/WHAT_WE_DID.md @@ -36,6 +36,17 @@ Compressed continuity log. Use git history and PRs for full details. - Verified `cd packages/opencode && bun typecheck` passed. - Verified `cd packages/opencode && bun test test/lsp/server.test.ts test/mcp/lifecycle.test.ts test/tool/webfetch.test.ts` passed with `6 pass`, `0 fail`. - Verified user-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed with `1557 pass`, `8 skip`, `0 fail`. +- PR #39 merged on 2026-06-06 at `d10c548a7`. +- Began the Phase 3 small CLI/plugin slice on branch `feat/upstream-small-cli-features`. +- Ported upstream non-interactive `mcp add` by adding a positional server name, `--url`, `--header`, `--env`, and `--` command handling while preserving interactive mode with no arguments. +- Ported upstream auth logout search behavior by changing interactive logout selection to autocomplete and allowing a provider id or provider name argument. +- Ported upstream plugin `dispose` by adding the hook to the plugin package type and running it through Frankencode's instance disposal registry. +- Left provider `headerTimeout` for a provider-specific PR because it touched broader provider/session retry behavior. +- Kept interrupted assistant finalization and compaction tail restoration deferred for dedicated session/compaction work, as recorded in `PLAN.md`, `BUGS.md`, and `DO_NEXT.md`. +- Verified `cd packages/opencode && bun typecheck` passed. +- Verified `cd packages/plugin && bun typecheck` passed. +- Verified `cd packages/opencode && bun test test/cli/mcp-add.test.ts test/cli/plugin-auth-picker.test.ts test/plugin/dispose.test.ts` passed with `18 pass`, `0 fail`. +- Verified user-approved unsandboxed `cd packages/opencode && bun test --timeout 30000` passed with `1565 pass`, `8 skip`, `0 fail`. ## Completed Baseline Through 2026-03-22 diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 1eca70dac..4f24ca8b0 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -412,12 +412,98 @@ async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: s return configPath } +type McpAddArgs = { + name?: string + url?: string + env?: string[] + header?: string[] + "--"?: string[] +} + +function pairs(values: string[] | undefined, kind: string) { + return Object.fromEntries( + (values ?? []).map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`) + return [entry.slice(0, index), entry.slice(index + 1)] + }), + ) +} + +export function resolveMcpAdd(args: McpAddArgs): { name: string; config: Config.Mcp } | undefined { + const command = args["--"] ?? [] + if (!args.name) { + if (args.url || args.env?.length || args.header?.length || command.length) { + throw new Error("A server name is required for non-interactive MCP configuration") + } + return undefined + } + + if (!!args.url === !!command.length) { + throw new Error("Provide either --url or a command after --") + } + if (args.url && !URL.canParse(args.url)) { + throw new Error(`Invalid URL: ${args.url}`) + } + if (args.url && args.env?.length) { + throw new Error("--env is only valid for local MCP servers") + } + if (command.length && args.header?.length) { + throw new Error("--header is only valid for remote MCP servers") + } + + const environment = pairs(args.env, "environment variable") + const headers = pairs(args.header, "HTTP header") + return { + name: args.name, + config: args.url + ? { + type: "remote", + url: args.url, + ...(Object.keys(headers).length ? { headers } : {}), + } + : { + type: "local", + command, + ...(Object.keys(environment).length ? { environment } : {}), + }, + } +} + export const McpAddCommand = cmd({ - command: "add", + command: "add [name]", describe: "add an MCP server", - async handler() { + builder: (yargs) => + yargs + .positional("name", { + describe: "name of the MCP server", + type: "string", + }) + .option("url", { + describe: "URL for a remote MCP server", + type: "string", + }) + .option("env", { + describe: "environment variable for a local MCP server (KEY=VALUE)", + type: "string", + array: true, + }) + .option("header", { + describe: "HTTP header for a remote MCP server (KEY=VALUE)", + type: "string", + array: true, + }), + async handler(args) { const ctx = await InstanceLifecycle.boot(process.cwd()) return InstanceALS.run(ctx, async () => { + const resolved = resolveMcpAdd(args) + if (resolved) { + const configPath = await resolveConfigPath(Global.Path.config, true) + await addMcpToConfig(resolved.name, resolved.config, configPath) + prompts.log.success(`MCP server "${resolved.name}" added to ${configPath}`) + return + } + UI.empty() prompts.intro("Add MCP server") diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 164feb401..bee0b6fda 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -193,6 +193,16 @@ export function resolvePluginProviders(input: { return result } +export function resolveLogoutProvider(input: { + provider: string + credentials: string[] + names: Record +}) { + return input.credentials.find( + (id) => id === input.provider || input.names[id]?.toLowerCase() === input.provider.toLowerCase(), + ) +} + export const ProvidersCommand = cmd({ command: "providers", aliases: ["auth"], @@ -455,9 +465,14 @@ export const ProvidersLoginCommand = cmd({ }) export const ProvidersLogoutCommand = cmd({ - command: "logout", + command: "logout [provider]", describe: "log out from a configured provider", - async handler(_args) { + builder: (yargs) => + yargs.positional("provider", { + describe: "provider id or name to log out from", + type: "string", + }), + async handler(args) { UI.empty() const credentials = await Auth.all().then((x) => Object.entries(x)) prompts.intro("Remove credential") @@ -466,15 +481,27 @@ export const ProvidersLogoutCommand = cmd({ return } const database = await ModelsDev.get() - const providerID = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), - }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + const options = credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })) + const provider = args.provider + ? resolveLogoutProvider({ + provider: args.provider, + credentials: options.map((option) => option.value), + names: Object.fromEntries(options.map((option) => [option.value, database[option.value]?.name])), + }) + : await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options, + }) + if (!provider) { + prompts.log.error(`Unknown configured provider "${args.provider}"`) + return + } + if (prompts.isCancel(provider)) throw new UI.CancelledError() + await Auth.remove(provider) prompts.outro("Logout successful") }, }) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e00e9f42c..1e5fbe080 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -15,9 +15,6 @@ import { CopilotAuthPlugin } from "./copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" export const pluginStates = new Map>() -registerDisposer(async (directory) => { - pluginStates.delete(directory) -}) export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -169,4 +166,28 @@ export namespace Plugin { } }, directory) } + + export async function shutdown(directory: string) { + const state = pluginStates.get(directory) + pluginStates.delete(directory) + if (!state) return + + const hooks = await state + .then((x) => x.hooks) + .catch((err) => { + log.error("failed to load plugins before dispose", { error: err }) + return [] + }) + await Promise.all( + hooks.map((hook) => + Promise.resolve(hook.dispose?.()).catch((err) => { + log.error("plugin dispose hook failed", { error: err }) + }), + ), + ) + } } + +registerDisposer(async (directory) => { + await Plugin.shutdown(directory) +}) diff --git a/packages/opencode/test/cli/mcp-add.test.ts b/packages/opencode/test/cli/mcp-add.test.ts new file mode 100644 index 000000000..f7bf73387 --- /dev/null +++ b/packages/opencode/test/cli/mcp-add.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { resolveMcpAdd } from "../../src/cli/cmd/mcp" + +describe("resolveMcpAdd", () => { + test("builds remote server config with headers", () => { + expect( + resolveMcpAdd({ + name: "github", + url: "https://example.com/mcp", + header: ["Authorization=Bearer {env:GITHUB_TOKEN}", "X-Option=one=two"], + }), + ).toEqual({ + name: "github", + config: { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer {env:GITHUB_TOKEN}", + "X-Option": "one=two", + }, + }, + }) + }) + + test("builds local server config with argv and environment", () => { + expect( + resolveMcpAdd({ + name: "local", + env: ["API_KEY=secret", "VALUE=one=two"], + "--": ["npx", "-y", "@example/server", "--label", "two words"], + }), + ).toEqual({ + name: "local", + config: { + type: "local", + command: ["npx", "-y", "@example/server", "--label", "two words"], + environment: { + API_KEY: "secret", + VALUE: "one=two", + }, + }, + }) + }) + + test("keeps interactive mode when no arguments are provided", () => { + expect(resolveMcpAdd({})).toBeUndefined() + }) + + test("rejects invalid non-interactive combinations", () => { + expect(() => resolveMcpAdd({ url: "https://example.com/mcp" })).toThrow( + "A server name is required for non-interactive MCP configuration", + ) + expect(() => resolveMcpAdd({ name: "both", url: "https://example.com/mcp", "--": ["server"] })).toThrow( + "Provide either --url or a command after --", + ) + expect(() => resolveMcpAdd({ name: "bad", url: "not a url" })).toThrow("Invalid URL: not a url") + expect(() => resolveMcpAdd({ name: "remote", url: "https://example.com/mcp", env: ["KEY=value"] })).toThrow( + "--env is only valid for local MCP servers", + ) + expect(() => resolveMcpAdd({ name: "local", "--": ["server"], header: ["Key=value"] })).toThrow( + "--header is only valid for remote MCP servers", + ) + expect(() => resolveMcpAdd({ name: "local", "--": ["server"], env: ["=value"] })).toThrow( + "Invalid environment variable: =value. Expected KEY=VALUE", + ) + }) +}) diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts index 5a1cf059d..9ff111eda 100644 --- a/packages/opencode/test/cli/plugin-auth-picker.test.ts +++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test" -import { resolvePluginProviders } from "../../src/cli/cmd/providers" +import { resolveLogoutProvider, resolvePluginProviders } from "../../src/cli/cmd/providers" import type { Hooks } from "@opencode-ai/plugin" function hookWithAuth(provider: string): Hooks { @@ -118,3 +118,39 @@ describe("resolvePluginProviders", () => { expect(result).toEqual([]) }) }) + +describe("resolveLogoutProvider", () => { + test("matches configured provider id", () => { + expect( + resolveLogoutProvider({ + provider: "openai", + credentials: ["anthropic", "openai"], + names: {}, + }), + ).toBe("openai") + }) + + test("matches configured provider name case-insensitively", () => { + expect( + resolveLogoutProvider({ + provider: "openai", + credentials: ["custom-openai"], + names: { + "custom-openai": "OpenAI", + }, + }), + ).toBe("custom-openai") + }) + + test("returns undefined for an unknown configured provider", () => { + expect( + resolveLogoutProvider({ + provider: "missing", + credentials: ["openai"], + names: { + openai: "OpenAI", + }, + }), + ).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/plugin/dispose.test.ts b/packages/opencode/test/plugin/dispose.test.ts new file mode 100644 index 000000000..c0e0c55f2 --- /dev/null +++ b/packages/opencode/test/plugin/dispose.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../fixture/instance-shim" +import { Plugin } from "../../src/plugin" + +describe("plugin dispose hook", () => { + test("runs when the instance is disposed", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const root = path.join(dir, ".opencode", "plugin") + const marker = path.join(dir, "disposed.txt") + await fs.mkdir(root, { recursive: true }) + await Bun.write( + path.join(root, "dispose.ts"), + [ + "export default async () => ({", + " dispose: async () => {", + ` await Bun.write(${JSON.stringify(marker)}, "disposed")`, + " },", + "})", + "", + ].join("\n"), + ) + return marker + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init(Instance.directory) + expect(await Bun.file(tmp.extra).exists()).toBe(false) + await Instance.dispose() + expect(await Bun.file(tmp.extra).text()).toBe("disposed") + }, + }) + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index f398652cb..960056bbf 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -146,6 +146,7 @@ export type AuthOuathResult = { url: string; instructions: string } & ( ) export interface Hooks { + dispose?: () => Promise event?: (input: { event: Event }) => Promise config?: (input: Config) => Promise tool?: {