@uniqbit/mate-core 0.18.2 → 0.18.3-canary.1
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 +1 -1
- package/src/cli/commands/doctor.ts +48 -3
- package/src/cli/commands/install.ts +21 -3
- package/src/cli/commands/studio/terminal-launch.ts +13 -3
- package/src/lib/orchestrator/config-store.ts +152 -1
- package/src/lib/orchestrator/types.ts +13 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +128 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +17 -5
- package/src/tools/setup/dynamic-plugins/verify.ts +5 -3
- package/src/tools/setup.ts +11 -2
package/package.json
CHANGED
|
@@ -9,6 +9,11 @@ import type { GlobalConfigStore } from "../../lib/orchestrator/global-config-sto
|
|
|
9
9
|
import { pathIsDirectory } from "../../lib/orchestrator/repo-local-registry";
|
|
10
10
|
import { resolveRootContext, type RootContext } from "../../lib/orchestrator/root-context";
|
|
11
11
|
import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
|
|
12
|
+
import {
|
|
13
|
+
describeUnknownAudience,
|
|
14
|
+
readAudienceSelection,
|
|
15
|
+
resolveAudience,
|
|
16
|
+
} from "../../tools/setup/dynamic-plugins/audiences";
|
|
12
17
|
import {
|
|
13
18
|
inspectDeclaredPlugins,
|
|
14
19
|
type PluginInspection,
|
|
@@ -26,6 +31,8 @@ interface DoctorDeps {
|
|
|
26
31
|
/** Returns a member checkout's HEAD commit, or null when unreadable. */
|
|
27
32
|
gitHead?: (memberPath: string) => string | null;
|
|
28
33
|
inspectPlugins?: (companionPath: string) => Promise<PluginInspection>;
|
|
34
|
+
/** Environment `MATE_AUDIENCE` is read from; defaults to the process environment. */
|
|
35
|
+
env?: Record<string, string | undefined>;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export type DoctorKind = "core" | "working" | "companion" | "hub";
|
|
@@ -59,6 +66,10 @@ export interface DoctorReport {
|
|
|
59
66
|
toolInstallations?: DoctorToolInstallation[];
|
|
60
67
|
requiredPluginDrift?: Array<{ pluginId: string; kind: string; reason: string }>;
|
|
61
68
|
pluginFailures?: PluginVerificationFailure[];
|
|
69
|
+
/** Present for a companion that declares audiences or has one selected. */
|
|
70
|
+
audiences?: { declared: string[]; active: string | null };
|
|
71
|
+
/** Set when `MATE_AUDIENCE` names an audience the companion does not declare. */
|
|
72
|
+
audienceError?: string;
|
|
62
73
|
/** IDs of the capabilities the verified plugins provide. */
|
|
63
74
|
pluginCapabilities?: string[];
|
|
64
75
|
engineRequirement?: { range: string; ok: boolean; detail: string };
|
|
@@ -215,6 +226,19 @@ function collectConfiguredRootSections(
|
|
|
215
226
|
report.engineRequirement = collectEngineRequirement(config);
|
|
216
227
|
}
|
|
217
228
|
|
|
229
|
+
function collectAudiences(
|
|
230
|
+
report: DoctorReport,
|
|
231
|
+
config: FrameworkConfig,
|
|
232
|
+
env: Record<string, string | undefined>,
|
|
233
|
+
): void {
|
|
234
|
+
const declared = Object.keys(config.audiences ?? {});
|
|
235
|
+
const requested = readAudienceSelection(env);
|
|
236
|
+
if (declared.length === 0 && requested === null) return;
|
|
237
|
+
const resolution = resolveAudience(config, requested);
|
|
238
|
+
report.audiences = { declared, active: resolution.ok ? resolution.active : null };
|
|
239
|
+
if (!resolution.ok) report.audienceError = describeUnknownAudience(resolution);
|
|
240
|
+
}
|
|
241
|
+
|
|
218
242
|
export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<DoctorReport> {
|
|
219
243
|
const cwd = path.resolve(deps.cwd ?? process.cwd());
|
|
220
244
|
const pathValue = deps.pathValue ?? process.env.PATH ?? "";
|
|
@@ -248,8 +272,15 @@ export async function collectDoctorReport(deps: DoctorDeps = {}): Promise<Doctor
|
|
|
248
272
|
report.engineRequirement = collectEngineRequirement(root.config);
|
|
249
273
|
}
|
|
250
274
|
|
|
275
|
+
const env = deps.env ?? process.env;
|
|
276
|
+
if (root.rootPath && root.config && kind !== "hub") {
|
|
277
|
+
collectAudiences(report, root.config, env);
|
|
278
|
+
}
|
|
279
|
+
|
|
251
280
|
if (root.rootPath && root.config) {
|
|
252
|
-
const
|
|
281
|
+
const inspect =
|
|
282
|
+
deps.inspectPlugins ?? ((companionPath) => inspectDeclaredPlugins(companionPath, { env }));
|
|
283
|
+
const inspection = await inspect(root.rootPath);
|
|
253
284
|
report.pluginFailures = inspection.failures;
|
|
254
285
|
report.pluginCapabilities = inspection.capabilities;
|
|
255
286
|
}
|
|
@@ -334,6 +365,15 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
334
365
|
if (report.toolInstallations) {
|
|
335
366
|
printSection("Tool Installations", renderToolInstallations(report.toolInstallations));
|
|
336
367
|
}
|
|
368
|
+
if (report.audiences) {
|
|
369
|
+
printSection(
|
|
370
|
+
"Audiences",
|
|
371
|
+
renderKeyValueTable([
|
|
372
|
+
["Declared", report.audiences.declared.join(", ") || "none"],
|
|
373
|
+
["Active", report.audiences.active ?? "none"],
|
|
374
|
+
]),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
337
377
|
if (report.hub) printSection("Hub Members", renderHubMembers(report.hub.members));
|
|
338
378
|
// Companions with no drift produce no section at all: required plugins are
|
|
339
379
|
// silent when healthy.
|
|
@@ -376,8 +416,9 @@ function renderHumanReport(report: DoctorReport): void {
|
|
|
376
416
|
* working repository), `companion`, `hub`, or `core` (not linked) — with
|
|
377
417
|
* kind-specific diagnostics: policy and capabilities for working repos,
|
|
378
418
|
* capabilities and tool checks for companions, member health for hubs.
|
|
379
|
-
* Declared plugins
|
|
380
|
-
* installed, loadable); any failure is named
|
|
419
|
+
* Declared plugins of the effective set (base plus the `MATE_AUDIENCE`
|
|
420
|
+
* audience) are verified strictly (installed, loadable); any failure is named
|
|
421
|
+
* on stderr and exits non-zero, as is an undeclared `MATE_AUDIENCE`.
|
|
381
422
|
* `--json` emits the same report as one JSON document.
|
|
382
423
|
*/
|
|
383
424
|
export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
|
|
@@ -389,5 +430,9 @@ export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {
|
|
|
389
430
|
for (const failure of failures) {
|
|
390
431
|
process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
|
|
391
432
|
}
|
|
433
|
+
if (report.audienceError) {
|
|
434
|
+
process.stderr.write(`${FRAMEWORK_NAME}: ${report.audienceError}\n`);
|
|
435
|
+
process.exitCode = 1;
|
|
436
|
+
}
|
|
392
437
|
if (failures.length > 0) process.exitCode = 1;
|
|
393
438
|
}
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
resolveInstallContext,
|
|
10
10
|
} from "../../lib/install";
|
|
11
11
|
import { renderInstallExecution } from "../install-plan";
|
|
12
|
+
import {
|
|
13
|
+
describeUnknownAudience,
|
|
14
|
+
installPluginDeclarations,
|
|
15
|
+
readAudienceSelection,
|
|
16
|
+
resolveAudience,
|
|
17
|
+
} from "../../tools/setup/dynamic-plugins/audiences";
|
|
12
18
|
import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
|
|
13
19
|
import {
|
|
14
20
|
installDeclaredPlugins,
|
|
@@ -69,19 +75,31 @@ export async function runInstallCommand(argv: string[], cwd = process.cwd()): Pr
|
|
|
69
75
|
process.exitCode = 1;
|
|
70
76
|
return false;
|
|
71
77
|
}
|
|
78
|
+
if (context.kind === "companion") {
|
|
79
|
+
const audience = resolveAudience(context.config, readAudienceSelection(process.env));
|
|
80
|
+
if (!audience.ok) {
|
|
81
|
+
process.stderr.write(`${FRAMEWORK_NAME}: ${describeUnknownAudience(audience)}\n`);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const declarations =
|
|
87
|
+
context.kind === "companion" || context.kind === "hub"
|
|
88
|
+
? installPluginDeclarations(context.config)
|
|
89
|
+
: [];
|
|
72
90
|
// Root-declared plugins (companion or hub) install first and hydrate into
|
|
73
91
|
// the registry before the plan is built, so one run goes install → load →
|
|
74
92
|
// plan and their own install requirements are part of the plan.
|
|
75
93
|
if (
|
|
76
94
|
(context.kind === "companion" || context.kind === "hub") &&
|
|
77
95
|
context.companionPath &&
|
|
78
|
-
|
|
96
|
+
declarations.length
|
|
79
97
|
) {
|
|
80
98
|
let results: PluginInstallResult[];
|
|
81
99
|
try {
|
|
82
100
|
results = frozen
|
|
83
|
-
? await installDeclaredPluginsFrozen(context.companionPath,
|
|
84
|
-
: await installDeclaredPlugins(context.companionPath,
|
|
101
|
+
? await installDeclaredPluginsFrozen(context.companionPath, declarations)
|
|
102
|
+
: await installDeclaredPlugins(context.companionPath, declarations);
|
|
85
103
|
} catch (error) {
|
|
86
104
|
if (!(error instanceof FrozenInstallError)) throw error;
|
|
87
105
|
process.stderr.write(`${FRAMEWORK_NAME}: frozen plugin install refused: ${error.message}\n`);
|
|
@@ -3,6 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
5
5
|
import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
|
|
6
|
+
import {
|
|
7
|
+
effectiveStudioAgent,
|
|
8
|
+
readAudienceSelection,
|
|
9
|
+
} from "../../../tools/setup/dynamic-plugins/audiences";
|
|
6
10
|
import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
|
|
7
11
|
import { findAction, promptArgs, skillInstalledFor, SUBJECT_PATTERN } from "./actions";
|
|
8
12
|
import { collectSkillInventory, type StudioSkillInventory } from "./mate-inventory";
|
|
@@ -45,18 +49,24 @@ export interface DefaultAgentOutcome {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
|
-
* The companion's
|
|
49
|
-
*
|
|
52
|
+
* The companion's effective default agent for one provider: the active
|
|
53
|
+
* audience's `studio.terminal.agent` (Studio's own `MATE_AUDIENCE`, never the
|
|
54
|
+
* browser's), else the base one. Never throws and never echoes the configured
|
|
55
|
+
* value into the notice.
|
|
50
56
|
*/
|
|
51
57
|
export async function defaultAgentFor(
|
|
52
58
|
companionPath: string,
|
|
53
59
|
agent: TerminalAgent,
|
|
60
|
+
env: Record<string, string | undefined> = process.env,
|
|
54
61
|
): Promise<DefaultAgentOutcome> {
|
|
55
62
|
const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
|
|
56
63
|
if (!fs.existsSync(configPath)) return {};
|
|
57
64
|
let name: unknown;
|
|
58
65
|
try {
|
|
59
|
-
name = (
|
|
66
|
+
name = effectiveStudioAgent(
|
|
67
|
+
await new ConfigStore(configPath).load(),
|
|
68
|
+
readAudienceSelection(env),
|
|
69
|
+
);
|
|
60
70
|
} catch {
|
|
61
71
|
return {};
|
|
62
72
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../framework";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getDefaultSetupSelections,
|
|
6
|
+
listSetupCapabilityCompatibilities,
|
|
7
|
+
} from "./setup-compatibilities";
|
|
5
8
|
import { YamlFileStore } from "./yaml-file-store";
|
|
6
9
|
import { ConfigError, type FrameworkConfig } from "./types";
|
|
7
10
|
|
|
@@ -141,6 +144,153 @@ function validatePluginDeclarations(config: FrameworkConfig): void {
|
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
export const AUDIENCE_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
148
|
+
|
|
149
|
+
const AUDIENCE_KEYS = ["capabilities", "plugins", "studio"] as const;
|
|
150
|
+
|
|
151
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
152
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateAudiencePlugins(audience: string, plugins: unknown): Map<string, string> {
|
|
156
|
+
if (!Array.isArray(plugins)) {
|
|
157
|
+
throw new ConfigError(`audience "${audience}": plugins must be a list.`);
|
|
158
|
+
}
|
|
159
|
+
const versions = new Map<string, string>();
|
|
160
|
+
for (const entry of plugins) {
|
|
161
|
+
if (
|
|
162
|
+
!isRecord(entry) ||
|
|
163
|
+
typeof entry.package !== "string" ||
|
|
164
|
+
typeof entry.version !== "string"
|
|
165
|
+
) {
|
|
166
|
+
throw new ConfigError(
|
|
167
|
+
`audience "${audience}": plugins entry must have string package and version: ${JSON.stringify(entry)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
entry.policy !== undefined &&
|
|
172
|
+
!PLUGIN_DECLARATION_POLICIES.includes(entry.policy as never)
|
|
173
|
+
) {
|
|
174
|
+
throw new ConfigError(
|
|
175
|
+
`audience "${audience}": plugins entry "${entry.package}": policy "${String(entry.policy)}" is not allowed for declared plugins (allowed: ${PLUGIN_DECLARATION_POLICIES.join(", ")}).`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (versions.has(entry.package)) {
|
|
179
|
+
throw new ConfigError(
|
|
180
|
+
`audience "${audience}": package "${entry.package}" is declared more than once.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
versions.set(entry.package, entry.version);
|
|
184
|
+
}
|
|
185
|
+
return versions;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateAudienceCapabilities(
|
|
189
|
+
audience: string,
|
|
190
|
+
capabilities: unknown,
|
|
191
|
+
baseNames: ReadonlySet<string>,
|
|
192
|
+
): void {
|
|
193
|
+
if (!Array.isArray(capabilities)) {
|
|
194
|
+
throw new ConfigError(`audience "${audience}": capabilities must be a list.`);
|
|
195
|
+
}
|
|
196
|
+
const builtin = new Set(
|
|
197
|
+
listSetupCapabilityCompatibilities().map((entry) => entry.capability.name),
|
|
198
|
+
);
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
for (const entry of capabilities) {
|
|
201
|
+
if (!isRecord(entry) || typeof entry.name !== "string" || entry.name === "") {
|
|
202
|
+
throw new ConfigError(
|
|
203
|
+
`audience "${audience}": capabilities entry must have a string name: ${JSON.stringify(entry)}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (builtin.has(entry.name)) {
|
|
207
|
+
throw new ConfigError(
|
|
208
|
+
`audience "${audience}": capability "${entry.name}" is built in; declare it in the base capabilities.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (baseNames.has(entry.name)) {
|
|
212
|
+
throw new ConfigError(
|
|
213
|
+
`audience "${audience}": capability "${entry.name}" is also declared in base capabilities.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (seen.has(entry.name)) {
|
|
217
|
+
throw new ConfigError(
|
|
218
|
+
`audience "${audience}": capability "${entry.name}" is declared more than once.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
seen.add(entry.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Validates the optional `audiences` mapping. Only companions may declare
|
|
227
|
+
* audiences; names are safe identifiers and keys form a closed set so typos
|
|
228
|
+
* fail instead of silently doing nothing.
|
|
229
|
+
*/
|
|
230
|
+
function validateAudiences(config: FrameworkConfig): void {
|
|
231
|
+
const audiences = config.audiences as unknown;
|
|
232
|
+
if (audiences === undefined) return;
|
|
233
|
+
if (config.type === "hub") {
|
|
234
|
+
throw new ConfigError('Only companions may declare audiences; a "hub" framework may not.');
|
|
235
|
+
}
|
|
236
|
+
if (!isRecord(audiences)) {
|
|
237
|
+
throw new ConfigError("audiences must be a mapping of audience name to configuration.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const basePackages = new Set((config.plugins ?? []).map((plugin) => plugin.package));
|
|
241
|
+
const baseCapabilities = new Set((config.capabilities ?? []).map((entry) => entry.name));
|
|
242
|
+
const seen = new Map<string, { audience: string; version: string }>();
|
|
243
|
+
|
|
244
|
+
for (const [name, audience] of Object.entries(audiences)) {
|
|
245
|
+
if (!AUDIENCE_NAME_PATTERN.test(name)) {
|
|
246
|
+
throw new ConfigError(
|
|
247
|
+
`Invalid audience name "${name}": must match ${AUDIENCE_NAME_PATTERN.source}.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (!isRecord(audience)) {
|
|
251
|
+
throw new ConfigError(`audience "${name}" must be a mapping.`);
|
|
252
|
+
}
|
|
253
|
+
for (const key of Object.keys(audience)) {
|
|
254
|
+
if (!AUDIENCE_KEYS.includes(key as never)) {
|
|
255
|
+
throw new ConfigError(
|
|
256
|
+
`audience "${name}": unknown key "${key}" (allowed: ${AUDIENCE_KEYS.join(", ")}).`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (audience.capabilities !== undefined) {
|
|
262
|
+
validateAudienceCapabilities(name, audience.capabilities, baseCapabilities);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (audience.plugins !== undefined) {
|
|
266
|
+
for (const [pkg, version] of validateAudiencePlugins(name, audience.plugins)) {
|
|
267
|
+
if (basePackages.has(pkg)) {
|
|
268
|
+
throw new ConfigError(
|
|
269
|
+
`audience "${name}": package "${pkg}" is also declared in base plugins.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const other = seen.get(pkg);
|
|
273
|
+
if (other && other.version !== version) {
|
|
274
|
+
throw new ConfigError(
|
|
275
|
+
`package "${pkg}" has conflicting versions in audiences "${other.audience}" (${other.version}) and "${name}" (${version}).`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
seen.set(pkg, other ?? { audience: name, version });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (audience.studio !== undefined) {
|
|
283
|
+
const agent =
|
|
284
|
+
isRecord(audience.studio) && isRecord(audience.studio.terminal)
|
|
285
|
+
? audience.studio.terminal.agent
|
|
286
|
+
: undefined;
|
|
287
|
+
if (agent !== undefined && typeof agent !== "string") {
|
|
288
|
+
throw new ConfigError(`audience "${name}": studio.terminal.agent must be a string.`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
144
294
|
export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
145
295
|
constructor(configPath = process.env.MATE_CONFIG ?? defaultConfigPath()) {
|
|
146
296
|
super(path.resolve(configPath));
|
|
@@ -150,6 +300,7 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
|
|
|
150
300
|
const merged = mergeWithDefaults(migrateProfilesToAllowedAgents(await super.load()));
|
|
151
301
|
validateHubConfig(merged);
|
|
152
302
|
validatePluginDeclarations(merged);
|
|
303
|
+
validateAudiences(merged);
|
|
153
304
|
return merged;
|
|
154
305
|
}
|
|
155
306
|
|
|
@@ -125,6 +125,18 @@ export interface StudioTerminalConfig {
|
|
|
125
125
|
agent?: string;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A named slice of a companion's configuration, activated by `MATE_AUDIENCE`.
|
|
130
|
+
* Plugins and capabilities add to the base lists; `studio.terminal.agent`
|
|
131
|
+
* overrides the base. Capabilities here name what a declared plugin provides,
|
|
132
|
+
* never a built-in one, so what `install` writes does not depend on the audience.
|
|
133
|
+
*/
|
|
134
|
+
export interface AudienceConfig {
|
|
135
|
+
capabilities?: CapabilityConfig[];
|
|
136
|
+
plugins?: PluginDeclaration[];
|
|
137
|
+
studio?: { terminal?: StudioTerminalConfig };
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
export interface FrameworkConfig {
|
|
129
141
|
type?: FrameworkType;
|
|
130
142
|
git?: GitModeProfile;
|
|
@@ -137,6 +149,7 @@ export interface FrameworkConfig {
|
|
|
137
149
|
packageManagers?: string[];
|
|
138
150
|
engines?: EngineConstraints;
|
|
139
151
|
studio?: { terminal?: StudioTerminalConfig };
|
|
152
|
+
audiences?: Record<string, AudienceConfig>;
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
export interface CompanionRegistryConfig {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
7
|
+
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
8
|
+
|
|
9
|
+
export const AUDIENCE_ENV = "MATE_AUDIENCE";
|
|
10
|
+
|
|
11
|
+
export type AudienceResolution =
|
|
12
|
+
| { ok: true; active: string | null }
|
|
13
|
+
| { ok: false; requested: string; declared: string[] };
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pluginList(value: unknown): unknown[] {
|
|
20
|
+
return isRecord(value) && Array.isArray(value.plugins) ? value.plugins : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function audienceEntry(raw: unknown, audience: string | null): unknown {
|
|
24
|
+
if (audience === null || !isRecord(raw) || !isRecord(raw.audiences)) return undefined;
|
|
25
|
+
return Object.hasOwn(raw.audiences, audience) ? raw.audiences[audience] : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function studioAgent(value: unknown): unknown {
|
|
29
|
+
if (!isRecord(value) || !isRecord(value.studio) || !isRecord(value.studio.terminal)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return value.studio.terminal.agent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `MATE_AUDIENCE` from the environment; unset, empty or blank means none. */
|
|
36
|
+
export function readAudienceSelection(env: Record<string, string | undefined>): string | null {
|
|
37
|
+
const value = env[AUDIENCE_ENV]?.trim();
|
|
38
|
+
return value ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Audience names declared by raw parsed `framework.yaml`, in declared order. */
|
|
42
|
+
export function declaredAudiences(raw: unknown): string[] {
|
|
43
|
+
return isRecord(raw) && isRecord(raw.audiences) ? Object.keys(raw.audiences) : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAudience(raw: unknown, audience: string | null): AudienceResolution {
|
|
47
|
+
if (audience === null) return { ok: true, active: null };
|
|
48
|
+
const declared = declaredAudiences(raw);
|
|
49
|
+
if (declared.includes(audience)) return { ok: true, active: audience };
|
|
50
|
+
return { ok: false, requested: audience, declared };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Base plugins followed by the active audience's, each declaration keeping
|
|
55
|
+
* its own `policy` and `config`. An unknown audience contributes nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function effectivePluginDeclarations(raw: unknown, audience: string | null): unknown[] {
|
|
58
|
+
return [...pluginList(raw), ...pluginList(audienceEntry(raw, audience))];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Base plugins followed by every audience's, for installation only: a
|
|
63
|
+
* package shared by audiences appears once since validation guarantees one
|
|
64
|
+
* version. Runtime declarations must come from `effectivePluginDeclarations`.
|
|
65
|
+
*/
|
|
66
|
+
export function allPluginDeclarations(raw: unknown): unknown[] {
|
|
67
|
+
const all = [...pluginList(raw)];
|
|
68
|
+
for (const name of declaredAudiences(raw)) {
|
|
69
|
+
all.push(...pluginList(audienceEntry(raw, name)));
|
|
70
|
+
}
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
return all.filter((entry) => {
|
|
73
|
+
if (!isRecord(entry) || typeof entry.package !== "string") return true;
|
|
74
|
+
const key = `${entry.package}@${String(entry.version)}`;
|
|
75
|
+
if (seen.has(key)) return false;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Operator-facing message for a `MATE_AUDIENCE` the companion does not declare. */
|
|
82
|
+
export function describeUnknownAudience(
|
|
83
|
+
resolution: Extract<AudienceResolution, { ok: false }>,
|
|
84
|
+
): string {
|
|
85
|
+
const declared = resolution.declared.length > 0 ? resolution.declared.join(", ") : "none";
|
|
86
|
+
return `unknown audience "${resolution.requested}" (${AUDIENCE_ENV}); declared audiences: ${declared}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Install-time declarations of an already validated config: base plus every audience. */
|
|
90
|
+
export function installPluginDeclarations(config: unknown): PluginDeclaration[] {
|
|
91
|
+
return allPluginDeclarations(config) as PluginDeclaration[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The active audience's Studio default agent, else the base one. Returned
|
|
96
|
+
* as found: the caller owns rejecting a value that is not a safe name.
|
|
97
|
+
*/
|
|
98
|
+
export function effectiveStudioAgent(raw: unknown, audience: string | null): unknown {
|
|
99
|
+
return studioAgent(audienceEntry(raw, audience)) ?? studioAgent(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Raw read of the companion's `framework.yaml`. Deliberately avoids
|
|
104
|
+
* `ConfigStore.load()` — hydration runs on every invocation and must never
|
|
105
|
+
* create or migrate config files as a side effect.
|
|
106
|
+
*/
|
|
107
|
+
export async function readFrameworkRaw(companionPath: string): Promise<unknown> {
|
|
108
|
+
try {
|
|
109
|
+
const text = await fs.readFile(
|
|
110
|
+
path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"),
|
|
111
|
+
"utf8",
|
|
112
|
+
);
|
|
113
|
+
return (parse(text) as unknown) ?? null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function readEffectiveDeclarations(
|
|
120
|
+
companionPath: string,
|
|
121
|
+
audience: string | null,
|
|
122
|
+
): Promise<unknown[]> {
|
|
123
|
+
return effectivePluginDeclarations(await readFrameworkRaw(companionPath), audience);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function readAllDeclarations(companionPath: string): Promise<unknown[]> {
|
|
127
|
+
return allPluginDeclarations(await readFrameworkRaw(companionPath));
|
|
128
|
+
}
|
|
@@ -7,7 +7,14 @@ import { CompanionResolver } from "../../../lib/orchestrator/companion-resolver"
|
|
|
7
7
|
import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
|
|
8
8
|
import type { Plugin } from "../plugin";
|
|
9
9
|
import type { PluginRegistry } from "../registry";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
describeUnknownAudience,
|
|
12
|
+
effectivePluginDeclarations,
|
|
13
|
+
readAudienceSelection,
|
|
14
|
+
readFrameworkRaw,
|
|
15
|
+
resolveAudience,
|
|
16
|
+
} from "./audiences";
|
|
17
|
+
import { validateDeclaration } from "./declarations";
|
|
11
18
|
import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
|
|
12
19
|
|
|
13
20
|
// Packages already registered in this process; re-hydration (e.g. right after
|
|
@@ -62,9 +69,9 @@ function activateDeclaredPlugin(plugin: Plugin): Plugin {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
|
-
* Registers the companion's
|
|
66
|
-
*
|
|
67
|
-
* after. Runs before cap-command detection on every invocation. No-op
|
|
72
|
+
* Registers the companion's effective plugins (base plus the active
|
|
73
|
+
* `MATE_AUDIENCE`) into the active registry — compiled-in plugins first
|
|
74
|
+
* (they are already registered), declared order after. Runs before cap-command detection on every invocation. No-op
|
|
68
75
|
* without a companion or `plugins:` entries; all diagnostics go to stderr;
|
|
69
76
|
* never throws.
|
|
70
77
|
*/
|
|
@@ -77,7 +84,12 @@ export async function hydrateDynamicPlugins(deps: HydrateDynamicPluginsDeps = {}
|
|
|
77
84
|
deps.companionPath ?? (await resolveCompanionQuietly(deps.cwd ?? process.cwd(), env));
|
|
78
85
|
if (!companionPath) return;
|
|
79
86
|
|
|
80
|
-
const
|
|
87
|
+
const raw = await readFrameworkRaw(companionPath);
|
|
88
|
+
const audience = resolveAudience(raw, readAudienceSelection(env));
|
|
89
|
+
if (!audience.ok) {
|
|
90
|
+
warn(`${describeUnknownAudience(audience)}; applying the base configuration only`);
|
|
91
|
+
}
|
|
92
|
+
const entries = effectivePluginDeclarations(raw, audience.ok ? audience.active : null);
|
|
81
93
|
if (entries.length === 0) return;
|
|
82
94
|
|
|
83
95
|
const registry = deps.registry ?? getActiveDistribution().registry;
|
|
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
|
|
3
3
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
4
4
|
import type { PluginDeclaration } from "../../../lib/orchestrator/types";
|
|
5
|
-
import {
|
|
5
|
+
import { readAudienceSelection, readEffectiveDeclarations } from "./audiences";
|
|
6
|
+
import { validateDeclaration } from "./declarations";
|
|
6
7
|
import { loadDynamicPlugin, type DynamicPluginLoadDeps } from "./loader";
|
|
7
8
|
import { pluginPackageRoot } from "./paths";
|
|
8
9
|
|
|
@@ -18,7 +19,8 @@ export interface PluginInspection {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* Strict, installation-free check of every
|
|
22
|
+
* Strict, installation-free check of every effective plugin (base plus the
|
|
23
|
+
* active `MATE_AUDIENCE`; inactive audiences are not checked): installed
|
|
22
24
|
* and loadable with the effective environment. Unlike hydration it fails
|
|
23
25
|
* closed on the first-class problems ordinary commands only warn about.
|
|
24
26
|
*/
|
|
@@ -37,7 +39,7 @@ export async function inspectDeclaredPlugins(
|
|
|
37
39
|
const failures: PluginVerificationFailure[] = [];
|
|
38
40
|
const capabilities: string[] = [];
|
|
39
41
|
const declarations: PluginDeclaration[] = [];
|
|
40
|
-
for (const entry of await
|
|
42
|
+
for (const entry of await readEffectiveDeclarations(companionPath, readAudienceSelection(env))) {
|
|
41
43
|
const { declaration, error } = validateDeclaration(entry);
|
|
42
44
|
if (declaration) declarations.push(declaration);
|
|
43
45
|
else failures.push({ package: "(malformed entry)", reason: error ?? "invalid plugins entry" });
|
package/src/tools/setup.ts
CHANGED
|
@@ -28,6 +28,12 @@ import { createUvPlugin } from "./setup/package-managers/uv";
|
|
|
28
28
|
import type { PackageManagerSetupDeps } from "./setup/package-managers/uv";
|
|
29
29
|
import { applyRequiredSelectionsToConfig } from "./setup/policy";
|
|
30
30
|
import { invalidateInstallState } from "../lib/install";
|
|
31
|
+
import {
|
|
32
|
+
describeUnknownAudience,
|
|
33
|
+
installPluginDeclarations,
|
|
34
|
+
readAudienceSelection,
|
|
35
|
+
resolveAudience,
|
|
36
|
+
} from "./setup/dynamic-plugins/audiences";
|
|
31
37
|
import { hydrateDynamicPlugins } from "./setup/dynamic-plugins/hydrate";
|
|
32
38
|
import { installDeclaredPlugins } from "./setup/dynamic-plugins/install";
|
|
33
39
|
import {
|
|
@@ -139,6 +145,8 @@ export async function executeSetup(
|
|
|
139
145
|
deps.configStore ??
|
|
140
146
|
new ConfigStore(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
|
|
141
147
|
const config = mergeWithDefaults(await configStore.load());
|
|
148
|
+
const audience = resolveAudience(config, readAudienceSelection(process.env));
|
|
149
|
+
if (!audience.ok) throw new ConfigError(describeUnknownAudience(audience));
|
|
142
150
|
if (input.allowedAgents !== undefined) {
|
|
143
151
|
config.allowedAgents = [...new Set(input.allowedAgents)];
|
|
144
152
|
}
|
|
@@ -162,8 +170,9 @@ export async function executeSetup(
|
|
|
162
170
|
// Declared plugin packages install and hydrate before the setup plan runs,
|
|
163
171
|
// so a fresh clone reaches a fully applied state from one setup run
|
|
164
172
|
// (install → load → plan).
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
const declarations = installPluginDeclarations(config);
|
|
174
|
+
if (declarations.length) {
|
|
175
|
+
for (const result of await installDeclaredPlugins(companionPath, declarations)) {
|
|
167
176
|
if (result.status === "failed") {
|
|
168
177
|
process.stderr.write(
|
|
169
178
|
`${FRAMEWORK_NAME}: plugin "${result.package}" failed to install: ${result.error ?? "unknown error"}\n`,
|