{"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/install.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Plugin install/uninstall/discovery — the shared engine behind both the\n * `/plugin` slash command (human-driven) and the model-facing lifecycle tools\n * (`SearchPlugins`, `InstallPlugin`, ...). Keeping it in one place means the two\n * surfaces can never drift on where plugins live or how sources resolve.\n *\n * Trust model (see docs/plugin-system-spec.md): *adding* a marketplace is the\n * human trust boundary; *installing* from an already-trusted marketplace is the\n * model's discretion (package-manager model), and must stay transparent and\n * reversible. This module performs the mechanical install/remove; the gating\n * (announce, injection carve-out) lives with the callers.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { getAgentDir } from \"../../../config.js\";\nimport { defaultPluginDirs } from \"../loader.js\";\nimport { hasAnyManifest } from \"./formats/index.js\";\nimport type { MarketplacePlatform } from \"./formats/types.js\";\nimport { discoverPlugins } from \"./index.js\";\nimport {\n\tcandidatePluginDirs,\n\tinstallHomeForScope,\n\tmarketplaceCacheDir,\n\tmarketplaceCacheMetaPath,\n\tmarketplaceCacheRoot,\n\tmarketplaceStorePath,\n\ttype PluginInstallScope,\n\tpluginHomeRoots,\n\tsanitizeForDir,\n} from \"./locations.js\";\nimport type { NormalizedPlugin } from \"./manifest.js\";\nimport { parsePluginDir } from \"./manifest.js\";\nimport {\n\ttype MarketplacePluginSource,\n\ttype MarketplaceRecord,\n\tparseMarketplaceDir,\n\treadMarketplaceStore,\n\tresolvePluginSource,\n} from \"./marketplace.js\";\n\n/**\n * Marketplace registries older versions wrote into the working tree, newest\n * convention first. Still read so a user who added a marketplace before the\n * registry moved to the agent dir does not silently lose it; nothing writes here.\n */\nfunction legacyStorePaths(cwd: string): string[] {\n\treturn [path.join(cwd, \".agents\", \"marketplaces.json\"), path.join(cwd, \".hoocode\", \"marketplaces.json\")];\n}\n\n/** Absolute path to the curated default marketplace bundled with hoocode. */\nfunction defaultMarketplaceDir(): string {\n\treturn fileURLToPath(new URL(\"./default-marketplace/\", import.meta.url));\n}\n\n/** The default marketplace record — always present so source-level trust is meaningful out of the box. */\nexport function defaultMarketplaceRecord(): MarketplaceRecord {\n\treturn { location: \"hoocode-default\", dir: defaultMarketplaceDir() };\n}\n\n/**\n * Curated well-known marketplaces, trusted out of the box. Shipping an entry\n * here is a maintainer-level trust decision (the human half of the \"adding a\n * marketplace is the human trust boundary\" rule) — keep this list short and\n * high-trust. Indices are cloned lazily into the marketplace cache on first\n * search and refreshed on a TTL thereafter. Refreshing the *index* only changes\n * what is discoverable; installed plugins are still never auto-updated, and\n * plugin sources inside the official index are sha-pinned.\n */\nexport const WELL_KNOWN_MARKETPLACES: ReadonlyArray<{ name: string; url: string; ref?: string }> = [\n\t{ name: \"claude-plugins-official\", url: \"https://github.com/anthropics/claude-plugins-official\" },\n\t// The Copilot plugin directory ships both a `.github/plugin/marketplace.json`\n\t// and a `.claude-plugin/marketplace.json`, so it parses with\n\t// supportPlatform [\"claude\", \"github\"] — the default `github` source.\n\t{ name: \"copilot-plugins\", url: \"https://github.com/github/copilot-plugins\" },\n\t// GitHub's community index, `.github/plugin/marketplace.json`. Same owner as\n\t// copilot-plugins, which is what makes it a source-trust decision of the same\n\t// kind.\n\t//\n\t// Pinned to the `marketplace` branch, and that is load-bearing. On `main` a\n\t// `plugins/<name>/` directory holds only `plugin.json` and `README.md`; the\n\t// real content lives in the repository's top-level `agents/`, `skills/` and\n\t// `extensions/` trees and is named from the manifest under the repo's own\n\t// `extensions[\"com.github.awesome-copilot\"]` namespace. CI *materializes* those\n\t// references into each plugin directory and publishes the result on\n\t// `marketplace` (`eng/materialize-plugins.mjs`), which is the branch the\n\t// vendor's own `copilot plugin install` consumes. Cloning the default branch\n\t// installed the stub — every entry landed with no capabilities and said so,\n\t// which read as \"hoocode does not model this surface\" when it was really\n\t// \"hoocode fetched the wrong ref\".\n\t{ name: \"awesome-copilot\", url: \"https://github.com/github/awesome-copilot\", ref: \"marketplace\" },\n];\n\n/**\n * How long a cached marketplace index is considered fresh.\n *\n * Refreshing an *index* is safe to automate: it changes what is discoverable,\n * not what is installed. The rule that an installed plugin is never auto-updated\n * from a remote is untouched — that is the supply-chain boundary, and this is a\n * catalog listing.\n */\nconst MARKETPLACE_TTL_MS = 24 * 60 * 60 * 1000;\n\nfunction marketplaceTtlMs(): number {\n\tconst raw = Number(process.env.HOOCODE_MARKETPLACE_TTL_MS);\n\treturn Number.isFinite(raw) && raw >= 0 ? raw : MARKETPLACE_TTL_MS;\n}\n\ntype CacheMeta = Record<string, string>;\n\nfunction readCacheMeta(agentDir: string): CacheMeta {\n\tconst parsed = readJsonFile<CacheMeta>(marketplaceCacheMetaPath(agentDir));\n\treturn parsed && typeof parsed === \"object\" ? parsed : {};\n}\n\nfunction recordFetch(agentDir: string, url: string): void {\n\tconst meta = readCacheMeta(agentDir);\n\tmeta[url] = new Date().toISOString();\n\tconst file = marketplaceCacheMetaPath(agentDir);\n\tmkdirSync(path.dirname(file), { recursive: true });\n\twriteFileSync(file, `${JSON.stringify(meta, null, 2)}\\n`, \"utf8\");\n}\n\nfunction isStale(agentDir: string, url: string): boolean {\n\tconst ttl = marketplaceTtlMs();\n\tif (ttl === 0) return true;\n\tconst at = readCacheMeta(agentDir)[url];\n\tif (!at) return true;\n\tconst age = Date.now() - Date.parse(at);\n\treturn !Number.isFinite(age) || age > ttl;\n}\n\nfunction readJsonFile<T>(file: string): T | null {\n\ttry {\n\t\treturn JSON.parse(readFileSync(file, \"utf8\")) as T;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Bring an existing clone up to date, falling back to a fresh clone when the pull\n * cannot fast-forward (a force-pushed remote, or a corrupt cache).\n *\n * The re-clone goes to a temp directory and is swapped in only on success. An\n * earlier version deleted the cache first, which meant a refresh attempted while\n * offline destroyed a perfectly good index — the opposite of the \"offline is\n * non-fatal\" property the caller depends on.\n */\nasync function refreshClone(\n\turl: string,\n\tdir: string,\n\tref?: string,\n): Promise<{ code: number; stdout: string; stderr: string }> {\n\tconst pulled = await execGit([\"-C\", dir, \"pull\", \"--ff-only\"]);\n\tif (pulled.code === 0) return pulled;\n\n\tconst staging = mkdtempSync(path.join(os.tmpdir(), \"hoo-market-refresh-\"));\n\tconst target = path.join(staging, \"clone\");\n\t// The re-clone must land on the same ref the cache was created from, or a\n\t// pinned marketplace silently falls back to the default branch on its first\n\t// non-fast-forward refresh.\n\tconst cloned = await cloneGitRepo(url, target, ref);\n\tif (cloned.code !== 0) {\n\t\trmSync(staging, { recursive: true, force: true });\n\t\treturn cloned;\n\t}\n\trmSync(dir, { recursive: true, force: true });\n\tmkdirSync(path.dirname(dir), { recursive: true });\n\ttry {\n\t\trenameSync(target, dir);\n\t} catch {\n\t\tcpSync(target, dir, { recursive: true });\n\t}\n\trmSync(staging, { recursive: true, force: true });\n\treturn cloned;\n}\n\n/**\n * Branch name a cached clone is sitting on, or undefined when it is detached or\n * unreadable (a corrupt cache answers \"not the ref I want\", which is correct).\n */\nasync function checkedOutRef(dir: string): Promise<string | undefined> {\n\tconst res = await execGit([\"-C\", dir, \"rev-parse\", \"--abbrev-ref\", \"HEAD\"]);\n\tif (res.code !== 0) return undefined;\n\tconst name = res.stdout.trim();\n\treturn name && name !== \"HEAD\" ? name : undefined;\n}\n\n/** Whether `ref` is still a branch on the remote. False on any network failure. */\nasync function remoteHasRef(url: string, ref: string): Promise<boolean> {\n\tconst res = await execGit([\"ls-remote\", \"--heads\", \"--\", url, ref]);\n\treturn res.code === 0 && res.stdout.trim().length > 0;\n}\n\n/**\n * Whether {@link ensureWellKnownMarketplaces} would actually reach the network.\n *\n * Exists so an interactive caller can say \"Refreshing marketplace indices…\"\n * before a multi-second clone and stay silent when the answer is already on\n * disk. Deliberately does not probe the remote — the ref-mismatch case needs\n * network to detect, so this under-reports rather than paying for a round trip\n * to decide whether to print a line.\n */\nexport function wellKnownMarketplacesAreStale(agentDir: string = getAgentDir()): boolean {\n\treturn WELL_KNOWN_MARKETPLACES.some(\n\t\t(wk) => !existsSync(marketplaceCacheDir(wk.url, agentDir)) || isStale(agentDir, wk.url),\n\t);\n}\n\nexport interface EnsureMarketplacesOptions {\n\t/** Refresh regardless of the TTL. */\n\tforce?: boolean;\n\t/**\n\t * Marketplaces to ensure; defaults to {@link WELL_KNOWN_MARKETPLACES}.\n\t *\n\t * A seam, and a deliberate one. The ref-pinning rules below — discard a cache\n\t * on the wrong branch, fall back when a pin is retired, and do not loop\n\t * between those two — are only exercised against a remote whose branches can\n\t * be moved and deleted, which no real marketplace will do on demand. Without\n\t * this the whole path could only be checked by hand against GitHub, and a\n\t * check that needs the network is a check that rots.\n\t */\n\tmarketplaces?: ReadonlyArray<{ name: string; url: string; ref?: string }>;\n}\n\n/**\n * Make sure every well-known marketplace index is cached and reasonably fresh.\n *\n * Clones what is missing and pulls what has gone stale. Without the TTL the\n * indices were fetched exactly once and never again, so a plugin added upstream\n * stayed permanently invisible — the inherit half of the system quietly stopped\n * inheriting. Offline is non-fatal: each failure is returned and search degrades\n * to whatever is already on disk.\n *\n * The returned strings are notes for the caller to surface, not fatal errors —\n * a retired ref that fell back to the default branch reports here too, because\n * the consequence (entries that install with no capabilities) is otherwise\n * invisible and inexplicable.\n */\nexport async function ensureWellKnownMarketplaces(\n\tagentDir: string = getAgentDir(),\n\toptions: EnsureMarketplacesOptions = {},\n): Promise<string[]> {\n\tconst errors: string[] = [];\n\tfor (const wk of options.marketplaces ?? WELL_KNOWN_MARKETPLACES) {\n\t\tconst dir = marketplaceCacheDir(wk.url, agentDir);\n\t\tlet present = existsSync(dir);\n\t\t// A cache is keyed by URL, not by ref, so a marketplace that gains (or\n\t\t// changes) a pin finds a clone of the wrong branch sitting in its slot. A\n\t\t// `pull --ff-only` there would faithfully keep updating the wrong branch\n\t\t// forever, so the mismatch is resolved the only way it can be: discard and\n\t\t// clone the ref that is asked for.\n\t\t//\n\t\t// Guarded on the ref still existing upstream, which is not paranoia — it is\n\t\t// what stops a loop. When a pin is retired, the fallback below leaves a\n\t\t// default-branch clone in the slot; that clone mismatches the pin forever,\n\t\t// so an unguarded check would discard and re-download it on every single\n\t\t// run. Probing costs one `ls-remote` and only on the mismatch path.\n\t\tif (present && wk.ref && (await checkedOutRef(dir)) !== wk.ref && (await remoteHasRef(wk.url, wk.ref))) {\n\t\t\trmSync(dir, { recursive: true, force: true });\n\t\t\tpresent = false;\n\t\t}\n\t\tif (present && !options.force && !isStale(agentDir, wk.url)) continue;\n\n\t\tmkdirSync(path.dirname(dir), { recursive: true });\n\t\tlet res = present ? await refreshClone(wk.url, dir, wk.ref) : await cloneGitRepo(wk.url, dir, wk.ref);\n\t\t// A pinned ref that cannot be cloned is the one failure worth retrying\n\t\t// differently. The pin names someone else's distribution branch — for\n\t\t// awesome-copilot, a name held in a workflow env var — so it can be renamed\n\t\t// or retired without warning, and a cold cache would then lose that\n\t\t// marketplace outright rather than degrade. A warm cache already degrades\n\t\t// correctly (the stale copy is kept below, and it is *built* content, which\n\t\t// beats an unbuilt default branch), so this applies to the cold case only.\n\t\tlet degraded: string | undefined;\n\t\tif (res.code !== 0 && !present && wk.ref) {\n\t\t\trmSync(dir, { recursive: true, force: true });\n\t\t\tconst fallback = await cloneGitRepo(wk.url, dir);\n\t\t\tif (fallback.code === 0) {\n\t\t\t\tdegraded =\n\t\t\t\t\t`${wk.name}: branch \"${wk.ref}\" is unavailable, so the default branch was used instead. ` +\n\t\t\t\t\t\"Plugin entries there may be unbuilt and install with no capabilities.\";\n\t\t\t\tres = fallback;\n\t\t\t}\n\t\t}\n\t\tif (res.code !== 0) {\n\t\t\t// A failed *refresh* keeps the stale copy: an out-of-date index is far\n\t\t\t// more useful than none, and the network may simply be down.\n\t\t\tif (!present) rmSync(dir, { recursive: true, force: true });\n\t\t\terrors.push(`${wk.name}: ${(res.stderr || res.stdout).trim()}`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (degraded) errors.push(degraded);\n\t\trecordFetch(agentDir, wk.url);\n\t}\n\treturn errors;\n}\n\n/**\n * Refresh every cached index — well-known and user-added — ignoring the TTL.\n * The explicit half of the freshness story, for when a user knows the upstream\n * changed and does not want to wait out the interval.\n */\nexport async function refreshMarketplaces(\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n\toptions: EnsureMarketplacesOptions = {},\n): Promise<{ refreshed: string[]; errors: string[] }> {\n\t// The same seam `ensureWellKnownMarketplaces` documents, carried up to the\n\t// caller that needs it. Without it a test covering *user-added* marketplaces\n\t// still cloned three real GitHub repositories on every call, because\n\t// refreshing \"the marketplaces\" has always meant the well-known list too —\n\t// which is a check that needs the network, and so a check that rots.\n\tconst wellKnown = options.marketplaces ?? WELL_KNOWN_MARKETPLACES;\n\tconst errors = await ensureWellKnownMarketplaces(agentDir, { ...options, force: true });\n\tconst refreshed = wellKnown.filter((wk) => existsSync(marketplaceCacheDir(wk.url, agentDir))).map((wk) => wk.name);\n\n\t// User-added marketplaces are cached the same way when they came from git; a\n\t// local-path marketplace is read in place and has nothing to refresh.\n\tfor (const record of readMarketplaceRecords(cwd, agentDir)) {\n\t\tconst isCachedClone = record.dir.startsWith(marketplaceCacheRoot(agentDir));\n\t\tif (!isCachedClone || wellKnown.some((wk) => wk.url === record.location)) continue;\n\t\tconst res = await refreshClone(record.location, record.dir);\n\t\tif (res.code !== 0) errors.push(`${record.location}: ${(res.stderr || res.stdout).trim()}`);\n\t\telse {\n\t\t\trecordFetch(agentDir, record.location);\n\t\t\trefreshed.push(record.location);\n\t\t}\n\t}\n\treturn { refreshed, errors };\n}\n\n/**\n * All marketplace records in effect: the bundled default first (curated,\n * trusted), then user-added ones from `.agents/` (falling back to legacy\n * `.hoocode/`). Deduplicated by directory.\n */\nexport function readMarketplaceRecords(cwd: string, agentDir: string = getAgentDir()): MarketplaceRecord[] {\n\tconst records: MarketplaceRecord[] = [];\n\tconst def = defaultMarketplaceRecord();\n\tif (existsSync(def.dir)) records.push(def);\n\n\t// Well-known marketplaces participate once their index is cached locally\n\t// (see ensureWellKnownMarketplaces; SearchPlugins fetches lazily).\n\tfor (const wk of WELL_KNOWN_MARKETPLACES) {\n\t\tconst dir = marketplaceCacheDir(wk.url, agentDir);\n\t\tif (existsSync(dir) && !records.some((x) => x.dir === dir)) {\n\t\t\trecords.push({ location: wk.url, dir });\n\t\t}\n\t}\n\n\t// The registry lives in the agent dir. Project-local ones are a migration\n\t// read-path: merged in, never written back, so an older setup keeps working.\n\tconst user = [\n\t\t...readMarketplaceStore(marketplaceStorePath(agentDir)),\n\t\t...legacyStorePaths(cwd).flatMap((p) => readMarketplaceStore(p)),\n\t];\n\tfor (const r of user) {\n\t\tif (!records.some((x) => x.dir === r.dir)) records.push(r);\n\t}\n\treturn records;\n}\n\n/** A plugin offered by some registered marketplace (not necessarily installed). */\nexport interface AvailablePlugin {\n\tname: string;\n\tdescription?: string;\n\t/** Relative path, git URL, `npm:<spec>`, or structured source object. */\n\tsource: MarketplacePluginSource;\n\t/** Resolved source kind, for display and gating. `npm`/`archive` are listed but not installable. */\n\tsourceKind: \"local\" | \"git\" | \"git-subdir\" | \"npm\" | \"archive\";\n\tmarketplaceName: string;\n\tmarketplaceRoot: string;\n\t/** Platforms this entry targets (per-entry hint, else the marketplace's). */\n\tsupportPlatform: MarketplacePlatform[];\n}\n\n/** Every plugin offered across all registered marketplaces (first marketplace wins on name clash). */\nexport function listAvailablePlugins(cwd: string, agentDir: string = getAgentDir()): AvailablePlugin[] {\n\tconst out: AvailablePlugin[] = [];\n\tconst seen = new Set<string>();\n\tfor (const rec of readMarketplaceRecords(cwd, agentDir)) {\n\t\tconst market = parseMarketplaceDir(rec.dir);\n\t\tif (!market) continue;\n\t\tfor (const entry of market.plugins) {\n\t\t\tif (seen.has(entry.name)) continue;\n\t\t\tseen.add(entry.name);\n\t\t\tout.push({\n\t\t\t\tname: entry.name,\n\t\t\t\tdescription: entry.description,\n\t\t\t\tsource: entry.source,\n\t\t\t\tsourceKind: resolvePluginSource(entry.source, market.root).kind,\n\t\t\t\tmarketplaceName: market.name,\n\t\t\t\tmarketplaceRoot: market.root,\n\t\t\t\tsupportPlatform: entry.supportPlatform ?? market.supportPlatform,\n\t\t\t});\n\t\t}\n\t}\n\treturn out;\n}\n\n/** Find a single available plugin by exact name. */\nexport function findAvailablePlugin(\n\tcwd: string,\n\tname: string,\n\tagentDir: string = getAgentDir(),\n): AvailablePlugin | undefined {\n\treturn listAvailablePlugins(cwd, agentDir).find((p) => p.name === name);\n}\n\n/** All currently installed plugins (project + global plugin dirs). */\nexport function listInstalledPlugins(cwd: string, agentDir: string = getAgentDir()): NormalizedPlugin[] {\n\treturn discoverPlugins(defaultPluginDirs(cwd, agentDir));\n}\n\n/**\n * Whether `name` is already installed, by either name it can go by.\n *\n * A marketplace entry name and the plugin's own manifest `name` are allowed to\n * differ — both vendors say so outright — and installs land in a directory named\n * for the *entry*, while discovery reports the *manifest* id. Matching ids alone\n * meant a plugin installed as `42crunch-api-security-testing` (manifest id\n * `api-security-testing`) never counted as installed, so every InstallPlugin\n * call for the catalog name re-cloned it.\n */\nexport function isPluginInstalled(cwd: string, name: string, agentDir: string = getAgentDir()): boolean {\n\tif (listInstalledPlugins(cwd, agentDir).some((p) => p.id === name)) return true;\n\treturn candidatePluginDirs(cwd, name, agentDir).some((p) => existsSync(p));\n}\n\nfunction execGit(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(\"git\", args);\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\t\tchild.stdout?.on(\"data\", (d) => {\n\t\t\tstdout += d.toString();\n\t\t});\n\t\tchild.stderr?.on(\"data\", (d) => {\n\t\t\tstderr += d.toString();\n\t\t});\n\t\tchild.on(\"error\", (e) => resolve({ code: 1, stdout, stderr: stderr || String(e) }));\n\t\tchild.on(\"close\", (code) => resolve({ code: code ?? 1, stdout, stderr }));\n\t});\n}\n\n/**\n * Materialize exactly `sha` at `dest`, without cloning the repository's history.\n *\n * `git fetch --depth 1 <sha>` is what makes honoring a pin affordable: a pinned\n * commit is usually not the tip of any branch, so the alternative is a full\n * clone — 55s on a large plugin repo against ~1s here, on sources where two\n * thirds of the official Claude marketplace is pinned. Servers that refuse a\n * by-sha fetch (AWS CodeCommit and other non-GitHub hosts) fall back to a clone\n * deep enough to reach the commit, then check it out.\n */\nasync function fetchGitCommit(\n\turl: string,\n\tdest: string,\n\tsha: string,\n\tref?: string,\n): Promise<{ code: number; stdout: string; stderr: string }> {\n\tmkdirSync(dest, { recursive: true });\n\tconst init = await execGit([\"init\", \"--quiet\", dest]);\n\tif (init.code === 0) {\n\t\tconst remote = await execGit([\"-C\", dest, \"remote\", \"add\", \"origin\", url]);\n\t\tif (remote.code === 0) {\n\t\t\tconst fetched = await execGit([\"-C\", dest, \"fetch\", \"--quiet\", \"--depth\", \"1\", \"origin\", sha]);\n\t\t\tif (fetched.code === 0) {\n\t\t\t\tconst checkout = await execGit([\"-C\", dest, \"checkout\", \"--quiet\", \"FETCH_HEAD\"]);\n\t\t\t\tif (checkout.code === 0) return checkout;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: a full clone (shallow cannot reach an arbitrary commit), then the\n\t// pin. `ref` narrows it when the entry named one.\n\trmSync(dest, { recursive: true, force: true });\n\tconst cloned = ref\n\t\t? await execGit([\"clone\", \"--branch\", ref, \"--\", url, dest])\n\t\t: await execGit([\"clone\", \"--\", url, dest]);\n\tif (cloned.code !== 0) return cloned;\n\treturn execGit([\"-C\", dest, \"checkout\", \"--quiet\", sha]);\n}\n\n/**\n * Clone a git repository into `dest` at the requested pin.\n *\n * **`sha` outranks `ref`.** Both vendors define it that way (\"when both `ref`\n * and `sha` are set, the `sha` is the effective pin\"), and a marketplace that\n * ships both is stating the exact commit it vouches for — a tag can be moved or\n * re-pointed after the catalog pinned it, so preferring `ref` installed a\n * different commit than the index promised. It is not hypothetical: the official\n * catalog's `42crunch-api-security-testing` pins `30287f5` while its `v1.5.5`\n * tag now resolves to `faf5305`.\n *\n * Returns the git result; on failure `dest` may be partially created.\n */\nasync function cloneGitRepo(\n\turl: string,\n\tdest: string,\n\tref?: string,\n\tsha?: string,\n): Promise<{ code: number; stdout: string; stderr: string }> {\n\tif (sha) return fetchGitCommit(url, dest, sha, ref);\n\tif (ref) return execGit([\"clone\", \"--branch\", ref, \"--depth\", \"1\", \"--\", url, dest]);\n\treturn execGit([\"clone\", \"--depth\", \"1\", \"--\", url, dest]);\n}\n\n/**\n * Resolve a git-subdir source: clone the repo, checkout the requested ref/sha,\n * and copy the subdirectory to `dest`. Cleans up the temporary clone on failure.\n */\nasync function installGitSubdir(\n\turl: string,\n\tsubdir: string,\n\tdest: string,\n\tref?: string,\n\tsha?: string,\n): Promise<{ ok: true } | { ok: false; message: string }> {\n\tconst tmpDir = mkdtempSync(path.join(os.tmpdir(), \"hoo-plugin-clone-\"));\n\ttry {\n\t\tconst cloneRes = await cloneGitRepo(url, tmpDir, ref, sha);\n\t\tif (cloneRes.code !== 0) {\n\t\t\treturn { ok: false, message: `git clone failed: ${cloneRes.stderr || cloneRes.stdout}`.trim() };\n\t\t}\n\t\tconst src = path.resolve(tmpDir, subdir);\n\t\tif (!existsSync(src)) {\n\t\t\treturn { ok: false, message: `Plugin subdirectory not found in cloned repo: ${subdir}` };\n\t\t}\n\t\tcpSync(src, dest, { recursive: true });\n\t\treturn { ok: true };\n\t} finally {\n\t\trmSync(tmpDir, { recursive: true, force: true });\n\t}\n}\n\nexport interface InstallOptions {\n\t/**\n\t * Where the plugin lands. Defaults to `user`. The human path (`/plugin\n\t * install`) asks; the autonomous path passes the `pluginInstallScope` setting.\n\t */\n\tscope?: PluginInstallScope;\n}\n\nexport interface InstallOutcome {\n\tinstalled: boolean;\n\t/** Install destination directory (when installed). */\n\tdest?: string;\n\t/** The scope the plugin was installed at. */\n\tscope?: PluginInstallScope;\n\t/**\n\t * The plugin's own manifest id, when it differs from the marketplace entry\n\t * name it was installed by. This is the id `ListPlugins` reports, so the\n\t * caller can name both rather than leaving the model to reconcile them.\n\t */\n\tid?: string;\n\t/** Platforms the installed plugin supports (read back from disk). */\n\tsupportPlatform?: MarketplacePlatform[];\n\t/** Human-readable summary suitable for a tool result or notification. */\n\tmessage: string;\n}\n\n/** Capability surfaces hoocode can actually load out of an installed plugin. */\nfunction loadableCapabilities(plugin: NormalizedPlugin): string[] {\n\treturn [\n\t\tplugin.skillsDir && \"skills\",\n\t\tplugin.commandsDir && \"commands\",\n\t\tplugin.agentsDir && \"subagents\",\n\t\tplugin.themesDir && \"themes\",\n\t\tplugin.hooks && \"hooks\",\n\t\tplugin.mcpServers && Object.keys(plugin.mcpServers).length > 0 && \"MCP servers\",\n\t\tplugin.providers?.length && \"providers\",\n\t\t// Not loaded at session start like the rest — a canvas has no passive half —\n\t\t// but it is unquestionably something the install brought, and reporting it\n\t\t// is what stops a canvas plugin reading as an install that did nothing.\n\t\tplugin.canvasExtensions?.length && \"canvases\",\n\t].filter((c): c is string => typeof c === \"string\");\n}\n\n/**\n * Install an available plugin by name into the consumption home for\n * `options.scope` — `~/.agents/plugins/` for `user` (the default), or\n * `<cwd>/.agents/plugins/` for `project`.\n *\n * `user` is the default for a reason worth keeping in view: a plugin is portable\n * and reusable across projects, so installing into the repo hides the capability\n * from every other checkout and puts content into `git status` that has nothing\n * to do with the change being made. `project` inverts both of those on purpose —\n * it is how a team pins a plugin to a repository — so it is chosen, never\n * defaulted into.\n *\n * Copies local sources; clones git sources. Transparent + reversible by\n * construction — the plugin lands in a named directory and\n * {@link uninstallPlugin} removes it from either scope. Callers activate the\n * result (live activation via AgentSession.activatePlugin, or a reload).\n */\nexport async function installAvailablePlugin(\n\tcwd: string,\n\tname: string,\n\tagentDir: string = getAgentDir(),\n\toptions: InstallOptions = {},\n): Promise<InstallOutcome> {\n\tconst found = findAvailablePlugin(cwd, name, agentDir);\n\tif (!found) return { installed: false, message: `Plugin \"${name}\" not found in any registered marketplace.` };\n\n\tconst scope = options.scope ?? \"user\";\n\tconst resolved = resolvePluginSource(found.source, found.marketplaceRoot);\n\tconst home = installHomeForScope(scope, cwd, agentDir);\n\tconst dest = path.join(home, sanitizeForDir(name));\n\trmSync(dest, { recursive: true, force: true });\n\tmkdirSync(home, { recursive: true });\n\n\tif (resolved.kind === \"local\") {\n\t\tif (!existsSync(resolved.path)) {\n\t\t\treturn { installed: false, message: `Plugin source path not found: ${resolved.path}` };\n\t\t}\n\t\tcpSync(resolved.path, dest, { recursive: true });\n\t} else if (resolved.kind === \"git\") {\n\t\tconst res = await cloneGitRepo(resolved.url, dest, resolved.ref, resolved.sha);\n\t\tif (res.code !== 0) {\n\t\t\trmSync(dest, { recursive: true, force: true });\n\t\t\treturn { installed: false, message: `git clone failed: ${res.stderr || res.stdout}`.trim() };\n\t\t}\n\t} else if (resolved.kind === \"git-subdir\") {\n\t\tconst res = await installGitSubdir(resolved.url, resolved.path, dest, resolved.ref, resolved.sha);\n\t\tif (!res.ok) {\n\t\t\trmSync(dest, { recursive: true, force: true });\n\t\t\treturn { installed: false, message: res.message };\n\t\t}\n\t} else if (resolved.kind === \"npm\") {\n\t\treturn {\n\t\t\tinstalled: false,\n\t\t\tmessage:\n\t\t\t\t`\"${name}\" is published as an npm package (${resolved.spec}), a source type hoocode cannot install yet. ` +\n\t\t\t\t\"The entry is listed so you know it exists; install it through the vendor CLI, or ask the marketplace \" +\n\t\t\t\t\"for a git source.\",\n\t\t};\n\t} else {\n\t\treturn {\n\t\t\tinstalled: false,\n\t\t\tmessage:\n\t\t\t\t`\"${name}\" is published as a zip archive (${resolved.url}), a source type hoocode cannot install yet. ` +\n\t\t\t\t\"The entry is listed so you know it exists; install it through the vendor CLI, or ask the marketplace \" +\n\t\t\t\t\"for a git source.\",\n\t\t};\n\t}\n\n\t// Drop the clone's `.git`. Nothing reads it — an installed plugin is never\n\t// updated from its remote, by design (that is the supply-chain boundary) — and\n\t// keeping it actively breaks project scope: a repository nested inside the\n\t// user's repository is an embedded repo, so `git add` writes a gitlink instead\n\t// of the plugin's files and the install cannot actually be committed. Which is\n\t// the one thing project scope is for. Cheaper installs at user scope too.\n\trmSync(path.join(dest, \".git\"), { recursive: true, force: true });\n\n\tlet parsed = parsePluginDir(dest);\n\tif (!hasAnyManifest(dest)) {\n\t\t// Manifest-less plugin dir (some marketplaces index bare capability trees,\n\t\t// e.g. a plugin that is just a `skills/` directory). Such a directory now\n\t\t// *parses* on its own — the vendors make the manifest optional — but the\n\t\t// entry's name and description live only in the marketplace index, and the\n\t\t// derived id would be the sanitized directory name. Synthesize a native\n\t\t// manifest so both survive the install.\n\t\tconst manifestDir = path.join(dest, \".agents-plugin\");\n\t\tmkdirSync(manifestDir, { recursive: true });\n\t\twriteFileSync(\n\t\t\tpath.join(manifestDir, \"plugin.json\"),\n\t\t\t`${JSON.stringify(\n\t\t\t\t{ name: found.name, ...(found.description ? { description: found.description } : {}) },\n\t\t\t\tnull,\n\t\t\t\t2,\n\t\t\t)}\\n`,\n\t\t\t\"utf8\",\n\t\t);\n\t\tparsed = parsePluginDir(dest);\n\t}\n\tif (!parsed) {\n\t\trmSync(dest, { recursive: true, force: true });\n\t\treturn { installed: false, message: `Installed source for \"${name}\" has no recognizable plugin manifest.` };\n\t}\n\tconst capabilities = loadableCapabilities(parsed);\n\tlet message =\n\t\t`Installed \"${name}\" from marketplace \"${found.marketplaceName}\" ` +\n\t\t`(${parsed.supportPlatform.join(\", \")}) to ${dest} [${scope} scope]. ` +\n\t\t`Remove it with UninstallPlugin.`;\n\tif (scope === \"project\") {\n\t\t// Project scope puts the plugin in the working tree, which is the point —\n\t\t// but it is also a `git status` entry and, once committed, code that runs\n\t\t// for whoever clones next. Executable capabilities are the half that\n\t\t// matters: hoocode has no workspace-trust gate for `<cwd>/.agents/plugins`\n\t\t// (see docs/plugin-system-architecture.md §5.9), so a committed hook or MCP\n\t\t// server loads for collaborators without a prompt.\n\t\tconst executables = [parsed.hooks && \"hooks\", parsed.mcpServers && \"MCP servers\"].filter(Boolean);\n\t\tmessage += ` It is now part of the working tree — commit it to share it with collaborators.`;\n\t\tif (executables.length > 0) {\n\t\t\tmessage +=\n\t\t\t\t` It carries ${executables.join(\" and \")}, which run for anyone who clones the repository;` +\n\t\t\t\t` keep it out of version control if that is not what you want.`;\n\t\t}\n\t}\n\tif (parsed.id !== name) {\n\t\t// The entry name and the manifest name are allowed to differ, and the\n\t\t// difference is otherwise invisible until ListPlugins reports an id the\n\t\t// caller never asked for.\n\t\tmessage += ` Listed by ListPlugins as \"${parsed.id}\"; either name uninstalls it.`;\n\t}\n\tif (parsed.canvasExtensions?.length) {\n\t\t// A canvas is the one capability that is not loaded on activation: it has no\n\t\t// passive half, so it waits for someone to open it. Saying so here is the\n\t\t// difference between \"installed and inert\" and \"installed, run /canvas\".\n\t\tconst ids = parsed.canvasExtensions.map((extension) => extension.id).join(\", \");\n\t\tmessage += ` Canvases: ${ids} — open one with /canvas open <id>.`;\n\t}\n\tif (capabilities.length === 0) {\n\t\t// An install that adds nothing should not read as an install that worked.\n\t\tconst surfaces = parsed.unsupportedSurfaces?.length\n\t\t\t? ` Unsupported surfaces present: ${parsed.unsupportedSurfaces.join(\", \")}.`\n\t\t\t: \"\";\n\t\tmessage +=\n\t\t\t\" Note: it contributes no capabilities hoocode can load\" +\n\t\t\t` (no skills, commands, subagents, canvases, hooks or MCP servers).${surfaces}`;\n\t}\n\treturn {\n\t\tinstalled: true,\n\t\tdest,\n\t\tscope,\n\t\t...(parsed.id !== name ? { id: parsed.id } : {}),\n\t\tsupportPlatform: parsed.supportPlatform,\n\t\tmessage,\n\t};\n}\n\nexport interface UninstallOutcome {\n\tremoved: boolean;\n\tmessage: string;\n}\n\n/** True when `target` is `root` or sits inside it. */\nfunction isUnderDir(target: string, root: string): boolean {\n\tconst normalized = path.resolve(root);\n\tif (path.resolve(target) === normalized) return true;\n\treturn path.resolve(target).startsWith(normalized.endsWith(path.sep) ? normalized : `${normalized}${path.sep}`);\n}\n\n/**\n * Remove an installed or authored plugin from every location it could occupy:\n * the consumption home, both production homes, and the legacy project-local\n * directories that older versions wrote into.\n *\n * `name` is matched two ways, because a plugin has two names: the marketplace\n * entry name it was installed under (which is what the directory is called) and\n * the manifest id `ListPlugins` reports. Resolving only the first left a plugin\n * whose names differ impossible to remove through the tool surface — the id the\n * model was shown was the one uninstall did not accept.\n *\n * Removal by id stays inside {@link pluginHomeRoots}, so a plugin discovered in\n * `<cwd>/.claude/skills` — repository content the team committed, which hoocode\n * did not install — is never deleted out of the working tree.\n */\nexport function uninstallPlugin(cwd: string, name: string, agentDir: string = getAgentDir()): UninstallOutcome {\n\tconst homes = pluginHomeRoots(cwd, agentDir);\n\tconst byManifestId = listInstalledPlugins(cwd, agentDir)\n\t\t.filter((p) => p.id === name)\n\t\t.map((p) => p.root)\n\t\t.filter((root) => homes.some((home) => isUnderDir(root, home)));\n\tconst candidates = [...new Set([...candidatePluginDirs(cwd, name, agentDir), ...byManifestId])];\n\tconst present = candidates.filter((p) => existsSync(p));\n\tif (present.length === 0) return { removed: false, message: `Plugin \"${name}\" is not installed.` };\n\tfor (const p of present) rmSync(p, { recursive: true, force: true });\n\treturn { removed: true, message: `Removed \"${name}\".` };\n}\n"]}