@uniqbit/mate-core 0.17.0-canary.8 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -1,16 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { parse } from "yaml";
5
-
6
4
  import { getActiveDistribution } from "../../../distribution";
7
5
  import { FRAMEWORK_NAME } from "../../../framework";
8
6
  import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
9
7
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
10
- import { PLUGIN_DECLARATION_POLICIES } from "../../../lib/orchestrator/config-store";
11
- import type { PluginDeclaration } from "../../../lib/orchestrator/types";
12
8
  import type { Plugin } from "../plugin";
13
9
  import type { PluginRegistry } from "../registry";
10
+ import { readDeclarations, validateDeclaration } from "./declarations";
14
11
  import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
15
12
 
16
13
  // Packages already registered in this process; re-hydration (e.g. right after
@@ -29,10 +26,6 @@ export interface HydrateDynamicPluginsDeps extends DynamicPluginLoadDeps {
29
26
  warn?: (message: string) => void;
30
27
  }
31
28
 
32
- function isRecord(value: unknown): value is Record<string, unknown> {
33
- return typeof value === "object" && value !== null && !Array.isArray(value);
34
- }
35
-
36
29
  /**
37
30
  * Quiet variant of companion resolution: never prompts, never errors. An
38
31
  * ambiguous or absent companion means hydration is a no-op —
@@ -58,38 +51,6 @@ async function resolveCompanionQuietly(
58
51
  }
59
52
  }
60
53
 
61
- /**
62
- * Raw read of the companion's `plugins:` list. Deliberately avoids
63
- * `ConfigStore.load()` — hydration runs on every invocation and must never
64
- * create or migrate config files as a side effect.
65
- */
66
- async function readDeclarations(companionPath: string): Promise<unknown[]> {
67
- try {
68
- const raw = await fs.readFile(
69
- path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
70
- "utf8",
71
- );
72
- const parsed = parse(raw) as unknown;
73
- if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
74
- return parsed.plugins;
75
- } catch {
76
- return [];
77
- }
78
- }
79
-
80
- function validateDeclaration(entry: unknown): { declaration?: PluginDeclaration; error?: string } {
81
- if (!isRecord(entry) || typeof entry.package !== "string" || typeof entry.version !== "string") {
82
- return { error: `ignoring malformed plugins entry: ${JSON.stringify(entry)}` };
83
- }
84
- const policy = entry.policy;
85
- if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
86
- return {
87
- error: `plugin "${entry.package}": policy "${String(policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
88
- };
89
- }
90
- return { declaration: entry as unknown as PluginDeclaration };
91
- }
92
-
93
54
  /**
94
55
  * A framework declaration is the activation switch for a dynamic plugin.
95
56
  * Own-property override rather than a spread: class-instance plugins keep
@@ -14,6 +14,12 @@ import {
14
14
  type PluginHost,
15
15
  } from "./host";
16
16
  import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
17
+ import {
18
+ disallowedPluginMessage,
19
+ isPluginAllowed,
20
+ PluginPolicyError,
21
+ readPluginPolicy,
22
+ } from "./policy";
17
23
 
18
24
  interface PluginManifest {
19
25
  mate?: { pluginApiVersion?: unknown };
@@ -47,6 +53,16 @@ export async function loadDynamicPlugin(
47
53
  const name = declaration.package;
48
54
  const packageRoot = pluginPackageRoot(companionPath, name);
49
55
 
56
+ /** Fails closed: a malformed policy allows nothing. Checked before any plugin file is touched. */
57
+ try {
58
+ if (!isPluginAllowed(readPluginPolicy(deps.env ?? process.env), name)) {
59
+ return { ok: false, warning: `${disallowedPluginMessage(name)}; not loaded.` };
60
+ }
61
+ } catch (error) {
62
+ if (!(error instanceof PluginPolicyError)) throw error;
63
+ return { ok: false, warning: `plugin "${name}" not loaded: ${error.message}` };
64
+ }
65
+
50
66
  let manifest: PluginManifest;
51
67
  try {
52
68
  manifest = JSON.parse(
@@ -0,0 +1,55 @@
1
+ /** Environment variable carrying the appliance's declared-plugin package allowlist. */
2
+ export const ALLOWED_PLUGINS_ENV = "MATE_ALLOWED_PLUGINS";
3
+
4
+ /**
5
+ * Parsed allowlist: exact package names and scope-wide `@scope/*` patterns.
6
+ * An empty list permits no package; the policy's absence permits all.
7
+ */
8
+ export interface PluginPolicy {
9
+ exact: string[];
10
+ scopes: string[];
11
+ }
12
+
13
+ export class PluginPolicyError extends Error {}
14
+
15
+ const EXACT_PATTERN = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
16
+ const SCOPE_PATTERN = /^@[a-z0-9][a-z0-9._~-]*\/\*$/;
17
+
18
+ /**
19
+ * Parses a comma-separated allowlist. `undefined` means no policy; an empty
20
+ * or blank string is an explicit empty policy. Malformed entries throw so a
21
+ * typo can never widen or silently narrow the trust decision.
22
+ */
23
+ export function parseAllowedPlugins(raw: string | undefined): PluginPolicy | null {
24
+ if (raw === undefined) return null;
25
+ const policy: PluginPolicy = { exact: [], scopes: [] };
26
+ if (raw.trim() === "") return policy;
27
+ for (const part of raw.split(",")) {
28
+ const entry = part.trim();
29
+ if (SCOPE_PATTERN.test(entry)) policy.scopes.push(entry.slice(0, entry.indexOf("/") + 1));
30
+ else if (EXACT_PATTERN.test(entry)) policy.exact.push(entry);
31
+ else {
32
+ throw new PluginPolicyError(
33
+ `${ALLOWED_PLUGINS_ENV}: malformed entry ${JSON.stringify(entry)}; expected an exact package name or a scope pattern such as "@acme/*"`,
34
+ );
35
+ }
36
+ }
37
+ return policy;
38
+ }
39
+
40
+ /** Reads the effective policy from an environment; throws on a malformed value. */
41
+ export function readPluginPolicy(
42
+ env: Record<string, string | undefined> = process.env,
43
+ ): PluginPolicy | null {
44
+ return parseAllowedPlugins(env[ALLOWED_PLUGINS_ENV]);
45
+ }
46
+
47
+ export function isPluginAllowed(policy: PluginPolicy | null, packageName: string): boolean {
48
+ if (policy === null) return true;
49
+ if (policy.exact.includes(packageName)) return true;
50
+ return policy.scopes.some((scope) => packageName.startsWith(scope));
51
+ }
52
+
53
+ export function disallowedPluginMessage(packageName: string): string {
54
+ return `plugin "${packageName}" is not allowed by ${ALLOWED_PLUGINS_ENV}`;
55
+ }
@@ -0,0 +1,53 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export class TrackedOutputError extends Error {}
7
+
8
+ export type StagedProjection = (stagingPath: string) => Promise<void>;
9
+
10
+ function git(cwd: string, args: string[]): string {
11
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
12
+ if (result.error || result.status !== 0) {
13
+ throw new TrackedOutputError(
14
+ `git ${args[0]} failed in ${cwd}: ${result.error?.message ?? result.stderr?.trim() ?? result.status}`,
15
+ );
16
+ }
17
+ return result.stdout;
18
+ }
19
+
20
+ function changedPaths(cwd: string): string[] {
21
+ return git(cwd, ["status", "--porcelain", "--untracked-files=all"])
22
+ .split("\n")
23
+ .filter((line) => line.trim() !== "")
24
+ .map((line) => line.slice(3));
25
+ }
26
+
27
+ /**
28
+ * Checks that what plugins would generate is already committed. The live
29
+ * checkout must have no edits of its own; the projection then runs against a
30
+ * disposable clone of its commit, and any file that differs afterwards is
31
+ * drift. Nothing is written back to the live checkout. Returns the drifting
32
+ * paths (empty when the checkout is complete).
33
+ */
34
+ export async function verifyTrackedPluginOutputs(
35
+ companionPath: string,
36
+ project: StagedProjection,
37
+ ): Promise<string[]> {
38
+ const pending = changedPaths(companionPath);
39
+ if (pending.length > 0) {
40
+ throw new TrackedOutputError(
41
+ `the checkout has uncommitted changes, which frozen setup will not overwrite: ${pending.join(", ")}`,
42
+ );
43
+ }
44
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "mate-staging-"));
45
+ try {
46
+ const staged = path.join(root, "companion");
47
+ git(root, ["clone", "--quiet", "--no-hardlinks", companionPath, staged]);
48
+ await project(staged);
49
+ return changedPaths(staged);
50
+ } finally {
51
+ await fs.rm(root, { recursive: true, force: true });
52
+ }
53
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
5
+ import { readDeclarations, validateDeclaration } from "./declarations";
6
+ import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
7
+ import { pluginPackageRoot } from "./paths";
8
+ import {
9
+ disallowedPluginMessage,
10
+ isPluginAllowed,
11
+ PluginPolicyError,
12
+ readPluginPolicy,
13
+ } from "./policy";
14
+
15
+ export interface PluginVerificationFailure {
16
+ package: string;
17
+ reason: string;
18
+ }
19
+
20
+ /**
21
+ * Strict, installation-free check of every declared plugin: allowed, installed
22
+ * and loadable with the effective environment. Unlike hydration it fails
23
+ * closed on the first-class problems ordinary commands only warn about. Only
24
+ * allowlisted packages are imported.
25
+ */
26
+ export async function verifyDeclaredPlugins(
27
+ companionPath: string,
28
+ deps: DynamicPluginLoadDeps = {},
29
+ ): Promise<PluginVerificationFailure[]> {
30
+ const env = deps.env ?? process.env;
31
+ const failures: PluginVerificationFailure[] = [];
32
+ let policy: ReturnType<typeof readPluginPolicy>;
33
+ try {
34
+ policy = readPluginPolicy(env);
35
+ } catch (error) {
36
+ if (!(error instanceof PluginPolicyError)) throw error;
37
+ return [{ package: "(allowlist)", reason: error.message }];
38
+ }
39
+
40
+ const declarations: PluginDeclaration[] = [];
41
+ for (const entry of await readDeclarations(companionPath)) {
42
+ const { declaration, error } = validateDeclaration(entry);
43
+ if (declaration) declarations.push(declaration);
44
+ else failures.push({ package: "(malformed entry)", reason: error ?? "invalid plugins entry" });
45
+ }
46
+
47
+ for (const declaration of declarations) {
48
+ const name = declaration.package;
49
+ if (!isPluginAllowed(policy, name)) {
50
+ failures.push({ package: name, reason: disallowedPluginMessage(name) });
51
+ continue;
52
+ }
53
+ // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
54
+ const installed = await fs
55
+ .access(pluginPackageRoot(companionPath, name))
56
+ .then(() => true)
57
+ .catch(() => false);
58
+ if (!installed) {
59
+ failures.push({
60
+ package: name,
61
+ reason: `not installed; run setup (\`${FRAMEWORK_NAME} install --yes --frozen-plugins\`) to install it`,
62
+ });
63
+ continue;
64
+ }
65
+ // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
66
+ const result = await loadDynamicPlugin(companionPath, declaration, { ...deps, env });
67
+ if (!result.ok) failures.push({ package: name, reason: result.warning });
68
+ }
69
+ return failures;
70
+ }
@@ -92,7 +92,7 @@ The default path writes self-contained HTML to a unique OS temporary directory a
92
92
  The built-in \`mate report\` path collects Mate usage data and adapts it to the same contract and renderer.
93
93
  `;
94
94
 
95
- export async function deployMateSkillDir(src: string, dest: string): Promise<void> {
95
+ async function deployMateSkillDir(src: string, dest: string): Promise<void> {
96
96
  await fs.mkdir(dest, { recursive: true });
97
97
  const entries = await fs.readdir(src, { withFileTypes: true });
98
98
  for (const entry of entries) {
@@ -87,7 +87,7 @@ export function filterClaudeHookGroups(
87
87
  // Claude plugin, and the retired mate-artifact-finish.sh archive hook is gone
88
88
  // entirely; their markers are retained migration-only so stale managed groups
89
89
  // written by earlier releases keep being stripped, and are never re-added.
90
- export const MANAGED_HOOK_MARKERS = [
90
+ const MANAGED_HOOK_MARKERS = [
91
91
  "validate-artifact-path",
92
92
  "mate-session-banner",
93
93
  "react-doctor.sh",
@@ -151,7 +151,7 @@ export function renderManagedClaudeMcpServers(
151
151
  // servers) on top of whatever already exists in the settings document. Managed
152
152
  // groups/entries always lead their arrays so the emitted shape stays stable
153
153
  // across syncs; unmanaged content is preserved untouched.
154
- export function renderManagedClaudeSettings(
154
+ function renderManagedClaudeSettings(
155
155
  existing: ClaudeSettings,
156
156
  companionPath: string,
157
157
  config: FrameworkConfig,
@@ -3,10 +3,9 @@ import path from "node:path";
3
3
 
4
4
  import type { McpEntryDescriptor } from "./claude-format";
5
5
 
6
- // OpenCode runtime config format primitives: parse/serialize for
7
- // `opencode.json`/`tui.json`, plugin-reference array handling, and MCP entry
8
- // shapes. This module owns the file formats only — which entries are
9
- // Mate-managed is the callers' (Runtime Surface) knowledge.
6
+ // OpenCode V2 config format primitives, plus the one-time V1-to-V2 normalization
7
+ // used while reconciling an existing companion config. This module owns file
8
+ // formats only — which entries are Mate-managed is the callers' knowledge.
10
9
 
11
10
  export type OpenCodeConfig = Record<string, unknown>;
12
11
 
@@ -38,21 +37,214 @@ export async function writeOpenCodeConfig(
38
37
  }
39
38
 
40
39
  export function getOpenCodePluginReferences(config: OpenCodeConfig): unknown[] {
41
- return Array.isArray(config.plugin) ? config.plugin : [];
40
+ return [
41
+ ...(Array.isArray(config.plugin) ? config.plugin : []),
42
+ ...(Array.isArray(config.plugins) ? config.plugins : []),
43
+ ];
42
44
  }
43
45
 
44
- /** Replace the plugin array in place; an empty list removes the key. */
46
+ function normalizePluginReference(reference: unknown): unknown {
47
+ if (Array.isArray(reference) && typeof reference[0] === "string" && isRecord(reference[1])) {
48
+ return { package: reference[0], options: reference[1] };
49
+ }
50
+ return reference;
51
+ }
52
+
53
+ /** Replace plugin references using the V2 key; an empty list removes it. */
45
54
  export function setOpenCodePluginReferences(config: OpenCodeConfig, references: unknown[]): void {
55
+ delete config.plugin;
46
56
  if (references.length === 0) {
47
- delete config.plugin;
57
+ delete config.plugins;
48
58
  return;
49
59
  }
50
- config.plugin = references;
60
+ config.plugins = references.map(normalizePluginReference);
61
+ }
62
+
63
+ /** V2 splits one V1 timeout into separate catalog and execution budgets. */
64
+ function splitMcpTimeout(timeout: number): { catalog: number; execution: number } {
65
+ return { catalog: timeout, execution: timeout };
66
+ }
67
+
68
+ function normalizeMcpEntry(value: unknown): unknown {
69
+ if (!isRecord(value)) return value;
70
+
71
+ const entry = { ...value };
72
+ if (typeof entry.enabled === "boolean") {
73
+ if (entry.disabled === undefined) entry.disabled = !entry.enabled;
74
+ delete entry.enabled;
75
+ }
76
+ if (typeof entry.timeout === "number") {
77
+ entry.timeout = splitMcpTimeout(entry.timeout);
78
+ }
79
+ for (const [legacy, current] of [
80
+ ["clientId", "client_id"],
81
+ ["clientSecret", "client_secret"],
82
+ ["callbackPort", "callback_port"],
83
+ ["redirectUri", "redirect_uri"],
84
+ ] as const) {
85
+ if (entry[legacy] !== undefined) {
86
+ if (entry[current] === undefined) entry[current] = entry[legacy];
87
+ delete entry[legacy];
88
+ }
89
+ }
90
+ return entry;
91
+ }
92
+
93
+ const LEGACY_PERMISSION_ACTIONS: Record<string, string> = {
94
+ bash: "shell",
95
+ task: "subagent",
96
+ write: "edit",
97
+ patch: "edit",
98
+ };
99
+
100
+ function permissionAction(legacyAction: string): string {
101
+ return LEGACY_PERMISSION_ACTIONS[legacyAction] ?? legacyAction;
102
+ }
103
+
104
+ function appendPermissionRules(target: unknown[], permissions: unknown): void {
105
+ if (!isRecord(permissions)) return;
106
+ for (const [legacyAction, rules] of Object.entries(permissions)) {
107
+ const action = permissionAction(legacyAction);
108
+ if (rules === "allow" || rules === "deny" || rules === "ask") {
109
+ target.push({ action, resource: "*", effect: rules });
110
+ continue;
111
+ }
112
+ if (!isRecord(rules)) continue;
113
+ for (const [resource, effect] of Object.entries(rules)) {
114
+ if (effect === "allow" || effect === "deny" || effect === "ask") {
115
+ target.push({ action, resource, effect });
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ function dedupePluginReferences(config: OpenCodeConfig): void {
122
+ const references = getOpenCodePluginReferences(config);
123
+ if (references.length === 0 && !("plugin" in config)) return;
124
+
125
+ const seen = new Set<string>();
126
+ setOpenCodePluginReferences(
127
+ config,
128
+ references.map(normalizePluginReference).filter((entry) => {
129
+ const key = JSON.stringify(entry);
130
+ if (seen.has(key)) return false;
131
+ seen.add(key);
132
+ return true;
133
+ }),
134
+ );
135
+ }
136
+
137
+ function normalizeCompaction(config: OpenCodeConfig): void {
138
+ if (!isRecord(config.compaction)) return;
139
+
140
+ const compaction = { ...config.compaction };
141
+ const keep = isRecord(compaction.keep) ? { ...compaction.keep } : {};
142
+ if (keep.tokens === undefined && compaction.preserve_recent_tokens !== undefined) {
143
+ keep.tokens = compaction.preserve_recent_tokens;
144
+ }
145
+ if (compaction.buffer === undefined && compaction.reserved !== undefined) {
146
+ compaction.buffer = compaction.reserved;
147
+ }
148
+ delete compaction.preserve_recent_tokens;
149
+ delete compaction.reserved;
150
+ // V2 compaction uses checkpoint-based summaries, not V1 pruning/tail turns.
151
+ delete compaction.prune;
152
+ delete compaction.tail_turns;
153
+ if (Object.keys(keep).length > 0) compaction.keep = keep;
154
+ else delete compaction.keep;
155
+ config.compaction = compaction;
156
+ }
157
+
158
+ /** Moves flat V1 `mcp.<name>` entries under `mcp.servers`; an existing V2 entry wins per key. */
159
+ function normalizeMcpServers(config: OpenCodeConfig): void {
160
+ if (!isRecord(config.mcp)) return;
161
+
162
+ const mcp = { ...config.mcp };
163
+ const servers = isRecord(mcp.servers) ? { ...mcp.servers } : {};
164
+ for (const [name, value] of Object.entries(mcp)) {
165
+ if (name === "servers" || name === "timeout") continue;
166
+ const previous = normalizeMcpEntry(value);
167
+ const current = servers[name];
168
+ servers[name] =
169
+ isRecord(previous) && isRecord(current) ? { ...previous, ...current } : (current ?? previous);
170
+ delete mcp[name];
171
+ }
172
+ for (const [name, entry] of Object.entries(servers)) {
173
+ servers[name] = normalizeMcpEntry(entry);
174
+ }
175
+ if (Object.keys(servers).length > 0) mcp.servers = servers;
176
+ else delete mcp.servers;
177
+ if (typeof mcp.timeout === "number") {
178
+ mcp.timeout = splitMcpTimeout(mcp.timeout);
179
+ }
180
+ config.mcp = mcp;
181
+ }
182
+
183
+ function normalizeExperimentalMcpTimeout(config: OpenCodeConfig): void {
184
+ if (!isRecord(config.experimental) || typeof config.experimental.mcp_timeout !== "number") return;
185
+
186
+ const timeout = config.experimental.mcp_timeout;
187
+ const mcp = isRecord(config.mcp) ? { ...config.mcp } : {};
188
+ const existingTimeout = isRecord(mcp.timeout) ? { ...mcp.timeout } : {};
189
+ mcp.timeout = {
190
+ catalog: existingTimeout.catalog ?? timeout,
191
+ execution: existingTimeout.execution ?? timeout,
192
+ };
193
+ config.mcp = mcp;
194
+ const experimental = { ...config.experimental };
195
+ delete experimental.mcp_timeout;
196
+ if (Object.keys(experimental).length > 0) config.experimental = experimental;
197
+ else delete config.experimental;
198
+ }
199
+
200
+ /** Converts V1 `permission` maps and disabled `tools` into V2 rules ahead of existing ones. */
201
+ function normalizePermissions(config: OpenCodeConfig): void {
202
+ const permissionRules: unknown[] = [];
203
+ appendPermissionRules(permissionRules, config.permission);
204
+ if (isRecord(config.tools)) {
205
+ for (const [toolName, enabled] of Object.entries(config.tools)) {
206
+ if (enabled === false) {
207
+ permissionRules.push({ action: permissionAction(toolName), resource: "*", effect: "deny" });
208
+ }
209
+ }
210
+ delete config.tools;
211
+ }
212
+ const currentPermissions = Array.isArray(config.permissions) ? config.permissions : [];
213
+ if (permissionRules.length > 0 || "permission" in config) {
214
+ config.permissions = [...permissionRules, ...currentPermissions];
215
+ }
216
+ delete config.permission;
217
+ }
218
+
219
+ function normalizeSkills(config: OpenCodeConfig): void {
220
+ if (!isRecord(config.skills)) return;
221
+
222
+ const legacySkills = config.skills;
223
+ const normalizedSkills = [
224
+ ...(Array.isArray(legacySkills.paths) ? legacySkills.paths : []),
225
+ ...(Array.isArray(legacySkills.urls) ? legacySkills.urls : []),
226
+ ];
227
+ config.skills = [...new Set(normalizedSkills.filter((entry) => typeof entry === "string"))];
228
+ }
229
+
230
+ /** Normalize supported V1 config syntax to V2 without discarding unrelated settings. */
231
+ export function normalizeOpenCodeConfig(config: OpenCodeConfig): OpenCodeConfig {
232
+ dedupePluginReferences(config);
233
+ normalizeCompaction(config);
234
+ normalizeMcpServers(config);
235
+ normalizeExperimentalMcpTimeout(config);
236
+ normalizePermissions(config);
237
+ normalizeSkills(config);
238
+ return config;
51
239
  }
52
240
 
53
241
  function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): void {
54
242
  for (const [key, sourceValue] of Object.entries(source)) {
55
243
  const targetValue = target[key];
244
+ if (key === "permissions" && Array.isArray(targetValue) && Array.isArray(sourceValue)) {
245
+ target[key] = [...targetValue, ...sourceValue];
246
+ continue;
247
+ }
56
248
  if (isRecord(targetValue) && isRecord(sourceValue)) {
57
249
  mergeConfig(targetValue, sourceValue);
58
250
  continue;
@@ -79,7 +271,7 @@ export function mergeOpenCodeConfigContent(
79
271
  try {
80
272
  const parsed = JSON.parse(existing) as unknown;
81
273
  if (isRecord(parsed)) {
82
- config = parsed;
274
+ config = normalizeOpenCodeConfig(parsed);
83
275
  }
84
276
  } catch {
85
277
  // Ignore invalid inherited config content rather than breaking launch.
@@ -89,36 +281,34 @@ export function mergeOpenCodeConfigContent(
89
281
  mergeConfig(config, overlay);
90
282
 
91
283
  if (options.appendSkillPaths && options.appendSkillPaths.length > 0) {
92
- const skills = isRecord(config.skills) ? config.skills : {};
93
- const paths = Array.isArray(skills.paths) ? skills.paths : [];
94
- const existingPaths = new Set(paths);
95
- skills.paths = [
96
- ...paths,
284
+ const skills = Array.isArray(config.skills) ? config.skills : [];
285
+ const existingPaths = new Set(skills);
286
+ config.skills = [
287
+ ...skills,
97
288
  ...options.appendSkillPaths.filter((skillPath) => !existingPaths.has(skillPath)),
98
289
  ];
99
- config.skills = skills;
100
290
  }
101
291
 
102
292
  return JSON.stringify(config);
103
293
  }
104
294
 
105
- /** Map a provider-agnostic MCP descriptor to OpenCode's `mcp` entry shape. */
295
+ /** Map a provider-agnostic MCP descriptor to OpenCode V2's server entry shape. */
106
296
  export function toOpenCodeMcpEntry(descriptor: McpEntryDescriptor): Record<string, unknown> {
107
297
  if (descriptor.url) {
108
- return { type: "remote", url: descriptor.url, enabled: true };
298
+ return { type: "remote", url: descriptor.url, disabled: false };
109
299
  }
110
300
  return {
111
301
  type: "local",
112
302
  command: [descriptor.command ?? "", ...(descriptor.args ?? [])].filter(Boolean),
113
303
  ...(descriptor.env ? { environment: descriptor.env } : {}),
114
- enabled: true,
304
+ disabled: false,
115
305
  };
116
306
  }
117
307
 
118
308
  /**
119
- * Reconcile a single server entry in the `mcp` map while preserving every
309
+ * Reconcile a single server under `mcp.servers` while preserving every
120
310
  * unrelated key. `entry: null` removes the server; removal never creates the
121
- * file, and an emptied `mcp` map is dropped entirely.
311
+ * file, and an emptied `mcp.servers` map is dropped.
122
312
  */
123
313
  export async function updateOpenCodeMcpServer(
124
314
  configPath: string,
@@ -128,19 +318,24 @@ export async function updateOpenCodeMcpServer(
128
318
  const { present, config } = await readOpenCodeConfig(configPath);
129
319
  if (entry === null && !present) return;
130
320
 
321
+ normalizeOpenCodeConfig(config);
131
322
  const mcp: Record<string, unknown> = isRecord(config.mcp) ? { ...config.mcp } : {};
323
+ const servers: Record<string, unknown> = isRecord(mcp.servers) ? { ...mcp.servers } : {};
132
324
  if (entry === null) {
133
- if (!(name in mcp)) return;
134
- delete mcp[name];
325
+ if (!(name in servers)) return;
326
+ delete servers[name];
135
327
  } else {
136
- mcp[name] = entry;
328
+ servers[name] = normalizeMcpEntry(entry);
137
329
  }
138
330
 
139
331
  const next = { ...config };
140
- if (Object.keys(mcp).length > 0) {
332
+ if (Object.keys(servers).length > 0) {
333
+ mcp.servers = servers;
141
334
  next.mcp = mcp;
142
335
  } else {
143
- delete next.mcp;
336
+ delete mcp.servers;
337
+ if (Object.keys(mcp).length > 0) next.mcp = mcp;
338
+ else delete next.mcp;
144
339
  }
145
340
  await writeOpenCodeConfig(configPath, next);
146
341
  }