{"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/authoring.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Plugin authoring engine (spec §3) — shared by the two ProposePlugin tools.\n *\n * Completes the discover → acquire → author spectrum: when no marketplace plugin\n * fits a gap, the model can scaffold one. Authoring is gated on the *content /\n * capability-grant* trust axis (what the plugin can do), not the *source* axis\n * used for install. This module carries the risk classification, the\n * privilege-amplification guardrail, and the file writer; the tools own the two\n * escalating-risk *paths* (autonomous scaffold vs. confirm-then-activate).\n *\n * Everything is written through the format registry's {@link emitForPlatforms},\n * so an authored plugin lands in the requested vendor layouts (Claude Code and\n * GitHub Copilot by default) and round-trips back through {@link parsePluginDir}.\n */\n\nimport { cpSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport * as path from \"node:path\";\nimport { CLAUDE_TOOL_ALIASES } from \"../../agent-frontmatter.js\";\nimport { PLUGIN_SYSTEM_TOOL_NAMES } from \"../../tools/plugin-tool-names.js\";\nimport { emitForPlatforms } from \"./formats/index.js\";\nimport { isPluginPlatform, resolvePluginPlatforms } from \"./formats/platform-targets.js\";\nimport { slug } from \"./formats/shared.js\";\nimport type { AuthoredHook, AuthoredMcpServer, MarketplacePlatform, PluginDraft } from \"./formats/types.js\";\nimport { candidatePluginDirs, discardDraftDir, makeDraftDir, productionPluginDir } from \"./locations.js\";\nimport { type NormalizedPlugin, type PluginHooksConfig, parsePluginDir } from \"./manifest.js\";\n\n// Re-exported so existing importers keep one vocabulary; the resolution chain\n// (explicit → session --platform → default) lives in platform-targets.\nexport { resolvePluginPlatforms } from \"./formats/platform-targets.js\";\n\n/**\n * Layout for an *edit* of an existing plugin. Unlike creation, this never\n * re-resolves against the session's `--platform`: a merge or a removal must keep\n * the plugin in whatever layout it already has on disk, or it would move the\n * manifest out from under a plugin the user (or an older hoocode) already\n * published. Plugins authored before plugin production became platform-specific\n * carry the native `agents` layout, and those must keep working.\n */\nfunction existingLayout(existing: NormalizedPlugin, explicit?: readonly MarketplacePlatform[]): MarketplacePlatform[] {\n\tif (explicit && explicit.length > 0) return [...explicit];\n\tif (existing.supportPlatform.length > 0) return [...existing.supportPlatform];\n\treturn resolvePluginPlatforms();\n}\n\n/** hoocode tool names that only read (no mutation, no exec). Grants limited to these are low-risk. */\nconst READONLY_TOOLS = new Set([\"read\", \"SearchCodebase\", \"webfetch\", \"websearch\"]);\n\nexport interface AllowlistClassification {\n\t/** read-only grants are as safe as a skill; mutating/exec/network/`*` grants need confirmation. */\n\trisk: \"read-only\" | \"mutating\";\n\t/** Human-readable explanation of what drove the classification. */\n\treason: string;\n\t/** Any plugin-system (capability-acquisition) tools found — always forbidden in an authored allowlist. */\n\tpluginTools: string[];\n\t/** The raw allowlist tokens. */\n\ttokens: string[];\n}\n\n/**\n * Classify an authored subagent `tools:` allowlist as read-only vs. mutating,\n * reusing the same Claude-alias vocabulary as the agent-frontmatter normalizer\n * (spec §3 \"compute the risk, don't guess it\"). Anything unrecognized — an MCP\n * tool, a bare `*`, an unknown name — is treated as mutating (fail-safe).\n */\nexport function classifyAllowlist(tools: string | undefined): AllowlistClassification {\n\tconst tokens = (tools ?? \"\")\n\t\t.split(/[,\\s]+/)\n\t\t.map((t) => t.trim())\n\t\t.filter(Boolean);\n\tconst pluginTools = tokens.filter((t) => PLUGIN_SYSTEM_TOOL_NAMES.some((n) => n.toLowerCase() === t.toLowerCase()));\n\n\tif (tokens.length === 0) {\n\t\treturn { risk: \"read-only\", reason: \"no tools granted\", pluginTools, tokens };\n\t}\n\n\tconst reasons: string[] = [];\n\tlet mutating = false;\n\tfor (const t of tokens) {\n\t\tconst low = t.toLowerCase();\n\t\tif (t === \"*\" || low === \"all\") {\n\t\t\tmutating = true;\n\t\t\treasons.push(\"grants all tools (*)\");\n\t\t\tcontinue;\n\t\t}\n\t\tif (pluginTools.some((p) => p.toLowerCase() === low)) continue; // reported separately as a guardrail violation\n\t\tconst mapped = CLAUDE_TOOL_ALIASES[low];\n\t\tif (!mapped) {\n\t\t\tmutating = true;\n\t\t\treasons.push(`grants \"${t}\" (unrecognized or MCP tool — treated as mutating)`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!READONLY_TOOLS.has(mapped)) {\n\t\t\tmutating = true;\n\t\t\treasons.push(`grants \"${mapped}\" (mutating/exec)`);\n\t\t}\n\t}\n\n\treturn {\n\t\trisk: mutating ? \"mutating\" : \"read-only\",\n\t\treason: reasons.join(\"; \") || \"read-only tools only\",\n\t\tpluginTools,\n\t\ttokens,\n\t};\n}\n\nexport interface WriteResult {\n\t/** Where the plugin ended up: a production home, or the draft dir when not promoted. */\n\tdest: string;\n\t/** False when the caller asked to stop at the draft (gates still to run). */\n\tpromoted: boolean;\n\t/** Platforms the draft was emitted for. */\n\ttargets: MarketplacePlatform[];\n\t/** Written file paths, relative to the plugin root. */\n\tfiles: string[];\n\t/** Re-parsed plugin (confirms the scaffold round-trips). */\n\tplugin: NormalizedPlugin | null;\n}\n\n/**\n * Provenance marker written at the root of every authored plugin. A production\n * home can also hold artifacts hoocode did not write — `~/.claude/skills/` is a\n * directory Claude Code and the user both write into — so this marker is what\n * separates \"ours to re-emit or delete\" from \"someone else's\". UpdatePlugin\n * (which rewrites manifests and hook/MCP files) is gated on it. Existence is the\n * signal; the content is informational.\n */\nconst AUTHORED_MARKER_FILE = \".authored.json\";\n\n/** Locate an existing plugin by id across every home, or undefined. */\nexport function findPluginDir(cwd: string, id: string): string | undefined {\n\treturn candidatePluginDirs(cwd, id).find((dir) => existsSync(dir));\n}\n\n/** Whether the plugin at `id` was authored here (carries the provenance marker), vs. installed or hand-written. */\nexport function isAuthoredPlugin(cwd: string, id: string): boolean {\n\tconst dir = findPluginDir(cwd, id);\n\treturn !!dir && existsSync(path.join(dir, AUTHORED_MARKER_FILE));\n}\n\n/**\n * Render `draft` into the requested platform layouts and write it out.\n *\n * Two modes, and the distinction matters:\n *\n * - **Creation** (default): emit into an ephemeral draft dir, then promote into\n *   the target platform's production home, replacing whatever was there. A\n *   production home is live — `~/.claude/skills/<id>/` is loaded by Claude Code\n *   on its next session — so writing there directly would mean a plugin that\n *   failed validation, or whose confirmation was declined, is already running.\n *   Pass `promote: false` to stop at the draft and promote later with\n *   {@link promoteDraft}.\n * - **In-place edit** (`dest` given): write the emitted files over an existing\n *   plugin directory, wherever it already lives. An edit must never relocate the\n *   plugin — a merge or a removal on a plugin sitting in the consumption home or\n *   the legacy project home would otherwise silently move it into a production\n *   home. Files not named by the emit are left alone, which is what makes the\n *   directory-scanned merge semantics work.\n */\nexport function writePluginDraft(\n\tdraft: PluginDraft,\n\tplatforms?: MarketplacePlatform[],\n\toptions?: { promote?: boolean; dest?: string },\n): WriteResult {\n\t// Creation picks a platform; `agents` is refused here (see resolvePluginPlatforms).\n\tconst targets = resolvePluginPlatforms(platforms ?? draft.supportPlatform?.filter(isPluginPlatform));\n\tconst files = emitForPlatforms({ ...draft, supportPlatform: targets }, targets);\n\n\t// Formats share the capability tree (only marker manifests differ), so\n\t// dedupe by path — later formats overwrite with identical content.\n\tconst byPath = new Map(files.map((f) => [f.path, f]));\n\tconst writeInto = (root: string) => {\n\t\tfor (const f of byPath.values()) {\n\t\t\tconst abs = path.join(root, f.path);\n\t\t\tmkdirSync(path.dirname(abs), { recursive: true });\n\t\t\twriteFileSync(abs, f.content);\n\t\t}\n\t\twriteFileSync(path.join(root, AUTHORED_MARKER_FILE), `${JSON.stringify({ authored: true }, null, 2)}\\n`);\n\t};\n\tconst written = { files: [...byPath.keys(), AUTHORED_MARKER_FILE], targets };\n\n\tif (options?.dest) {\n\t\tmkdirSync(options.dest, { recursive: true });\n\t\twriteInto(options.dest);\n\t\treturn { dest: options.dest, promoted: true, ...written, plugin: parsePluginDir(options.dest) };\n\t}\n\n\tconst draftDir = makeDraftDir();\n\twriteInto(draftDir);\n\tif (options?.promote === false) {\n\t\treturn { dest: draftDir, promoted: false, ...written, plugin: parsePluginDir(draftDir) };\n\t}\n\tconst dest = promoteDraft(draftDir, draft.id, targets);\n\treturn { dest, promoted: true, ...written, plugin: parsePluginDir(dest) };\n}\n\n/**\n * Move a draft into the production home of its first target platform, replacing\n * whatever is there. The draft directory is consumed either way.\n *\n * A cross-device rename fails when temp and home sit on different filesystems,\n * so this falls back to copy-then-delete rather than assuming `rename` works.\n */\nexport function promoteDraft(draftDir: string, id: string, targets: readonly MarketplacePlatform[]): string {\n\tconst platform = targets.filter(isPluginPlatform)[0] ?? resolvePluginPlatforms()[0];\n\treturn promoteDraftTo(draftDir, productionPluginDir(platform, id));\n}\n\n/**\n * Replace `dest` with the contents of a draft. Used when the destination is\n * already known — an edit stays wherever the plugin lives, which may be the\n * consumption home or a legacy directory rather than a production home.\n */\nexport function promoteDraftTo(draftDir: string, dest: string): string {\n\trmSync(dest, { recursive: true, force: true });\n\tmkdirSync(path.dirname(dest), { recursive: true });\n\ttry {\n\t\trenameSync(draftDir, dest);\n\t} catch {\n\t\tcpSync(draftDir, dest, { recursive: true });\n\t\tdiscardDraftDir(draftDir);\n\t}\n\treturn dest;\n}\n\n/**\n * Whether authoring this id would clobber something **in the target platform's\n * production home**.\n *\n * Scoped to the target rather than every home on purpose: a `claude` `foo` and a\n * `github` `foo` are artifacts for two different ecosystems, not duplicates of\n * each other, and a marketplace-installed `foo` in the consumption home is a\n * third unrelated thing. Only a collision in the directory this write would\n * actually land in is a collision.\n */\nexport function pluginExists(id: string, platforms?: readonly MarketplacePlatform[]): boolean {\n\tconst target = resolvePluginPlatforms(platforms?.filter(isPluginPlatform))[0];\n\treturn existsSync(productionPluginDir(target, id));\n}\n\n/** Load an installed/authored plugin by id from whichever home holds it, or null. */\nexport function getPlugin(cwd: string, id: string): NormalizedPlugin | null {\n\tconst dir = findPluginDir(cwd, id);\n\treturn dir ? parsePluginDir(dir) : null;\n}\n\n/** Reverse of {@link authoredHooksToConfig}: flatten a parsed hook event-map back to authored hooks. */\nfunction hooksConfigToAuthored(config: PluginHooksConfig): AuthoredHook[] {\n\tconst out: AuthoredHook[] = [];\n\tfor (const [event, groups] of Object.entries(config)) {\n\t\tfor (const group of groups) {\n\t\t\tfor (const cmd of group.hooks) {\n\t\t\t\tif (typeof cmd.command !== \"string\" || !cmd.command) continue;\n\t\t\t\tout.push({\n\t\t\t\t\tevent,\n\t\t\t\t\t...(group.matcher ? { matcher: group.matcher } : {}),\n\t\t\t\t\tcommand: cmd.command,\n\t\t\t\t\t...(cmd.timeout ? { timeout: cmd.timeout } : {}),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\treturn out;\n}\n\n/**\n * Convert a parsed `mcpServers` record back to authored form. Throws on a\n * non-stdio (url/http-type) server rather than silently dropping it from the\n * re-emit — a merge must never quietly lose a capability (only reachable via a\n * hand-edited authored plugin; our own schema always writes `command` servers).\n */\nfunction mcpRecordToAuthored(record: Record<string, unknown>): AuthoredMcpServer[] {\n\tconst out: AuthoredMcpServer[] = [];\n\tfor (const [name, value] of Object.entries(record)) {\n\t\tif (!value || typeof value !== \"object\") continue;\n\t\tconst server = value as { command?: unknown; args?: unknown; env?: unknown };\n\t\tif (typeof server.command !== \"string\") {\n\t\t\tthrow new Error(\n\t\t\t\t`Cannot merge: MCP server \"${name}\" has no command (url/http-type servers don't round-trip through authoring). ` +\n\t\t\t\t\t\"Edit the plugin's .mcp.json directly instead.\",\n\t\t\t);\n\t\t}\n\t\tout.push({\n\t\t\tname,\n\t\t\tcommand: server.command,\n\t\t\t...(Array.isArray(server.args) ? { args: server.args.map(String) } : {}),\n\t\t\t...(server.env && typeof server.env === \"object\" ? { env: server.env as Record<string, string> } : {}),\n\t\t});\n\t}\n\treturn out;\n}\n\n/** Dedupe authored hooks by (event, matcher, command) so a re-supplied hook doesn't stack. */\nfunction dedupeHooks(hooks: AuthoredHook[]): AuthoredHook[] {\n\tconst seen = new Set<string>();\n\tconst out: AuthoredHook[] = [];\n\tfor (const h of hooks) {\n\t\tconst key = `${h.event}\u0000${h.matcher ?? \"\"}\u0000${h.command}`;\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\tout.push(h);\n\t}\n\treturn out;\n}\n\n/**\n * Merge inline-authored `delta` capabilities into the existing local plugin `id`\n * and re-emit. Unlike a marketplace `UpdatePlugin`, nothing is fetched from a\n * remote source — the new content comes from the caller — so the supply-chain\n * \"benign v1 → hostile v2\" vector the spec guards against is structurally absent.\n *\n * Merge semantics:\n *  - **Skills / commands / agents** are directory-scanned, so existing ones are\n *    left on disk untouched; a delta entry with a matching name overwrites just\n *    that file (an update), a new name is added.\n *  - **Hooks** and **MCP servers** live in single files that a re-emit rewrites,\n *    so they are re-emitted as the *union* of existing + delta (MCP keyed by\n *    server name with delta winning; hooks deduped by event/matcher/command).\n *    Hooks have no name, so there is deliberately no modify-in-place: a delta\n *    hook with the same event/matcher but a different command is a NEW hook\n *    added alongside the old one, never a replacement. (Keying replacement by\n *    event+matcher would silently drop legitimate sibling hooks that share\n *    them.) Changing a hook = {@link removeFromPlugin} the old one + merge the\n *    new one.\n *  - **Metadata** (version, description, author) takes the delta's value when\n *    provided, else keeps the existing one.\n *\n * Platforms default to the plugin's existing `supportPlatform` so a merge never\n * silently adds or drops a vendor layout.\n */\nexport function mergePluginDraft(\n\tcwd: string,\n\tid: string,\n\tdelta: Partial<PluginDraft>,\n\tplatforms?: MarketplacePlatform[],\n): WriteResult {\n\tconst existing = getPlugin(cwd, id);\n\tif (!existing) {\n\t\tthrow new Error(`Cannot update plugin \"${id}\": it does not exist. Use ProposePlugin to create it first.`);\n\t}\n\t// Authored-only: merging re-emits manifests and hook/MCP files through our\n\t// writer, which only round-trips what PluginDraft can represent. Running that\n\t// over a marketplace install could silently drop fields it carries (capability\n\t// -dir overrides, url-type MCP servers, extra manifest keys).\n\tif (!isAuthoredPlugin(cwd, id)) {\n\t\tthrow new Error(\n\t\t\t`Cannot update plugin \"${id}\": it was not authored here (no ${AUTHORED_MARKER_FILE} marker). ` +\n\t\t\t\t\"Only locally authored plugins can be merged.\",\n\t\t);\n\t}\n\n\tconst existingHooks = existing.hooks ? hooksConfigToAuthored(existing.hooks) : [];\n\tconst mergedHooks = dedupeHooks([...existingHooks, ...(delta.hooks ?? [])]);\n\n\tconst mcpByName = new Map<string, AuthoredMcpServer>();\n\tfor (const s of existing.mcpServers ? mcpRecordToAuthored(existing.mcpServers) : []) mcpByName.set(s.name, s);\n\tfor (const s of delta.mcpServers ?? []) mcpByName.set(s.name, s);\n\n\tconst targets = existingLayout(existing, platforms);\n\tconst merged: PluginDraft = {\n\t\tid,\n\t\tversion: delta.version ?? existing.version,\n\t\tdescription: delta.description ?? existing.description,\n\t\tauthor: delta.author ?? existing.author,\n\t\tsupportPlatform: targets,\n\t\t// Keys we do not model survive the manifest rewrite.\n\t\tunknownFields: existing.unknownFields,\n\t\t// Directory-scanned capabilities: delta-only; existing files stay on disk.\n\t\tskills: delta.skills,\n\t\tcommands: delta.commands,\n\t\tagents: delta.agents,\n\t\t// Single-file capabilities: re-emit the union so a merge never drops them.\n\t\thooks: mergedHooks.length ? mergedHooks : undefined,\n\t\tmcpServers: mcpByName.size ? [...mcpByName.values()] : undefined,\n\t};\n\t// An edit stays where the plugin already lives; it never relocates it.\n\treturn writePluginDraft(merged, targets, { dest: findPluginDir(cwd, id) });\n}\n\n/** A hook to remove: `event` is required; `matcher`/`command` narrow the match when provided. */\ninterface HookRemovalSpec {\n\tevent: string;\n\tmatcher?: string;\n\tcommand?: string;\n}\n\n/** Named capabilities to remove from an authored plugin. */\nexport interface RemovalSpec {\n\tskills?: string[];\n\tcommands?: string[];\n\tsubagents?: string[];\n\tmcpServers?: string[];\n\thooks?: HookRemovalSpec[];\n}\n\nexport interface RemoveResult {\n\tdest: string;\n\t/** Human-readable descriptions of what was removed. */\n\tremoved: string[];\n\t/** Requested capabilities that were not found (nothing was removed for these). */\n\tmissing: string[];\n}\n\nfunction describeHookSpec(h: HookRemovalSpec): string {\n\treturn `hook [${h.event}${h.matcher !== undefined ? ` matcher=${h.matcher}` : \"\"}${h.command !== undefined ? ` command=${h.command}` : \"\"}]`;\n}\n\n/**\n * Remove named capabilities from the authored plugin `id`. The inverse of the\n * additive merge, and — like {@link mergePluginDraft} — authored-only.\n *\n * Removal is the low-risk direction (deleting capabilities cannot execute\n * code), which is why callers may run it without a confirmation gate.\n *\n *  - **Skills / commands / subagents** are directory-scanned, so removal is a\n *    surgical file delete at our emit conventions; no re-emit needed.\n *  - **Hooks** (matched by event, narrowed by matcher/command when given) and\n *    **MCP servers** (by name) live in single files, so the remaining set is\n *    re-emitted — and when a set empties, its file is DELETED, because the\n *    parser falls back to `hooks/hooks.json` / `.mcp.json` on disk and a stale\n *    file would resurrect the removed capability on the next parse.\n */\nexport function removeFromPlugin(cwd: string, id: string, spec: RemovalSpec): RemoveResult {\n\tconst existing = getPlugin(cwd, id);\n\tif (!existing) {\n\t\tthrow new Error(`Cannot remove from plugin \"${id}\": it does not exist.`);\n\t}\n\tif (!isAuthoredPlugin(cwd, id)) {\n\t\tthrow new Error(\n\t\t\t`Cannot remove from plugin \"${id}\": it was not authored here (no ${AUTHORED_MARKER_FILE} marker). ` +\n\t\t\t\t\"Only locally authored plugins can be edited.\",\n\t\t);\n\t}\n\tconst dest = findPluginDir(cwd, id);\n\tif (!dest) throw new Error(`Cannot remove from plugin \"${id}\": it is not on disk.`);\n\tconst removed: string[] = [];\n\tconst missing: string[] = [];\n\n\t// Directory-scanned capabilities: surgical deletes at our emit conventions.\n\tconst fileTargets: Array<[kind: string, name: string, relPath: string]> = [\n\t\t...(spec.skills ?? []).map((n): [string, string, string] => [\"skill\", n, path.join(\"skills\", slug(n))]),\n\t\t...(spec.commands ?? []).map((n): [string, string, string] => [\n\t\t\t\"command\",\n\t\t\tn,\n\t\t\tpath.join(\"commands\", `${slug(n)}.md`),\n\t\t]),\n\t\t...(spec.subagents ?? []).map((n): [string, string, string] => [\n\t\t\t\"subagent\",\n\t\t\tn,\n\t\t\tpath.join(\"agents\", `${slug(n)}.md`),\n\t\t]),\n\t];\n\tfor (const [kind, name, rel] of fileTargets) {\n\t\tconst abs = path.join(dest, rel);\n\t\tif (existsSync(abs)) {\n\t\t\trmSync(abs, { recursive: true, force: true });\n\t\t\tremoved.push(`${kind} \"${name}\"`);\n\t\t} else {\n\t\t\tmissing.push(`${kind} \"${name}\"`);\n\t\t}\n\t}\n\n\t// Single-file capabilities: filter the reconstructed sets, then re-emit.\n\tlet singleFileChanged = false;\n\tlet remainingHooks = existing.hooks ? hooksConfigToAuthored(existing.hooks) : [];\n\tfor (const h of spec.hooks ?? []) {\n\t\tconst before = remainingHooks.length;\n\t\tremainingHooks = remainingHooks.filter(\n\t\t\t(x) =>\n\t\t\t\t!(\n\t\t\t\t\tx.event === h.event &&\n\t\t\t\t\t(h.matcher === undefined || (x.matcher ?? \"\") === h.matcher) &&\n\t\t\t\t\t(h.command === undefined || x.command === h.command)\n\t\t\t\t),\n\t\t);\n\t\tconst n = before - remainingHooks.length;\n\t\tif (n > 0) {\n\t\t\tremoved.push(`${n} ${describeHookSpec(h)}`);\n\t\t\tsingleFileChanged = true;\n\t\t} else {\n\t\t\tmissing.push(describeHookSpec(h));\n\t\t}\n\t}\n\tlet remainingMcp = existing.mcpServers ? mcpRecordToAuthored(existing.mcpServers) : [];\n\tfor (const name of spec.mcpServers ?? []) {\n\t\tconst before = remainingMcp.length;\n\t\tremainingMcp = remainingMcp.filter((s) => s.name !== name);\n\t\tif (remainingMcp.length < before) {\n\t\t\tremoved.push(`mcp server \"${name}\"`);\n\t\t\tsingleFileChanged = true;\n\t\t} else {\n\t\t\tmissing.push(`mcp server \"${name}\"`);\n\t\t}\n\t}\n\n\tif (singleFileChanged) {\n\t\tconst targets = existingLayout(existing);\n\t\twritePluginDraft(\n\t\t\t{\n\t\t\t\tid,\n\t\t\t\tversion: existing.version,\n\t\t\t\tdescription: existing.description,\n\t\t\t\tauthor: existing.author,\n\t\t\t\tsupportPlatform: targets,\n\t\t\t\tunknownFields: existing.unknownFields,\n\t\t\t\thooks: remainingHooks.length ? remainingHooks : undefined,\n\t\t\t\tmcpServers: remainingMcp.length ? remainingMcp : undefined,\n\t\t\t},\n\t\t\ttargets,\n\t\t\t{ dest },\n\t\t);\n\t\t// Emit skips empty sets, so a stale file from the previous emit survives\n\t\t// and the parser's on-disk fallback would resurrect it — delete explicitly.\n\t\tif (remainingHooks.length === 0) rmSync(path.join(dest, \"hooks\", \"hooks.json\"), { force: true });\n\t\tif (remainingMcp.length === 0) rmSync(path.join(dest, \".mcp.json\"), { force: true });\n\t}\n\n\treturn { dest, removed, missing };\n}\n"]}