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.
package/src/cli.mjs CHANGED
@@ -4,15 +4,25 @@ import { analyzeExecution } from "./backlog.mjs";
4
4
  import { renderAnalyzeConfirmation, resolveAutoRunMaxTasks } from "./analyze_confirmation.mjs";
5
5
  import {
6
6
  confirmAutoExecution,
7
+ confirmRepoConflictResolution,
7
8
  renderAnalyzeStopMessage,
8
9
  renderAutoRunSummary,
10
+ renderRepoConflictStopMessage,
9
11
  runDoctor,
12
+ shouldConfirmRepoRebuild,
10
13
  } from "./cli_support.mjs";
11
14
  import { createContext } from "./context.mjs";
12
15
  import { analyzeWorkspace } from "./analyzer.mjs";
16
+ import {
17
+ hasBlockingInputIssues,
18
+ normalizeAnalyzeOptions,
19
+ renderBlockingInputIssueMessage,
20
+ } from "./analyze_user_input.mjs";
13
21
  import { loadBacklog, scaffoldIfMissing } from "./config.mjs";
14
22
  import { resolveRepoRoot } from "./discovery.mjs";
15
- import { installPluginBundle } from "./install.mjs";
23
+ import { createDiscoveryPromptSession, resolveDiscoveryFailureInteractively } from "./discovery_prompt.mjs";
24
+ import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
25
+ import { resetRepoForRebuild } from "./rebuild.mjs";
16
26
  import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
17
27
 
18
28
  const REPO_ROOT_PLACEHOLDER = "<REPO_ROOT>";
@@ -20,6 +30,7 @@ const DOCS_PATH_PLACEHOLDER = "<DOCS_PATH>";
20
30
  const KNOWN_COMMANDS = new Set([
21
31
  "analyze",
22
32
  "install",
33
+ "uninstall",
23
34
  "init",
24
35
  "status",
25
36
  "next",
@@ -42,6 +53,7 @@ function parseArgs(argv) {
42
53
  const options = {
43
54
  requiredDocs: [],
44
55
  constraints: [],
56
+ positionalArgs: [],
45
57
  };
46
58
 
47
59
  for (let index = 0; index < rest.length; index += 1) {
@@ -49,6 +61,7 @@ function parseArgs(argv) {
49
61
  if (arg === "--dry-run") options.dryRun = true;
50
62
  else if (arg === "--yes" || arg === "-y") options.yes = true;
51
63
  else if (arg === "--allow-high-risk") options.allowHighRisk = true;
64
+ else if (arg === "--rebuild-existing") options.rebuildExisting = true;
52
65
  else if (arg === "--force") options.force = true;
53
66
  else if (arg === "--task-id") { options.taskId = rest[index + 1]; index += 1; }
54
67
  else if (arg === "--max-tasks") { options.maxTasks = Number(rest[index + 1]); index += 1; }
@@ -63,10 +76,7 @@ function parseArgs(argv) {
63
76
  else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
64
77
  else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
65
78
  else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
66
- else if (!options.inputPath) { options.inputPath = arg; }
67
- else {
68
- throw new Error(`未知参数:${arg}`);
69
- }
79
+ else { options.positionalArgs.push(arg); }
70
80
  }
71
81
 
72
82
  return { command, options };
@@ -74,11 +84,12 @@ function parseArgs(argv) {
74
84
 
75
85
  function helpText() {
76
86
  return [
77
- "用法:helloloop [command] [path] [options]",
87
+ "用法:helloloop [command] [path|需求说明...] [options]",
78
88
  "",
79
89
  "命令:",
80
90
  " analyze 自动分析并生成执行确认单;确认后继续自动接续开发(默认)",
81
91
  " install 安装插件到 Codex Home(适合 npx / npm bin 分发)",
92
+ " uninstall 从所选宿主卸载插件并清理注册信息",
82
93
  " init 初始化 .helloloop 配置",
83
94
  " status 查看 backlog 与下一任务",
84
95
  " next 生成下一任务干跑预览",
@@ -101,8 +112,13 @@ function helpText() {
101
112
  " --max-attempts <n> 每种策略内最多重试 n 次",
102
113
  " --max-strategies <n> 单任务最多切换 n 种策略继续重试",
103
114
  " --allow-high-risk 允许执行 medium/high/critical 风险任务",
115
+ " --rebuild-existing 分析判断当前项目与文档冲突时,自动清理当前项目后按文档重建",
104
116
  " --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
105
117
  " --constraint <text> 增加一个全局实现约束",
118
+ "",
119
+ "补充说明:",
120
+ " analyze 默认支持在命令后混合传入路径和自然语言要求。",
121
+ " 示例:npx helloloop <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
106
122
  ].join("\n");
107
123
  }
108
124
 
@@ -117,6 +133,7 @@ function renderFollowupExamples() {
117
133
  `npx helloloop <PATH>`,
118
134
  `npx helloloop --dry-run`,
119
135
  `npx helloloop install --host all`,
136
+ `npx helloloop uninstall --host all`,
120
137
  `npx helloloop next`,
121
138
  `如需显式补充路径:npx helloloop --repo ${REPO_ROOT_PLACEHOLDER} --docs ${DOCS_PATH_PLACEHOLDER}`,
122
139
  ].join("\n");
@@ -147,6 +164,29 @@ function renderInstallSummary(result) {
147
164
  return lines.join("\n");
148
165
  }
149
166
 
167
+ function renderUninstallSummary(result) {
168
+ const lines = [
169
+ "HelloLoop 已从以下宿主卸载:",
170
+ ];
171
+
172
+ for (const item of result.uninstalledHosts) {
173
+ lines.push(`- ${item.displayName}:${item.removed ? "已清理" : "未发现现有安装"}`);
174
+ lines.push(` 目标目录:${item.targetRoot}`);
175
+ if (item.marketplaceFile) {
176
+ lines.push(` marketplace:${item.marketplaceFile}`);
177
+ }
178
+ if (item.settingsFile) {
179
+ lines.push(` settings:${item.settingsFile}`);
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ lines.push("如需重新安装:");
185
+ lines.push("- `npx helloloop install --host codex`");
186
+ lines.push("- `npx helloloop install --host all`");
187
+ return lines.join("\n");
188
+ }
189
+
150
190
  function resolveContextFromOptions(options) {
151
191
  const resolvedRepo = resolveRepoRoot({
152
192
  cwd: process.cwd(),
@@ -164,8 +204,88 @@ function resolveContextFromOptions(options) {
164
204
  });
165
205
  }
166
206
 
207
+ async function analyzeWithResolvedDiscovery(options) {
208
+ let currentOptions = { ...options };
209
+ let lastResult = null;
210
+ let promptSession = null;
211
+
212
+ function getPromptSession() {
213
+ if (currentOptions.yes) {
214
+ return null;
215
+ }
216
+ if (!promptSession) {
217
+ promptSession = createDiscoveryPromptSession();
218
+ }
219
+ return promptSession;
220
+ }
221
+
222
+ try {
223
+ for (let attempt = 0; attempt < 3; attempt += 1) {
224
+ lastResult = await analyzeWorkspace({
225
+ cwd: process.cwd(),
226
+ inputPath: currentOptions.inputPath,
227
+ repoRoot: currentOptions.repoRoot,
228
+ docsPath: currentOptions.docsPath,
229
+ configDirName: currentOptions.configDirName,
230
+ allowNewRepoRoot: currentOptions.allowNewRepoRoot,
231
+ selectionSources: currentOptions.selectionSources,
232
+ userIntent: currentOptions.userIntent,
233
+ });
234
+
235
+ if (lastResult.ok) {
236
+ return {
237
+ options: currentOptions,
238
+ result: lastResult,
239
+ };
240
+ }
241
+
242
+ const nextOptions = await resolveDiscoveryFailureInteractively(
243
+ lastResult,
244
+ currentOptions,
245
+ process.cwd(),
246
+ !currentOptions.yes,
247
+ getPromptSession(),
248
+ );
249
+ if (!nextOptions) {
250
+ break;
251
+ }
252
+ currentOptions = nextOptions;
253
+ }
254
+ } finally {
255
+ promptSession?.close();
256
+ }
257
+
258
+ return {
259
+ options: currentOptions,
260
+ result: lastResult,
261
+ };
262
+ }
263
+
264
+ function renderRebuildSummary(resetSummary) {
265
+ return [
266
+ "已按确认结果清理当前项目,并准备按开发文档重新开始。",
267
+ `- 已清理顶层条目:${resetSummary.removedEntries.length ? resetSummary.removedEntries.join(",") : "无"}`,
268
+ `- 已保留开发文档:${resetSummary.preservedDocs.length ? resetSummary.preservedDocs.join(",") : "无"}`,
269
+ `- 重建记录:${resetSummary.manifestFile.replaceAll("\\", "/")}`,
270
+ ].join("\n");
271
+ }
272
+
167
273
  export async function runCli(argv) {
168
- const { command, options } = parseArgs(argv);
274
+ const parsed = parseArgs(argv);
275
+ const command = parsed.command;
276
+ const options = command === "analyze"
277
+ ? normalizeAnalyzeOptions(parsed.options, process.cwd())
278
+ : (() => {
279
+ const nextOptions = { ...parsed.options };
280
+ const positionals = Array.isArray(nextOptions.positionalArgs) ? nextOptions.positionalArgs : [];
281
+ if (positionals.length > 1) {
282
+ throw new Error(`未知参数:${positionals.slice(1).join(" ")}`);
283
+ }
284
+ if (positionals.length === 1 && !nextOptions.inputPath) {
285
+ nextOptions.inputPath = positionals[0];
286
+ }
287
+ return nextOptions;
288
+ })();
169
289
 
170
290
  if (command === "help" || command === "--help" || command === "-h") {
171
291
  printHelp();
@@ -189,28 +309,94 @@ export async function runCli(argv) {
189
309
  return;
190
310
  }
191
311
 
192
- if (command === "analyze") {
193
- const result = await analyzeWorkspace({
194
- cwd: process.cwd(),
195
- inputPath: options.inputPath,
196
- repoRoot: options.repoRoot,
197
- docsPath: options.docsPath,
198
- configDirName: options.configDirName,
312
+ if (command === "uninstall") {
313
+ const result = uninstallPluginBundle({
314
+ host: options.host,
315
+ codexHome: options.codexHome,
316
+ claudeHome: options.claudeHome,
317
+ geminiHome: options.geminiHome,
199
318
  });
319
+ console.log(renderUninstallSummary(result));
320
+ return;
321
+ }
200
322
 
201
- if (!result.ok) {
202
- console.error(result.summary);
323
+ if (command === "analyze") {
324
+ if (hasBlockingInputIssues(options.inputIssues)) {
325
+ console.error(renderBlockingInputIssueMessage(options.inputIssues));
203
326
  process.exitCode = 1;
204
327
  return;
205
328
  }
206
329
 
207
- const confirmationText = renderAnalyzeConfirmation(result.context, result.analysis, result.backlog, options);
208
- const execution = analyzeExecution(result.backlog, options);
330
+ let analyzed = await analyzeWithResolvedDiscovery(options);
331
+ let result = analyzed.result;
332
+ let activeOptions = analyzed.options;
209
333
 
210
- console.log(confirmationText);
211
- console.log("");
334
+ while (true) {
335
+ if (!result.ok) {
336
+ console.error(result.summary);
337
+ process.exitCode = 1;
338
+ return;
339
+ }
212
340
 
213
- if (options.dryRun) {
341
+ const confirmationText = renderAnalyzeConfirmation(
342
+ result.context,
343
+ result.analysis,
344
+ result.backlog,
345
+ activeOptions,
346
+ result.discovery,
347
+ );
348
+ console.log(confirmationText);
349
+ console.log("");
350
+
351
+ if (!shouldConfirmRepoRebuild(result.analysis, result.discovery)) {
352
+ break;
353
+ }
354
+
355
+ if (activeOptions.rebuildExisting) {
356
+ const resetSummary = resetRepoForRebuild(result.context, result.discovery);
357
+ console.log(renderRebuildSummary(resetSummary));
358
+ console.log("");
359
+ analyzed = await analyzeWithResolvedDiscovery({
360
+ ...activeOptions,
361
+ repoRoot: result.context.repoRoot,
362
+ rebuildExisting: false,
363
+ });
364
+ result = analyzed.result;
365
+ activeOptions = analyzed.options;
366
+ continue;
367
+ }
368
+
369
+ if (activeOptions.yes) {
370
+ console.log(renderRepoConflictStopMessage(result.analysis));
371
+ process.exitCode = 1;
372
+ return;
373
+ }
374
+
375
+ const repoConflictDecision = await confirmRepoConflictResolution(result.analysis);
376
+ if (repoConflictDecision === "cancel") {
377
+ console.log("已取消自动执行;分析结果与 backlog 已保留在 .helloloop/。");
378
+ return;
379
+ }
380
+
381
+ if (repoConflictDecision === "continue") {
382
+ break;
383
+ }
384
+
385
+ const resetSummary = resetRepoForRebuild(result.context, result.discovery);
386
+ console.log(renderRebuildSummary(resetSummary));
387
+ console.log("");
388
+ analyzed = await analyzeWithResolvedDiscovery({
389
+ ...activeOptions,
390
+ repoRoot: result.context.repoRoot,
391
+ rebuildExisting: false,
392
+ });
393
+ result = analyzed.result;
394
+ activeOptions = analyzed.options;
395
+ }
396
+
397
+ const execution = analyzeExecution(result.backlog, activeOptions);
398
+
399
+ if (activeOptions.dryRun) {
214
400
  console.log("已按 --dry-run 跳过自动执行。");
215
401
  return;
216
402
  }
@@ -220,7 +406,7 @@ export async function runCli(argv) {
220
406
  return;
221
407
  }
222
408
 
223
- const approved = options.yes ? true : await confirmAutoExecution();
409
+ const approved = activeOptions.yes ? true : await confirmAutoExecution();
224
410
  if (!approved) {
225
411
  console.log("已取消自动执行;分析结果与 backlog 已保留在 .helloloop/。");
226
412
  return;
@@ -229,11 +415,11 @@ export async function runCli(argv) {
229
415
  console.log("");
230
416
  console.log("开始自动接续执行...");
231
417
  const results = await runLoop(result.context, {
232
- ...options,
233
- maxTasks: resolveAutoRunMaxTasks(result.backlog, options),
418
+ ...activeOptions,
419
+ maxTasks: resolveAutoRunMaxTasks(result.backlog, activeOptions),
234
420
  });
235
421
  const refreshedBacklog = loadBacklog(result.context);
236
- console.log(renderAutoRunSummary(result.context, refreshedBacklog, results, options));
422
+ console.log(renderAutoRunSummary(result.context, refreshedBacklog, results, activeOptions));
237
423
  if (results.some((item) => !item.ok)) {
238
424
  process.exitCode = 1;
239
425
  }
@@ -28,34 +28,30 @@ function probeCodexVersion() {
28
28
  };
29
29
  }
30
30
 
31
- export function collectDoctorChecks(context) {
32
- const codexVersion = probeCodexVersion();
31
+ function shouldCheckProjectRuntime(context, options = {}) {
32
+ if (options.repoRoot || options.inputPath) {
33
+ return true;
34
+ }
35
+
36
+ if (context.repoRoot !== context.toolRoot) {
37
+ return true;
38
+ }
39
+
33
40
  return [
41
+ context.backlogFile,
42
+ context.policyFile,
43
+ context.projectFile,
44
+ ].some((filePath) => fileExists(filePath));
45
+ }
46
+
47
+ export function collectDoctorChecks(context, options = {}) {
48
+ const codexVersion = probeCodexVersion();
49
+ const checks = [
34
50
  {
35
51
  name: "codex CLI",
36
52
  ok: codexVersion.ok,
37
53
  detail: codexVersion.detail,
38
54
  },
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
55
  {
60
56
  name: "plugin manifest",
61
57
  ok: fileExists(context.pluginManifestFile),
@@ -72,6 +68,33 @@ export function collectDoctorChecks(context) {
72
68
  detail: context.installScriptFile,
73
69
  },
74
70
  ];
71
+
72
+ if (shouldCheckProjectRuntime(context, options)) {
73
+ checks.splice(1, 0,
74
+ {
75
+ name: "backlog.json",
76
+ ok: fileExists(context.backlogFile),
77
+ detail: context.backlogFile,
78
+ },
79
+ {
80
+ name: "policy.json",
81
+ ok: fileExists(context.policyFile),
82
+ detail: context.policyFile,
83
+ },
84
+ {
85
+ name: "verify.yaml",
86
+ ok: fileExists(context.repoVerifyFile),
87
+ detail: context.repoVerifyFile,
88
+ },
89
+ {
90
+ name: "project.json",
91
+ ok: fileExists(context.projectFile),
92
+ detail: context.projectFile,
93
+ },
94
+ );
95
+ }
96
+
97
+ return checks;
75
98
  }
76
99
 
77
100
  function probeNamedCliVersion(commandName, toolDisplayName) {
@@ -108,7 +131,7 @@ function normalizeDoctorHosts(hostOption) {
108
131
  }
109
132
 
110
133
  function collectCodexDoctorChecks(context, options = {}) {
111
- const checks = collectDoctorChecks(context);
134
+ const checks = collectDoctorChecks(context, options);
112
135
  if (options.codexHome) {
113
136
  checks.push({
114
137
  name: "codex installed plugin",
@@ -151,16 +174,36 @@ function collectClaudeDoctorChecks(context, options = {}) {
151
174
 
152
175
  if (options.claudeHome) {
153
176
  const settingsFile = path.join(options.claudeHome, "settings.json");
177
+ const knownMarketplacesFile = path.join(options.claudeHome, "plugins", "known_marketplaces.json");
178
+ const installedPluginsFile = path.join(options.claudeHome, "plugins", "installed_plugins.json");
154
179
  const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
180
+ const installedPlugins = fileExists(installedPluginsFile) ? readJson(installedPluginsFile) : {};
181
+ const installs = Array.isArray(installedPlugins?.plugins?.["helloloop@helloloop-local"])
182
+ ? installedPlugins.plugins["helloloop@helloloop-local"]
183
+ : [];
184
+ const installedPluginRoot = installs[0]?.installPath
185
+ ? String(installs[0].installPath)
186
+ : path.join(options.claudeHome, "plugins", "cache", "helloloop-local", "helloloop");
187
+
155
188
  checks.push({
156
189
  name: "claude installed marketplace",
157
- ok: fileExists(path.join(options.claudeHome, "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json")),
158
- detail: path.join(options.claudeHome, "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json"),
190
+ ok: fileExists(path.join(options.claudeHome, "plugins", "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json")),
191
+ detail: path.join(options.claudeHome, "plugins", "marketplaces", "helloloop-local", ".claude-plugin", "marketplace.json"),
192
+ });
193
+ checks.push({
194
+ name: "claude marketplace registry",
195
+ ok: fileExists(knownMarketplacesFile),
196
+ detail: knownMarketplacesFile,
197
+ });
198
+ checks.push({
199
+ name: "claude installed plugin index",
200
+ ok: fileExists(installedPluginsFile),
201
+ detail: installedPluginsFile,
159
202
  });
160
203
  checks.push({
161
204
  name: "claude installed plugin",
162
- ok: fileExists(path.join(options.claudeHome, "marketplaces", "helloloop-local", "plugins", "helloloop", ".claude-plugin", "plugin.json")),
163
- detail: path.join(options.claudeHome, "marketplaces", "helloloop-local", "plugins", "helloloop", ".claude-plugin", "plugin.json"),
205
+ ok: fileExists(path.join(installedPluginRoot, ".claude-plugin", "plugin.json")),
206
+ detail: path.join(installedPluginRoot, ".claude-plugin", "plugin.json"),
164
207
  });
165
208
  checks.push({
166
209
  name: "claude settings enabled",
@@ -258,6 +301,50 @@ export async function confirmAutoExecution() {
258
301
  }
259
302
  }
260
303
 
304
+ export function shouldConfirmRepoRebuild(analysis, discovery) {
305
+ return analysis?.repoDecision?.action === "confirm_rebuild"
306
+ && discovery?.resolution?.repo?.exists !== false;
307
+ }
308
+
309
+ export async function confirmRepoConflictResolution(analysis) {
310
+ const decision = analysis?.repoDecision || {};
311
+ const readline = createInterface({
312
+ input: process.stdin,
313
+ output: process.stdout,
314
+ });
315
+
316
+ const promptText = [
317
+ "检测到当前项目与开发文档目标存在明显冲突:",
318
+ `- ${decision.reason || "分析结果认为当前项目更适合先确认处理方式。"}`,
319
+ "请选择后续动作:",
320
+ "1. 继续在当前项目上尝试接续",
321
+ "2. 清理当前项目内容后按文档目标重新开始(推荐)",
322
+ "3. 取消本次执行",
323
+ "请输入 1 / 2 / 3:",
324
+ ].join("\n");
325
+
326
+ try {
327
+ const answer = String(await readline.question(promptText) || "").trim();
328
+ if (["2", "重建", "rebuild"].includes(answer.toLowerCase ? answer.toLowerCase() : answer)) {
329
+ return "rebuild";
330
+ }
331
+ if (["1", "继续", "continue"].includes(answer.toLowerCase ? answer.toLowerCase() : answer)) {
332
+ return "continue";
333
+ }
334
+ return "cancel";
335
+ } finally {
336
+ readline.close();
337
+ }
338
+ }
339
+
340
+ export function renderRepoConflictStopMessage(analysis) {
341
+ return [
342
+ "当前项目与开发文档目标存在明显冲突,已暂停自动执行。",
343
+ analysis?.repoDecision?.reason ? `原因:${analysis.repoDecision.reason}` : "",
344
+ "请重新运行交互式 `npx helloloop` 进行选择,或显式追加 `--rebuild-existing` 后再执行。",
345
+ ].filter(Boolean).join("\n");
346
+ }
347
+
261
348
  export function renderAnalyzeStopMessage(reason) {
262
349
  return reason || "当前没有可自动执行的任务。";
263
350
  }