@uniqbit/mate-core 0.17.0-canary.11 → 0.17.0-canary.14

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 (46) hide show
  1. package/package.json +6 -5
  2. package/src/cli/commands/artifact/pending/index.ts +0 -1
  3. package/src/cli/commands/cap/index-cmd.ts +1 -1
  4. package/src/cli/commands/companion/companion.ts +4 -0
  5. package/src/cli/commands/companion/update.ts +334 -0
  6. package/src/cli/commands/install.ts +59 -5
  7. package/src/cli/commands/plugin/plugin.ts +4 -0
  8. package/src/cli/commands/plugin/verify.ts +25 -0
  9. package/src/cli/commands/studio/access.ts +0 -2
  10. package/src/cli/commands/studio/selection.ts +128 -12
  11. package/src/cli/commands/studio/server.ts +75 -47
  12. package/src/cli/commands/studio/vault.ts +319 -69
  13. package/src/cli/commands/studio/views/client.ts +90 -9
  14. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  15. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  16. package/src/cli/commands/studio/views/document.tsx +403 -109
  17. package/src/cli/commands/studio/views/model.ts +4 -1
  18. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  19. package/src/cli/commands/studio/views/styles.ts +103 -21
  20. package/src/cli/commands/studio/views/terminal.tsx +227 -33
  21. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  22. package/src/cli/commands/update.ts +5 -2
  23. package/src/cli/main.ts +2 -1
  24. package/src/cli/usage.ts +3 -1
  25. package/src/lib/opencode-plugin-package.ts +3 -0
  26. package/src/lib/orchestrator/adapters/opencode.ts +39 -12
  27. package/src/lib/orchestrator/engine-guard.ts +9 -1
  28. package/src/opencode/companion-hooks.ts +131 -153
  29. package/src/opencode/index.ts +12 -6
  30. package/src/opencode/tui.tsx +46 -49
  31. package/src/templates/providers/opencode/opencode.json +6 -4
  32. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +489 -408
  33. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  34. package/src/tools/setup/capabilities/tokensave.ts +96 -9
  35. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  36. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  37. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  38. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  39. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  40. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  41. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  42. package/src/tools/setup/providers/opencode-format.ts +220 -25
  43. package/src/tools/setup/providers/opencode.ts +60 -19
  44. package/src/tools/setup/runtime-documents.ts +8 -4
  45. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  46. package/src/templates/providers/opencode/tui.json +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.17.0-canary.11",
3
+ "version": "0.17.0-canary.14",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -16,6 +16,7 @@
16
16
  "./plugins": "./src/plugins.ts",
17
17
  "./runtime": "./src/runtime/index.ts",
18
18
  "./opencode": "./src/opencode/index.ts",
19
+ "./opencode/tui": "./src/opencode/tui.tsx",
19
20
  "./tui": "./src/tui.ts"
20
21
  },
21
22
  "publishConfig": {
@@ -31,10 +32,10 @@
31
32
  "format:check": "oxfmt src/ \"!**/*.generated.*\" \"!**/report-assets/**\" --check"
32
33
  },
33
34
  "dependencies": {
34
- "@opencode-ai/plugin": "^1.18.3",
35
- "@opentui/core": "^0.4.5",
36
- "@opentui/keymap": "^0.4.5",
37
- "@opentui/solid": "^0.4.5",
35
+ "@opencode/plugin": "^2.0.23",
36
+ "@opentui/core": "^0.5.14",
37
+ "@opentui/keymap": "^0.5.14",
38
+ "@opentui/solid": "^0.5.14",
38
39
  "@tanstack/highlight": "0.0.10",
39
40
  "@xterm/addon-clipboard": "^0.2.0",
40
41
  "@xterm/addon-fit": "^0.11.0",
@@ -1,2 +1 @@
1
1
  export { runArtifactPendingCommand } from "./command";
2
- export type { PendingCommandDeps, PendingResult } from "./command";
@@ -159,7 +159,7 @@ export async function runIndexCapCommand(args: string[], deps: SyncCapDeps = {})
159
159
  const repoPath = process.env.MATE_REPO_PATH ?? process.cwd();
160
160
  if (!(await isTokensaveInitialized())) {
161
161
  await ensureStoreExcluded(repoPath);
162
- const initOk = await runCapStep(() => runTokensave(["init"]));
162
+ const initOk = await runCapStep(() => runTokensave(["init", "--no-git-hook"]));
163
163
  if (!initOk) {
164
164
  failed = true;
165
165
  onStepDone("tokensave", false);
@@ -5,6 +5,7 @@ import { runCompanionPrepareCommand } from "./prepare";
5
5
  import { runSetupCommand } from "../setup";
6
6
  import { runCompanionListCommand } from "./list";
7
7
  import { runCompanionSyncCommand } from "./sync";
8
+ import { runCompanionUpdateCommand } from "./update";
8
9
  import { runWorkspaceOpenCommand } from "./open";
9
10
  import { runCompanionTuiCommand } from "./tui";
10
11
 
@@ -31,6 +32,9 @@ export async function runCompanionCommand(
31
32
  case "sync":
32
33
  await runCompanionSyncCommand();
33
34
  return;
35
+ case "update":
36
+ await runCompanionUpdateCommand(argv);
37
+ return;
34
38
  case "open":
35
39
  await runWorkspaceOpenCommand();
36
40
  return;
@@ -0,0 +1,334 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { FRAMEWORK_NAME } from "../../../framework";
6
+ import { ConfigStore } from "../../../lib/orchestrator/config-store";
7
+ import type { FrameworkConfig } from "../../../lib/orchestrator/types";
8
+ import { YamlFileStore } from "../../../lib/orchestrator/yaml-file-store";
9
+ import {
10
+ fetchLatestVersion,
11
+ getCurrentVersion,
12
+ isCanaryVersion,
13
+ isNewer,
14
+ } from "../../../lib/update-checker";
15
+ import { resolveCompanionRuntime } from "../../../runtime/env";
16
+ import { syncCompanionFiles } from "../../../tools/setup";
17
+ import { runCommand } from "../../../tools/setup/utils";
18
+ import { confirm } from "../../confirm";
19
+ import { defaultGitOps, type GitOps } from "../artifact/finish/git";
20
+ import { resolveCompanionPath } from "./tui";
21
+
22
+ /** Snapshot marker for a changed path that no longer exists on disk. */
23
+ export const DELETED = "<deleted>";
24
+
25
+ const SKILLS_LOCKFILE = "skills-lock.json";
26
+
27
+ /**
28
+ * OpenSpec CLI output (skills and opsx commands): fully regenerated by every update, so
29
+ * pre-existing uncommitted edits there are stale output, never local work worth separating.
30
+ */
31
+ const OPENSPEC_GENERATED = [
32
+ /^\.(claude|opencode)\/skills\/openspec-[^/]+(\/|$)/,
33
+ /^\.claude\/commands\/opsx(\/|$)/,
34
+ /^\.opencode\/commands\/opsx-[^/]+$/,
35
+ ];
36
+
37
+ export function isOpenSpecGenerated(relativePath: string): boolean {
38
+ return OPENSPEC_GENERATED.some((pattern) => pattern.test(relativePath));
39
+ }
40
+
41
+ function frameworkConfigPath(companionPath: string): string {
42
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
43
+ }
44
+
45
+ /**
46
+ * Raw view of `framework.yaml`: `ConfigStore.load()` merges defaults, so saving
47
+ * its result would add keys the companion never declared.
48
+ */
49
+ class RawFrameworkConfigStore extends YamlFileStore<Record<string, unknown>> {
50
+ protected onMissing(): Promise<Record<string, unknown>> {
51
+ return Promise.resolve({});
52
+ }
53
+ }
54
+
55
+ async function hashTree(absolute: string, hash: crypto.Hash, relative: string): Promise<void> {
56
+ const stat = await fs.lstat(absolute);
57
+ if (stat.isDirectory()) {
58
+ const entries = (await fs.readdir(absolute)).toSorted();
59
+ for (const entry of entries) {
60
+ await hashTree(path.join(absolute, entry), hash, path.posix.join(relative, entry));
61
+ }
62
+ return;
63
+ }
64
+ hash.update(`${relative}\0`);
65
+ hash.update(stat.isSymbolicLink() ? await fs.readlink(absolute) : await fs.readFile(absolute));
66
+ hash.update("\0");
67
+ }
68
+
69
+ /** Porcelain collapses an untracked directory into one `dir/` entry, so directories hash their whole tree. */
70
+ async function snapshotPath(companionPath: string, relativePath: string): Promise<string> {
71
+ const absolute = path.join(companionPath, relativePath);
72
+ try {
73
+ const hash = crypto.createHash("sha256");
74
+ await hashTree(absolute, hash, ".");
75
+ return hash.digest("hex");
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return DELETED;
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export interface CompanionUpdateDeps {
83
+ resolveCompanionPath: typeof resolveCompanionPath;
84
+ resolveProjectedCompanionPath: (cwd: string) => string | undefined;
85
+ getCurrentVersion: () => string;
86
+ fetchLatestVersion: () => Promise<string>;
87
+ loadConfig: (companionPath: string) => Promise<FrameworkConfig>;
88
+ syncCompanionFiles: (companionPath: string, config: FrameworkConfig) => Promise<void>;
89
+ /** Returns true when the file was rewritten. */
90
+ writeEngineFloor: (companionPath: string, range: string) => Promise<boolean>;
91
+ hasSkillsLock: (companionPath: string) => Promise<boolean>;
92
+ runSkillsUpdate: (companionPath: string) => Promise<void>;
93
+ createGitOps: (companionPath: string) => GitOps;
94
+ snapshotPath: (companionPath: string, relativePath: string) => Promise<string>;
95
+ confirm: (question: string) => Promise<boolean>;
96
+ isInteractive: () => boolean;
97
+ }
98
+
99
+ export const companionUpdateDeps: CompanionUpdateDeps = {
100
+ resolveCompanionPath,
101
+ resolveProjectedCompanionPath: (cwd) =>
102
+ resolveCompanionRuntime(process.env, cwd).context.companionPath || undefined,
103
+ getCurrentVersion,
104
+ fetchLatestVersion,
105
+ loadConfig: (companionPath) => new ConfigStore(frameworkConfigPath(companionPath)).load(),
106
+ syncCompanionFiles,
107
+ async writeEngineFloor(companionPath, range) {
108
+ const store = new RawFrameworkConfigStore(frameworkConfigPath(companionPath));
109
+ const raw = (await store.load()) ?? {};
110
+ const engines = (raw.engines ?? {}) as Record<string, string>;
111
+ if (engines[FRAMEWORK_NAME] === range) return false;
112
+ await store.save({ ...raw, engines: { ...engines, [FRAMEWORK_NAME]: range } });
113
+ return true;
114
+ },
115
+ hasSkillsLock: (companionPath) =>
116
+ fs
117
+ .access(path.join(companionPath, SKILLS_LOCKFILE))
118
+ .then(() => true)
119
+ .catch(() => false),
120
+ runSkillsUpdate: (companionPath) =>
121
+ runCommand("npx", ["skills", "update", "-p", "-y"], { cwd: companionPath }),
122
+ createGitOps: (companionPath) => defaultGitOps(companionPath, undefined),
123
+ snapshotPath,
124
+ confirm,
125
+ isInteractive: () => !!(process.stdin.isTTY && process.stdout.isTTY),
126
+ };
127
+
128
+ export interface UpdateChanges {
129
+ written: string[];
130
+ conflict: string[];
131
+ }
132
+
133
+ /** Content snapshot of every uncommitted path, taken before the update writes. */
134
+ export async function snapshotChangedPaths(
135
+ companionPath: string,
136
+ git: GitOps,
137
+ ): Promise<Map<string, string>> {
138
+ const before = new Map<string, string>();
139
+ for (const changed of await git.changedPaths()) {
140
+ before.set(changed, await companionUpdateDeps.snapshotPath(companionPath, changed));
141
+ }
142
+ return before;
143
+ }
144
+
145
+ /** Paths the update wrote, and the subset that already carried local edits (design D4). */
146
+ export async function diffChangedPaths(
147
+ companionPath: string,
148
+ git: GitOps,
149
+ before: Map<string, string>,
150
+ ): Promise<UpdateChanges> {
151
+ const written: string[] = [];
152
+ const conflict: string[] = [];
153
+ for (const changed of await git.changedPaths()) {
154
+ if (!before.has(changed)) written.push(changed);
155
+ }
156
+ for (const [changed, hash] of before) {
157
+ if ((await companionUpdateDeps.snapshotPath(companionPath, changed)) === hash) continue;
158
+ written.push(changed);
159
+ if (!isOpenSpecGenerated(changed)) conflict.push(changed);
160
+ }
161
+ return { written: written.toSorted(), conflict: conflict.toSorted() };
162
+ }
163
+
164
+ async function resolveCompanion(cwd: string): Promise<string | undefined> {
165
+ const resolved = await companionUpdateDeps.resolveCompanionPath(cwd);
166
+ if (resolved) return resolved;
167
+ return companionUpdateDeps.resolveProjectedCompanionPath(cwd);
168
+ }
169
+
170
+ function fail(message: string): void {
171
+ process.stderr.write(`${FRAMEWORK_NAME}: ${message}\n`);
172
+ process.exitCode = 1;
173
+ }
174
+
175
+ function errorMessage(error: unknown): string {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+
179
+ function listPaths(paths: string[]): string {
180
+ return paths.map((changed) => ` ${changed}`).join("\n");
181
+ }
182
+
183
+ /** Version precondition; returns the running version when the update may proceed. */
184
+ async function checkVersion(): Promise<string | undefined> {
185
+ const current = companionUpdateDeps.getCurrentVersion();
186
+ if (isCanaryVersion(current)) {
187
+ fail(
188
+ `\`${FRAMEWORK_NAME} companion update\` requires a stable ${FRAMEWORK_NAME}; ${current} is a prerelease.`,
189
+ );
190
+ return undefined;
191
+ }
192
+ let latest: string;
193
+ try {
194
+ latest = await companionUpdateDeps.fetchLatestVersion();
195
+ } catch (error) {
196
+ fail(`could not check the registry for the newest version: ${errorMessage(error)}`);
197
+ return undefined;
198
+ }
199
+ if (isNewer(latest, current)) {
200
+ fail(
201
+ [
202
+ `${current} is installed, but the newest stable version is ${latest}.`,
203
+ ` Run \`${FRAMEWORK_NAME} update\` first.`,
204
+ ].join("\n"),
205
+ );
206
+ return undefined;
207
+ }
208
+ return current;
209
+ }
210
+
211
+ async function commitAndPush(
212
+ git: GitOps,
213
+ written: string[],
214
+ version: string,
215
+ yes: boolean,
216
+ ): Promise<void> {
217
+ if (!yes && !companionUpdateDeps.isInteractive()) {
218
+ process.stdout.write(
219
+ `${FRAMEWORK_NAME}: not an interactive shell; changes left uncommitted. Re-run with --yes to commit and push.\n`,
220
+ );
221
+ return;
222
+ }
223
+
224
+ const message = `chore(${FRAMEWORK_NAME}): update companion to ${FRAMEWORK_NAME} ${version}`;
225
+ if (!yes && !(await companionUpdateDeps.confirm(`Commit as "${message}"?`))) {
226
+ process.stdout.write(`${FRAMEWORK_NAME}: changes left uncommitted.\n`);
227
+ return;
228
+ }
229
+ try {
230
+ await git.add(written);
231
+ await git.commit(message, written);
232
+ } catch (error) {
233
+ fail(`commit failed: ${errorMessage(error)}`);
234
+ return;
235
+ }
236
+ process.stdout.write(`${FRAMEWORK_NAME}: committed "${message}".\n`);
237
+
238
+ if (!yes && !(await companionUpdateDeps.confirm("Push the commit?"))) return;
239
+ if (!(await git.hasUpstream())) {
240
+ process.stdout.write(
241
+ `${FRAMEWORK_NAME}: the current branch has no upstream; the commit stays local.\n`,
242
+ );
243
+ return;
244
+ }
245
+ const pushed = await git.push();
246
+ if (!pushed.ok) {
247
+ fail(`push failed; the commit stays local.\n ${pushed.error}`);
248
+ return;
249
+ }
250
+ process.stdout.write(`${FRAMEWORK_NAME}: pushed.\n`);
251
+ }
252
+
253
+ /**
254
+ * @command mate companion update
255
+ * @description Regenerates the companion's Mate-managed files and skills with
256
+ * the newest stable Mate, raises `engines.mate` to match, and offers to commit
257
+ * and push exactly the files the update wrote.
258
+ */
259
+ export async function runCompanionUpdateCommand(argv: string[]): Promise<void> {
260
+ const unknown = argv.filter((arg) => arg !== "--yes");
261
+ if (unknown.length > 0) {
262
+ fail(`unknown argument for companion update: ${unknown.join(" ")}`);
263
+ return;
264
+ }
265
+ const yes = argv.includes("--yes");
266
+
267
+ const companionPath = await resolveCompanion(process.cwd());
268
+ if (!companionPath) {
269
+ fail(
270
+ `no companion resolves for this directory. Run \`${FRAMEWORK_NAME} companion link\` first.`,
271
+ );
272
+ return;
273
+ }
274
+
275
+ const version = await checkVersion();
276
+ if (!version) return;
277
+
278
+ let git: GitOps;
279
+ let before: Map<string, string>;
280
+ try {
281
+ git = companionUpdateDeps.createGitOps(companionPath);
282
+ before = await snapshotChangedPaths(companionPath, git);
283
+ } catch (error) {
284
+ fail(errorMessage(error));
285
+ return;
286
+ }
287
+
288
+ try {
289
+ const config = await companionUpdateDeps.loadConfig(companionPath);
290
+ await companionUpdateDeps.syncCompanionFiles(companionPath, config);
291
+ } catch (error) {
292
+ fail(`companion file synchronization failed: ${errorMessage(error)}`);
293
+ return;
294
+ }
295
+
296
+ if (await companionUpdateDeps.hasSkillsLock(companionPath)) {
297
+ try {
298
+ await companionUpdateDeps.runSkillsUpdate(companionPath);
299
+ } catch (error) {
300
+ process.stderr.write(
301
+ `${FRAMEWORK_NAME}: warning: third-party skill refresh failed: ${errorMessage(error)}\n`,
302
+ );
303
+ }
304
+ }
305
+
306
+ try {
307
+ await companionUpdateDeps.writeEngineFloor(companionPath, `>=${version}`);
308
+ } catch (error) {
309
+ fail(`could not set engines.${FRAMEWORK_NAME}: ${errorMessage(error)}`);
310
+ return;
311
+ }
312
+
313
+ const { written, conflict } = await diffChangedPaths(companionPath, git, before);
314
+ if (written.length === 0) {
315
+ process.stdout.write(
316
+ `${FRAMEWORK_NAME}: ${companionPath} is already up to date with ${FRAMEWORK_NAME} ${version}.\n`,
317
+ );
318
+ return;
319
+ }
320
+ if (conflict.length > 0) {
321
+ fail(
322
+ [
323
+ "the update rewrote files that already had uncommitted changes; local edits and update output can no longer be separated, so nothing is committed:",
324
+ listPaths(conflict),
325
+ ].join("\n"),
326
+ );
327
+ return;
328
+ }
329
+
330
+ process.stdout.write(
331
+ [`${FRAMEWORK_NAME}: the update changed ${companionPath}:`, listPaths(written), ""].join("\n"),
332
+ );
333
+ await commitAndPush(git, written, version, yes);
334
+ }
@@ -15,8 +15,14 @@ import {
15
15
  type PluginInstallResult,
16
16
  } from "../../tools/setup/dynamic-plugins/install";
17
17
  import { registryConfigHint } from "../../tools/setup/dynamic-plugins/registry-hint";
18
+ import {
19
+ FrozenInstallError,
20
+ installDeclaredPluginsFrozen,
21
+ } from "../../tools/setup/dynamic-plugins/frozen";
22
+ import { verifyTrackedPluginOutputs } from "../../tools/setup/dynamic-plugins/staging";
23
+ import { verifyDeclaredPlugins } from "../../tools/setup/dynamic-plugins/verify";
18
24
 
19
- export function reportPluginInstallResults(results: PluginInstallResult[]): void {
25
+ export function reportPluginInstallResults(results: PluginInstallResult[]): boolean {
20
26
  for (const result of results) {
21
27
  if (result.status === "failed") {
22
28
  process.stderr.write(
@@ -27,6 +33,7 @@ export function reportPluginInstallResults(results: PluginInstallResult[]): void
27
33
  console.log(` installed plugin ${result.package}@${result.resolvedVersion}`);
28
34
  }
29
35
  }
36
+ return results.every((result) => result.status !== "failed");
30
37
  }
31
38
 
32
39
  function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): void {
@@ -47,10 +54,17 @@ function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): vo
47
54
  * @description Installs and verifies the core runtime and dependencies selected by the current companion.
48
55
  * @flags
49
56
  * - `--yes` — skip confirmation; required for non-TTY execution.
57
+ * - `--frozen-plugins` — deployment restore: install declared plugins exactly from the committed lockfile, verify plugin-generated tracked files are committed, never rewrite tracked files.
50
58
  */
51
59
  export async function runInstallCommand(argv: string[], cwd = process.cwd()): Promise<boolean> {
52
60
  const skipConfirm = argv.includes("--yes");
61
+ const frozen = argv.includes("--frozen-plugins");
53
62
  const context = await resolveInstallContext(cwd);
63
+ if (frozen && context.kind !== "companion") {
64
+ process.stderr.write(`${FRAMEWORK_NAME}: --frozen-plugins requires a companion context.\n`);
65
+ process.exitCode = 1;
66
+ return false;
67
+ }
54
68
  if (context.kind === "ambiguous") {
55
69
  process.stderr.write(`${context.message}\n`);
56
70
  process.exitCode = 1;
@@ -64,10 +78,35 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
64
78
  context.companionPath &&
65
79
  context.config.plugins?.length
66
80
  ) {
67
- reportPluginInstallResults(
68
- await installDeclaredPlugins(context.companionPath, context.config.plugins),
69
- );
81
+ let results: PluginInstallResult[];
82
+ try {
83
+ results = frozen
84
+ ? await installDeclaredPluginsFrozen(context.companionPath, context.config.plugins)
85
+ : await installDeclaredPlugins(context.companionPath, context.config.plugins);
86
+ } catch (error) {
87
+ if (!(error instanceof FrozenInstallError)) throw error;
88
+ process.stderr.write(`${FRAMEWORK_NAME}: frozen plugin install refused: ${error.message}\n`);
89
+ process.exitCode = 1;
90
+ return false;
91
+ }
92
+ if (!reportPluginInstallResults(results)) {
93
+ process.stderr.write(
94
+ `${FRAMEWORK_NAME}: a declared plugin failed to install; installation is incomplete.\n`,
95
+ );
96
+ process.exitCode = 1;
97
+ return false;
98
+ }
70
99
  await hydrateDynamicPlugins({ companionPath: context.companionPath });
100
+ if (frozen) {
101
+ const failures = await verifyDeclaredPlugins(context.companionPath);
102
+ if (failures.length > 0) {
103
+ for (const failure of failures) {
104
+ process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
105
+ }
106
+ process.exitCode = 1;
107
+ return false;
108
+ }
109
+ }
71
110
  }
72
111
  const plan = await inspectInstallPlan(buildInstallPlan(context));
73
112
  const missing = plan.requirements.filter((item) => !item.satisfied);
@@ -120,7 +159,22 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
120
159
  }
121
160
 
122
161
  try {
123
- await reconcileInstalledCompanion(plan);
162
+ if (frozen && plan.context.companionPath) {
163
+ const { syncCompanionFiles } = await import("../../tools/setup");
164
+ const drift = await verifyTrackedPluginOutputs(plan.context.companionPath, (staged) =>
165
+ syncCompanionFiles(staged, plan.context.config),
166
+ );
167
+ if (drift.length > 0) {
168
+ process.stderr.write(
169
+ `${FRAMEWORK_NAME}: plugin-generated files differ from the committed checkout: ${drift.join(", ")}\n` +
170
+ `Prepare and commit them in the companion's authoring flow; the checkout was left unchanged.\n`,
171
+ );
172
+ process.exitCode = 1;
173
+ return false;
174
+ }
175
+ } else {
176
+ await reconcileInstalledCompanion(plan);
177
+ }
124
178
  await saveCompleteInstallState(plan, execution.results);
125
179
  } catch (error) {
126
180
  process.stderr.write(
@@ -1,5 +1,6 @@
1
1
  import { usage } from "../../usage";
2
2
  import { runPluginInstallCommand } from "./install";
3
+ import { runPluginVerifyCommand } from "./verify";
3
4
 
4
5
  export async function runPluginCommand(
5
6
  subcommand: string | undefined,
@@ -9,6 +10,9 @@ export async function runPluginCommand(
9
10
  case "install":
10
11
  await runPluginInstallCommand(argv);
11
12
  return;
13
+ case "verify":
14
+ await runPluginVerifyCommand();
15
+ return;
12
16
  default:
13
17
  console.error(`Unknown plugin command: ${subcommand ?? ""}`);
14
18
  console.error(usage());
@@ -0,0 +1,25 @@
1
+ import { FRAMEWORK_NAME } from "../../../framework";
2
+ import { resolveInstallContext } from "../../../lib/install";
3
+ import { verifyDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
4
+
5
+ /**
6
+ * @command mate plugin verify
7
+ * @description Strict, installation-free check that every plugin declared by the companion is allowed by `MATE_ALLOWED_PLUGINS`, installed, and loadable with the current environment. Exits non-zero naming each failing package.
8
+ */
9
+ export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boolean> {
10
+ const context = await resolveInstallContext(cwd);
11
+ if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
12
+ process.stderr.write(`${FRAMEWORK_NAME}: \`plugin verify\` requires a companion context.\n`);
13
+ process.exitCode = 1;
14
+ return false;
15
+ }
16
+ const failures = await verifyDeclaredPlugins(context.companionPath);
17
+ for (const failure of failures) {
18
+ process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
19
+ }
20
+ if (failures.length > 0) {
21
+ process.exitCode = 1;
22
+ return false;
23
+ }
24
+ return true;
25
+ }
@@ -19,8 +19,6 @@ export interface StudioAccessOptions {
19
19
  token?: string | null;
20
20
  }
21
21
 
22
- export type StudioRequirement = "none" | "token";
23
-
24
22
  /**
25
23
  * One server's Host, Origin, and token policy. `null` token means the server
26
24
  * is unguarded and issues none.