@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.
- package/claude-plugin/.claude-plugin/plugin.json +4 -0
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
- package/claude-plugin/hooks/hooks.json +37 -0
- package/claude-plugin/hooks/session-banner.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +28 -0
- package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
- package/package.json +4 -2
- package/src/cli/commands/artifact/finish/openspec.ts +1 -5
- package/src/cli/commands/companion/hub.ts +99 -0
- package/src/cli/commands/companion/link.ts +0 -5
- package/src/cli/commands/companion/tui.ts +3 -2
- package/src/cli/commands/doctor.ts +254 -170
- package/src/cli/commands/install.ts +18 -14
- package/src/cli/commands/launch/shared.ts +4 -4
- package/src/cli/commands/plugin/install.ts +94 -0
- package/src/cli/commands/plugin/plugin.ts +17 -0
- package/src/cli/commands/setup.ts +4 -6
- package/src/cli/commands/update.ts +9 -11
- package/src/cli/companion-link-wizard.tsx +22 -78
- package/src/cli/main.ts +57 -13
- package/src/cli/plugin-commands.ts +4 -4
- package/src/cli/repo-list-table.tsx +2 -7
- package/src/cli/usage.ts +7 -3
- package/src/distribution.ts +3 -5
- package/src/framework.ts +4 -36
- package/src/hooks/artifact-finish-nudge.ts +212 -0
- package/src/hooks/session-banner.ts +25 -0
- package/src/hooks/validate-artifact-path.ts +236 -0
- package/src/index.ts +4 -0
- package/src/lib/context-mode-package.ts +5 -3
- package/src/lib/install.ts +31 -43
- package/src/lib/orchestrator/adapters/base.ts +7 -8
- package/src/lib/orchestrator/adapters/claude.ts +16 -0
- package/src/lib/orchestrator/adapters/opencode.ts +10 -60
- package/src/lib/orchestrator/companion-hub.ts +380 -0
- package/src/lib/orchestrator/companion-store.ts +1 -35
- package/src/lib/orchestrator/config-store.ts +94 -7
- package/src/lib/orchestrator/editor.ts +4 -20
- package/src/lib/orchestrator/framework-context.ts +119 -24
- package/src/lib/orchestrator/global-config-store.ts +1 -5
- package/src/lib/orchestrator/launcher.ts +3 -9
- package/src/lib/orchestrator/migration.ts +0 -23
- package/src/lib/orchestrator/opencode-guidance.ts +1 -2
- package/src/lib/orchestrator/repo-local-registry.ts +3 -1
- package/src/lib/orchestrator/root-context.ts +117 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +5 -18
- package/src/lib/orchestrator/types.ts +27 -19
- package/src/lib/orchestrator/working-repo-store.ts +9 -3
- package/src/lib/package-paths.ts +37 -0
- package/src/lib/update-checker.ts +5 -5
- package/src/playbooks/companion-guidance.ts +7 -7
- package/src/runtime/env.ts +0 -4
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
- package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
- 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 +3541 -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 +27 -30
- 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 +50 -71
- package/src/tools/setup/context-services.ts +29 -0
- package/src/tools/setup/dynamic-plugins/host.ts +2 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
- package/src/tools/setup/dynamic-plugins/install.ts +124 -109
- package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
- package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
- package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
- package/src/tools/setup/engine.ts +65 -1
- package/src/tools/setup/mate.ts +28 -31
- package/src/tools/setup/plugin.ts +81 -0
- package/src/tools/setup/plugins/gitignore.ts +19 -9
- package/src/tools/setup/plugins/guidance.ts +2 -6
- package/src/tools/setup/policy.ts +4 -7
- 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 +283 -292
- package/src/tools/setup/providers/opencode-format.ts +146 -0
- package/src/tools/setup/providers/opencode.ts +171 -66
- package/src/tools/setup/providers/skill-tree.ts +38 -0
- package/src/tools/setup.ts +12 -7
- package/src/tui.ts +5 -0
- package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
- package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
- package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
- 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 {
|
|
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
|
|
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
|
|
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:
|
|
105
|
-
const
|
|
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
|
|
120
|
+
setOpenCodePluginReferences(config, [...preserved, pluginReference]);
|
|
110
121
|
}
|
|
111
122
|
|
|
112
|
-
function stripMatePluginReference(config:
|
|
123
|
+
function stripMatePluginReference(config: OpenCodeConfig): void {
|
|
113
124
|
if (!Array.isArray(config.plugin)) return;
|
|
114
125
|
|
|
115
|
-
const preserved = config.
|
|
126
|
+
const preserved = getOpenCodePluginReferences(config).filter(
|
|
116
127
|
(entry) => !isMateOpenCodePluginReference(entry) && !isLegacyMatePluginConfigEntry(entry),
|
|
117
128
|
);
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
-
`${
|
|
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 \`${
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
entry: Record<string, unknown> | null,
|
|
387
|
+
isForeign: (entry: unknown) => boolean,
|
|
348
388
|
): Promise<void> {
|
|
349
389
|
const configPath = getCompanionOpenCodeConfigPath(companionPath);
|
|
350
|
-
let
|
|
351
|
-
let present = false;
|
|
390
|
+
let raw: string;
|
|
352
391
|
try {
|
|
353
|
-
|
|
354
|
-
if (isRecord(parsed)) {
|
|
355
|
-
existing = parsed;
|
|
356
|
-
present = true;
|
|
357
|
-
}
|
|
392
|
+
raw = await fs.readFile(configPath, "utf8");
|
|
358
393
|
} catch {
|
|
359
|
-
|
|
394
|
+
return;
|
|
360
395
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
489
|
+
isEnabled: (config) => (config.allowedAgents ?? []).includes("opencode"),
|
|
389
490
|
hosting: {
|
|
390
491
|
mcp: {
|
|
391
|
-
async register(ctx: SetupContext, descriptor
|
|
392
|
-
await
|
|
393
|
-
ctx.companionPath,
|
|
492
|
+
async register(ctx: SetupContext, descriptor) {
|
|
493
|
+
await updateOpenCodeMcpServer(
|
|
494
|
+
getCompanionOpenCodeConfigPath(ctx.companionPath),
|
|
394
495
|
descriptor.name,
|
|
395
|
-
|
|
496
|
+
toOpenCodeMcpEntry(descriptor),
|
|
396
497
|
);
|
|
397
498
|
},
|
|
398
499
|
async unregister(ctx: SetupContext, name: string) {
|
|
399
|
-
await
|
|
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
|
+
}
|
package/src/tools/setup.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
`- **
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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";
|