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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +3 -3
- package/README.md +297 -272
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +19 -9
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -4
- package/hosts/gemini/extension/GEMINI.md +13 -4
- package/hosts/gemini/extension/commands/helloloop.toml +19 -8
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/scripts/uninstall-home-plugin.ps1 +25 -0
- package/skills/helloloop/SKILL.md +42 -7
- package/src/analyze_confirmation.mjs +108 -8
- package/src/analyze_prompt.mjs +17 -1
- package/src/analyze_user_input.mjs +321 -0
- package/src/analyzer.mjs +167 -42
- package/src/cli.mjs +34 -308
- package/src/cli_analyze_command.mjs +248 -0
- package/src/cli_args.mjs +106 -0
- package/src/cli_command_handlers.mjs +120 -0
- package/src/cli_context.mjs +31 -0
- package/src/cli_render.mjs +70 -0
- package/src/cli_support.mjs +95 -31
- package/src/completion_review.mjs +243 -0
- package/src/config.mjs +50 -0
- package/src/discovery.mjs +243 -9
- package/src/discovery_inference.mjs +62 -18
- package/src/discovery_paths.mjs +143 -8
- package/src/discovery_prompt.mjs +273 -0
- package/src/engine_metadata.mjs +79 -0
- package/src/engine_selection.mjs +335 -0
- package/src/engine_selection_failure.mjs +51 -0
- package/src/engine_selection_messages.mjs +119 -0
- package/src/engine_selection_probe.mjs +78 -0
- package/src/engine_selection_prompt.mjs +48 -0
- package/src/engine_selection_settings.mjs +38 -0
- package/src/guardrails.mjs +15 -4
- package/src/install.mjs +20 -266
- package/src/install_claude.mjs +189 -0
- package/src/install_codex.mjs +114 -0
- package/src/install_gemini.mjs +43 -0
- package/src/install_shared.mjs +90 -0
- package/src/process.mjs +482 -39
- package/src/prompt.mjs +9 -5
- package/src/prompt_session.mjs +40 -0
- package/src/rebuild.mjs +116 -0
- package/src/runner.mjs +3 -341
- package/src/runner_execute_task.mjs +301 -0
- package/src/runner_execution_support.mjs +155 -0
- package/src/runner_loop.mjs +106 -0
- package/src/runner_once.mjs +29 -0
- package/src/runner_status.mjs +104 -0
- package/src/runtime_recovery.mjs +301 -0
- package/src/shell_invocation.mjs +16 -0
- package/templates/analysis-output.schema.json +58 -1
- package/templates/policy.template.json +27 -0
- package/templates/project.template.json +2 -0
- 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
|
-
|
|
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(/^["'`(<\[]+|[>"'`)\]
|
|
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("'
|
|
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
|
|
90
|
-
if (!
|
|
91
|
+
function formatCandidates(title, candidates) {
|
|
92
|
+
if (!Array.isArray(candidates) || !candidates.length) {
|
|
91
93
|
return [];
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
return
|
|
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(
|
|
97
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
207
|
-
|
|
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
|
}
|
package/src/discovery_paths.mjs
CHANGED
|
@@ -36,15 +36,80 @@ const PROJECT_DIR_MARKERS = [
|
|
|
36
36
|
"tests",
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
-
|
|
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
|
|
64
|
+
return [];
|
|
42
65
|
}
|
|
43
66
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|