@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FrameworkConfig, LinkedRepository } from "../../lib/orchestrator/types";
|
|
2
2
|
import type { InstallRequirement, InstallRequirementContext } from "./install-contract";
|
|
3
|
+
import type { ClaudeHookGroup } from "./providers/claude-format";
|
|
3
4
|
|
|
4
5
|
export interface SetupContext {
|
|
5
6
|
companionPath: string;
|
|
@@ -112,9 +113,89 @@ export type PluginPolicy = "required" | "default" | "optional";
|
|
|
112
113
|
*/
|
|
113
114
|
export type PluginRegistration = Plugin | { plugin: Plugin; policy: PluginPolicy };
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A hook group a Capability contributes to a runtime's settings. `marker` is
|
|
118
|
+
* the command substring that identifies the group as Mate-managed (D4 marker
|
|
119
|
+
* scheme): reconciliation strips every group whose command contains any
|
|
120
|
+
* declared marker before re-adding the groups of enabled Capabilities.
|
|
121
|
+
*/
|
|
122
|
+
export interface HookGroupContribution {
|
|
123
|
+
event: string;
|
|
124
|
+
marker: string;
|
|
125
|
+
group: ClaudeHookGroup;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A skill directory copied into `<runtime dir>/skills/<name>`. */
|
|
129
|
+
export interface SkillTreeContribution {
|
|
130
|
+
name: string;
|
|
131
|
+
sourceDir: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A managed guidance section in the runtime's agent instruction file
|
|
136
|
+
* (CLAUDE.md / AGENTS.md). Reconciled as a framework-managed block; on
|
|
137
|
+
* teardown a section in a file shared with another active runtime is only
|
|
138
|
+
* stripped together with the last runtime using that file.
|
|
139
|
+
*/
|
|
140
|
+
export interface GuidanceSectionContribution {
|
|
141
|
+
content: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A plugin reference entry in the runtime's config (OpenCode `plugin` array).
|
|
146
|
+
* `isManagedReference` identifies entries this contribution owns so stale
|
|
147
|
+
* variants (e.g. older pins) are replaced and teardown removes only them.
|
|
148
|
+
*/
|
|
149
|
+
export interface PluginReferenceContribution {
|
|
150
|
+
reference: string;
|
|
151
|
+
isManagedReference(entry: unknown): boolean;
|
|
152
|
+
/** Config files to reconcile, relative to the runtime dir. Defaults to both OpenCode configs. */
|
|
153
|
+
configFiles?: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Declarative Agent Runtime contributions of one Capability for one runtime.
|
|
158
|
+
* The runtime's Runtime Surface reconciles these symmetrically: applied while
|
|
159
|
+
* the Capability is enabled, removed when it is not, idempotent across runs.
|
|
160
|
+
*/
|
|
161
|
+
export interface RuntimeContributions {
|
|
162
|
+
mcpServers?: McpServerDescriptor[];
|
|
163
|
+
hookGroups?: HookGroupContribution[];
|
|
164
|
+
permissionEntries?: string[];
|
|
165
|
+
guidanceSections?: GuidanceSectionContribution[];
|
|
166
|
+
skillTrees?: SkillTreeContribution[];
|
|
167
|
+
pluginReferences?: PluginReferenceContribution[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Contributions keyed by runtime id ("claude", "opencode"). Keyed-by-runtime
|
|
172
|
+
* because the payload shapes are runtime-specific (hook groups are
|
|
173
|
+
* Claude-shaped, plugin references OpenCode-shaped); a runtime-agnostic
|
|
174
|
+
* declaration would only push translation into every capability.
|
|
175
|
+
*/
|
|
176
|
+
export type RuntimeContributionsByRuntime = Partial<Record<string, RuntimeContributions>>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One Capability's contributions for one runtime, as handed to that runtime's
|
|
180
|
+
* Runtime Surface reconciliation. Disabled Capabilities participate too: their
|
|
181
|
+
* declarations define the managed entries to strip.
|
|
182
|
+
*/
|
|
183
|
+
export interface CapabilityContributionInput {
|
|
184
|
+
pluginId: string;
|
|
185
|
+
enabled: boolean;
|
|
186
|
+
contributions: RuntimeContributions;
|
|
187
|
+
}
|
|
188
|
+
|
|
115
189
|
export interface CapabilityPlugin extends Plugin {
|
|
116
190
|
kind: "capability";
|
|
117
191
|
requires?: { packageManagers: string[] };
|
|
192
|
+
/**
|
|
193
|
+
* Declare Agent Runtime contributions as data. Called on every setup/sync
|
|
194
|
+
* pass for all registered Capabilities — enabled ones contribute their
|
|
195
|
+
* entries, disabled ones only widen the managed strip set so their previous
|
|
196
|
+
* entries are removed.
|
|
197
|
+
*/
|
|
198
|
+
getRuntimeContributions?(ctx: SetupContext): RuntimeContributionsByRuntime;
|
|
118
199
|
forProvider?: Record<
|
|
119
200
|
string,
|
|
120
201
|
{
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getActiveDistribution } from "../../../distribution";
|
|
2
2
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY,
|
|
5
|
+
PLUGIN_WORKSPACE_NPMRC_GITIGNORE_ENTRY,
|
|
6
|
+
} from "../dynamic-plugins/paths";
|
|
4
7
|
import type { Plugin, SetupContext } from "../plugin";
|
|
5
8
|
|
|
6
9
|
const MANAGED_START = (name: string) => `# ${name} managed: start`;
|
|
@@ -58,22 +61,29 @@ export async function writeManagedGitignoreBlock(
|
|
|
58
61
|
|
|
59
62
|
export function collectManagedGitignoreEntries(ctx: SetupContext, plugins: Plugin[]): string[] {
|
|
60
63
|
// Baseline for every companion: node_modules never versions wherever a tool
|
|
61
|
-
// materializes it, and
|
|
62
|
-
// plugins
|
|
63
|
-
// version pins live in .mate/config and in code,
|
|
64
|
+
// materializes it, and the machine-local distribution-dependencies workspace
|
|
65
|
+
// (.mate/plugins/.local/ — context-mode, future consumers) is regenerated by
|
|
66
|
+
// setup/install in full — version pins live in .mate/config and in code,
|
|
67
|
+
// never in the tree itself. This is distinct from the committed dynamic-
|
|
68
|
+
// plugins workspace at .mate/plugins/, whose package.json and lockfile stay
|
|
69
|
+
// trackable.
|
|
64
70
|
const entries = [
|
|
65
71
|
"node_modules/",
|
|
66
|
-
`.${FRAMEWORK_NAME}/
|
|
72
|
+
`.${FRAMEWORK_NAME}/plugins/.local/`,
|
|
67
73
|
".mcp.json*",
|
|
68
74
|
...plugins
|
|
69
75
|
.filter((p) => p.kind !== "root" && (p.isEnabled(ctx.config) || p.persistGitignoreEntries))
|
|
70
76
|
.flatMap((p) => p.gitignoreEntries?.(ctx) ?? []),
|
|
71
77
|
];
|
|
72
|
-
// Dynamic-plugin loading artifacts: the local override file
|
|
73
|
-
//
|
|
74
|
-
//
|
|
78
|
+
// Dynamic-plugin loading artifacts: the local override file and the shared
|
|
79
|
+
// workspace's local registry credentials never version. The workspace's
|
|
80
|
+
// installed tree (node_modules) is already covered by the baseline
|
|
81
|
+
// node_modules/ rule above, since the workspace lives outside the
|
|
82
|
+
// dependencies tree wildcard; its package.json and committed
|
|
83
|
+
// package-lock.json are the pin/reproducibility record and are never
|
|
84
|
+
// touched by any ignore rule.
|
|
75
85
|
if (ctx.config.plugins?.length) {
|
|
76
|
-
entries.push(PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY);
|
|
86
|
+
entries.push(PLUGIN_WORKSPACE_NPMRC_GITIGNORE_ENTRY, PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY);
|
|
77
87
|
}
|
|
78
88
|
return entries;
|
|
79
89
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { FRAMEWORK_NAME
|
|
5
|
-
import { removeGraphifySection } from "../capabilities/graphify";
|
|
4
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
5
|
+
import { removeGraphifySection } from "../capabilities/graphify-shared";
|
|
6
6
|
|
|
7
7
|
// Marker names derive from the framework identity, never the invocation name.
|
|
8
8
|
const upperName = () => FRAMEWORK_NAME.toUpperCase();
|
|
@@ -12,9 +12,6 @@ function allMateStarts(): string[] {
|
|
|
12
12
|
`<!-- ${upperName()}:COMPANION:START -->`,
|
|
13
13
|
"<!-- COMPANION:GUIDANCE:START -->",
|
|
14
14
|
`<!-- ${upperName()}:COMPANION:GUIDANCE:START -->`,
|
|
15
|
-
...frameworkConfig.legacyNames.map(
|
|
16
|
-
(n) => `<!-- ${n.toUpperCase()}:COMPANION:GUIDANCE:START -->`,
|
|
17
|
-
),
|
|
18
15
|
`<!-- ${upperName()}:GRAPHIFY:START -->`,
|
|
19
16
|
];
|
|
20
17
|
}
|
|
@@ -24,7 +21,6 @@ function allMateEnds(): string[] {
|
|
|
24
21
|
`<!-- ${upperName()}:COMPANION:END -->`,
|
|
25
22
|
"<!-- COMPANION:GUIDANCE:END -->",
|
|
26
23
|
`<!-- ${upperName()}:COMPANION:GUIDANCE:END -->`,
|
|
27
|
-
...frameworkConfig.legacyNames.map((n) => `<!-- ${n.toUpperCase()}:COMPANION:GUIDANCE:END -->`),
|
|
28
24
|
`<!-- ${upperName()}:GRAPHIFY:END -->`,
|
|
29
25
|
];
|
|
30
26
|
}
|
|
@@ -57,10 +57,7 @@ export interface RequiredPluginDrift {
|
|
|
57
57
|
export function getRequiredPluginDrift(config: FrameworkConfig): RequiredPluginDrift[] {
|
|
58
58
|
const drift: RequiredPluginDrift[] = [];
|
|
59
59
|
for (const { plugin } of requiredPluginsByKind()) {
|
|
60
|
-
if (
|
|
61
|
-
plugin.kind === "provider" &&
|
|
62
|
-
!(config.profiles.default?.allowedAgents ?? []).includes(plugin.id)
|
|
63
|
-
) {
|
|
60
|
+
if (plugin.kind === "provider" && !(config.allowedAgents ?? []).includes(plugin.id)) {
|
|
64
61
|
drift.push({
|
|
65
62
|
pluginId: plugin.id,
|
|
66
63
|
kind: plugin.kind,
|
|
@@ -96,9 +93,9 @@ export function getRequiredPluginDrift(config: FrameworkConfig): RequiredPluginD
|
|
|
96
93
|
export function applyRequiredSelectionsToConfig(config: FrameworkConfig): void {
|
|
97
94
|
for (const { plugin } of requiredPluginsByKind()) {
|
|
98
95
|
if (plugin.kind === "provider") {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
|
|
96
|
+
const allowedAgents = config.allowedAgents ?? [];
|
|
97
|
+
if (!allowedAgents.includes(plugin.id)) {
|
|
98
|
+
config.allowedAgents = [...allowedAgents, plugin.id];
|
|
102
99
|
}
|
|
103
100
|
}
|
|
104
101
|
if (plugin.kind === "packageManager") {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Shared markdown section primitives for agent instruction files (CLAUDE.md,
|
|
2
|
+
// AGENTS.md), plus a thin file-level wrapper for stripping sections in place.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
export interface RemoveHeadingSectionOptions {
|
|
7
|
+
/** Matches the heading line that opens the section to remove. */
|
|
8
|
+
isHeading(line: string): boolean;
|
|
9
|
+
/** Exact lines (e.g. HTML comment markers) stripped wherever they appear. */
|
|
10
|
+
markerLines?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Remove a heading-delimited section (heading line up to the next `#`/`##`
|
|
15
|
+
* heading or EOF), collapsing leftover blank runs. Marker lines are stripped
|
|
16
|
+
* even when no section heading is present.
|
|
17
|
+
*/
|
|
18
|
+
export function removeHeadingSection(
|
|
19
|
+
content: string,
|
|
20
|
+
options: RemoveHeadingSectionOptions,
|
|
21
|
+
): string {
|
|
22
|
+
const markerLines = new Set(options.markerLines ?? []);
|
|
23
|
+
const normalizedLines = content.split("\n").filter((line) => !markerLines.has(line));
|
|
24
|
+
|
|
25
|
+
const start = normalizedLines.findIndex(options.isHeading);
|
|
26
|
+
if (start === -1) {
|
|
27
|
+
const strippedMarkersOnly = normalizedLines.join("\n");
|
|
28
|
+
return strippedMarkersOnly === content ? content : strippedMarkersOnly;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let end = normalizedLines.length;
|
|
32
|
+
for (let i = start + 1; i < normalizedLines.length; i++) {
|
|
33
|
+
if (normalizedLines[i].startsWith("## ") || normalizedLines[i].startsWith("# ")) {
|
|
34
|
+
end = i;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const remaining = [...normalizedLines.slice(0, start), ...normalizedLines.slice(end)].join("\n");
|
|
40
|
+
const collapsed = remaining.replace(/\n{3,}/g, "\n\n").trim();
|
|
41
|
+
return collapsed ? collapsed + "\n" : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cut everything from `marker` to EOF. Returns the content unchanged when the
|
|
46
|
+
* marker is absent, and "" when nothing but whitespace precedes it (callers
|
|
47
|
+
* typically delete the file then).
|
|
48
|
+
*/
|
|
49
|
+
export function cutFromMarker(content: string, marker: string): string {
|
|
50
|
+
const idx = content.indexOf(marker);
|
|
51
|
+
if (idx === -1) return content;
|
|
52
|
+
|
|
53
|
+
const before = content.slice(0, idx).replace(/\s+$/, "");
|
|
54
|
+
return before.length > 0 ? before + "\n" : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip a heading-delimited section from a file in place. Absent files are a
|
|
59
|
+
* no-op; a file left without content is deleted.
|
|
60
|
+
*/
|
|
61
|
+
export async function stripSectionFromFile(
|
|
62
|
+
filePath: string,
|
|
63
|
+
options: RemoveHeadingSectionOptions,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
let content: string;
|
|
66
|
+
try {
|
|
67
|
+
content = await fs.readFile(filePath, "utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const stripped = removeHeadingSection(content, options);
|
|
72
|
+
if (stripped === content) return;
|
|
73
|
+
if (!stripped.trim()) {
|
|
74
|
+
await fs.unlink(filePath);
|
|
75
|
+
} else {
|
|
76
|
+
await fs.writeFile(filePath, stripped, "utf8");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Structurally matches `McpServerDescriptor` from ../plugin; declared here so
|
|
5
|
+
// the format module stays dependency-free of the plugin contract (which
|
|
6
|
+
// imports hook types from this module).
|
|
7
|
+
export interface McpEntryDescriptor {
|
|
8
|
+
name: string;
|
|
9
|
+
command?: string;
|
|
10
|
+
args?: string[];
|
|
11
|
+
url?: string;
|
|
12
|
+
env?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Claude runtime config format primitives: parse/merge/serialize for
|
|
16
|
+
// `settings.local.json` (hook maps, permissions) and `.mcp.json`. This module
|
|
17
|
+
// owns the file formats only — which entries are Mate-managed is the callers'
|
|
18
|
+
// (Runtime Surface) knowledge.
|
|
19
|
+
|
|
20
|
+
export interface ClaudeHookCommand {
|
|
21
|
+
type?: string;
|
|
22
|
+
command?: string;
|
|
23
|
+
args?: string[];
|
|
24
|
+
timeout?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ClaudeHookGroup {
|
|
28
|
+
matcher?: string;
|
|
29
|
+
hooks?: ClaudeHookCommand[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ClaudeHookMap = Record<string, ClaudeHookGroup[]>;
|
|
33
|
+
|
|
34
|
+
export interface ClaudeSettings {
|
|
35
|
+
hooks?: ClaudeHookMap;
|
|
36
|
+
permissions?: { additionalDirectories?: string[]; allow?: string[] } & Record<string, unknown>;
|
|
37
|
+
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ClaudeMcpConfig {
|
|
42
|
+
mcpServers?: Record<string, unknown>;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hookGroupKey(group: ClaudeHookGroup): string {
|
|
47
|
+
return JSON.stringify(group);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Append incoming hook groups per event, skipping structurally identical ones. */
|
|
51
|
+
export function mergeClaudeHookGroups(
|
|
52
|
+
existingHooks: ClaudeHookMap,
|
|
53
|
+
incomingHooks: ClaudeHookMap,
|
|
54
|
+
): ClaudeHookMap {
|
|
55
|
+
const merged: ClaudeHookMap = { ...existingHooks };
|
|
56
|
+
for (const [event, groups] of Object.entries(incomingHooks)) {
|
|
57
|
+
const current = merged[event] ?? [];
|
|
58
|
+
const seen = new Set(current.map(hookGroupKey));
|
|
59
|
+
merged[event] = [...current];
|
|
60
|
+
for (const group of groups ?? []) {
|
|
61
|
+
const key = hookGroupKey(group);
|
|
62
|
+
if (seen.has(key)) continue;
|
|
63
|
+
merged[event].push(group);
|
|
64
|
+
seen.add(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Keep only groups matching `keep`; events left empty are dropped. */
|
|
71
|
+
export function filterClaudeHookGroups(
|
|
72
|
+
hooks: ClaudeHookMap,
|
|
73
|
+
keep: (group: ClaudeHookGroup) => boolean,
|
|
74
|
+
): ClaudeHookMap {
|
|
75
|
+
const filtered: ClaudeHookMap = {};
|
|
76
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
77
|
+
const remaining = (groups ?? []).filter(keep);
|
|
78
|
+
if (remaining.length > 0) {
|
|
79
|
+
filtered[event] = remaining;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return filtered;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Tolerant read: absent or malformed settings read as an empty object. */
|
|
86
|
+
export async function readClaudeSettings(settingsPath: string): Promise<ClaudeSettings> {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(await fs.readFile(settingsPath, "utf8")) as unknown;
|
|
89
|
+
if (parsed && typeof parsed === "object") {
|
|
90
|
+
return parsed as ClaudeSettings;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Absent or unparseable — start from an empty object.
|
|
94
|
+
}
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function writeClaudeSettings(
|
|
99
|
+
settingsPath: string,
|
|
100
|
+
settings: ClaudeSettings,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
103
|
+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Tolerant read of `.mcp.json`; `present` distinguishes absent from empty. */
|
|
107
|
+
export async function readClaudeMcpConfig(
|
|
108
|
+
mcpConfigPath: string,
|
|
109
|
+
): Promise<{ present: boolean; config: ClaudeMcpConfig }> {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(await fs.readFile(mcpConfigPath, "utf8")) as unknown;
|
|
112
|
+
if (parsed && typeof parsed === "object") {
|
|
113
|
+
return { present: true, config: parsed as ClaudeMcpConfig };
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Absent or unparseable — start from an empty object.
|
|
117
|
+
}
|
|
118
|
+
return { present: false, config: {} };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeClaudeMcpConfig(
|
|
122
|
+
mcpConfigPath: string,
|
|
123
|
+
config: ClaudeMcpConfig,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await fs.writeFile(mcpConfigPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Map a provider-agnostic MCP descriptor to Claude's `.mcp.json` entry shape. */
|
|
129
|
+
export function toClaudeMcpEntry(descriptor: McpEntryDescriptor): Record<string, unknown> {
|
|
130
|
+
if (descriptor.url) return { url: descriptor.url };
|
|
131
|
+
return {
|
|
132
|
+
command: descriptor.command,
|
|
133
|
+
...(descriptor.args ? { args: descriptor.args } : {}),
|
|
134
|
+
...(descriptor.env ? { env: descriptor.env } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reconcile a single server entry in `.mcp.json` while preserving every
|
|
140
|
+
* unrelated key. `entry: null` removes the server; removal never creates the
|
|
141
|
+
* file.
|
|
142
|
+
*/
|
|
143
|
+
export async function updateClaudeMcpServer(
|
|
144
|
+
mcpConfigPath: string,
|
|
145
|
+
name: string,
|
|
146
|
+
entry: Record<string, unknown> | null,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const { present, config } = await readClaudeMcpConfig(mcpConfigPath);
|
|
149
|
+
if (entry === null && !present) return;
|
|
150
|
+
|
|
151
|
+
const mcpServers: Record<string, unknown> = { ...config.mcpServers };
|
|
152
|
+
if (entry === null) {
|
|
153
|
+
if (!(name in mcpServers)) return;
|
|
154
|
+
delete mcpServers[name];
|
|
155
|
+
} else {
|
|
156
|
+
mcpServers[name] = entry;
|
|
157
|
+
}
|
|
158
|
+
await writeClaudeMcpConfig(mcpConfigPath, { ...config, mcpServers });
|
|
159
|
+
}
|