@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.
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +5 -3
- package/claude-plugin/hooks/session-banner.mjs +5 -3
- package/claude-plugin/hooks/ts-loader.mjs +28 -0
- package/claude-plugin/hooks/validate-artifact-path.mjs +5 -3
- package/package.json +1 -1
- package/src/cli/commands/cap/openspec.ts +20 -1
- package/src/cli/commands/companion/hub.ts +1 -0
- package/src/cli/commands/plugin/install.ts +15 -3
- package/src/hooks/artifact-finish-nudge.ts +35 -3
- package/src/lib/context-mode-package.ts +5 -3
- package/src/lib/orchestrator/adapters/opencode.ts +10 -60
- package/src/lib/orchestrator/companion-git-sync.ts +67 -8
- package/src/lib/orchestrator/companion-hub.ts +28 -6
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/package-paths.ts +1 -0
- package/src/opencode/companion-hooks.ts +33 -12
- package/src/playbooks/companion-guidance.ts +1 -1
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-openspec-backfill/SKILL.md +65 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +1 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3612 -0
- package/src/tools/setup/capabilities/context-mode.ts +57 -83
- package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
- package/src/tools/setup/capabilities/graphify.ts +86 -295
- package/src/tools/setup/capabilities/openspec.ts +34 -1
- package/src/tools/setup/capabilities/react-doctor.ts +37 -37
- package/src/tools/setup/capabilities/rtk.ts +4 -1
- package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
- package/src/tools/setup/capabilities/tokensave.ts +51 -72
- package/src/tools/setup/context-services.ts +29 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +15 -1
- package/src/tools/setup/engine.ts +68 -1
- package/src/tools/setup/mate.ts +10 -2
- package/src/tools/setup/plugin.ts +105 -0
- package/src/tools/setup/plugins/gitignore.ts +7 -4
- package/src/tools/setup/plugins/guidance.ts +1 -1
- package/src/tools/setup/providers/agent-file-sections.ts +78 -0
- package/src/tools/setup/providers/claude-format.ts +159 -0
- package/src/tools/setup/providers/claude.ts +281 -227
- package/src/tools/setup/providers/opencode-format.ts +146 -0
- package/src/tools/setup/providers/opencode.ts +209 -60
- package/src/tools/setup/providers/skill-tree.ts +38 -0
- 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
|
|
14
|
-
import {
|
|
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:
|
|
104
|
-
const
|
|
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
|
|
120
|
+
setOpenCodePluginReferences(config, [...preserved, pluginReference]);
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
function stripMatePluginReference(config:
|
|
123
|
+
function stripMatePluginReference(config: OpenCodeConfig): void {
|
|
112
124
|
if (!Array.isArray(config.plugin)) return;
|
|
113
125
|
|
|
114
|
-
const preserved = config.
|
|
126
|
+
const preserved = getOpenCodePluginReferences(config).filter(
|
|
115
127
|
(entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
|
|
116
128
|
);
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
entry: Record<string, unknown> | null,
|
|
387
|
+
isForeign: (entry: unknown) => boolean,
|
|
347
388
|
): Promise<void> {
|
|
348
389
|
const configPath = getCompanionOpenCodeConfigPath(companionPath);
|
|
349
|
-
let
|
|
350
|
-
let present = false;
|
|
390
|
+
let raw: string;
|
|
351
391
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
//
|
|
410
|
+
// JSON parse error — leave file untouched
|
|
359
411
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
391
|
-
await
|
|
392
|
-
ctx.companionPath,
|
|
534
|
+
async register(ctx: SetupContext, descriptor) {
|
|
535
|
+
await updateOpenCodeMcpServer(
|
|
536
|
+
getCompanionOpenCodeConfigPath(ctx.companionPath),
|
|
393
537
|
descriptor.name,
|
|
394
|
-
|
|
538
|
+
toOpenCodeMcpEntry(descriptor),
|
|
395
539
|
);
|
|
396
540
|
},
|
|
397
541
|
async unregister(ctx: SetupContext, name: string) {
|
|
398
|
-
await
|
|
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
|
+
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -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({
|
|
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.
|