@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,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
+ }
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { frameworkCommandName } from "../../../framework";
5
+ import { FRAMEWORK_NAME } from "../../../framework";
6
6
  import {
7
7
  OPENCODE_PLUGIN_PACKAGE_NAME,
8
8
  getOpenCodePluginPackageReference,
@@ -10,15 +10,31 @@ 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
18
35
  // package-owned now; these exact filenames are removed during sync and
19
36
  // teardown while every other companion-local plugin is preserved.
20
37
  const LEGACY_PLUGIN_FILES = [
21
- "kizuna-companion.ts",
22
38
  "add-dir.ts",
23
39
  "companion.ts",
24
40
  "small-model.ts",
@@ -47,10 +63,6 @@ const LEGACY_TUI_DEPENDENCIES = {
47
63
  "@opentui/solid": "^0.3.4",
48
64
  };
49
65
 
50
- function isRecord(value: unknown): value is Record<string, unknown> {
51
- return typeof value === "object" && value !== null && !Array.isArray(value);
52
- }
53
-
54
66
  function mergeConfigDefaults(
55
67
  target: Record<string, unknown>,
56
68
  defaults: Record<string, unknown>,
@@ -101,25 +113,20 @@ function isLegacyMatePluginConfigEntry(entry: unknown): boolean {
101
113
  * references) with the current pinned package reference while preserving
102
114
  * every unrelated plugin entry.
103
115
  */
104
- function ensureMatePluginReference(config: Record<string, unknown>, pluginReference: string): void {
105
- const existing = Array.isArray(config.plugin) ? config.plugin : [];
106
- const preserved = existing.filter(
116
+ function ensureMatePluginReference(config: OpenCodeConfig, pluginReference: string): void {
117
+ const preserved = getOpenCodePluginReferences(config).filter(
107
118
  (entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
108
119
  );
109
- config.plugin = [...preserved, pluginReference];
120
+ setOpenCodePluginReferences(config, [...preserved, pluginReference]);
110
121
  }
111
122
 
112
- function stripMatePluginReference(config: Record<string, unknown>): void {
123
+ function stripMatePluginReference(config: OpenCodeConfig): void {
113
124
  if (!Array.isArray(config.plugin)) return;
114
125
 
115
- const preserved = config.plugin.filter(
126
+ const preserved = getOpenCodePluginReferences(config).filter(
116
127
  (entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
117
128
  );
118
- if (preserved.length === 0) {
119
- delete config.plugin;
120
- return;
121
- }
122
- config.plugin = preserved;
129
+ setOpenCodePluginReferences(config, preserved);
123
130
  }
124
131
 
125
132
  async function syncOpenCodeConfigFile(
@@ -141,7 +148,7 @@ async function syncOpenCodeConfigFile(
141
148
 
142
149
  mergeConfigDefaults(existing, defaults);
143
150
  ensureMatePluginReference(existing, pluginReference);
144
- await fs.writeFile(destPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
151
+ await writeOpenCodeConfig(destPath, existing);
145
152
  }
146
153
 
147
154
  function normalizeForComparison(value: unknown): unknown {
@@ -188,7 +195,7 @@ async function teardownOpenCodeConfigFile(srcPath: string, destPath: string): Pr
188
195
  return;
189
196
  }
190
197
 
191
- await fs.writeFile(destPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
198
+ await writeOpenCodeConfig(destPath, existing);
192
199
  }
193
200
 
194
201
  async function removeLegacyTuiDependencies(dest: string): Promise<void> {
@@ -244,9 +251,9 @@ async function warmPluginCacheForSetup(pluginReference: string): Promise<void> {
244
251
 
245
252
  process.stderr.write(
246
253
  [
247
- `${frameworkCommandName()}: could not pre-fetch ${pluginReference} into OpenCode's plugin environment.`,
254
+ `${FRAMEWORK_NAME}: could not pre-fetch ${pluginReference} into OpenCode's plugin environment.`,
248
255
  "The first managed OpenCode launch will download it, which requires registry access.",
249
- `Re-run \`${frameworkCommandName()} companion setup\` with network access to warm the cache ahead of time.`,
256
+ `Re-run \`${FRAMEWORK_NAME} companion setup\` with network access to warm the cache ahead of time.`,
250
257
  ...(warmed.detail ? [`Details: ${warmed.detail}`] : []),
251
258
  ].join("\n") + "\n",
252
259
  );
@@ -327,55 +334,149 @@ function getCompanionOpenCodeConfigPath(companionPath: string): string {
327
334
  return path.join(companionPath, ".opencode", "opencode.json");
328
335
  }
329
336
 
330
- function opencodeMcpEntry(descriptor: McpServerDescriptor): Record<string, unknown> {
331
- if (descriptor.url) {
332
- 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);
333
378
  }
334
- return {
335
- type: "local",
336
- command: [descriptor.command ?? "", ...(descriptor.args ?? [])].filter(Boolean),
337
- ...(descriptor.env ? { environment: descriptor.env } : {}),
338
- enabled: true,
339
- };
340
379
  }
341
380
 
342
- // Reconcile a single Mate-managed MCP server in `.opencode/opencode.json`
343
- // while preserving every unrelated key. `entry: null` removes the server.
344
- 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(
345
386
  companionPath: string,
346
- name: string,
347
- entry: Record<string, unknown> | null,
387
+ isForeign: (entry: unknown) => boolean,
348
388
  ): Promise<void> {
349
389
  const configPath = getCompanionOpenCodeConfigPath(companionPath);
350
- let existing: Record<string, unknown> = {};
351
- let present = false;
390
+ let raw: string;
352
391
  try {
353
- const parsed = JSON.parse(await fs.readFile(configPath, "utf8")) as unknown;
354
- if (isRecord(parsed)) {
355
- existing = parsed;
356
- present = true;
357
- }
392
+ raw = await fs.readFile(configPath, "utf8");
358
393
  } catch {
359
- // Absent or unparseable — start from an empty object.
394
+ return;
360
395
  }
361
- if (entry === null && !present) return;
362
-
363
- const mcp: Record<string, unknown> = isRecord(existing.mcp) ? { ...existing.mcp } : {};
364
- if (entry === null) {
365
- if (!(name in mcp)) return;
366
- delete mcp[name];
367
- } else {
368
- mcp[name] = entry;
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);
408
+ }
409
+ } catch {
410
+ // JSON parse error — leave file untouched
369
411
  }
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
+ // ---------------------------------------------------------------------------
370
420
 
371
- const next = { ...existing };
372
- if (Object.keys(mcp).length > 0) {
373
- next.mcp = mcp;
374
- } else {
375
- delete next.mcp;
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
+ for (const input of inputs) {
430
+ for (const descriptor of input.contributions.mcpServers ?? []) {
431
+ await updateOpenCodeMcpServer(
432
+ getCompanionOpenCodeConfigPath(companionPath),
433
+ descriptor.name,
434
+ input.enabled ? toOpenCodeMcpEntry(descriptor) : null,
435
+ );
436
+ }
437
+
438
+ for (const pluginReference of input.contributions.pluginReferences ?? []) {
439
+ const configFiles = pluginReference.configFiles ?? OPENCODE_CONTRIBUTION_CONFIG_FILES;
440
+ for (const name of configFiles) {
441
+ const configPath = path.join(companionPath, ".opencode", name);
442
+ const { present, config } = await readOpenCodeConfig(configPath);
443
+ if (!input.enabled && !present) continue;
444
+ const preserved = getOpenCodePluginReferences(config).filter(
445
+ (entry) => !pluginReference.isManagedReference(entry),
446
+ );
447
+ setOpenCodePluginReferences(
448
+ config,
449
+ input.enabled ? [...preserved, pluginReference.reference] : preserved,
450
+ );
451
+ await writeOpenCodeConfig(configPath, config);
452
+ }
453
+ }
454
+
455
+ // Guidance sections are managed blocks in AGENTS.md. Capability disable
456
+ // strips them here; runtime teardown of the shared AGENTS.md is guarded in
457
+ // the provider teardown paths (kept while another active runtime uses it).
458
+ const guidancePath = path.join(companionPath, "AGENTS.md");
459
+ const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
460
+ const keepKeys = new Set<string>();
461
+ for (const section of sections) {
462
+ const blockKey = instructionBlockKey(input.pluginId, section.content);
463
+ keepKeys.add(blockKey);
464
+ await upsertManagedBlock(guidancePath, FRAMEWORK_NAME, blockKey, section.content);
465
+ }
466
+ if ((input.contributions.guidanceSections ?? []).length > 0) {
467
+ await removeManagedBlocksForPlugin(guidancePath, FRAMEWORK_NAME, input.pluginId, keepKeys);
468
+ }
469
+
470
+ for (const skillTree of input.contributions.skillTrees ?? []) {
471
+ const skillDir = path.join(companionPath, ".opencode", "skills", skillTree.name);
472
+ if (input.enabled) {
473
+ await mergeDir(skillTree.sourceDir, skillDir);
474
+ } else {
475
+ await fs.rm(skillDir, { recursive: true, force: true });
476
+ await pruneEmptyAncestors(path.join(companionPath, ".opencode", "skills"), companionPath);
477
+ }
478
+ }
376
479
  }
377
- await fs.mkdir(path.dirname(configPath), { recursive: true });
378
- await fs.writeFile(configPath, JSON.stringify(next, null, 2) + "\n", "utf8");
379
480
  }
380
481
 
381
482
  export function createOpenCodePlugin(): ProviderPlugin {
@@ -385,18 +486,22 @@ export function createOpenCodePlugin(): ProviderPlugin {
385
486
  label: "OpenCode",
386
487
  description: "Install the OpenCode plugin workspace.",
387
488
  defaultSelected: false,
388
- isEnabled: (config) => (config.profiles.default?.allowedAgents ?? []).includes("opencode"),
489
+ isEnabled: (config) => (config.allowedAgents ?? []).includes("opencode"),
389
490
  hosting: {
390
491
  mcp: {
391
- async register(ctx: SetupContext, descriptor: McpServerDescriptor) {
392
- await updateCompanionOpenCodeMcpServer(
393
- ctx.companionPath,
492
+ async register(ctx: SetupContext, descriptor) {
493
+ await updateOpenCodeMcpServer(
494
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
394
495
  descriptor.name,
395
- opencodeMcpEntry(descriptor),
496
+ toOpenCodeMcpEntry(descriptor),
396
497
  );
397
498
  },
398
499
  async unregister(ctx: SetupContext, name: string) {
399
- await updateCompanionOpenCodeMcpServer(ctx.companionPath, name, null);
500
+ await updateOpenCodeMcpServer(
501
+ getCompanionOpenCodeConfigPath(ctx.companionPath),
502
+ name,
503
+ null,
504
+ );
400
505
  },
401
506
  },
402
507
  instructions: {
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
- import { FRAMEWORK_NAME, frameworkCommandName } from "../framework";
5
+ import { FRAMEWORK_NAME } from "../framework";
6
6
  import { ConfigStore, mergeWithDefaults } from "../lib/orchestrator/config-store";
7
7
  import { GlobalConfigStore } from "../lib/orchestrator/global-config-store";
8
8
  import {
@@ -91,9 +91,8 @@ export async function syncCompanionFiles(
91
91
  );
92
92
  }
93
93
 
94
- // Exported for the distribution-identity tests.
95
94
  export function mateFolderReadme(): string {
96
- const n = frameworkCommandName();
95
+ const n = FRAMEWORK_NAME;
97
96
  const packageName =
98
97
  getActiveDistribution().config.update?.packageName ?? `@uniqbit/${FRAMEWORK_NAME}`;
99
98
  return [
@@ -121,7 +120,7 @@ export function mateFolderReadme(): string {
121
120
  ``,
122
121
  `Edit \`.${FRAMEWORK_NAME}/config/framework.yaml\` to configure:`,
123
122
  ``,
124
- `- **profiles** — per-profile allowed agents list`,
123
+ `- **allowedAgents** — agents permitted to launch from linked repositories`,
125
124
  `- **capabilities** — skill and CLI tool capabilities (e.g. react-doctor, openspec, tokensave, headroom, rtk)`,
126
125
  `- **git** — set to \`auto\` to synchronize the companion before agent launches`,
127
126
  ``,
@@ -140,7 +139,7 @@ export const setupToolDeps = {
140
139
 
141
140
  const setup: FrameworkTool<SetupInput, { config: FrameworkConfig }> = {
142
141
  name: "setup",
143
- description: "Initialize framework runtime, default profiles, and configure .opencode.",
142
+ description: "Initialize framework runtime, agent policy, and configure .opencode.",
144
143
  async execute(input) {
145
144
  return setupToolDeps.executeSetup(input);
146
145
  },
@@ -161,10 +160,16 @@ export async function executeSetup(
161
160
  deps.configStore ??
162
161
  new ConfigStore(path.join(cwd, `.${FRAMEWORK_NAME}`, "config", "framework.yaml"));
163
162
  const config = mergeWithDefaults(await configStore.load());
164
- const defaultProfile = config.profiles.default;
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
+ }
165
170
 
166
171
  if (input.allowedAgents !== undefined) {
167
- defaultProfile.allowedAgents = [...new Set(input.allowedAgents)];
172
+ config.allowedAgents = [...new Set(input.allowedAgents)];
168
173
  }
169
174
  if (input.packageManagers !== undefined) {
170
175
  config.packageManagers = getSetupSelectionsFromConfig({
package/src/tui.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { SelectMenu, type SelectMenuItem } from "./lib/components/select-menu";
2
+ export { WizardFooter } from "./lib/components/wizard-footer";
3
+ export { WizardHeader } from "./lib/components/wizard-header";
4
+ export { TextInputDisplay } from "./lib/components/text-input-display";
5
+ export { ConfirmPromptApp, confirmPrompt } from "./lib/components/confirm-prompt";