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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +4 -0
  2. package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
  3. package/claude-plugin/hooks/hooks.json +37 -0
  4. package/claude-plugin/hooks/session-banner.mjs +8 -0
  5. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  6. package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
  7. package/package.json +4 -2
  8. package/src/cli/commands/artifact/finish/openspec.ts +1 -5
  9. package/src/cli/commands/companion/hub.ts +99 -0
  10. package/src/cli/commands/companion/link.ts +0 -5
  11. package/src/cli/commands/companion/tui.ts +3 -2
  12. package/src/cli/commands/doctor.ts +254 -170
  13. package/src/cli/commands/install.ts +18 -14
  14. package/src/cli/commands/launch/shared.ts +4 -4
  15. package/src/cli/commands/plugin/install.ts +94 -0
  16. package/src/cli/commands/plugin/plugin.ts +17 -0
  17. package/src/cli/commands/setup.ts +4 -6
  18. package/src/cli/commands/update.ts +9 -11
  19. package/src/cli/companion-link-wizard.tsx +22 -78
  20. package/src/cli/main.ts +57 -13
  21. package/src/cli/plugin-commands.ts +4 -4
  22. package/src/cli/repo-list-table.tsx +2 -7
  23. package/src/cli/usage.ts +7 -3
  24. package/src/distribution.ts +3 -5
  25. package/src/framework.ts +4 -36
  26. package/src/hooks/artifact-finish-nudge.ts +212 -0
  27. package/src/hooks/session-banner.ts +25 -0
  28. package/src/hooks/validate-artifact-path.ts +236 -0
  29. package/src/index.ts +4 -0
  30. package/src/lib/context-mode-package.ts +5 -3
  31. package/src/lib/install.ts +31 -43
  32. package/src/lib/orchestrator/adapters/base.ts +7 -8
  33. package/src/lib/orchestrator/adapters/claude.ts +16 -0
  34. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  35. package/src/lib/orchestrator/companion-hub.ts +380 -0
  36. package/src/lib/orchestrator/companion-store.ts +1 -35
  37. package/src/lib/orchestrator/config-store.ts +94 -7
  38. package/src/lib/orchestrator/editor.ts +4 -20
  39. package/src/lib/orchestrator/framework-context.ts +119 -24
  40. package/src/lib/orchestrator/global-config-store.ts +1 -5
  41. package/src/lib/orchestrator/launcher.ts +3 -9
  42. package/src/lib/orchestrator/migration.ts +0 -23
  43. package/src/lib/orchestrator/opencode-guidance.ts +1 -2
  44. package/src/lib/orchestrator/repo-local-registry.ts +3 -1
  45. package/src/lib/orchestrator/root-context.ts +117 -0
  46. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  47. package/src/lib/orchestrator/setup-preflight.ts +5 -18
  48. package/src/lib/orchestrator/types.ts +27 -19
  49. package/src/lib/orchestrator/working-repo-store.ts +9 -3
  50. package/src/lib/package-paths.ts +37 -0
  51. package/src/lib/update-checker.ts +5 -5
  52. package/src/playbooks/companion-guidance.ts +7 -7
  53. package/src/runtime/env.ts +0 -4
  54. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  55. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  56. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  57. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  58. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
  59. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  60. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  61. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3541 -0
  62. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  63. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  64. package/src/tools/setup/capabilities/graphify.ts +86 -295
  65. package/src/tools/setup/capabilities/openspec.ts +27 -30
  66. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  67. package/src/tools/setup/capabilities/rtk.ts +4 -1
  68. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  69. package/src/tools/setup/capabilities/tokensave.ts +50 -71
  70. package/src/tools/setup/context-services.ts +29 -0
  71. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  72. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  73. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  74. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  75. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  76. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  77. package/src/tools/setup/engine.ts +65 -1
  78. package/src/tools/setup/mate.ts +28 -31
  79. package/src/tools/setup/plugin.ts +81 -0
  80. package/src/tools/setup/plugins/gitignore.ts +19 -9
  81. package/src/tools/setup/plugins/guidance.ts +2 -6
  82. package/src/tools/setup/policy.ts +4 -7
  83. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  84. package/src/tools/setup/providers/claude-format.ts +159 -0
  85. package/src/tools/setup/providers/claude.ts +283 -292
  86. package/src/tools/setup/providers/opencode-format.ts +146 -0
  87. package/src/tools/setup/providers/opencode.ts +171 -66
  88. package/src/tools/setup/providers/skill-tree.ts +38 -0
  89. package/src/tools/setup.ts +12 -7
  90. package/src/tui.ts +5 -0
  91. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
  92. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
  93. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
  94. package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
@@ -0,0 +1,380 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { parse } from "yaml";
6
+
7
+ import { FRAMEWORK_NAME } from "../../framework";
8
+ import { ConfigStore, validateHubConfig } from "./config-store";
9
+ import { GlobalConfigStore } from "./global-config-store";
10
+ import type { FrameworkConfig, HubMember, HubMemberSource } from "./types";
11
+ import {
12
+ installDeclaredPlugins,
13
+ type PluginInstallDeps,
14
+ type PluginInstallResult,
15
+ } from "../../tools/setup/dynamic-plugins/install";
16
+ import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
17
+ import { findRepoLocalRegistryFile } from "./repo-local-registry";
18
+
19
+ export interface GitCommandResult {
20
+ status: number | null;
21
+ stdout: string;
22
+ stderr: string;
23
+ }
24
+
25
+ export type GitCommand = (cwd: string, args: string[]) => GitCommandResult;
26
+
27
+ export interface HubSource {
28
+ kind: "git" | "local";
29
+ path?: string;
30
+ url?: string;
31
+ ref?: string;
32
+ }
33
+
34
+ export interface HubSyncResult {
35
+ id: string;
36
+ status: "updated" | "up-to-date" | "local-only" | "dirty" | "divergent" | "failed";
37
+ message: string;
38
+ materializedCommit?: string;
39
+ }
40
+
41
+ export interface HubPluginSyncDeps {
42
+ installDeps?: PluginInstallDeps;
43
+ hydrate?: (options: { companionPath: string }) => Promise<void>;
44
+ }
45
+
46
+ export function defaultGitCommand(cwd: string, args: string[]): GitCommandResult {
47
+ const result = spawnSync("git", ["-C", cwd, ...args], {
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ });
51
+ return {
52
+ status: result.status,
53
+ stdout: String(result.stdout ?? "").trim(),
54
+ stderr: String(result.stderr ?? "").trim(),
55
+ };
56
+ }
57
+
58
+ function isInsideDir(parent: string, candidate: string): boolean {
59
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
60
+ return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
61
+ }
62
+
63
+ export function normalizeHubMemberId(value: string): string {
64
+ const normalized = value
65
+ .replace(/\.git$/i, "")
66
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
67
+ .replace(/^-+|-+$/g, "");
68
+ if (!normalized) throw new Error(`Cannot derive a hub member id from: ${value}`);
69
+ return normalized;
70
+ }
71
+
72
+ export function validateHubMemberPath(hubPath: string, memberPath: string): string {
73
+ if (!memberPath || path.isAbsolute(memberPath)) {
74
+ throw new Error("Hub member path must be relative to the hub root.");
75
+ }
76
+ const resolved = path.resolve(hubPath, memberPath);
77
+ if (!isInsideDir(hubPath, resolved)) {
78
+ throw new Error(`Hub member path resolves outside the hub root: ${memberPath}`);
79
+ }
80
+ return resolved;
81
+ }
82
+
83
+ function frameworkConfigPath(root: string): string {
84
+ return path.join(root, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
85
+ }
86
+
87
+ function memberPathForId(id: string): string {
88
+ return path.join("companions", id);
89
+ }
90
+
91
+ async function assertHubRoot(
92
+ hubPath: string,
93
+ ): Promise<{ config: FrameworkConfig; store: ConfigStore }> {
94
+ const resolved = path.resolve(hubPath);
95
+ const configStore = new ConfigStore(frameworkConfigPath(resolved));
96
+ const config = await configStore.load();
97
+ if (config.type !== "hub") {
98
+ throw new Error(`Not a companion hub: ${resolved}`);
99
+ }
100
+ validateHubConfig(config);
101
+ return { config, store: configStore };
102
+ }
103
+
104
+ export async function initializeCompanionHub(
105
+ folder: string,
106
+ globalConfigStore = new GlobalConfigStore(),
107
+ ): Promise<string> {
108
+ const hubPath = path.resolve(folder);
109
+ const linkedRepo = await findRepoLocalRegistryFile(hubPath);
110
+ if (linkedRepo) {
111
+ throw new Error(
112
+ `Cannot initialize a hub inside linked working repository: ${linkedRepo.repoRoot}`,
113
+ );
114
+ }
115
+ await fs.mkdir(hubPath, { recursive: true });
116
+ const configStore = new ConfigStore(frameworkConfigPath(hubPath));
117
+ const existing = await fs.stat(configStore.configPath).catch(() => null);
118
+ if (existing) {
119
+ const current = await configStore.load();
120
+ if (current.type !== "hub") {
121
+ throw new Error(`Cannot initialize hub over an existing non-hub framework: ${hubPath}`);
122
+ }
123
+ } else {
124
+ const config: FrameworkConfig = {
125
+ type: "hub",
126
+ allowedAgents: [],
127
+ packageManagers: [],
128
+ capabilities: [],
129
+ hub: { companions: [] },
130
+ };
131
+ await configStore.save(config);
132
+ }
133
+ await globalConfigStore.register(hubPath);
134
+ return hubPath;
135
+ }
136
+
137
+ export function discoverGitSource(
138
+ sourcePath: string,
139
+ git: GitCommand = defaultGitCommand,
140
+ ): HubSource {
141
+ const root = git(sourcePath, ["rev-parse", "--show-toplevel"]);
142
+ if (root.status !== 0 || !root.stdout) return { kind: "local", path: path.resolve(sourcePath) };
143
+
144
+ const remote = git(sourcePath, ["config", "--get", "remote.origin.url"]);
145
+ if (remote.status !== 0 || !remote.stdout)
146
+ return { kind: "local", path: path.resolve(sourcePath) };
147
+
148
+ const branch = git(sourcePath, ["symbolic-ref", "--short", "HEAD"]);
149
+ return {
150
+ kind: "git",
151
+ path: path.resolve(sourcePath),
152
+ url: remote.stdout,
153
+ ref: branch.status === 0 && branch.stdout ? branch.stdout : undefined,
154
+ };
155
+ }
156
+
157
+ export function discoverHubSource(source: string, git: GitCommand = defaultGitCommand): HubSource {
158
+ const trimmed = source.trim();
159
+ if (/^(?:https?|ssh|git):\/\//i.test(trimmed) || trimmed.startsWith("git@")) {
160
+ return { kind: "git", url: trimmed };
161
+ }
162
+ return discoverGitSource(path.resolve(trimmed), git);
163
+ }
164
+
165
+ function sourceForManifest(source: HubSource, fallbackPath: string): HubMemberSource {
166
+ if (source.kind === "git") {
167
+ return { kind: "git", url: source.url, ref: source.ref };
168
+ }
169
+ return { kind: "local", path: source.path ?? fallbackPath };
170
+ }
171
+
172
+ function gitOutputOrThrow(result: GitCommandResult, operation: string): string {
173
+ if (result.status !== 0) {
174
+ throw new Error(
175
+ `${operation} failed: ${result.stderr || result.stdout || "unknown Git error"}`,
176
+ );
177
+ }
178
+ return result.stdout;
179
+ }
180
+
181
+ async function copyWithoutGit(sourcePath: string, destination: string): Promise<void> {
182
+ await fs.cp(sourcePath, destination, {
183
+ recursive: true,
184
+ filter: (candidate) => path.basename(candidate) !== ".git",
185
+ });
186
+ }
187
+
188
+ async function assertMaterializedCompanion(memberPath: string): Promise<void> {
189
+ const configPath = frameworkConfigPath(memberPath);
190
+ const raw = await fs.readFile(configPath, "utf8").catch(() => "");
191
+ const config = parse(raw) as { type?: unknown } | null;
192
+ if (config?.type !== "companion") {
193
+ throw new Error(`Materialized child must declare type: companion: ${memberPath}`);
194
+ }
195
+ }
196
+
197
+ async function currentCommit(memberPath: string, git: GitCommand): Promise<string> {
198
+ return gitOutputOrThrow(git(memberPath, ["rev-parse", "HEAD"]), `Reading ${memberPath} commit`);
199
+ }
200
+
201
+ export async function materializeHubMember(
202
+ hubPath: string,
203
+ source: HubSource,
204
+ options: { id?: string; memberPath?: string; git?: GitCommand } = {},
205
+ ): Promise<HubMember> {
206
+ const git = options.git ?? defaultGitCommand;
207
+ const sourceName = source.url ?? source.path ?? "companion";
208
+ const id = normalizeHubMemberId(options.id ?? path.basename(sourceName));
209
+ const relativePath = options.memberPath ?? memberPathForId(id);
210
+ const destination = validateHubMemberPath(hubPath, relativePath);
211
+ const target = await fs.stat(destination).catch(() => null);
212
+ if (target) throw new Error(`Hub member destination already exists: ${relativePath}`);
213
+
214
+ await fs.mkdir(path.dirname(destination), { recursive: true });
215
+ try {
216
+ if (source.kind === "git") {
217
+ const args = ["clone"];
218
+ if (source.ref) args.push("--branch", source.ref);
219
+ args.push(source.url!, destination);
220
+ gitOutputOrThrow(git(hubPath, args), `Cloning ${source.url}`);
221
+ } else {
222
+ await copyWithoutGit(source.path!, destination);
223
+ }
224
+
225
+ await assertMaterializedCompanion(destination);
226
+ const member: HubMember = {
227
+ id,
228
+ path: relativePath,
229
+ source: sourceForManifest(source, source.path ?? relativePath),
230
+ };
231
+ if (source.kind === "git") member.materializedCommit = await currentCommit(destination, git);
232
+ return member;
233
+ } catch (error) {
234
+ await fs.rm(destination, { recursive: true, force: true });
235
+ throw error;
236
+ }
237
+ }
238
+
239
+ export async function addHubMember(
240
+ hubPath: string,
241
+ source: HubSource,
242
+ options: { id?: string; memberPath?: string; git?: GitCommand } = {},
243
+ ): Promise<HubMember> {
244
+ const { config, store } = await assertHubRoot(hubPath);
245
+ const member = await materializeHubMember(hubPath, source, options);
246
+ if (config.hub!.companions.some((candidate) => candidate.id === member.id)) {
247
+ await fs.rm(path.resolve(hubPath, member.path), { recursive: true, force: true });
248
+ throw new Error(`Hub member id already exists: ${member.id}`);
249
+ }
250
+ config.hub!.companions.push(member);
251
+ await store.save(config);
252
+ return member;
253
+ }
254
+
255
+ function syncTarget(memberPath: string, git: GitCommand): string | null {
256
+ const upstream = git(memberPath, [
257
+ "rev-parse",
258
+ "--abbrev-ref",
259
+ "--symbolic-full-name",
260
+ "@{upstream}",
261
+ ]);
262
+ if (upstream.status !== 0 || !upstream.stdout) return null;
263
+ return upstream.stdout;
264
+ }
265
+
266
+ export async function syncHubMember(
267
+ hubPath: string,
268
+ member: HubMember,
269
+ git: GitCommand = defaultGitCommand,
270
+ ): Promise<HubSyncResult> {
271
+ const memberPath = path.resolve(hubPath, member.path);
272
+ if (member.source.kind === "local") {
273
+ return { id: member.id, status: "local-only", message: "local-only child has no Git source" };
274
+ }
275
+
276
+ const dirty = git(memberPath, ["status", "--porcelain=v1", "--untracked-files=all"]);
277
+ if (dirty.status !== 0) {
278
+ return {
279
+ id: member.id,
280
+ status: "failed",
281
+ message: dirty.stderr || "unable to inspect Git state",
282
+ };
283
+ }
284
+ if (dirty.stdout) {
285
+ return { id: member.id, status: "dirty", message: "local changes must be resolved first" };
286
+ }
287
+
288
+ const fetch = git(memberPath, ["fetch", "origin"]);
289
+ if (fetch.status !== 0) {
290
+ return { id: member.id, status: "failed", message: fetch.stderr || "fetch failed" };
291
+ }
292
+ const target = syncTarget(memberPath, git);
293
+ if (!target) {
294
+ return { id: member.id, status: "failed", message: "no tracked upstream branch" };
295
+ }
296
+ const counts = git(memberPath, ["rev-list", "--left-right", "--count", `HEAD...${target}`]);
297
+ if (counts.status !== 0) {
298
+ return {
299
+ id: member.id,
300
+ status: "failed",
301
+ message: counts.stderr || "unable to compare branches",
302
+ };
303
+ }
304
+ const [ahead, behind] = counts.stdout.split(/\s+/).map(Number);
305
+ if (ahead > 0 && behind > 0) {
306
+ return {
307
+ id: member.id,
308
+ status: "divergent",
309
+ message: "local and remote branches have diverged",
310
+ };
311
+ }
312
+ if (behind === 0) {
313
+ const commit = await currentCommit(memberPath, git);
314
+ return {
315
+ id: member.id,
316
+ status: "up-to-date",
317
+ message: "already up to date",
318
+ materializedCommit: commit,
319
+ };
320
+ }
321
+
322
+ const readTree = git(memberPath, ["read-tree", "-u", "-m", target]);
323
+ if (readTree.status !== 0) {
324
+ return {
325
+ id: member.id,
326
+ status: "failed",
327
+ message: readTree.stderr || "fast-forward worktree update failed",
328
+ };
329
+ }
330
+ const branch = git(memberPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
331
+ if (branch.status !== 0 || !branch.stdout) {
332
+ return { id: member.id, status: "failed", message: "cannot advance a detached HEAD" };
333
+ }
334
+ const updateRef = git(memberPath, ["update-ref", `refs/heads/${branch.stdout}`, target, "HEAD"]);
335
+ if (updateRef.status !== 0) {
336
+ return {
337
+ id: member.id,
338
+ status: "failed",
339
+ message: updateRef.stderr || "fast-forward ref update failed",
340
+ };
341
+ }
342
+ const commit = await currentCommit(memberPath, git);
343
+ return {
344
+ id: member.id,
345
+ status: "updated",
346
+ message: `fast-forwarded to ${commit}`,
347
+ materializedCommit: commit,
348
+ };
349
+ }
350
+
351
+ export async function syncHub(
352
+ hubPath: string,
353
+ git: GitCommand = defaultGitCommand,
354
+ ): Promise<HubSyncResult[]> {
355
+ const { config, store } = await assertHubRoot(hubPath);
356
+ const results: HubSyncResult[] = [];
357
+ for (const member of config.hub!.companions) {
358
+ const result = await syncHubMember(hubPath, member, git);
359
+ results.push(result);
360
+ if (result.materializedCommit && result.status === "updated") {
361
+ member.materializedCommit = result.materializedCommit;
362
+ }
363
+ }
364
+ if (results.some((result) => result.status === "updated")) await store.save(config);
365
+ return results;
366
+ }
367
+
368
+ export async function updateHubPlugins(
369
+ hubPath: string,
370
+ deps: HubPluginSyncDeps = {},
371
+ ): Promise<PluginInstallResult[]> {
372
+ const { config } = await assertHubRoot(hubPath);
373
+ const declarations = config.plugins ?? [];
374
+ if (declarations.length === 0) return [];
375
+
376
+ const resolvedHubPath = path.resolve(hubPath);
377
+ const results = await installDeclaredPlugins(resolvedHubPath, declarations, deps.installDeps);
378
+ await (deps.hydrate ?? hydrateDynamicPlugins)({ companionPath: resolvedHubPath });
379
+ return results;
380
+ }
@@ -3,13 +3,7 @@ import path from "node:path";
3
3
 
4
4
  import { ConfigStore } from "./config-store";
5
5
  import { writeRepoLocalRegistryEntry } from "./repo-local-registry";
6
- import {
7
- type CompanionSource,
8
- ConfigError,
9
- type FrameworkConfig,
10
- type LinkedRepository,
11
- type PolicySettings,
12
- } from "./types";
6
+ import { type CompanionSource, ConfigError, type LinkedRepository } from "./types";
13
7
  import { WorkingRepoStore } from "./working-repo-store";
14
8
 
15
9
  export class CompanionStore {
@@ -68,32 +62,4 @@ export class CompanionStore {
68
62
  const working = await this.workingRepoStore.load();
69
63
  return working.repos;
70
64
  }
71
-
72
- async resolvePolicy(repositoryId: string): Promise<PolicySettings> {
73
- const [config, working] = await Promise.all([
74
- this.configStore.load(),
75
- this.workingRepoStore.load(),
76
- ]);
77
- return resolvePolicyFromConfig(config, working.repos, repositoryId);
78
- }
79
- }
80
-
81
- export function resolvePolicyFromConfig(
82
- config: FrameworkConfig,
83
- repos: LinkedRepository[],
84
- repositoryId: string,
85
- ): PolicySettings {
86
- const repository = repos.find((r) => r.id === repositoryId);
87
- if (!repository) {
88
- throw new ConfigError(`Unknown linked repository: ${repositoryId}`);
89
- }
90
-
91
- const profile = config.profiles[repository.profile];
92
- if (!profile) {
93
- throw new ConfigError(`Unknown policy profile: ${repository.profile}`);
94
- }
95
-
96
- return {
97
- allowedAgents: repository.overrides?.allowedAgents ?? profile.allowedAgents,
98
- };
99
65
  }
@@ -7,6 +7,8 @@ import { ConfigError, type FrameworkConfig } from "./types";
7
7
 
8
8
  export const RTK_CAPABILITY_SPLIT_MIGRATION = "rtk-capability-split-v1";
9
9
 
10
+ const HUB_MEMBER_SOURCE_KINDS = ["git", "local"] as const;
11
+
10
12
  function defaultConfigPath(): string {
11
13
  return `.${FRAMEWORK_NAME}/config/framework.yaml`;
12
14
  }
@@ -15,22 +17,37 @@ export function defaultConfig(): FrameworkConfig {
15
17
  const defaults = getDefaultSetupSelections();
16
18
  return {
17
19
  type: "companion",
18
- profiles: {
19
- default: {
20
- name: "default",
21
- allowedAgents: defaults.allowedAgents,
22
- },
23
- },
20
+ allowedAgents: defaults.allowedAgents,
24
21
  packageManagers: defaults.packageManagers,
25
22
  capabilities: defaults.capabilities,
26
23
  };
27
24
  }
28
25
 
26
+ interface LegacyProfilesShape {
27
+ profiles?: Record<string, { name?: string; allowedAgents?: string[] }>;
28
+ }
29
+
30
+ /**
31
+ * Silently collapses the legacy `profiles` map into the flat `allowedAgents`
32
+ * list (`profiles.default.allowedAgents` wins; other profiles are dropped).
33
+ * The new shape persists on the next save.
34
+ */
35
+ export function migrateProfilesToAllowedAgents(config: FrameworkConfig): FrameworkConfig {
36
+ const legacy = config as FrameworkConfig & LegacyProfilesShape;
37
+ if (!legacy.profiles) return config;
38
+ const { profiles, ...rest } = legacy;
39
+ return {
40
+ ...rest,
41
+ allowedAgents: rest.allowedAgents ?? profiles.default?.allowedAgents ?? [],
42
+ };
43
+ }
44
+
29
45
  export function mergeWithDefaults(existing: FrameworkConfig): FrameworkConfig {
30
46
  const defaults = defaultConfig();
31
47
  return {
32
48
  ...existing,
33
49
  type: existing.type ?? defaults.type,
50
+ allowedAgents: existing.allowedAgents ?? [],
34
51
  packageManagers: existing.packageManagers ?? defaults.packageManagers,
35
52
  // Only backfill capabilities for legacy configs that predate the field
36
53
  // entirely. Once a capabilities array has been persisted, it reflects the
@@ -54,6 +71,75 @@ export function migrateRtkCapabilitySplit(config: FrameworkConfig): FrameworkCon
54
71
  };
55
72
  }
56
73
 
74
+ /** Validates the shape of a hub manifest before it is used. */
75
+ export function validateHubConfig(config: FrameworkConfig): void {
76
+ if (config.type !== "hub") {
77
+ if (config.hub !== undefined) {
78
+ throw new ConfigError('Only a framework with type "hub" may define hub.companions.');
79
+ }
80
+ return;
81
+ }
82
+
83
+ const members = config.hub?.companions;
84
+ if (!Array.isArray(members)) {
85
+ throw new ConfigError('A "hub" framework requires a hub.companions array.');
86
+ }
87
+
88
+ const ids = new Set<string>();
89
+ for (const member of members) {
90
+ if (!member || typeof member !== "object") {
91
+ throw new ConfigError("Each hub member must be an object.");
92
+ }
93
+
94
+ if (typeof member.id !== "string" || member.id.trim() === "") {
95
+ throw new ConfigError("Each hub member requires a non-empty id.");
96
+ }
97
+ if (ids.has(member.id)) {
98
+ throw new ConfigError(`Hub member id is duplicated: ${member.id}`);
99
+ }
100
+ ids.add(member.id);
101
+
102
+ if (
103
+ typeof member.path !== "string" ||
104
+ member.path.trim() === "" ||
105
+ path.isAbsolute(member.path) ||
106
+ path.normalize(member.path) === "." ||
107
+ path.normalize(member.path) === ".." ||
108
+ path.normalize(member.path).startsWith(`..${path.sep}`)
109
+ ) {
110
+ throw new ConfigError(`Hub member "${member.id}" path must be a relative child path.`);
111
+ }
112
+
113
+ const source = member.source;
114
+ if (!source || typeof source !== "object") {
115
+ throw new ConfigError(`Hub member "${member.id}" requires source provenance.`);
116
+ }
117
+ if (!HUB_MEMBER_SOURCE_KINDS.includes(source.kind)) {
118
+ throw new ConfigError(
119
+ `Hub member "${member.id}" source kind must be one of: ${HUB_MEMBER_SOURCE_KINDS.join(", ")}.`,
120
+ );
121
+ }
122
+ if (source.kind === "git" && (typeof source.url !== "string" || source.url.trim() === "")) {
123
+ throw new ConfigError(`Git-backed hub member "${member.id}" requires a source URL.`);
124
+ }
125
+ if (source.ref !== undefined && (typeof source.ref !== "string" || source.ref.trim() === "")) {
126
+ throw new ConfigError(`Hub member "${member.id}" source ref must be a non-empty string.`);
127
+ }
128
+ if (
129
+ source.kind === "git" &&
130
+ (typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
131
+ ) {
132
+ throw new ConfigError(`Git-backed hub member "${member.id}" requires materializedCommit.`);
133
+ }
134
+ if (
135
+ member.materializedCommit !== undefined &&
136
+ (typeof member.materializedCommit !== "string" || member.materializedCommit.trim() === "")
137
+ ) {
138
+ throw new ConfigError(`Hub member "${member.id}" materializedCommit must be non-empty.`);
139
+ }
140
+ }
141
+ }
142
+
57
143
  export const PLUGIN_DECLARATION_POLICIES = ["default", "optional"] as const;
58
144
 
59
145
  /**
@@ -77,7 +163,8 @@ export class ConfigStore extends YamlFileStore<FrameworkConfig> {
77
163
  }
78
164
 
79
165
  override async load(): Promise<FrameworkConfig> {
80
- const merged = mergeWithDefaults(await super.load());
166
+ const merged = mergeWithDefaults(migrateProfilesToAllowedAgents(await super.load()));
167
+ validateHubConfig(merged);
81
168
  validatePluginDeclarations(merged);
82
169
  const needsMigration = !(merged.migrations ?? []).includes(RTK_CAPABILITY_SPLIT_MIGRATION);
83
170
  const config = migrateRtkCapabilitySplit(merged);
@@ -141,7 +141,7 @@ export function detectEditorWorkspaceState(
141
141
  }
142
142
 
143
143
  export async function injectEditorFolder(
144
- companionPath: string | string[],
144
+ companionPath: string,
145
145
  repoPath: string,
146
146
  cli = "code",
147
147
  spawnProcess: SpawnLike = spawn,
@@ -163,9 +163,7 @@ export async function injectEditorFolder(
163
163
 
164
164
  const { cli: resolvedCli, binary } = resolved;
165
165
 
166
- const companionPaths = (Array.isArray(companionPath) ? companionPath : [companionPath]).map(
167
- (candidate) => path.resolve(candidate),
168
- );
166
+ const workspaceFolders = [path.resolve(repoPath), path.resolve(companionPath)];
169
167
 
170
168
  const workspacePath = editorWorkspacePath(repoPath);
171
169
  await fs.mkdir(path.dirname(workspacePath), { recursive: true });
@@ -173,10 +171,7 @@ export async function injectEditorFolder(
173
171
  workspacePath,
174
172
  `${JSON.stringify(
175
173
  {
176
- folders: [
177
- { path: path.resolve(repoPath) },
178
- ...companionPaths.map((candidate) => ({ path: candidate })),
179
- ],
174
+ folders: workspaceFolders.map((candidate) => ({ path: candidate })),
180
175
  },
181
176
  null,
182
177
  2,
@@ -186,7 +181,7 @@ export async function injectEditorFolder(
186
181
 
187
182
  if (detectWorkspace(workspacePath, resolvedCli) === "open") return true;
188
183
 
189
- const child = spawnProcess(binary, ["--add", path.resolve(repoPath), ...companionPaths], {
184
+ const child = spawnProcess(binary, ["--add", ...workspaceFolders], {
190
185
  stdio: "ignore",
191
186
  detached: true,
192
187
  });
@@ -197,14 +192,3 @@ export async function injectEditorFolder(
197
192
 
198
193
  return true;
199
194
  }
200
-
201
- export function openEditorWorkspace(paths: string[], cli: string): boolean {
202
- const binary = resolveEditorBinary(cli);
203
- if (!binary) {
204
- writeMissingEditorCliGuidance(cli);
205
- return false;
206
- }
207
-
208
- execFileSync(binary, paths, { stdio: "ignore" });
209
- return true;
210
- }