@uniqbit/mate-core 0.15.3 → 0.15.4-canary.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +4 -0
  2. package/claude-plugin/hooks/artifact-finish-nudge.mjs +8 -0
  3. package/claude-plugin/hooks/hooks.json +37 -0
  4. package/claude-plugin/hooks/session-banner.mjs +8 -0
  5. package/claude-plugin/hooks/ts-loader.mjs +28 -0
  6. package/claude-plugin/hooks/validate-artifact-path.mjs +8 -0
  7. package/package.json +4 -2
  8. package/src/cli/commands/artifact/finish/openspec.ts +1 -5
  9. package/src/cli/commands/companion/hub.ts +99 -0
  10. package/src/cli/commands/companion/link.ts +0 -5
  11. package/src/cli/commands/companion/tui.ts +3 -2
  12. package/src/cli/commands/doctor.ts +254 -170
  13. package/src/cli/commands/install.ts +18 -14
  14. package/src/cli/commands/launch/shared.ts +4 -4
  15. package/src/cli/commands/plugin/install.ts +94 -0
  16. package/src/cli/commands/plugin/plugin.ts +17 -0
  17. package/src/cli/commands/setup.ts +4 -6
  18. package/src/cli/commands/update.ts +9 -11
  19. package/src/cli/companion-link-wizard.tsx +22 -78
  20. package/src/cli/main.ts +57 -13
  21. package/src/cli/plugin-commands.ts +4 -4
  22. package/src/cli/repo-list-table.tsx +2 -7
  23. package/src/cli/usage.ts +7 -3
  24. package/src/distribution.ts +3 -5
  25. package/src/framework.ts +4 -36
  26. package/src/hooks/artifact-finish-nudge.ts +212 -0
  27. package/src/hooks/session-banner.ts +25 -0
  28. package/src/hooks/validate-artifact-path.ts +236 -0
  29. package/src/index.ts +4 -0
  30. package/src/lib/context-mode-package.ts +5 -3
  31. package/src/lib/install.ts +31 -43
  32. package/src/lib/orchestrator/adapters/base.ts +7 -8
  33. package/src/lib/orchestrator/adapters/claude.ts +16 -0
  34. package/src/lib/orchestrator/adapters/opencode.ts +10 -60
  35. package/src/lib/orchestrator/companion-hub.ts +380 -0
  36. package/src/lib/orchestrator/companion-store.ts +1 -35
  37. package/src/lib/orchestrator/config-store.ts +94 -7
  38. package/src/lib/orchestrator/editor.ts +4 -20
  39. package/src/lib/orchestrator/framework-context.ts +119 -24
  40. package/src/lib/orchestrator/global-config-store.ts +1 -5
  41. package/src/lib/orchestrator/launcher.ts +3 -9
  42. package/src/lib/orchestrator/migration.ts +0 -23
  43. package/src/lib/orchestrator/opencode-guidance.ts +1 -2
  44. package/src/lib/orchestrator/repo-local-registry.ts +3 -1
  45. package/src/lib/orchestrator/root-context.ts +117 -0
  46. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  47. package/src/lib/orchestrator/setup-preflight.ts +5 -18
  48. package/src/lib/orchestrator/types.ts +27 -19
  49. package/src/lib/orchestrator/working-repo-store.ts +9 -3
  50. package/src/lib/package-paths.ts +37 -0
  51. package/src/lib/update-checker.ts +5 -5
  52. package/src/playbooks/companion-guidance.ts +7 -7
  53. package/src/runtime/env.ts +0 -4
  54. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/SKILL.md +8 -8
  55. package/src/templates/capabilities/openspec-cap/mate-skills/{mate-artifact-finish → agents/mate-artifact-finish}/references/openspec.md +3 -3
  56. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +58 -0
  57. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +139 -0
  58. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +6 -5
  59. package/src/templates/root/TEMPLATE_AGENTS.md +2 -0
  60. package/src/templates/root/TEMPLATE_CLAUDE.md +2 -0
  61. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3541 -0
  62. package/src/tools/setup/capabilities/context-mode.ts +57 -83
  63. package/src/tools/setup/capabilities/graphify-shared.ts +27 -0
  64. package/src/tools/setup/capabilities/graphify.ts +86 -295
  65. package/src/tools/setup/capabilities/openspec.ts +27 -30
  66. package/src/tools/setup/capabilities/react-doctor.ts +37 -37
  67. package/src/tools/setup/capabilities/rtk.ts +4 -1
  68. package/src/tools/setup/capabilities/tokensave-shared.ts +6 -0
  69. package/src/tools/setup/capabilities/tokensave.ts +50 -71
  70. package/src/tools/setup/context-services.ts +29 -0
  71. package/src/tools/setup/dynamic-plugins/host.ts +2 -1
  72. package/src/tools/setup/dynamic-plugins/hydrate.ts +3 -13
  73. package/src/tools/setup/dynamic-plugins/install.ts +124 -109
  74. package/src/tools/setup/dynamic-plugins/loader.ts +23 -58
  75. package/src/tools/setup/dynamic-plugins/paths.ts +8 -19
  76. package/src/tools/setup/dynamic-plugins/registry-hint.ts +14 -0
  77. package/src/tools/setup/engine.ts +65 -1
  78. package/src/tools/setup/mate.ts +28 -31
  79. package/src/tools/setup/plugin.ts +81 -0
  80. package/src/tools/setup/plugins/gitignore.ts +19 -9
  81. package/src/tools/setup/plugins/guidance.ts +2 -6
  82. package/src/tools/setup/policy.ts +4 -7
  83. package/src/tools/setup/providers/agent-file-sections.ts +78 -0
  84. package/src/tools/setup/providers/claude-format.ts +159 -0
  85. package/src/tools/setup/providers/claude.ts +283 -292
  86. package/src/tools/setup/providers/opencode-format.ts +146 -0
  87. package/src/tools/setup/providers/opencode.ts +171 -66
  88. package/src/tools/setup/providers/skill-tree.ts +38 -0
  89. package/src/tools/setup.ts +12 -7
  90. package/src/tui.ts +5 -0
  91. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-artifact-finish.sh +0 -161
  92. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +0 -28
  93. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +0 -242
  94. package/src/tools/setup/dynamic-plugins/pin-store.ts +0 -30
@@ -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 { PLUGIN_LOCAL_OVERRIDES_GITIGNORE_ENTRY } from "../dynamic-plugins/paths";
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 everything under the dependencies tree (dynamic
62
- // plugins, context-mode, future consumers) is regenerated by setup/install —
63
- // version pins live in .mate/config and in code, never in the tree itself.
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}/dependencies/`,
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 never versions;
73
- // the pin file (plugins.lock.yaml) stays committed and is deliberately not
74
- // listed here.
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, frameworkConfig } from "../../../framework";
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 profile = config.profiles.default;
100
- if (profile && !profile.allowedAgents.includes(plugin.id)) {
101
- profile.allowedAgents.push(plugin.id);
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
+ }