helloloop 0.7.2 → 0.8.0
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 +1 -1
- package/README.md +35 -17
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +2 -2
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +2 -1
- package/hosts/gemini/extension/GEMINI.md +1 -1
- package/hosts/gemini/extension/commands/helloloop.toml +2 -2
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/helloloop/SKILL.md +2 -1
- package/src/analyze_confirmation.mjs +4 -4
- package/src/cli_render.mjs +13 -3
- package/src/cli_support.mjs +28 -5
- package/src/discovery.mjs +40 -9
- package/src/discovery_inference.mjs +27 -11
- package/src/discovery_prompt.mjs +92 -76
- package/src/engine_selection_settings.mjs +28 -10
- package/src/install_codex.mjs +125 -13
- package/src/install_shared.mjs +8 -0
package/README.md
CHANGED
|
@@ -29,10 +29,16 @@
|
|
|
29
29
|
|
|
30
30
|
| 宿主 | 安装方式 | 原生入口 | 说明 |
|
|
31
31
|
| --- | --- | --- | --- |
|
|
32
|
-
| `Codex CLI` | `helloloop install` / `helloloop install --host codex` |
|
|
32
|
+
| `Codex CLI` | `helloloop install` / `helloloop install --host codex` | `Codex` 内:`$helloloop`;终端:`npx helloloop` | 仅在显式调用时进入 HelloLoop |
|
|
33
33
|
| `Claude Code` | `helloloop install --host claude` | `/helloloop` | 仅在显式调用时进入 HelloLoop |
|
|
34
34
|
| `Gemini CLI` | `helloloop install --host gemini` | `/helloloop` | 仅在显式调用时进入 HelloLoop |
|
|
35
35
|
|
|
36
|
+
补充说明:
|
|
37
|
+
|
|
38
|
+
- `install --host codex` 只会把 `HelloLoop` 注册成 `Codex` 插件,不会把 `helloloop` 写进系统 `PATH`
|
|
39
|
+
- 终端里的 `npx helloloop` 始终是 npm CLI 入口;如果本机未全局安装,首次执行出现 `Need to install the following packages` 属正常行为
|
|
40
|
+
- `Codex` 安装完成后,建议重启 `Codex` 或新开一个会话,再检查 `$helloloop`
|
|
41
|
+
|
|
36
42
|
## 最短使用方式
|
|
37
43
|
|
|
38
44
|
推荐先记住下面四种:
|
|
@@ -156,13 +162,10 @@ npx helloloop
|
|
|
156
162
|
|
|
157
163
|
`HelloLoop` 会先明确执行引擎,再快速扫描当前目录:
|
|
158
164
|
|
|
159
|
-
-
|
|
160
|
-
-
|
|
161
|
-
-
|
|
162
|
-
|
|
163
|
-
- 顶层目录
|
|
164
|
-
- 疑似项目目录
|
|
165
|
-
然后再询问开发文档路径
|
|
165
|
+
- 当前目录本身就是开发文档目录时,会先尝试从文档反推项目目录
|
|
166
|
+
- 当前目录看起来像普通项目目录时,默认直接把当前目录作为项目目录
|
|
167
|
+
- 当前目录更像工作区或用户主目录时,才会额外询问项目目录
|
|
168
|
+
- 如果当前项目目录下没有明确开发文档,会只提示补充“开发文档”这一项,并说明已检查的常见位置
|
|
166
169
|
|
|
167
170
|
### 2. 项目路径只问一次
|
|
168
171
|
|
|
@@ -173,11 +176,22 @@ npx helloloop
|
|
|
173
176
|
|
|
174
177
|
不会再额外追问一个“新项目路径”。
|
|
175
178
|
|
|
176
|
-
### 3.
|
|
179
|
+
### 3. 缺什么就只问什么
|
|
180
|
+
|
|
181
|
+
正常情况下只需要两项信息:
|
|
182
|
+
|
|
183
|
+
- 项目目录
|
|
184
|
+
- 开发文档
|
|
185
|
+
|
|
186
|
+
其中:
|
|
187
|
+
|
|
188
|
+
- 项目目录默认优先使用当前打开的目录
|
|
189
|
+
- 当前目录明显不是项目目录时,才会额外询问项目目录
|
|
190
|
+
- 开发文档缺失时,只会询问开发文档
|
|
177
191
|
|
|
178
192
|
以下情况不会硬猜:
|
|
179
193
|
|
|
180
|
-
-
|
|
194
|
+
- 给了开发文档,但仍无法定位项目目录
|
|
181
195
|
- 给了项目路径,但无法定位开发文档
|
|
182
196
|
- 同时出现多个冲突的文档路径或项目路径
|
|
183
197
|
|
|
@@ -217,7 +231,7 @@ npx helloloop --rebuild-existing
|
|
|
217
231
|
- 执行引擎
|
|
218
232
|
- 需求语义理解
|
|
219
233
|
- 项目匹配判断
|
|
220
|
-
-
|
|
234
|
+
- 项目目录
|
|
221
235
|
- 开发文档
|
|
222
236
|
- 当前进度
|
|
223
237
|
- 已实现事项
|
|
@@ -290,11 +304,12 @@ npx helloloop install --host all --force
|
|
|
290
304
|
说明:
|
|
291
305
|
|
|
292
306
|
- `--force` 会清理当前宿主里已有安装残留的 `helloloop` 目录后再重装
|
|
293
|
-
- `Codex`
|
|
307
|
+
- `Codex` 会刷新 home 根下的插件源码目录、已安装缓存、`config.toml` 启用项和 marketplace 条目
|
|
294
308
|
- `Claude` 会刷新 marketplace、缓存插件目录,以及 `settings.json` / `known_marketplaces.json` / `installed_plugins.json` 中的 `helloloop` 条目
|
|
295
309
|
- `Gemini` 会刷新 `extensions/helloloop/`,不会动同目录下其他扩展
|
|
296
310
|
- 安装 / 升级 / 重装时,会同步校准 `~/.helloloop/settings.json` 的当前版本结构:补齐缺失项、清理未知项、保留已知项现有值
|
|
297
|
-
- 如果 `~/.helloloop/settings.json`
|
|
311
|
+
- 如果 `~/.helloloop/settings.json` 被确认不是合法 JSON,会先备份原文件,再按当前版本结构重建
|
|
312
|
+
- 如果只是首次读取时出现瞬时异常,但重读后内容合法,则不会误生成备份文件
|
|
298
313
|
- 如果宿主自己的配置 JSON(如 `Codex marketplace.json`、`Claude settings.json`、`known_marketplaces.json`、`installed_plugins.json`)本身已损坏,`HelloLoop` 会先明确报错并停止,不会先清理现有安装再失败
|
|
299
314
|
|
|
300
315
|
### 卸载
|
|
@@ -333,7 +348,7 @@ $helloloop
|
|
|
333
348
|
helloloop:helloloop
|
|
334
349
|
```
|
|
335
350
|
|
|
336
|
-
|
|
351
|
+
在终端里(包括 `Codex` 打开的内置终端)也可以直接执行:
|
|
337
352
|
|
|
338
353
|
```bash
|
|
339
354
|
npx helloloop
|
|
@@ -412,8 +427,10 @@ npx helloloop doctor --host all --codex-home <CODEX_HOME> --claude-home <CLAUDE_
|
|
|
412
427
|
|
|
413
428
|
### `Codex CLI`
|
|
414
429
|
|
|
415
|
-
-
|
|
416
|
-
-
|
|
430
|
+
- 插件源码目录:`~/plugins/helloloop/`
|
|
431
|
+
- 已安装缓存:`~/.codex/plugins/cache/local-plugins/helloloop/local/`
|
|
432
|
+
- marketplace:`~/.agents/plugins/marketplace.json`
|
|
433
|
+
- 启用配置:`~/.codex/config.toml`
|
|
417
434
|
|
|
418
435
|
### `Claude Code`
|
|
419
436
|
|
|
@@ -442,7 +459,8 @@ npx helloloop doctor --host all --codex-home <CODEX_HOME> --claude-home <CLAUDE_
|
|
|
442
459
|
|
|
443
460
|
- 这里不保存项目 backlog、状态、运行记录
|
|
444
461
|
- 安装 / 升级 / 重装时,会对 `settings.json` 做结构校准,但不会校验或篡改你已存在的已知项内容
|
|
445
|
-
-
|
|
462
|
+
- 只有在 `settings.json` 被确认非法时,才会先备份,再重建为当前版本结构
|
|
463
|
+
- 如果只是读取瞬时异常、重读后合法,不会误生成 `.bak`
|
|
446
464
|
|
|
447
465
|
## `.helloloop/` 状态目录
|
|
448
466
|
|
|
@@ -12,7 +12,7 @@ argument-hint: [PATH]
|
|
|
12
12
|
3. 如果用户在命令后提供了 `$ARGUMENTS`,必须同时保留其中的显式路径和自然语言补充要求,不要依赖固定关键词硬编码决策。
|
|
13
13
|
3. 自动识别目标仓库与开发文档,并先分析“当前代码做到哪里了”“与文档目标是否存在偏差”“当前项目与文档目标是否匹配”。
|
|
14
14
|
4. 当前宿主是 `Claude`;如果用户未明确指定执行引擎,必须先确认本轮引擎,`Claude` 只可作为推荐项,不允许自动选中;如果用户明确要求改用 `Codex` 或 `Gemini`,必须先确认,不允许静默切换。
|
|
15
|
-
5.
|
|
15
|
+
5. 如果当前目录看起来像普通项目目录,默认直接把当前目录当作项目目录;只有明显是工作区或用户主目录时才额外询问项目目录。如果当前项目目录里没有明确开发文档,只补充询问开发文档,不展示内部扫描分类。
|
|
16
16
|
6. 项目路径对外只有一个概念;如果用户输入的项目路径不存在,就把它视为准备创建的新项目目录,不要再单独追问“新项目路径”。
|
|
17
17
|
7. 在目标仓库根目录创建或刷新 `.helloloop/`,至少维护 `backlog.json`、`project.json`、`status.json`、`STATE.md` 与 `runs/`。
|
|
18
18
|
8. 如果用户给了开发文档但无法定位项目仓库,或者给了项目路径但无法定位开发文档,必须停下来询问用户,而不是猜测。
|
|
@@ -22,7 +22,7 @@ argument-hint: [PATH]
|
|
|
22
22
|
- 本次命令补充输入
|
|
23
23
|
- 需求语义理解
|
|
24
24
|
- 项目匹配判断
|
|
25
|
-
-
|
|
25
|
+
- 项目目录
|
|
26
26
|
- 开发文档
|
|
27
27
|
- 当前进度
|
|
28
28
|
- 已实现事项
|
|
@@ -21,7 +21,8 @@ description: 仅当用户显式调用 `/helloloop`,或明确要求使用 Hello
|
|
|
21
21
|
|
|
22
22
|
- 未显式调用 `helloloop` 且用户也没有明确要求使用 HelloLoop 时,不允许接管普通 Claude 会话。
|
|
23
23
|
- 如果缺少目标仓库或开发文档,必须停下来询问用户。
|
|
24
|
-
-
|
|
24
|
+
- 如果当前目录看起来像普通项目目录,默认直接把当前目录当作项目目录;只有明显是工作区或用户主目录时才额外询问项目目录。
|
|
25
|
+
- 如果当前项目目录里没有明确开发文档,只补充询问开发文档,不展示内部扫描分类。
|
|
25
26
|
- 项目路径对外只有一个概念;如果用户提供的项目路径不存在,直接按新项目路径处理,不要再追问“新项目路径”。
|
|
26
27
|
- 如果当前项目与开发文档目标明显冲突,必须先确认是继续现有项目、清理后重建,还是取消。
|
|
27
28
|
- 如果当前引擎在运行中遇到 400、鉴权、余额、429、5xx、网络抖动、流中断或长时间卡死,必须优先按无人值守策略做同引擎“健康探测 + 条件恢复”,不要中途停下来询问用户,也不要自动切换引擎。
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
0. 仅在用户显式调用 `/helloloop`,或明确要求使用 HelloLoop 时才介入;不要接管普通 Gemini 会话
|
|
12
12
|
1. 自动识别项目仓库与开发文档
|
|
13
13
|
2. 如果用户未明确指定执行引擎,先确认本轮引擎;`Gemini` 只作为推荐项,不自动选中;如果用户明确要求改用 `Codex` / `Claude`,先确认再切换
|
|
14
|
-
3.
|
|
14
|
+
3. 如果当前目录看起来像普通项目目录,默认直接把当前目录当作项目目录;只有明显是工作区或用户主目录时才额外询问项目目录。如果当前项目目录里没有明确开发文档,只补充询问开发文档
|
|
15
15
|
4. 项目路径对外只有一个概念;若用户输入的路径不存在,直接按新项目路径处理
|
|
16
16
|
5. 分析当前代码与开发文档的差距,并判断当前项目与文档目标是否匹配
|
|
17
17
|
6. 在目标仓库根目录创建或刷新 `.helloloop/`
|
|
@@ -7,7 +7,7 @@ prompt = """
|
|
|
7
7
|
1. 只有当用户显式调用 `/helloloop`,或明确要求“使用 HelloLoop / 走 HelloLoop 流程”时,才进入当前工作流;不要接管普通 Gemini 会话。
|
|
8
8
|
2. 自动识别目标仓库与开发文档;如果用户在命令后提供了参数,必须同时保留其中的显式路径和自然语言补充要求,不要靠固定关键词硬编码判断。
|
|
9
9
|
3. 当前宿主是 `Gemini`;如果用户未明确指定执行引擎,必须先确认本轮引擎,`Gemini` 只可作为推荐项,不允许自动选中;如果用户明确要求改用 `Codex` 或 `Claude`,必须先确认,不允许静默切换。
|
|
10
|
-
3.
|
|
10
|
+
3. 如果当前目录看起来像普通项目目录,默认直接把当前目录当作项目目录;只有明显是工作区或用户主目录时才额外询问项目目录。如果当前项目目录里没有明确开发文档,只补充询问开发文档,不展示内部扫描分类。
|
|
11
11
|
4. 项目路径对外只有一个概念;如果用户给出的项目路径不存在,直接按新项目路径处理,不要再单独追问“新项目路径”。
|
|
12
12
|
5. 分析当前代码与开发文档之间的真实进度和偏差,并判断当前项目目录与开发文档目标是否匹配。
|
|
13
13
|
6. 在目标仓库根目录创建或刷新 `.helloloop/`,至少维护 `backlog.json`、`project.json`、`status.json`、`STATE.md` 与 `runs/`。
|
|
@@ -17,7 +17,7 @@ prompt = """
|
|
|
17
17
|
- 执行引擎
|
|
18
18
|
- 需求语义理解
|
|
19
19
|
- 项目匹配判断
|
|
20
|
-
-
|
|
20
|
+
- 项目目录
|
|
21
21
|
- 开发文档
|
|
22
22
|
- 当前进度
|
|
23
23
|
- 已实现事项
|
package/package.json
CHANGED
|
@@ -39,7 +39,8 @@ description: 仅当用户显式调用 `$helloloop` / `#helloloop` / `helloloop:h
|
|
|
39
39
|
|
|
40
40
|
## 路径发现与提问规则
|
|
41
41
|
|
|
42
|
-
-
|
|
42
|
+
- 如果当前目录看起来像普通项目目录,默认直接把当前目录当作项目目录;只有明显是工作区或用户主目录时才额外询问项目目录。
|
|
43
|
+
- 如果当前项目目录里没有自动识别到明确开发文档,只补充询问开发文档,不展示“顶层文档文件 / 顶层目录 / 疑似项目目录”之类内部扫描分类。
|
|
43
44
|
- 项目路径对外只有一个概念,不要单独追问“新项目路径”。
|
|
44
45
|
- 如果用户输入的项目路径不存在,应直接把它视为准备创建的新项目目录。
|
|
45
46
|
- 如果自动发现同时出现多个冲突的文档路径或项目路径,不允许替用户猜测,必须停下来确认。
|
|
@@ -83,11 +83,11 @@ function renderResolutionLines(discovery, context, docsDisplay) {
|
|
|
83
83
|
`- 文档来源:${docsResolution?.sourceLabel || "自动判断"}`,
|
|
84
84
|
`- 文档把握:${docsResolution?.confidenceLabel || "中"}`,
|
|
85
85
|
`- 文档依据:${docsBasis}`,
|
|
86
|
-
`-
|
|
86
|
+
`- 项目目录:${context.repoRoot.replaceAll("\\", "/")}`,
|
|
87
87
|
`- 项目状态:${repoState}`,
|
|
88
|
-
`-
|
|
89
|
-
`-
|
|
90
|
-
`-
|
|
88
|
+
`- 目录来源:${repoResolution?.sourceLabel || "自动判断"}`,
|
|
89
|
+
`- 目录把握:${repoResolution?.confidenceLabel || "中"}`,
|
|
90
|
+
`- 目录依据:${repoBasis}`,
|
|
91
91
|
];
|
|
92
92
|
}
|
|
93
93
|
|
package/src/cli_render.mjs
CHANGED
|
@@ -21,9 +21,15 @@ export function renderInstallSummary(result) {
|
|
|
21
21
|
|
|
22
22
|
for (const item of result.installedHosts) {
|
|
23
23
|
lines.push(`- ${item.displayName}:${item.targetRoot}`);
|
|
24
|
+
if (item.installedRoot) {
|
|
25
|
+
lines.push(` installed:${item.installedRoot}`);
|
|
26
|
+
}
|
|
24
27
|
if (item.marketplaceFile) {
|
|
25
28
|
lines.push(` marketplace:${item.marketplaceFile}`);
|
|
26
29
|
}
|
|
30
|
+
if (item.configFile) {
|
|
31
|
+
lines.push(` config:${item.configFile}`);
|
|
32
|
+
}
|
|
27
33
|
if (item.settingsFile) {
|
|
28
34
|
lines.push(` settings:${item.settingsFile}`);
|
|
29
35
|
}
|
|
@@ -47,9 +53,10 @@ export function renderInstallSummary(result) {
|
|
|
47
53
|
|
|
48
54
|
lines.push("");
|
|
49
55
|
lines.push("使用入口:");
|
|
50
|
-
lines.push("-
|
|
51
|
-
lines.push("-
|
|
52
|
-
lines.push("-
|
|
56
|
+
lines.push("- 终端 CLI:`npx helloloop`");
|
|
57
|
+
lines.push("- Codex 技能:`$helloloop`");
|
|
58
|
+
lines.push("- Claude 指令:`/helloloop`");
|
|
59
|
+
lines.push("- Gemini 指令:`/helloloop`");
|
|
53
60
|
lines.push("");
|
|
54
61
|
lines.push(renderFollowupExamples());
|
|
55
62
|
return lines.join("\n");
|
|
@@ -64,6 +71,9 @@ export function renderUninstallSummary(result) {
|
|
|
64
71
|
if (item.marketplaceFile) {
|
|
65
72
|
lines.push(` marketplace:${item.marketplaceFile}`);
|
|
66
73
|
}
|
|
74
|
+
if (item.configFile) {
|
|
75
|
+
lines.push(` config:${item.configFile}`);
|
|
76
|
+
}
|
|
67
77
|
if (item.settingsFile) {
|
|
68
78
|
lines.push(` settings:${item.settingsFile}`);
|
|
69
79
|
}
|
package/src/cli_support.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { analyzeExecution, summarizeBacklog } from "./backlog.mjs";
|
|
5
|
-
import { fileExists, readJson } from "./common.mjs";
|
|
5
|
+
import { fileExists, readJson, readTextIfExists } from "./common.mjs";
|
|
6
|
+
import { resolveCodexLocalRoot } from "./install_shared.mjs";
|
|
6
7
|
import { createPromptSession } from "./prompt_session.mjs";
|
|
7
8
|
import { resolveCliInvocation, resolveCodexInvocation } from "./shell_invocation.mjs";
|
|
8
9
|
|
|
@@ -133,15 +134,37 @@ function normalizeDoctorHosts(hostOption) {
|
|
|
133
134
|
function collectCodexDoctorChecks(context, options = {}) {
|
|
134
135
|
const checks = collectDoctorChecks(context, options);
|
|
135
136
|
if (options.codexHome) {
|
|
137
|
+
const codexLocalRoot = resolveCodexLocalRoot(options.codexHome);
|
|
138
|
+
const codexConfigFile = path.join(options.codexHome, "config.toml");
|
|
139
|
+
const codexInstalledPluginFile = path.join(
|
|
140
|
+
options.codexHome,
|
|
141
|
+
"plugins",
|
|
142
|
+
"cache",
|
|
143
|
+
"local-plugins",
|
|
144
|
+
"helloloop",
|
|
145
|
+
"local",
|
|
146
|
+
".codex-plugin",
|
|
147
|
+
"plugin.json",
|
|
148
|
+
);
|
|
149
|
+
checks.push({
|
|
150
|
+
name: "codex plugin source",
|
|
151
|
+
ok: fileExists(path.join(codexLocalRoot, "plugins", "helloloop", ".codex-plugin", "plugin.json")),
|
|
152
|
+
detail: path.join(codexLocalRoot, "plugins", "helloloop", ".codex-plugin", "plugin.json"),
|
|
153
|
+
});
|
|
136
154
|
checks.push({
|
|
137
155
|
name: "codex installed plugin",
|
|
138
|
-
ok: fileExists(
|
|
139
|
-
detail:
|
|
156
|
+
ok: fileExists(codexInstalledPluginFile),
|
|
157
|
+
detail: codexInstalledPluginFile,
|
|
140
158
|
});
|
|
141
159
|
checks.push({
|
|
142
160
|
name: "codex marketplace",
|
|
143
|
-
ok: fileExists(path.join(
|
|
144
|
-
detail: path.join(
|
|
161
|
+
ok: fileExists(path.join(codexLocalRoot, ".agents", "plugins", "marketplace.json")),
|
|
162
|
+
detail: path.join(codexLocalRoot, ".agents", "plugins", "marketplace.json"),
|
|
163
|
+
});
|
|
164
|
+
checks.push({
|
|
165
|
+
name: "codex plugin config",
|
|
166
|
+
ok: readTextIfExists(codexConfigFile, "").includes('[plugins."helloloop@local-plugins"]'),
|
|
167
|
+
detail: codexConfigFile,
|
|
145
168
|
});
|
|
146
169
|
}
|
|
147
170
|
return checks;
|
package/src/discovery.mjs
CHANGED
|
@@ -47,7 +47,8 @@ const DOCS_SOURCE_META = {
|
|
|
47
47
|
cwd_docs: { label: "当前目录", confidence: "medium" },
|
|
48
48
|
workspace_single_doc: { label: "工作区唯一文档候选", confidence: "medium" },
|
|
49
49
|
existing_state: { label: "已有 .helloloop 配置", confidence: "medium" },
|
|
50
|
-
|
|
50
|
+
repo_docs_dir: { label: "项目目录 docs 目录", confidence: "medium" },
|
|
51
|
+
repo_doc_file: { label: "项目目录顶层文档", confidence: "medium" },
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
function isImplicitHomeDirectory(targetPath) {
|
|
@@ -200,7 +201,12 @@ export function discoverWorkspace(options = {}) {
|
|
|
200
201
|
repoSource = "explicit_input";
|
|
201
202
|
pushBasis(repoBasis, "目标项目来自命令中传入的单一路径。");
|
|
202
203
|
}
|
|
203
|
-
if (classified.kind === "
|
|
204
|
+
if (classified.kind === "directory") {
|
|
205
|
+
repoRoot = classified.absolutePath;
|
|
206
|
+
repoSource = "explicit_input";
|
|
207
|
+
pushBasis(repoBasis, "目标项目来自命令中传入的目录路径。");
|
|
208
|
+
}
|
|
209
|
+
if (classified.kind === "workspace") {
|
|
204
210
|
workspaceRoot = classified.absolutePath;
|
|
205
211
|
}
|
|
206
212
|
}
|
|
@@ -223,8 +229,12 @@ export function discoverWorkspace(options = {}) {
|
|
|
223
229
|
repoRoot = cwdClassified.absolutePath;
|
|
224
230
|
repoSource = "cwd_repo";
|
|
225
231
|
pushBasis(repoBasis, "当前终端目录本身就是项目仓库。");
|
|
226
|
-
} else if (cwdClassified.kind === "workspace"
|
|
232
|
+
} else if (cwdClassified.kind === "workspace") {
|
|
227
233
|
workspaceRoot = cwdClassified.absolutePath;
|
|
234
|
+
} else if (cwdClassified.kind === "directory") {
|
|
235
|
+
repoRoot = cwdClassified.absolutePath;
|
|
236
|
+
repoSource = "cwd_repo";
|
|
237
|
+
pushBasis(repoBasis, "当前终端目录默认作为项目目录。");
|
|
228
238
|
} else if (looksLikeProjectRoot(cwd)) {
|
|
229
239
|
repoRoot = cwd;
|
|
230
240
|
repoSource = "cwd_repo";
|
|
@@ -233,6 +243,25 @@ export function discoverWorkspace(options = {}) {
|
|
|
233
243
|
}
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
if (!repoRoot && docsEntries.length && !treatCwdAsHomeWorkspace) {
|
|
247
|
+
const cwdClassified = classifyExplicitPath(cwd);
|
|
248
|
+
if (cwdClassified.kind === "repo") {
|
|
249
|
+
repoRoot = cwdClassified.absolutePath;
|
|
250
|
+
repoSource = "cwd_repo";
|
|
251
|
+
pushBasis(repoBasis, "当前终端目录本身就是项目仓库。");
|
|
252
|
+
} else if (cwdClassified.kind === "directory") {
|
|
253
|
+
repoRoot = cwdClassified.absolutePath;
|
|
254
|
+
repoSource = "cwd_repo";
|
|
255
|
+
pushBasis(repoBasis, "当前终端目录默认作为项目目录。");
|
|
256
|
+
} else if (cwdClassified.kind === "workspace") {
|
|
257
|
+
workspaceRoot = workspaceRoot || cwdClassified.absolutePath;
|
|
258
|
+
} else if (looksLikeProjectRoot(cwd)) {
|
|
259
|
+
repoRoot = cwd;
|
|
260
|
+
repoSource = "cwd_repo";
|
|
261
|
+
pushBasis(repoBasis, "当前终端目录具备项目仓库特征。");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
236
265
|
if (!repoRoot && !docsEntries.length && workspaceRoot) {
|
|
237
266
|
const workspace = inspectWorkspaceDirectory(workspaceRoot);
|
|
238
267
|
docCandidates = workspace.docCandidates;
|
|
@@ -312,8 +341,10 @@ export function discoverWorkspace(options = {}) {
|
|
|
312
341
|
} else if (inferred.source === "cwd") {
|
|
313
342
|
docsSource = "cwd_docs";
|
|
314
343
|
pushBasis(docsBasis, "当前终端目录本身就是开发文档目录或文件。");
|
|
315
|
-
} else if (inferred.source === "
|
|
316
|
-
pushBasis(docsBasis, "
|
|
344
|
+
} else if (inferred.source === "repo_docs_dir") {
|
|
345
|
+
pushBasis(docsBasis, "已使用项目目录中的 `docs/` 目录作为默认开发文档入口。");
|
|
346
|
+
} else if (inferred.source === "repo_doc_file") {
|
|
347
|
+
pushBasis(docsBasis, "已使用项目目录中的顶层文档文件作为默认开发文档入口。");
|
|
317
348
|
}
|
|
318
349
|
}
|
|
319
350
|
}
|
|
@@ -384,6 +415,9 @@ export function resolveRepoRoot(options = {}) {
|
|
|
384
415
|
}
|
|
385
416
|
return { ok: false, message: renderMissingRepoMessage([classified.absolutePath], inferred.candidates) };
|
|
386
417
|
}
|
|
418
|
+
if (classified.kind === "directory") {
|
|
419
|
+
return { ok: true, repoRoot: classified.absolutePath };
|
|
420
|
+
}
|
|
387
421
|
|
|
388
422
|
if (!explicitRepoRoot && !explicitInputPath && isImplicitHomeDirectory(cwd)) {
|
|
389
423
|
return {
|
|
@@ -401,8 +435,5 @@ export function resolveRepoRoot(options = {}) {
|
|
|
401
435
|
return { ok: true, repoRoot: cwd };
|
|
402
436
|
}
|
|
403
437
|
|
|
404
|
-
return {
|
|
405
|
-
ok: false,
|
|
406
|
-
message: "当前目录不是项目仓库。请切到项目目录,或传入一个项目路径/开发文档路径。",
|
|
407
|
-
};
|
|
438
|
+
return { ok: true, repoRoot: cwd };
|
|
408
439
|
}
|
|
@@ -223,40 +223,56 @@ export function inferDocsForRepo(repoRoot, cwd, configDirName) {
|
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
const
|
|
226
|
+
const repoDocsDirectories = uniquePaths([
|
|
227
227
|
path.join(repoRoot, "docs"),
|
|
228
228
|
path.join(repoRoot, "Docs"),
|
|
229
|
+
path.join(repoRoot, "doc"),
|
|
230
|
+
path.join(repoRoot, "Doc"),
|
|
231
|
+
path.join(repoRoot, "documentation"),
|
|
232
|
+
path.join(repoRoot, "Documentation"),
|
|
229
233
|
].filter((candidate) => isDocsDirectory(candidate)));
|
|
230
234
|
|
|
231
|
-
|
|
235
|
+
const repoRootDocFiles = listDocFilesInDirectory(repoRoot)
|
|
236
|
+
.filter((candidate) => path.basename(candidate).toLowerCase() !== "agents.md");
|
|
237
|
+
const candidates = uniquePaths([...repoDocsDirectories, ...repoRootDocFiles]);
|
|
238
|
+
|
|
239
|
+
if (repoDocsDirectories.length === 1) {
|
|
240
|
+
return {
|
|
241
|
+
docsEntries: [normalizeForRepo(repoRoot, repoDocsDirectories[0])],
|
|
242
|
+
source: "repo_docs_dir",
|
|
243
|
+
candidates,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!repoDocsDirectories.length && repoRootDocFiles.length === 1) {
|
|
232
248
|
return {
|
|
233
|
-
docsEntries: [normalizeForRepo(repoRoot,
|
|
234
|
-
source: "
|
|
235
|
-
candidates
|
|
249
|
+
docsEntries: [normalizeForRepo(repoRoot, repoRootDocFiles[0])],
|
|
250
|
+
source: "repo_doc_file",
|
|
251
|
+
candidates,
|
|
236
252
|
};
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
return {
|
|
240
256
|
docsEntries: [],
|
|
241
257
|
source: "",
|
|
242
|
-
candidates
|
|
258
|
+
candidates,
|
|
243
259
|
};
|
|
244
260
|
}
|
|
245
261
|
|
|
246
262
|
export function renderMissingRepoMessage(docEntries, candidates) {
|
|
247
263
|
return [
|
|
248
|
-
"
|
|
264
|
+
"无法自动确定要开发的项目目录。",
|
|
249
265
|
docEntries.length ? `已找到开发文档:${docEntries.map((item) => item.replaceAll("\\", "/")).join(",")}` : "",
|
|
250
|
-
...formatCandidates("
|
|
266
|
+
...formatCandidates("候选项目目录:", candidates),
|
|
251
267
|
"可重新运行 `npx helloloop` 后按提示选择,或显式补充项目路径,例如:",
|
|
252
|
-
"npx helloloop --repo <
|
|
268
|
+
"npx helloloop --repo <PROJECT_DIR>",
|
|
253
269
|
].filter(Boolean).join("\n");
|
|
254
270
|
}
|
|
255
271
|
|
|
256
272
|
export function renderMissingDocsMessage(repoRoot, candidates = []) {
|
|
257
273
|
return [
|
|
258
|
-
"
|
|
259
|
-
|
|
274
|
+
"未找到开发文档。",
|
|
275
|
+
`当前项目目录:${repoRoot.replaceAll("\\", "/")}`,
|
|
260
276
|
...formatCandidates("候选开发文档:", candidates),
|
|
261
277
|
"可重新运行 `npx helloloop` 后按提示选择,或显式补充开发文档路径,例如:",
|
|
262
278
|
"npx helloloop --docs <DOCS_PATH>",
|
package/src/discovery_prompt.mjs
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
listDocFilesInDirectory,
|
|
6
|
-
listProjectCandidatesInDirectory,
|
|
7
|
-
pathExists,
|
|
8
|
-
resolveAbsolute,
|
|
9
|
-
} from "./discovery_paths.mjs";
|
|
4
|
+
import { pathExists, resolveAbsolute } from "./discovery_paths.mjs";
|
|
10
5
|
import { createPromptSession } from "./prompt_session.mjs";
|
|
11
6
|
|
|
12
7
|
function toDisplayPath(targetPath) {
|
|
@@ -31,63 +26,21 @@ function summarizeList(items, options = {}) {
|
|
|
31
26
|
return lines;
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
function
|
|
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) {
|
|
29
|
+
function renderExistingChoices(title, candidates, footer = "请输入编号;也可以直接输入本地路径;直接回车取消。") {
|
|
77
30
|
return [
|
|
78
31
|
title,
|
|
79
32
|
...candidates.map((item, index) => `${index + 1}. ${toDisplayPath(item)}`),
|
|
80
33
|
"",
|
|
81
|
-
|
|
34
|
+
footer,
|
|
82
35
|
].join("\n");
|
|
83
36
|
}
|
|
84
37
|
|
|
85
|
-
async function promptForExistingPathSelection(readline, title, candidates, cwd,
|
|
86
|
-
if (preface) {
|
|
87
|
-
console.log(preface);
|
|
38
|
+
async function promptForExistingPathSelection(readline, title, candidates, cwd, options = {}) {
|
|
39
|
+
if (options.preface) {
|
|
40
|
+
console.log(options.preface);
|
|
88
41
|
console.log("");
|
|
89
42
|
}
|
|
90
|
-
console.log(renderExistingChoices(title, candidates));
|
|
43
|
+
console.log(renderExistingChoices(title, candidates, options.footer));
|
|
91
44
|
while (true) {
|
|
92
45
|
const answer = String(await readline.question("> ") || "").trim();
|
|
93
46
|
if (!answer) {
|
|
@@ -104,26 +57,91 @@ async function promptForExistingPathSelection(readline, title, candidates, cwd,
|
|
|
104
57
|
return maybePath;
|
|
105
58
|
}
|
|
106
59
|
|
|
107
|
-
console.log("输入无效,请输入候选编号或一个存在的本地路径。");
|
|
60
|
+
console.log(options.invalidMessage || "输入无效,请输入候选编号或一个存在的本地路径。");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createCommonDocsPathHints(repoRoot) {
|
|
65
|
+
return [
|
|
66
|
+
"./docs",
|
|
67
|
+
"./doc",
|
|
68
|
+
"./documentation",
|
|
69
|
+
"./README.md",
|
|
70
|
+
"./PRD.md",
|
|
71
|
+
"./requirements.md",
|
|
72
|
+
"./spec.md",
|
|
73
|
+
"./design.md",
|
|
74
|
+
"./plan.md",
|
|
75
|
+
"./roadmap.md",
|
|
76
|
+
].map((entry) => {
|
|
77
|
+
if (!repoRoot) {
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
return toDisplayPath(path.join(repoRoot, entry.replace(/^\.\//, "")));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderDocsPromptPreface(discovery, cwd) {
|
|
85
|
+
const repoRoot = discovery.repoRoot || "";
|
|
86
|
+
const lines = [];
|
|
87
|
+
|
|
88
|
+
if (repoRoot) {
|
|
89
|
+
lines.push("未找到开发文档。");
|
|
90
|
+
lines.push(`项目目录:${toDisplayPath(repoRoot)}`);
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (discovery.workspaceRoot) {
|
|
95
|
+
lines.push("当前目录更像工作区,暂时不直接作为项目目录。");
|
|
96
|
+
lines.push(`当前目录:${toDisplayPath(discovery.workspaceRoot)}`);
|
|
97
|
+
if (Array.isArray(discovery.repoCandidates) && discovery.repoCandidates.length) {
|
|
98
|
+
lines.push(`候选项目目录:${discovery.repoCandidates.length} 个,稍后再确认。`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push("未找到开发文档。");
|
|
104
|
+
lines.push(`当前目录:${toDisplayPath(cwd)}`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderRepoPromptPreface(discovery, cwd) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
if (discovery.workspaceRoot) {
|
|
111
|
+
lines.push("当前目录更像工作区,不能直接作为项目目录。");
|
|
112
|
+
lines.push(`当前目录:${toDisplayPath(discovery.workspaceRoot)}`);
|
|
113
|
+
} else {
|
|
114
|
+
lines.push("还需要确认项目目录。");
|
|
115
|
+
lines.push(`当前目录:${toDisplayPath(cwd)}`);
|
|
108
116
|
}
|
|
117
|
+
|
|
118
|
+
if (Array.isArray(discovery.docsEntries) && discovery.docsEntries.length) {
|
|
119
|
+
lines.push(`开发文档:${discovery.docsEntries.map((item) => toDisplayPath(item)).join(",")}`);
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n");
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
async function promptForDocsPath(readline, discovery, cwd) {
|
|
112
125
|
const docChoices = Array.isArray(discovery.docCandidates) ? discovery.docCandidates : [];
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const title = docChoices.length
|
|
116
|
-
? "请选择开发文档来源:"
|
|
117
|
-
: "未自动识别到明确的开发文档。请输入开发文档目录或文件路径:";
|
|
118
|
-
const preface = renderDirectoryOverview("当前目录顶层概览", overview);
|
|
126
|
+
const repoRoot = discovery.repoRoot || "";
|
|
127
|
+
const preface = renderDocsPromptPreface(discovery, cwd);
|
|
119
128
|
|
|
120
129
|
if (docChoices.length) {
|
|
121
|
-
return promptForExistingPathSelection(readline,
|
|
130
|
+
return promptForExistingPathSelection(readline, "请选择开发文档:", docChoices, cwd, {
|
|
131
|
+
preface,
|
|
132
|
+
footer: "请输入编号;也可以直接输入已存在的本地路径;直接回车取消。",
|
|
133
|
+
invalidMessage: "输入无效,请输入候选编号或一个已存在的开发文档路径。",
|
|
134
|
+
});
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
console.log(preface);
|
|
125
138
|
console.log("");
|
|
126
|
-
|
|
139
|
+
if (repoRoot) {
|
|
140
|
+
console.log("已检查这些常见位置:");
|
|
141
|
+
console.log(summarizeList(createCommonDocsPathHints(repoRoot)).join("\n"));
|
|
142
|
+
console.log("");
|
|
143
|
+
}
|
|
144
|
+
console.log("请输入开发文档路径(文件或目录):");
|
|
127
145
|
console.log("可直接输入当前目录下的相对路径或绝对路径;直接回车取消。");
|
|
128
146
|
while (true) {
|
|
129
147
|
const answer = String(await readline.question("> ") || "").trim();
|
|
@@ -142,21 +160,19 @@ async function promptForDocsPath(readline, discovery, cwd) {
|
|
|
142
160
|
|
|
143
161
|
async function promptForRepoPath(readline, discovery, cwd) {
|
|
144
162
|
const repoChoices = Array.isArray(discovery.repoCandidates) ? discovery.repoCandidates : [];
|
|
145
|
-
const
|
|
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);
|
|
163
|
+
const preface = renderRepoPromptPreface(discovery, cwd);
|
|
152
164
|
const title = repoChoices.length
|
|
153
|
-
? "
|
|
154
|
-
: "
|
|
165
|
+
? "请选择要开发的项目目录:"
|
|
166
|
+
: "请输入要开发的项目目录:";
|
|
167
|
+
|
|
155
168
|
console.log(preface);
|
|
156
169
|
console.log("");
|
|
157
170
|
if (repoChoices.length) {
|
|
158
|
-
console.log(renderExistingChoices(
|
|
159
|
-
|
|
171
|
+
console.log(renderExistingChoices(
|
|
172
|
+
title,
|
|
173
|
+
repoChoices,
|
|
174
|
+
"请输入编号;也可以直接输入项目路径;如果这是新项目,可输入准备创建的新目录路径;直接回车取消。",
|
|
175
|
+
));
|
|
160
176
|
} else {
|
|
161
177
|
console.log(title);
|
|
162
178
|
console.log("如果这是新项目,可直接输入准备创建的新目录路径;直接回车取消。");
|
|
@@ -258,9 +274,9 @@ export async function resolveDiscoveryFailureInteractively(
|
|
|
258
274
|
: "interactive";
|
|
259
275
|
changed = true;
|
|
260
276
|
if (selectedRepo.allowNewRepoRoot) {
|
|
261
|
-
console.log(
|
|
277
|
+
console.log(`已指定项目目录(当前不存在,将按新项目创建):${toDisplayPath(selectedRepo.repoRoot)}`);
|
|
262
278
|
} else {
|
|
263
|
-
console.log(
|
|
279
|
+
console.log(`已选择项目目录:${toDisplayPath(selectedRepo.repoRoot)}`);
|
|
264
280
|
}
|
|
265
281
|
console.log("");
|
|
266
282
|
}
|
|
@@ -148,6 +148,10 @@ export function saveUserSettings(settings, options = {}) {
|
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function tryParseUserSettingsText(text) {
|
|
152
|
+
return JSON.parse(String(text || ""));
|
|
153
|
+
}
|
|
154
|
+
|
|
151
155
|
export function syncUserSettingsFile(options = {}) {
|
|
152
156
|
const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
|
|
153
157
|
const defaults = defaultUserSettings();
|
|
@@ -161,12 +165,33 @@ export function syncUserSettingsFile(options = {}) {
|
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
|
|
168
|
+
const firstText = readText(settingsFile);
|
|
165
169
|
try {
|
|
166
|
-
parsed =
|
|
170
|
+
const parsed = tryParseUserSettingsText(firstText);
|
|
171
|
+
writeJson(settingsFile, syncUserSettingsShape(parsed));
|
|
172
|
+
return {
|
|
173
|
+
settingsFile,
|
|
174
|
+
action: "synced",
|
|
175
|
+
backupFile: "",
|
|
176
|
+
};
|
|
167
177
|
} catch (error) {
|
|
178
|
+
const retryText = readText(settingsFile);
|
|
179
|
+
if (retryText !== firstText) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = tryParseUserSettingsText(retryText);
|
|
182
|
+
writeJson(settingsFile, syncUserSettingsShape(parsed));
|
|
183
|
+
return {
|
|
184
|
+
settingsFile,
|
|
185
|
+
action: "synced",
|
|
186
|
+
backupFile: "",
|
|
187
|
+
recoveredAfterRetry: true,
|
|
188
|
+
};
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
const backupFile = `${settingsFile}.invalid-${timestampForFile()}.bak`;
|
|
169
|
-
writeText(backupFile,
|
|
194
|
+
writeText(backupFile, retryText);
|
|
170
195
|
writeJson(settingsFile, defaults);
|
|
171
196
|
return {
|
|
172
197
|
settingsFile,
|
|
@@ -175,11 +200,4 @@ export function syncUserSettingsFile(options = {}) {
|
|
|
175
200
|
error: String(error?.message || error || ""),
|
|
176
201
|
};
|
|
177
202
|
}
|
|
178
|
-
|
|
179
|
-
writeJson(settingsFile, syncUserSettingsShape(parsed));
|
|
180
|
-
return {
|
|
181
|
-
settingsFile,
|
|
182
|
-
action: "synced",
|
|
183
|
-
backupFile: "",
|
|
184
|
-
};
|
|
185
203
|
}
|
package/src/install_codex.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { ensureDir, fileExists, readJson, writeJson } from "./common.mjs";
|
|
3
|
+
import { ensureDir, fileExists, readJson, readTextIfExists, writeJson, writeText } from "./common.mjs";
|
|
4
4
|
import {
|
|
5
5
|
assertPathInside,
|
|
6
6
|
codexBundleEntries,
|
|
@@ -8,15 +8,75 @@ import {
|
|
|
8
8
|
readExistingJsonOrThrow,
|
|
9
9
|
removePathIfExists,
|
|
10
10
|
removeTargetIfNeeded,
|
|
11
|
+
resolveCodexLocalRoot,
|
|
11
12
|
resolveHomeDir,
|
|
12
13
|
} from "./install_shared.mjs";
|
|
13
14
|
|
|
15
|
+
const CODEX_MARKETPLACE_NAME = "local-plugins";
|
|
16
|
+
const CODEX_PLUGIN_NAME = "helloloop";
|
|
17
|
+
const CODEX_PLUGIN_KEY = `${CODEX_PLUGIN_NAME}@${CODEX_MARKETPLACE_NAME}`;
|
|
18
|
+
const CODEX_PLUGIN_CONFIG_HEADER = `[plugins."${CODEX_PLUGIN_KEY}"]`;
|
|
19
|
+
const CODEX_PLUGIN_CONFIG_BLOCK = `${CODEX_PLUGIN_CONFIG_HEADER}\nenabled = true`;
|
|
20
|
+
|
|
21
|
+
function isTomlTableHeader(line) {
|
|
22
|
+
const trimmed = String(line || "").trim();
|
|
23
|
+
return trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripTomlSection(text, headerLine) {
|
|
27
|
+
const lines = String(text || "").replaceAll("\r\n", "\n").split("\n");
|
|
28
|
+
const kept = [];
|
|
29
|
+
let removed = false;
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < lines.length;) {
|
|
32
|
+
if (lines[index].trim() === headerLine) {
|
|
33
|
+
removed = true;
|
|
34
|
+
index += 1;
|
|
35
|
+
while (index < lines.length && !isTomlTableHeader(lines[index])) {
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
kept.push(lines[index]);
|
|
42
|
+
index += 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const nextText = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
46
|
+
return {
|
|
47
|
+
removed,
|
|
48
|
+
text: nextText ? `${nextText}\n` : "",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function upsertCodexPluginConfig(configFile) {
|
|
53
|
+
const existingText = readTextIfExists(configFile, "");
|
|
54
|
+
const stripped = stripTomlSection(existingText, CODEX_PLUGIN_CONFIG_HEADER).text.trimEnd();
|
|
55
|
+
const nextText = stripped
|
|
56
|
+
? `${stripped}\n\n${CODEX_PLUGIN_CONFIG_BLOCK}\n`
|
|
57
|
+
: `${CODEX_PLUGIN_CONFIG_BLOCK}\n`;
|
|
58
|
+
writeText(configFile, nextText);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function removeCodexPluginConfig(configFile) {
|
|
62
|
+
if (!fileExists(configFile)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const existingText = readTextIfExists(configFile, "");
|
|
67
|
+
const result = stripTomlSection(existingText, CODEX_PLUGIN_CONFIG_HEADER);
|
|
68
|
+
if (result.removed) {
|
|
69
|
+
writeText(configFile, result.text);
|
|
70
|
+
}
|
|
71
|
+
return result.removed;
|
|
72
|
+
}
|
|
73
|
+
|
|
14
74
|
function updateCodexMarketplace(marketplaceFile, existingMarketplace = null) {
|
|
15
75
|
const marketplace = existingMarketplace
|
|
16
76
|
|| (fileExists(marketplaceFile)
|
|
17
77
|
? readJson(marketplaceFile)
|
|
18
78
|
: {
|
|
19
|
-
name:
|
|
79
|
+
name: CODEX_MARKETPLACE_NAME,
|
|
20
80
|
interface: {
|
|
21
81
|
displayName: "Local Plugins",
|
|
22
82
|
},
|
|
@@ -29,10 +89,10 @@ function updateCodexMarketplace(marketplaceFile, existingMarketplace = null) {
|
|
|
29
89
|
marketplace.plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
30
90
|
|
|
31
91
|
const nextEntry = {
|
|
32
|
-
name:
|
|
92
|
+
name: CODEX_PLUGIN_NAME,
|
|
33
93
|
source: {
|
|
34
94
|
source: "local",
|
|
35
|
-
path:
|
|
95
|
+
path: `./plugins/${CODEX_PLUGIN_NAME}`,
|
|
36
96
|
},
|
|
37
97
|
policy: {
|
|
38
98
|
installation: "AVAILABLE",
|
|
@@ -41,7 +101,7 @@ function updateCodexMarketplace(marketplaceFile, existingMarketplace = null) {
|
|
|
41
101
|
category: "Coding",
|
|
42
102
|
};
|
|
43
103
|
|
|
44
|
-
const existingIndex = marketplace.plugins.findIndex((plugin) => plugin?.name ===
|
|
104
|
+
const existingIndex = marketplace.plugins.findIndex((plugin) => plugin?.name === CODEX_PLUGIN_NAME);
|
|
45
105
|
if (existingIndex >= 0) {
|
|
46
106
|
marketplace.plugins.splice(existingIndex, 1, nextEntry);
|
|
47
107
|
} else {
|
|
@@ -58,7 +118,7 @@ function removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace = null
|
|
|
58
118
|
|
|
59
119
|
const marketplace = existingMarketplace || readJson(marketplaceFile);
|
|
60
120
|
const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
61
|
-
const nextPlugins = plugins.filter((plugin) => plugin?.name !==
|
|
121
|
+
const nextPlugins = plugins.filter((plugin) => plugin?.name !== CODEX_PLUGIN_NAME);
|
|
62
122
|
if (nextPlugins.length === plugins.length) {
|
|
63
123
|
return false;
|
|
64
124
|
}
|
|
@@ -70,49 +130,101 @@ function removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace = null
|
|
|
70
130
|
|
|
71
131
|
export function installCodexHost(bundleRoot, options = {}) {
|
|
72
132
|
const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
133
|
+
const resolvedLocalRoot = resolveCodexLocalRoot(options.codexHome);
|
|
134
|
+
const targetPluginsRoot = path.join(resolvedLocalRoot, "plugins");
|
|
135
|
+
const targetPluginRoot = path.join(targetPluginsRoot, CODEX_PLUGIN_NAME);
|
|
136
|
+
const legacyTargetPluginRoot = path.join(resolvedCodexHome, "plugins", CODEX_PLUGIN_NAME);
|
|
137
|
+
const targetPluginCacheRoot = path.join(
|
|
138
|
+
resolvedCodexHome,
|
|
139
|
+
"plugins",
|
|
140
|
+
"cache",
|
|
141
|
+
CODEX_MARKETPLACE_NAME,
|
|
142
|
+
CODEX_PLUGIN_NAME,
|
|
143
|
+
);
|
|
144
|
+
const targetInstalledPluginRoot = path.join(targetPluginCacheRoot, "local");
|
|
145
|
+
const marketplaceFile = path.join(resolvedLocalRoot, ".agents", "plugins", "marketplace.json");
|
|
146
|
+
const legacyMarketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
|
|
147
|
+
const configFile = path.join(resolvedCodexHome, "config.toml");
|
|
76
148
|
const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
|
|
77
149
|
const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
|
|
150
|
+
const existingLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
|
|
151
|
+
? existingMarketplace
|
|
152
|
+
: readExistingJsonOrThrow(legacyMarketplaceFile, "Codex legacy marketplace 配置");
|
|
78
153
|
|
|
79
154
|
if (!fileExists(manifestFile)) {
|
|
80
155
|
throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
|
|
81
156
|
}
|
|
82
157
|
|
|
83
|
-
assertPathInside(
|
|
158
|
+
assertPathInside(resolvedLocalRoot, targetPluginRoot, "Codex 本地插件目录");
|
|
159
|
+
assertPathInside(resolvedCodexHome, targetPluginCacheRoot, "Codex 插件缓存目录");
|
|
84
160
|
removeTargetIfNeeded(targetPluginRoot, options.force);
|
|
161
|
+
removeTargetIfNeeded(targetPluginCacheRoot, options.force);
|
|
162
|
+
if (legacyTargetPluginRoot !== targetPluginRoot) {
|
|
163
|
+
removePathIfExists(legacyTargetPluginRoot);
|
|
164
|
+
}
|
|
85
165
|
|
|
86
166
|
ensureDir(targetPluginsRoot);
|
|
87
167
|
ensureDir(targetPluginRoot);
|
|
168
|
+
ensureDir(targetInstalledPluginRoot);
|
|
88
169
|
copyBundleEntries(bundleRoot, targetPluginRoot, codexBundleEntries);
|
|
170
|
+
copyBundleEntries(bundleRoot, targetInstalledPluginRoot, codexBundleEntries);
|
|
89
171
|
removePathIfExists(path.join(targetPluginRoot, ".git"));
|
|
172
|
+
removePathIfExists(path.join(targetInstalledPluginRoot, ".git"));
|
|
90
173
|
|
|
91
174
|
ensureDir(path.dirname(marketplaceFile));
|
|
92
175
|
updateCodexMarketplace(marketplaceFile, existingMarketplace);
|
|
176
|
+
if (legacyMarketplaceFile !== marketplaceFile) {
|
|
177
|
+
removeCodexMarketplaceEntry(legacyMarketplaceFile, existingLegacyMarketplace);
|
|
178
|
+
}
|
|
179
|
+
upsertCodexPluginConfig(configFile);
|
|
93
180
|
|
|
94
181
|
return {
|
|
95
182
|
host: "codex",
|
|
96
183
|
displayName: "Codex",
|
|
97
184
|
targetRoot: targetPluginRoot,
|
|
185
|
+
installedRoot: targetInstalledPluginRoot,
|
|
98
186
|
marketplaceFile,
|
|
187
|
+
configFile,
|
|
99
188
|
};
|
|
100
189
|
}
|
|
101
190
|
|
|
102
191
|
export function uninstallCodexHost(options = {}) {
|
|
103
192
|
const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
|
|
104
|
-
const
|
|
105
|
-
const
|
|
193
|
+
const resolvedLocalRoot = resolveCodexLocalRoot(options.codexHome);
|
|
194
|
+
const targetPluginRoot = path.join(resolvedLocalRoot, "plugins", CODEX_PLUGIN_NAME);
|
|
195
|
+
const legacyTargetPluginRoot = path.join(resolvedCodexHome, "plugins", CODEX_PLUGIN_NAME);
|
|
196
|
+
const targetPluginCacheRoot = path.join(
|
|
197
|
+
resolvedCodexHome,
|
|
198
|
+
"plugins",
|
|
199
|
+
"cache",
|
|
200
|
+
CODEX_MARKETPLACE_NAME,
|
|
201
|
+
CODEX_PLUGIN_NAME,
|
|
202
|
+
);
|
|
203
|
+
const marketplaceFile = path.join(resolvedLocalRoot, ".agents", "plugins", "marketplace.json");
|
|
204
|
+
const legacyMarketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
|
|
205
|
+
const configFile = path.join(resolvedCodexHome, "config.toml");
|
|
106
206
|
const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
|
|
207
|
+
const existingLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
|
|
208
|
+
? existingMarketplace
|
|
209
|
+
: readExistingJsonOrThrow(legacyMarketplaceFile, "Codex legacy marketplace 配置");
|
|
107
210
|
|
|
108
211
|
const removedPlugin = removePathIfExists(targetPluginRoot);
|
|
212
|
+
const removedLegacyPlugin = legacyTargetPluginRoot === targetPluginRoot
|
|
213
|
+
? false
|
|
214
|
+
: removePathIfExists(legacyTargetPluginRoot);
|
|
215
|
+
const removedCache = removePathIfExists(targetPluginCacheRoot);
|
|
109
216
|
const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace);
|
|
217
|
+
const removedLegacyMarketplace = legacyMarketplaceFile === marketplaceFile
|
|
218
|
+
? false
|
|
219
|
+
: removeCodexMarketplaceEntry(legacyMarketplaceFile, existingLegacyMarketplace);
|
|
220
|
+
const removedConfig = removeCodexPluginConfig(configFile);
|
|
110
221
|
|
|
111
222
|
return {
|
|
112
223
|
host: "codex",
|
|
113
224
|
displayName: "Codex",
|
|
114
225
|
targetRoot: targetPluginRoot,
|
|
115
|
-
removed: removedPlugin || removedMarketplace,
|
|
226
|
+
removed: removedPlugin || removedLegacyPlugin || removedCache || removedMarketplace || removedLegacyMarketplace || removedConfig,
|
|
116
227
|
marketplaceFile,
|
|
228
|
+
configFile,
|
|
117
229
|
};
|
|
118
230
|
}
|
package/src/install_shared.mjs
CHANGED
|
@@ -31,6 +31,14 @@ export function resolveHomeDir(homeDir, defaultDirName) {
|
|
|
31
31
|
return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function resolveCodexLocalRoot(codexHome) {
|
|
35
|
+
const resolvedCodexHome = resolveHomeDir(codexHome, ".codex");
|
|
36
|
+
if (path.basename(resolvedCodexHome).toLowerCase() === ".codex") {
|
|
37
|
+
return path.dirname(resolvedCodexHome);
|
|
38
|
+
}
|
|
39
|
+
return resolvedCodexHome;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export function assertPathInside(parentDir, targetDir, label) {
|
|
35
43
|
const relative = path.relative(parentDir, targetDir);
|
|
36
44
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|