@uniqbit/mate-core 0.15.1-canary.2 → 0.15.1-canary.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.1-canary.2",
3
+ "version": "0.15.1-canary.3",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -9,6 +9,23 @@ import {
9
9
  resolveInstallContext,
10
10
  } from "../../lib/install";
11
11
  import { renderInstallExecution } from "../install-plan";
12
+ import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
13
+ import {
14
+ installDeclaredPlugins,
15
+ type PluginInstallResult,
16
+ } from "../../tools/setup/dynamic-plugins/install";
17
+
18
+ export function reportPluginInstallResults(results: PluginInstallResult[]): void {
19
+ for (const result of results) {
20
+ if (result.status === "failed") {
21
+ process.stderr.write(
22
+ `${frameworkCommandName()}: plugin "${result.package}" failed to install: ${result.error ?? "unknown error"}\n`,
23
+ );
24
+ } else if (result.status === "installed") {
25
+ console.log(` installed plugin ${result.package}@${result.resolvedVersion}`);
26
+ }
27
+ }
28
+ }
12
29
 
13
30
  function printPlanText(plan: Awaited<ReturnType<typeof inspectInstallPlan>>): void {
14
31
  if (plan.context.message) process.stderr.write(`${plan.context.message}\n\n`);
@@ -37,6 +54,15 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
37
54
  process.exitCode = 1;
38
55
  return false;
39
56
  }
57
+ // Companion-declared plugins install first and hydrate into the registry
58
+ // before the plan is built, so one run goes install → load → plan and their
59
+ // own install requirements are part of the plan.
60
+ if (context.kind === "companion" && context.companionPath && context.config.plugins?.length) {
61
+ reportPluginInstallResults(
62
+ await installDeclaredPlugins(context.companionPath, context.config.plugins),
63
+ );
64
+ await hydrateDynamicPlugins({ companionPath: context.companionPath });
65
+ }
40
66
  const plan = await inspectInstallPlan(buildInstallPlan(context));
41
67
  const missing = plan.requirements.filter((item) => !item.satisfied);
42
68
  let execution: Awaited<ReturnType<typeof renderInstallExecution>>;
package/src/cli/main.ts CHANGED
@@ -18,6 +18,7 @@ import { runUpdateCommand } from "./commands/update";
18
18
  import { runInstallCommand } from "./commands/install";
19
19
  import { inspectInstallPreflight } from "../lib/install";
20
20
  import { ensureUnambiguousCompanion } from "./commands/shared/companion-selection";
21
+ import { hydrateDynamicPlugins } from "../tools/setup/dynamic-plugins/hydrate";
21
22
  import { findPluginCliCommand } from "./plugin-commands";
22
23
  import { usage } from "./usage";
23
24
 
@@ -36,11 +37,13 @@ export function isInstallRecoveryCommand(command?: string, subcommand?: string):
36
37
  export interface MainDeps {
37
38
  ensureUnambiguousCompanion: typeof ensureUnambiguousCompanion;
38
39
  inspectInstallPreflight: typeof inspectInstallPreflight;
40
+ hydrateDynamicPlugins: typeof hydrateDynamicPlugins;
39
41
  }
40
42
 
41
43
  const mainDeps: MainDeps = {
42
44
  ensureUnambiguousCompanion,
43
45
  inspectInstallPreflight,
46
+ hydrateDynamicPlugins,
44
47
  };
45
48
 
46
49
  export async function main(argv = process.argv, deps: MainDeps = mainDeps): Promise<void> {
@@ -51,6 +54,12 @@ export async function main(argv = process.argv, deps: MainDeps = mainDeps): Prom
51
54
  return;
52
55
  }
53
56
 
57
+ // Companion-declared plugins register before cap-command detection so
58
+ // their commands route like compiled-in ones (including MCP servers whose
59
+ // command is their own cap subcommand). Diagnostics stay on stderr; a
60
+ // missing or ambiguous companion makes this a no-op.
61
+ await deps.hydrateDynamicPlugins();
62
+
54
63
  // Plugin commands (`mate cap <namespace> <command>`) own their stdout (an
55
64
  // MCP server speaks JSON-RPC over it), so banners and background chatter
56
65
  // are suppressed for them.
package/src/index.ts CHANGED
@@ -21,6 +21,16 @@ export {
21
21
  type NormalizedRegistration,
22
22
  } from "./tools/setup/registry";
23
23
  export { ensureCapabilityEnabled, type EnsureCapabilityEnabledDeps } from "./cli/plugin-commands";
24
+ // Dynamic plugin authors: import from this package as TYPES ONLY. Runtime
25
+ // imports resolve a second, uninitialized copy of mate-core inside the
26
+ // plugin's own node_modules; all runtime behavior flows through PluginHost.
27
+ export {
28
+ createPluginHost,
29
+ PLUGIN_API_VERSION,
30
+ SUPPORTED_PLUGIN_API_VERSIONS,
31
+ type CreatePlugin,
32
+ type PluginHost,
33
+ } from "./tools/setup/dynamic-plugins/host";
24
34
  export type {
25
35
  CapabilityPlugin,
26
36
  InstructionsService,
@@ -36,4 +46,8 @@ export type {
36
46
  SetupContext,
37
47
  TemplatesService,
38
48
  } from "./tools/setup/plugin";
39
- export type { FrameworkConfig } from "./lib/orchestrator/types";
49
+ export type {
50
+ FrameworkConfig,
51
+ PluginDeclaration,
52
+ PluginDeclarationPolicy,
53
+ } from "./lib/orchestrator/types";
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { FRAMEWORK_NAME } from "../../framework";
4
4
  import { getDefaultSetupSelections } from "./setup-compatibilities";
5
5
  import { YamlFileStore } from "./yaml-file-store";
6
- import { type FrameworkConfig } from "./types";
6
+ import { ConfigError, type FrameworkConfig } from "./types";
7
7
 
8
8
  export const RTK_CAPABILITY_SPLIT_MIGRATION = "rtk-capability-split-v1";
9
9
 
@@ -54,6 +54,23 @@ export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkCon
54
54
  };
55
55
  }
56
56
 
57
+ export const PLUGIN_DECLARATION_POLICIES = ["default", "optional"] as const;
58
+
59
+ /**
60
+ * Validates the `plugins` list of a loaded config. Declared plugins may not
61
+ * claim `required` — required-ness is a distribution prerogative.
62
+ */
63
+ export function validatePluginDeclarations(config: FrameworkConfig): void {
64
+ for (const declaration of config.plugins ?? []) {
65
+ const policy = declaration.policy as string | undefined;
66
+ if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
67
+ throw new ConfigError(
68
+ `plugins entry "${declaration.package}": policy "${policy}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
69
+ );
70
+ }
71
+ }
72
+ }
73
+
57
74
  export class ConfigStore extends YamlFileStore<FrameworkConfig> {
58
75
  constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
59
76
  super(path.resolve(configPath));
@@ -61,6 +78,7 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
61
78
 
62
79
  override async load(): Promise<FrameworkConfig> {
63
80
  const merged = mergeWithDefaults(await super.load());
81
+ validatePluginDeclarations(merged);
64
82
  const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
65
83
  const config = migrateRtkCapabilitySplit(merged);
66
84
  if (needsMigration) await this.save(config);
@@ -81,11 +81,34 @@ export interface CapabilityConfig {
81
81
  */
82
82
  export type EngineConstraints = Record<string, string>;
83
83
 
84
+ /**
85
+ * Registration policy for a companion-declared plugin. `required` is a
86
+ * distribution prerogative and is rejected for declared plugins.
87
+ */
88
+ export type PluginDeclarationPolicy = "default" | "optional";
89
+
90
+ /**
91
+ * A companion-declared npm plugin: installed by setup/install into
92
+ * `.mate/dependencies/plugins/` and loaded on every invocation. Declaration
93
+ * registers the plugin; enablement stays in the `capabilities` list.
94
+ */
95
+ export interface PluginDeclaration {
96
+ /** npm package name (e.g. `@acme/custom-plugin`). */
97
+ package: string;
98
+ /** Exact version, semver range, or the literal `latest`. */
99
+ version: string;
100
+ /** Registration policy; absent means `optional`. */
101
+ policy?: PluginDeclarationPolicy;
102
+ /** Opaque plugin parameters, passed to the plugin factory after resolution. */
103
+ config?: unknown;
104
+ }
105
+
84
106
  export interface FrameworkConfig {
85
107
  type?: "working" | "companion";
86
108
  git?: GitModeProfile;
87
109
  profiles: Record<string, PolicyProfile>;
88
110
  capabilities?: CapabilityConfig[];
111
+ plugins?: PluginDeclaration[];
89
112
  migrations?: string[];
90
113
  cliTools?: CliToolConfig[];
91
114
  packageManagers?: string[];
@@ -20,9 +20,24 @@ export const updateCheckerDeps = {
20
20
  toIsoString: () => new Date().toISOString(),
21
21
  };
22
22
 
23
+ const updateStateFileSlug = (packageName: string): string =>
24
+ packageName.replace(/^@/, "").replace(/[^a-zA-Z0-9._-]+/g, "-");
25
+
26
+ /**
27
+ * Every distribution built on mate-core shares `~/.mate`, but each checks its
28
+ * own update package — so the cached state is scoped per package. A shared
29
+ * file would let the default distribution's public-npm check poison a custom
30
+ * distribution's banner with a version it can never install.
31
+ */
23
32
  export class UpdateStateStore extends YamlFileStore<UpdateState> {
24
- constructor() {
25
- super(path.join(os.homedir(), `.${FRAMEWORK_NAME}`, "update-state.yaml"));
33
+ constructor(packageName: string = getUpdateConfig().packageName) {
34
+ super(
35
+ path.join(
36
+ os.homedir(),
37
+ `.${FRAMEWORK_NAME}`,
38
+ `update-state-${updateStateFileSlug(packageName)}.yaml`,
39
+ ),
40
+ );
26
41
  }
27
42
 
28
43
  protected onMissing(): Promise<UpdateState> {
@@ -16,10 +16,6 @@ const TOKENSAVE_CARGO_INSTALL_CMD = "cargo install tokensave";
16
16
  const TOKENSAVE_SCOOP_INSTALL_CMD =
17
17
  "scoop bucket add tokensave https://github.com/aovestdipaperino/scoop-bucket && scoop install tokensave";
18
18
 
19
- function getTokensaveConfigPath(): string {
20
- return path.join(process.env.HOME ?? "", ".tokensave", "config.toml");
21
- }
22
-
23
19
  interface CompanionOpenCodeSettings {
24
20
  mcp?: Record<string, unknown>;
25
21
  [key: string]: unknown;
@@ -132,47 +128,23 @@ async function tokensaveInstalled(repoPath: string): Promise<boolean> {
132
128
  return result.ok;
133
129
  }
134
130
 
135
- async function readTokensaveConfig(): Promise<string | null> {
136
- try {
137
- return await fs.readFile(getTokensaveConfigPath(), "utf8");
138
- } catch {
139
- return null;
140
- }
141
- }
142
-
143
- async function writeTokensaveConfig(content: string): Promise<void> {
144
- await fs.mkdir(path.dirname(getTokensaveConfigPath()), { recursive: true });
145
- await fs.writeFile(getTokensaveConfigPath(), content, "utf8");
146
- }
147
-
148
- async function updateTokensaveInstalledAgents(providers: string[]): Promise<void> {
149
- const content = await readTokensaveConfig();
150
- if (!content) return;
151
-
131
+ // Global agent integration (MCP entry, session hooks, wildcard permission grant, and
132
+ // tokensave's own config bookkeeping) is owned by tokensave's installer — Mate never
133
+ // hand-edits ~/.tokensave/config.toml. Runs in setup mode only; the installer is
134
+ // idempotent, so re-running on every setup doubles as repair for stale global state.
135
+ function installTokensaveAgentIntegrations(providers: string[], cwd: string): void {
152
136
  const agents = providers.filter((p) => TOKENSAVE_SUPPORTED_AGENTS.has(p)).sort();
153
- if (agents.length === 0) return;
154
-
155
- const existingLine = content.match(/^installed_agents\s*=\s*\[([^\]]*)\]/m);
156
- if (!existingLine) return;
157
-
158
- const existingStr = existingLine[1];
159
- const existing = new Set(
160
- existingStr
161
- .split(",")
162
- .map((s) => s.trim().replace(/^"|"$/g, ""))
163
- .filter(Boolean),
164
- );
165
-
166
137
  for (const agent of agents) {
167
- existing.add(agent);
168
- }
169
-
170
- const sorted = [...existing].sort();
171
- const newValue = `installed_agents = [${sorted.map((a) => `"${a}"`).join(", ")}]`;
172
- const updated = content.replace(existingLine[0], newValue);
173
-
174
- if (updated !== content) {
175
- await writeTokensaveConfig(updated);
138
+ const result = tokensaveDeps.run(
139
+ ["install", "--agent", agent, "--git-hook", "no", "--wildcard-permissions"],
140
+ cwd,
141
+ );
142
+ if (!result.ok) {
143
+ const detail = result.stderr.trim();
144
+ process.stderr.write(
145
+ `tokensave: \`tokensave install --agent ${agent}\` failed${detail ? `: ${detail}` : ""} - continuing without global ${agent} integration\n`,
146
+ );
147
+ }
176
148
  }
177
149
  }
178
150
 
@@ -329,7 +301,9 @@ export function createTokensavePlugin(): CapabilityPlugin {
329
301
  if (!(await ensureTokensaveInstalled(targetPath))) {
330
302
  return;
331
303
  }
332
- await updateTokensaveInstalledAgents(ctx.activeProviders);
304
+ if (ctx.mode === "setup") {
305
+ installTokensaveAgentIntegrations(ctx.activeProviders, targetPath);
306
+ }
333
307
  },
334
308
  async teardown(ctx) {
335
309
  if (!ctx.repoPath) return;
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import { parse } from "yaml";
4
+
5
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
+ import { pluginLocalOverridesPath } from "./paths";
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /**
13
+ * Deep-merges a local override over a committed plugin config: objects merge
14
+ * recursively, arrays and scalars from the override replace the committed
15
+ * value. Either side may be absent.
16
+ */
17
+ export function deepMergePluginConfig(committed: unknown, override: unknown): unknown {
18
+ if (override === undefined) return committed;
19
+ if (!isRecord(committed) || !isRecord(override)) return override;
20
+ const merged: Record<string, unknown> = { ...committed };
21
+ for (const [key, value] of Object.entries(override)) {
22
+ merged[key] = deepMergePluginConfig(committed[key], value);
23
+ }
24
+ return merged;
25
+ }
26
+
27
+ const VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
28
+
29
+ export interface InterpolationResult {
30
+ value: unknown;
31
+ /** Referenced-but-unset variable names, in first-seen order. */
32
+ missing: string[];
33
+ }
34
+
35
+ /**
36
+ * Replaces `${VAR}` occurrences in every string value with the corresponding
37
+ * environment value, recursively through objects and arrays. Unset variables
38
+ * are collected instead of substituted.
39
+ */
40
+ export function interpolateEnvVars(
41
+ value: unknown,
42
+ env: Record<string, string | undefined>,
43
+ ): InterpolationResult {
44
+ const missing: string[] = [];
45
+ const visit = (node: unknown): unknown => {
46
+ if (typeof node === "string") {
47
+ return node.replace(VAR_PATTERN, (placeholder, name: string) => {
48
+ const resolved = env[name];
49
+ if (resolved === undefined) {
50
+ if (!missing.includes(name)) missing.push(name);
51
+ return placeholder;
52
+ }
53
+ return resolved;
54
+ });
55
+ }
56
+ if (Array.isArray(node)) return node.map(visit);
57
+ if (isRecord(node)) {
58
+ return Object.fromEntries(Object.entries(node).map(([key, entry]) => [key, visit(entry)]));
59
+ }
60
+ return node;
61
+ };
62
+ return { value: visit(value), missing };
63
+ }
64
+
65
+ /**
66
+ * Reads the gitignored `.mate/config/plugins.local.yaml`, a `plugins:` map
67
+ * keyed by package name. A missing or malformed file yields no overrides.
68
+ */
69
+ export async function readLocalPluginOverrides(
70
+ companionPath: string,
71
+ ): Promise<Record<string, unknown>> {
72
+ try {
73
+ const raw = await fs.readFile(pluginLocalOverridesPath(companionPath), "utf8");
74
+ const parsed = parse(raw) as unknown;
75
+ if (!isRecord(parsed) || !isRecord(parsed.plugins)) return {};
76
+ return parsed.plugins;
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ export type EffectivePluginConfigResult =
83
+ | { ok: true; config: unknown }
84
+ | { ok: false; missingVariables: string[] };
85
+
86
+ /**
87
+ * Computes the config passed to a plugin factory: committed `config` block,
88
+ * deep-merged with the local override entry for the package, then `${VAR}`
89
+ * interpolated from the environment. Resolved values are never written back.
90
+ */
91
+ export async function resolveEffectivePluginConfig(
92
+ companionPath: string,
93
+ declaration: PluginDeclaration,
94
+ env: Record<string, string | undefined> = process.env,
95
+ ): Promise<EffectivePluginConfigResult> {
96
+ const overrides = await readLocalPluginOverrides(companionPath);
97
+ const merged = deepMergePluginConfig(declaration.config, overrides[declaration.package]);
98
+ const { value, missing } = interpolateEnvVars(merged, env);
99
+ if (missing.length > 0) return { ok: false, missingVariables: missing };
100
+ return { ok: true, config: value };
101
+ }
@@ -0,0 +1,51 @@
1
+ import { getActiveDistribution } from "../../../distribution";
2
+ import { ensureCapabilityEnabled } from "../../../cli/plugin-commands";
3
+ import type { CapabilityPlugin, Plugin } from "../plugin";
4
+
5
+ /** Host API version this core constructs. Grows only on breaking changes. */
6
+ export const PLUGIN_API_VERSION = 1;
7
+
8
+ /** Plugin API versions this core can load. */
9
+ export const SUPPORTED_PLUGIN_API_VERSIONS: readonly number[] = [PLUGIN_API_VERSION];
10
+
11
+ /**
12
+ * The sole runtime surface a dynamic plugin receives into the running core.
13
+ *
14
+ * Dynamic plugin packages must not import runtime values from
15
+ * `@uniqbit/mate-core` — a dynamically imported plugin would resolve a second
16
+ * bundled copy whose module-level state was never initialized. Type-only
17
+ * imports are safe (erased at build); all behavior flows through this host.
18
+ * The surface evolves additively within an `apiVersion`.
19
+ */
20
+ export interface PluginHost {
21
+ apiVersion: typeof PLUGIN_API_VERSION;
22
+ /** Identity of the running distribution. */
23
+ distribution: { name: string; version: string };
24
+ /**
25
+ * Verifies the named capability is enabled for the active companion,
26
+ * reporting guidance and setting a failing exit code when it is not.
27
+ */
28
+ ensureCapabilityEnabled(name: string): Promise<boolean>;
29
+ }
30
+
31
+ /**
32
+ * The factory a dynamic plugin package exports — as the default export, or as
33
+ * a named `createPlugin` export when no default function exists. Called once
34
+ * per invocation with the plugin's effective config (committed block merged
35
+ * with local overrides, env-interpolated). Config validation is the plugin's
36
+ * responsibility: throw with a descriptive message to reject it.
37
+ */
38
+ export type CreatePlugin = (config: unknown, host: PluginHost) => Plugin | CapabilityPlugin;
39
+
40
+ /** Builds the host bound to the running core instance. */
41
+ export function createPluginHost(): PluginHost {
42
+ const distribution = getActiveDistribution();
43
+ return {
44
+ apiVersion: PLUGIN_API_VERSION,
45
+ distribution: {
46
+ name: distribution.config.name,
47
+ version: distribution.config.version,
48
+ },
49
+ ensureCapabilityEnabled: (name) => ensureCapabilityEnabled(name),
50
+ };
51
+ }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { parse } from "yaml";
5
+
6
+ import { getActiveDistribution } from "../../../distribution";
7
+ import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
8
+ import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver";
9
+ 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
+ import type { PluginRegistry } from "../registry";
13
+ import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
14
+ import { PluginPinStore } from "./pin-store";
15
+
16
+ // Packages already registered in this process; re-hydration (e.g. right after
17
+ // an install inside the same run) only picks up plugins that failed before.
18
+ const hydratedPackages = new Set<string>();
19
+
20
+ export function resetDynamicPluginHydration(): void {
21
+ hydratedPackages.clear();
22
+ }
23
+
24
+ export interface HydrateDynamicPluginsDeps extends DynamicPluginLoadDeps {
25
+ cwd?: string;
26
+ /** Explicit companion context; skips quiet resolution. */
27
+ companionPath?: string;
28
+ registry?: PluginRegistry;
29
+ warn?: (message: string) => void;
30
+ }
31
+
32
+ function commandName(): string {
33
+ try {
34
+ return frameworkCommandName();
35
+ } catch {
36
+ return FRAMEWORK_NAME;
37
+ }
38
+ }
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ /**
45
+ * Quiet variant of companion resolution: never prompts, never errors. An
46
+ * ambiguous or absent companion means hydration is a no-op —
47
+ * `ensureUnambiguousCompanion` later in `main()` stays the authoritative,
48
+ * user-facing resolution.
49
+ */
50
+ async function resolveCompanionQuietly(
51
+ cwd: string,
52
+ env: Record<string, string | undefined>,
53
+ ): Promise<string | null> {
54
+ const pinned = env.MATE_ARTIFACT_PATH;
55
+ if (pinned) return path.resolve(pinned);
56
+ const resolver = new CompanionResolver(new GlobalConfigStore());
57
+ const resolution = await resolver.resolveWithDiagnostics(path.resolve(cwd));
58
+ if (resolution.ambiguousMatches.length > 1) return null;
59
+ if (resolution.match) return resolution.match.companionPath;
60
+ try {
61
+ // Running inside a companion directory itself.
62
+ await fs.access(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
63
+ return path.resolve(cwd);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Raw read of the companion's `plugins:` list. Deliberately avoids
71
+ * `ConfigStore.load()` — hydration runs on every invocation and must never
72
+ * create or migrate config files as a side effect.
73
+ */
74
+ async function readDeclarations(companionPath: string): Promise<unknown[]> {
75
+ try {
76
+ const raw = await fs.readFile(
77
+ path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
78
+ "utf8",
79
+ );
80
+ const parsed = parse(raw) as unknown;
81
+ if (!isRecord(parsed) || !Array.isArray(parsed.plugins)) return [];
82
+ return parsed.plugins;
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ function validateDeclaration(entry: unknown): { declaration?: PluginDeclaration; error?: string } {
89
+ if (!isRecord(entry) || typeof entry.package !== "string" || typeof entry.version !== "string") {
90
+ return { error: `ignoring malformed plugins entry: ${JSON.stringify(entry)}` };
91
+ }
92
+ const policy = entry.policy;
93
+ if (policy !== undefined && !PLUGIN_DECLARATION_POLICIES.includes(policy as never)) {
94
+ return {
95
+ error: `plugin "${entry.package}": policy "${String(policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
96
+ };
97
+ }
98
+ return { declaration: entry as unknown as PluginDeclaration };
99
+ }
100
+
101
+ /**
102
+ * Registers the companion's declared plugins into the active registry —
103
+ * compiled-in plugins first (they are already registered), declared order
104
+ * after. Runs before cap-command detection on every invocation. No-op
105
+ * without a companion or `plugins:` entries; all diagnostics go to stderr;
106
+ * never throws.
107
+ */
108
+ export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}): Promise<void> {
109
+ const warn =
110
+ deps.warn ?? ((message: string) => process.stderr.write(`${commandName()}: ${message}\n`));
111
+ try {
112
+ const env = deps.env ?? process.env;
113
+ const companionPath =
114
+ deps.companionPath ?? (await resolveCompanionQuietly(deps.cwd ?? process.cwd(), env));
115
+ if (!companionPath) return;
116
+
117
+ const entries = await readDeclarations(companionPath);
118
+ if (entries.length === 0) return;
119
+
120
+ const pins = (await new PluginPinStore(companionPath).load()).plugins;
121
+ const registry = deps.registry ?? getActiveDistribution().registry;
122
+
123
+ for (const entry of entries) {
124
+ const { declaration, error } = validateDeclaration(entry);
125
+ if (!declaration) {
126
+ if (error) warn(error);
127
+ continue;
128
+ }
129
+ if (hydratedPackages.has(declaration.package)) continue;
130
+
131
+ // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
132
+ const result = await loadDynamicPlugin(companionPath, declaration, pins, deps);
133
+ if (!result.ok) {
134
+ warn(result.warning);
135
+ continue;
136
+ }
137
+
138
+ const namespace = result.plugin.cliNamespace ?? result.plugin.id;
139
+ const holder = registry
140
+ .getAll()
141
+ .find((existing) => (existing.cliNamespace ?? existing.id) === namespace);
142
+ if (holder) {
143
+ warn(
144
+ `plugin "${declaration.package}" ("${result.plugin.id}") claims cap namespace "${namespace}" already registered by "${holder.id}"; first registration wins.`,
145
+ );
146
+ }
147
+
148
+ registry.register({ plugin: result.plugin, policy: declaration.policy ?? "optional" });
149
+ hydratedPackages.add(declaration.package);
150
+ }
151
+ } catch (error) {
152
+ warn(
153
+ `dynamic plugin hydration failed: ${error instanceof Error ? error.message : String(error)}`,
154
+ );
155
+ }
156
+ }
@@ -0,0 +1,171 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
+ import { pluginInstallDir, pluginPackageRoot } from "./paths";
7
+ import { PluginPinStore, type PluginPin } from "./pin-store";
8
+
9
+ export type BunInstallRunner = (
10
+ installDir: string,
11
+ ) => Promise<{ ok: boolean; detail?: string }> | { ok: boolean; detail?: string };
12
+
13
+ export interface PluginInstallDeps {
14
+ runBunInstall?: BunInstallRunner;
15
+ }
16
+
17
+ export interface PluginInstallResult {
18
+ package: string;
19
+ status: "installed" | "unchanged" | "failed";
20
+ resolvedVersion?: string;
21
+ error?: string;
22
+ }
23
+
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" });
27
+ if (result.error || result.status !== 0) {
28
+ return {
29
+ ok: false,
30
+ detail:
31
+ result.error?.message ??
32
+ result.stderr?.trim() ??
33
+ `bun install exited with ${result.status}`,
34
+ };
35
+ }
36
+ return { ok: true };
37
+ }
38
+
39
+ async function readInstalledVersion(packageRoot: string): Promise<string | null> {
40
+ try {
41
+ const manifest = JSON.parse(
42
+ await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
43
+ ) as { version?: string };
44
+ return manifest.version ?? null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
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> {
59
+ 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
+ );
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /**
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.
80
+ */
81
+ export async function installDeclaredPlugins(
82
+ companionPath: string,
83
+ declarations: PluginDeclaration[],
84
+ deps: PluginInstallDeps = {},
85
+ ): 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
+ }
113
+
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
+ }
126
+ }
127
+
128
+ // Pins mirror the declaration list; undeclared packages drop out.
129
+ if (JSON.stringify(nextPins) !== JSON.stringify(previousPins)) {
130
+ await pinStore.save({ plugins: nextPins });
131
+ }
132
+ return results;
133
+ }
134
+
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");
158
+ }
159
+ const resolvedVersion = await readInstalledVersion(packageRoot);
160
+ if (!resolvedVersion) {
161
+ throw new Error(`installed tree is missing ${declaration.package}/package.json`);
162
+ }
163
+ 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
+ }
171
+ }
@@ -0,0 +1,171 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import { FRAMEWORK_NAME, frameworkCommandName } from "../../../framework";
6
+ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
7
+ import type { Plugin } from "../plugin";
8
+ import { resolveEffectivePluginConfig } from "./config-resolution";
9
+ import {
10
+ createPluginHost,
11
+ SUPPORTED_PLUGIN_API_VERSIONS,
12
+ type CreatePlugin,
13
+ type PluginHost,
14
+ } from "./host";
15
+ import { pluginPackageRoot } from "./paths";
16
+ import type { PluginPin } from "./pin-store";
17
+
18
+ interface PluginManifest {
19
+ version?: string;
20
+ main?: string;
21
+ module?: string;
22
+ exports?: unknown;
23
+ mate?: { pluginApiVersion?: unknown };
24
+ }
25
+
26
+ export type DynamicPluginLoadResult = { ok: true; plugin: Plugin } | { ok: false; warning: string };
27
+
28
+ export interface DynamicPluginLoadDeps {
29
+ env?: Record<string, string | undefined>;
30
+ importModule?: (specifier: string) => Promise<Record<string, unknown>>;
31
+ host?: PluginHost;
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
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
+ /**
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.
65
+ */
66
+ export async function loadDynamicPlugin(
67
+ companionPath: string,
68
+ declaration: PluginDeclaration,
69
+ pins: PluginPin[],
70
+ deps: DynamicPluginLoadDeps = {},
71
+ ): Promise<DynamicPluginLoadResult> {
72
+ const name = declaration.package;
73
+ const packageRoot = pluginPackageRoot(companionPath, name);
74
+
75
+ let manifest: PluginManifest;
76
+ try {
77
+ manifest = JSON.parse(
78
+ await fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
79
+ ) as PluginManifest;
80
+ } catch {
81
+ return {
82
+ 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\`.`,
104
+ };
105
+ }
106
+
107
+ // Version negotiation happens before any plugin code executes.
108
+ const apiVersion = manifest.mate?.pluginApiVersion ?? 1;
109
+ if (typeof apiVersion !== "number" || !SUPPORTED_PLUGIN_API_VERSIONS.includes(apiVersion)) {
110
+ return {
111
+ ok: false,
112
+ warning: `plugin "${name}" requires plugin API version ${String(apiVersion)}; this CLI supports: ${SUPPORTED_PLUGIN_API_VERSIONS.join(", ")}.`,
113
+ };
114
+ }
115
+
116
+ const configResult = await resolveEffectivePluginConfig(companionPath, declaration, deps.env);
117
+ if (!configResult.ok) {
118
+ return {
119
+ ok: false,
120
+ warning: `plugin "${name}" references unset environment variable${configResult.missingVariables.length > 1 ? "s" : ""}: ${configResult.missingVariables.join(", ")}.`,
121
+ };
122
+ }
123
+
124
+ const entryRelative =
125
+ resolveEntryFromExports(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js";
126
+ const entryPath = path.resolve(packageRoot, entryRelative);
127
+ const importModule =
128
+ deps.importModule ??
129
+ ((specifier: string) => import(specifier) as Promise<Record<string, unknown>>);
130
+
131
+ let moduleExports: Record<string, unknown>;
132
+ try {
133
+ moduleExports = await importModule(pathToFileURL(entryPath).href);
134
+ } catch (error) {
135
+ return {
136
+ ok: false,
137
+ warning: `plugin "${name}" could not be imported: ${error instanceof Error ? error.message : String(error)}`,
138
+ };
139
+ }
140
+
141
+ const factory =
142
+ typeof moduleExports.default === "function"
143
+ ? (moduleExports.default as CreatePlugin)
144
+ : typeof moduleExports.createPlugin === "function"
145
+ ? (moduleExports.createPlugin as CreatePlugin)
146
+ : undefined;
147
+ if (!factory) {
148
+ return {
149
+ ok: false,
150
+ warning: `plugin "${name}" has no factory export; expected a default-exported function or a named createPlugin export.`,
151
+ };
152
+ }
153
+
154
+ let plugin: Plugin;
155
+ try {
156
+ plugin = factory(configResult.config, deps.host ?? createPluginHost());
157
+ } catch (error) {
158
+ return {
159
+ ok: false,
160
+ warning: `plugin "${name}" rejected its configuration: ${error instanceof Error ? error.message : String(error)}`,
161
+ };
162
+ }
163
+
164
+ if (!isRecord(plugin) || typeof plugin.id !== "string" || plugin.id.length === 0) {
165
+ return {
166
+ ok: false,
167
+ warning: `plugin "${name}" returned an invalid plugin object (missing id).`,
168
+ };
169
+ }
170
+ return { ok: true, plugin };
171
+ }
@@ -0,0 +1,42 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
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, "-");
8
+ }
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. */
20
+ export function pluginPackageRoot(companionPath: string, packageName: string): string {
21
+ return path.join(
22
+ pluginInstallDir(companionPath, packageName),
23
+ "node_modules",
24
+ ...packageName.split("/"),
25
+ );
26
+ }
27
+
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
+ /** Gitignored per-machine override file deep-merged over committed plugin config. */
34
+ export function pluginLocalOverridesPath(companionPath: string): string {
35
+ return path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "plugins.local.yaml");
36
+ }
37
+
38
+ /** Companion-relative gitignore entry for the local override file. */
39
+ export const PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY = `.${FRAMEWORK_NAME}/config/plugins.local.yaml`;
40
+
41
+ /** Companion-relative gitignore entry for installed plugin trees (regenerated by install). */
42
+ export const PLUGIN_DEPENDENCIES_GITIGNORE_ENTRY = `.${FRAMEWORK_NAME}/dependencies/plugins/`;
@@ -0,0 +1,30 @@
1
+ import { YamlFileStore } from "../../../lib/orchestrator/yaml-file-store";
2
+ import { pluginPinFilePath } from "./paths";
3
+
4
+ /** Committed record of one declared plugin's resolved install. */
5
+ export interface PluginPin {
6
+ package: string;
7
+ declaredVersion: string;
8
+ resolvedVersion: string;
9
+ integrity?: string;
10
+ }
11
+
12
+ export interface PluginPinFile {
13
+ plugins: PluginPin[];
14
+ }
15
+
16
+ export class PluginPinStore extends YamlFileStore<PluginPinFile> {
17
+ constructor(companionPath: string) {
18
+ super(pluginPinFilePath(companionPath));
19
+ }
20
+
21
+ override async load(): Promise<PluginPinFile> {
22
+ const parsed = await super.load();
23
+ return { plugins: Array.isArray(parsed?.plugins) ? parsed.plugins : [] };
24
+ }
25
+
26
+ protected onMissing(): Promise<PluginPinFile> {
27
+ // The pin file appears only once install records a resolved plugin.
28
+ return Promise.resolve({ plugins: [] });
29
+ }
30
+ }
@@ -1,4 +1,8 @@
1
1
  import { getActiveDistribution } from "../../../distribution";
2
+ import {
3
+ PLUGIN_DEPENDENCIES_GITIGNORE_ENTRY,
4
+ PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY,
5
+ } from "../dynamic-plugins/paths";
2
6
  import type { Plugin, SetupContext } from "../plugin";
3
7
 
4
8
  const MANAGED_START = (name: string) => `# ${name} managed: start`;
@@ -55,9 +59,16 @@ export async function writeManagedGitignoreBlock(
55
59
  }
56
60
 
57
61
  export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugin[]): string[] {
58
- return plugins
62
+ const entries = plugins
59
63
  .filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
60
64
  .flatMap((p) => p.gitignoreEntries?.(ctx) ?? []);
65
+ // Dynamic-plugin loading artifacts: the local override file and installed
66
+ // trees never version; the pin file (plugins.lock.yaml) stays committed and
67
+ // is deliberately not listed here.
68
+ if (ctx.config.plugins?.length) {
69
+ entries.push(PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY, PLUGIN_DEPENDENCIES_GITIGNORE_ENTRY);
70
+ }
71
+ return entries;
61
72
  }
62
73
 
63
74
  export function createGitignorePlugin(
@@ -31,6 +31,8 @@ import { createUvPlugin } from "./setup/package-managers/uv";
31
31
  import type { PackageManagerSetupDeps } from "./setup/package-managers/uv";
32
32
  import { applyRequiredSelectionsToConfig } from "./setup/policy";
33
33
  import { invalidateInstallState } from "../lib/install";
34
+ import { hydrateDynamicPlugins } from "./setup/dynamic-plugins/hydrate";
35
+ import { installDeclaredPlugins } from "./setup/dynamic-plugins/install";
34
36
  import {
35
37
  buildSetupInstallationPlan,
36
38
  executeSetupInstallationPlan,
@@ -181,6 +183,19 @@ export async function executeSetup(
181
183
  await configStore.save(config);
182
184
 
183
185
  const companionPath = path.resolve(cwd);
186
+ // Declared plugin packages install and hydrate before the setup plan runs,
187
+ // so a fresh clone reaches a fully applied state from one setup run
188
+ // (install → load → plan).
189
+ if (config.plugins?.length) {
190
+ for (const result of await installDeclaredPlugins(companionPath, config.plugins)) {
191
+ if (result.status === "failed") {
192
+ process.stderr.write(
193
+ `${FRAMEWORK_NAME}: plugin "${result.package}" failed to install: ${result.error ?? "unknown error"}\n`,
194
+ );
195
+ }
196
+ }
197
+ await hydrateDynamicPlugins({ companionPath });
198
+ }
184
199
  await applySetupCompatibilities(companionPath, config, "setup");
185
200
  await invalidateInstallState({ kind: "companion", companionPath });
186
201