@uniqbit/mate-core 0.15.4-canary.8 → 0.15.4

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 (44) hide show
  1. package/claude-plugin/hooks/artifact-finish-nudge.mjs +5 -3
  2. package/claude-plugin/hooks/session-banner.mjs +5 -3
  3. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  4. package/claude-plugin/hooks/validate-artifact-path.mjs +5 -3
  5. package/package.json +1 -1
  6. package/src/cli/commands/cap/openspec.ts +20 -1
  7. package/src/cli/commands/companion/hub.ts +1 -0
  8. package/src/cli/commands/plugin/install.ts +15 -3
  9. package/src/hooks/artifact-finish-nudge.ts +35 -3
  10. package/src/lib/context-mode-package.ts +5 -3
  11. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  12. package/src/lib/orchestrator/companion-git-sync.ts +67 -8
  13. package/src/lib/orchestrator/companion-hub.ts +28 -6
  14. package/src/lib/orchestrator/types.ts +2 -2
  15. package/src/lib/package-paths.ts +1 -0
  16. package/src/opencode/companion-hooks.ts +33 -12
  17. package/src/playbooks/companion-guidance.ts +1 -1
  18. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
  19. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
  20. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  21. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  22. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3612 -0
  23. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  24. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  25. package/src/tools/setup/capabilities/graphify.ts +86 -295
  26. package/src/tools/setup/capabilities/openspec.ts +34 -1
  27. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  28. package/src/tools/setup/capabilities/rtk.ts +4 -1
  29. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  30. package/src/tools/setup/capabilities/tokensave.ts +51 -72
  31. package/src/tools/setup/context-services.ts +29 -0
  32. package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
  33. package/src/tools/setup/engine.ts +68 -1
  34. package/src/tools/setup/mate.ts +10 -2
  35. package/src/tools/setup/plugin.ts +105 -0
  36. package/src/tools/setup/plugins/gitignore.ts +7 -4
  37. package/src/tools/setup/plugins/guidance.ts +1 -1
  38. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  39. package/src/tools/setup/providers/claude-format.ts +159 -0
  40. package/src/tools/setup/providers/claude.ts +281 -227
  41. package/src/tools/setup/providers/opencode-format.ts +146 -0
  42. package/src/tools/setup/providers/opencode.ts +209 -60
  43. package/src/tools/setup/providers/skill-tree.ts +38 -0
  44. package/src/tools/setup.ts +7 -56
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { McpEntryDescriptor } from "./claude-format";
5
+
6
+ // OpenCode runtime config format primitives: parse/serialize for
7
+ // `opencode.json`/`tui.json`, plugin-reference array handling, and MCP entry
8
+ // shapes. This module owns the file formats only — which entries are
9
+ // Mate-managed is the callers' (Runtime Surface) knowledge.
10
+
11
+ export type OpenCodeConfig = Record<string, unknown>;
12
+
13
+ export function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ /** Tolerant read; `present` distinguishes absent/malformed from empty. */
18
+ export async function readOpenCodeConfig(
19
+ configPath: string,
20
+ ): Promise<{ present: boolean; config: OpenCodeConfig }> {
21
+ try {
22
+ const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
23
+ if (isRecord(parsed)) {
24
+ return { present: true, config: parsed };
25
+ }
26
+ } catch {
27
+ // Absent or unparseable — start from an empty object.
28
+ }
29
+ return { present: false, config: {} };
30
+ }
31
+
32
+ export async function writeOpenCodeConfig(
33
+ configPath: string,
34
+ config: OpenCodeConfig,
35
+ ): Promise<void> {
36
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
37
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
38
+ }
39
+
40
+ export function getOpenCodePluginReferences(config: OpenCodeConfig): unknown[] {
41
+ return Array.isArray(config.plugin) ? config.plugin : [];
42
+ }
43
+
44
+ /** Replace the plugin array in place; an empty list removes the key. */
45
+ export function setOpenCodePluginReferences(config: OpenCodeConfig, references: unknown[]): void {
46
+ if (references.length === 0) {
47
+ delete config.plugin;
48
+ return;
49
+ }
50
+ config.plugin = references;
51
+ }
52
+
53
+ function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): void {
54
+ for (const [key, sourceValue] of Object.entries(source)) {
55
+ const targetValue = target[key];
56
+ if (isRecord(targetValue) && isRecord(sourceValue)) {
57
+ mergeConfig(targetValue, sourceValue);
58
+ continue;
59
+ }
60
+
61
+ target[key] = sourceValue;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Merge an overlay into the launch-time `OPENCODE_CONFIG_CONTENT` env value
67
+ * (overlay wins on scalar conflicts) and return the serialized result. Invalid
68
+ * inherited content is ignored rather than breaking the launch.
69
+ */
70
+ export function mergeOpenCodeConfigContent(
71
+ overlay: Record<string, unknown>,
72
+ env: NodeJS.ProcessEnv = process.env,
73
+ options: { appendSkillPaths?: string[] } = {},
74
+ ): string {
75
+ let config: Record<string, unknown> = {};
76
+ const existing = env.OPENCODE_CONFIG_CONTENT;
77
+
78
+ if (existing) {
79
+ try {
80
+ const parsed = JSON.parse(existing) as unknown;
81
+ if (isRecord(parsed)) {
82
+ config = parsed;
83
+ }
84
+ } catch {
85
+ // Ignore invalid inherited config content rather than breaking launch.
86
+ }
87
+ }
88
+
89
+ mergeConfig(config, overlay);
90
+
91
+ if (options.appendSkillPaths && options.appendSkillPaths.length > 0) {
92
+ const skills = isRecord(config.skills) ? config.skills : {};
93
+ const paths = Array.isArray(skills.paths) ? skills.paths : [];
94
+ const existingPaths = new Set(paths);
95
+ skills.paths = [
96
+ ...paths,
97
+ ...options.appendSkillPaths.filter((skillPath) => !existingPaths.has(skillPath)),
98
+ ];
99
+ config.skills = skills;
100
+ }
101
+
102
+ return JSON.stringify(config);
103
+ }
104
+
105
+ /** Map a provider-agnostic MCP descriptor to OpenCode's `mcp` entry shape. */
106
+ export function toOpenCodeMcpEntry(descriptor: McpEntryDescriptor): Record<string, unknown> {
107
+ if (descriptor.url) {
108
+ return { type: "remote", url: descriptor.url, enabled: true };
109
+ }
110
+ return {
111
+ type: "local",
112
+ command: [descriptor.command ?? "", ...(descriptor.args ?? [])].filter(Boolean),
113
+ ...(descriptor.env ? { environment: descriptor.env } : {}),
114
+ enabled: true,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Reconcile a single server entry in the `mcp` map while preserving every
120
+ * unrelated key. `entry: null` removes the server; removal never creates the
121
+ * file, and an emptied `mcp` map is dropped entirely.
122
+ */
123
+ export async function updateOpenCodeMcpServer(
124
+ configPath: string,
125
+ name: string,
126
+ entry: Record<string, unknown> | null,
127
+ ): Promise<void> {
128
+ const { present, config } = await readOpenCodeConfig(configPath);
129
+ if (entry === null && !present) return;
130
+
131
+ const mcp: Record<string, unknown> = isRecord(config.mcp) ? { ...config.mcp } : {};
132
+ if (entry === null) {
133
+ if (!(name in mcp)) return;
134
+ delete mcp[name];
135
+ } else {
136
+ mcp[name] = entry;
137
+ }
138
+
139
+ const next = { ...config };
140
+ if (Object.keys(mcp).length > 0) {
141
+ next.mcp = mcp;
142
+ } else {
143
+ delete next.mcp;
144
+ }
145
+ await writeOpenCodeConfig(configPath, next);
146
+ }
@@ -10,8 +10,25 @@ import {
10
10
  warmOpenCodePluginCache,
11
11
  } from "../../../lib/opencode-plugin-package";
12
12
  import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
13
- import type { McpServerDescriptor, ProviderPlugin, SetupContext } from "../plugin";
14
- import { pruneEmptyAncestors } from "../utils";
13
+ import { stripSectionFromFile, type RemoveHeadingSectionOptions } from "./agent-file-sections";
14
+ import { patchSkillTreeMarkdownFiles } from "./skill-tree";
15
+ import {
16
+ instructionBlockKey,
17
+ removeManagedBlocksForPlugin,
18
+ upsertManagedBlock,
19
+ } from "../context-services";
20
+ import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
21
+ import { mergeDir, pruneEmptyAncestors } from "../utils";
22
+ import {
23
+ getOpenCodePluginReferences,
24
+ isRecord,
25
+ readOpenCodeConfig,
26
+ setOpenCodePluginReferences,
27
+ toOpenCodeMcpEntry,
28
+ updateOpenCodeMcpServer,
29
+ writeOpenCodeConfig,
30
+ type OpenCodeConfig,
31
+ } from "./opencode-format";
15
32
  import { getSetupProvidersRoot, getSetupRootTemplates } from "./utils";
16
33
 
17
34
  // Copied Mate plugin source files from earlier releases. Plugin code is
@@ -46,10 +63,6 @@ const LEGACY_TUI_DEPENDENCIES = {
46
63
  "@opentui/solid": "^0.3.4",
47
64
  };
48
65
 
49
- function isRecord(value: unknown): value is Record<string, unknown> {
50
- return typeof value === "object" && value !== null && !Array.isArray(value);
51
- }
52
-
53
66
  function mergeConfigDefaults(
54
67
  target: Record<string, unknown>,
55
68
  defaults: Record<string, unknown>,
@@ -100,25 +113,20 @@ function isLegacyMatePluginConfigEntry(entry: unknown): boolean {
100
113
  * references) with the current pinned package reference while preserving
101
114
  * every unrelated plugin entry.
102
115
  */
103
- function ensureMatePluginReference(config: Record<string, unknown>, pluginReference: string): void {
104
- const existing = Array.isArray(config.plugin) ? config.plugin : [];
105
- const preserved = existing.filter(
116
+ function ensureMatePluginReference(config: OpenCodeConfig, pluginReference: string): void {
117
+ const preserved = getOpenCodePluginReferences(config).filter(
106
118
  (entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
107
119
  );
108
- config.plugin = [...preserved, pluginReference];
120
+ setOpenCodePluginReferences(config, [...preserved, pluginReference]);
109
121
  }
110
122
 
111
- function stripMatePluginReference(config: Record<string, unknown>): void {
123
+ function stripMatePluginReference(config: OpenCodeConfig): void {
112
124
  if (!Array.isArray(config.plugin)) return;
113
125
 
114
- const preserved = config.plugin.filter(
126
+ const preserved = getOpenCodePluginReferences(config).filter(
115
127
  (entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
116
128
  );
117
- if (preserved.length === 0) {
118
- delete config.plugin;
119
- return;
120
- }
121
- config.plugin = preserved;
129
+ setOpenCodePluginReferences(config, preserved);
122
130
  }
123
131
 
124
132
  async function syncOpenCodeConfigFile(
@@ -140,7 +148,7 @@ async function syncOpenCodeConfigFile(
140
148
 
141
149
  mergeConfigDefaults(existing, defaults);
142
150
  ensureMatePluginReference(existing, pluginReference);
143
- await fs.writeFile(destPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
151
+ await writeOpenCodeConfig(destPath, existing);
144
152
  }
145
153
 
146
154
  function normalizeForComparison(value: unknown): unknown {
@@ -187,7 +195,7 @@ async function teardownOpenCodeConfigFile(srcPath: string, destPath: string): Pr
187
195
  return;
188
196
  }
189
197
 
190
- await fs.writeFile(destPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
198
+ await writeOpenCodeConfig(destPath, existing);
191
199
  }
192
200
 
193
201
  async function removeLegacyTuiDependencies(dest: string): Promise<void> {
@@ -326,55 +334,191 @@ function getCompanionOpenCodeConfigPath(companionPath: string): string {
326
334
  return path.join(companionPath, ".opencode", "opencode.json");
327
335
  }
328
336
 
329
- function opencodeMcpEntry(descriptor: McpServerDescriptor): Record<string, unknown> {
330
- if (descriptor.url) {
331
- return { type: "remote", url: descriptor.url, enabled: true };
337
+ // ---------------------------------------------------------------------------
338
+ // Runtime Surface escape hatch (spec: runtime-surface). Imperative operations
339
+ // for effects a declaration cannot express. Format knowledge stays here;
340
+ // capabilities pass only predicates.
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /** Patch markdown files of an externally written `.opencode/skills/<name>` tree. */
344
+ export async function patchOpenCodeSkillTree(
345
+ companionPath: string,
346
+ name: string,
347
+ transform: (content: string) => string,
348
+ options: { excludeFiles?: readonly string[] } = {},
349
+ ): Promise<void> {
350
+ await patchSkillTreeMarkdownFiles(
351
+ path.join(companionPath, ".opencode", "skills", name),
352
+ transform,
353
+ new Set(options.excludeFiles ?? []),
354
+ );
355
+ }
356
+
357
+ /**
358
+ * Strip a foreign heading section from the OpenCode guidance surfaces: the
359
+ * shared root AGENTS.md, the per-provider `.opencode/AGENTS.md`, and — when
360
+ * syncing from a linked repo — the repo-level AGENTS.md. With
361
+ * `guardSharedFile`, the shared root file is left alone while another active
362
+ * runtime (Claude) still uses it.
363
+ */
364
+ export async function stripOpenCodeForeignSections(
365
+ companionPath: string,
366
+ options: RemoveHeadingSectionOptions & {
367
+ repoPath?: string;
368
+ guardSharedFile?: { activeProviders: string[] };
369
+ },
370
+ ): Promise<void> {
371
+ const sharedWithActive = options.guardSharedFile?.activeProviders.includes("claude") ?? false;
372
+ if (!sharedWithActive) {
373
+ await stripSectionFromFile(path.join(companionPath, "AGENTS.md"), options);
374
+ }
375
+ await stripSectionFromFile(path.join(companionPath, ".opencode", "AGENTS.md"), options);
376
+ if (options.repoPath) {
377
+ await stripSectionFromFile(path.join(options.repoPath, "AGENTS.md"), options);
332
378
  }
333
- return {
334
- type: "local",
335
- command: [descriptor.command ?? "", ...(descriptor.args ?? [])].filter(Boolean),
336
- ...(descriptor.env ? { environment: descriptor.env } : {}),
337
- enabled: true,
338
- };
339
379
  }
340
380
 
341
- // Reconcile a single Mate-managed MCP server in `.opencode/opencode.json`
342
- // while preserving every unrelated key. `entry: null` removes the server.
343
- async function updateCompanionOpenCodeMcpServer(
381
+ /**
382
+ * Remove plugin entries matching `isForeign` from `opencode.json`. A config
383
+ * left empty is deleted; malformed configs are left untouched.
384
+ */
385
+ export async function removeOpenCodeForeignPluginReferences(
344
386
  companionPath: string,
345
- name: string,
346
- entry: Record<string, unknown> | null,
387
+ isForeign: (entry: unknown) => boolean,
347
388
  ): Promise<void> {
348
389
  const configPath = getCompanionOpenCodeConfigPath(companionPath);
349
- let existing: Record<string, unknown> = {};
350
- let present = false;
390
+ let raw: string;
351
391
  try {
352
- const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
353
- if (isRecord(parsed)) {
354
- existing = parsed;
355
- present = true;
392
+ raw = await fs.readFile(configPath, "utf8");
393
+ } catch {
394
+ return;
395
+ }
396
+ try {
397
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
398
+ if (Array.isArray(parsed.plugin)) {
399
+ setOpenCodePluginReferences(
400
+ parsed,
401
+ parsed.plugin.filter((entry) => !isForeign(entry)),
402
+ );
403
+ }
404
+ if (Object.keys(parsed).length === 0) {
405
+ await fs.unlink(configPath);
406
+ } else {
407
+ await writeOpenCodeConfig(configPath, parsed);
356
408
  }
357
409
  } catch {
358
- // Absent or unparseable — start from an empty object.
410
+ // JSON parse error — leave file untouched
359
411
  }
360
- if (entry === null && !present) return;
361
-
362
- const mcp: Record<string, unknown> = isRecord(existing.mcp) ? { ...existing.mcp } : {};
363
- if (entry === null) {
364
- if (!(name in mcp)) return;
365
- delete mcp[name];
366
- } else {
367
- mcp[name] = entry;
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Runtime Surface reconciliation: apply/remove declared Capability
416
+ // contributions (spec: runtime-surface). Managed identity: named MCP servers,
417
+ // `isManagedReference` for plugin entries, managed guidance blocks, and named
418
+ // skill trees.
419
+ // ---------------------------------------------------------------------------
420
+
421
+ const OPENCODE_CONTRIBUTION_CONFIG_FILES = ["opencode.json", "tui.json"];
422
+
423
+ export async function reconcileOpenCodeContributions(
424
+ ctx: SetupContext,
425
+ inputs: CapabilityContributionInput[],
426
+ ): Promise<void> {
427
+ const { companionPath } = ctx;
428
+
429
+ if (ctx.scope === "hub") {
430
+ await reconcileOpenCodeMcpContributions(ctx, inputs);
431
+ await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
432
+ return;
433
+ }
434
+
435
+ await reconcileOpenCodeMcpContributions(ctx, inputs);
436
+ await reconcileOpenCodeAgentDefinitionContributions(ctx, inputs);
437
+
438
+ for (const input of inputs) {
439
+ for (const pluginReference of input.contributions.pluginReferences ?? []) {
440
+ const configFiles = pluginReference.configFiles ?? OPENCODE_CONTRIBUTION_CONFIG_FILES;
441
+ for (const name of configFiles) {
442
+ const configPath = path.join(companionPath, ".opencode", name);
443
+ const { present, config } = await readOpenCodeConfig(configPath);
444
+ if (!input.enabled && !present) continue;
445
+ const preserved = getOpenCodePluginReferences(config).filter(
446
+ (entry) => !pluginReference.isManagedReference(entry),
447
+ );
448
+ setOpenCodePluginReferences(
449
+ config,
450
+ input.enabled ? [...preserved, pluginReference.reference] : preserved,
451
+ );
452
+ await writeOpenCodeConfig(configPath, config);
453
+ }
454
+ }
455
+
456
+ // Guidance sections are managed blocks in AGENTS.md. Capability disable
457
+ // strips them here; runtime teardown of the shared AGENTS.md is guarded in
458
+ // the provider teardown paths (kept while another active runtime uses it).
459
+ const guidancePath = path.join(companionPath, "AGENTS.md");
460
+ const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
461
+ const keepKeys = new Set<string>();
462
+ for (const section of sections) {
463
+ const blockKey = instructionBlockKey(input.pluginId, section.content);
464
+ keepKeys.add(blockKey);
465
+ await upsertManagedBlock(guidancePath, FRAMEWORK_NAME, blockKey, section.content);
466
+ }
467
+ if ((input.contributions.guidanceSections ?? []).length > 0) {
468
+ await removeManagedBlocksForPlugin(guidancePath, FRAMEWORK_NAME, input.pluginId, keepKeys);
469
+ }
470
+
471
+ for (const skillTree of input.contributions.skillTrees ?? []) {
472
+ const skillDir = path.join(companionPath, ".opencode", "skills", skillTree.name);
473
+ if (input.enabled) {
474
+ await mergeDir(skillTree.sourceDir, skillDir);
475
+ } else {
476
+ await fs.rm(skillDir, { recursive: true, force: true });
477
+ await pruneEmptyAncestors(path.join(companionPath, ".opencode", "skills"), companionPath);
478
+ }
479
+ }
368
480
  }
481
+ }
369
482
 
370
- const next = { ...existing };
371
- if (Object.keys(mcp).length > 0) {
372
- next.mcp = mcp;
373
- } else {
374
- delete next.mcp;
483
+ /** Reconcile only MCP entries without touching OpenCode plugins or guidance. */
484
+ async function reconcileOpenCodeMcpContributions(
485
+ ctx: SetupContext,
486
+ inputs: CapabilityContributionInput[],
487
+ ): Promise<void> {
488
+ for (const input of inputs) {
489
+ for (const descriptor of input.contributions.mcpServers ?? []) {
490
+ await updateOpenCodeMcpServer(
491
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
492
+ descriptor.name,
493
+ input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
494
+ );
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Reconcile declared agent definition files under `.opencode/agents/`. Runs
501
+ * in both companion and hub scope — an agent definition is fully
502
+ * self-contained (no shared-file merge), so it needs no companion-only
503
+ * surface.
504
+ */
505
+ async function reconcileOpenCodeAgentDefinitionContributions(
506
+ ctx: SetupContext,
507
+ inputs: CapabilityContributionInput[],
508
+ ): Promise<void> {
509
+ const agentsDir = path.join(ctx.companionPath, ".opencode", "agents");
510
+ for (const input of inputs) {
511
+ for (const agent of input.contributions.agentDefinitions ?? []) {
512
+ const agentPath = path.join(agentsDir, `${agent.name}.md`);
513
+ if (input.enabled) {
514
+ await fs.mkdir(agentsDir, { recursive: true });
515
+ await fs.writeFile(agentPath, agent.content, "utf8");
516
+ } else {
517
+ await fs.rm(agentPath, { force: true });
518
+ await pruneEmptyAncestors(agentsDir, ctx.companionPath);
519
+ }
520
+ }
375
521
  }
376
- await fs.mkdir(path.dirname(configPath), { recursive: true });
377
- await fs.writeFile(configPath, JSON.stringify(next, null, 2) + "\n", "utf8");
378
522
  }
379
523
 
380
524
  export function createOpenCodePlugin(): ProviderPlugin {
@@ -387,15 +531,19 @@ export function createOpenCodePlugin(): ProviderPlugin {
387
531
  isEnabled: (config) => (config.allowedAgents ?? []).includes("opencode"),
388
532
  hosting: {
389
533
  mcp: {
390
- async register(ctx: SetupContext, descriptor: McpServerDescriptor) {
391
- await updateCompanionOpenCodeMcpServer(
392
- ctx.companionPath,
534
+ async register(ctx: SetupContext, descriptor) {
535
+ await updateOpenCodeMcpServer(
536
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
393
537
  descriptor.name,
394
- opencodeMcpEntry(descriptor),
538
+ toOpenCodeMcpEntry(descriptor),
395
539
  );
396
540
  },
397
541
  async unregister(ctx: SetupContext, name: string) {
398
- await updateCompanionOpenCodeMcpServer(ctx.companionPath, name, null);
542
+ await updateOpenCodeMcpServer(
543
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
544
+ name,
545
+ null,
546
+ );
399
547
  },
400
548
  },
401
549
  instructions: {
@@ -403,6 +551,7 @@ export function createOpenCodePlugin(): ProviderPlugin {
403
551
  },
404
552
  },
405
553
  async apply(ctx: SetupContext) {
554
+ if (ctx.scope === "hub") return;
406
555
  await syncOpenCodeRuntimeFiles(
407
556
  path.join(getSetupProvidersRoot(), "opencode"),
408
557
  ctx.companionPath,
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // Escape-hatch support: patch markdown files of a skill tree that an external
5
+ // CLI wrote into a runtime directory (SKILL.md plus references/*.md).
6
+ // Directory-driven so reference files added by future releases are covered.
7
+
8
+ async function patchMarkdownFile(
9
+ filePath: string,
10
+ transform: (content: string) => string,
11
+ ): Promise<void> {
12
+ let content: string;
13
+ try {
14
+ content = await fs.readFile(filePath, "utf8");
15
+ } catch {
16
+ return; // file absent — nothing to patch
17
+ }
18
+ const rewritten = transform(content);
19
+ if (rewritten !== content) await fs.writeFile(filePath, rewritten, "utf8");
20
+ }
21
+
22
+ export async function patchSkillTreeMarkdownFiles(
23
+ skillDir: string,
24
+ transform: (content: string) => string,
25
+ excludeFiles: ReadonlySet<string> = new Set(),
26
+ ): Promise<void> {
27
+ await patchMarkdownFile(path.join(skillDir, "SKILL.md"), transform);
28
+
29
+ const refsDir = path.join(skillDir, "references");
30
+ let entries: string[];
31
+ try {
32
+ entries = await fs.readdir(refsDir);
33
+ } catch {
34
+ return; // no references/ dir
35
+ }
36
+ const targets = entries.filter((name) => name.endsWith(".md") && !excludeFiles.has(name));
37
+ await Promise.all(targets.map((name) => patchMarkdownFile(path.join(refsDir, name), transform)));
38
+ }
@@ -1,5 +1,4 @@
1
1
  // oxlint-disable no-await-in-loop
2
- import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
5
4
  import { FRAMEWORK_NAME } from "../framework";
@@ -25,7 +24,7 @@ import {
25
24
  collectManagedGitignoreEntries,
26
25
  writeManagedGitignoreBlock,
27
26
  } from "./setup/plugins/gitignore";
28
- import type { PluginRegistration, SetupContext } from "./setup/plugin";
27
+ import type { PluginRegistration, SetupContext, SetupScope } from "./setup/plugin";
29
28
  import { getActiveDistribution } from "../distribution";
30
29
  import { createUvPlugin } from "./setup/package-managers/uv";
31
30
  import type { PackageManagerSetupDeps } from "./setup/package-managers/uv";
@@ -60,10 +59,11 @@ export async function applySetupCompatibilities(
60
59
  mode: "setup" | "sync",
61
60
  plugins: PluginRegistration[] = getActiveDistribution().registry.getEntries(),
62
61
  repoPath?: string,
62
+ scope: SetupScope = config.type === "hub" ? "hub" : "companion",
63
63
  ): Promise<SetupInstallationOutcome> {
64
64
  const plan = buildSetupInstallationPlan(config, plugins);
65
65
  const activeProviders = plan.activeProviders;
66
- const ctx: SetupContext = { companionPath, config, mode, activeProviders, repoPath };
66
+ const ctx: SetupContext = { companionPath, config, mode, activeProviders, repoPath, scope };
67
67
  return executeSetupInstallationPlan(ctx, plugins, plan);
68
68
  }
69
69
 
@@ -91,48 +91,6 @@ export async function syncCompanionFiles(
91
91
  );
92
92
  }
93
93
 
94
- export function mateFolderReadme(): string {
95
- const n = FRAMEWORK_NAME;
96
- const packageName =
97
- getActiveDistribution().config.update?.packageName ?? `@uniqbit/${FRAMEWORK_NAME}`;
98
- return [
99
- `# .${FRAMEWORK_NAME}`,
100
- ``,
101
- `This directory is managed by the **${FRAMEWORK_NAME}** companion framework (\`${packageName}\`).`,
102
- ``,
103
- `The ${FRAMEWORK_NAME} framework keeps your AI agent's companion artifacts separate from the code it works on.`,
104
- `Specs, notes, and agent config live here; code stays in the linked working repository.`,
105
- ``,
106
- `## Common commands`,
107
- ``,
108
- `| Command | Description |`,
109
- `|---|---|`,
110
- `| \`${n} companion setup\` | Initialize or re-configure this companion |`,
111
- `| \`${n} companion link\` | Link a working repository to a companion |`,
112
- `| \`${n} companion list\` | List linked repositories for the active working repo context |`,
113
- `| \`${n} companion open\` | Inject the resolved companion into the current editor window |`,
114
- `| \`${n} claude\` / \`${n} opencode\` | Launch an allowed agent from a linked working repository |`,
115
- `| \`${n} doctor\` | Check current link state, installed tools, and active capabilities |`,
116
- ``,
117
- `Run \`${n} claude\` or \`${n} opencode\` from any linked working repository directory.`,
118
- ``,
119
- `## Configuration`,
120
- ``,
121
- `Edit \`.${FRAMEWORK_NAME}/config/framework.yaml\` to configure:`,
122
- ``,
123
- `- **allowedAgents** — agents permitted to launch from linked repositories`,
124
- `- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom, rtk)`,
125
- `- **git** — set to \`auto\` to synchronize the companion before agent launches`,
126
- ``,
127
- ].join("\n");
128
- }
129
-
130
- async function writeMateReadme(companionPath: string): Promise<void> {
131
- const readmePath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "README.md");
132
- await fs.mkdir(path.dirname(readmePath), { recursive: true });
133
- await fs.writeFile(readmePath, mateFolderReadme(), "utf8");
134
- }
135
-
136
94
  export const setupToolDeps = {
137
95
  executeSetup: (input: SetupInput) => executeSetup(input),
138
96
  };
@@ -160,14 +118,6 @@ export async function executeSetup(
160
118
  deps.configStore ??
161
119
  new ConfigStore(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
162
120
  const config = mergeWithDefaults(await configStore.load());
163
- // Hubs are never companions: no setup entry path (CLI, wizard, or the
164
- // setup framework tool) may write agent guidance into a hub root.
165
- if (config.type === "hub") {
166
- throw new ConfigError(
167
- `A companion hub cannot be set up as a companion: ${cwd}. Use \`${FRAMEWORK_NAME} hub\` commands to manage the hub.`,
168
- );
169
- }
170
-
171
121
  if (input.allowedAgents !== undefined) {
172
122
  config.allowedAgents = [...new Set(input.allowedAgents)];
173
123
  }
@@ -202,12 +152,13 @@ export async function executeSetup(
202
152
  await hydrateDynamicPlugins({ companionPath });
203
153
  }
204
154
  await applySetupCompatibilities(companionPath, config, "setup");
205
- await invalidateInstallState({ kind: "companion", companionPath });
155
+ await invalidateInstallState({
156
+ kind: config.type === "hub" ? "hub" : "companion",
157
+ companionPath,
158
+ });
206
159
 
207
160
  await globalConfigStore.register(companionPath);
208
161
 
209
- await writeMateReadme(companionPath);
210
-
211
162
  // Setup no longer fans out to linked repos: capabilities that touch a working repo
212
163
  // (companion files on launch, the tokensave/graphify graph via `mate cap index`)
213
164
  // are applied in that repo's own context, not eagerly from here.