@uniqbit/mate-core 0.17.2-canary.3 → 0.18.0

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/companion/update.ts +43 -6
  3. package/src/cli/commands/install.ts +3 -1
  4. package/src/cli/commands/plugin/plugin.ts +1 -1
  5. package/src/cli/commands/plugin/verify.ts +4 -8
  6. package/src/cli/commands/studio/terminal-launch.ts +1 -51
  7. package/src/cli/commands/studio/terminal.ts +2 -13
  8. package/src/lib/install.ts +3 -0
  9. package/src/lib/orchestrator/types.ts +0 -6
  10. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +5 -2
  11. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +5 -2
  12. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +5 -2
  13. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +5 -2
  14. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -2
  15. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +5 -2
  16. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +5 -2
  17. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +5 -2
  18. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +5 -2
  19. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +5 -2
  20. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +5 -2
  21. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -2
  22. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +5 -2
  23. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +5 -2
  24. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +90 -63
  25. package/src/tools/setup/capabilities/openspec.ts +55 -27
  26. package/src/tools/setup/capabilities/react-doctor.ts +8 -16
  27. package/src/tools/setup/capabilities/tokensave.ts +1 -0
  28. package/src/tools/setup/dynamic-plugins/frozen.ts +24 -3
  29. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  30. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  31. package/src/tools/setup/dynamic-plugins/staging.ts +11 -5
  32. package/src/tools/setup/dynamic-plugins/verify.ts +22 -18
  33. package/src/tools/setup/engine.ts +6 -1
  34. package/src/tools/setup/install-contract.ts +2 -0
  35. package/src/tools/setup/mate.ts +3 -1
  36. package/src/tools/setup/plugin.ts +2 -0
  37. package/src/tools/setup/providers/agents-skill-root.ts +79 -0
  38. package/src/tools/setup/providers/opencode.ts +8 -9
@@ -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
+ }
@@ -25,16 +25,22 @@ function changedPaths(cwd: string): string[] {
25
25
  }
26
26
 
27
27
  /**
28
- * Reports what plugins would generate that is not already committed. The
29
- * projection runs against a disposable clone of the checkout's commit, so edits
30
- * in the live checkout neither matter nor are touched, and any file that
31
- * differs afterwards is drift. Nothing is written back to the live checkout.
32
- * Returns the drifting paths (empty when the commit is complete).
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
33
  */
34
34
  export async function verifyTrackedPluginOutputs(
35
35
  companionPath: string,
36
36
  project: StagedProjection,
37
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
+ }
38
44
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "mate-staging-"));
39
45
  try {
40
46
  const staged = path.join(root, "companion");
@@ -5,37 +5,38 @@ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
5
5
  import { readDeclarations, validateDeclaration } from "./declarations";
6
6
  import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
7
7
  import { pluginPackageRoot } from "./paths";
8
+ import {
9
+ disallowedPluginMessage,
10
+ isPluginAllowed,
11
+ PluginPolicyError,
12
+ readPluginPolicy,
13
+ } from "./policy";
8
14
 
9
15
  export interface PluginVerificationFailure {
10
16
  package: string;
11
17
  reason: string;
12
18
  }
13
19
 
14
- export interface PluginInspection {
15
- failures: PluginVerificationFailure[];
16
- /** IDs of the capabilities the loaded plugins provide. */
17
- capabilities: string[];
18
- }
19
-
20
20
  /**
21
- * Strict, installation-free check of every declared plugin: installed
21
+ * Strict, installation-free check of every declared plugin: allowed, installed
22
22
  * and loadable with the effective environment. Unlike hydration it fails
23
- * closed on the first-class problems ordinary commands only warn about.
23
+ * closed on the first-class problems ordinary commands only warn about. Only
24
+ * allowlisted packages are imported.
24
25
  */
25
26
  export async function verifyDeclaredPlugins(
26
27
  companionPath: string,
27
28
  deps: DynamicPluginLoadDeps = {},
28
29
  ): Promise<PluginVerificationFailure[]> {
29
- return (await inspectDeclaredPlugins(companionPath, deps)).failures;
30
- }
31
-
32
- export async function inspectDeclaredPlugins(
33
- companionPath: string,
34
- deps: DynamicPluginLoadDeps = {},
35
- ): Promise<PluginInspection> {
36
30
  const env = deps.env ?? process.env;
37
31
  const failures: PluginVerificationFailure[] = [];
38
- const capabilities: string[] = [];
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
+
39
40
  const declarations: PluginDeclaration[] = [];
40
41
  for (const entry of await readDeclarations(companionPath)) {
41
42
  const { declaration, error } = validateDeclaration(entry);
@@ -45,6 +46,10 @@ export async function inspectDeclaredPlugins(
45
46
 
46
47
  for (const declaration of declarations) {
47
48
  const name = declaration.package;
49
+ if (!isPluginAllowed(policy, name)) {
50
+ failures.push({ package: name, reason: disallowedPluginMessage(name) });
51
+ continue;
52
+ }
48
53
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
49
54
  const installed = await fs
50
55
  .access(pluginPackageRoot(companionPath, name))
@@ -60,7 +65,6 @@ export async function inspectDeclaredPlugins(
60
65
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
61
66
  const result = await loadDynamicPlugin(companionPath, declaration, { ...deps, env });
62
67
  if (!result.ok) failures.push({ package: name, reason: result.warning });
63
- else if (result.plugin.kind === "capability") capabilities.push(result.plugin.id);
64
68
  }
65
- return { failures, capabilities };
69
+ return failures;
66
70
  }
@@ -221,7 +221,12 @@ async function collectContributionInputs(
221
221
  // are torn down. The surfaces never create files for disabled inputs.
222
222
  const runtimeActive = plan.activeProviders.includes(runtimeId);
223
223
  const inputs = inputsByRuntime.get(runtimeId) ?? [];
224
- inputs.push({ pluginId: capability.id, enabled: enabled && runtimeActive, contributions });
224
+ inputs.push({
225
+ pluginId: capability.id,
226
+ enabled: enabled && runtimeActive,
227
+ capabilityEnabled: enabled,
228
+ contributions,
229
+ });
225
230
  inputsByRuntime.set(runtimeId, inputs);
226
231
  }
227
232
  }
@@ -17,4 +17,6 @@ export interface InstallRequirement {
17
17
  detect(): boolean | Promise<boolean>;
18
18
  install(): Promise<void>;
19
19
  verify?(): boolean | Promise<boolean>;
20
+ /** Best-effort refresh of an already-satisfied requirement; must never throw. */
21
+ update?(): Promise<void>;
20
22
  }
@@ -17,6 +17,8 @@ export const MATE_SKILLS = [
17
17
  "mate-simplify-code",
18
18
  ] as const;
19
19
  const LEGACY_MATE_SKILLS = ["mate-artifact-finish", "mate-openspec-artifact-finish"] as const;
20
+ /** Every Mate-owned skill name a skill root may hold, current or retired. */
21
+ export const MANAGED_MATE_SKILL_NAMES = [...MATE_SKILLS, ...LEGACY_MATE_SKILLS] as const;
20
22
 
21
23
  const MATE_SKILLS_SOURCE = path.join(import.meta.dirname, "../../templates/mate-skills");
22
24
 
@@ -147,7 +149,7 @@ export async function applyMateSkills(skillsDir: string, tool: string): Promise<
147
149
  }
148
150
 
149
151
  export async function teardownMateSkills(skillsDir: string, companionPath: string): Promise<void> {
150
- for (const skill of [...MATE_SKILLS, ...LEGACY_MATE_SKILLS]) {
152
+ for (const skill of MANAGED_MATE_SKILL_NAMES) {
151
153
  try {
152
154
  await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true });
153
155
  } catch {
@@ -219,6 +219,8 @@ export type RuntimeContributionsByRuntime = Partial<Record<string, RuntimeContri
219
219
  export interface CapabilityContributionInput {
220
220
  pluginId: string;
221
221
  enabled: boolean;
222
+ /** Capability enabled regardless of runtime; absent means disabled. */
223
+ capabilityEnabled?: boolean;
222
224
  contributions: RuntimeContributions;
223
225
  }
224
226
 
@@ -0,0 +1,79 @@
1
+ // oxlint-disable no-await-in-loop
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import type { SkillTreeContribution } from "../plugin";
6
+ import { mergeDir, pruneEmptyAncestors } from "../utils";
7
+
8
+ /**
9
+ * Agent Runtimes that read the vendor-neutral companion `.agents/skills` root.
10
+ * Claude is never a member: it reads only `.claude/skills`.
11
+ */
12
+ export const SHARED_SKILL_ROOT_RUNTIMES = ["opencode"] as const;
13
+
14
+ export function isSharedSkillRootActive(activeProviders: readonly string[]): boolean {
15
+ return SHARED_SKILL_ROOT_RUNTIMES.some((runtime) => activeProviders.includes(runtime));
16
+ }
17
+
18
+ export function getSharedSkillsDir(companionPath: string): string {
19
+ return path.join(companionPath, ".agents", "skills");
20
+ }
21
+
22
+ function getLegacyOpenCodeSkillsDir(companionPath: string): string {
23
+ return path.join(companionPath, ".opencode", "skills");
24
+ }
25
+
26
+ /** Mate owns skill-root entries only by name; the directory itself is never listed or wiped. */
27
+ async function removeSkillNames(
28
+ skillsDir: string,
29
+ names: readonly string[],
30
+ companionPath: string,
31
+ ): Promise<void> {
32
+ for (const name of names) {
33
+ await fs.rm(path.join(skillsDir, name), { recursive: true, force: true });
34
+ }
35
+ await pruneEmptyAncestors(skillsDir, companionPath);
36
+ }
37
+
38
+ export async function removeSharedSkills(
39
+ companionPath: string,
40
+ names: readonly string[],
41
+ ): Promise<void> {
42
+ await removeSkillNames(getSharedSkillsDir(companionPath), names, companionPath);
43
+ }
44
+
45
+ export async function removeLegacyOpenCodeSkills(
46
+ companionPath: string,
47
+ names: readonly string[],
48
+ ): Promise<void> {
49
+ await removeSkillNames(getLegacyOpenCodeSkillsDir(companionPath), names, companionPath);
50
+ }
51
+
52
+ export interface SharedSkillTreeState {
53
+ /** Capability enabled and the reconciling runtime active. */
54
+ enabled: boolean;
55
+ /** Capability enabled regardless of runtime; separates disable from runtime deselect. */
56
+ capabilityEnabled: boolean;
57
+ activeProviders: readonly string[];
58
+ }
59
+
60
+ /**
61
+ * Reconcile one declared skill tree into the shared root. A deselected runtime
62
+ * keeps the tree while another reading runtime is still active.
63
+ */
64
+ export async function reconcileSharedSkillTree(
65
+ companionPath: string,
66
+ skillTree: SkillTreeContribution,
67
+ state: SharedSkillTreeState,
68
+ ): Promise<void> {
69
+ await removeLegacyOpenCodeSkills(companionPath, [skillTree.name]);
70
+ if (state.enabled) {
71
+ await mergeDir(
72
+ skillTree.sourceDir,
73
+ path.join(getSharedSkillsDir(companionPath), skillTree.name),
74
+ );
75
+ return;
76
+ }
77
+ if (state.capabilityEnabled && isSharedSkillRootActive(state.activeProviders)) return;
78
+ await removeSharedSkills(companionPath, [skillTree.name]);
79
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../../lib/opencode-plugin-package";
12
12
  import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
13
13
  import { stripSectionFromFile, type RemoveHeadingSectionOptions } from "./agent-file-sections";
14
+ import { reconcileSharedSkillTree } from "./agents-skill-root";
14
15
  import { patchSkillTreeMarkdownFiles } from "./skill-tree";
15
16
  import {
16
17
  instructionBlockKey,
@@ -21,7 +22,7 @@ import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "
21
22
  import { surfaceRoot } from "../surface-target";
22
23
  import { resolvePreinstalledPluginReference } from "../../../lib/preinstalled-plugins";
23
24
  import { getCurrentVersion } from "../../../lib/update-checker";
24
- import { mergeDir, pruneEmptyAncestors } from "../utils";
25
+ import { pruneEmptyAncestors } from "../utils";
25
26
  import {
26
27
  getOpenCodePluginReferences,
27
28
  isRecord,
@@ -465,7 +466,7 @@ export async function removeOpenCodeForeignPluginReferences(
465
466
  // Runtime Surface reconciliation: apply/remove declared Capability
466
467
  // contributions (spec: runtime-surface). Managed identity: named MCP servers,
467
468
  // `isManagedReference` for plugin entries, managed guidance blocks, and named
468
- // skill trees.
469
+ // skill trees (written to the shared `.agents/skills` root).
469
470
  // ---------------------------------------------------------------------------
470
471
 
471
472
  const OPENCODE_CONTRIBUTION_CONFIG_FILES = ["opencode.json"];
@@ -530,13 +531,11 @@ export async function reconcileOpenCodeContributions(
530
531
  }
531
532
 
532
533
  for (const skillTree of input.contributions.skillTrees ?? []) {
533
- const skillDir = path.join(root, ".opencode", "skills", skillTree.name);
534
- if (input.enabled) {
535
- await mergeDir(skillTree.sourceDir, skillDir);
536
- } else {
537
- await fs.rm(skillDir, { recursive: true, force: true });
538
- await pruneEmptyAncestors(path.join(root, ".opencode", "skills"), root);
539
- }
534
+ await reconcileSharedSkillTree(root, skillTree, {
535
+ enabled: input.enabled,
536
+ capabilityEnabled: input.capabilityEnabled ?? false,
537
+ activeProviders: ctx.activeProviders,
538
+ });
540
539
  }
541
540
  }
542
541
  }