@uniqbit/mate-core 0.18.0 → 0.18.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.0",
3
+ "version": "0.18.1-canary.0",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -19,7 +19,6 @@ import {
19
19
  FrozenInstallError,
20
20
  installDeclaredPluginsFrozen,
21
21
  } from "../../tools/setup/dynamic-plugins/frozen";
22
- import { verifyTrackedPluginOutputs } from "../../tools/setup/dynamic-plugins/staging";
23
22
  import { verifyDeclaredPlugins } from "../../tools/setup/dynamic-plugins/verify";
24
23
 
25
24
  export function reportPluginInstallResults(results: PluginInstallResult[]): boolean {
@@ -159,22 +158,7 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
159
158
  }
160
159
 
161
160
  try {
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
- }
161
+ await reconcileInstalledCompanion(plan);
178
162
  await saveCompleteInstallState(plan, execution.results);
179
163
  } catch (error) {
180
164
  process.stderr.write(
@@ -11,7 +11,7 @@ export async function runPluginCommand(
11
11
  await runPluginInstallCommand(argv);
12
12
  return;
13
13
  case "verify":
14
- await runPluginVerifyCommand();
14
+ await runPluginVerifyCommand(argv);
15
15
  return;
16
16
  default:
17
17
  console.error(`Unknown plugin command: ${subcommand ?? ""}`);
@@ -1,19 +1,22 @@
1
1
  import { FRAMEWORK_NAME } from "../../../framework";
2
2
  import { resolveInstallContext } from "../../../lib/install";
3
- import { verifyDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
3
+ import { inspectDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
4
4
 
5
5
  /**
6
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.
7
+ * @description Strict, installation-free check that every plugin declared by the companion is installed, and loadable with the current environment. Exits non-zero naming each failing package. With `--json`, prints `{"capabilities": [...]}` listing the capability IDs the verified plugins provide.
8
8
  */
9
- export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boolean> {
9
+ export async function runPluginVerifyCommand(
10
+ argv: string[] = [],
11
+ cwd = process.cwd(),
12
+ ): Promise<boolean> {
10
13
  const context = await resolveInstallContext(cwd);
11
14
  if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
12
15
  process.stderr.write(`${FRAMEWORK_NAME}: \`plugin verify\` requires a companion context.\n`);
13
16
  process.exitCode = 1;
14
17
  return false;
15
18
  }
16
- const failures = await verifyDeclaredPlugins(context.companionPath);
19
+ const { failures, capabilities } = await inspectDeclaredPlugins(context.companionPath);
17
20
  for (const failure of failures) {
18
21
  process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
19
22
  }
@@ -21,5 +24,6 @@ export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boole
21
24
  process.exitCode = 1;
22
25
  return false;
23
26
  }
27
+ if (argv.includes("--json")) process.stdout.write(`${JSON.stringify({ capabilities })}\n`);
24
28
  return true;
25
29
  }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import { FRAMEWORK_NAME } from "../../../framework";
@@ -26,11 +27,56 @@ export async function launchableAgents(
26
27
  return TERMINAL_AGENTS.filter((agent) => allowed.includes(agent) && installed(agent));
27
28
  }
28
29
 
30
+ /** Lowercase letters, digits and hyphens, so a name can never carry shell syntax or a flag. */
31
+ export const AGENT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
32
+
33
+ const AGENT_DEFINITION_DIRS: Record<TerminalAgent, string> = {
34
+ claude: path.join(".claude", "agents"),
35
+ opencode: path.join(".opencode", "agents"),
36
+ };
37
+
38
+ export interface DefaultAgentOutcome {
39
+ agentArgs?: string[];
40
+ notice?: string;
41
+ }
42
+
43
+ /**
44
+ * The companion's `studio.terminal.agent` for one provider. Never throws and
45
+ * never echoes the configured value into the notice.
46
+ */
47
+ export async function defaultAgentFor(
48
+ companionPath: string,
49
+ agent: TerminalAgent,
50
+ ): Promise<DefaultAgentOutcome> {
51
+ const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
52
+ if (!fs.existsSync(configPath)) return {};
53
+ let name: unknown;
54
+ try {
55
+ name = (await new ConfigStore(configPath).load()).studio?.terminal?.agent;
56
+ } catch {
57
+ return {};
58
+ }
59
+ if (name === undefined || name === null) return {};
60
+ if (typeof name !== "string" || !AGENT_NAME_PATTERN.test(name)) {
61
+ return {
62
+ notice: "Default agent not applied: studio.terminal.agent is not a valid agent name.",
63
+ };
64
+ }
65
+ const definition = path.join(companionPath, AGENT_DEFINITION_DIRS[agent], `${name}.md`);
66
+ if (!fs.existsSync(definition)) {
67
+ return {
68
+ notice: `Default agent "${name}" not applied: this companion has no ${agent} definition for it.`,
69
+ };
70
+ }
71
+ return { agentArgs: ["--agent", name] };
72
+ }
73
+
29
74
  export interface LaunchResolverOptions {
30
75
  collectInventory: () => Promise<StudioInventory>;
31
76
  /** Pinned by `serve --companion`; overrides the page selection. */
32
77
  launchCompanion?: string | null;
33
78
  launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
79
+ defaultAgent?: (companionPath: string, agent: TerminalAgent) => Promise<DefaultAgentOutcome>;
34
80
  }
35
81
 
36
82
  /** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
@@ -50,6 +96,7 @@ export function createLaunchResolver(
50
96
  options: LaunchResolverOptions,
51
97
  ): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
52
98
  const agents = options.launchableAgents ?? launchableAgents;
99
+ const defaultAgent = options.defaultAgent ?? defaultAgentFor;
53
100
  return async (agent, digest) => {
54
101
  const companion = await effectiveLaunchCompanion(
55
102
  await options.collectInventory(),
@@ -66,6 +113,9 @@ export function createLaunchResolver(
66
113
  if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
67
114
  return { reason: `${agent} is not allowed by this companion or is not installed` };
68
115
  }
69
- return { companionPath: companion.path };
116
+ return {
117
+ companionPath: companion.path,
118
+ ...(await defaultAgent(companion.path, agent as TerminalAgent)),
119
+ };
70
120
  };
71
121
  }
@@ -177,7 +177,15 @@ export interface TerminalSessionInfo {
177
177
  startedAt: number;
178
178
  }
179
179
 
180
- export type TerminalLaunchResolution = { companionPath: string } | { reason: string };
180
+ export type TerminalLaunchResolution =
181
+ | {
182
+ companionPath: string;
183
+ /** Extra argv entries for the managed launch; set only by companion configuration. */
184
+ agentArgs?: string[];
185
+ /** Written to the terminal before the agent starts. */
186
+ notice?: string;
187
+ }
188
+ | { reason: string };
181
189
 
182
190
  export interface TerminalRegistryOptions {
183
191
  detachMs: number;
@@ -388,7 +396,7 @@ export class TerminalRegistry {
388
396
  void this.finish(detached, { type: "ended", reason: "evicted" });
389
397
  }
390
398
 
391
- const session = this.launch(agent, resolved.companionPath, size);
399
+ const session = this.launch(agent, resolved.companionPath, size, resolved);
392
400
  this.attachTo(connection, session, size);
393
401
  }
394
402
 
@@ -396,6 +404,7 @@ export class TerminalRegistry {
396
404
  agent: TerminalAgent,
397
405
  companionPath: string,
398
406
  size: { cols: number; rows: number },
407
+ extras: { agentArgs?: string[]; notice?: string } = {},
399
408
  ): Session {
400
409
  const env: Record<string, string> = {};
401
410
  for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
@@ -413,6 +422,7 @@ export class TerminalRegistry {
413
422
  "--companion",
414
423
  "--yes",
415
424
  ...(this.options.noGit ? ["--no-git"] : []),
425
+ ...(extras.agentArgs ?? []),
416
426
  ];
417
427
 
418
428
  const session = {
@@ -427,6 +437,7 @@ export class TerminalRegistry {
427
437
  ending: null,
428
438
  exited: false,
429
439
  } as Omit<Session, "process"> as Session;
440
+ if (extras.notice) session.ring.push(new TextEncoder().encode(`${extras.notice}\r\n`));
430
441
  session.process = this.spawn({
431
442
  argv,
432
443
  cwd: companionPath,
@@ -120,6 +120,11 @@ export interface PluginDeclaration {
120
120
  config?: unknown;
121
121
  }
122
122
 
123
+ export interface StudioTerminalConfig {
124
+ /** Persona selected with `--agent` when Studio's terminal starts a provider. */
125
+ agent?: string;
126
+ }
127
+
123
128
  export interface FrameworkConfig {
124
129
  type?: FrameworkType;
125
130
  git?: GitModeProfile;
@@ -131,6 +136,7 @@ export interface FrameworkConfig {
131
136
  cliTools?: CliToolConfig[];
132
137
  packageManagers?: string[];
133
138
  engines?: EngineConstraints;
139
+ studio?: { terminal?: StudioTerminalConfig };
134
140
  }
135
141
 
136
142
  export interface CompanionRegistryConfig {
@@ -5,14 +5,8 @@ import path from "node:path";
5
5
  import type { PluginDeclaration } from "../../../lib/orchestrator/types";
6
6
  import type { NpmInstallRunner, PluginInstallResult } from "./install";
7
7
  import { dynamicPluginsWorkspaceRoot } from "./paths";
8
- import {
9
- disallowedPluginMessage,
10
- isPluginAllowed,
11
- PluginPolicyError,
12
- readPluginPolicy,
13
- } from "./policy";
14
-
15
- /** Raised before anything is installed: policy, manifest or lockfile inputs are unusable. */
8
+
9
+ /** Raised before anything is installed: manifest or lockfile inputs are unusable. */
16
10
  export class FrozenInstallError extends Error {}
17
11
 
18
12
  export interface FrozenInstallDeps {
@@ -112,8 +106,7 @@ async function treeMismatches(workspaceRoot: string, lock: Lockfile): Promise<st
112
106
 
113
107
  /**
114
108
  * Deployment restore: installs exactly what the committed lockfile records.
115
- * Inputs are validated before npm runs — the appliance allowlist, the
116
- * workspace manifest against the declarations, and the lockfile against the
109
+ * Inputs are validated before npm runs — the workspace manifest against the declarations, and the lockfile against the
117
110
  * manifest — and neither tracked file is ever rewritten. An installed tree is
118
111
  * reused only when it matches the lockfile's versions and integrity.
119
112
  */
@@ -127,20 +120,6 @@ export async function installDeclaredPluginsFrozen(
127
120
  const desired: Record<string, string> = {};
128
121
  for (const declaration of sorted) desired[declaration.package] = declaration.version;
129
122
 
130
- let policy: ReturnType<typeof readPluginPolicy>;
131
- try {
132
- policy = readPluginPolicy(deps.env ?? process.env);
133
- } catch (error) {
134
- if (error instanceof PluginPolicyError) throw new FrozenInstallError(error.message);
135
- throw error;
136
- }
137
- const refused = sorted.filter((declaration) => !isPluginAllowed(policy, declaration.package));
138
- if (refused.length > 0) {
139
- throw new FrozenInstallError(
140
- refused.map((declaration) => disallowedPluginMessage(declaration.package)).join("\n"),
141
- );
142
- }
143
-
144
123
  const manifestFile = path.join(workspaceRoot, "package.json");
145
124
  const lockFile = path.join(workspaceRoot, "package-lock.json");
146
125
  const [manifestText, lockText] = await Promise.all([readText(manifestFile), readText(lockFile)]);
@@ -14,12 +14,6 @@ import {
14
14
  type PluginHost,
15
15
  } from "./host";
16
16
  import { dynamicPluginsWorkspaceRoot, pluginPackageRoot } from "./paths";
17
- import {
18
- disallowedPluginMessage,
19
- isPluginAllowed,
20
- PluginPolicyError,
21
- readPluginPolicy,
22
- } from "./policy";
23
17
 
24
18
  interface PluginManifest {
25
19
  mate?: { pluginApiVersion?: unknown };
@@ -53,16 +47,6 @@ export async function loadDynamicPlugin(
53
47
  const name = declaration.package;
54
48
  const packageRoot = pluginPackageRoot(companionPath, name);
55
49
 
56
- /** Fails closed: a malformed policy allows nothing. Checked before any plugin file is touched. */
57
- try {
58
- if (!isPluginAllowed(readPluginPolicy(deps.env ?? process.env), name)) {
59
- return { ok: false, warning: `${disallowedPluginMessage(name)}; not loaded.` };
60
- }
61
- } catch (error) {
62
- if (!(error instanceof PluginPolicyError)) throw error;
63
- return { ok: false, warning: `plugin "${name}" not loaded: ${error.message}` };
64
- }
65
-
66
50
  let manifest: PluginManifest;
67
51
  try {
68
52
  manifest = JSON.parse(
@@ -5,38 +5,37 @@ import type { PluginDeclaration } from "../../../lib/orchestrator/types";
5
5
  import { readDeclarations, validateDeclaration } from "./declarations";
6
6
  import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
7
7
  import { pluginPackageRoot } from "./paths";
8
- import {
9
- disallowedPluginMessage,
10
- isPluginAllowed,
11
- PluginPolicyError,
12
- readPluginPolicy,
13
- } from "./policy";
14
8
 
15
9
  export interface PluginVerificationFailure {
16
10
  package: string;
17
11
  reason: string;
18
12
  }
19
13
 
14
+ export interface PluginInspection {
15
+ failures: PluginVerificationFailure[];
16
+ /** IDs of the capabilities the loaded plugins provide. */
17
+ capabilities: string[];
18
+ }
19
+
20
20
  /**
21
- * Strict, installation-free check of every declared plugin: allowed, installed
21
+ * Strict, installation-free check of every declared plugin: installed
22
22
  * and loadable with the effective environment. Unlike hydration it fails
23
- * closed on the first-class problems ordinary commands only warn about. Only
24
- * allowlisted packages are imported.
23
+ * closed on the first-class problems ordinary commands only warn about.
25
24
  */
26
25
  export async function verifyDeclaredPlugins(
27
26
  companionPath: string,
28
27
  deps: DynamicPluginLoadDeps = {},
29
28
  ): Promise<PluginVerificationFailure[]> {
29
+ return (await inspectDeclaredPlugins(companionPath, deps)).failures;
30
+ }
31
+
32
+ export async function inspectDeclaredPlugins(
33
+ companionPath: string,
34
+ deps: DynamicPluginLoadDeps = {},
35
+ ): Promise<PluginInspection> {
30
36
  const env = deps.env ?? process.env;
31
37
  const failures: PluginVerificationFailure[] = [];
32
- let policy: ReturnType<typeof readPluginPolicy>;
33
- try {
34
- policy = readPluginPolicy(env);
35
- } catch (error) {
36
- if (!(error instanceof PluginPolicyError)) throw error;
37
- return [{ package: "(allowlist)", reason: error.message }];
38
- }
39
-
38
+ const capabilities: string[] = [];
40
39
  const declarations: PluginDeclaration[] = [];
41
40
  for (const entry of await readDeclarations(companionPath)) {
42
41
  const { declaration, error } = validateDeclaration(entry);
@@ -46,10 +45,6 @@ export async function verifyDeclaredPlugins(
46
45
 
47
46
  for (const declaration of declarations) {
48
47
  const name = declaration.package;
49
- if (!isPluginAllowed(policy, name)) {
50
- failures.push({ package: name, reason: disallowedPluginMessage(name) });
51
- continue;
52
- }
53
48
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
54
49
  const installed = await fs
55
50
  .access(pluginPackageRoot(companionPath, name))
@@ -65,6 +60,7 @@ export async function verifyDeclaredPlugins(
65
60
  // oxlint-disable-next-line no-await-in-loop -- declared order is part of the contract
66
61
  const result = await loadDynamicPlugin(companionPath, declaration, { ...deps, env });
67
62
  if (!result.ok) failures.push({ package: name, reason: result.warning });
63
+ else if (result.plugin.kind === "capability") capabilities.push(result.plugin.id);
68
64
  }
69
- return failures;
65
+ return { failures, capabilities };
70
66
  }
@@ -1,55 +0,0 @@
1
- /** Environment variable carrying the appliance's declared-plugin package allowlist. */
2
- export const ALLOWED_PLUGINS_ENV = "MATE_ALLOWED_PLUGINS";
3
-
4
- /**
5
- * Parsed allowlist: exact package names and scope-wide `@scope/*` patterns.
6
- * An empty list permits no package; the policy's absence permits all.
7
- */
8
- export interface PluginPolicy {
9
- exact: string[];
10
- scopes: string[];
11
- }
12
-
13
- export class PluginPolicyError extends Error {}
14
-
15
- const EXACT_PATTERN = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
16
- const SCOPE_PATTERN = /^@[a-z0-9][a-z0-9._~-]*\/\*$/;
17
-
18
- /**
19
- * Parses a comma-separated allowlist. `undefined` means no policy; an empty
20
- * or blank string is an explicit empty policy. Malformed entries throw so a
21
- * typo can never widen or silently narrow the trust decision.
22
- */
23
- export function parseAllowedPlugins(raw: string | undefined): PluginPolicy | null {
24
- if (raw === undefined) return null;
25
- const policy: PluginPolicy = { exact: [], scopes: [] };
26
- if (raw.trim() === "") return policy;
27
- for (const part of raw.split(",")) {
28
- const entry = part.trim();
29
- if (SCOPE_PATTERN.test(entry)) policy.scopes.push(entry.slice(0, entry.indexOf("/") + 1));
30
- else if (EXACT_PATTERN.test(entry)) policy.exact.push(entry);
31
- else {
32
- throw new PluginPolicyError(
33
- `${ALLOWED_PLUGINS_ENV}: malformed entry ${JSON.stringify(entry)}; expected an exact package name or a scope pattern such as "@acme/*"`,
34
- );
35
- }
36
- }
37
- return policy;
38
- }
39
-
40
- /** Reads the effective policy from an environment; throws on a malformed value. */
41
- export function readPluginPolicy(
42
- env: Record<string, string | undefined> = process.env,
43
- ): PluginPolicy | null {
44
- return parseAllowedPlugins(env[ALLOWED_PLUGINS_ENV]);
45
- }
46
-
47
- export function isPluginAllowed(policy: PluginPolicy | null, packageName: string): boolean {
48
- if (policy === null) return true;
49
- if (policy.exact.includes(packageName)) return true;
50
- return policy.scopes.some((scope) => packageName.startsWith(scope));
51
- }
52
-
53
- export function disallowedPluginMessage(packageName: string): string {
54
- return `plugin "${packageName}" is not allowed by ${ALLOWED_PLUGINS_ENV}`;
55
- }
@@ -1,53 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
-
6
- export class TrackedOutputError extends Error {}
7
-
8
- export type StagedProjection = (stagingPath: string) => Promise<void>;
9
-
10
- function git(cwd: string, args: string[]): string {
11
- const result = spawnSync("git", args, { cwd, encoding: "utf8" });
12
- if (result.error || result.status !== 0) {
13
- throw new TrackedOutputError(
14
- `git ${args[0]} failed in ${cwd}: ${result.error?.message ?? result.stderr?.trim() ?? result.status}`,
15
- );
16
- }
17
- return result.stdout;
18
- }
19
-
20
- function changedPaths(cwd: string): string[] {
21
- return git(cwd, ["status", "--porcelain", "--untracked-files=all"])
22
- .split("\n")
23
- .filter((line) => line.trim() !== "")
24
- .map((line) => line.slice(3));
25
- }
26
-
27
- /**
28
- * Checks that what plugins would generate is already committed. The live
29
- * checkout must have no edits of its own; the projection then runs against a
30
- * disposable clone of its commit, and any file that differs afterwards is
31
- * drift. Nothing is written back to the live checkout. Returns the drifting
32
- * paths (empty when the checkout is complete).
33
- */
34
- export async function verifyTrackedPluginOutputs(
35
- companionPath: string,
36
- project: StagedProjection,
37
- ): Promise<string[]> {
38
- const pending = changedPaths(companionPath);
39
- if (pending.length > 0) {
40
- throw new TrackedOutputError(
41
- `the checkout has uncommitted changes, which frozen setup will not overwrite: ${pending.join(", ")}`,
42
- );
43
- }
44
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "mate-staging-"));
45
- try {
46
- const staged = path.join(root, "companion");
47
- git(root, ["clone", "--quiet", "--no-hardlinks", companionPath, staged]);
48
- await project(staged);
49
- return changedPaths(staged);
50
- } finally {
51
- await fs.rm(root, { recursive: true, force: true });
52
- }
53
- }