one-code-extension 0.1.4 → 0.3.0

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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Plugin install/uninstall against the One Code plugin root.
3
+ *
4
+ * Install materializes the plugin's files into the versioned cache
5
+ * (`./relative` sources are copied out of the marketplace clone after a
6
+ * containment check — manifests are third-party content; git sources are
7
+ * shallow-cloned), then records the `{scope: "user", installPath, version,
8
+ * enabled: true}` entry in our installed_plugins.json. Never touches ~/.claude.
9
+ *
10
+ * Unsupported source kinds and features (npm/pip, commit-sha pinning,
11
+ * dependencies) fail loudly with the reason named.
12
+ */
13
+
14
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, readdirSync, realpathSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join, resolve, relative, sep } from "node:path";
17
+ import { gitClone, gitHeadSha } from "../marketplace/git.ts";
18
+ import type { MarketplaceEntry } from "../marketplace/types.ts";
19
+ import { addInstalledPlugin, installedEntry, removeInstalledPlugin } from "./registry.ts";
20
+ import { pluginCacheDir, versionedCachePath, withinBase } from "./paths.ts";
21
+
22
+ export interface InstallResult {
23
+ id: string;
24
+ installPath: string;
25
+ version: string;
26
+ gitCommitSha?: string;
27
+ }
28
+
29
+ function manifestVersion(pluginDir: string): string | undefined {
30
+ try {
31
+ const manifest = JSON.parse(readFileSync(join(pluginDir, ".claude-plugin", "plugin.json"), "utf-8")) as {
32
+ version?: unknown;
33
+ };
34
+ return typeof manifest.version === "string" ? manifest.version : undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A marketplace plugin may not smuggle files in via symlinks: `SKILL.md →
42
+ * ~/.ssh/id_rsa` would pass the containment check as a link and be copied
43
+ * (or, dereferenced, would copy the key into the plugin cache). Every symlink
44
+ * under `src` must resolve inside `src`; then the copy dereferences so the
45
+ * cache holds plain files (review M13).
46
+ */
47
+ function assertSymlinksContained(src: string): void {
48
+ const root = realpathSync(src);
49
+ const stack = [src];
50
+ while (stack.length > 0) {
51
+ const dir = stack.pop() as string;
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ const path = join(dir, entry.name);
54
+ if (entry.isSymbolicLink()) {
55
+ let target: string;
56
+ try {
57
+ target = realpathSync(path);
58
+ } catch {
59
+ throw new Error(`plugin contains a broken symlink: ${relative(src, path)}`);
60
+ }
61
+ if (target !== root && !target.startsWith(root + sep)) {
62
+ throw new Error(`plugin contains a symlink that points outside the plugin: ${relative(src, path)} → ${target}`);
63
+ }
64
+ } else if (entry.isDirectory()) {
65
+ stack.push(path);
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ function copyIntoCache(src: string, dest: string): void {
72
+ assertSymlinksContained(src);
73
+ rmSync(dest, { recursive: true, force: true });
74
+ mkdirSync(join(dest, ".."), { recursive: true });
75
+ cpSync(src, dest, { recursive: true, dereference: true });
76
+ }
77
+
78
+ export async function installPlugin(
79
+ root: string,
80
+ marketplaceName: string,
81
+ entry: MarketplaceEntry,
82
+ contentRoot: string | undefined,
83
+ ): Promise<InstallResult> {
84
+ const id = `${entry.name}@${marketplaceName}`;
85
+ const raw = entry.raw;
86
+ if (raw.dependencies !== undefined) {
87
+ throw new Error(`${id}: plugin dependencies are not supported yet — install the dependencies manually first`);
88
+ }
89
+
90
+ let sourceDir: string;
91
+ let cleanup: string | undefined;
92
+ let gitCommitSha: string | undefined;
93
+
94
+ const source = entry.source;
95
+ if (typeof source === "string") {
96
+ if (!contentRoot) throw new Error(`${id}: the marketplace's content is not available locally (refresh it first)`);
97
+ if (!withinBase(contentRoot, source)) {
98
+ throw new Error(`${id}: source path "${source}" escapes the marketplace directory — refusing to install`);
99
+ }
100
+ sourceDir = resolve(contentRoot, source);
101
+ if (!existsSync(sourceDir)) throw new Error(`${id}: source path "${source}" does not exist in the marketplace`);
102
+ } else if (source.source === "github" || source.source === "url" || source.source === "git-subdir") {
103
+ if (source.sha) {
104
+ throw new Error(`${id}: commit-sha pinning is not supported yet — use a ref (branch/tag) instead`);
105
+ }
106
+ const url = source.source === "github" ? `https://github.com/${source.repo}.git` : source.url;
107
+ const temp = mkdtempSync(join(tmpdir(), "one-code-plugin-"));
108
+ cleanup = temp;
109
+ const cloneDir = join(temp, "clone");
110
+ await gitClone(url, cloneDir, {
111
+ ref: source.ref,
112
+ subdir: source.source === "git-subdir" ? source.path : undefined,
113
+ });
114
+ gitCommitSha = await gitHeadSha(cloneDir);
115
+ sourceDir = source.source === "git-subdir" ? join(cloneDir, source.path) : cloneDir;
116
+ if (source.source === "git-subdir" && !withinBase(cloneDir, source.path)) {
117
+ rmSync(temp, { recursive: true, force: true });
118
+ throw new Error(`${id}: git-subdir path "${source.path}" escapes the repository — refusing to install`);
119
+ }
120
+ if (!existsSync(sourceDir)) {
121
+ rmSync(temp, { recursive: true, force: true });
122
+ throw new Error(`${id}: "${source.source === "git-subdir" ? source.path : url}" has no content at the expected path`);
123
+ }
124
+ } else {
125
+ throw new Error(`${id}: source kind "${(source as { source: string }).source}" is not supported`);
126
+ }
127
+
128
+ try {
129
+ const version = entry.version ?? manifestVersion(sourceDir) ?? (gitCommitSha ? gitCommitSha.slice(0, 12) : "0.0.0");
130
+ const installPath = versionedCachePath(root, marketplaceName, entry.name, version);
131
+ // Defence in depth against a crafted version/name that escapes its own
132
+ // cache subdirectory (review M4): copyIntoCache would rmSync the resolved
133
+ // dest, so a path landing on the plugin or marketplace directory would
134
+ // delete siblings. sanitizePathSegment already neutralises dots-only
135
+ // segments; assert containment before the destructive copy regardless.
136
+ const pluginCacheBase = pluginCacheDir(root, marketplaceName, entry.name);
137
+ if (!withinBase(pluginCacheBase, installPath)) {
138
+ throw new Error(`${id}: computed install path "${installPath}" escapes the plugin cache directory — refusing to install`);
139
+ }
140
+ copyIntoCache(sourceDir, installPath);
141
+ const now = new Date().toISOString();
142
+ addInstalledPlugin(root, id, {
143
+ scope: "user",
144
+ installPath,
145
+ version,
146
+ installedAt: now,
147
+ lastUpdated: now,
148
+ gitCommitSha,
149
+ enabled: true,
150
+ });
151
+ return { id, installPath, version, gitCommitSha };
152
+ } finally {
153
+ if (cleanup) rmSync(cleanup, { recursive: true, force: true });
154
+ }
155
+ }
156
+
157
+ /** Remove the registry entry and the cached files. Keeps `data/` (plugin-owned state). */
158
+ export function uninstallPlugin(root: string, id: string): void {
159
+ const entry = installedEntry(root, id);
160
+ removeInstalledPlugin(root, id);
161
+ // Only delete what lives inside OUR cache — never an install path that
162
+ // points elsewhere (hand-edited registries, claude-origin paths).
163
+ const cacheDir = `${resolve(join(root, "cache"))}/`;
164
+ if (entry?.installPath && resolve(entry.installPath).startsWith(cacheDir)) {
165
+ rmSync(entry.installPath, { recursive: true, force: true });
166
+ }
167
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Plugin cache path construction + containment checks (pure).
3
+ *
4
+ * Installed plugin files live in `<root>/cache/<marketplace>/<plugin>/<version>/`
5
+ * with every segment sanitized — marketplace manifests are third-party content,
6
+ * so entry names must never be able to place files outside the cache, and
7
+ * `./relative` plugin sources must never read outside their marketplace clone.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { pathWithinBase, sanitizePathSegment } from "../../lib/plugin-root.ts";
12
+
13
+ /** The directory holding every version of one plugin: `<root>/cache/<marketplace>/<plugin>`. */
14
+ export function pluginCacheDir(root: string, marketplace: string, plugin: string): string {
15
+ return join(root, "cache", sanitizePathSegment(marketplace), sanitizePathSegment(plugin));
16
+ }
17
+
18
+ export function versionedCachePath(root: string, marketplace: string, plugin: string, version: string): string {
19
+ return join(pluginCacheDir(root, marketplace, plugin), sanitizePathSegment(version, true));
20
+ }
21
+
22
+ /** True when `target` resolves inside `base` (rejects .. escapes and absolute overrides). */
23
+ export const withinBase = pathWithinBase;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * installed_plugins.json under the One Code plugin root (pure fs).
3
+ *
4
+ * Claude Code's v2 format, plus an `enabled` field on our entries (Claude Code
5
+ * keeps enabled state in settings.json; in our own root the entry is the
6
+ * natural home). Unknown keys round-trip untouched.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { readJsonFile, writeJsonAtomic } from "../../lib/atomic-write.ts";
11
+
12
+ export interface InstalledEntry {
13
+ scope: "user";
14
+ installPath: string;
15
+ version?: string;
16
+ installedAt?: string;
17
+ lastUpdated?: string;
18
+ gitCommitSha?: string;
19
+ enabled?: boolean;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ interface RegistryFile {
24
+ version?: unknown;
25
+ plugins?: Record<string, unknown>;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ function registryPath(root: string): string {
30
+ return join(root, "installed_plugins.json");
31
+ }
32
+
33
+ function readRegistry(root: string): RegistryFile {
34
+ return readJsonFile<RegistryFile>(registryPath(root)) ?? {};
35
+ }
36
+
37
+ export function installedEntry(root: string, id: string): InstalledEntry | undefined {
38
+ const entries = readRegistry(root).plugins?.[id];
39
+ if (!Array.isArray(entries)) return undefined;
40
+ return entries.find((e) => e && typeof e === "object") as InstalledEntry | undefined;
41
+ }
42
+
43
+ /** Add or replace the plugin's entry (one user-scope entry per id in our root). */
44
+ export function addInstalledPlugin(root: string, id: string, entry: InstalledEntry): void {
45
+ const registry = readRegistry(root);
46
+ writeJsonAtomic(registryPath(root), {
47
+ ...registry,
48
+ version: 2,
49
+ plugins: { ...(registry.plugins ?? {}), [id]: [entry] },
50
+ });
51
+ }
52
+
53
+ export function removeInstalledPlugin(root: string, id: string): void {
54
+ const registry = readRegistry(root);
55
+ if (!registry.plugins || !(id in registry.plugins)) return;
56
+ const { [id]: _removed, ...rest } = registry.plugins;
57
+ writeJsonAtomic(registryPath(root), { ...registry, version: 2, plugins: rest });
58
+ }
59
+
60
+ /** Flip `enabled` on the plugin's entries, preserving everything else. */
61
+ export function setInstalledEnabled(root: string, id: string, enabled: boolean): boolean {
62
+ const registry = readRegistry(root);
63
+ const entries = registry.plugins?.[id];
64
+ if (!Array.isArray(entries) || entries.length === 0) return false;
65
+ const updated = entries.map((e) => (e && typeof e === "object" ? { ...(e as InstalledEntry), enabled } : e));
66
+ writeJsonAtomic(registryPath(root), {
67
+ ...registry,
68
+ version: 2,
69
+ plugins: { ...(registry.plugins ?? {}), [id]: updated },
70
+ });
71
+ return true;
72
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Non-interactive git operations for marketplace/plugin fetching.
3
+ *
4
+ * Every call is prompt-proof (GIT_TERMINAL_PROMPT=0, empty GIT_ASKPASS,
5
+ * BatchMode SSH with strict host checking) and time-bounded. Clones land in a
6
+ * temp sibling and are renamed into place on success, so a failed or timed-out
7
+ * clone never leaves a partial directory behind.
8
+ */
9
+
10
+ import { execFile } from "node:child_process";
11
+ import { mkdirSync, renameSync, rmSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { promisify } from "node:util";
14
+
15
+ const run = promisify(execFile);
16
+
17
+ export const GIT_TIMEOUT_MS = 120_000;
18
+
19
+ const GIT_BASE_ARGS = ["-c", 'core.sshCommand=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes'];
20
+
21
+ function gitEnv(): NodeJS.ProcessEnv {
22
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "" };
23
+ }
24
+
25
+ async function git(args: string[], cwd: string | undefined, timeoutMs: number): Promise<string> {
26
+ try {
27
+ const { stdout } = await run("git", [...GIT_BASE_ARGS, ...args], {
28
+ cwd,
29
+ env: gitEnv(),
30
+ timeout: timeoutMs,
31
+ maxBuffer: 10 * 1024 * 1024,
32
+ });
33
+ return stdout.trim();
34
+ } catch (error) {
35
+ const detail = error as { stderr?: string; message?: string; code?: string };
36
+ if (detail.code === "ENOENT") throw new Error("git is not installed (or not on PATH)");
37
+ const stderr = (detail.stderr ?? "").trim().split("\n").slice(-3).join(" ");
38
+ throw new Error(stderr || detail.message || "git failed");
39
+ }
40
+ }
41
+
42
+ export interface GitCloneOptions {
43
+ ref?: string;
44
+ /** Sparse checkout of just this subdirectory (partial clone). */
45
+ subdir?: string;
46
+ timeoutMs?: number;
47
+ }
48
+
49
+ /**
50
+ * URLs and refs come from third-party marketplace manifests; one starting
51
+ * with "-" would be parsed by git as an option (e.g. --upload-pack=…), not a
52
+ * repository — classic argument injection even though execFile is shell-safe.
53
+ */
54
+ function assertSafeGitValue(value: string, what: string): void {
55
+ if (value.startsWith("-")) throw new Error(`refusing ${what} starting with "-": ${value}`);
56
+ }
57
+
58
+ export async function gitClone(url: string, dest: string, options: GitCloneOptions = {}): Promise<void> {
59
+ assertSafeGitValue(url, "a git URL");
60
+ if (options.ref) assertSafeGitValue(options.ref, "a git ref");
61
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUT_MS;
62
+ const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
63
+ mkdirSync(dirname(dest), { recursive: true });
64
+ try {
65
+ const args = ["clone", "--depth", "1"];
66
+ if (options.subdir) args.push("--filter=tree:0", "--no-checkout");
67
+ else args.push("--recurse-submodules", "--shallow-submodules");
68
+ if (options.ref) args.push("--branch", options.ref);
69
+ args.push("--", url, tmp);
70
+ await git(args, undefined, timeoutMs);
71
+ if (options.subdir) {
72
+ await git(["sparse-checkout", "set", "--cone", "--", options.subdir], tmp, timeoutMs);
73
+ await git(["checkout", "HEAD"], tmp, timeoutMs);
74
+ }
75
+ rmSync(dest, { recursive: true, force: true });
76
+ renameSync(tmp, dest);
77
+ } catch (error) {
78
+ rmSync(tmp, { recursive: true, force: true });
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ /** In-place update; the caller falls back to a fresh gitClone when this throws. */
84
+ export async function gitUpdate(dest: string, options: { ref?: string; timeoutMs?: number } = {}): Promise<void> {
85
+ if (options.ref) assertSafeGitValue(options.ref, "a git ref");
86
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUT_MS;
87
+ if (options.ref) {
88
+ await git(["fetch", "--depth", "1", "origin", options.ref], dest, timeoutMs);
89
+ await git(["checkout", options.ref], dest, timeoutMs);
90
+ await git(["reset", "--hard", "FETCH_HEAD"], dest, timeoutMs);
91
+ } else {
92
+ await git(["fetch", "--depth", "1", "origin", "HEAD"], dest, timeoutMs);
93
+ await git(["reset", "--hard", "FETCH_HEAD"], dest, timeoutMs);
94
+ }
95
+ }
96
+
97
+ export async function gitHeadSha(dest: string): Promise<string | undefined> {
98
+ try {
99
+ return await git(["rev-parse", "HEAD"], dest, 10_000);
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The official Claude Code plugin marketplace (Apache-2.0 GitHub repository).
3
+ *
4
+ * Registered lazily on the first /plugins open — never at session start — and
5
+ * refreshed when its clone is older than a day. Content comes from GitHub
6
+ * only; no other endpoints are used.
7
+ */
8
+
9
+ import { readKnownMarketplaces, writeKnownMarketplace } from "./registry.ts";
10
+ import type { KnownMarketplace } from "./types.ts";
11
+
12
+ export const OFFICIAL_MARKETPLACE_NAME = "claude-plugins-official";
13
+
14
+ export const OFFICIAL_MARKETPLACE_SOURCE = { source: "github", repo: "anthropics/claude-plugins-official" } as const;
15
+
16
+ const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
17
+
18
+ /** Register the official marketplace in our root when absent; returns its entry. */
19
+ export function ensureOfficialRegistered(root: string, installLocation: string): KnownMarketplace {
20
+ const existing = readKnownMarketplaces(root)[OFFICIAL_MARKETPLACE_NAME];
21
+ if (existing) return existing;
22
+ const entry: KnownMarketplace = {
23
+ source: OFFICIAL_MARKETPLACE_SOURCE,
24
+ installLocation,
25
+ lastUpdated: new Date(0).toISOString(),
26
+ autoUpdate: true,
27
+ };
28
+ writeKnownMarketplace(root, OFFICIAL_MARKETPLACE_NAME, entry);
29
+ return entry;
30
+ }
31
+
32
+ export function isStale(known: KnownMarketplace, now: Date = new Date()): boolean {
33
+ const last = Date.parse(known.lastUpdated);
34
+ if (Number.isNaN(last)) return true;
35
+ return now.getTime() - last > STALE_AFTER_MS;
36
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * "Add Marketplace" input parsing (pure) — the same shorthand grammar Claude
3
+ * Code accepts:
4
+ *
5
+ * owner/repo[#ref] github
6
+ * git@host:path[.git][#ref] git (SSH)
7
+ * https://…/repo.git / …/_git/… git (explicit clone URL)
8
+ * https://github.com/owner/repo git (forced .git)
9
+ * https://…/marketplace.json (other) url (fetched manifest)
10
+ * ./path | /path | ~/path file (.json) or directory
11
+ */
12
+
13
+ import { existsSync, statSync } from "node:fs";
14
+ import os from "node:os";
15
+ import { basename, isAbsolute, resolve } from "node:path";
16
+ import type { MarketplaceSource } from "./types.ts";
17
+ import { expandTilde } from "../../lib/paths.ts";
18
+
19
+ export interface ParsedMarketplaceInput {
20
+ source: MarketplaceSource;
21
+ suggestedName: string;
22
+ }
23
+
24
+ const SSH_RE = /^([a-zA-Z0-9._-]+@[^:]+:.+?)(?:#(.+))?$/;
25
+ const GITHUB_SHORTHAND_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:#(.+))?$/;
26
+
27
+ function repoBasename(path: string): string {
28
+ return basename(path).replace(/\.git$/, "");
29
+ }
30
+
31
+ export function parseMarketplaceInput(input: string, cwd: string): ParsedMarketplaceInput | { error: string } {
32
+ const trimmed = input.trim();
33
+ if (!trimmed) return { error: "Enter a marketplace source." };
34
+
35
+ const ssh = trimmed.match(SSH_RE);
36
+ if (ssh && !trimmed.startsWith("http")) {
37
+ return {
38
+ source: { source: "git", url: ssh[1], ref: ssh[2] },
39
+ suggestedName: repoBasename(ssh[1].split(":").pop() ?? ssh[1]),
40
+ };
41
+ }
42
+
43
+ if (/^https?:\/\//.test(trimmed)) {
44
+ let url: URL;
45
+ try {
46
+ url = new URL(trimmed);
47
+ } catch {
48
+ return { error: `Not a valid URL: ${trimmed}` };
49
+ }
50
+ const ref = url.hash ? url.hash.slice(1) : undefined;
51
+ url.hash = "";
52
+ const bare = url.toString();
53
+ if (url.pathname.endsWith(".git") || url.pathname.includes("/_git/")) {
54
+ return { source: { source: "git", url: bare, ref }, suggestedName: repoBasename(url.pathname) };
55
+ }
56
+ if (url.hostname === "github.com" || url.hostname === "www.github.com") {
57
+ const parts = url.pathname.split("/").filter(Boolean);
58
+ if (parts.length === 2) {
59
+ return {
60
+ source: { source: "git", url: `https://github.com/${parts[0]}/${parts[1]}.git`, ref },
61
+ suggestedName: parts[1],
62
+ };
63
+ }
64
+ }
65
+ const jsonName = basename(url.pathname).replace(/\.json$/, "").replace(/\.git$/, "");
66
+ return { source: { source: "url", url: bare }, suggestedName: jsonName || url.hostname };
67
+ }
68
+
69
+ const looksLikePath =
70
+ trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed === "~" || trimmed.startsWith("~/") || isAbsolute(trimmed);
71
+ if (looksLikePath) {
72
+ // `~user` is deliberately not a path here: never guess another user's home.
73
+ const expanded = expandTilde(trimmed, os.homedir());
74
+ const path = resolve(cwd, expanded);
75
+ if (!existsSync(path)) return { error: `Path does not exist: ${path}` };
76
+ if (statSync(path).isDirectory()) {
77
+ return { source: { source: "directory", path }, suggestedName: basename(path) };
78
+ }
79
+ if (path.endsWith(".json")) {
80
+ return { source: { source: "file", path }, suggestedName: basename(path, ".json") };
81
+ }
82
+ return { error: `Not a marketplace: ${path} (expected a directory or a marketplace.json file)` };
83
+ }
84
+
85
+ const shorthand = trimmed.match(GITHUB_SHORTHAND_RE);
86
+ if (shorthand && !trimmed.startsWith("@") && !trimmed.includes(":")) {
87
+ return {
88
+ source: { source: "github", repo: `${shorthand[1]}/${shorthand[2]}`, ref: shorthand[3] },
89
+ suggestedName: shorthand[2],
90
+ };
91
+ }
92
+
93
+ return { error: `Could not understand "${trimmed}" — try owner/repo, a git URL, an https marketplace.json URL, or a local path.` };
94
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * known_marketplaces.json under the One Code plugin root (pure fs).
3
+ *
4
+ * `{"<name>": {source, installLocation, lastUpdated, autoUpdate?}}` — the same
5
+ * shape Claude Code keeps, but in our own root; Claude Code's copy is never
6
+ * read or written (a claude-origin plugin's marketplace name already rides in
7
+ * its `name@marketplace` id, so nothing is lost).
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { readJsonFile, writeJsonAtomic } from "../../lib/atomic-write.ts";
12
+ import { sanitizePathSegment } from "../../lib/plugin-root.ts";
13
+ import { type KnownMarketplace, parseKnownMarketplace } from "./types.ts";
14
+
15
+ const FILE = "known_marketplaces.json";
16
+
17
+ export function knownMarketplacesPath(root: string): string {
18
+ return join(root, FILE);
19
+ }
20
+
21
+ export function readKnownMarketplaces(root: string): Record<string, KnownMarketplace> {
22
+ const raw = readJsonFile<Record<string, unknown>>(knownMarketplacesPath(root));
23
+ if (!raw || typeof raw !== "object") return {};
24
+ const marketplaces: Record<string, KnownMarketplace> = {};
25
+ for (const [name, value] of Object.entries(raw)) {
26
+ const parsed = parseKnownMarketplace(value);
27
+ if (parsed) marketplaces[name] = parsed;
28
+ }
29
+ return marketplaces;
30
+ }
31
+
32
+ export function writeKnownMarketplace(root: string, name: string, entry: KnownMarketplace): void {
33
+ const path = knownMarketplacesPath(root);
34
+ const existing = readJsonFile<Record<string, unknown>>(path) ?? {};
35
+ writeJsonAtomic(path, { ...existing, [name]: entry });
36
+ }
37
+
38
+ export function removeKnownMarketplace(root: string, name: string): void {
39
+ const path = knownMarketplacesPath(root);
40
+ const existing = readJsonFile<Record<string, unknown>>(path);
41
+ if (!existing || !(name in existing)) return;
42
+ const { [name]: _removed, ...rest } = existing;
43
+ writeJsonAtomic(path, rest);
44
+ }
45
+
46
+ /**
47
+ * A registry name not yet taken, suffixing -2, -3, … on collision. The name
48
+ * becomes a path segment under marketplaces/ (and is rm'd on removal), so it
49
+ * is sanitized here — the one entry point every registration goes through —
50
+ * or a shorthand like `x/..` would name a marketplace `..`.
51
+ */
52
+ export function availableMarketplaceName(root: string, suggested: string): string {
53
+ const safe = sanitizePathSegment(suggested);
54
+ const taken = new Set(Object.keys(readKnownMarketplaces(root)));
55
+ if (!taken.has(safe)) return safe;
56
+ for (let i = 2; ; i++) {
57
+ const candidate = `${safe}-${i}`;
58
+ if (!taken.has(candidate)) return candidate;
59
+ }
60
+ }