helloloop 0.2.0 → 0.3.1

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.
@@ -0,0 +1,298 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+
5
+ import {
6
+ listDocFilesInDirectory,
7
+ listProjectCandidatesInDirectory,
8
+ pathExists,
9
+ resolveAbsolute,
10
+ } from "./discovery_paths.mjs";
11
+
12
+ function toDisplayPath(targetPath) {
13
+ return String(targetPath || "").replaceAll("\\", "/");
14
+ }
15
+
16
+ export function createDiscoveryPromptSession() {
17
+ if (process.stdin.isTTY) {
18
+ const readline = createInterface({
19
+ input: process.stdin,
20
+ output: process.stdout,
21
+ });
22
+ return {
23
+ async question(promptText) {
24
+ return readline.question(promptText);
25
+ },
26
+ close() {
27
+ readline.close();
28
+ },
29
+ };
30
+ }
31
+
32
+ const bufferedAnswers = fs.readFileSync(0, "utf8").split(/\r?\n/);
33
+ let answerIndex = 0;
34
+ return {
35
+ async question(promptText) {
36
+ process.stdout.write(promptText);
37
+ const answer = bufferedAnswers[answerIndex] ?? "";
38
+ answerIndex += 1;
39
+ return answer;
40
+ },
41
+ close() {},
42
+ };
43
+ }
44
+
45
+ function summarizeList(items, options = {}) {
46
+ const limit = Number(options.limit || 12);
47
+ if (!items.length) {
48
+ return ["- 无"];
49
+ }
50
+
51
+ const visible = items.slice(0, limit);
52
+ const lines = visible.map((item) => `- ${item}`);
53
+ if (items.length > limit) {
54
+ lines.push(`- 其余 ${items.length - limit} 项未展开`);
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ function collectDirectoryOverview(rootPath) {
60
+ if (!pathExists(rootPath) || !fs.statSync(rootPath).isDirectory()) {
61
+ return {
62
+ rootPath,
63
+ directories: [],
64
+ docFiles: [],
65
+ repoCandidates: [],
66
+ };
67
+ }
68
+
69
+ const entries = fs.readdirSync(rootPath, { withFileTypes: true });
70
+ return {
71
+ rootPath,
72
+ directories: entries
73
+ .filter((entry) => entry.isDirectory())
74
+ .map((entry) => entry.name)
75
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
76
+ docFiles: listDocFilesInDirectory(rootPath)
77
+ .map((filePath) => path.basename(filePath))
78
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
79
+ repoCandidates: listProjectCandidatesInDirectory(rootPath)
80
+ .map((directoryPath) => path.basename(directoryPath))
81
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
82
+ };
83
+ }
84
+
85
+ function renderDirectoryOverview(title, overview) {
86
+ return [
87
+ title,
88
+ `扫描目录:${toDisplayPath(overview.rootPath)}`,
89
+ "",
90
+ "顶层文档文件:",
91
+ ...summarizeList(overview.docFiles),
92
+ "",
93
+ "顶层目录:",
94
+ ...summarizeList(overview.directories),
95
+ "",
96
+ "疑似项目目录:",
97
+ ...summarizeList(overview.repoCandidates),
98
+ ].join("\n");
99
+ }
100
+
101
+ function renderExistingChoices(title, candidates) {
102
+ return [
103
+ title,
104
+ ...candidates.map((item, index) => `${index + 1}. ${toDisplayPath(item)}`),
105
+ "",
106
+ "请输入编号;也可以直接输入本地路径;直接回车取消。",
107
+ ].join("\n");
108
+ }
109
+
110
+ async function promptForExistingPathSelection(readline, title, candidates, cwd, preface = "") {
111
+ if (preface) {
112
+ console.log(preface);
113
+ console.log("");
114
+ }
115
+ console.log(renderExistingChoices(title, candidates));
116
+ while (true) {
117
+ const answer = String(await readline.question("> ") || "").trim();
118
+ if (!answer) {
119
+ return "";
120
+ }
121
+
122
+ const choiceIndex = Number(answer);
123
+ if (Number.isInteger(choiceIndex) && choiceIndex >= 1 && choiceIndex <= candidates.length) {
124
+ return candidates[choiceIndex - 1];
125
+ }
126
+
127
+ const maybePath = resolveAbsolute(answer, cwd);
128
+ if (pathExists(maybePath)) {
129
+ return maybePath;
130
+ }
131
+
132
+ console.log("输入无效,请输入候选编号或一个存在的本地路径。");
133
+ }
134
+ }
135
+
136
+ async function promptForDocsPath(readline, discovery, cwd) {
137
+ const docChoices = Array.isArray(discovery.docCandidates) ? discovery.docCandidates : [];
138
+ const scanRoot = discovery.workspaceRoot || discovery.repoRoot || cwd;
139
+ const overview = collectDirectoryOverview(scanRoot);
140
+ const title = docChoices.length
141
+ ? "请选择开发文档来源:"
142
+ : "未自动识别到明确的开发文档。请输入开发文档目录或文件路径:";
143
+ const preface = renderDirectoryOverview("当前目录顶层概览", overview);
144
+
145
+ if (docChoices.length) {
146
+ return promptForExistingPathSelection(readline, title, docChoices, cwd, preface);
147
+ }
148
+
149
+ console.log(preface);
150
+ console.log("");
151
+ console.log(title);
152
+ console.log("可直接输入当前目录下的相对路径或绝对路径;直接回车取消。");
153
+ while (true) {
154
+ const answer = String(await readline.question("> ") || "").trim();
155
+ if (!answer) {
156
+ return "";
157
+ }
158
+
159
+ const maybePath = resolveAbsolute(answer, cwd);
160
+ if (pathExists(maybePath)) {
161
+ return maybePath;
162
+ }
163
+
164
+ console.log("路径不存在,请输入一个已存在的开发文档目录或文件路径。");
165
+ }
166
+ }
167
+
168
+ async function promptForRepoPath(readline, discovery, cwd) {
169
+ const repoChoices = Array.isArray(discovery.repoCandidates) ? discovery.repoCandidates : [];
170
+ const scanRoot = discovery.workspaceRoot
171
+ || (Array.isArray(discovery.docsEntries) && discovery.docsEntries.length
172
+ ? path.dirname(discovery.docsEntries[0])
173
+ : "")
174
+ || cwd;
175
+ const overview = collectDirectoryOverview(scanRoot);
176
+ const preface = renderDirectoryOverview("当前目录顶层概览", overview);
177
+ const title = repoChoices.length
178
+ ? "请选择目标项目仓库:"
179
+ : "请输入要开发的项目路径:";
180
+ console.log(preface);
181
+ console.log("");
182
+ if (repoChoices.length) {
183
+ console.log(renderExistingChoices(title, repoChoices));
184
+ console.log("也可以直接输入项目路径;如果这是新项目,可输入准备创建的新目录路径。");
185
+ } else {
186
+ console.log(title);
187
+ console.log("如果这是新项目,可直接输入准备创建的新目录路径;直接回车取消。");
188
+ }
189
+
190
+ while (true) {
191
+ const answer = String(await readline.question("> ") || "").trim();
192
+ if (!answer) {
193
+ return { repoRoot: "", allowNewRepoRoot: false };
194
+ }
195
+
196
+ const choiceIndex = Number(answer);
197
+ if (repoChoices.length && Number.isInteger(choiceIndex) && choiceIndex >= 1 && choiceIndex <= repoChoices.length) {
198
+ return {
199
+ repoRoot: repoChoices[choiceIndex - 1],
200
+ allowNewRepoRoot: false,
201
+ };
202
+ }
203
+
204
+ const maybePath = resolveAbsolute(answer, cwd);
205
+ if (pathExists(maybePath) && !fs.statSync(maybePath).isDirectory()) {
206
+ console.log("项目路径必须是目录,不能是文件。");
207
+ continue;
208
+ }
209
+
210
+ return {
211
+ repoRoot: maybePath,
212
+ allowNewRepoRoot: !pathExists(maybePath),
213
+ };
214
+ }
215
+ }
216
+
217
+ export async function resolveDiscoveryFailureInteractively(
218
+ failure,
219
+ options = {},
220
+ cwd = process.cwd(),
221
+ allowPrompt = true,
222
+ sharedPromptSession = null,
223
+ ) {
224
+ const discovery = failure?.discovery || {};
225
+ const nextOptions = {
226
+ ...options,
227
+ selectionSources: {
228
+ ...(options.selectionSources || {}),
229
+ },
230
+ };
231
+ let changed = false;
232
+ const promptSession = sharedPromptSession || (allowPrompt ? createDiscoveryPromptSession() : null);
233
+ const ownsPromptSession = Boolean(promptSession) && !sharedPromptSession;
234
+
235
+ try {
236
+ if (!nextOptions.docsPath) {
237
+ const docChoices = Array.isArray(discovery.docCandidates) ? discovery.docCandidates : [];
238
+ if (Array.isArray(discovery.docsEntries) && discovery.docsEntries.length === 1) {
239
+ nextOptions.docsPath = discovery.docsEntries[0];
240
+ nextOptions.selectionSources.docs = "workspace_single_doc";
241
+ changed = true;
242
+ } else if (docChoices.length === 1) {
243
+ nextOptions.docsPath = docChoices[0];
244
+ nextOptions.selectionSources.docs = "workspace_single_doc";
245
+ changed = true;
246
+ } else if (failure?.code === "missing_docs" || docChoices.length > 1 || discovery.workspaceRoot) {
247
+ if (!allowPrompt) {
248
+ return null;
249
+ }
250
+ const selectedDocs = await promptForDocsPath(promptSession, discovery, cwd);
251
+ if (!selectedDocs) {
252
+ return null;
253
+ }
254
+ nextOptions.docsPath = selectedDocs;
255
+ nextOptions.selectionSources.docs = "interactive";
256
+ changed = true;
257
+ console.log(`已选择开发文档:${toDisplayPath(selectedDocs)}`);
258
+ console.log("");
259
+ }
260
+ }
261
+
262
+ const repoChoices = Array.isArray(discovery.repoCandidates) ? discovery.repoCandidates : [];
263
+ if (!nextOptions.repoRoot && repoChoices.length) {
264
+ if (repoChoices.length === 1) {
265
+ nextOptions.repoRoot = repoChoices[0];
266
+ nextOptions.selectionSources.repo = "workspace_single_repo";
267
+ changed = true;
268
+ }
269
+ }
270
+
271
+ if (!nextOptions.repoRoot && failure?.code === "missing_repo") {
272
+ if (!allowPrompt) {
273
+ return null;
274
+ }
275
+ const selectedRepo = await promptForRepoPath(promptSession, discovery, cwd);
276
+ if (!selectedRepo.repoRoot) {
277
+ return null;
278
+ }
279
+ nextOptions.repoRoot = selectedRepo.repoRoot;
280
+ nextOptions.allowNewRepoRoot = selectedRepo.allowNewRepoRoot;
281
+ nextOptions.selectionSources.repo = selectedRepo.allowNewRepoRoot
282
+ ? "interactive_new_repo"
283
+ : "interactive";
284
+ changed = true;
285
+ if (selectedRepo.allowNewRepoRoot) {
286
+ console.log(`已指定项目路径(当前不存在,将按新项目创建):${toDisplayPath(selectedRepo.repoRoot)}`);
287
+ } else {
288
+ console.log(`已选择项目仓库:${toDisplayPath(selectedRepo.repoRoot)}`);
289
+ }
290
+ console.log("");
291
+ }
292
+ return changed ? nextOptions : null;
293
+ } finally {
294
+ if (ownsPromptSession) {
295
+ promptSession?.close();
296
+ }
297
+ }
298
+ }
package/src/install.mjs CHANGED
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- import { ensureDir, fileExists, readJson, writeJson } from "./common.mjs";
6
+ import { ensureDir, fileExists, nowIso, readJson, writeJson } from "./common.mjs";
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
@@ -28,6 +28,8 @@ const codexBundleEntries = runtimeBundleEntries.filter((entry) => ![
28
28
  ].includes(entry));
29
29
 
30
30
  const supportedHosts = ["codex", "claude", "gemini"];
31
+ const CLAUDE_MARKETPLACE_NAME = "helloloop-local";
32
+ const CLAUDE_PLUGIN_KEY = "helloloop@helloloop-local";
31
33
 
32
34
  function resolveHomeDir(homeDir, defaultDirName) {
33
35
  return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
@@ -50,6 +52,14 @@ function removeTargetIfNeeded(targetPath, force) {
50
52
  fs.rmSync(targetPath, { recursive: true, force: true });
51
53
  }
52
54
 
55
+ function removePathIfExists(targetPath) {
56
+ if (!fileExists(targetPath)) {
57
+ return false;
58
+ }
59
+ fs.rmSync(targetPath, { recursive: true, force: true });
60
+ return true;
61
+ }
62
+
53
63
  function copyBundleEntries(bundleRoot, targetRoot, entries) {
54
64
  for (const entry of entries) {
55
65
  const sourcePath = path.join(bundleRoot, entry);
@@ -110,6 +120,23 @@ function updateCodexMarketplace(marketplaceFile) {
110
120
  writeJson(marketplaceFile, marketplace);
111
121
  }
112
122
 
123
+ function removeCodexMarketplaceEntry(marketplaceFile) {
124
+ if (!fileExists(marketplaceFile)) {
125
+ return false;
126
+ }
127
+
128
+ const marketplace = readJson(marketplaceFile);
129
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
130
+ const nextPlugins = plugins.filter((plugin) => plugin?.name !== "helloloop");
131
+ if (nextPlugins.length === plugins.length) {
132
+ return false;
133
+ }
134
+
135
+ marketplace.plugins = nextPlugins;
136
+ writeJson(marketplaceFile, marketplace);
137
+ return true;
138
+ }
139
+
113
140
  function updateClaudeSettings(settingsFile, marketplaceRoot) {
114
141
  const settings = fileExists(settingsFile)
115
142
  ? readJson(settingsFile)
@@ -118,15 +145,111 @@ function updateClaudeSettings(settingsFile, marketplaceRoot) {
118
145
  settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
119
146
  settings.enabledPlugins = settings.enabledPlugins || {};
120
147
 
121
- settings.extraKnownMarketplaces["helloloop-local"] = {
122
- source: "directory",
123
- path: marketplaceRoot.replaceAll("\\", "/"),
148
+ settings.extraKnownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
149
+ source: {
150
+ source: "directory",
151
+ path: marketplaceRoot,
152
+ },
124
153
  };
125
- settings.enabledPlugins["helloloop@helloloop-local"] = true;
154
+ settings.enabledPlugins[CLAUDE_PLUGIN_KEY] = true;
126
155
 
127
156
  writeJson(settingsFile, settings);
128
157
  }
129
158
 
159
+ function removeClaudeSettingsEntries(settingsFile) {
160
+ if (!fileExists(settingsFile)) {
161
+ return false;
162
+ }
163
+
164
+ const settings = readJson(settingsFile);
165
+ let changed = false;
166
+
167
+ if (settings.extraKnownMarketplaces && Object.hasOwn(settings.extraKnownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
168
+ delete settings.extraKnownMarketplaces[CLAUDE_MARKETPLACE_NAME];
169
+ changed = true;
170
+ }
171
+
172
+ if (settings.enabledPlugins && Object.hasOwn(settings.enabledPlugins, CLAUDE_PLUGIN_KEY)) {
173
+ delete settings.enabledPlugins[CLAUDE_PLUGIN_KEY];
174
+ changed = true;
175
+ }
176
+
177
+ if (changed) {
178
+ writeJson(settingsFile, settings);
179
+ }
180
+ return changed;
181
+ }
182
+
183
+ function updateClaudeKnownMarketplaces(knownMarketplacesFile, marketplaceRoot, updatedAt) {
184
+ const knownMarketplaces = fileExists(knownMarketplacesFile)
185
+ ? readJson(knownMarketplacesFile)
186
+ : {};
187
+
188
+ knownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
189
+ source: {
190
+ source: "directory",
191
+ path: marketplaceRoot,
192
+ },
193
+ installLocation: marketplaceRoot,
194
+ lastUpdated: updatedAt,
195
+ };
196
+
197
+ writeJson(knownMarketplacesFile, knownMarketplaces);
198
+ }
199
+
200
+ function removeClaudeKnownMarketplace(knownMarketplacesFile) {
201
+ if (!fileExists(knownMarketplacesFile)) {
202
+ return false;
203
+ }
204
+
205
+ const knownMarketplaces = readJson(knownMarketplacesFile);
206
+ if (!Object.hasOwn(knownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
207
+ return false;
208
+ }
209
+
210
+ delete knownMarketplaces[CLAUDE_MARKETPLACE_NAME];
211
+ writeJson(knownMarketplacesFile, knownMarketplaces);
212
+ return true;
213
+ }
214
+
215
+ function updateClaudeInstalledPlugins(installedPluginsFile, pluginRoot, pluginVersion, updatedAt) {
216
+ const installedPlugins = fileExists(installedPluginsFile)
217
+ ? readJson(installedPluginsFile)
218
+ : {
219
+ version: 2,
220
+ plugins: {},
221
+ };
222
+
223
+ installedPlugins.version = 2;
224
+ installedPlugins.plugins = installedPlugins.plugins || {};
225
+ installedPlugins.plugins[CLAUDE_PLUGIN_KEY] = [
226
+ {
227
+ scope: "user",
228
+ installPath: pluginRoot,
229
+ version: pluginVersion,
230
+ installedAt: updatedAt,
231
+ lastUpdated: updatedAt,
232
+ },
233
+ ];
234
+
235
+ writeJson(installedPluginsFile, installedPlugins);
236
+ }
237
+
238
+ function removeClaudeInstalledPlugin(installedPluginsFile) {
239
+ if (!fileExists(installedPluginsFile)) {
240
+ return false;
241
+ }
242
+
243
+ const installedPlugins = readJson(installedPluginsFile);
244
+ if (!installedPlugins.plugins || !Object.hasOwn(installedPlugins.plugins, CLAUDE_PLUGIN_KEY)) {
245
+ return false;
246
+ }
247
+
248
+ delete installedPlugins.plugins[CLAUDE_PLUGIN_KEY];
249
+ writeJson(installedPluginsFile, installedPlugins);
250
+ return true;
251
+ }
252
+
130
253
  function installCodexHost(bundleRoot, options) {
131
254
  const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
132
255
  const targetPluginsRoot = path.join(resolvedCodexHome, "plugins");
@@ -161,11 +284,34 @@ function installCodexHost(bundleRoot, options) {
161
284
  };
162
285
  }
163
286
 
287
+ function uninstallCodexHost(options) {
288
+ const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
289
+ const targetPluginRoot = path.join(resolvedCodexHome, "plugins", "helloloop");
290
+ const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
291
+
292
+ const removedPlugin = removePathIfExists(targetPluginRoot);
293
+ const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile);
294
+
295
+ return {
296
+ host: "codex",
297
+ displayName: "Codex",
298
+ targetRoot: targetPluginRoot,
299
+ removed: removedPlugin || removedMarketplace,
300
+ marketplaceFile,
301
+ };
302
+ }
303
+
164
304
  function installClaudeHost(bundleRoot, options) {
165
305
  const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
166
306
  const sourceMarketplaceRoot = path.join(bundleRoot, "hosts", "claude", "marketplace");
167
307
  const sourceManifest = path.join(bundleRoot, ".claude-plugin", "plugin.json");
168
- const targetMarketplaceRoot = path.join(resolvedClaudeHome, "marketplaces", "helloloop-local");
308
+ const pluginVersion = readJson(sourceManifest).version || readJson(path.join(bundleRoot, "package.json")).version;
309
+ const targetPluginsRoot = path.join(resolvedClaudeHome, "plugins");
310
+ const targetMarketplaceRoot = path.join(targetPluginsRoot, "marketplaces", CLAUDE_MARKETPLACE_NAME);
311
+ const targetCachePluginsRoot = path.join(targetPluginsRoot, "cache", CLAUDE_MARKETPLACE_NAME, "helloloop");
312
+ const targetInstalledPluginRoot = path.join(targetCachePluginsRoot, pluginVersion);
313
+ const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
314
+ const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
169
315
  const settingsFile = path.join(resolvedClaudeHome, "settings.json");
170
316
 
171
317
  if (!fileExists(sourceManifest)) {
@@ -176,16 +322,55 @@ function installClaudeHost(bundleRoot, options) {
176
322
  }
177
323
 
178
324
  assertPathInside(resolvedClaudeHome, targetMarketplaceRoot, "Claude marketplace 目录");
325
+ assertPathInside(resolvedClaudeHome, targetInstalledPluginRoot, "Claude 插件缓存目录");
179
326
  removeTargetIfNeeded(targetMarketplaceRoot, options.force);
327
+ removeTargetIfNeeded(targetCachePluginsRoot, options.force);
180
328
 
181
329
  ensureDir(path.dirname(targetMarketplaceRoot));
330
+ ensureDir(path.dirname(targetInstalledPluginRoot));
182
331
  copyDirectory(sourceMarketplaceRoot, targetMarketplaceRoot);
332
+ copyDirectory(path.join(sourceMarketplaceRoot, "plugins", "helloloop"), targetInstalledPluginRoot);
333
+ ensureDir(targetPluginsRoot);
334
+ const updatedAt = nowIso();
183
335
  updateClaudeSettings(settingsFile, targetMarketplaceRoot);
336
+ updateClaudeKnownMarketplaces(knownMarketplacesFile, targetMarketplaceRoot, updatedAt);
337
+ updateClaudeInstalledPlugins(installedPluginsFile, targetInstalledPluginRoot, pluginVersion, updatedAt);
338
+
339
+ return {
340
+ host: "claude",
341
+ displayName: "Claude",
342
+ targetRoot: targetInstalledPluginRoot,
343
+ marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
344
+ settingsFile,
345
+ };
346
+ }
347
+
348
+ function uninstallClaudeHost(options) {
349
+ const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
350
+ const targetPluginsRoot = path.join(resolvedClaudeHome, "plugins");
351
+ const targetMarketplaceRoot = path.join(targetPluginsRoot, "marketplaces", CLAUDE_MARKETPLACE_NAME);
352
+ const targetCachePluginsRoot = path.join(targetPluginsRoot, "cache", CLAUDE_MARKETPLACE_NAME);
353
+ const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
354
+ const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
355
+ const settingsFile = path.join(resolvedClaudeHome, "settings.json");
356
+
357
+ const removedMarketplaceDir = removePathIfExists(targetMarketplaceRoot);
358
+ const removedCacheDir = removePathIfExists(targetCachePluginsRoot);
359
+ const removedKnownMarketplace = removeClaudeKnownMarketplace(knownMarketplacesFile);
360
+ const removedInstalledPlugin = removeClaudeInstalledPlugin(installedPluginsFile);
361
+ const removedSettingsEntries = removeClaudeSettingsEntries(settingsFile);
184
362
 
185
363
  return {
186
364
  host: "claude",
187
365
  displayName: "Claude",
188
- targetRoot: path.join(targetMarketplaceRoot, "plugins", "helloloop"),
366
+ targetRoot: targetCachePluginsRoot,
367
+ removed: [
368
+ removedMarketplaceDir,
369
+ removedCacheDir,
370
+ removedKnownMarketplace,
371
+ removedInstalledPlugin,
372
+ removedSettingsEntries,
373
+ ].some(Boolean),
189
374
  marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
190
375
  settingsFile,
191
376
  };
@@ -213,6 +398,18 @@ function installGeminiHost(bundleRoot, options) {
213
398
  };
214
399
  }
215
400
 
401
+ function uninstallGeminiHost(options) {
402
+ const resolvedGeminiHome = resolveHomeDir(options.geminiHome, ".gemini");
403
+ const targetExtensionRoot = path.join(resolvedGeminiHome, "extensions", "helloloop");
404
+
405
+ return {
406
+ host: "gemini",
407
+ displayName: "Gemini",
408
+ targetRoot: targetExtensionRoot,
409
+ removed: removePathIfExists(targetExtensionRoot),
410
+ };
411
+ }
412
+
216
413
  function resolveInstallHosts(hostOption) {
217
414
  const normalized = String(hostOption || "codex").trim().toLowerCase();
218
415
  if (normalized === "all") {
@@ -242,3 +439,17 @@ export function installPluginBundle(options = {}) {
242
439
  marketplaceFile: codexResult?.marketplaceFile || "",
243
440
  };
244
441
  }
442
+
443
+ export function uninstallPluginBundle(options = {}) {
444
+ const selectedHosts = resolveInstallHosts(options.host);
445
+ const uninstallers = {
446
+ codex: () => uninstallCodexHost(options),
447
+ claude: () => uninstallClaudeHost(options),
448
+ gemini: () => uninstallGeminiHost(options),
449
+ };
450
+
451
+ const uninstalledHosts = selectedHosts.map((host) => uninstallers[host]());
452
+ return {
453
+ uninstalledHosts,
454
+ };
455
+ }