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
@@ -0,0 +1,335 @@
1
+ import { loadProjectConfig, saveProjectConfig } from "./config.mjs";
2
+ import {
3
+ getEngineDisplayName,
4
+ getHostDisplayName,
5
+ normalizeEngineName,
6
+ normalizeHostContext,
7
+ } from "./engine_metadata.mjs";
8
+ import { classifySwitchableEngineFailure } from "./engine_selection_failure.mjs";
9
+ import {
10
+ buildCrossHostSwitchMessage,
11
+ buildEngineSelectionRequiredMessage,
12
+ buildNoAvailableEngineMessage,
13
+ buildUnavailableRequestedEngineMessage,
14
+ candidate,
15
+ detectEngineIntentFromRequestText,
16
+ engineSourceLabel,
17
+ isUserDirectedSource,
18
+ } from "./engine_selection_messages.mjs";
19
+ import { confirmCrossHostSwitch, promptSelectEngine } from "./engine_selection_prompt.mjs";
20
+ import { probeExecutionEngines } from "./engine_selection_probe.mjs";
21
+ import { loadUserSettings, resolveUserSettingsFile, saveUserSettings } from "./engine_selection_settings.mjs";
22
+
23
+ function preferredRecommendationOrder(hostContext) {
24
+ return [
25
+ hostContext !== "terminal" ? hostContext : "",
26
+ "codex",
27
+ "claude",
28
+ "gemini",
29
+ ].filter(Boolean);
30
+ }
31
+
32
+ function recommendEngine({
33
+ hostContext,
34
+ availableEngines = [],
35
+ projectConfig = {},
36
+ userSettings = {},
37
+ }) {
38
+ const recommendedCandidates = [
39
+ projectConfig.defaultEngine,
40
+ projectConfig.lastSelectedEngine,
41
+ userSettings.defaultEngine,
42
+ userSettings.lastSelectedEngine,
43
+ ...preferredRecommendationOrder(hostContext),
44
+ ].map((item) => normalizeEngineName(item)).filter(Boolean);
45
+
46
+ for (const candidate of recommendedCandidates) {
47
+ if (availableEngines.includes(candidate)) {
48
+ return candidate;
49
+ }
50
+ }
51
+ return availableEngines[0] || "";
52
+ }
53
+
54
+ function buildRecommendationBasis({
55
+ hostContext,
56
+ projectConfig = {},
57
+ userSettings = {},
58
+ recommendedEngine,
59
+ }) {
60
+ if (!recommendedEngine) {
61
+ return "";
62
+ }
63
+ if (normalizeEngineName(projectConfig.defaultEngine) === recommendedEngine) {
64
+ return `推荐:项目默认引擎是 ${getEngineDisplayName(recommendedEngine)}。`;
65
+ }
66
+ if (normalizeEngineName(projectConfig.lastSelectedEngine) === recommendedEngine) {
67
+ return `推荐:项目上次使用的引擎是 ${getEngineDisplayName(recommendedEngine)}。`;
68
+ }
69
+ if (normalizeEngineName(userSettings.defaultEngine) === recommendedEngine) {
70
+ return `推荐:用户默认引擎是 ${getEngineDisplayName(recommendedEngine)}。`;
71
+ }
72
+ if (normalizeEngineName(userSettings.lastSelectedEngine) === recommendedEngine) {
73
+ return `推荐:用户上次使用的引擎是 ${getEngineDisplayName(recommendedEngine)}。`;
74
+ }
75
+ if (hostContext !== "terminal" && normalizeEngineName(hostContext) === recommendedEngine) {
76
+ return `推荐:当前宿主是 ${getHostDisplayName(hostContext)}。`;
77
+ }
78
+ return `推荐:当前可用引擎中更适合优先尝试 ${getEngineDisplayName(recommendedEngine)}。`;
79
+ }
80
+
81
+ async function promptForExplicitEngineSelection({
82
+ availableEngines,
83
+ hostContext,
84
+ projectConfig,
85
+ userSettings,
86
+ }) {
87
+ const recommendedEngine = recommendEngine({
88
+ hostContext,
89
+ availableEngines,
90
+ projectConfig,
91
+ userSettings,
92
+ });
93
+ return promptSelectEngine(availableEngines, {
94
+ hostContext,
95
+ recommendedEngine,
96
+ message: [
97
+ "本轮开始前必须先明确执行引擎;未明确引擎时不会自动选择。",
98
+ `当前宿主:${getHostDisplayName(hostContext)}。`,
99
+ buildRecommendationBasis({
100
+ hostContext,
101
+ projectConfig,
102
+ userSettings,
103
+ recommendedEngine,
104
+ }),
105
+ "",
106
+ "请选择本次要使用的执行引擎:",
107
+ ].filter(Boolean).join("\n"),
108
+ });
109
+ }
110
+
111
+ function buildResolution({
112
+ engine,
113
+ source,
114
+ basis,
115
+ hostContext,
116
+ probes,
117
+ }) {
118
+ const availableEngines = probes.filter((item) => item.ok).map((item) => item.engine);
119
+ return {
120
+ ok: true,
121
+ engine,
122
+ displayName: getEngineDisplayName(engine),
123
+ hostContext,
124
+ hostDisplayName: getHostDisplayName(hostContext),
125
+ source,
126
+ sourceLabel: engineSourceLabel(source),
127
+ basis: Array.isArray(basis) ? basis.filter(Boolean) : [],
128
+ probes,
129
+ availableEngines,
130
+ };
131
+ }
132
+
133
+ export { classifySwitchableEngineFailure, loadUserSettings, probeExecutionEngines, resolveUserSettingsFile, saveUserSettings };
134
+
135
+ export function resolveHostContext(options = {}) {
136
+ const envCandidate = process.env.HELLOLOOP_HOST_CONTEXT || process.env.HELLOLOOP_HOST;
137
+ return normalizeHostContext(options.hostContext || envCandidate || "terminal");
138
+ }
139
+
140
+ export async function resolveEngineSelection({
141
+ context,
142
+ policy = {},
143
+ options = {},
144
+ interactive = true,
145
+ } = {}) {
146
+ const hostContext = resolveHostContext(options);
147
+ const probes = probeExecutionEngines(policy);
148
+ const availableEngines = probes.filter((item) => item.ok).map((item) => item.engine);
149
+ const projectConfig = context ? loadProjectConfig(context) : {};
150
+ const userSettings = loadUserSettings(options);
151
+ const requestIntent = detectEngineIntentFromRequestText(
152
+ options.userRequestText || options.userIntent?.requestText || "",
153
+ );
154
+ const candidates = [];
155
+ const requestedEngine = normalizeEngineName(options.engine);
156
+ const requestedEngineSource = options.engineSource || "flag";
157
+
158
+ if (requestedEngine) {
159
+ candidates.push(candidate(requestedEngine, requestedEngineSource, [
160
+ requestedEngineSource === "leading_positional"
161
+ ? `命令首参数显式指定了 ${getEngineDisplayName(requestedEngine)}。`
162
+ : `命令参数显式指定了 ${getEngineDisplayName(requestedEngine)}。`,
163
+ ]));
164
+ } else if (requestIntent && !requestIntent.ambiguous) {
165
+ candidates.push(candidate(requestIntent.engine, "request_text", requestIntent.basis));
166
+ }
167
+
168
+ const attempted = new Set();
169
+ for (const item of candidates) {
170
+ if (!item.engine || attempted.has(item.engine)) {
171
+ continue;
172
+ }
173
+ attempted.add(item.engine);
174
+
175
+ if (availableEngines.includes(item.engine)) {
176
+ if (
177
+ hostContext !== "terminal"
178
+ && item.engine !== hostContext
179
+ && isUserDirectedSource(item.source)
180
+ ) {
181
+ if (!interactive) {
182
+ return {
183
+ ok: false,
184
+ code: "cross_host_engine_confirmation_required",
185
+ message: buildCrossHostSwitchMessage(hostContext, item.engine),
186
+ hostContext,
187
+ probes,
188
+ availableEngines,
189
+ };
190
+ }
191
+
192
+ const confirmed = await confirmCrossHostSwitch(hostContext, item.engine, buildCrossHostSwitchMessage);
193
+ if (!confirmed) {
194
+ return {
195
+ ok: false,
196
+ code: "cross_host_engine_cancelled",
197
+ message: "已取消跨宿主引擎切换,本次未开始执行。",
198
+ hostContext,
199
+ probes,
200
+ availableEngines,
201
+ };
202
+ }
203
+ }
204
+
205
+ return buildResolution({
206
+ engine: item.engine,
207
+ source: item.source,
208
+ basis: item.basis,
209
+ hostContext,
210
+ probes,
211
+ });
212
+ }
213
+
214
+ if (isUserDirectedSource(item.source)) {
215
+ const failedProbe = probes.find((probe) => probe.engine === item.engine);
216
+ if (!interactive || !availableEngines.length) {
217
+ return {
218
+ ok: false,
219
+ code: "requested_engine_unavailable",
220
+ message: buildUnavailableRequestedEngineMessage(item.engine, availableEngines, failedProbe),
221
+ hostContext,
222
+ probes,
223
+ availableEngines,
224
+ };
225
+ }
226
+
227
+ const fallbackEngine = await promptSelectEngine(availableEngines, {
228
+ hostContext,
229
+ recommendedEngine: recommendEngine({
230
+ hostContext,
231
+ availableEngines,
232
+ projectConfig,
233
+ userSettings,
234
+ }),
235
+ message: [
236
+ buildUnavailableRequestedEngineMessage(item.engine, availableEngines, failedProbe),
237
+ "",
238
+ "请选择一个可继续使用的引擎:",
239
+ ].join("\n"),
240
+ });
241
+ if (!fallbackEngine) {
242
+ return {
243
+ ok: false,
244
+ code: "requested_engine_cancelled",
245
+ message: "已取消执行引擎选择,本次未开始执行。",
246
+ hostContext,
247
+ probes,
248
+ availableEngines,
249
+ };
250
+ }
251
+
252
+ return buildResolution({
253
+ engine: fallbackEngine,
254
+ source: "interactive_choice",
255
+ basis: [
256
+ `原始指定引擎 ${getEngineDisplayName(item.engine)} 当前不可用。`,
257
+ `用户改为选择 ${getEngineDisplayName(fallbackEngine)}。`,
258
+ ],
259
+ hostContext,
260
+ probes,
261
+ });
262
+ }
263
+ }
264
+
265
+ if (!availableEngines.length) {
266
+ return {
267
+ ok: false,
268
+ code: "no_available_engine",
269
+ message: buildNoAvailableEngineMessage(hostContext, probes),
270
+ hostContext,
271
+ probes,
272
+ availableEngines,
273
+ };
274
+ }
275
+
276
+ if (!interactive) {
277
+ return {
278
+ ok: false,
279
+ code: "engine_selection_required",
280
+ message: [
281
+ "本轮开始前必须先明确执行引擎;当前未检测到用户已明确指定引擎。",
282
+ buildEngineSelectionRequiredMessage(hostContext, availableEngines),
283
+ ].join("\n\n"),
284
+ hostContext,
285
+ probes,
286
+ availableEngines,
287
+ };
288
+ }
289
+
290
+ const selectedEngine = await promptForExplicitEngineSelection({
291
+ availableEngines,
292
+ hostContext,
293
+ projectConfig,
294
+ userSettings,
295
+ });
296
+ if (!selectedEngine) {
297
+ return {
298
+ ok: false,
299
+ code: "engine_selection_cancelled",
300
+ message: "已取消执行引擎选择,本次未开始执行。",
301
+ hostContext,
302
+ probes,
303
+ availableEngines,
304
+ };
305
+ }
306
+
307
+ return buildResolution({
308
+ engine: selectedEngine,
309
+ source: "interactive_choice",
310
+ basis: [`用户在交互中选择了 ${getEngineDisplayName(selectedEngine)}。`],
311
+ hostContext,
312
+ probes,
313
+ });
314
+ }
315
+
316
+ export function rememberEngineSelection(context, engineResolution, options = {}) {
317
+ const engine = normalizeEngineName(engineResolution?.engine);
318
+ if (!engine) {
319
+ return;
320
+ }
321
+
322
+ if (context) {
323
+ const projectConfig = loadProjectConfig(context);
324
+ saveProjectConfig(context, {
325
+ ...projectConfig,
326
+ lastSelectedEngine: engine,
327
+ });
328
+ }
329
+
330
+ const userSettings = loadUserSettings(options);
331
+ saveUserSettings({
332
+ ...userSettings,
333
+ lastSelectedEngine: engine,
334
+ }, options);
335
+ }
@@ -0,0 +1,51 @@
1
+ const SWITCHABLE_FAILURE_MATCHERS = [
2
+ {
3
+ code: "quota",
4
+ reason: "当前引擎可能遇到额度、配额或限流问题。",
5
+ patterns: [
6
+ "429",
7
+ "rate limit",
8
+ "too many requests",
9
+ "quota",
10
+ "credit",
11
+ "usage limit",
12
+ "capacity",
13
+ "overloaded",
14
+ "insufficient balance",
15
+ ],
16
+ },
17
+ {
18
+ code: "auth",
19
+ reason: "当前引擎可能未登录、鉴权失效或权限不足。",
20
+ patterns: [
21
+ "not authenticated",
22
+ "authentication",
23
+ "unauthorized",
24
+ "forbidden",
25
+ "login",
26
+ "api key",
27
+ "token",
28
+ "subscription",
29
+ "setup-token",
30
+ "sign in",
31
+ ],
32
+ },
33
+ ];
34
+
35
+ export function classifySwitchableEngineFailure(detail = "") {
36
+ const normalized = String(detail || "").toLowerCase();
37
+ if (!normalized) {
38
+ return null;
39
+ }
40
+
41
+ for (const matcher of SWITCHABLE_FAILURE_MATCHERS) {
42
+ if (matcher.patterns.some((pattern) => normalized.includes(pattern))) {
43
+ return {
44
+ code: matcher.code,
45
+ reason: matcher.reason,
46
+ };
47
+ }
48
+ }
49
+
50
+ return null;
51
+ }
@@ -0,0 +1,119 @@
1
+ import { getEngineDisplayName, getHostDisplayName, normalizeEngineName } from "./engine_metadata.mjs";
2
+ import { renderEngineList } from "./engine_selection_probe.mjs";
3
+
4
+ const ENGINE_SOURCE_LABELS = {
5
+ flag: "命令参数",
6
+ leading_positional: "命令首参数",
7
+ request_text: "自然语言要求",
8
+ host_context: "当前宿主",
9
+ project_default: "项目默认引擎",
10
+ project_last: "项目上次引擎",
11
+ user_default: "用户默认引擎",
12
+ user_last: "用户上次引擎",
13
+ only_available: "唯一可用引擎",
14
+ interactive_choice: "交互选择",
15
+ };
16
+
17
+ export function detectEngineIntentFromRequestText(requestText = "") {
18
+ const normalized = String(requestText || "").toLowerCase();
19
+ if (!normalized) {
20
+ return null;
21
+ }
22
+
23
+ const matches = new Set();
24
+ const pattern = /(^|[^a-z0-9])(codex|claude|gemini)(?=[^a-z0-9]|$)/g;
25
+ let match = pattern.exec(normalized);
26
+ while (match) {
27
+ matches.add(match[2]);
28
+ match = pattern.exec(normalized);
29
+ }
30
+
31
+ if (!matches.size) {
32
+ return null;
33
+ }
34
+
35
+ const engines = [...matches];
36
+ if (engines.length === 1) {
37
+ return {
38
+ engine: engines[0],
39
+ source: "request_text",
40
+ basis: [`补充要求里明确提到了 ${getEngineDisplayName(engines[0])}。`],
41
+ ambiguous: false,
42
+ };
43
+ }
44
+
45
+ return {
46
+ engine: "",
47
+ source: "request_text",
48
+ basis: [`补充要求里同时提到了多个引擎:${renderEngineList(engines)}。`],
49
+ ambiguous: true,
50
+ engines,
51
+ };
52
+ }
53
+
54
+ export function engineSourceLabel(source) {
55
+ return ENGINE_SOURCE_LABELS[source] || "自动判断";
56
+ }
57
+
58
+ export function candidate(engine, source, basis = []) {
59
+ return {
60
+ engine: normalizeEngineName(engine),
61
+ source,
62
+ basis: Array.isArray(basis) ? basis.filter(Boolean) : [],
63
+ };
64
+ }
65
+
66
+ export function isUserDirectedSource(source) {
67
+ return ["flag", "leading_positional", "request_text"].includes(source);
68
+ }
69
+
70
+ function describeProbeFailures(probes = []) {
71
+ return probes
72
+ .filter((item) => !item.ok)
73
+ .map((item) => `- ${getEngineDisplayName(item.engine)}:${item.detail || "不可用"}`);
74
+ }
75
+
76
+ export function buildEngineSelectionRequiredMessage(hostContext, availableEngines) {
77
+ const engineSummary = availableEngines.length > 1
78
+ ? `检测到可用执行引擎:${renderEngineList(availableEngines)}。`
79
+ : `检测到唯一可用执行引擎:${renderEngineList(availableEngines)}。`;
80
+ return [
81
+ engineSummary,
82
+ `当前宿主:${getHostDisplayName(hostContext)}。`,
83
+ "请显式指定要使用的引擎,例如:",
84
+ "- `npx helloloop codex`",
85
+ "- `npx helloloop claude <PATH>`",
86
+ "- `npx helloloop gemini <PATH> 继续开发`",
87
+ ].join("\n");
88
+ }
89
+
90
+ export function buildNoAvailableEngineMessage(hostContext, probes = []) {
91
+ const failureLines = describeProbeFailures(probes);
92
+ return [
93
+ `当前宿主:${getHostDisplayName(hostContext)}。`,
94
+ "未发现可安全执行的开发引擎(Codex / Claude / Gemini)。",
95
+ ...(failureLines.length ? ["", "检查结果:", ...failureLines] : []),
96
+ "",
97
+ "请先安装并确认至少一个 CLI 可正常执行,然后重试。",
98
+ ].join("\n");
99
+ }
100
+
101
+ export function buildUnavailableRequestedEngineMessage(engine, availableEngines, probe) {
102
+ const lines = [`你指定的执行引擎当前不可用:${getEngineDisplayName(engine)}。`];
103
+
104
+ if (probe?.detail) {
105
+ lines.push(`原因:${probe.detail}`);
106
+ }
107
+ if (availableEngines.length) {
108
+ lines.push(`当前仍可用的引擎:${renderEngineList(availableEngines)}。`);
109
+ }
110
+ lines.push("请改用可用引擎,或先修复该 CLI 的安装 / 登录 / 配额问题。");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ export function buildCrossHostSwitchMessage(hostContext, engine) {
115
+ return [
116
+ `当前从 ${getHostDisplayName(hostContext)} 宿主发起,但本次将改用 ${getEngineDisplayName(engine)} 执行。`,
117
+ "这不会静默切换;请确认是否继续。",
118
+ ].join("\n");
119
+ }
@@ -0,0 +1,78 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { getEngineDisplayName, getEngineMetadata, listKnownEngines, normalizeEngineName } from "./engine_metadata.mjs";
4
+ import { resolveCliInvocation } from "./shell_invocation.mjs";
5
+
6
+ function resolveExecutableOverride(policy = {}, engine) {
7
+ const envExecutable = String(process.env[`HELLOLOOP_${String(engine || "").toUpperCase()}_EXECUTABLE`] || "").trim();
8
+ if (envExecutable) {
9
+ return envExecutable;
10
+ }
11
+ return String(policy?.[engine]?.executable || "").trim();
12
+ }
13
+
14
+ function probeEngineAvailability(engine, policy = {}) {
15
+ const meta = getEngineMetadata(engine);
16
+ const invocation = resolveCliInvocation({
17
+ commandName: meta.commandName,
18
+ toolDisplayName: meta.displayName,
19
+ explicitExecutable: resolveExecutableOverride(policy, engine),
20
+ });
21
+
22
+ if (invocation.error) {
23
+ return {
24
+ engine,
25
+ ok: false,
26
+ detail: invocation.error,
27
+ };
28
+ }
29
+
30
+ const result = spawnSync(invocation.command, [...invocation.argsPrefix, "--version"], {
31
+ encoding: "utf8",
32
+ shell: invocation.shell,
33
+ });
34
+ const ok = result.status === 0;
35
+ return {
36
+ engine,
37
+ ok,
38
+ detail: ok
39
+ ? String(result.stdout || "").trim()
40
+ : String(result.stderr || result.error || `无法执行 ${meta.commandName} --version`).trim(),
41
+ };
42
+ }
43
+
44
+ export function probeExecutionEngines(policy = {}) {
45
+ return listKnownEngines().map((engine) => probeEngineAvailability(engine, policy));
46
+ }
47
+
48
+ export function uniqueEngines(items = []) {
49
+ const result = [];
50
+ const seen = new Set();
51
+ for (const item of items) {
52
+ const normalized = normalizeEngineName(item);
53
+ if (!normalized || seen.has(normalized)) {
54
+ continue;
55
+ }
56
+ seen.add(normalized);
57
+ result.push(normalized);
58
+ }
59
+ return result;
60
+ }
61
+
62
+ export function rankEngines(engines, hostContext = "terminal") {
63
+ const preferredOrder = uniqueEngines([hostContext, "codex", "claude", "gemini"]);
64
+ return [...engines].sort((left, right) => {
65
+ const leftIndex = preferredOrder.indexOf(left);
66
+ const rightIndex = preferredOrder.indexOf(right);
67
+ const normalizedLeft = leftIndex >= 0 ? leftIndex : Number.MAX_SAFE_INTEGER;
68
+ const normalizedRight = rightIndex >= 0 ? rightIndex : Number.MAX_SAFE_INTEGER;
69
+ if (normalizedLeft !== normalizedRight) {
70
+ return normalizedLeft - normalizedRight;
71
+ }
72
+ return left.localeCompare(right, "en");
73
+ });
74
+ }
75
+
76
+ export function renderEngineList(engines = []) {
77
+ return rankEngines(engines).map((engine) => getEngineDisplayName(engine)).join("、");
78
+ }
@@ -0,0 +1,48 @@
1
+ import { getEngineDisplayName } from "./engine_metadata.mjs";
2
+ import { rankEngines } from "./engine_selection_probe.mjs";
3
+ import { createPromptSession } from "./prompt_session.mjs";
4
+
5
+ function parseAffirmative(answer) {
6
+ const raw = String(answer || "").trim();
7
+ const normalized = raw.toLowerCase();
8
+ return ["y", "yes", "ok", "确认", "是", "继续", "好的"].includes(normalized)
9
+ || ["确认", "是", "继续", "好的"].includes(raw);
10
+ }
11
+
12
+ export async function confirmCrossHostSwitch(hostContext, engine, buildMessage) {
13
+ const promptSession = createPromptSession();
14
+ try {
15
+ const answer = await promptSession.question(
16
+ `${buildMessage(hostContext, engine)}\n请输入 y / yes / 确认 继续,其它任意输入取消:`,
17
+ );
18
+ return parseAffirmative(answer);
19
+ } finally {
20
+ promptSession.close();
21
+ }
22
+ }
23
+
24
+ export async function promptSelectEngine(availableEngines, options = {}) {
25
+ const promptSession = createPromptSession();
26
+ const ranked = rankEngines(availableEngines, options.hostContext);
27
+ const choiceLines = ranked.map((engine, index) => {
28
+ const suffix = engine === options.recommendedEngine ? "(推荐)" : "";
29
+ return `${index + 1}. ${getEngineDisplayName(engine)}${suffix}`;
30
+ });
31
+
32
+ try {
33
+ const answer = await promptSession.question([
34
+ options.message || "请选择本次要使用的执行引擎:",
35
+ ...choiceLines,
36
+ "",
37
+ "请输入编号;直接回车取消。",
38
+ "> ",
39
+ ].join("\n"));
40
+ const choiceIndex = Number(String(answer || "").trim());
41
+ if (!Number.isInteger(choiceIndex) || choiceIndex < 1 || choiceIndex > ranked.length) {
42
+ return "";
43
+ }
44
+ return ranked[choiceIndex - 1];
45
+ } finally {
46
+ promptSession.close();
47
+ }
48
+ }
@@ -0,0 +1,38 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ import { fileExists, readJson, writeJson } from "./common.mjs";
5
+ import { normalizeEngineName } from "./engine_metadata.mjs";
6
+
7
+ function defaultUserSettings() {
8
+ return {
9
+ defaultEngine: "",
10
+ lastSelectedEngine: "",
11
+ };
12
+ }
13
+
14
+ export function resolveUserSettingsFile(userSettingsFile = "") {
15
+ return userSettingsFile
16
+ || String(process.env.HELLOLOOP_USER_SETTINGS_FILE || "").trim()
17
+ || path.join(os.homedir(), ".helloloop", "settings.json");
18
+ }
19
+
20
+ export function loadUserSettings(options = {}) {
21
+ const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
22
+ if (!fileExists(settingsFile)) {
23
+ return defaultUserSettings();
24
+ }
25
+
26
+ const settings = readJson(settingsFile);
27
+ return {
28
+ defaultEngine: normalizeEngineName(settings?.defaultEngine),
29
+ lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
30
+ };
31
+ }
32
+
33
+ export function saveUserSettings(settings, options = {}) {
34
+ writeJson(resolveUserSettingsFile(options.userSettingsFile), {
35
+ defaultEngine: normalizeEngineName(settings?.defaultEngine),
36
+ lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
37
+ });
38
+ }