helloloop 0.2.1 → 0.6.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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/README.md +297 -272
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +19 -9
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -4
  7. package/hosts/gemini/extension/GEMINI.md +13 -4
  8. package/hosts/gemini/extension/commands/helloloop.toml +19 -8
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/scripts/uninstall-home-plugin.ps1 +25 -0
  12. package/skills/helloloop/SKILL.md +42 -7
  13. package/src/analyze_confirmation.mjs +108 -8
  14. package/src/analyze_prompt.mjs +17 -1
  15. package/src/analyze_user_input.mjs +321 -0
  16. package/src/analyzer.mjs +167 -42
  17. package/src/cli.mjs +34 -308
  18. package/src/cli_analyze_command.mjs +248 -0
  19. package/src/cli_args.mjs +106 -0
  20. package/src/cli_command_handlers.mjs +120 -0
  21. package/src/cli_context.mjs +31 -0
  22. package/src/cli_render.mjs +70 -0
  23. package/src/cli_support.mjs +95 -31
  24. package/src/completion_review.mjs +243 -0
  25. package/src/config.mjs +50 -0
  26. package/src/discovery.mjs +243 -9
  27. package/src/discovery_inference.mjs +62 -18
  28. package/src/discovery_paths.mjs +143 -8
  29. package/src/discovery_prompt.mjs +273 -0
  30. package/src/engine_metadata.mjs +79 -0
  31. package/src/engine_selection.mjs +335 -0
  32. package/src/engine_selection_failure.mjs +51 -0
  33. package/src/engine_selection_messages.mjs +119 -0
  34. package/src/engine_selection_probe.mjs +78 -0
  35. package/src/engine_selection_prompt.mjs +48 -0
  36. package/src/engine_selection_settings.mjs +38 -0
  37. package/src/guardrails.mjs +15 -4
  38. package/src/install.mjs +20 -266
  39. package/src/install_claude.mjs +189 -0
  40. package/src/install_codex.mjs +114 -0
  41. package/src/install_gemini.mjs +43 -0
  42. package/src/install_shared.mjs +90 -0
  43. package/src/process.mjs +482 -39
  44. package/src/prompt.mjs +9 -5
  45. package/src/prompt_session.mjs +40 -0
  46. package/src/rebuild.mjs +116 -0
  47. package/src/runner.mjs +3 -341
  48. package/src/runner_execute_task.mjs +301 -0
  49. package/src/runner_execution_support.mjs +155 -0
  50. package/src/runner_loop.mjs +106 -0
  51. package/src/runner_once.mjs +29 -0
  52. package/src/runner_status.mjs +104 -0
  53. package/src/runtime_recovery.mjs +301 -0
  54. package/src/shell_invocation.mjs +16 -0
  55. package/templates/analysis-output.schema.json +58 -1
  56. package/templates/policy.template.json +27 -0
  57. package/templates/project.template.json +2 -0
  58. package/templates/task-review-output.schema.json +70 -0
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
  import { readJson } from "./common.mjs";
5
5
  import { expandDocumentEntries } from "./doc_loader.mjs";
6
6
  import {
7
+ findPreferredRepoRootFromPath,
7
8
  findRepoRootFromPath,
8
9
  isDocFile,
9
10
  isDocsDirectory,
10
- looksLikeProjectRoot,
11
+ listDocFilesInDirectory,
12
+ listProjectCandidatesInDirectory,
11
13
  normalizeForRepo,
12
14
  pathExists,
13
15
  resolveAbsolute,
@@ -48,7 +50,7 @@ function readDocPreview(docEntries, cwd) {
48
50
  }
49
51
 
50
52
  function normalizePathHint(rawValue) {
51
- const trimmed = String(rawValue || "").trim().replace(/^["'`(<\[]+|[>"'`)\].,;:]+$/g, "");
53
+ const trimmed = String(rawValue || "").trim().replace(/^[:"'`(<\[]+|[>"'`)\].,;::]+$/g, "");
52
54
  if (/^\/[A-Za-z]:[\\/]/.test(trimmed)) {
53
55
  return trimmed.slice(1);
54
56
  }
@@ -59,7 +61,7 @@ function extractPathHintsFromText(text) {
59
61
  const hints = new Set();
60
62
  const normalizedText = String(text || "");
61
63
  const windowsMatches = normalizedText.match(/\/?[A-Za-z]:[\\/][^\s"'`<>()\]]+/g) || [];
62
- const posixMatches = normalizedText.match(/(?:^|[\s("'`])\/[^\s"'`<>()\]]+/g) || [];
64
+ const posixMatches = normalizedText.match(/(?:^|[\s("'`::])\/(?!\/)[^\s"'`<>()\]]+/g) || [];
63
65
 
64
66
  for (const rawMatch of [...windowsMatches, ...posixMatches]) {
65
67
  const normalized = normalizePathHint(rawMatch);
@@ -86,15 +88,52 @@ function extractRepoNameHintsFromText(text) {
86
88
  return [...hints];
87
89
  }
88
90
 
89
- function listNearbyProjectCandidates(searchRoot) {
90
- if (!pathExists(searchRoot) || !fs.statSync(searchRoot).isDirectory()) {
91
+ function formatCandidates(title, candidates) {
92
+ if (!Array.isArray(candidates) || !candidates.length) {
91
93
  return [];
92
94
  }
93
95
 
94
- return fs.readdirSync(searchRoot, { withFileTypes: true })
96
+ return [
97
+ title,
98
+ ...candidates.map((item, index) => `${index + 1}. ${path.basename(item)} — ${item.replaceAll("\\", "/")}`),
99
+ ];
100
+ }
101
+
102
+ export function inspectWorkspaceDirectory(directoryPath) {
103
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
104
+ return {
105
+ docCandidates: [],
106
+ docsEntries: [],
107
+ repoCandidates: [],
108
+ topLevelDirectories: [],
109
+ topLevelDocFiles: [],
110
+ };
111
+ }
112
+
113
+ const topLevelEntries = fs.readdirSync(directoryPath, { withFileTypes: true });
114
+ const namedDocsDirectories = topLevelEntries
115
+ .filter((entry) => (
116
+ entry.isDirectory()
117
+ && ["doc", "docs", "documentation"].includes(entry.name.toLowerCase())
118
+ ))
119
+ .map((entry) => path.join(directoryPath, entry.name))
120
+ .filter((candidate) => isDocsDirectory(candidate));
121
+
122
+ const topLevelDocFiles = listDocFilesInDirectory(directoryPath)
123
+ .filter((candidate) => path.basename(candidate).toLowerCase() !== "agents.md");
124
+ const docCandidates = uniquePaths([...topLevelDocFiles, ...namedDocsDirectories]);
125
+ const topLevelDirectories = topLevelEntries
95
126
  .filter((entry) => entry.isDirectory())
96
- .map((entry) => path.join(searchRoot, entry.name))
97
- .filter((directoryPath) => looksLikeProjectRoot(directoryPath));
127
+ .map((entry) => path.join(directoryPath, entry.name));
128
+
129
+ const repoCandidates = uniquePaths(listProjectCandidatesInDirectory(directoryPath));
130
+ return {
131
+ docCandidates,
132
+ docsEntries: docCandidates.length === 1 ? [docCandidates[0]] : [],
133
+ repoCandidates,
134
+ topLevelDirectories,
135
+ topLevelDocFiles,
136
+ };
98
137
  }
99
138
 
100
139
  export function inferRepoFromDocs(docEntries, cwd) {
@@ -117,7 +156,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
117
156
  .map((item) => resolveAbsolute(item, cwd))
118
157
  .filter((candidate) => pathExists(candidate))
119
158
  .map((candidate) => (fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate)))
120
- .map((candidate) => findRepoRootFromPath(candidate) || (looksLikeProjectRoot(candidate) ? candidate : ""))
159
+ .map((candidate) => findPreferredRepoRootFromPath(candidate))
121
160
  .filter(Boolean);
122
161
 
123
162
  const uniquePathCandidates = uniquePaths(pathHintCandidates);
@@ -136,11 +175,15 @@ export function inferRepoFromDocs(docEntries, cwd) {
136
175
  ? absolutePath
137
176
  : path.dirname(absolutePath);
138
177
  const parent = path.dirname(directory);
139
- return [directory, parent, path.dirname(parent)];
178
+ const roots = [directory, parent];
179
+ if (["doc", "docs", "documentation"].includes(path.basename(directory).toLowerCase())) {
180
+ roots.push(path.dirname(parent));
181
+ }
182
+ return roots;
140
183
  }),
141
184
  );
142
185
  const nearbyCandidates = uniquePaths(
143
- searchRoots.flatMap((searchRoot) => listNearbyProjectCandidates(searchRoot)),
186
+ searchRoots.flatMap((searchRoot) => listProjectCandidatesInDirectory(searchRoot)),
144
187
  );
145
188
  const namedCandidates = nearbyCandidates.filter((directoryPath) => (
146
189
  repoNameHints.includes(path.basename(directoryPath).toLowerCase())
@@ -156,7 +199,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
156
199
 
157
200
  return {
158
201
  repoRoot: "",
159
- candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates]),
202
+ candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates, ...nearbyCandidates]),
160
203
  source: "",
161
204
  };
162
205
  }
@@ -203,18 +246,19 @@ export function inferDocsForRepo(repoRoot, cwd, configDirName) {
203
246
  export function renderMissingRepoMessage(docEntries, candidates) {
204
247
  return [
205
248
  "无法自动确定要开发的项目仓库路径。",
206
- docEntries.length ? `已找到开发文档:${docEntries.join(", ")}` : "",
207
- candidates.length > 1 ? `本地发现多个候选项目:${candidates.map((item) => path.basename(item)).join(", ")}` : "",
208
- "请补充项目仓库路径后重试,例如:",
249
+ docEntries.length ? `已找到开发文档:${docEntries.map((item) => item.replaceAll("\\", "/")).join(",")}` : "",
250
+ ...formatCandidates("候选项目:", candidates),
251
+ "可重新运行 `npx helloloop` 后按提示选择,或显式补充项目路径,例如:",
209
252
  "npx helloloop --repo <PROJECT_ROOT>",
210
253
  ].filter(Boolean).join("\n");
211
254
  }
212
255
 
213
- export function renderMissingDocsMessage(repoRoot) {
256
+ export function renderMissingDocsMessage(repoRoot, candidates = []) {
214
257
  return [
215
258
  "无法自动确定开发文档位置。",
216
- `已找到项目仓库:${repoRoot}`,
217
- "请补充开发文档路径后重试,例如:",
259
+ `已找到项目仓库:${repoRoot.replaceAll("\\", "/")}`,
260
+ ...formatCandidates("候选开发文档:", candidates),
261
+ "可重新运行 `npx helloloop` 后按提示选择,或显式补充开发文档路径,例如:",
218
262
  "npx helloloop --docs <DOCS_PATH>",
219
263
  ].join("\n");
220
264
  }
@@ -36,15 +36,80 @@ const PROJECT_DIR_MARKERS = [
36
36
  "tests",
37
37
  ];
38
38
 
39
- function directoryContainsDocs(directoryPath) {
39
+ const IGNORED_PROJECT_SEGMENTS = new Set([
40
+ ".cache",
41
+ ".git",
42
+ ".next",
43
+ ".nuxt",
44
+ ".pnpm",
45
+ ".turbo",
46
+ ".venv",
47
+ ".yarn",
48
+ "__pycache__",
49
+ "build",
50
+ "cache",
51
+ "coverage",
52
+ "dist",
53
+ "node_modules",
54
+ "out",
55
+ "target",
56
+ "temp",
57
+ "tmp",
58
+ "vendor",
59
+ "venv",
60
+ ]);
61
+
62
+ function listImmediateDirectories(directoryPath) {
40
63
  if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
41
- return false;
64
+ return [];
42
65
  }
43
66
 
44
- const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
45
- return entries.some((entry) => (
46
- entry.isFile() && DOC_FILE_SUFFIX.has(path.extname(entry.name).toLowerCase())
47
- ));
67
+ return fs.readdirSync(directoryPath, { withFileTypes: true })
68
+ .filter((entry) => entry.isDirectory())
69
+ .map((entry) => path.join(directoryPath, entry.name));
70
+ }
71
+
72
+ function hasProjectMarker(directoryPath) {
73
+ return PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
74
+ }
75
+
76
+ function looksLikeStrongProjectRoot(directoryPath) {
77
+ return pathExists(directoryPath)
78
+ && fs.statSync(directoryPath).isDirectory()
79
+ && hasProjectMarker(directoryPath);
80
+ }
81
+
82
+ export function listDocFilesInDirectory(directoryPath) {
83
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
84
+ return [];
85
+ }
86
+
87
+ return fs.readdirSync(directoryPath, { withFileTypes: true })
88
+ .filter((entry) => (
89
+ entry.isFile() && DOC_FILE_SUFFIX.has(path.extname(entry.name).toLowerCase())
90
+ ))
91
+ .map((entry) => path.join(directoryPath, entry.name));
92
+ }
93
+
94
+ function directoryContainsDocs(directoryPath) {
95
+ return listDocFilesInDirectory(directoryPath).length > 0;
96
+ }
97
+
98
+ function hasIgnoredProjectBasename(targetPath) {
99
+ return IGNORED_PROJECT_SEGMENTS.has(path.basename(targetPath).toLowerCase());
100
+ }
101
+
102
+ function choosePreferredCandidate(candidates, directory) {
103
+ return candidates.find((candidate) => {
104
+ const relativeToLeaf = path.relative(candidate, directory);
105
+ return relativeToLeaf
106
+ .split(/[\\/]+/)
107
+ .filter(Boolean)
108
+ .some((segment) => IGNORED_PROJECT_SEGMENTS.has(segment.toLowerCase()));
109
+ })
110
+ || candidates.find((candidate) => !hasIgnoredProjectBasename(candidate))
111
+ || candidates[0]
112
+ || "";
48
113
  }
49
114
 
50
115
  function walkUpDirectories(startPath) {
@@ -86,13 +151,37 @@ export function looksLikeProjectRoot(directoryPath) {
86
151
  return false;
87
152
  }
88
153
 
89
- if (PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)))) {
154
+ if (hasProjectMarker(directoryPath)) {
90
155
  return true;
91
156
  }
92
157
 
93
158
  return PROJECT_DIR_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
94
159
  }
95
160
 
161
+ export function listProjectCandidatesInDirectory(searchRoot) {
162
+ return listImmediateDirectories(searchRoot)
163
+ .filter((directoryPath) => (
164
+ looksLikeProjectRoot(directoryPath) && !hasIgnoredProjectBasename(directoryPath)
165
+ ));
166
+ }
167
+
168
+ export function looksLikeWorkspaceDirectory(directoryPath) {
169
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
170
+ return false;
171
+ }
172
+
173
+ const repoCandidates = listProjectCandidatesInDirectory(directoryPath);
174
+ if (!repoCandidates.length) {
175
+ return false;
176
+ }
177
+
178
+ const hasLooseDocs = directoryContainsDocs(directoryPath)
179
+ || listImmediateDirectories(directoryPath)
180
+ .some((childPath) => DOC_DIR_NAMES.has(path.basename(childPath).toLowerCase()));
181
+
182
+ return repoCandidates.length > 1 || hasLooseDocs;
183
+ }
184
+
96
185
  export function isDocsDirectory(directoryPath) {
97
186
  if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
98
187
  return false;
@@ -103,6 +192,10 @@ export function isDocsDirectory(directoryPath) {
103
192
  return true;
104
193
  }
105
194
 
195
+ if (looksLikeWorkspaceDirectory(directoryPath)) {
196
+ return false;
197
+ }
198
+
106
199
  return directoryContainsDocs(directoryPath) && !looksLikeProjectRoot(directoryPath);
107
200
  }
108
201
 
@@ -124,6 +217,39 @@ export function findRepoRootFromPath(startPath) {
124
217
  return "";
125
218
  }
126
219
 
220
+ export function findPreferredRepoRootFromPath(startPath) {
221
+ if (!pathExists(startPath)) {
222
+ return "";
223
+ }
224
+
225
+ const directory = fs.statSync(startPath).isDirectory()
226
+ ? startPath
227
+ : path.dirname(startPath);
228
+ const strongCandidates = [];
229
+ const weakCandidates = [];
230
+
231
+ for (const current of walkUpDirectories(directory)) {
232
+ if (pathExists(path.join(current, ".git"))) {
233
+ return current;
234
+ }
235
+
236
+ if (looksLikeStrongProjectRoot(current)) {
237
+ strongCandidates.push(current);
238
+ continue;
239
+ }
240
+
241
+ if (looksLikeProjectRoot(current)) {
242
+ weakCandidates.push(current);
243
+ }
244
+ }
245
+
246
+ if (strongCandidates.length) {
247
+ return choosePreferredCandidate(strongCandidates, directory);
248
+ }
249
+
250
+ return choosePreferredCandidate(weakCandidates, directory);
251
+ }
252
+
127
253
  export function normalizeForRepo(repoRoot, targetPath) {
128
254
  const relative = path.relative(repoRoot, targetPath);
129
255
  if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
@@ -159,7 +285,16 @@ export function classifyExplicitPath(inputPath) {
159
285
  }
160
286
 
161
287
  if (fs.statSync(inputPath).isDirectory()) {
162
- return { kind: "repo", absolutePath: inputPath };
288
+ const repoRoot = findRepoRootFromPath(inputPath);
289
+ if (repoRoot) {
290
+ return { kind: "repo", absolutePath: repoRoot };
291
+ }
292
+
293
+ if (looksLikeWorkspaceDirectory(inputPath)) {
294
+ return { kind: "workspace", absolutePath: inputPath };
295
+ }
296
+
297
+ return { kind: "directory", absolutePath: inputPath };
163
298
  }
164
299
 
165
300
  return { kind: "unsupported", absolutePath: inputPath };
@@ -0,0 +1,273 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ listDocFilesInDirectory,
6
+ listProjectCandidatesInDirectory,
7
+ pathExists,
8
+ resolveAbsolute,
9
+ } from "./discovery_paths.mjs";
10
+ import { createPromptSession } from "./prompt_session.mjs";
11
+
12
+ function toDisplayPath(targetPath) {
13
+ return String(targetPath || "").replaceAll("\\", "/");
14
+ }
15
+
16
+ export function createDiscoveryPromptSession() {
17
+ return createPromptSession();
18
+ }
19
+
20
+ function summarizeList(items, options = {}) {
21
+ const limit = Number(options.limit || 12);
22
+ if (!items.length) {
23
+ return ["- 无"];
24
+ }
25
+
26
+ const visible = items.slice(0, limit);
27
+ const lines = visible.map((item) => `- ${item}`);
28
+ if (items.length > limit) {
29
+ lines.push(`- 其余 ${items.length - limit} 项未展开`);
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ function collectDirectoryOverview(rootPath) {
35
+ if (!pathExists(rootPath) || !fs.statSync(rootPath).isDirectory()) {
36
+ return {
37
+ rootPath,
38
+ directories: [],
39
+ docFiles: [],
40
+ repoCandidates: [],
41
+ };
42
+ }
43
+
44
+ const entries = fs.readdirSync(rootPath, { withFileTypes: true });
45
+ return {
46
+ rootPath,
47
+ directories: entries
48
+ .filter((entry) => entry.isDirectory())
49
+ .map((entry) => entry.name)
50
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
51
+ docFiles: listDocFilesInDirectory(rootPath)
52
+ .map((filePath) => path.basename(filePath))
53
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
54
+ repoCandidates: listProjectCandidatesInDirectory(rootPath)
55
+ .map((directoryPath) => path.basename(directoryPath))
56
+ .sort((left, right) => left.localeCompare(right, "zh-CN")),
57
+ };
58
+ }
59
+
60
+ function renderDirectoryOverview(title, overview) {
61
+ return [
62
+ title,
63
+ `扫描目录:${toDisplayPath(overview.rootPath)}`,
64
+ "",
65
+ "顶层文档文件:",
66
+ ...summarizeList(overview.docFiles),
67
+ "",
68
+ "顶层目录:",
69
+ ...summarizeList(overview.directories),
70
+ "",
71
+ "疑似项目目录:",
72
+ ...summarizeList(overview.repoCandidates),
73
+ ].join("\n");
74
+ }
75
+
76
+ function renderExistingChoices(title, candidates) {
77
+ return [
78
+ title,
79
+ ...candidates.map((item, index) => `${index + 1}. ${toDisplayPath(item)}`),
80
+ "",
81
+ "请输入编号;也可以直接输入本地路径;直接回车取消。",
82
+ ].join("\n");
83
+ }
84
+
85
+ async function promptForExistingPathSelection(readline, title, candidates, cwd, preface = "") {
86
+ if (preface) {
87
+ console.log(preface);
88
+ console.log("");
89
+ }
90
+ console.log(renderExistingChoices(title, candidates));
91
+ while (true) {
92
+ const answer = String(await readline.question("> ") || "").trim();
93
+ if (!answer) {
94
+ return "";
95
+ }
96
+
97
+ const choiceIndex = Number(answer);
98
+ if (Number.isInteger(choiceIndex) && choiceIndex >= 1 && choiceIndex <= candidates.length) {
99
+ return candidates[choiceIndex - 1];
100
+ }
101
+
102
+ const maybePath = resolveAbsolute(answer, cwd);
103
+ if (pathExists(maybePath)) {
104
+ return maybePath;
105
+ }
106
+
107
+ console.log("输入无效,请输入候选编号或一个存在的本地路径。");
108
+ }
109
+ }
110
+
111
+ async function promptForDocsPath(readline, discovery, cwd) {
112
+ const docChoices = Array.isArray(discovery.docCandidates) ? discovery.docCandidates : [];
113
+ const scanRoot = discovery.workspaceRoot || discovery.repoRoot || cwd;
114
+ const overview = collectDirectoryOverview(scanRoot);
115
+ const title = docChoices.length
116
+ ? "请选择开发文档来源:"
117
+ : "未自动识别到明确的开发文档。请输入开发文档目录或文件路径:";
118
+ const preface = renderDirectoryOverview("当前目录顶层概览", overview);
119
+
120
+ if (docChoices.length) {
121
+ return promptForExistingPathSelection(readline, title, docChoices, cwd, preface);
122
+ }
123
+
124
+ console.log(preface);
125
+ console.log("");
126
+ console.log(title);
127
+ console.log("可直接输入当前目录下的相对路径或绝对路径;直接回车取消。");
128
+ while (true) {
129
+ const answer = String(await readline.question("> ") || "").trim();
130
+ if (!answer) {
131
+ return "";
132
+ }
133
+
134
+ const maybePath = resolveAbsolute(answer, cwd);
135
+ if (pathExists(maybePath)) {
136
+ return maybePath;
137
+ }
138
+
139
+ console.log("路径不存在,请输入一个已存在的开发文档目录或文件路径。");
140
+ }
141
+ }
142
+
143
+ async function promptForRepoPath(readline, discovery, cwd) {
144
+ const repoChoices = Array.isArray(discovery.repoCandidates) ? discovery.repoCandidates : [];
145
+ const scanRoot = discovery.workspaceRoot
146
+ || (Array.isArray(discovery.docsEntries) && discovery.docsEntries.length
147
+ ? path.dirname(discovery.docsEntries[0])
148
+ : "")
149
+ || cwd;
150
+ const overview = collectDirectoryOverview(scanRoot);
151
+ const preface = renderDirectoryOverview("当前目录顶层概览", overview);
152
+ const title = repoChoices.length
153
+ ? "请选择目标项目仓库:"
154
+ : "请输入要开发的项目路径:";
155
+ console.log(preface);
156
+ console.log("");
157
+ if (repoChoices.length) {
158
+ console.log(renderExistingChoices(title, repoChoices));
159
+ console.log("也可以直接输入项目路径;如果这是新项目,可输入准备创建的新目录路径。");
160
+ } else {
161
+ console.log(title);
162
+ console.log("如果这是新项目,可直接输入准备创建的新目录路径;直接回车取消。");
163
+ }
164
+
165
+ while (true) {
166
+ const answer = String(await readline.question("> ") || "").trim();
167
+ if (!answer) {
168
+ return { repoRoot: "", allowNewRepoRoot: false };
169
+ }
170
+
171
+ const choiceIndex = Number(answer);
172
+ if (repoChoices.length && Number.isInteger(choiceIndex) && choiceIndex >= 1 && choiceIndex <= repoChoices.length) {
173
+ return {
174
+ repoRoot: repoChoices[choiceIndex - 1],
175
+ allowNewRepoRoot: false,
176
+ };
177
+ }
178
+
179
+ const maybePath = resolveAbsolute(answer, cwd);
180
+ if (pathExists(maybePath) && !fs.statSync(maybePath).isDirectory()) {
181
+ console.log("项目路径必须是目录,不能是文件。");
182
+ continue;
183
+ }
184
+
185
+ return {
186
+ repoRoot: maybePath,
187
+ allowNewRepoRoot: !pathExists(maybePath),
188
+ };
189
+ }
190
+ }
191
+
192
+ export async function resolveDiscoveryFailureInteractively(
193
+ failure,
194
+ options = {},
195
+ cwd = process.cwd(),
196
+ allowPrompt = true,
197
+ sharedPromptSession = null,
198
+ ) {
199
+ const discovery = failure?.discovery || {};
200
+ const nextOptions = {
201
+ ...options,
202
+ selectionSources: {
203
+ ...(options.selectionSources || {}),
204
+ },
205
+ };
206
+ let changed = false;
207
+ const promptSession = sharedPromptSession || (allowPrompt ? createDiscoveryPromptSession() : null);
208
+ const ownsPromptSession = Boolean(promptSession) && !sharedPromptSession;
209
+
210
+ try {
211
+ if (!nextOptions.docsPath) {
212
+ const docChoices = Array.isArray(discovery.docCandidates) ? discovery.docCandidates : [];
213
+ if (Array.isArray(discovery.docsEntries) && discovery.docsEntries.length === 1) {
214
+ nextOptions.docsPath = discovery.docsEntries[0];
215
+ nextOptions.selectionSources.docs = "workspace_single_doc";
216
+ changed = true;
217
+ } else if (docChoices.length === 1) {
218
+ nextOptions.docsPath = docChoices[0];
219
+ nextOptions.selectionSources.docs = "workspace_single_doc";
220
+ changed = true;
221
+ } else if (failure?.code === "missing_docs" || docChoices.length > 1 || discovery.workspaceRoot) {
222
+ if (!allowPrompt) {
223
+ return null;
224
+ }
225
+ const selectedDocs = await promptForDocsPath(promptSession, discovery, cwd);
226
+ if (!selectedDocs) {
227
+ return null;
228
+ }
229
+ nextOptions.docsPath = selectedDocs;
230
+ nextOptions.selectionSources.docs = "interactive";
231
+ changed = true;
232
+ console.log(`已选择开发文档:${toDisplayPath(selectedDocs)}`);
233
+ console.log("");
234
+ }
235
+ }
236
+
237
+ const repoChoices = Array.isArray(discovery.repoCandidates) ? discovery.repoCandidates : [];
238
+ if (!nextOptions.repoRoot && repoChoices.length) {
239
+ if (repoChoices.length === 1) {
240
+ nextOptions.repoRoot = repoChoices[0];
241
+ nextOptions.selectionSources.repo = "workspace_single_repo";
242
+ changed = true;
243
+ }
244
+ }
245
+
246
+ if (!nextOptions.repoRoot && failure?.code === "missing_repo") {
247
+ if (!allowPrompt) {
248
+ return null;
249
+ }
250
+ const selectedRepo = await promptForRepoPath(promptSession, discovery, cwd);
251
+ if (!selectedRepo.repoRoot) {
252
+ return null;
253
+ }
254
+ nextOptions.repoRoot = selectedRepo.repoRoot;
255
+ nextOptions.allowNewRepoRoot = selectedRepo.allowNewRepoRoot;
256
+ nextOptions.selectionSources.repo = selectedRepo.allowNewRepoRoot
257
+ ? "interactive_new_repo"
258
+ : "interactive";
259
+ changed = true;
260
+ if (selectedRepo.allowNewRepoRoot) {
261
+ console.log(`已指定项目路径(当前不存在,将按新项目创建):${toDisplayPath(selectedRepo.repoRoot)}`);
262
+ } else {
263
+ console.log(`已选择项目仓库:${toDisplayPath(selectedRepo.repoRoot)}`);
264
+ }
265
+ console.log("");
266
+ }
267
+ return changed ? nextOptions : null;
268
+ } finally {
269
+ if (ownsPromptSession) {
270
+ promptSession?.close();
271
+ }
272
+ }
273
+ }
@@ -0,0 +1,79 @@
1
+ const ENGINE_METADATA = {
2
+ codex: {
3
+ name: "codex",
4
+ displayName: "Codex",
5
+ commandName: "codex",
6
+ hostDisplayName: "Codex CLI",
7
+ },
8
+ claude: {
9
+ name: "claude",
10
+ displayName: "Claude",
11
+ commandName: "claude",
12
+ hostDisplayName: "Claude Code",
13
+ },
14
+ gemini: {
15
+ name: "gemini",
16
+ displayName: "Gemini",
17
+ commandName: "gemini",
18
+ hostDisplayName: "Gemini CLI",
19
+ },
20
+ };
21
+
22
+ const HOST_METADATA = {
23
+ terminal: {
24
+ name: "terminal",
25
+ displayName: "终端",
26
+ },
27
+ codex: {
28
+ name: "codex",
29
+ displayName: "Codex",
30
+ },
31
+ claude: {
32
+ name: "claude",
33
+ displayName: "Claude",
34
+ },
35
+ gemini: {
36
+ name: "gemini",
37
+ displayName: "Gemini",
38
+ },
39
+ };
40
+
41
+ export function listKnownEngines() {
42
+ return Object.keys(ENGINE_METADATA);
43
+ }
44
+
45
+ export function normalizeEngineName(value) {
46
+ const normalized = String(value || "").trim().toLowerCase();
47
+ return ENGINE_METADATA[normalized] ? normalized : "";
48
+ }
49
+
50
+ export function isKnownEngine(value) {
51
+ return Boolean(normalizeEngineName(value));
52
+ }
53
+
54
+ export function getEngineMetadata(engine) {
55
+ return ENGINE_METADATA[normalizeEngineName(engine)] || null;
56
+ }
57
+
58
+ export function getEngineDisplayName(engine) {
59
+ return getEngineMetadata(engine)?.displayName || String(engine || "").trim();
60
+ }
61
+
62
+ export function normalizeHostContext(value) {
63
+ const normalized = String(value || "").trim().toLowerCase();
64
+ if (!normalized) {
65
+ return "terminal";
66
+ }
67
+ if (normalized === "shell") {
68
+ return "terminal";
69
+ }
70
+ return HOST_METADATA[normalized] ? normalized : "terminal";
71
+ }
72
+
73
+ export function getHostMetadata(hostContext) {
74
+ return HOST_METADATA[normalizeHostContext(hostContext)] || HOST_METADATA.terminal;
75
+ }
76
+
77
+ export function getHostDisplayName(hostContext) {
78
+ return getHostMetadata(hostContext).displayName;
79
+ }