@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
|
@@ -4,13 +4,38 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
6
6
|
import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
|
|
7
|
-
import { getWrapperBinPath } from "../../../lib/package-paths";
|
|
8
7
|
import type { FrameworkConfig } from "../../../lib/orchestrator/types";
|
|
9
8
|
import { refreshFromTemplate, stripGuidanceBlock } from "../plugins/guidance";
|
|
10
|
-
import {
|
|
11
|
-
|
|
9
|
+
import {
|
|
10
|
+
TOKENSAVE_CLAUDE_MD_MARKER,
|
|
11
|
+
TOKENSAVE_WORKING_REPO_EXCLUDE_ENTRIES,
|
|
12
|
+
} from "../capabilities/tokensave-shared";
|
|
13
|
+
import {
|
|
14
|
+
instructionBlockKey,
|
|
15
|
+
removeManagedBlocksForPlugin,
|
|
16
|
+
upsertManagedBlock,
|
|
17
|
+
} from "../context-services";
|
|
18
|
+
import type { CapabilityContributionInput, ProviderPlugin, SetupContext } from "../plugin";
|
|
12
19
|
import { resolveGitInfoExcludePath } from "../git-utils";
|
|
13
|
-
import {
|
|
20
|
+
import { mergeDir, pruneEmptyAncestors } from "../utils";
|
|
21
|
+
import {
|
|
22
|
+
cutFromMarker,
|
|
23
|
+
stripSectionFromFile,
|
|
24
|
+
type RemoveHeadingSectionOptions,
|
|
25
|
+
} from "./agent-file-sections";
|
|
26
|
+
import { patchSkillTreeMarkdownFiles } from "./skill-tree";
|
|
27
|
+
import {
|
|
28
|
+
filterClaudeHookGroups,
|
|
29
|
+
mergeClaudeHookGroups,
|
|
30
|
+
readClaudeMcpConfig,
|
|
31
|
+
readClaudeSettings,
|
|
32
|
+
toClaudeMcpEntry,
|
|
33
|
+
updateClaudeMcpServer,
|
|
34
|
+
writeClaudeMcpConfig,
|
|
35
|
+
writeClaudeSettings,
|
|
36
|
+
type ClaudeHookGroup,
|
|
37
|
+
type ClaudeSettings,
|
|
38
|
+
} from "./claude-format";
|
|
14
39
|
import { getSetupRootTemplates } from "./utils";
|
|
15
40
|
|
|
16
41
|
async function configureClaudeGuidance(companionPath: string): Promise<void> {
|
|
@@ -89,46 +114,9 @@ const LEGACY_MANAGED_PERMISSION_ENTRIES = [
|
|
|
89
114
|
"Bash($MATE_COMPANION_BIN_PATH/graphify:*)",
|
|
90
115
|
];
|
|
91
116
|
|
|
92
|
-
// Enabled capability -> the `permissions.allow` entries it pre-seeds.
|
|
93
|
-
function getCapabilityPermissionEntries(): Record<string, string[]> {
|
|
94
|
-
const wrapperBinPath = getWrapperBinPath();
|
|
95
|
-
return {
|
|
96
|
-
openspec: [
|
|
97
|
-
"Skill(openspec-explore)",
|
|
98
|
-
"Skill(openspec-propose)",
|
|
99
|
-
"Skill(openspec-apply-change)",
|
|
100
|
-
"Skill(openspec-archive-change)",
|
|
101
|
-
"Skill(mate-artifact-finish)",
|
|
102
|
-
"Bash(openspec:*)",
|
|
103
|
-
`Bash(${FRAMEWORK_NAME} cap graphify:*)`,
|
|
104
|
-
`Bash(${path.join(wrapperBinPath, "openspec")}:*)`,
|
|
105
|
-
],
|
|
106
|
-
rtk: ["Bash(rtk:*)"],
|
|
107
|
-
graphify: [
|
|
108
|
-
"Skill(graphify)",
|
|
109
|
-
"Bash(graphify:*)",
|
|
110
|
-
`Bash(${FRAMEWORK_NAME} cap graphify:*)`,
|
|
111
|
-
`Bash(${path.join(wrapperBinPath, "graphify")}:*)`,
|
|
112
|
-
],
|
|
113
|
-
"react-doctor": [
|
|
114
|
-
"Skill(react-doctor)",
|
|
115
|
-
"Bash(npx react-doctor:*)",
|
|
116
|
-
"Bash(npx react-doctor@latest *)",
|
|
117
|
-
],
|
|
118
|
-
tokensave: ["mcp__tokensave__*"],
|
|
119
|
-
// The context-mode Claude plugin exposes its skill and MCP tools under the
|
|
120
|
-
// plugin namespace; pre-seed both so routine routing doesn't prompt.
|
|
121
|
-
"context-mode": [
|
|
122
|
-
"Skill(context-mode:context-mode)",
|
|
123
|
-
"mcp__plugin_context-mode_context-mode__*",
|
|
124
|
-
],
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
117
|
function getAllManagedPermissionEntries(companionPath: string): Set<string> {
|
|
129
118
|
return new Set([
|
|
130
119
|
...getBaseManagedPermissionEntries(companionPath),
|
|
131
|
-
...Object.values(getCapabilityPermissionEntries()).flat(),
|
|
132
120
|
...LEGACY_MANAGED_PERMISSION_ENTRIES,
|
|
133
121
|
// Legacy base entry: Claude Code never matched Glob() rules for file
|
|
134
122
|
// permission checks and warns about them, so setup no longer emits it.
|
|
@@ -137,37 +125,22 @@ function getAllManagedPermissionEntries(companionPath: string): Set<string> {
|
|
|
137
125
|
]);
|
|
138
126
|
}
|
|
139
127
|
|
|
140
|
-
|
|
141
|
-
type?: string;
|
|
142
|
-
command?: string;
|
|
143
|
-
args?: string[];
|
|
144
|
-
timeout?: number;
|
|
145
|
-
}
|
|
146
|
-
interface HookGroup {
|
|
147
|
-
matcher?: string;
|
|
148
|
-
hooks?: HookCommand[];
|
|
149
|
-
}
|
|
150
|
-
interface WorkingRepoSettings {
|
|
151
|
-
hooks?: Record<string, HookGroup[]>;
|
|
152
|
-
permissions?: { additionalDirectories?: string[]; allow?: string[] } & Record<string, unknown>;
|
|
153
|
-
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
|
154
|
-
[key: string]: unknown;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function isManagedHookGroup(group: HookGroup): boolean {
|
|
128
|
+
function isManagedHookGroup(group: ClaudeHookGroup, extraMarkers: string[] = []): boolean {
|
|
158
129
|
return (group.hooks ?? []).some((hook) =>
|
|
159
|
-
MANAGED_HOOK_MARKERS.some((marker) =>
|
|
130
|
+
[...MANAGED_HOOK_MARKERS, ...extraMarkers].some((marker) =>
|
|
131
|
+
(hook.command ?? "").includes(marker),
|
|
132
|
+
),
|
|
160
133
|
);
|
|
161
134
|
}
|
|
162
135
|
|
|
163
|
-
function removeManagedHookGroups(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
136
|
+
function removeManagedHookGroups(
|
|
137
|
+
settings: ClaudeSettings,
|
|
138
|
+
extraMarkers: string[] = [],
|
|
139
|
+
): ClaudeSettings {
|
|
140
|
+
const hooks = filterClaudeHookGroups(
|
|
141
|
+
settings.hooks ?? {},
|
|
142
|
+
(group) => !isManagedHookGroup(group, extraMarkers),
|
|
143
|
+
);
|
|
171
144
|
|
|
172
145
|
const next = { ...settings };
|
|
173
146
|
if (Object.keys(hooks).length > 0) {
|
|
@@ -178,18 +151,6 @@ function removeManagedHookGroups(settings: WorkingRepoSettings): WorkingRepoSett
|
|
|
178
151
|
return next;
|
|
179
152
|
}
|
|
180
153
|
|
|
181
|
-
async function readWorkingRepoSettings(settingsPath: string): Promise<WorkingRepoSettings> {
|
|
182
|
-
try {
|
|
183
|
-
const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
|
|
184
|
-
if (parsed && typeof parsed === "object") {
|
|
185
|
-
return parsed as WorkingRepoSettings;
|
|
186
|
-
}
|
|
187
|
-
} catch {
|
|
188
|
-
// Absent or unparseable — start from an empty object.
|
|
189
|
-
}
|
|
190
|
-
return {};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
154
|
export async function ensureWorkingRepoLocalExcludes(
|
|
194
155
|
workingRepoPath: string,
|
|
195
156
|
config: FrameworkConfig,
|
|
@@ -234,9 +195,6 @@ export async function ensureWorkingRepoLocalExcludes(
|
|
|
234
195
|
await fs.writeFile(excludePath, nextContent, "utf8");
|
|
235
196
|
}
|
|
236
197
|
|
|
237
|
-
// Marker that identifies the tokensave installer's CLAUDE.md append block.
|
|
238
|
-
const TOKENSAVE_CLAUDE_MD_MARKER = "## MANDATORY: No Explore Agents When Tokensave Is Available";
|
|
239
|
-
|
|
240
198
|
async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<void> {
|
|
241
199
|
const claudeMdPath = path.join(workingRepoPath, "CLAUDE.md");
|
|
242
200
|
let content: string;
|
|
@@ -246,13 +204,10 @@ async function stripTokensaveClaudeMdAppend(workingRepoPath: string): Promise<vo
|
|
|
246
204
|
return;
|
|
247
205
|
}
|
|
248
206
|
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const before = content.slice(0, idx).replace(/\s+$/, "");
|
|
254
|
-
if (before.length > 0) {
|
|
255
|
-
await fs.writeFile(claudeMdPath, before + "\n", "utf8");
|
|
207
|
+
const stripped = cutFromMarker(content, TOKENSAVE_CLAUDE_MD_MARKER);
|
|
208
|
+
if (stripped === content) return;
|
|
209
|
+
if (stripped.length > 0) {
|
|
210
|
+
await fs.writeFile(claudeMdPath, stripped, "utf8");
|
|
256
211
|
} else {
|
|
257
212
|
await fs.unlink(claudeMdPath);
|
|
258
213
|
}
|
|
@@ -277,68 +232,37 @@ export function getCompanionClaudeMcpConfigPath(companionPath: string): string {
|
|
|
277
232
|
// groups/entries always lead their arrays so the emitted shape stays stable
|
|
278
233
|
// across syncs; unmanaged content is preserved untouched.
|
|
279
234
|
function buildManagedClaudeSettings(
|
|
280
|
-
existing:
|
|
235
|
+
existing: ClaudeSettings,
|
|
281
236
|
companionPath: string,
|
|
282
237
|
config: FrameworkConfig,
|
|
283
|
-
|
|
284
|
-
):
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
238
|
+
contributions: CapabilityContributionInput[] = [],
|
|
239
|
+
): ClaudeSettings {
|
|
240
|
+
// Declared markers and permission entries widen the managed strip set for
|
|
241
|
+
// every registered Capability, enabled or not, so deselection tears down.
|
|
242
|
+
const declaredMarkers = contributions.flatMap((input) =>
|
|
243
|
+
(input.contributions.hookGroups ?? []).map((hook) => hook.marker),
|
|
244
|
+
);
|
|
245
|
+
const declaredPermissionEntries = contributions.flatMap(
|
|
246
|
+
(input) => input.contributions.permissionEntries ?? [],
|
|
247
|
+
);
|
|
248
|
+
const enabledDeclaredPermissionEntries = contributions
|
|
249
|
+
.filter((input) => input.enabled)
|
|
250
|
+
.flatMap((input) => input.contributions.permissionEntries ?? []);
|
|
288
251
|
|
|
289
252
|
// Mate's own hooks (artifact-path guard, session banner, archive-finish
|
|
290
253
|
// nudge) ship in the bundled Claude plugin loaded at launch; settings-sync
|
|
291
254
|
// only strips their legacy managed groups (via removeManagedHookGroups) and
|
|
292
255
|
// reconciles the capability hooks that remain settings-delivered.
|
|
293
|
-
const hooks = removeManagedHookGroups(existing).hooks ?? {};
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
timeout: 5,
|
|
304
|
-
},
|
|
305
|
-
],
|
|
306
|
-
},
|
|
307
|
-
...(hooks.PostToolUse ?? []),
|
|
308
|
-
];
|
|
309
|
-
hooks.Stop = [
|
|
310
|
-
{
|
|
311
|
-
hooks: [
|
|
312
|
-
{
|
|
313
|
-
type: "command",
|
|
314
|
-
command: `sh "${companionPath}/.claude/hooks/react-doctor.sh"`,
|
|
315
|
-
timeout: 45,
|
|
316
|
-
},
|
|
317
|
-
],
|
|
318
|
-
},
|
|
319
|
-
...(hooks.Stop ?? []),
|
|
320
|
-
];
|
|
321
|
-
}
|
|
322
|
-
if (enabledNames.has("tokensave")) {
|
|
323
|
-
hooks.PreToolUse = [
|
|
324
|
-
{
|
|
325
|
-
matcher: "Agent|Grep|Bash",
|
|
326
|
-
hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-pre-tool-use"] }],
|
|
327
|
-
},
|
|
328
|
-
...(hooks.PreToolUse ?? []),
|
|
329
|
-
];
|
|
330
|
-
hooks.UserPromptSubmit = [
|
|
331
|
-
{
|
|
332
|
-
hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-prompt-submit"] }],
|
|
333
|
-
},
|
|
334
|
-
...(hooks.UserPromptSubmit ?? []),
|
|
335
|
-
];
|
|
336
|
-
hooks.Stop = [
|
|
337
|
-
{
|
|
338
|
-
hooks: [{ type: "command", command: tokensaveCommandPath, args: ["hook-stop"] }],
|
|
339
|
-
},
|
|
340
|
-
...(hooks.Stop ?? []),
|
|
341
|
-
];
|
|
256
|
+
const hooks = removeManagedHookGroups(existing, declaredMarkers).hooks ?? {};
|
|
257
|
+
// Declared hook groups are applied after the table-driven blocks so managed
|
|
258
|
+
// groups keep leading their event arrays in the same order as before the
|
|
259
|
+
// capabilities migrated to declarations.
|
|
260
|
+
for (const input of contributions) {
|
|
261
|
+
if (!input.enabled) continue;
|
|
262
|
+
// Reversed so multiple groups of one declaration end up in declared order.
|
|
263
|
+
for (const hook of (input.contributions.hookGroups ?? []).toReversed()) {
|
|
264
|
+
hooks[hook.event] = [hook.group, ...(hooks[hook.event] ?? [])];
|
|
265
|
+
}
|
|
342
266
|
}
|
|
343
267
|
for (const event of Object.keys(hooks)) {
|
|
344
268
|
if (hooks[event].length === 0) delete hooks[event];
|
|
@@ -347,13 +271,13 @@ function buildManagedClaudeSettings(
|
|
|
347
271
|
// permissions.allow: preserve unmanaged entries in place, then union the
|
|
348
272
|
// Mate-managed base entries and enabled capability entries. Dropping a
|
|
349
273
|
// capability removes only its managed entry.
|
|
350
|
-
const
|
|
351
|
-
|
|
274
|
+
const allManagedPermissionEntries = new Set([
|
|
275
|
+
...getAllManagedPermissionEntries(companionPath),
|
|
276
|
+
...declaredPermissionEntries,
|
|
277
|
+
]);
|
|
352
278
|
const managedAllow = [
|
|
353
279
|
...getBaseManagedPermissionEntries(companionPath),
|
|
354
|
-
...
|
|
355
|
-
.filter(([name]) => enabledNames.has(name))
|
|
356
|
-
.flatMap(([, entries]) => entries),
|
|
280
|
+
...enabledDeclaredPermissionEntries,
|
|
357
281
|
];
|
|
358
282
|
const existingPermissions = existing.permissions ?? {};
|
|
359
283
|
const existingAllow = Array.isArray(existingPermissions.allow) ? existingPermissions.allow : [];
|
|
@@ -374,7 +298,7 @@ function buildManagedClaudeSettings(
|
|
|
374
298
|
// entry is removed so it cannot shadow the `.mcp.json` definition.
|
|
375
299
|
delete mcpServers.tokensave;
|
|
376
300
|
|
|
377
|
-
const settings:
|
|
301
|
+
const settings: ClaudeSettings = { ...existing, hooks, autoMemoryEnabled: false };
|
|
378
302
|
if (Object.keys(hooks).length === 0) {
|
|
379
303
|
delete settings.hooks;
|
|
380
304
|
}
|
|
@@ -398,54 +322,125 @@ function buildManagedClaudeSettings(
|
|
|
398
322
|
export async function syncCompanionClaudeSettings(
|
|
399
323
|
companionPath: string,
|
|
400
324
|
config: FrameworkConfig,
|
|
325
|
+
contributions: CapabilityContributionInput[] = [],
|
|
401
326
|
): Promise<void> {
|
|
402
|
-
const tokensaveCommandPath =
|
|
403
|
-
resolveCommandOnPath("tokensave", process.env.PATH ?? "") ?? "tokensave";
|
|
404
|
-
|
|
405
327
|
const settingsPath = getCompanionClaudeSettingsPath(companionPath);
|
|
406
|
-
const existing = await
|
|
407
|
-
const settings = buildManagedClaudeSettings(
|
|
408
|
-
existing,
|
|
409
|
-
companionPath,
|
|
410
|
-
config,
|
|
411
|
-
tokensaveCommandPath,
|
|
412
|
-
);
|
|
328
|
+
const existing = await readClaudeSettings(settingsPath);
|
|
329
|
+
const settings = buildManagedClaudeSettings(existing, companionPath, config, contributions);
|
|
413
330
|
|
|
414
|
-
await
|
|
415
|
-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
331
|
+
await writeClaudeSettings(settingsPath, settings);
|
|
416
332
|
|
|
417
|
-
await syncCompanionClaudeMcpConfig(companionPath
|
|
333
|
+
await syncCompanionClaudeMcpConfig(companionPath);
|
|
418
334
|
}
|
|
419
335
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Runtime Surface reconciliation: apply/remove declared Capability
|
|
338
|
+
// contributions (spec: runtime-surface). Managed identity uses the existing
|
|
339
|
+
// marker scheme — hook markers, permission-entry membership, managed guidance
|
|
340
|
+
// blocks, and named skill trees / MCP servers.
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export async function reconcileClaudeContributions(
|
|
344
|
+
ctx: SetupContext,
|
|
345
|
+
inputs: CapabilityContributionInput[],
|
|
428
346
|
): Promise<void> {
|
|
429
|
-
const
|
|
430
|
-
const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
|
|
347
|
+
const { companionPath, config } = ctx;
|
|
431
348
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
349
|
+
if (ctx.scope === "hub") {
|
|
350
|
+
await reconcileClaudeMcpContributions(ctx, inputs);
|
|
351
|
+
await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The settings sync (re)creates the managed settings document, so it only
|
|
356
|
+
// runs while the Claude runtime is active; a deactivated runtime's pass is
|
|
357
|
+
// teardown-only and must not resurrect files the provider teardown removed.
|
|
358
|
+
if (ctx.activeProviders.includes("claude")) {
|
|
359
|
+
await syncCompanionClaudeSettings(companionPath, config, inputs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await reconcileClaudeMcpContributions(ctx, inputs);
|
|
363
|
+
await reconcileClaudeAgentDefinitionContributions(ctx, inputs);
|
|
364
|
+
|
|
365
|
+
for (const input of inputs) {
|
|
366
|
+
// Guidance sections are managed blocks in CLAUDE.md. Current sections are
|
|
367
|
+
// upserted, then stale keys (content changes, disabled capability) are
|
|
368
|
+
// swept. CLAUDE.md is Claude-exclusive, so no shared-file guard applies.
|
|
369
|
+
const guidancePath = path.join(companionPath, "CLAUDE.md");
|
|
370
|
+
const sections = input.enabled ? (input.contributions.guidanceSections ?? []) : [];
|
|
371
|
+
const keepKeys = new Set<string>();
|
|
372
|
+
for (const section of sections) {
|
|
373
|
+
const blockKey = instructionBlockKey(input.pluginId, section.content);
|
|
374
|
+
keepKeys.add(blockKey);
|
|
375
|
+
await upsertManagedBlock(guidancePath, FRAMEWORK_NAME, blockKey, section.content);
|
|
376
|
+
}
|
|
377
|
+
if ((input.contributions.guidanceSections ?? []).length > 0) {
|
|
378
|
+
await removeManagedBlocksForPlugin(guidancePath, FRAMEWORK_NAME, input.pluginId, keepKeys);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const skillTree of input.contributions.skillTrees ?? []) {
|
|
382
|
+
const skillDir = path.join(companionPath, ".claude", "skills", skillTree.name);
|
|
383
|
+
if (input.enabled) {
|
|
384
|
+
await mergeDir(skillTree.sourceDir, skillDir);
|
|
385
|
+
} else {
|
|
386
|
+
await fs.rm(skillDir, { recursive: true, force: true });
|
|
387
|
+
await pruneEmptyAncestors(path.join(companionPath, ".claude", "skills"), companionPath);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Reconcile only MCP entries without touching Claude settings or guidance. */
|
|
394
|
+
async function reconcileClaudeMcpContributions(
|
|
395
|
+
ctx: SetupContext,
|
|
396
|
+
inputs: CapabilityContributionInput[],
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
for (const input of inputs) {
|
|
399
|
+
for (const descriptor of input.contributions.mcpServers ?? []) {
|
|
400
|
+
await updateClaudeMcpServer(
|
|
401
|
+
getCompanionClaudeMcpConfigPath(ctx.companionPath),
|
|
402
|
+
descriptor.name,
|
|
403
|
+
input.enabled ? toClaudeMcpEntry(descriptor) : null,
|
|
404
|
+
);
|
|
437
405
|
}
|
|
438
|
-
} catch {
|
|
439
|
-
// Absent or unparseable — start from an empty object.
|
|
440
406
|
}
|
|
407
|
+
}
|
|
441
408
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Reconcile declared agent definition files under `.claude/agents/`. Runs in
|
|
411
|
+
* both companion and hub scope — an agent definition is fully self-contained
|
|
412
|
+
* (no shared-file merge), so it needs no companion-only surface.
|
|
413
|
+
*/
|
|
414
|
+
async function reconcileClaudeAgentDefinitionContributions(
|
|
415
|
+
ctx: SetupContext,
|
|
416
|
+
inputs: CapabilityContributionInput[],
|
|
417
|
+
): Promise<void> {
|
|
418
|
+
const agentsDir = path.join(ctx.companionPath, ".claude", "agents");
|
|
419
|
+
for (const input of inputs) {
|
|
420
|
+
for (const agent of input.contributions.agentDefinitions ?? []) {
|
|
421
|
+
const agentPath = path.join(agentsDir, `${agent.name}.md`);
|
|
422
|
+
if (input.enabled) {
|
|
423
|
+
await fs.mkdir(agentsDir, { recursive: true });
|
|
424
|
+
await fs.writeFile(agentPath, agent.content, "utf8");
|
|
425
|
+
} else {
|
|
426
|
+
await fs.rm(agentPath, { force: true });
|
|
427
|
+
await pruneEmptyAncestors(agentsDir, ctx.companionPath);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
445
430
|
}
|
|
431
|
+
}
|
|
446
432
|
|
|
447
|
-
|
|
448
|
-
|
|
433
|
+
// Maintain the companion `.mcp.json` shell. Managed MCP servers are reconciled
|
|
434
|
+
// from declared Capability contributions (and legacy `ctx.mcp` hosting); this
|
|
435
|
+
// only guarantees the file exists with an `mcpServers` map. Loaded at launch
|
|
436
|
+
// via `claude --mcp-config`.
|
|
437
|
+
async function syncCompanionClaudeMcpConfig(companionPath: string): Promise<void> {
|
|
438
|
+
const mcpConfigPath = getCompanionClaudeMcpConfigPath(companionPath);
|
|
439
|
+
const { config: existing } = await readClaudeMcpConfig(mcpConfigPath);
|
|
440
|
+
await writeClaudeMcpConfig(mcpConfigPath, {
|
|
441
|
+
...existing,
|
|
442
|
+
mcpServers: { ...existing.mcpServers },
|
|
443
|
+
});
|
|
449
444
|
}
|
|
450
445
|
|
|
451
446
|
// Reconcile the working repo for a Claude launch. Mate-managed Claude settings
|
|
@@ -478,7 +473,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
|
478
473
|
const settingsPath = path.join(workingRepoPath, ".claude", "settings.local.json");
|
|
479
474
|
// Migrate hooks only. Existing permissions and MCP entries stay in the
|
|
480
475
|
// working repo; additionalDirectories is reconciled below.
|
|
481
|
-
const existing = removeManagedHookGroups(await
|
|
476
|
+
const existing = removeManagedHookGroups(await readClaudeSettings(settingsPath));
|
|
482
477
|
const permissions = { ...existing.permissions };
|
|
483
478
|
const existingAdditionalDirectories = Array.isArray(permissions.additionalDirectories)
|
|
484
479
|
? permissions.additionalDirectories
|
|
@@ -501,7 +496,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
|
501
496
|
),
|
|
502
497
|
);
|
|
503
498
|
|
|
504
|
-
const settings:
|
|
499
|
+
const settings: ClaudeSettings = {
|
|
505
500
|
...existing,
|
|
506
501
|
permissions: {
|
|
507
502
|
...permissions,
|
|
@@ -509,8 +504,7 @@ async function syncWorkingRepoClaudeAdditionalDirectories(
|
|
|
509
504
|
},
|
|
510
505
|
};
|
|
511
506
|
|
|
512
|
-
await
|
|
513
|
-
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
507
|
+
await writeClaudeSettings(settingsPath, settings);
|
|
514
508
|
}
|
|
515
509
|
|
|
516
510
|
async function configureClaude(companionPath: string): Promise<void> {
|
|
@@ -608,48 +602,100 @@ async function teardownLegacyClaudeBin(companionPath: string): Promise<void> {
|
|
|
608
602
|
await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
|
|
609
603
|
}
|
|
610
604
|
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// Runtime Surface escape hatch (spec: runtime-surface). Imperative operations
|
|
607
|
+
// for effects a declaration cannot express — patching skill trees an external
|
|
608
|
+
// CLI wrote, absorbing/stripping foreign config another tool produced. Format
|
|
609
|
+
// knowledge stays in this module; capabilities pass only predicates.
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/** Patch markdown files of an externally written `.claude/skills/<name>` tree. */
|
|
613
|
+
export async function patchClaudeSkillTree(
|
|
614
614
|
companionPath: string,
|
|
615
615
|
name: string,
|
|
616
|
-
|
|
616
|
+
transform: (content: string) => string,
|
|
617
|
+
options: { excludeFiles?: readonly string[] } = {},
|
|
617
618
|
): Promise<void> {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
619
|
+
await patchSkillTreeMarkdownFiles(
|
|
620
|
+
path.join(companionPath, ".claude", "skills", name),
|
|
621
|
+
transform,
|
|
622
|
+
new Set(options.excludeFiles ?? []),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Strip a foreign heading section from the Claude guidance surfaces: the
|
|
628
|
+
* companion CLAUDE.md, the legacy `.claude/CLAUDE.md`, and — when syncing from
|
|
629
|
+
* a linked repo — the repo-level CLAUDE.md.
|
|
630
|
+
*/
|
|
631
|
+
export async function stripClaudeForeignSections(
|
|
632
|
+
companionPath: string,
|
|
633
|
+
options: RemoveHeadingSectionOptions & { repoPath?: string },
|
|
634
|
+
): Promise<void> {
|
|
635
|
+
await stripSectionFromFile(path.join(companionPath, "CLAUDE.md"), options);
|
|
636
|
+
await stripSectionFromFile(path.join(companionPath, ".claude", "CLAUDE.md"), options);
|
|
637
|
+
await pruneEmptyAncestors(path.join(companionPath, ".claude"), companionPath);
|
|
638
|
+
if (options.repoPath) {
|
|
639
|
+
await stripSectionFromFile(path.join(options.repoPath, "CLAUDE.md"), options);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Merge hook groups an external tool wrote to `.claude/settings.json` into the
|
|
645
|
+
* Mate-owned `settings.local.json`, then remove the tracked file so it cannot
|
|
646
|
+
* shadow companion config. Malformed or absent files skip the merge but the
|
|
647
|
+
* `settings.json` removal still happens.
|
|
648
|
+
*/
|
|
649
|
+
export async function mergeClaudeSettingsJsonHooks(companionPath: string): Promise<void> {
|
|
650
|
+
const settingsJsonPath = path.join(companionPath, ".claude", "settings.json");
|
|
651
|
+
const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
|
|
621
652
|
try {
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
653
|
+
const settingsJson = JSON.parse(await fs.readFile(settingsJsonPath, "utf8")) as ClaudeSettings;
|
|
654
|
+
const settingsLocal = JSON.parse(
|
|
655
|
+
await fs.readFile(settingsLocalPath, "utf8"),
|
|
656
|
+
) as ClaudeSettings;
|
|
657
|
+
|
|
658
|
+
if (settingsJson.hooks && Object.keys(settingsJson.hooks).length > 0) {
|
|
659
|
+
settingsLocal.hooks = mergeClaudeHookGroups(settingsLocal.hooks ?? {}, settingsJson.hooks);
|
|
626
660
|
}
|
|
661
|
+
|
|
662
|
+
await writeClaudeSettings(settingsLocalPath, settingsLocal);
|
|
627
663
|
} catch {
|
|
628
|
-
|
|
664
|
+
/* settings files absent or malformed — just remove settings.json */
|
|
629
665
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
if (!(name in mcpServers)) return;
|
|
635
|
-
delete mcpServers[name];
|
|
636
|
-
} else {
|
|
637
|
-
mcpServers[name] = entry;
|
|
666
|
+
try {
|
|
667
|
+
await fs.unlink(settingsJsonPath);
|
|
668
|
+
} catch {
|
|
669
|
+
/* not present */
|
|
638
670
|
}
|
|
639
|
-
await fs.writeFile(
|
|
640
|
-
mcpConfigPath,
|
|
641
|
-
JSON.stringify({ ...existing, mcpServers }, null, 2) + "\n",
|
|
642
|
-
"utf8",
|
|
643
|
-
);
|
|
644
671
|
}
|
|
645
672
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
673
|
+
/** Remove settings hook groups matching `isForeign`, pruning emptied containers. */
|
|
674
|
+
export async function removeClaudeHookGroupsWhere(
|
|
675
|
+
companionPath: string,
|
|
676
|
+
isForeign: (group: ClaudeHookGroup) => boolean,
|
|
677
|
+
): Promise<void> {
|
|
678
|
+
const settingsLocalPath = getCompanionClaudeSettingsPath(companionPath);
|
|
679
|
+
let settings: ClaudeSettings;
|
|
680
|
+
try {
|
|
681
|
+
settings = JSON.parse(await fs.readFile(settingsLocalPath, "utf8")) as ClaudeSettings;
|
|
682
|
+
} catch {
|
|
683
|
+
return; /* settings absent or malformed */
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const existingHooks = settings.hooks ?? {};
|
|
687
|
+
const hooks = filterClaudeHookGroups(existingHooks, (group) => !isForeign(group));
|
|
688
|
+
const changed =
|
|
689
|
+
JSON.stringify(hooks) !== JSON.stringify(existingHooks) ||
|
|
690
|
+
(Object.keys(hooks).length === 0 && Object.prototype.hasOwnProperty.call(settings, "hooks"));
|
|
691
|
+
if (!changed) return;
|
|
692
|
+
|
|
693
|
+
if (Object.keys(hooks).length > 0) {
|
|
694
|
+
settings.hooks = hooks;
|
|
695
|
+
} else {
|
|
696
|
+
delete settings.hooks;
|
|
697
|
+
}
|
|
698
|
+
await writeClaudeSettings(settingsLocalPath, settings);
|
|
653
699
|
}
|
|
654
700
|
|
|
655
701
|
export function createClaudePlugin(): ProviderPlugin {
|
|
@@ -663,15 +709,19 @@ export function createClaudePlugin(): ProviderPlugin {
|
|
|
663
709
|
gitignoreEntries: () => COMPANION_GITIGNORE_ENTRIES,
|
|
664
710
|
hosting: {
|
|
665
711
|
mcp: {
|
|
666
|
-
async register(ctx: SetupContext, descriptor
|
|
667
|
-
await
|
|
668
|
-
ctx.companionPath,
|
|
712
|
+
async register(ctx: SetupContext, descriptor) {
|
|
713
|
+
await updateClaudeMcpServer(
|
|
714
|
+
getCompanionClaudeMcpConfigPath(ctx.companionPath),
|
|
669
715
|
descriptor.name,
|
|
670
|
-
|
|
716
|
+
toClaudeMcpEntry(descriptor),
|
|
671
717
|
);
|
|
672
718
|
},
|
|
673
719
|
async unregister(ctx: SetupContext, name: string) {
|
|
674
|
-
await
|
|
720
|
+
await updateClaudeMcpServer(
|
|
721
|
+
getCompanionClaudeMcpConfigPath(ctx.companionPath),
|
|
722
|
+
name,
|
|
723
|
+
null,
|
|
724
|
+
);
|
|
675
725
|
},
|
|
676
726
|
},
|
|
677
727
|
instructions: {
|
|
@@ -679,6 +729,10 @@ export function createClaudePlugin(): ProviderPlugin {
|
|
|
679
729
|
},
|
|
680
730
|
},
|
|
681
731
|
async apply(ctx) {
|
|
732
|
+
if (ctx.scope === "hub") {
|
|
733
|
+
await syncCompanionClaudeSettings(ctx.companionPath, ctx.config);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
682
736
|
await configureClaude(ctx.companionPath);
|
|
683
737
|
await configureClaudeGuidance(ctx.companionPath);
|
|
684
738
|
await teardownLegacyClaudeBin(ctx.companionPath);
|