helloloop 0.1.2 → 0.2.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,327 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+
5
+ import { analyzeExecution, summarizeBacklog } from "./backlog.mjs";
6
+ import { fileExists, readJson } from "./common.mjs";
7
+ import { resolveCliInvocation, resolveCodexInvocation } from "./shell_invocation.mjs";
8
+
9
+ function probeCodexVersion() {
10
+ const invocation = resolveCodexInvocation();
11
+ if (invocation.error) {
12
+ return {
13
+ ok: false,
14
+ detail: invocation.error,
15
+ };
16
+ }
17
+
18
+ const codexVersion = spawnSync(invocation.command, [...invocation.argsPrefix, "--version"], {
19
+ encoding: "utf8",
20
+ shell: invocation.shell,
21
+ });
22
+ const ok = codexVersion.status === 0;
23
+ return {
24
+ ok,
25
+ detail: ok
26
+ ? String(codexVersion.stdout || "").trim()
27
+ : String(codexVersion.stderr || codexVersion.error || "无法执行 codex --version").trim(),
28
+ };
29
+ }
30
+
31
+ export function collectDoctorChecks(context) {
32
+ const codexVersion = probeCodexVersion();
33
+ return [
34
+ {
35
+ name: "codex CLI",
36
+ ok: codexVersion.ok,
37
+ detail: codexVersion.detail,
38
+ },
39
+ {
40
+ name: "backlog.json",
41
+ ok: fileExists(context.backlogFile),
42
+ detail: context.backlogFile,
43
+ },
44
+ {
45
+ name: "policy.json",
46
+ ok: fileExists(context.policyFile),
47
+ detail: context.policyFile,
48
+ },
49
+ {
50
+ name: "verify.yaml",
51
+ ok: fileExists(context.repoVerifyFile),
52
+ detail: context.repoVerifyFile,
53
+ },
54
+ {
55
+ name: "project.json",
56
+ ok: fileExists(context.projectFile),
57
+ detail: context.projectFile,
58
+ },
59
+ {
60
+ name: "plugin manifest",
61
+ ok: fileExists(context.pluginManifestFile),
62
+ detail: context.pluginManifestFile,
63
+ },
64
+ {
65
+ name: "plugin skill",
66
+ ok: fileExists(context.skillFile),
67
+ detail: context.skillFile,
68
+ },
69
+ {
70
+ name: "install script",
71
+ ok: fileExists(context.installScriptFile),
72
+ detail: context.installScriptFile,
73
+ },
74
+ ];
75
+ }
76
+
77
+ function probeNamedCliVersion(commandName, toolDisplayName) {
78
+ const invocation = resolveCliInvocation({
79
+ commandName,
80
+ toolDisplayName,
81
+ });
82
+ if (invocation.error) {
83
+ return {
84
+ ok: false,
85
+ detail: invocation.error,
86
+ };
87
+ }
88
+
89
+ const result = spawnSync(invocation.command, [...invocation.argsPrefix, "--version"], {
90
+ encoding: "utf8",
91
+ shell: invocation.shell,
92
+ });
93
+ const ok = result.status === 0;
94
+ return {
95
+ ok,
96
+ detail: ok
97
+ ? String(result.stdout || "").trim()
98
+ : String(result.stderr || result.error || `无法执行 ${commandName} --version`).trim(),
99
+ };
100
+ }
101
+
102
+ function normalizeDoctorHosts(hostOption) {
103
+ const normalized = String(hostOption || "codex").trim().toLowerCase();
104
+ if (normalized === "all") {
105
+ return ["codex", "claude", "gemini"];
106
+ }
107
+ return [normalized];
108
+ }
109
+
110
+ function collectCodexDoctorChecks(context, options = {}) {
111
+ const checks = collectDoctorChecks(context);
112
+ if (options.codexHome) {
113
+ checks.push({
114
+ name: "codex installed plugin",
115
+ ok: fileExists(path.join(options.codexHome, "plugins", "helloloop", ".codex-plugin", "plugin.json")),
116
+ detail: path.join(options.codexHome, "plugins", "helloloop", ".codex-plugin", "plugin.json"),
117
+ });
118
+ checks.push({
119
+ name: "codex marketplace",
120
+ ok: fileExists(path.join(options.codexHome, ".agents", "plugins", "marketplace.json")),
121
+ detail: path.join(options.codexHome, ".agents", "plugins", "marketplace.json"),
122
+ });
123
+ }
124
+ return checks;
125
+ }
126
+
127
+ function collectClaudeDoctorChecks(context, options = {}) {
128
+ const claudeVersion = probeNamedCliVersion("claude", "Claude");
129
+ const checks = [
130
+ {
131
+ name: "claude CLI",
132
+ ok: claudeVersion.ok,
133
+ detail: claudeVersion.detail,
134
+ },
135
+ {
136
+ name: "claude plugin manifest",
137
+ ok: fileExists(path.join(context.bundleRoot, ".claude-plugin", "plugin.json")),
138
+ detail: path.join(context.bundleRoot, ".claude-plugin", "plugin.json"),
139
+ },
140
+ {
141
+ name: "claude marketplace manifest",
142
+ ok: fileExists(path.join(context.bundleRoot, "hosts", "claude", "marketplace", ".claude-plugin", "marketplace.json")),
143
+ detail: path.join(context.bundleRoot, "hosts", "claude", "marketplace", ".claude-plugin", "marketplace.json"),
144
+ },
145
+ {
146
+ name: "claude command",
147
+ ok: fileExists(path.join(context.bundleRoot, "hosts", "claude", "marketplace", "plugins", "helloloop", "commands", "helloloop.md")),
148
+ detail: path.join(context.bundleRoot, "hosts", "claude", "marketplace", "plugins", "helloloop", "commands", "helloloop.md"),
149
+ },
150
+ ];
151
+
152
+ if (options.claudeHome) {
153
+ const settingsFile = path.join(options.claudeHome, "settings.json");
154
+ const knownMarketplacesFile = path.join(options.claudeHome, "plugins", "known_marketplaces.json");
155
+ const installedPluginsFile = path.join(options.claudeHome, "plugins", "installed_plugins.json");
156
+ const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
157
+ const installedPlugins = fileExists(installedPluginsFile) ? readJson(installedPluginsFile) : {};
158
+ const installs = Array.isArray(installedPlugins?.plugins?.["helloloop@helloloop-local"])
159
+ ? installedPlugins.plugins["helloloop@helloloop-local"]
160
+ : [];
161
+ const installedPluginRoot = installs[0]?.installPath
162
+ ? String(installs[0].installPath)
163
+ : path.join(options.claudeHome, "plugins", "cache", "helloloop-local", "helloloop");
164
+
165
+ checks.push({
166
+ name: "claude installed marketplace",
167
+ ok: fileExists(path.join(options.claudeHome, "plugins", "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json")),
168
+ detail: path.join(options.claudeHome, "plugins", "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json"),
169
+ });
170
+ checks.push({
171
+ name: "claude marketplace registry",
172
+ ok: fileExists(knownMarketplacesFile),
173
+ detail: knownMarketplacesFile,
174
+ });
175
+ checks.push({
176
+ name: "claude installed plugin index",
177
+ ok: fileExists(installedPluginsFile),
178
+ detail: installedPluginsFile,
179
+ });
180
+ checks.push({
181
+ name: "claude installed plugin",
182
+ ok: fileExists(path.join(installedPluginRoot, ".claude-plugin", "plugin.json")),
183
+ detail: path.join(installedPluginRoot, ".claude-plugin", "plugin.json"),
184
+ });
185
+ checks.push({
186
+ name: "claude settings enabled",
187
+ ok: settings?.enabledPlugins?.["helloloop@helloloop-local"] === true,
188
+ detail: settingsFile,
189
+ });
190
+ }
191
+
192
+ return checks;
193
+ }
194
+
195
+ function collectGeminiDoctorChecks(context, options = {}) {
196
+ const geminiVersion = probeNamedCliVersion("gemini", "Gemini");
197
+ const checks = [
198
+ {
199
+ name: "gemini CLI",
200
+ ok: geminiVersion.ok,
201
+ detail: geminiVersion.detail,
202
+ },
203
+ {
204
+ name: "gemini extension manifest",
205
+ ok: fileExists(path.join(context.bundleRoot, "hosts", "gemini", "extension", "gemini-extension.json")),
206
+ detail: path.join(context.bundleRoot, "hosts", "gemini", "extension", "gemini-extension.json"),
207
+ },
208
+ {
209
+ name: "gemini command",
210
+ ok: fileExists(path.join(context.bundleRoot, "hosts", "gemini", "extension", "commands", "helloloop.toml")),
211
+ detail: path.join(context.bundleRoot, "hosts", "gemini", "extension", "commands", "helloloop.toml"),
212
+ },
213
+ {
214
+ name: "gemini context file",
215
+ ok: fileExists(path.join(context.bundleRoot, "hosts", "gemini", "extension", "GEMINI.md")),
216
+ detail: path.join(context.bundleRoot, "hosts", "gemini", "extension", "GEMINI.md"),
217
+ },
218
+ ];
219
+
220
+ if (options.geminiHome) {
221
+ checks.push({
222
+ name: "gemini installed extension",
223
+ ok: fileExists(path.join(options.geminiHome, "extensions", "helloloop", "gemini-extension.json")),
224
+ detail: path.join(options.geminiHome, "extensions", "helloloop", "gemini-extension.json"),
225
+ });
226
+ }
227
+
228
+ return checks;
229
+ }
230
+
231
+ export async function runDoctor(context, options = {}) {
232
+ const hosts = normalizeDoctorHosts(options.host);
233
+ const checks = hosts.flatMap((host) => {
234
+ if (host === "codex") return collectCodexDoctorChecks(context, options);
235
+ if (host === "claude") return collectClaudeDoctorChecks(context, options);
236
+ if (host === "gemini") return collectGeminiDoctorChecks(context, options);
237
+ return [];
238
+ });
239
+
240
+ for (const item of checks) {
241
+ console.log(`${item.ok ? "OK" : "FAIL"} ${item.name} ${item.detail}`);
242
+ }
243
+
244
+ if (checks.every((item) => item.ok)) {
245
+ console.log("\nDoctor 结论:当前 HelloLoop 所选宿主与目标仓库已具备基本运行条件。");
246
+ }
247
+
248
+ if (checks.some((item) => !item.ok)) {
249
+ process.exitCode = 1;
250
+ }
251
+ }
252
+
253
+ function isAffirmativeAnswer(answer) {
254
+ const raw = String(answer || "").trim();
255
+ const normalized = raw.toLowerCase();
256
+ return [
257
+ "y",
258
+ "yes",
259
+ "ok",
260
+ "确认",
261
+ "是",
262
+ "继续",
263
+ "好的",
264
+ ].includes(normalized) || ["确认", "是", "继续", "好的"].includes(raw);
265
+ }
266
+
267
+ export async function confirmAutoExecution() {
268
+ const readline = createInterface({
269
+ input: process.stdin,
270
+ output: process.stdout,
271
+ });
272
+
273
+ try {
274
+ const answer = await readline.question("是否开始自动接续执行?输入 y / yes / 确认 继续,其它任意输入取消:");
275
+ return isAffirmativeAnswer(answer);
276
+ } finally {
277
+ readline.close();
278
+ }
279
+ }
280
+
281
+ export function renderAnalyzeStopMessage(reason) {
282
+ return reason || "当前没有可自动执行的任务。";
283
+ }
284
+
285
+ export function renderAutoRunSummary(context, backlog, results, options = {}) {
286
+ const summary = summarizeBacklog(backlog);
287
+ const execution = analyzeExecution(backlog, options);
288
+ const lines = [
289
+ "自动执行结果",
290
+ "============",
291
+ ];
292
+
293
+ if (!results.length) {
294
+ lines.push("- 本轮未执行任何任务");
295
+ } else {
296
+ for (const item of results) {
297
+ if (!item.task) {
298
+ lines.push(`- 已停止:${item.summary || item.kind || "没有更多任务"}`);
299
+ continue;
300
+ }
301
+ lines.push(`- ${item.ok ? "完成任务" : "失败任务"}:${item.task.title}`);
302
+ if (!item.ok && item.summary) {
303
+ lines.push(item.summary);
304
+ }
305
+ }
306
+ }
307
+
308
+ lines.push("");
309
+ lines.push("当前统计:");
310
+ lines.push(`- 已完成:${summary.done}`);
311
+ lines.push(`- 待处理:${summary.pending}`);
312
+ lines.push(`- 进行中:${summary.inProgress}`);
313
+ lines.push(`- 阻塞:${summary.blocked}`);
314
+ lines.push(`- 失败:${summary.failed}`);
315
+ lines.push(`- 状态文件:${context.statusFile.replaceAll("\\", "/")}`);
316
+ lines.push("");
317
+ lines.push("结论:");
318
+ if (execution.state === "done") {
319
+ lines.push("- backlog 已全部完成");
320
+ } else if (execution.blockedReason) {
321
+ lines.push(`- 当前停止原因:${execution.blockedReason}`);
322
+ } else {
323
+ lines.push("- 当前轮次已结束,可继续查看 status 或下一任务");
324
+ }
325
+
326
+ return lines.join("\n");
327
+ }
package/src/install.mjs CHANGED
@@ -3,16 +3,18 @@ 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);
10
10
 
11
11
  export const runtimeBundleEntries = [
12
+ ".claude-plugin",
12
13
  ".codex-plugin",
13
14
  "LICENSE",
14
15
  "README.md",
15
16
  "bin",
17
+ "hosts",
16
18
  "package.json",
17
19
  "scripts",
18
20
  "skills",
@@ -20,8 +22,17 @@ export const runtimeBundleEntries = [
20
22
  "templates",
21
23
  ];
22
24
 
23
- function resolveCodexHome(codexHome) {
24
- return path.resolve(codexHome || path.join(os.homedir(), ".codex"));
25
+ const codexBundleEntries = runtimeBundleEntries.filter((entry) => ![
26
+ ".claude-plugin",
27
+ "hosts",
28
+ ].includes(entry));
29
+
30
+ const supportedHosts = ["codex", "claude", "gemini"];
31
+ const CLAUDE_MARKETPLACE_NAME = "helloloop-local";
32
+ const CLAUDE_PLUGIN_KEY = "helloloop@helloloop-local";
33
+
34
+ function resolveHomeDir(homeDir, defaultDirName) {
35
+ return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
25
36
  }
26
37
 
27
38
  function assertPathInside(parentDir, targetDir, label) {
@@ -31,21 +42,38 @@ function assertPathInside(parentDir, targetDir, label) {
31
42
  }
32
43
  }
33
44
 
34
- function copyBundleEntries(bundleRoot, targetPluginRoot) {
35
- for (const entry of runtimeBundleEntries) {
45
+ function removeTargetIfNeeded(targetPath, force) {
46
+ if (!fileExists(targetPath)) {
47
+ return;
48
+ }
49
+ if (!force) {
50
+ throw new Error(`目标目录已存在:${targetPath}。若要覆盖,请追加 --force。`);
51
+ }
52
+ fs.rmSync(targetPath, { recursive: true, force: true });
53
+ }
54
+
55
+ function copyBundleEntries(bundleRoot, targetRoot, entries) {
56
+ for (const entry of entries) {
36
57
  const sourcePath = path.join(bundleRoot, entry);
37
58
  if (!fileExists(sourcePath)) {
38
59
  continue;
39
60
  }
40
61
 
41
- fs.cpSync(sourcePath, path.join(targetPluginRoot, entry), {
62
+ fs.cpSync(sourcePath, path.join(targetRoot, entry), {
42
63
  force: true,
43
64
  recursive: true,
44
65
  });
45
66
  }
46
67
  }
47
68
 
48
- function updateMarketplace(marketplaceFile) {
69
+ function copyDirectory(sourceRoot, targetRoot) {
70
+ fs.cpSync(sourceRoot, targetRoot, {
71
+ force: true,
72
+ recursive: true,
73
+ });
74
+ }
75
+
76
+ function updateCodexMarketplace(marketplaceFile) {
49
77
  const marketplace = fileExists(marketplaceFile)
50
78
  ? readJson(marketplaceFile)
51
79
  : {
@@ -84,30 +112,82 @@ function updateMarketplace(marketplaceFile) {
84
112
  writeJson(marketplaceFile, marketplace);
85
113
  }
86
114
 
87
- export function installPluginBundle(options = {}) {
88
- const bundleRoot = path.resolve(options.bundleRoot || path.join(__dirname, ".."));
89
- const resolvedCodexHome = resolveCodexHome(options.codexHome);
115
+ function updateClaudeSettings(settingsFile, marketplaceRoot) {
116
+ const settings = fileExists(settingsFile)
117
+ ? readJson(settingsFile)
118
+ : {};
119
+
120
+ settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
121
+ settings.enabledPlugins = settings.enabledPlugins || {};
122
+
123
+ settings.extraKnownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
124
+ source: {
125
+ source: "directory",
126
+ path: marketplaceRoot,
127
+ },
128
+ };
129
+ settings.enabledPlugins[CLAUDE_PLUGIN_KEY] = true;
130
+
131
+ writeJson(settingsFile, settings);
132
+ }
133
+
134
+ function updateClaudeKnownMarketplaces(knownMarketplacesFile, marketplaceRoot, updatedAt) {
135
+ const knownMarketplaces = fileExists(knownMarketplacesFile)
136
+ ? readJson(knownMarketplacesFile)
137
+ : {};
138
+
139
+ knownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
140
+ source: {
141
+ source: "directory",
142
+ path: marketplaceRoot,
143
+ },
144
+ installLocation: marketplaceRoot,
145
+ lastUpdated: updatedAt,
146
+ };
147
+
148
+ writeJson(knownMarketplacesFile, knownMarketplaces);
149
+ }
150
+
151
+ function updateClaudeInstalledPlugins(installedPluginsFile, pluginRoot, pluginVersion, updatedAt) {
152
+ const installedPlugins = fileExists(installedPluginsFile)
153
+ ? readJson(installedPluginsFile)
154
+ : {
155
+ version: 2,
156
+ plugins: {},
157
+ };
158
+
159
+ installedPlugins.version = 2;
160
+ installedPlugins.plugins = installedPlugins.plugins || {};
161
+ installedPlugins.plugins[CLAUDE_PLUGIN_KEY] = [
162
+ {
163
+ scope: "user",
164
+ installPath: pluginRoot,
165
+ version: pluginVersion,
166
+ installedAt: updatedAt,
167
+ lastUpdated: updatedAt,
168
+ },
169
+ ];
170
+
171
+ writeJson(installedPluginsFile, installedPlugins);
172
+ }
173
+
174
+ function installCodexHost(bundleRoot, options) {
175
+ const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
90
176
  const targetPluginsRoot = path.join(resolvedCodexHome, "plugins");
91
177
  const targetPluginRoot = path.join(targetPluginsRoot, "helloloop");
92
178
  const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
93
179
  const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
94
180
 
95
181
  if (!fileExists(manifestFile)) {
96
- throw new Error(`未找到插件 manifest:${manifestFile}`);
182
+ throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
97
183
  }
98
184
 
99
- assertPathInside(resolvedCodexHome, targetPluginRoot, "目标插件目录");
100
-
101
- if (fileExists(targetPluginRoot)) {
102
- if (!options.force) {
103
- throw new Error(`目标插件目录已存在:${targetPluginRoot}。若要覆盖,请追加 --force。`);
104
- }
105
- fs.rmSync(targetPluginRoot, { recursive: true, force: true });
106
- }
185
+ assertPathInside(resolvedCodexHome, targetPluginRoot, "Codex 目标插件目录");
186
+ removeTargetIfNeeded(targetPluginRoot, options.force);
107
187
 
108
188
  ensureDir(targetPluginsRoot);
109
189
  ensureDir(targetPluginRoot);
110
- copyBundleEntries(bundleRoot, targetPluginRoot);
190
+ copyBundleEntries(bundleRoot, targetPluginRoot, codexBundleEntries);
111
191
 
112
192
  const gitMetadataPath = path.join(targetPluginRoot, ".git");
113
193
  if (fileExists(gitMetadataPath)) {
@@ -115,10 +195,108 @@ export function installPluginBundle(options = {}) {
115
195
  }
116
196
 
117
197
  ensureDir(path.dirname(marketplaceFile));
118
- updateMarketplace(marketplaceFile);
198
+ updateCodexMarketplace(marketplaceFile);
119
199
 
120
200
  return {
121
- targetPluginRoot,
201
+ host: "codex",
202
+ displayName: "Codex",
203
+ targetRoot: targetPluginRoot,
122
204
  marketplaceFile,
123
205
  };
124
206
  }
207
+
208
+ function installClaudeHost(bundleRoot, options) {
209
+ const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
210
+ const sourceMarketplaceRoot = path.join(bundleRoot, "hosts", "claude", "marketplace");
211
+ const sourceManifest = path.join(bundleRoot, ".claude-plugin", "plugin.json");
212
+ const pluginVersion = readJson(sourceManifest).version || readJson(path.join(bundleRoot, "package.json")).version;
213
+ const targetPluginsRoot = path.join(resolvedClaudeHome, "plugins");
214
+ const targetMarketplaceRoot = path.join(targetPluginsRoot, "marketplaces", CLAUDE_MARKETPLACE_NAME);
215
+ const targetCachePluginsRoot = path.join(targetPluginsRoot, "cache", CLAUDE_MARKETPLACE_NAME, "helloloop");
216
+ const targetInstalledPluginRoot = path.join(targetCachePluginsRoot, pluginVersion);
217
+ const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
218
+ const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
219
+ const settingsFile = path.join(resolvedClaudeHome, "settings.json");
220
+
221
+ if (!fileExists(sourceManifest)) {
222
+ throw new Error(`未找到 Claude 插件 manifest:${sourceManifest}`);
223
+ }
224
+ if (!fileExists(path.join(sourceMarketplaceRoot, ".claude-plugin", "marketplace.json"))) {
225
+ throw new Error(`未找到 Claude marketplace 模板:${sourceMarketplaceRoot}`);
226
+ }
227
+
228
+ assertPathInside(resolvedClaudeHome, targetMarketplaceRoot, "Claude marketplace 目录");
229
+ assertPathInside(resolvedClaudeHome, targetInstalledPluginRoot, "Claude 插件缓存目录");
230
+ removeTargetIfNeeded(targetMarketplaceRoot, options.force);
231
+ removeTargetIfNeeded(targetCachePluginsRoot, options.force);
232
+
233
+ ensureDir(path.dirname(targetMarketplaceRoot));
234
+ ensureDir(path.dirname(targetInstalledPluginRoot));
235
+ copyDirectory(sourceMarketplaceRoot, targetMarketplaceRoot);
236
+ copyDirectory(path.join(sourceMarketplaceRoot, "plugins", "helloloop"), targetInstalledPluginRoot);
237
+ ensureDir(targetPluginsRoot);
238
+ const updatedAt = nowIso();
239
+ updateClaudeSettings(settingsFile, targetMarketplaceRoot);
240
+ updateClaudeKnownMarketplaces(knownMarketplacesFile, targetMarketplaceRoot, updatedAt);
241
+ updateClaudeInstalledPlugins(installedPluginsFile, targetInstalledPluginRoot, pluginVersion, updatedAt);
242
+
243
+ return {
244
+ host: "claude",
245
+ displayName: "Claude",
246
+ targetRoot: targetInstalledPluginRoot,
247
+ marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
248
+ settingsFile,
249
+ };
250
+ }
251
+
252
+ function installGeminiHost(bundleRoot, options) {
253
+ const resolvedGeminiHome = resolveHomeDir(options.geminiHome, ".gemini");
254
+ const sourceExtensionRoot = path.join(bundleRoot, "hosts", "gemini", "extension");
255
+ const targetExtensionRoot = path.join(resolvedGeminiHome, "extensions", "helloloop");
256
+
257
+ if (!fileExists(path.join(sourceExtensionRoot, "gemini-extension.json"))) {
258
+ throw new Error(`未找到 Gemini 扩展清单:${sourceExtensionRoot}`);
259
+ }
260
+
261
+ assertPathInside(resolvedGeminiHome, targetExtensionRoot, "Gemini 扩展目录");
262
+ removeTargetIfNeeded(targetExtensionRoot, options.force);
263
+
264
+ ensureDir(path.dirname(targetExtensionRoot));
265
+ copyDirectory(sourceExtensionRoot, targetExtensionRoot);
266
+
267
+ return {
268
+ host: "gemini",
269
+ displayName: "Gemini",
270
+ targetRoot: targetExtensionRoot,
271
+ };
272
+ }
273
+
274
+ function resolveInstallHosts(hostOption) {
275
+ const normalized = String(hostOption || "codex").trim().toLowerCase();
276
+ if (normalized === "all") {
277
+ return [...supportedHosts];
278
+ }
279
+ if (!supportedHosts.includes(normalized)) {
280
+ throw new Error(`不支持的宿主:${hostOption}。可选值:codex、claude、gemini、all`);
281
+ }
282
+ return [normalized];
283
+ }
284
+
285
+ export function installPluginBundle(options = {}) {
286
+ const bundleRoot = path.resolve(options.bundleRoot || path.join(__dirname, ".."));
287
+ const selectedHosts = resolveInstallHosts(options.host);
288
+ const installers = {
289
+ codex: () => installCodexHost(bundleRoot, options),
290
+ claude: () => installClaudeHost(bundleRoot, options),
291
+ gemini: () => installGeminiHost(bundleRoot, options),
292
+ };
293
+
294
+ const installedHosts = selectedHosts.map((host) => installers[host]());
295
+ const codexResult = installedHosts.find((item) => item.host === "codex");
296
+
297
+ return {
298
+ installedHosts,
299
+ targetPluginRoot: codexResult?.targetRoot || "",
300
+ marketplaceFile: codexResult?.marketplaceFile || "",
301
+ };
302
+ }
@@ -125,7 +125,7 @@ function isCmdLikeExecutable(executable) {
125
125
  return /\.(cmd|bat)$/i.test(String(executable || ""));
126
126
  }
127
127
 
128
- function resolveWindowsCodexExecutable(options = {}) {
128
+ function resolveWindowsNamedExecutable(toolName, options = {}) {
129
129
  const explicitExecutable = String(options.explicitExecutable || "").trim();
130
130
  const findCommandPaths = options.findCommandPaths || ((command) => findWindowsCommandPaths(command));
131
131
 
@@ -133,7 +133,7 @@ function resolveWindowsCodexExecutable(options = {}) {
133
133
  return explicitExecutable;
134
134
  }
135
135
 
136
- const searchOrder = ["codex.ps1", "codex.exe", "codex"];
136
+ const searchOrder = [`${toolName}.ps1`, `${toolName}.exe`, toolName];
137
137
  for (const query of searchOrder) {
138
138
  const safeMatch = findCommandPaths(query).find((candidate) => !isCmdLikeExecutable(candidate));
139
139
  if (safeMatch) {
@@ -160,31 +160,37 @@ export function resolveVerifyShellInvocation(options = {}) {
160
160
  return resolvePosixCommandShell(options);
161
161
  }
162
162
 
163
- export function resolveCodexInvocation(options = {}) {
163
+ export function resolveCliInvocation(options = {}) {
164
164
  const platform = options.platform || process.platform;
165
165
  const explicitExecutable = String(options.explicitExecutable || "").trim();
166
+ const commandName = String(options.commandName || "").trim();
167
+ const toolDisplayName = String(options.toolDisplayName || commandName || "CLI");
168
+
169
+ if (!commandName && !explicitExecutable) {
170
+ return createUnavailableInvocation("未提供 CLI 名称或显式可执行路径。");
171
+ }
166
172
 
167
173
  if (platform !== "win32") {
168
174
  return {
169
- command: explicitExecutable || "codex",
175
+ command: explicitExecutable || commandName,
170
176
  argsPrefix: [],
171
177
  shell: false,
172
178
  };
173
179
  }
174
180
 
175
- const executable = resolveWindowsCodexExecutable({
181
+ const executable = resolveWindowsNamedExecutable(commandName, {
176
182
  explicitExecutable,
177
183
  findCommandPaths: options.findCommandPaths,
178
184
  });
179
185
  if (!executable) {
180
186
  return createUnavailableInvocation(
181
- "未找到可安全执行的 codex 入口。Windows 环境需要 codex.ps1、codex.exe 或其他非 cmd 的可执行入口。",
187
+ `未找到可安全执行的 ${toolDisplayName} 入口。Windows 环境需要 ${commandName}.ps1、${commandName}.exe 或其他非 cmd 的可执行入口。`,
182
188
  );
183
189
  }
184
190
 
185
191
  if (isCmdLikeExecutable(executable)) {
186
192
  return createUnavailableInvocation(
187
- `HelloLoop 在 Windows 已禁止通过 cmd/bat 启动 Codex:${executable}`,
193
+ `HelloLoop 在 Windows 已禁止通过 cmd/bat 启动 ${toolDisplayName}:${executable}`,
188
194
  );
189
195
  }
190
196
 
@@ -209,3 +215,11 @@ export function resolveCodexInvocation(options = {}) {
209
215
  shell: false,
210
216
  };
211
217
  }
218
+
219
+ export function resolveCodexInvocation(options = {}) {
220
+ return resolveCliInvocation({
221
+ ...options,
222
+ commandName: "codex",
223
+ toolDisplayName: "Codex",
224
+ });
225
+ }