@uniqbit/mate-core 0.15.3 → 0.15.4-canary.10

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 (94) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +4 -0
  2. package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
  3. package/claude-plugin/hooks/hooks.json +37 -0
  4. package/claude-plugin/hooks/session-banner.mjs +8 -0
  5. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  6. package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
  7. package/package.json +4 -2
  8. package/src/cli/commands/artifact/finish/openspec.ts +1 -5
  9. package/src/cli/commands/companion/hub.ts +99 -0
  10. package/src/cli/commands/companion/link.ts +0 -5
  11. package/src/cli/commands/companion/tui.ts +3 -2
  12. package/src/cli/commands/doctor.ts +254 -170
  13. package/src/cli/commands/install.ts +18 -14
  14. package/src/cli/commands/launch/shared.ts +4 -4
  15. package/src/cli/commands/plugin/install.ts +94 -0
  16. package/src/cli/commands/plugin/plugin.ts +17 -0
  17. package/src/cli/commands/setup.ts +4 -6
  18. package/src/cli/commands/update.ts +9 -11
  19. package/src/cli/companion-link-wizard.tsx +22 -78
  20. package/src/cli/main.ts +57 -13
  21. package/src/cli/plugin-commands.ts +4 -4
  22. package/src/cli/repo-list-table.tsx +2 -7
  23. package/src/cli/usage.ts +7 -3
  24. package/src/distribution.ts +3 -5
  25. package/src/framework.ts +4 -36
  26. package/src/hooks/artifact-finish-nudge.ts +212 -0
  27. package/src/hooks/session-banner.ts +25 -0
  28. package/src/hooks/validate-artifact-path.ts +236 -0
  29. package/src/index.ts +4 -0
  30. package/src/lib/context-mode-package.ts +5 -3
  31. package/src/lib/install.ts +31 -43
  32. package/src/lib/orchestrator/adapters/base.ts +7 -8
  33. package/src/lib/orchestrator/adapters/claude.ts +16 -0
  34. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  35. package/src/lib/orchestrator/companion-hub.ts +380 -0
  36. package/src/lib/orchestrator/companion-store.ts +1 -35
  37. package/src/lib/orchestrator/config-store.ts +94 -7
  38. package/src/lib/orchestrator/editor.ts +4 -20
  39. package/src/lib/orchestrator/framework-context.ts +119 -24
  40. package/src/lib/orchestrator/global-config-store.ts +1 -5
  41. package/src/lib/orchestrator/launcher.ts +3 -9
  42. package/src/lib/orchestrator/migration.ts +0 -23
  43. package/src/lib/orchestrator/opencode-guidance.ts +1 -2
  44. package/src/lib/orchestrator/repo-local-registry.ts +3 -1
  45. package/src/lib/orchestrator/root-context.ts +117 -0
  46. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  47. package/src/lib/orchestrator/setup-preflight.ts +5 -18
  48. package/src/lib/orchestrator/types.ts +27 -19
  49. package/src/lib/orchestrator/working-repo-store.ts +9 -3
  50. package/src/lib/package-paths.ts +37 -0
  51. package/src/lib/update-checker.ts +5 -5
  52. package/src/playbooks/companion-guidance.ts +7 -7
  53. package/src/runtime/env.ts +0 -4
  54. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  55. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  56. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  57. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  58. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
  59. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  60. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  61. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3541 -0
  62. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  63. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  64. package/src/tools/setup/capabilities/graphify.ts +86 -295
  65. package/src/tools/setup/capabilities/openspec.ts +27 -30
  66. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  67. package/src/tools/setup/capabilities/rtk.ts +4 -1
  68. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  69. package/src/tools/setup/capabilities/tokensave.ts +50 -71
  70. package/src/tools/setup/context-services.ts +29 -0
  71. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  72. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  73. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  74. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  75. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  76. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  77. package/src/tools/setup/engine.ts +65 -1
  78. package/src/tools/setup/mate.ts +28 -31
  79. package/src/tools/setup/plugin.ts +81 -0
  80. package/src/tools/setup/plugins/gitignore.ts +19 -9
  81. package/src/tools/setup/plugins/guidance.ts +2 -6
  82. package/src/tools/setup/policy.ts +4 -7
  83. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  84. package/src/tools/setup/providers/claude-format.ts +159 -0
  85. package/src/tools/setup/providers/claude.ts +283 -292
  86. package/src/tools/setup/providers/opencode-format.ts +146 -0
  87. package/src/tools/setup/providers/opencode.ts +171 -66
  88. package/src/tools/setup/providers/skill-tree.ts +38 -0
  89. package/src/tools/setup.ts +12 -7
  90. package/src/tui.ts +5 -0
  91. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
  92. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
  93. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
  94. package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
@@ -3,15 +3,20 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
- import { pluginInstallDir, pluginPackageRoot } from "./paths";
7
- import { PluginPinStore, type PluginPin } from "./pin-store";
6
+ import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
8
7
 
9
- export type BunInstallRunner = (
10
- installDir: string,
8
+ export type NpmInstallRunner = (
9
+ workspaceRoot: string,
10
+ ) => Promise<{ ok: boolean; detail?: string }> | { ok: boolean; detail?: string };
11
+
12
+ export type NpmUpdateRunner = (
13
+ workspaceRoot: string,
14
+ packages: string[],
11
15
  ) => Promise<{ ok: boolean; detail?: string }> | { ok: boolean; detail?: string };
12
16
 
13
17
  export interface PluginInstallDeps {
14
- runBunInstall?: BunInstallRunner;
18
+ runNpmInstall?: NpmInstallRunner;
19
+ runNpmUpdate?: NpmUpdateRunner;
15
20
  }
16
21
 
17
22
  export interface PluginInstallResult {
@@ -21,16 +26,38 @@ export interface PluginInstallResult {
21
26
  error?: string;
22
27
  }
23
28
 
24
- /** Runs `bun install` in the plugin workspace, honoring the user's ambient registry/auth config. */
25
- function defaultBunInstall(installDir: string): { ok: boolean; detail?: string } {
26
- const result = spawnSync("bun", ["install", "--silent"], { cwd: installDir, encoding: "utf8" });
29
+ /** Runs `npm install` for the shared plugin workspace, honoring the user's ambient registry/auth config. */
30
+ function defaultNpmInstall(workspaceRoot: string): { ok: boolean; detail?: string } {
31
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--silent"], {
32
+ cwd: workspaceRoot,
33
+ encoding: "utf8",
34
+ });
27
35
  if (result.error || result.status !== 0) {
28
36
  return {
29
37
  ok: false,
30
38
  detail:
31
39
  result.error?.message ??
32
40
  result.stderr?.trim() ??
33
- `bun install exited with ${result.status}`,
41
+ `npm install exited with ${result.status}`,
42
+ };
43
+ }
44
+ return { ok: true };
45
+ }
46
+
47
+ /** Runs `npm update <pkg...>` to force re-resolution of `latest`-declared plugins every run. */
48
+ function defaultNpmUpdate(
49
+ workspaceRoot: string,
50
+ packages: string[],
51
+ ): { ok: boolean; detail?: string } {
52
+ const result = spawnSync("npm", ["update", "--no-audit", "--no-fund", "--silent", ...packages], {
53
+ cwd: workspaceRoot,
54
+ encoding: "utf8",
55
+ });
56
+ if (result.error || result.status !== 0) {
57
+ return {
58
+ ok: false,
59
+ detail:
60
+ result.error?.message ?? result.stderr?.trim() ?? `npm update exited with ${result.status}`,
34
61
  };
35
62
  }
36
63
  return { ok: true };
@@ -47,125 +74,113 @@ async function readInstalledVersion(packageRoot: string): Promise<string | null>
47
74
  }
48
75
  }
49
76
 
50
- /**
51
- * Best-effort integrity extraction from bun's text lockfile. The lockfile is
52
- * JSONC (trailing commas); package entries are tuples whose last string is
53
- * the registry integrity hash. Absence is tolerated per spec.
54
- */
55
- async function readIntegrityFromBunLock(
56
- installDir: string,
57
- packageName: string,
58
- ): Promise<string | undefined> {
77
+ async function readWorkspaceDependencies(workspaceRoot: string): Promise<Record<string, string>> {
59
78
  try {
60
- const raw = await fs.readFile(path.join(installDir, "bun.lock"), "utf8");
61
- const parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, "$1")) as {
62
- packages?: Record<string, unknown>;
63
- };
64
- const entry = parsed.packages?.[packageName];
65
- if (!Array.isArray(entry)) return undefined;
66
- return entry.find(
67
- (element): element is string => typeof element === "string" && element.startsWith("sha"),
68
- );
79
+ const manifest = JSON.parse(
80
+ await fs.readFile(path.join(workspaceRoot, "package.json"), "utf8"),
81
+ ) as { dependencies?: Record<string, string> };
82
+ return manifest.dependencies ?? {};
69
83
  } catch {
70
- return undefined;
84
+ return {};
71
85
  }
72
86
  }
73
87
 
88
+ async function writeWorkspaceManifest(
89
+ workspaceRoot: string,
90
+ dependencies: Record<string, string>,
91
+ ): Promise<void> {
92
+ await fs.mkdir(workspaceRoot, { recursive: true });
93
+ await fs.writeFile(
94
+ path.join(workspaceRoot, "package.json"),
95
+ JSON.stringify({ private: true, dependencies }, null, 2) + "\n",
96
+ "utf8",
97
+ );
98
+ }
99
+
74
100
  /**
75
- * Installs each declared plugin into `.mate/dependencies/plugins/<sanitized>/`
76
- * and records `{ package, declaredVersion, resolvedVersion, integrity }` in
77
- * the committed pin file. Exact/range versions resolve once and stay pinned
78
- * until the declaration changes; `latest` re-resolves on every run. Matching
79
- * pinned installs are left untouched.
101
+ * Installs every declared plugin into one shared workspace at
102
+ * `.mate/plugins/`. Builds a single `dependencies` map (package
103
+ * → declared version, sorted by name) and diffs it against the workspace's
104
+ * current `package.json` plus each package's actual installed presence; an
105
+ * identical, fully-installed map with no `latest` declaration skips the
106
+ * install entirely. Otherwise the map is written and `npm install` runs once
107
+ * for the whole workspace. `latest`-declared plugins additionally get an
108
+ * `npm update` every run, regardless of whether the map changed, so they
109
+ * re-resolve on every run. The shared, committed `package-lock.json` is the
110
+ * sole reproducibility record; nothing else pins versions. Private
111
+ * registries are never Mate's concern: installs run through the operator's
112
+ * own ambient npm config, or a project-local, gitignored `.npmrc` dropped
113
+ * next to the workspace's `package.json`.
80
114
  */
81
115
  export async function installDeclaredPlugins(
82
116
  companionPath: string,
83
117
  declarations: PluginDeclaration[],
84
118
  deps: PluginInstallDeps = {},
85
119
  ): Promise<PluginInstallResult[]> {
86
- const runBunInstall = deps.runBunInstall ?? defaultBunInstall;
87
- const pinStore = new PluginPinStore(companionPath);
88
- const previousPins = (await pinStore.load()).plugins;
89
- const nextPins: PluginPin[] = [];
90
- const results: PluginInstallResult[] = [];
91
-
92
- for (const declaration of declarations) {
93
- const pin = previousPins.find((candidate) => candidate.package === declaration.package);
94
- const installDir = pluginInstallDir(companionPath, declaration.package);
95
- const packageRoot = pluginPackageRoot(companionPath, declaration.package);
96
- // oxlint-disable-next-line no-await-in-loop -- installs mutate a shared pin file sequentially
97
- const installedVersion = await readInstalledVersion(packageRoot);
98
-
99
- const pinMatches =
100
- declaration.version !== "latest" &&
101
- pin !== undefined &&
102
- pin.declaredVersion === declaration.version &&
103
- installedVersion === pin.resolvedVersion;
104
- if (pinMatches) {
105
- nextPins.push(pin);
106
- results.push({
107
- package: declaration.package,
108
- status: "unchanged",
109
- resolvedVersion: pin.resolvedVersion,
110
- });
111
- continue;
112
- }
120
+ const runNpmInstall = deps.runNpmInstall ?? defaultNpmInstall;
121
+ const runNpmUpdate = deps.runNpmUpdate ?? defaultNpmUpdate;
122
+ const workspaceRoot = dynamicPluginsWorkspaceRoot(companionPath);
113
123
 
114
- // oxlint-disable-next-line no-await-in-loop
115
- const result = await installOne(installDir, packageRoot, declaration, runBunInstall);
116
- results.push(result);
117
- if (result.status === "installed" && result.resolvedVersion) {
118
- nextPins.push({
119
- package: declaration.package,
120
- declaredVersion: declaration.version,
121
- resolvedVersion: result.resolvedVersion,
122
- // oxlint-disable-next-line no-await-in-loop
123
- integrity: await readIntegrityFromBunLock(installDir, declaration.package),
124
- });
125
- }
124
+ const sorted = declarations.toSorted((a, b) => a.package.localeCompare(b.package));
125
+ const desired: Record<string, string> = {};
126
+ for (const declaration of sorted) desired[declaration.package] = declaration.version;
127
+
128
+ const [current, installedBefore] = await Promise.all([
129
+ readWorkspaceDependencies(workspaceRoot),
130
+ Promise.all(
131
+ sorted.map((declaration) =>
132
+ readInstalledVersion(pluginPackageRoot(companionPath, declaration.package)),
133
+ ),
134
+ ),
135
+ ]);
136
+
137
+ const latestPackages = sorted
138
+ .filter((declaration) => declaration.version === "latest")
139
+ .map((declaration) => declaration.package);
140
+ const manifestMatches = JSON.stringify(desired) === JSON.stringify(current);
141
+ const allInstalled = installedBefore.every((version) => version !== null);
142
+ const unchanged = latestPackages.length === 0 && manifestMatches && allInstalled;
143
+
144
+ if (unchanged) {
145
+ return sorted.map((declaration, index) => ({
146
+ package: declaration.package,
147
+ status: "unchanged",
148
+ resolvedVersion: installedBefore[index] ?? undefined,
149
+ }));
126
150
  }
127
151
 
128
- // Pins mirror the declaration list; undeclared packages drop out.
129
- if (JSON.stringify(nextPins) !== JSON.stringify(previousPins)) {
130
- await pinStore.save({ plugins: nextPins });
152
+ await writeWorkspaceManifest(workspaceRoot, desired);
153
+ const installOutcome = await runNpmInstall(workspaceRoot);
154
+ const installFailure = installOutcome.ok
155
+ ? undefined
156
+ : (installOutcome.detail ?? "npm install failed");
157
+
158
+ let updateFailure: string | undefined;
159
+ if (latestPackages.length > 0) {
160
+ const updateOutcome = await runNpmUpdate(workspaceRoot, latestPackages);
161
+ updateFailure = updateOutcome.ok ? undefined : (updateOutcome.detail ?? "npm update failed");
131
162
  }
132
- return results;
133
- }
134
163
 
135
- async function installOne(
136
- installDir: string,
137
- packageRoot: string,
138
- declaration: PluginDeclaration,
139
- runBunInstall: BunInstallRunner,
140
- ): Promise<PluginInstallResult> {
141
- try {
142
- // Fresh workspace per (re)install so `latest` and edited declarations
143
- // actually re-resolve instead of reusing a stale lockfile.
144
- await fs.rm(installDir, { recursive: true, force: true });
145
- await fs.mkdir(installDir, { recursive: true });
146
- await fs.writeFile(
147
- path.join(installDir, "package.json"),
148
- JSON.stringify(
149
- { private: true, dependencies: { [declaration.package]: declaration.version } },
150
- null,
151
- 2,
152
- ) + "\n",
153
- "utf8",
154
- );
155
- const outcome = await runBunInstall(installDir);
156
- if (!outcome.ok) {
157
- throw new Error(outcome.detail ?? "bun install failed");
164
+ const resolvedVersions = await Promise.all(
165
+ sorted.map((declaration) =>
166
+ readInstalledVersion(pluginPackageRoot(companionPath, declaration.package)),
167
+ ),
168
+ );
169
+
170
+ return sorted.map((declaration, index) => {
171
+ const isLatest = declaration.version === "latest";
172
+ const failure = installFailure ?? (isLatest ? updateFailure : undefined);
173
+ if (failure) {
174
+ return { package: declaration.package, status: "failed", error: failure };
158
175
  }
159
- const resolvedVersion = await readInstalledVersion(packageRoot);
176
+ const resolvedVersion = resolvedVersions[index];
160
177
  if (!resolvedVersion) {
161
- throw new Error(`installed tree is missing ${declaration.package}/package.json`);
178
+ return {
179
+ package: declaration.package,
180
+ status: "failed",
181
+ error: `installed tree is missing ${declaration.package}/package.json`,
182
+ };
162
183
  }
163
184
  return { package: declaration.package, status: "installed", resolvedVersion };
164
- } catch (error) {
165
- return {
166
- package: declaration.package,
167
- status: "failed",
168
- error: error instanceof Error ? error.message : String(error),
169
- };
170
- }
185
+ });
171
186
  }
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { pathToFileURL } from "node:url";
4
5
 
5
- import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
6
+ import { FRAMEWORK_NAME } from "../../../framework";
6
7
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
7
8
  import type { Plugin } from "../plugin";
8
9
  import { resolveEffectivePluginConfig } from "./config-resolution";
@@ -12,14 +13,9 @@ import {
12
13
  type CreatePlugin,
13
14
  type PluginHost,
14
15
  } from "./host";
15
- import { pluginPackageRoot } from "./paths";
16
- import type { PluginPin } from "./pin-store";
16
+ import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
17
17
 
18
18
  interface PluginManifest {
19
- version?: string;
20
- main?: string;
21
- module?: string;
22
- exports?: unknown;
23
19
  mate?: { pluginApiVersion?: unknown };
24
20
  }
25
21
 
@@ -35,38 +31,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
35
31
  return typeof value === "object" && value !== null && !Array.isArray(value);
36
32
  }
37
33
 
38
- function commandName(): string {
39
- try {
40
- return frameworkCommandName();
41
- } catch {
42
- return FRAMEWORK_NAME;
43
- }
44
- }
45
-
46
- function resolveEntryFromExports(exportsField: unknown): string | undefined {
47
- if (typeof exportsField === "string") return exportsField;
48
- if (!isRecord(exportsField)) return undefined;
49
- const dot = "." in exportsField ? exportsField["."] : exportsField;
50
- if (typeof dot === "string") return dot;
51
- if (!isRecord(dot)) return undefined;
52
- for (const condition of ["bun", "import", "default", "require"]) {
53
- const candidate = dot[condition];
54
- if (typeof candidate === "string") return candidate;
55
- if (isRecord(candidate) && typeof candidate.default === "string") return candidate.default;
56
- }
57
- return undefined;
58
- }
59
-
60
34
  /**
61
- * Loads one declared plugin from its installed tree: pin verification, API
62
- * version gate before import, dynamic import, factory resolution, effective
63
- * config, factory invocation. Every failure class returns a single warning
64
- * instead of throwing, so one broken plugin never takes down the CLI.
35
+ * Loads one declared plugin from the shared workspace: installed-presence
36
+ * check, API version gate before import, entry-point resolution via the
37
+ * shared workspace's own module resolver, dynamic import, factory
38
+ * resolution, effective config, factory invocation. Every failure class
39
+ * returns a single warning instead of throwing, so one broken plugin never
40
+ * takes down the CLI.
65
41
  */
66
42
  export async function loadDynamicPlugin(
67
43
  companionPath: string,
68
44
  declaration: PluginDeclaration,
69
- pins: PluginPin[],
70
45
  deps: DynamicPluginLoadDeps = {},
71
46
  ): Promise<DynamicPluginLoadResult> {
72
47
  const name = declaration.package;
@@ -80,27 +55,7 @@ export async function loadDynamicPlugin(
80
55
  } catch {
81
56
  return {
82
57
  ok: false,
83
- warning: `plugin "${name}" is not installed; run \`${commandName()} install\`.`,
84
- };
85
- }
86
-
87
- const pin = pins.find((candidate) => candidate.package === name);
88
- if (!pin) {
89
- return {
90
- ok: false,
91
- warning: `plugin "${name}" has no recorded pin; run \`${commandName()} install\`.`,
92
- };
93
- }
94
- if (pin.declaredVersion !== declaration.version) {
95
- return {
96
- ok: false,
97
- warning: `plugin "${name}" declares version "${declaration.version}" but was pinned from "${pin.declaredVersion}"; run \`${commandName()} install\`.`,
98
- };
99
- }
100
- if (manifest.version !== pin.resolvedVersion) {
101
- return {
102
- ok: false,
103
- warning: `plugin "${name}" has version ${manifest.version ?? "unknown"} installed but ${pin.resolvedVersion} pinned; run \`${commandName()} install\`.`,
58
+ warning: `plugin "${name}" is not installed; run \`${FRAMEWORK_NAME} install\`.`,
104
59
  };
105
60
  }
106
61
 
@@ -121,9 +76,19 @@ export async function loadDynamicPlugin(
121
76
  };
122
77
  }
123
78
 
124
- const entryRelative =
125
- resolveEntryFromExports(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js";
126
- const entryPath = path.resolve(packageRoot, entryRelative);
79
+ let entryPath: string;
80
+ try {
81
+ const workspaceRequire = createRequire(
82
+ pathToFileURL(path.join(dynamicPluginsWorkspaceRoot(companionPath), "package.json")),
83
+ );
84
+ entryPath = workspaceRequire.resolve(name);
85
+ } catch (error) {
86
+ return {
87
+ ok: false,
88
+ warning: `plugin "${name}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
89
+ };
90
+ }
91
+
127
92
  const importModule =
128
93
  deps.importModule ??
129
94
  ((specifier: string) => import(specifier) as Promise<Record<string, unknown>>);
@@ -2,34 +2,20 @@ import path from "node:path";
2
2
 
3
3
  import { FRAMEWORK_NAME } from "../../../framework";
4
4
 
5
- /** Flattens an npm package name into a single directory segment. */
6
- export function sanitizePluginDirName(packageName: string): string {
7
- return packageName.replace(/^@/, "").replace(/\//g, "-");
5
+ /** Shared workspace root: one package.json + node_modules for every declared plugin. */
6
+ export function dynamicPluginsWorkspaceRoot(companionPath: string): string {
7
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "plugins");
8
8
  }
9
9
 
10
- export function dynamicPluginsRoot(companionPath: string): string {
11
- return path.join(companionPath, `.${FRAMEWORK_NAME}`, "dependencies", "plugins");
12
- }
13
-
14
- /** Per-plugin install workspace holding a private package.json and node_modules. */
15
- export function pluginInstallDir(companionPath: string, packageName: string): string {
16
- return path.join(dynamicPluginsRoot(companionPath), sanitizePluginDirName(packageName));
17
- }
18
-
19
- /** Root of the installed plugin package itself. */
10
+ /** Root of one installed plugin package inside the shared workspace's node_modules. */
20
11
  export function pluginPackageRoot(companionPath: string, packageName: string): string {
21
12
  return path.join(
22
- pluginInstallDir(companionPath, packageName),
13
+ dynamicPluginsWorkspaceRoot(companionPath),
23
14
  "node_modules",
24
15
  ...packageName.split("/"),
25
16
  );
26
17
  }
27
18
 
28
- /** Committed pin file recording resolved plugin versions. */
29
- export function pluginPinFilePath(companionPath: string): string {
30
- return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "plugins.lock.yaml");
31
- }
32
-
33
19
  /** Gitignored per-machine override file deep-merged over committed plugin config. */
34
20
  export function pluginLocalOverridesPath(companionPath: string): string {
35
21
  return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "plugins.local.yaml");
@@ -37,3 +23,6 @@ export function pluginLocalOverridesPath(companionPath: string): string {
37
23
 
38
24
  /** Companion-relative gitignore entry for the local override file. */
39
25
  export const PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY = `.${FRAMEWORK_NAME}/config/plugins.local.yaml`;
26
+
27
+ /** Companion-relative gitignore entry for the shared workspace's local, never-committed registry credentials. */
28
+ export const PLUGIN_WORKSPACE_NPMRC_GITIGNORE_ENTRY = `.${FRAMEWORK_NAME}/plugins/.npmrc`;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A generic, actionable hint printed on install failure — never guesses at
3
+ * the actual registry URL/host. Private registries stay a plain, standard
4
+ * npm operation: a one-time `npm config set --global`, or (since the shared
5
+ * plugin workspace is a real npm project) a project-local, gitignored
6
+ * `.npmrc` dropped directly in it — either is picked up by `npm install`
7
+ * with nothing Mate-injected.
8
+ */
9
+ export function registryConfigHint(packageName: string): string {
10
+ const scope = packageName.startsWith("@") ? packageName.split("/")[0] : undefined;
11
+ return scope
12
+ ? `If "${scope}" lives in a private registry, configure it once with:\n npm config set "${scope}:registry" "<registry-url>" --global\n npm config set "//<registry-host>/:_authToken" "<token>" --global`
13
+ : `If this package lives in a private registry, configure it once via \`npm config set\` (see the npm docs for scoped registries).`;
14
+ }
@@ -2,7 +2,15 @@ import { getActiveDistribution } from "../../distribution";
2
2
  import { FRAMEWORK_NAME } from "../../framework";
3
3
  import type { FrameworkConfig } from "../../lib/orchestrator/types";
4
4
  import { collectHostingProviders, ContextServiceMediator } from "./context-services";
5
- import type { CapabilityPlugin, Plugin, PluginRegistration, SetupContext } from "./plugin";
5
+ import type {
6
+ CapabilityContributionInput,
7
+ CapabilityPlugin,
8
+ Plugin,
9
+ PluginRegistration,
10
+ SetupContext,
11
+ } from "./plugin";
12
+ import { reconcileClaudeContributions } from "./providers/claude";
13
+ import { reconcileOpenCodeContributions } from "./providers/opencode";
6
14
  import { normalizeRegistration, type NormalizedRegistration } from "./registry";
7
15
 
8
16
  export interface SetupInstallationPlanAction {
@@ -151,5 +159,61 @@ export async function executeSetupInstallationPlan(
151
159
 
152
160
  await mediator.finalize();
153
161
 
162
+ await reconcileCapabilityContributions(ctx, plugins, plan);
163
+
154
164
  return { executedActions, skippedActions, warnings };
155
165
  }
166
+
167
+ // The Runtime Surface of each active Agent Runtime, by runtime id.
168
+ const RUNTIME_SURFACE_RECONCILERS: Record<
169
+ string,
170
+ (ctx: SetupContext, inputs: CapabilityContributionInput[]) => Promise<void>
171
+ > = {
172
+ claude: reconcileClaudeContributions,
173
+ opencode: reconcileOpenCodeContributions,
174
+ };
175
+
176
+ /**
177
+ * Reconcile declared Capability contributions through every active runtime's
178
+ * Runtime Surface (spec: plugin-engine). Runs after all plugin phases, so
179
+ * providers are always done first. Disabled capabilities participate with
180
+ * `enabled: false` and drive teardown of their managed entries.
181
+ */
182
+ async function reconcileCapabilityContributions(
183
+ ctx: SetupContext,
184
+ plugins: Plugin[],
185
+ plan: SetupInstallationPlan,
186
+ ): Promise<void> {
187
+ // The plan already resolved enablement (policy, saved selection, package
188
+ // manager requirements); mirror it instead of recomputing.
189
+ const enabledByPluginId = new Map(
190
+ plan.actions
191
+ .filter((action) => action.phase === "capability" && action.providerId === undefined)
192
+ .map((action) => [action.pluginId, action.action === "apply"]),
193
+ );
194
+
195
+ const inputsByRuntime = new Map<string, CapabilityContributionInput[]>();
196
+ for (const plugin of plugins) {
197
+ if (plugin.kind !== "capability") continue;
198
+ const capability = plugin as CapabilityPlugin;
199
+ if (!capability.getRuntimeContributions) continue;
200
+ const enabled = enabledByPluginId.get(capability.id) ?? false;
201
+ const byRuntime = capability.getRuntimeContributions(ctx);
202
+ for (const [runtimeId, contributions] of Object.entries(byRuntime)) {
203
+ if (!contributions) continue;
204
+ // Inactive runtimes still reconcile — with everything disabled — so a
205
+ // deselected runtime's managed entries (skill trees, guidance blocks)
206
+ // are torn down. The surfaces never create files for disabled inputs.
207
+ const runtimeActive = plan.activeProviders.includes(runtimeId);
208
+ const inputs = inputsByRuntime.get(runtimeId) ?? [];
209
+ inputs.push({ pluginId: capability.id, enabled: enabled && runtimeActive, contributions });
210
+ inputsByRuntime.set(runtimeId, inputs);
211
+ }
212
+ }
213
+
214
+ for (const [runtimeId, inputs] of inputsByRuntime) {
215
+ const reconcile = RUNTIME_SURFACE_RECONCILERS[runtimeId];
216
+ if (!reconcile || inputs.length === 0) continue;
217
+ await reconcile(ctx, inputs);
218
+ }
219
+ }
@@ -1,13 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { frameworkCommandName } from "../../framework";
5
4
  import { pruneEmptyAncestors } from "./utils";
6
5
 
7
6
  export const MATE_ARTIFACT_SKILLS = ["mate-artifact-finish"] as const;
8
7
  export const MATE_SKILLS = ["mate-artifact-finish", "mate-create-report"] as const;
9
8
 
10
- const MATE_COMMAND_PLACEHOLDER = "{{MATE_COMMAND}}";
11
9
  const MATE_SKILLS_SOURCE = path.join(
12
10
  import.meta.dirname,
13
11
  "../../templates/capabilities/openspec-cap/mate-skills",
@@ -16,9 +14,9 @@ const MATE_SKILLS_SOURCE = path.join(
16
14
  const MATE_CREATE_REPORT_SKILL = `---
17
15
  name: mate-create-report
18
16
  description: Create a browser-first Mate report from an explicit structured ReportDocument. Use when a skill needs to present supplied metrics, tables, statuses, metadata, or narrative text.
19
- allowed-tools: Bash({{MATE_COMMAND}}:*)
17
+ allowed-tools: Bash(mate:*)
20
18
  license: MIT
21
- compatibility: Requires the {{MATE_COMMAND}} CLI and the OpenSpec capability enabled.
19
+ compatibility: Requires the mate CLI and the OpenSpec capability enabled.
22
20
  metadata:
23
21
  author: mate
24
22
  version: "1.0"
@@ -57,22 +55,14 @@ Supported section types are \`metadata\`, \`metrics\`, \`key-value\`, \`table\`,
57
55
  ## Invocation
58
56
 
59
57
  1. Serialize the complete document to a temporary file or pipe it to stdin.
60
- 2. Run \`{{MATE_COMMAND}} report --input <file-or->\`.
58
+ 2. Run \`mate report --input <file-or->\`.
61
59
  3. Add \`--json\` when the caller needs normalized JSON instead of browser delivery.
62
60
 
63
61
  The default path writes self-contained HTML to a unique OS temporary directory and opens it in the default browser. The report includes a visible "Print / Save as PDF" control that calls the browser's native print dialog. If HTML delivery fails, the CLI warns on stderr and emits the complete report document as JSON on stdout.
64
62
 
65
- The built-in \`{{MATE_COMMAND}} report\` path collects Mate usage data and adapts it to the same contract and renderer.
63
+ The built-in \`mate report\` path collects Mate usage data and adapts it to the same contract and renderer.
66
64
  `;
67
65
 
68
- function currentMateCommand(): string {
69
- try {
70
- return frameworkCommandName();
71
- } catch {
72
- return "mate";
73
- }
74
- }
75
-
76
66
  export async function deployMateSkillDir(src: string, dest: string): Promise<void> {
77
67
  await fs.mkdir(dest, { recursive: true });
78
68
  const entries = await fs.readdir(src, { withFileTypes: true });
@@ -83,31 +73,38 @@ export async function deployMateSkillDir(src: string, dest: string): Promise<voi
83
73
  await deployMateSkillDir(srcPath, destPath);
84
74
  continue;
85
75
  }
86
- const content = await fs.readFile(srcPath, "utf8");
87
- if (content.includes(MATE_COMMAND_PLACEHOLDER)) {
88
- await fs.writeFile(
89
- destPath,
90
- content.replaceAll(MATE_COMMAND_PLACEHOLDER, currentMateCommand()),
91
- "utf8",
92
- );
93
- } else {
94
- await fs.copyFile(srcPath, destPath);
95
- }
76
+ await fs.copyFile(srcPath, destPath);
77
+ }
78
+ }
79
+
80
+ const DEFAULT_MATE_SKILLS_BUCKET = "agents";
81
+
82
+ /**
83
+ * A skill's source lives under a provider bucket so its folder name always
84
+ * matches its `name:` frontmatter: `<tool>/<skill>/` when that tool needs its
85
+ * own behavior (e.g. Claude Code always confirms before `mate artifact
86
+ * finish` pushes, since a push is a shared-state action), falling back to
87
+ * `agents/<skill>/` — the shared default every other tool (e.g. opencode,
88
+ * which pushes automatically) uses.
89
+ */
90
+ async function resolveMateSkillSource(skill: string, tool: string): Promise<string> {
91
+ const providerDir = path.join(MATE_SKILLS_SOURCE, tool, skill);
92
+ try {
93
+ await fs.access(providerDir);
94
+ return providerDir;
95
+ } catch {
96
+ return path.join(MATE_SKILLS_SOURCE, DEFAULT_MATE_SKILLS_BUCKET, skill);
96
97
  }
97
98
  }
98
99
 
99
- export async function applyMateSkills(skillsDir: string): Promise<void> {
100
+ export async function applyMateSkills(skillsDir: string, tool: string): Promise<void> {
100
101
  for (const skill of MATE_SKILLS) {
101
102
  const destination = path.join(skillsDir, skill);
102
103
  if (skill === "mate-create-report") {
103
104
  await fs.mkdir(destination, { recursive: true });
104
- await fs.writeFile(
105
- path.join(destination, "SKILL.md"),
106
- MATE_CREATE_REPORT_SKILL.replaceAll(MATE_COMMAND_PLACEHOLDER, currentMateCommand()),
107
- "utf8",
108
- );
105
+ await fs.writeFile(path.join(destination, "SKILL.md"), MATE_CREATE_REPORT_SKILL, "utf8");
109
106
  } else {
110
- await deployMateSkillDir(path.join(MATE_SKILLS_SOURCE, skill), destination);
107
+ await deployMateSkillDir(await resolveMateSkillSource(skill, tool), destination);
111
108
  }
112
109
  }
113
110
  }