helloloop 0.7.3 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "HelloLoop 的 Claude Code 原生插件元数据,用于多 CLI 宿主分发。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件,Codex 路径为首发与参考实现。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
package/README.md CHANGED
@@ -29,10 +29,16 @@
29
29
 
30
30
  | 宿主 | 安装方式 | 原生入口 | 说明 |
31
31
  | --- | --- | --- | --- |
32
- | `Codex CLI` | `helloloop install` / `helloloop install --host codex` | `$helloloop` / `npx helloloop` | 仅在显式调用时进入 HelloLoop |
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,7 +304,7 @@ npx helloloop install --host all --force
290
304
  说明:
291
305
 
292
306
  - `--force` 会清理当前宿主里已有安装残留的 `helloloop` 目录后再重装
293
- - `Codex` 会刷新插件目录和 marketplace 条目
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` 的当前版本结构:补齐缺失项、清理未知项、保留已知项现有值
@@ -334,7 +348,7 @@ $helloloop
334
348
  helloloop:helloloop
335
349
  ```
336
350
 
337
- `Codex` 中也可以直接执行:
351
+ 在终端里(包括 `Codex` 打开的内置终端)也可以直接执行:
338
352
 
339
353
  ```bash
340
354
  npx helloloop
@@ -413,8 +427,10 @@ npx helloloop doctor --host all --codex-home <CODEX_HOME> --claude-home <CLAUDE_
413
427
 
414
428
  ### `Codex CLI`
415
429
 
416
- - 插件目录:`~/.codex/plugins/helloloop/`
417
- - 注册文件:`~/.codex/.agents/plugins/marketplace.json`
430
+ - 插件源码目录:`~/plugins/helloloop/`
431
+ - 已安装缓存:`~/.codex/plugins/cache/local-plugins/helloloop/local/`
432
+ - marketplace:`~/.agents/plugins/marketplace.json`
433
+ - 启用配置:`~/.codex/config.toml`
418
434
 
419
435
  ### `Claude Code`
420
436
 
@@ -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
  - 已实现事项
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "HelloLoop 的 Gemini CLI 原生扩展,用于按开发文档接续推进项目开发。",
5
5
  "contextFileName": "GEMINI.md",
6
6
  "excludeTools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件",
5
5
  "author": "HelloLoop",
6
6
  "license": "Apache-2.0",
@@ -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
- `- 目标仓库:${context.repoRoot.replaceAll("\\", "/")}`,
86
+ `- 项目目录:${context.repoRoot.replaceAll("\\", "/")}`,
87
87
  `- 项目状态:${repoState}`,
88
- `- 仓库来源:${repoResolution?.sourceLabel || "自动判断"}`,
89
- `- 仓库把握:${repoResolution?.confidenceLabel || "中"}`,
90
- `- 仓库依据:${repoBasis}`,
88
+ `- 目录来源:${repoResolution?.sourceLabel || "自动判断"}`,
89
+ `- 目录把握:${repoResolution?.confidenceLabel || "中"}`,
90
+ `- 目录依据:${repoBasis}`,
91
91
  ];
92
92
  }
93
93
 
@@ -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("- Codex:`$helloloop` / `npx helloloop`");
51
- lines.push("- Claude:`/helloloop`");
52
- lines.push("- Gemini:`/helloloop`");
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
  }
@@ -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(path.join(options.codexHome, "plugins", "helloloop", ".codex-plugin", "plugin.json")),
139
- detail: path.join(options.codexHome, "plugins", "helloloop", ".codex-plugin", "plugin.json"),
156
+ ok: fileExists(codexInstalledPluginFile),
157
+ detail: codexInstalledPluginFile,
140
158
  });
141
159
  checks.push({
142
160
  name: "codex marketplace",
143
- ok: fileExists(path.join(options.codexHome, ".agents", "plugins", "marketplace.json")),
144
- detail: path.join(options.codexHome, ".agents", "plugins", "marketplace.json"),
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
- repo_docs: { label: "仓库 docs 目录", confidence: "medium" },
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 === "workspace" || classified.kind === "directory") {
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" || cwdClassified.kind === "directory") {
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 === "repo_docs") {
316
- pushBasis(docsBasis, "已使用目标仓库中的 `docs/` 目录作为默认开发文档入口。");
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 repoDocsCandidates = uniquePaths([
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
- if (repoDocsCandidates.length === 1) {
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, repoDocsCandidates[0])],
234
- source: "repo_docs",
235
- candidates: repoDocsCandidates,
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: repoDocsCandidates,
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("候选项目:", candidates),
266
+ ...formatCandidates("候选项目目录:", candidates),
251
267
  "可重新运行 `npx helloloop` 后按提示选择,或显式补充项目路径,例如:",
252
- "npx helloloop --repo <PROJECT_ROOT>",
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
- `已找到项目仓库:${repoRoot.replaceAll("\\", "/")}`,
274
+ "未找到开发文档。",
275
+ `当前项目目录:${repoRoot.replaceAll("\\", "/")}`,
260
276
  ...formatCandidates("候选开发文档:", candidates),
261
277
  "可重新运行 `npx helloloop` 后按提示选择,或显式补充开发文档路径,例如:",
262
278
  "npx helloloop --docs <DOCS_PATH>",
@@ -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 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) {
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, preface = "") {
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 scanRoot = discovery.workspaceRoot || discovery.repoRoot || cwd;
114
- const overview = collectDirectoryOverview(scanRoot);
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, title, docChoices, cwd, preface);
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
- console.log(title);
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 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);
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(title, repoChoices));
159
- console.log("也可以直接输入项目路径;如果这是新项目,可输入准备创建的新目录路径。");
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(`已指定项目路径(当前不存在,将按新项目创建):${toDisplayPath(selectedRepo.repoRoot)}`);
277
+ console.log(`已指定项目目录(当前不存在,将按新项目创建):${toDisplayPath(selectedRepo.repoRoot)}`);
262
278
  } else {
263
- console.log(`已选择项目仓库:${toDisplayPath(selectedRepo.repoRoot)}`);
279
+ console.log(`已选择项目目录:${toDisplayPath(selectedRepo.repoRoot)}`);
264
280
  }
265
281
  console.log("");
266
282
  }
@@ -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: "local-plugins",
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: "helloloop",
92
+ name: CODEX_PLUGIN_NAME,
33
93
  source: {
34
94
  source: "local",
35
- path: "./plugins/helloloop",
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 === "helloloop");
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 !== "helloloop");
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 targetPluginsRoot = path.join(resolvedCodexHome, "plugins");
74
- const targetPluginRoot = path.join(targetPluginsRoot, "helloloop");
75
- const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
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(resolvedCodexHome, targetPluginRoot, "Codex 目标插件目录");
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 targetPluginRoot = path.join(resolvedCodexHome, "plugins", "helloloop");
105
- const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
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
  }
@@ -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)) {