@kolisachint/hoocode-agent 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/cli/args.d.ts +1 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +10 -7
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +10 -2
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/capabilities/deferral.d.ts +78 -0
  10. package/dist/core/capabilities/deferral.d.ts.map +1 -0
  11. package/dist/core/capabilities/deferral.js +99 -0
  12. package/dist/core/capabilities/deferral.js.map +1 -0
  13. package/dist/core/capabilities/dense.d.ts +51 -0
  14. package/dist/core/capabilities/dense.d.ts.map +1 -0
  15. package/dist/core/capabilities/dense.js +121 -0
  16. package/dist/core/capabilities/dense.js.map +1 -0
  17. package/dist/core/capabilities/lexical.d.ts +43 -0
  18. package/dist/core/capabilities/lexical.d.ts.map +1 -0
  19. package/dist/core/capabilities/lexical.js +104 -0
  20. package/dist/core/capabilities/lexical.js.map +1 -0
  21. package/dist/core/capabilities/registry.d.ts +56 -0
  22. package/dist/core/capabilities/registry.d.ts.map +1 -0
  23. package/dist/core/capabilities/registry.js +0 -0
  24. package/dist/core/capabilities/registry.js.map +1 -0
  25. package/dist/core/capabilities/search.d.ts +47 -0
  26. package/dist/core/capabilities/search.d.ts.map +1 -0
  27. package/dist/core/capabilities/search.js +82 -0
  28. package/dist/core/capabilities/search.js.map +1 -0
  29. package/dist/core/extensions/loader.d.ts +27 -3
  30. package/dist/core/extensions/loader.d.ts.map +1 -1
  31. package/dist/core/extensions/loader.js +61 -7
  32. package/dist/core/extensions/loader.js.map +1 -1
  33. package/dist/core/extensions/plugins/authoring.d.ts +56 -9
  34. package/dist/core/extensions/plugins/authoring.d.ts.map +1 -1
  35. package/dist/core/extensions/plugins/authoring.js +0 -0
  36. package/dist/core/extensions/plugins/authoring.js.map +1 -1
  37. package/dist/core/extensions/plugins/drift.d.ts +75 -0
  38. package/dist/core/extensions/plugins/drift.d.ts.map +1 -0
  39. package/dist/core/extensions/plugins/drift.js +194 -0
  40. package/dist/core/extensions/plugins/drift.js.map +1 -0
  41. package/dist/core/extensions/plugins/formats/claude.d.ts.map +1 -1
  42. package/dist/core/extensions/plugins/formats/claude.js +5 -0
  43. package/dist/core/extensions/plugins/formats/claude.js.map +1 -1
  44. package/dist/core/extensions/plugins/formats/copilot.d.ts +6 -0
  45. package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -1
  46. package/dist/core/extensions/plugins/formats/copilot.js +57 -16
  47. package/dist/core/extensions/plugins/formats/copilot.js.map +1 -1
  48. package/dist/core/extensions/plugins/formats/index.d.ts +39 -1
  49. package/dist/core/extensions/plugins/formats/index.d.ts.map +1 -1
  50. package/dist/core/extensions/plugins/formats/index.js +90 -13
  51. package/dist/core/extensions/plugins/formats/index.js.map +1 -1
  52. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts +14 -0
  53. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -1
  54. package/dist/core/extensions/plugins/formats/jsonManifest.js +37 -4
  55. package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -1
  56. package/dist/core/extensions/plugins/formats/platform-targets.d.ts +48 -24
  57. package/dist/core/extensions/plugins/formats/platform-targets.d.ts.map +1 -1
  58. package/dist/core/extensions/plugins/formats/platform-targets.js +62 -30
  59. package/dist/core/extensions/plugins/formats/platform-targets.js.map +1 -1
  60. package/dist/core/extensions/plugins/formats/shared.d.ts +22 -0
  61. package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
  62. package/dist/core/extensions/plugins/formats/shared.js +81 -4
  63. package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
  64. package/dist/core/extensions/plugins/formats/types.d.ts +10 -1
  65. package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -1
  66. package/dist/core/extensions/plugins/formats/types.js.map +1 -1
  67. package/dist/core/extensions/plugins/gates.d.ts +64 -0
  68. package/dist/core/extensions/plugins/gates.d.ts.map +1 -0
  69. package/dist/core/extensions/plugins/gates.js +297 -0
  70. package/dist/core/extensions/plugins/gates.js.map +1 -0
  71. package/dist/core/extensions/plugins/hooks-bridge.d.ts +1 -1
  72. package/dist/core/extensions/plugins/hooks-bridge.d.ts.map +1 -1
  73. package/dist/core/extensions/plugins/hooks-bridge.js +12 -8
  74. package/dist/core/extensions/plugins/hooks-bridge.js.map +1 -1
  75. package/dist/core/extensions/plugins/index.d.ts +36 -5
  76. package/dist/core/extensions/plugins/index.d.ts.map +1 -1
  77. package/dist/core/extensions/plugins/index.js +66 -15
  78. package/dist/core/extensions/plugins/index.js.map +1 -1
  79. package/dist/core/extensions/plugins/install.d.ts +43 -24
  80. package/dist/core/extensions/plugins/install.d.ts.map +1 -1
  81. package/dist/core/extensions/plugins/install.js +167 -54
  82. package/dist/core/extensions/plugins/install.js.map +1 -1
  83. package/dist/core/extensions/plugins/locations.d.ts +84 -0
  84. package/dist/core/extensions/plugins/locations.d.ts.map +1 -0
  85. package/dist/core/extensions/plugins/locations.js +137 -0
  86. package/dist/core/extensions/plugins/locations.js.map +1 -0
  87. package/dist/core/extensions/plugins/manifest.d.ts +21 -1
  88. package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
  89. package/dist/core/extensions/plugins/manifest.js +2 -2
  90. package/dist/core/extensions/plugins/manifest.js.map +1 -1
  91. package/dist/core/extensions/plugins/packaging.d.ts +115 -0
  92. package/dist/core/extensions/plugins/packaging.d.ts.map +1 -0
  93. package/dist/core/extensions/plugins/packaging.js +362 -0
  94. package/dist/core/extensions/plugins/packaging.js.map +1 -0
  95. package/dist/core/extensions/plugins/smoke.d.ts +33 -0
  96. package/dist/core/extensions/plugins/smoke.d.ts.map +1 -0
  97. package/dist/core/extensions/plugins/smoke.js +231 -0
  98. package/dist/core/extensions/plugins/smoke.js.map +1 -0
  99. package/dist/core/extensions/plugins/trigger-eval.d.ts +136 -0
  100. package/dist/core/extensions/plugins/trigger-eval.d.ts.map +1 -0
  101. package/dist/core/extensions/plugins/trigger-eval.js +0 -0
  102. package/dist/core/extensions/plugins/trigger-eval.js.map +1 -0
  103. package/dist/core/package-manager.d.ts +6 -0
  104. package/dist/core/package-manager.d.ts.map +1 -1
  105. package/dist/core/package-manager.js.map +1 -1
  106. package/dist/core/resource-loader.d.ts +2 -0
  107. package/dist/core/resource-loader.d.ts.map +1 -1
  108. package/dist/core/resource-loader.js +15 -0
  109. package/dist/core/resource-loader.js.map +1 -1
  110. package/dist/core/settings-manager.d.ts +2 -2
  111. package/dist/core/settings-manager.d.ts.map +1 -1
  112. package/dist/core/settings-manager.js +3 -3
  113. package/dist/core/settings-manager.js.map +1 -1
  114. package/dist/core/settings-types.d.ts +1 -1
  115. package/dist/core/settings-types.d.ts.map +1 -1
  116. package/dist/core/settings-types.js.map +1 -1
  117. package/dist/core/skills.d.ts +9 -0
  118. package/dist/core/skills.d.ts.map +1 -1
  119. package/dist/core/skills.js +23 -3
  120. package/dist/core/skills.js.map +1 -1
  121. package/dist/core/tools/package-plugin.d.ts +19 -0
  122. package/dist/core/tools/package-plugin.d.ts.map +1 -0
  123. package/dist/core/tools/package-plugin.js +81 -0
  124. package/dist/core/tools/package-plugin.js.map +1 -0
  125. package/dist/core/tools/plugin-tool-names.d.ts +1 -0
  126. package/dist/core/tools/plugin-tool-names.d.ts.map +1 -1
  127. package/dist/core/tools/plugin-tool-names.js +6 -0
  128. package/dist/core/tools/plugin-tool-names.js.map +1 -1
  129. package/dist/core/tools/plugins.d.ts.map +1 -1
  130. package/dist/core/tools/plugins.js +48 -7
  131. package/dist/core/tools/plugins.js.map +1 -1
  132. package/dist/core/tools/propose-plugin.d.ts.map +1 -1
  133. package/dist/core/tools/propose-plugin.js +49 -21
  134. package/dist/core/tools/propose-plugin.js.map +1 -1
  135. package/dist/extensions/core/marketplace.d.ts +14 -3
  136. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  137. package/dist/extensions/core/marketplace.js +77 -18
  138. package/dist/extensions/core/marketplace.js.map +1 -1
  139. package/dist/extensions/core/mcp-deferred.d.ts +39 -2
  140. package/dist/extensions/core/mcp-deferred.d.ts.map +1 -1
  141. package/dist/extensions/core/mcp-deferred.js +74 -10
  142. package/dist/extensions/core/mcp-deferred.js.map +1 -1
  143. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  144. package/dist/extensions/core/mcp-loader.js +52 -9
  145. package/dist/extensions/core/mcp-loader.js.map +1 -1
  146. package/dist/extensions/core/scaffold.d.ts +3 -3
  147. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  148. package/dist/extensions/core/scaffold.js +9 -9
  149. package/dist/extensions/core/scaffold.js.map +1 -1
  150. package/dist/main.d.ts.map +1 -1
  151. package/dist/main.js +25 -7
  152. package/dist/main.js.map +1 -1
  153. package/dist/utils/tools-manager.d.ts +8 -0
  154. package/dist/utils/tools-manager.d.ts.map +1 -1
  155. package/dist/utils/tools-manager.js +8 -2
  156. package/dist/utils/tools-manager.js.map +1 -1
  157. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  158. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  159. package/examples/extensions/sandbox/package.json +1 -1
  160. package/examples/extensions/with-deps/package.json +1 -1
  161. package/package.json +5 -4
@@ -13,10 +13,6 @@
13
13
  import type { MarketplacePlatform } from "./formats/types.js";
14
14
  import type { NormalizedPlugin } from "./manifest.js";
15
15
  import { type MarketplacePluginSource, type MarketplaceRecord } from "./marketplace.js";
16
- /** `.agents/` is the primary, cross-vendor home for installed plugins and the added-marketplace registry. */
17
- export declare function installedPluginsDir(cwd: string): string;
18
- /** Filesystem-safe directory name derived from a plugin name (matches the `/plugin` command). */
19
- export declare function sanitizeForDir(s: string): string;
20
16
  /** The default marketplace record — always present so source-level trust is meaningful out of the box. */
21
17
  export declare function defaultMarketplaceRecord(): MarketplaceRecord;
22
18
  /**
@@ -24,28 +20,43 @@ export declare function defaultMarketplaceRecord(): MarketplaceRecord;
24
20
  * here is a maintainer-level trust decision (the human half of the "adding a
25
21
  * marketplace is the human trust boundary" rule) — keep this list short and
26
22
  * high-trust. Indices are cloned lazily into the marketplace cache on first
27
- * search and never auto-updated afterwards (plugin sources inside the official
28
- * index are sha-pinned).
23
+ * search and refreshed on a TTL thereafter. Refreshing the *index* only changes
24
+ * what is discoverable; installed plugins are still never auto-updated, and
25
+ * plugin sources inside the official index are sha-pinned.
29
26
  */
30
27
  export declare const WELL_KNOWN_MARKETPLACES: ReadonlyArray<{
31
28
  name: string;
32
29
  url: string;
33
30
  }>;
34
- /** Local cache directory for a marketplace fetched from `url` (same convention as `/plugin marketplace add`). */
35
- export declare function marketplaceCacheDir(cwd: string, url: string): string;
31
+ export interface EnsureMarketplacesOptions {
32
+ /** Refresh regardless of the TTL. */
33
+ force?: boolean;
34
+ }
35
+ /**
36
+ * Make sure every well-known marketplace index is cached and reasonably fresh.
37
+ *
38
+ * Clones what is missing and pulls what has gone stale. Without the TTL the
39
+ * indices were fetched exactly once and never again, so a plugin added upstream
40
+ * stayed permanently invisible — the inherit half of the system quietly stopped
41
+ * inheriting. Offline is non-fatal: each failure is returned and search degrades
42
+ * to whatever is already on disk.
43
+ */
44
+ export declare function ensureWellKnownMarketplaces(agentDir?: string, options?: EnsureMarketplacesOptions): Promise<string[]>;
36
45
  /**
37
- * Clone any well-known marketplace index that is not in the local cache yet.
38
- * No-op (and no network) when every index is already cached. Returns one error
39
- * string per marketplace that could not be fetched (offline is non-fatal
40
- * search degrades to the marketplaces already available).
46
+ * Refresh every cached index — well-known and user-added ignoring the TTL.
47
+ * The explicit half of the freshness story, for when a user knows the upstream
48
+ * changed and does not want to wait out the interval.
41
49
  */
42
- export declare function ensureWellKnownMarketplaces(cwd: string): Promise<string[]>;
50
+ export declare function refreshMarketplaces(cwd: string, agentDir?: string): Promise<{
51
+ refreshed: string[];
52
+ errors: string[];
53
+ }>;
43
54
  /**
44
55
  * All marketplace records in effect: the bundled default first (curated,
45
56
  * trusted), then user-added ones from `.agents/` (falling back to legacy
46
57
  * `.hoocode/`). Deduplicated by directory.
47
58
  */
48
- export declare function readMarketplaceRecords(cwd: string): MarketplaceRecord[];
59
+ export declare function readMarketplaceRecords(cwd: string, agentDir?: string): MarketplaceRecord[];
49
60
  /** A plugin offered by some registered marketplace (not necessarily installed). */
50
61
  export interface AvailablePlugin {
51
62
  name: string;
@@ -60,9 +71,9 @@ export interface AvailablePlugin {
60
71
  supportPlatform: MarketplacePlatform[];
61
72
  }
62
73
  /** Every plugin offered across all registered marketplaces (first marketplace wins on name clash). */
63
- export declare function listAvailablePlugins(cwd: string): AvailablePlugin[];
74
+ export declare function listAvailablePlugins(cwd: string, agentDir?: string): AvailablePlugin[];
64
75
  /** Find a single available plugin by exact name. */
65
- export declare function findAvailablePlugin(cwd: string, name: string): AvailablePlugin | undefined;
76
+ export declare function findAvailablePlugin(cwd: string, name: string, agentDir?: string): AvailablePlugin | undefined;
66
77
  /** All currently installed plugins (project + global plugin dirs). */
67
78
  export declare function listInstalledPlugins(cwd: string, agentDir?: string): NormalizedPlugin[];
68
79
  /** Whether a plugin with `name` (by id) is already installed. */
@@ -77,17 +88,25 @@ export interface InstallOutcome {
77
88
  message: string;
78
89
  }
79
90
  /**
80
- * Install an available plugin by name into `.agents/plugins`. Copies local
81
- * sources; clones git sources. Transparent + reversible by construction — the
82
- * plugin lands in a named directory and {@link uninstallPlugin} removes it.
83
- * Callers activate the result (live activation via AgentSession.activatePlugin,
84
- * or a reload).
91
+ * Install an available plugin by name into the **consumption home** — the global
92
+ * `~/.agents/plugins/`, not the working tree. A plugin is portable and reusable
93
+ * across projects, so installing into the repo would both dirty `git status` and
94
+ * hide the capability from every other checkout.
95
+ *
96
+ * Copies local sources; clones git sources. Transparent + reversible by
97
+ * construction — the plugin lands in a named directory and
98
+ * {@link uninstallPlugin} removes it. Callers activate the result (live
99
+ * activation via AgentSession.activatePlugin, or a reload).
85
100
  */
86
- export declare function installAvailablePlugin(cwd: string, name: string): Promise<InstallOutcome>;
101
+ export declare function installAvailablePlugin(cwd: string, name: string, agentDir?: string): Promise<InstallOutcome>;
87
102
  export interface UninstallOutcome {
88
103
  removed: boolean;
89
104
  message: string;
90
105
  }
91
- /** Remove an installed plugin from `.agents/plugins` (and the legacy `.hoocode/plugins`). */
92
- export declare function uninstallPlugin(cwd: string, name: string): UninstallOutcome;
106
+ /**
107
+ * Remove an installed or authored plugin from every location it could occupy:
108
+ * the consumption home, both production homes, and the legacy project-local
109
+ * directories that older versions wrote into.
110
+ */
111
+ export declare function uninstallPlugin(cwd: string, name: string, agentDir?: string): UninstallOutcome;
93
112
  //# sourceMappingURL=install.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EACN,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EAItB,MAAM,kBAAkB,CAAC;AAE1B,6GAA6G;AAC7G,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAUD,iGAAiG;AACjG,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEhD;AAOD,4GAA0G;AAC1G,wBAAgB,wBAAwB,IAAI,iBAAiB,CAE5D;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAMhF,CAAC;AAEF,iHAAiH;AACjH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAahF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAuBvE;AAED,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,MAAM,EAAE,uBAAuB,CAAC;IAChC,oDAAoD;IACpD,UAAU,EAAE,OAAO,GAAG,KAAK,GAAG,YAAY,GAAG,KAAK,CAAC;IACnD,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,eAAe,EAAE,mBAAmB,EAAE,CAAC;CACvC;AAED,sGAAsG;AACtG,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,EAAE,CAqBnE;AAED,oDAAoD;AACpD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAE1F;AAED,sEAAsE;AACtE,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,gBAAgB,EAAE,CAEtG;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,OAAO,CAEtG;AAqED,MAAM,WAAW,cAAc;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,eAAe,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACxC,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CA6D/F;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,6FAA6F;AAC7F,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAS3E","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, 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 type { MarketplacePlatform } from \"./formats/types.js\";\nimport { discoverPlugins } from \"./index.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/** `.agents/` is the primary, cross-vendor home for installed plugins and the added-marketplace registry. */\nexport function installedPluginsDir(cwd: string): string {\n\treturn path.join(cwd, \".agents\", \"plugins\");\n}\n\nfunction marketplaceStorePath(cwd: string): string {\n\treturn path.join(cwd, \".agents\", \"marketplaces.json\");\n}\n\nfunction legacyStorePath(cwd: string): string {\n\treturn path.join(cwd, \".hoocode\", \"marketplaces.json\");\n}\n\n/** Filesystem-safe directory name derived from a plugin name (matches the `/plugin` command). */\nexport function sanitizeForDir(s: string): string {\n\treturn s.replace(/[^a-zA-Z0-9._-]+/g, \"_\").slice(0, 80);\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 never auto-updated afterwards (plugin sources inside the official\n * index are sha-pinned).\n */\nexport const WELL_KNOWN_MARKETPLACES: ReadonlyArray<{ name: string; url: 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];\n\n/** Local cache directory for a marketplace fetched from `url` (same convention as `/plugin marketplace add`). */\nexport function marketplaceCacheDir(cwd: string, url: string): string {\n\treturn path.join(cwd, \".agents\", \"marketplace-cache\", sanitizeForDir(url));\n}\n\n/**\n * Clone any well-known marketplace index that is not in the local cache yet.\n * No-op (and no network) when every index is already cached. Returns one error\n * string per marketplace that could not be fetched (offline is non-fatal —\n * search degrades to the marketplaces already available).\n */\nexport async function ensureWellKnownMarketplaces(cwd: string): Promise<string[]> {\n\tconst errors: string[] = [];\n\tfor (const wk of WELL_KNOWN_MARKETPLACES) {\n\t\tconst dir = marketplaceCacheDir(cwd, wk.url);\n\t\tif (existsSync(dir)) continue;\n\t\tmkdirSync(path.dirname(dir), { recursive: true });\n\t\tconst res = await cloneGitRepo(wk.url, dir);\n\t\tif (res.code !== 0) {\n\t\t\trmSync(dir, { recursive: true, force: true });\n\t\t\terrors.push(`${wk.name}: ${(res.stderr || res.stdout).trim()}`);\n\t\t}\n\t}\n\treturn 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): 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(cwd, wk.url);\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\tconst primary = readMarketplaceStore(marketplaceStorePath(cwd));\n\tconst user =\n\t\tprimary.length > 0 || existsSync(marketplaceStorePath(cwd))\n\t\t\t? primary\n\t\t\t: readMarketplaceStore(legacyStorePath(cwd));\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. */\n\tsourceKind: \"local\" | \"git\" | \"git-subdir\" | \"npm\";\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): AvailablePlugin[] {\n\tconst out: AvailablePlugin[] = [];\n\tconst seen = new Set<string>();\n\tfor (const rec of readMarketplaceRecords(cwd)) {\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(cwd: string, name: string): AvailablePlugin | undefined {\n\treturn listAvailablePlugins(cwd).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/** Whether a plugin with `name` (by id) is already installed. */\nexport function isPluginInstalled(cwd: string, name: string, agentDir: string = getAgentDir()): boolean {\n\treturn listInstalledPlugins(cwd, agentDir).some((p) => p.id === name);\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 * Clone a git repository into `dest`.\n * If `ref` is provided, does a shallow clone of that branch/tag.\n * If only `sha` is provided, does a full clone and checks out the commit.\n * Returns the clone 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 (ref) {\n\t\treturn execGit([\"clone\", \"--branch\", ref, \"--depth\", \"1\", \"--\", url, dest]);\n\t}\n\tif (sha) {\n\t\tconst cloneRes = await execGit([\"clone\", \"--\", url, dest]);\n\t\tif (cloneRes.code !== 0) return cloneRes;\n\t\treturn execGit([\"-C\", dest, \"checkout\", \"--quiet\", sha]);\n\t}\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 InstallOutcome {\n\tinstalled: boolean;\n\t/** Install destination directory (when installed). */\n\tdest?: 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/**\n * Install an available plugin by name into `.agents/plugins`. Copies local\n * sources; clones git sources. Transparent + reversible by construction — the\n * plugin lands in a named directory and {@link uninstallPlugin} removes it.\n * Callers activate the result (live activation via AgentSession.activatePlugin,\n * or a reload).\n */\nexport async function installAvailablePlugin(cwd: string, name: string): Promise<InstallOutcome> {\n\tconst found = findAvailablePlugin(cwd, name);\n\tif (!found) return { installed: false, message: `Plugin \"${name}\" not found in any registered marketplace.` };\n\n\tconst resolved = resolvePluginSource(found.source, found.marketplaceRoot);\n\tconst dest = path.join(installedPluginsDir(cwd), sanitizeForDir(name));\n\trmSync(dest, { recursive: true, force: true });\n\tmkdirSync(installedPluginsDir(cwd), { 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 {\n\t\treturn { installed: false, message: `npm plugin sources are not supported yet (${resolved.spec}).` };\n\t}\n\n\tlet parsed = parsePluginDir(dest);\n\tif (!parsed) {\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): synthesize a native\n\t\t// manifest from the marketplace entry so the standard loader can carry it.\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\treturn {\n\t\tinstalled: true,\n\t\tdest,\n\t\tsupportPlatform: parsed.supportPlatform,\n\t\tmessage:\n\t\t\t`Installed \"${name}\" from marketplace \"${found.marketplaceName}\" ` +\n\t\t\t`(${parsed.supportPlatform.join(\", \")}) to ${path.relative(cwd, dest) || dest}. ` +\n\t\t\t`Remove it with UninstallPlugin.`,\n\t};\n}\n\nexport interface UninstallOutcome {\n\tremoved: boolean;\n\tmessage: string;\n}\n\n/** Remove an installed plugin from `.agents/plugins` (and the legacy `.hoocode/plugins`). */\nexport function uninstallPlugin(cwd: string, name: string): UninstallOutcome {\n\tconst candidates = [\n\t\tpath.join(installedPluginsDir(cwd), sanitizeForDir(name)),\n\t\tpath.join(cwd, \".hoocode\", \"plugins\", sanitizeForDir(name)),\n\t];\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"]}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAUH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAW9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EACN,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EAItB,MAAM,kBAAkB,CAAC;AAgB1B,4GAA0G;AAC1G,wBAAgB,wBAAwB,IAAI,iBAAiB,CAE5D;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAMhF,CAAC;AAgFF,MAAM,WAAW,yBAAyB;IACzC,qCAAqC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAsB,2BAA2B,CAChD,QAAQ,GAAE,MAAsB,EAChC,OAAO,GAAE,yBAA8B,GACrC,OAAO,CAAC,MAAM,EAAE,CAAC,CAmBnB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACxC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,GAC9B,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAmBpD;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,iBAAiB,EAAE,CAwBzG;AAED,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,MAAM,EAAE,uBAAuB,CAAC;IAChC,oDAAoD;IACpD,UAAU,EAAE,OAAO,GAAG,KAAK,GAAG,YAAY,GAAG,KAAK,CAAC;IACnD,eAAe,EAAE,MAAM,CAAC;IACxB,eAAe,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,eAAe,EAAE,mBAAmB,EAAE,CAAC;CACvC;AAED,sGAAsG;AACtG,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,eAAe,EAAE,CAqBrG;AAED,oDAAoD;AACpD,wBAAgB,mBAAmB,CAClC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAAsB,GAC9B,eAAe,GAAG,SAAS,CAE7B;AAED,sEAAsE;AACtE,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,gBAAgB,EAAE,CAEtG;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,OAAO,CAEtG;AAqED,MAAM,WAAW,cAAc;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,eAAe,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACxC,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC3C,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAAsB,GAC9B,OAAO,CAAC,cAAc,CAAC,CAiEzB;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,gBAAgB,CAS7G","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\tconsumptionPluginsDir,\n\tmarketplaceCacheDir,\n\tmarketplaceCacheMetaPath,\n\tmarketplaceCacheRoot,\n\tmarketplaceStorePath,\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 }> = [\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];\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(url: string, dir: string): 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\tconst cloned = await cloneGitRepo(url, target);\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\nexport interface EnsureMarketplacesOptions {\n\t/** Refresh regardless of the TTL. */\n\tforce?: boolean;\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 */\nexport async function ensureWellKnownMarketplaces(\n\tagentDir: string = getAgentDir(),\n\toptions: EnsureMarketplacesOptions = {},\n): Promise<string[]> {\n\tconst errors: string[] = [];\n\tfor (const wk of WELL_KNOWN_MARKETPLACES) {\n\t\tconst dir = marketplaceCacheDir(wk.url, agentDir);\n\t\tconst present = existsSync(dir);\n\t\tif (present && !options.force && !isStale(agentDir, wk.url)) continue;\n\n\t\tmkdirSync(path.dirname(dir), { recursive: true });\n\t\tconst res = present ? await refreshClone(wk.url, dir) : await cloneGitRepo(wk.url, dir);\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\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): Promise<{ refreshed: string[]; errors: string[] }> {\n\tconst errors = await ensureWellKnownMarketplaces(agentDir, { force: true });\n\tconst refreshed = WELL_KNOWN_MARKETPLACES.filter((wk) => existsSync(marketplaceCacheDir(wk.url, agentDir))).map(\n\t\t(wk) => wk.name,\n\t);\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 || WELL_KNOWN_MARKETPLACES.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. */\n\tsourceKind: \"local\" | \"git\" | \"git-subdir\" | \"npm\";\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/** Whether a plugin with `name` (by id) is already installed. */\nexport function isPluginInstalled(cwd: string, name: string, agentDir: string = getAgentDir()): boolean {\n\treturn listInstalledPlugins(cwd, agentDir).some((p) => p.id === name);\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 * Clone a git repository into `dest`.\n * If `ref` is provided, does a shallow clone of that branch/tag.\n * If only `sha` is provided, does a full clone and checks out the commit.\n * Returns the clone 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 (ref) {\n\t\treturn execGit([\"clone\", \"--branch\", ref, \"--depth\", \"1\", \"--\", url, dest]);\n\t}\n\tif (sha) {\n\t\tconst cloneRes = await execGit([\"clone\", \"--\", url, dest]);\n\t\tif (cloneRes.code !== 0) return cloneRes;\n\t\treturn execGit([\"-C\", dest, \"checkout\", \"--quiet\", sha]);\n\t}\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 InstallOutcome {\n\tinstalled: boolean;\n\t/** Install destination directory (when installed). */\n\tdest?: 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/**\n * Install an available plugin by name into the **consumption home** — the global\n * `~/.agents/plugins/`, not the working tree. A plugin is portable and reusable\n * across projects, so installing into the repo would both dirty `git status` and\n * hide the capability from every other checkout.\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. Callers activate the result (live\n * activation via AgentSession.activatePlugin, or a reload).\n */\nexport async function installAvailablePlugin(\n\tcwd: string,\n\tname: string,\n\tagentDir: string = getAgentDir(),\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 resolved = resolvePluginSource(found.source, found.marketplaceRoot);\n\tconst home = consumptionPluginsDir(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 {\n\t\treturn { installed: false, message: `npm plugin sources are not supported yet (${resolved.spec}).` };\n\t}\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\treturn {\n\t\tinstalled: true,\n\t\tdest,\n\t\tsupportPlatform: parsed.supportPlatform,\n\t\tmessage:\n\t\t\t`Installed \"${name}\" from marketplace \"${found.marketplaceName}\" ` +\n\t\t\t`(${parsed.supportPlatform.join(\", \")}) to ${dest}. ` +\n\t\t\t`Remove it with UninstallPlugin.`,\n\t};\n}\n\nexport interface UninstallOutcome {\n\tremoved: boolean;\n\tmessage: string;\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 */\nexport function uninstallPlugin(cwd: string, name: string, agentDir: string = getAgentDir()): UninstallOutcome {\n\tconst candidates = [\n\t\t...candidatePluginDirs(cwd, name, agentDir),\n\t\tpath.join(cwd, \".hoocode\", \"plugins\", sanitizeForDir(name)),\n\t];\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"]}
@@ -11,28 +11,24 @@
11
11
  * (announce, injection carve-out) lives with the callers.
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
- import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { getAgentDir } from "../../../config.js";
19
19
  import { defaultPluginDirs } from "../loader.js";
20
+ import { hasAnyManifest } from "./formats/index.js";
20
21
  import { discoverPlugins } from "./index.js";
22
+ import { candidatePluginDirs, consumptionPluginsDir, marketplaceCacheDir, marketplaceCacheMetaPath, marketplaceCacheRoot, marketplaceStorePath, sanitizeForDir, } from "./locations.js";
21
23
  import { parsePluginDir } from "./manifest.js";
22
24
  import { parseMarketplaceDir, readMarketplaceStore, resolvePluginSource, } from "./marketplace.js";
23
- /** `.agents/` is the primary, cross-vendor home for installed plugins and the added-marketplace registry. */
24
- export function installedPluginsDir(cwd) {
25
- return path.join(cwd, ".agents", "plugins");
26
- }
27
- function marketplaceStorePath(cwd) {
28
- return path.join(cwd, ".agents", "marketplaces.json");
29
- }
30
- function legacyStorePath(cwd) {
31
- return path.join(cwd, ".hoocode", "marketplaces.json");
32
- }
33
- /** Filesystem-safe directory name derived from a plugin name (matches the `/plugin` command). */
34
- export function sanitizeForDir(s) {
35
- return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
25
+ /**
26
+ * Marketplace registries older versions wrote into the working tree, newest
27
+ * convention first. Still read so a user who added a marketplace before the
28
+ * registry moved to the agent dir does not silently lose it; nothing writes here.
29
+ */
30
+ function legacyStorePaths(cwd) {
31
+ return [path.join(cwd, ".agents", "marketplaces.json"), path.join(cwd, ".hoocode", "marketplaces.json")];
36
32
  }
37
33
  /** Absolute path to the curated default marketplace bundled with hoocode. */
38
34
  function defaultMarketplaceDir() {
@@ -47,8 +43,9 @@ export function defaultMarketplaceRecord() {
47
43
  * here is a maintainer-level trust decision (the human half of the "adding a
48
44
  * marketplace is the human trust boundary" rule) — keep this list short and
49
45
  * high-trust. Indices are cloned lazily into the marketplace cache on first
50
- * search and never auto-updated afterwards (plugin sources inside the official
51
- * index are sha-pinned).
46
+ * search and refreshed on a TTL thereafter. Refreshing the *index* only changes
47
+ * what is discoverable; installed plugins are still never auto-updated, and
48
+ * plugin sources inside the official index are sha-pinned.
52
49
  */
53
50
  export const WELL_KNOWN_MARKETPLACES = [
54
51
  { name: "claude-plugins-official", url: "https://github.com/anthropics/claude-plugins-official" },
@@ -57,37 +54,139 @@ export const WELL_KNOWN_MARKETPLACES = [
57
54
  // supportPlatform ["claude", "github"] — the default `github` source.
58
55
  { name: "copilot-plugins", url: "https://github.com/github/copilot-plugins" },
59
56
  ];
60
- /** Local cache directory for a marketplace fetched from `url` (same convention as `/plugin marketplace add`). */
61
- export function marketplaceCacheDir(cwd, url) {
62
- return path.join(cwd, ".agents", "marketplace-cache", sanitizeForDir(url));
57
+ /**
58
+ * How long a cached marketplace index is considered fresh.
59
+ *
60
+ * Refreshing an *index* is safe to automate: it changes what is discoverable,
61
+ * not what is installed. The rule that an installed plugin is never auto-updated
62
+ * from a remote is untouched — that is the supply-chain boundary, and this is a
63
+ * catalog listing.
64
+ */
65
+ const MARKETPLACE_TTL_MS = 24 * 60 * 60 * 1000;
66
+ function marketplaceTtlMs() {
67
+ const raw = Number(process.env.HOOCODE_MARKETPLACE_TTL_MS);
68
+ return Number.isFinite(raw) && raw >= 0 ? raw : MARKETPLACE_TTL_MS;
69
+ }
70
+ function readCacheMeta(agentDir) {
71
+ const parsed = readJsonFile(marketplaceCacheMetaPath(agentDir));
72
+ return parsed && typeof parsed === "object" ? parsed : {};
73
+ }
74
+ function recordFetch(agentDir, url) {
75
+ const meta = readCacheMeta(agentDir);
76
+ meta[url] = new Date().toISOString();
77
+ const file = marketplaceCacheMetaPath(agentDir);
78
+ mkdirSync(path.dirname(file), { recursive: true });
79
+ writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
80
+ }
81
+ function isStale(agentDir, url) {
82
+ const ttl = marketplaceTtlMs();
83
+ if (ttl === 0)
84
+ return true;
85
+ const at = readCacheMeta(agentDir)[url];
86
+ if (!at)
87
+ return true;
88
+ const age = Date.now() - Date.parse(at);
89
+ return !Number.isFinite(age) || age > ttl;
90
+ }
91
+ function readJsonFile(file) {
92
+ try {
93
+ return JSON.parse(readFileSync(file, "utf8"));
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /**
100
+ * Bring an existing clone up to date, falling back to a fresh clone when the pull
101
+ * cannot fast-forward (a force-pushed remote, or a corrupt cache).
102
+ *
103
+ * The re-clone goes to a temp directory and is swapped in only on success. An
104
+ * earlier version deleted the cache first, which meant a refresh attempted while
105
+ * offline destroyed a perfectly good index — the opposite of the "offline is
106
+ * non-fatal" property the caller depends on.
107
+ */
108
+ async function refreshClone(url, dir) {
109
+ const pulled = await execGit(["-C", dir, "pull", "--ff-only"]);
110
+ if (pulled.code === 0)
111
+ return pulled;
112
+ const staging = mkdtempSync(path.join(os.tmpdir(), "hoo-market-refresh-"));
113
+ const target = path.join(staging, "clone");
114
+ const cloned = await cloneGitRepo(url, target);
115
+ if (cloned.code !== 0) {
116
+ rmSync(staging, { recursive: true, force: true });
117
+ return cloned;
118
+ }
119
+ rmSync(dir, { recursive: true, force: true });
120
+ mkdirSync(path.dirname(dir), { recursive: true });
121
+ try {
122
+ renameSync(target, dir);
123
+ }
124
+ catch {
125
+ cpSync(target, dir, { recursive: true });
126
+ }
127
+ rmSync(staging, { recursive: true, force: true });
128
+ return cloned;
63
129
  }
64
130
  /**
65
- * Clone any well-known marketplace index that is not in the local cache yet.
66
- * No-op (and no network) when every index is already cached. Returns one error
67
- * string per marketplace that could not be fetched (offline is non-fatal
68
- * search degrades to the marketplaces already available).
131
+ * Make sure every well-known marketplace index is cached and reasonably fresh.
132
+ *
133
+ * Clones what is missing and pulls what has gone stale. Without the TTL the
134
+ * indices were fetched exactly once and never again, so a plugin added upstream
135
+ * stayed permanently invisible — the inherit half of the system quietly stopped
136
+ * inheriting. Offline is non-fatal: each failure is returned and search degrades
137
+ * to whatever is already on disk.
69
138
  */
70
- export async function ensureWellKnownMarketplaces(cwd) {
139
+ export async function ensureWellKnownMarketplaces(agentDir = getAgentDir(), options = {}) {
71
140
  const errors = [];
72
141
  for (const wk of WELL_KNOWN_MARKETPLACES) {
73
- const dir = marketplaceCacheDir(cwd, wk.url);
74
- if (existsSync(dir))
142
+ const dir = marketplaceCacheDir(wk.url, agentDir);
143
+ const present = existsSync(dir);
144
+ if (present && !options.force && !isStale(agentDir, wk.url))
75
145
  continue;
76
146
  mkdirSync(path.dirname(dir), { recursive: true });
77
- const res = await cloneGitRepo(wk.url, dir);
147
+ const res = present ? await refreshClone(wk.url, dir) : await cloneGitRepo(wk.url, dir);
78
148
  if (res.code !== 0) {
79
- rmSync(dir, { recursive: true, force: true });
149
+ // A failed *refresh* keeps the stale copy: an out-of-date index is far
150
+ // more useful than none, and the network may simply be down.
151
+ if (!present)
152
+ rmSync(dir, { recursive: true, force: true });
80
153
  errors.push(`${wk.name}: ${(res.stderr || res.stdout).trim()}`);
154
+ continue;
81
155
  }
156
+ recordFetch(agentDir, wk.url);
82
157
  }
83
158
  return errors;
84
159
  }
160
+ /**
161
+ * Refresh every cached index — well-known and user-added — ignoring the TTL.
162
+ * The explicit half of the freshness story, for when a user knows the upstream
163
+ * changed and does not want to wait out the interval.
164
+ */
165
+ export async function refreshMarketplaces(cwd, agentDir = getAgentDir()) {
166
+ const errors = await ensureWellKnownMarketplaces(agentDir, { force: true });
167
+ const refreshed = WELL_KNOWN_MARKETPLACES.filter((wk) => existsSync(marketplaceCacheDir(wk.url, agentDir))).map((wk) => wk.name);
168
+ // User-added marketplaces are cached the same way when they came from git; a
169
+ // local-path marketplace is read in place and has nothing to refresh.
170
+ for (const record of readMarketplaceRecords(cwd, agentDir)) {
171
+ const isCachedClone = record.dir.startsWith(marketplaceCacheRoot(agentDir));
172
+ if (!isCachedClone || WELL_KNOWN_MARKETPLACES.some((wk) => wk.url === record.location))
173
+ continue;
174
+ const res = await refreshClone(record.location, record.dir);
175
+ if (res.code !== 0)
176
+ errors.push(`${record.location}: ${(res.stderr || res.stdout).trim()}`);
177
+ else {
178
+ recordFetch(agentDir, record.location);
179
+ refreshed.push(record.location);
180
+ }
181
+ }
182
+ return { refreshed, errors };
183
+ }
85
184
  /**
86
185
  * All marketplace records in effect: the bundled default first (curated,
87
186
  * trusted), then user-added ones from `.agents/` (falling back to legacy
88
187
  * `.hoocode/`). Deduplicated by directory.
89
188
  */
90
- export function readMarketplaceRecords(cwd) {
189
+ export function readMarketplaceRecords(cwd, agentDir = getAgentDir()) {
91
190
  const records = [];
92
191
  const def = defaultMarketplaceRecord();
93
192
  if (existsSync(def.dir))
@@ -95,15 +194,17 @@ export function readMarketplaceRecords(cwd) {
95
194
  // Well-known marketplaces participate once their index is cached locally
96
195
  // (see ensureWellKnownMarketplaces; SearchPlugins fetches lazily).
97
196
  for (const wk of WELL_KNOWN_MARKETPLACES) {
98
- const dir = marketplaceCacheDir(cwd, wk.url);
197
+ const dir = marketplaceCacheDir(wk.url, agentDir);
99
198
  if (existsSync(dir) && !records.some((x) => x.dir === dir)) {
100
199
  records.push({ location: wk.url, dir });
101
200
  }
102
201
  }
103
- const primary = readMarketplaceStore(marketplaceStorePath(cwd));
104
- const user = primary.length > 0 || existsSync(marketplaceStorePath(cwd))
105
- ? primary
106
- : readMarketplaceStore(legacyStorePath(cwd));
202
+ // The registry lives in the agent dir. Project-local ones are a migration
203
+ // read-path: merged in, never written back, so an older setup keeps working.
204
+ const user = [
205
+ ...readMarketplaceStore(marketplaceStorePath(agentDir)),
206
+ ...legacyStorePaths(cwd).flatMap((p) => readMarketplaceStore(p)),
207
+ ];
107
208
  for (const r of user) {
108
209
  if (!records.some((x) => x.dir === r.dir))
109
210
  records.push(r);
@@ -111,10 +212,10 @@ export function readMarketplaceRecords(cwd) {
111
212
  return records;
112
213
  }
113
214
  /** Every plugin offered across all registered marketplaces (first marketplace wins on name clash). */
114
- export function listAvailablePlugins(cwd) {
215
+ export function listAvailablePlugins(cwd, agentDir = getAgentDir()) {
115
216
  const out = [];
116
217
  const seen = new Set();
117
- for (const rec of readMarketplaceRecords(cwd)) {
218
+ for (const rec of readMarketplaceRecords(cwd, agentDir)) {
118
219
  const market = parseMarketplaceDir(rec.dir);
119
220
  if (!market)
120
221
  continue;
@@ -136,8 +237,8 @@ export function listAvailablePlugins(cwd) {
136
237
  return out;
137
238
  }
138
239
  /** Find a single available plugin by exact name. */
139
- export function findAvailablePlugin(cwd, name) {
140
- return listAvailablePlugins(cwd).find((p) => p.name === name);
240
+ export function findAvailablePlugin(cwd, name, agentDir = getAgentDir()) {
241
+ return listAvailablePlugins(cwd, agentDir).find((p) => p.name === name);
141
242
  }
142
243
  /** All currently installed plugins (project + global plugin dirs). */
143
244
  export function listInstalledPlugins(cwd, agentDir = getAgentDir()) {
@@ -203,20 +304,25 @@ async function installGitSubdir(url, subdir, dest, ref, sha) {
203
304
  }
204
305
  }
205
306
  /**
206
- * Install an available plugin by name into `.agents/plugins`. Copies local
207
- * sources; clones git sources. Transparent + reversible by construction — the
208
- * plugin lands in a named directory and {@link uninstallPlugin} removes it.
209
- * Callers activate the result (live activation via AgentSession.activatePlugin,
210
- * or a reload).
307
+ * Install an available plugin by name into the **consumption home** — the global
308
+ * `~/.agents/plugins/`, not the working tree. A plugin is portable and reusable
309
+ * across projects, so installing into the repo would both dirty `git status` and
310
+ * hide the capability from every other checkout.
311
+ *
312
+ * Copies local sources; clones git sources. Transparent + reversible by
313
+ * construction — the plugin lands in a named directory and
314
+ * {@link uninstallPlugin} removes it. Callers activate the result (live
315
+ * activation via AgentSession.activatePlugin, or a reload).
211
316
  */
212
- export async function installAvailablePlugin(cwd, name) {
213
- const found = findAvailablePlugin(cwd, name);
317
+ export async function installAvailablePlugin(cwd, name, agentDir = getAgentDir()) {
318
+ const found = findAvailablePlugin(cwd, name, agentDir);
214
319
  if (!found)
215
320
  return { installed: false, message: `Plugin "${name}" not found in any registered marketplace.` };
216
321
  const resolved = resolvePluginSource(found.source, found.marketplaceRoot);
217
- const dest = path.join(installedPluginsDir(cwd), sanitizeForDir(name));
322
+ const home = consumptionPluginsDir(agentDir);
323
+ const dest = path.join(home, sanitizeForDir(name));
218
324
  rmSync(dest, { recursive: true, force: true });
219
- mkdirSync(installedPluginsDir(cwd), { recursive: true });
325
+ mkdirSync(home, { recursive: true });
220
326
  if (resolved.kind === "local") {
221
327
  if (!existsSync(resolved.path)) {
222
328
  return { installed: false, message: `Plugin source path not found: ${resolved.path}` };
@@ -241,10 +347,13 @@ export async function installAvailablePlugin(cwd, name) {
241
347
  return { installed: false, message: `npm plugin sources are not supported yet (${resolved.spec}).` };
242
348
  }
243
349
  let parsed = parsePluginDir(dest);
244
- if (!parsed) {
350
+ if (!hasAnyManifest(dest)) {
245
351
  // Manifest-less plugin dir (some marketplaces index bare capability trees,
246
- // e.g. a plugin that is just a `skills/` directory): synthesize a native
247
- // manifest from the marketplace entry so the standard loader can carry it.
352
+ // e.g. a plugin that is just a `skills/` directory). Such a directory now
353
+ // *parses* on its own — the vendors make the manifest optional but the
354
+ // entry's name and description live only in the marketplace index, and the
355
+ // derived id would be the sanitized directory name. Synthesize a native
356
+ // manifest so both survive the install.
248
357
  const manifestDir = path.join(dest, ".agents-plugin");
249
358
  mkdirSync(manifestDir, { recursive: true });
250
359
  writeFileSync(path.join(manifestDir, "plugin.json"), `${JSON.stringify({ name: found.name, ...(found.description ? { description: found.description } : {}) }, null, 2)}\n`, "utf8");
@@ -259,14 +368,18 @@ export async function installAvailablePlugin(cwd, name) {
259
368
  dest,
260
369
  supportPlatform: parsed.supportPlatform,
261
370
  message: `Installed "${name}" from marketplace "${found.marketplaceName}" ` +
262
- `(${parsed.supportPlatform.join(", ")}) to ${path.relative(cwd, dest) || dest}. ` +
371
+ `(${parsed.supportPlatform.join(", ")}) to ${dest}. ` +
263
372
  `Remove it with UninstallPlugin.`,
264
373
  };
265
374
  }
266
- /** Remove an installed plugin from `.agents/plugins` (and the legacy `.hoocode/plugins`). */
267
- export function uninstallPlugin(cwd, name) {
375
+ /**
376
+ * Remove an installed or authored plugin from every location it could occupy:
377
+ * the consumption home, both production homes, and the legacy project-local
378
+ * directories that older versions wrote into.
379
+ */
380
+ export function uninstallPlugin(cwd, name, agentDir = getAgentDir()) {
268
381
  const candidates = [
269
- path.join(installedPluginsDir(cwd), sanitizeForDir(name)),
382
+ ...candidatePluginDirs(cwd, name, agentDir),
270
383
  path.join(cwd, ".hoocode", "plugins", sanitizeForDir(name)),
271
384
  ];
272
385
  const present = candidates.filter((p) => existsSync(p));