@uniqbit/mate-core 0.17.0-canary.9 → 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.
- package/package.json +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- 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
|
+
}
|
package/src/tools/setup/mate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
40
|
+
return [
|
|
41
|
+
...(Array.isArray(config.plugin) ? config.plugin : []),
|
|
42
|
+
...(Array.isArray(config.plugins) ? config.plugins : []),
|
|
43
|
+
];
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
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.
|
|
57
|
+
delete config.plugins;
|
|
48
58
|
return;
|
|
49
59
|
}
|
|
50
|
-
config.
|
|
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 =
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
304
|
+
disabled: false,
|
|
115
305
|
};
|
|
116
306
|
}
|
|
117
307
|
|
|
118
308
|
/**
|
|
119
|
-
* Reconcile a single server
|
|
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
|
|
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
|
|
134
|
-
delete
|
|
325
|
+
if (!(name in servers)) return;
|
|
326
|
+
delete servers[name];
|
|
135
327
|
} else {
|
|
136
|
-
|
|
328
|
+
servers[name] = normalizeMcpEntry(entry);
|
|
137
329
|
}
|
|
138
330
|
|
|
139
331
|
const next = { ...config };
|
|
140
|
-
if (Object.keys(
|
|
332
|
+
if (Object.keys(servers).length > 0) {
|
|
333
|
+
mcp.servers = servers;
|
|
141
334
|
next.mcp = mcp;
|
|
142
335
|
} else {
|
|
143
|
-
delete
|
|
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
|
}
|