helloloop 0.7.0 → 0.7.2

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.0",
3
+ "version": "0.7.2",
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.0",
3
+ "version": "0.7.2",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件,Codex 路径为首发与参考实现。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
@@ -17,7 +17,7 @@
17
17
  "interface": {
18
18
  "displayName": "HelloLoop",
19
19
  "shortDescription": "显式调用后按 backlog 持续开发与验证",
20
- "longDescription": "HelloLoop 把基于 backlog 的持续开发能力封装为官方 Codex 插件 bundle。只有在用户显式调用 helloloop skill,或明确要求使用 HelloLoop 时才应介入;进入后默认优先走 npx helloloop 主 CLI 流程:支持 npx helloloop、npx helloloop <PATH>、npx helloloop codex|claude|gemini ...,先分析、再展示确认单、确认后自动接续推进;运行中按无人值守自动恢复持续推进主线。",
20
+ "longDescription": "HelloLoop 把基于 backlog 的持续开发能力封装为官方 Codex 插件 bundle。只有在用户显式调用 helloloop skill 时才应介入;进入后默认优先走 npx helloloop 主 CLI 流程:支持 npx helloloop、npx helloloop <PATH>、npx helloloop codex|claude|gemini ...,先分析、再展示确认单、确认后自动接续推进;运行中按无人值守自动恢复持续推进主线。",
21
21
  "developerName": "HelloLoop",
22
22
  "category": "Coding",
23
23
  "capabilities": [
@@ -25,7 +25,7 @@
25
25
  "Write"
26
26
  ],
27
27
  "defaultPrompt": [
28
- "只有当用户显式调用 $helloloop / #helloloop / helloloop:helloloop,或明确要求使用 HelloLoop 时,才进入 npx helloloop 主 CLI 流程;普通 Codex 会话不要自动接管。进入后优先执行 npx helloloop 或 npx helloloop <PATH>;如果用户明确指定执行引擎,也允许使用 npx helloloop codex|claude|gemini ...。启动前确认一次,启动后按无人值守自动恢复与主线续跑执行,不要在对话里手工模拟流程。"
28
+ "只有当用户显式调用 $helloloop / #helloloop / helloloop:helloloop 时,才进入 npx helloloop 主 CLI 流程;普通 Codex 会话不要自动接管。仅仅提到 helloloop 仓库、插件名、README、代码、测试、issue、release、npm 包名,都不算调用。进入后优先执行 npx helloloop 或 npx helloloop <PATH>;如果用户明确指定执行引擎,也允许使用 npx helloloop codex|claude|gemini ...。启动前确认一次,启动后按无人值守自动恢复与主线续跑执行,不要在对话里手工模拟流程。"
29
29
  ],
30
30
  "brandColor": "#0F766E"
31
31
  }
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  这意味着:
20
20
 
21
21
  - 三宿主都只在用户显式调用 `helloloop` 时介入;普通会话不会被 HelloLoop 自动接管
22
+ - 在 `Codex` 中,只有显式输入 `$helloloop` / `#helloloop` / `helloloop:helloloop` 才算调用;仅仅提到 `helloloop` 仓库、README、代码、测试、release、npm 包名,都不算调用
22
23
  - 无论在终端还是在 `Codex` / `Claude` / `Gemini` 宿主内,只要用户未明确指定引擎,`HelloLoop` 都会先询问本轮执行引擎
23
24
  - 当前宿主、项目历史、用户历史只作为推荐依据,不会自动替你选中引擎
24
25
  - 如果你已经显式指定,或已经在首轮确认中明确选定了引擎,本轮就固定按该引擎执行
@@ -292,6 +293,9 @@ npx helloloop install --host all --force
292
293
  - `Codex` 会刷新插件目录和 marketplace 条目
293
294
  - `Claude` 会刷新 marketplace、缓存插件目录,以及 `settings.json` / `known_marketplaces.json` / `installed_plugins.json` 中的 `helloloop` 条目
294
295
  - `Gemini` 会刷新 `extensions/helloloop/`,不会动同目录下其他扩展
296
+ - 安装 / 升级 / 重装时,会同步校准 `~/.helloloop/settings.json` 的当前版本结构:补齐缺失项、清理未知项、保留已知项现有值
297
+ - 如果 `~/.helloloop/settings.json` 不是合法 JSON,会先备份原文件,再按当前版本结构重建
298
+ - 如果宿主自己的配置 JSON(如 `Codex marketplace.json`、`Claude settings.json`、`known_marketplaces.json`、`installed_plugins.json`)本身已损坏,`HelloLoop` 会先明确报错并停止,不会先清理现有安装再失败
295
299
 
296
300
  ### 卸载
297
301
 
@@ -425,6 +429,21 @@ npx helloloop doctor --host all --codex-home <CODEX_HOME> --claude-home <CLAUDE_
425
429
 
426
430
  `HelloLoop` 只维护自己的目录和自己的注册项,不会重写别的插件条目。
427
431
 
432
+ ## 用户目录写入范围
433
+
434
+ 全局用户目录 `~/.helloloop/` 只保留一份全局设置:
435
+
436
+ ```text
437
+ ~/.helloloop/
438
+ └── settings.json
439
+ ```
440
+
441
+ 说明:
442
+
443
+ - 这里不保存项目 backlog、状态、运行记录
444
+ - 安装 / 升级 / 重装时,会对 `settings.json` 做结构校准,但不会校验或篡改你已存在的已知项内容
445
+ - 如果 `settings.json` 非法,会先备份,再重建为当前版本结构
446
+
428
447
  ## `.helloloop/` 状态目录
429
448
 
430
449
  `HelloLoop` 的 backlog、状态和运行记录始终写入目标仓库根目录下的:
@@ -440,6 +459,7 @@ npx helloloop doctor --host all --codex-home <CODEX_HOME> --claude-home <CLAUDE_
440
459
  ```
441
460
 
442
461
  不会写回插件目录自身。
462
+ 也不会写入 `~/.helloloop/`。
443
463
 
444
464
  ## 跨平台与安全
445
465
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "HelloLoop 的 Claude Code 原生插件。",
5
5
  "author": {
6
6
  "name": "HelloLoop"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloloop",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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.0",
3
+ "version": "0.7.2",
4
4
  "description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件",
5
5
  "author": "HelloLoop",
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: helloloop
3
- description: 仅当用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop`,或明确要求使用 HelloLoop 按开发文档持续接续开发时使用。
3
+ description: 仅当用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop` 时使用。
4
4
  ---
5
5
 
6
6
  # HelloLoop
@@ -9,7 +9,8 @@ description: 仅当用户显式调用 `$helloloop` / `#helloloop` / `helloloop:h
9
9
 
10
10
  ## 强制入口规则
11
11
 
12
- - 未显式调用 `helloloop`,且用户也没有明确要求“使用 HelloLoop / 使用 helloloop 插件 / HelloLoop 流程”时,不允许接管普通 Codex 会话。
12
+ - 只有用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop` 时,才允许进入 HelloLoop;未显式调用时,不允许接管普通 Codex 会话。
13
+ - 仅仅提到 `helloloop` 仓库、插件名、README、代码、测试、issue、release、npm 包名,都不算调用 HelloLoop。
13
14
  - 用户显式调用 `$helloloop` / `#helloloop` / `helloloop:helloloop` 时,默认必须优先执行 `npx helloloop` 或 `npx helloloop <PATH>`;如果用户又明确指定了执行引擎,也允许使用 `npx helloloop codex|claude|gemini ...`。
14
15
  - 用户没有明确指定执行引擎时,不允许由 skill 自行补成 `codex` / `claude` / `gemini`;必须让 `HelloLoop` 先完成引擎确认。
15
16
  - 不允许在对话里手工模拟 `HelloLoop` 的分析、确认单、backlog 编排和自动续跑流程来代替 CLI。
@@ -2,11 +2,15 @@ import path from "node:path";
2
2
 
3
3
  import { createContext } from "./context.mjs";
4
4
  import { loadBacklog, scaffoldIfMissing } from "./config.mjs";
5
+ import { syncUserSettingsFile } from "./engine_selection_settings.mjs";
5
6
  import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
6
7
  import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
7
8
  import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
8
9
 
9
10
  export function handleInstallCommand(options) {
11
+ const userSettings = syncUserSettingsFile({
12
+ userSettingsFile: options.userSettingsFile,
13
+ });
10
14
  const context = createContext({
11
15
  repoRoot: options.repoRoot,
12
16
  configDirName: options.configDirName,
@@ -18,6 +22,7 @@ export function handleInstallCommand(options) {
18
22
  claudeHome: options.claudeHome,
19
23
  geminiHome: options.geminiHome,
20
24
  force: options.force,
25
+ userSettings,
21
26
  });
22
27
  console.log(renderInstallSummary(result));
23
28
  return 0;
@@ -29,6 +29,22 @@ export function renderInstallSummary(result) {
29
29
  }
30
30
  }
31
31
 
32
+ if (result.userSettings?.settingsFile) {
33
+ lines.push("");
34
+ lines.push("全局设置:");
35
+ lines.push(`- 配置文件:${result.userSettings.settingsFile}`);
36
+ if (result.userSettings.action === "created") {
37
+ lines.push("- 结构校准:已创建默认 settings.json");
38
+ } else if (result.userSettings.action === "reset_invalid_json") {
39
+ lines.push("- 结构校准:检测到非法 JSON,已重建为当前版本结构");
40
+ if (result.userSettings.backupFile) {
41
+ lines.push(`- 备份文件:${result.userSettings.backupFile}`);
42
+ }
43
+ } else {
44
+ lines.push("- 结构校准:已按当前版本同步 settings.json 项");
45
+ }
46
+ }
47
+
32
48
  lines.push("");
33
49
  lines.push("使用入口:");
34
50
  lines.push("- Codex:`$helloloop` / `npx helloloop`");
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
 
4
5
  import { fileExists } from "./common.mjs";
@@ -59,6 +60,26 @@ const IGNORED_PROJECT_SEGMENTS = new Set([
59
60
  "venv",
60
61
  ]);
61
62
 
63
+ const PREFERRED_ANCESTOR_SEGMENTS = new Set([
64
+ ".cache",
65
+ ".next",
66
+ ".nuxt",
67
+ ".pnpm",
68
+ ".turbo",
69
+ ".venv",
70
+ ".yarn",
71
+ "__pycache__",
72
+ "build",
73
+ "cache",
74
+ "coverage",
75
+ "dist",
76
+ "node_modules",
77
+ "out",
78
+ "target",
79
+ "vendor",
80
+ "venv",
81
+ ]);
82
+
62
83
  function listImmediateDirectories(directoryPath) {
63
84
  if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
64
85
  return [];
@@ -99,13 +120,52 @@ function hasIgnoredProjectBasename(targetPath) {
99
120
  return IGNORED_PROJECT_SEGMENTS.has(path.basename(targetPath).toLowerCase());
100
121
  }
101
122
 
123
+ function canonicalPath(targetPath) {
124
+ if (!targetPath) {
125
+ return "";
126
+ }
127
+
128
+ try {
129
+ const resolved = fs.realpathSync.native
130
+ ? fs.realpathSync.native(targetPath)
131
+ : fs.realpathSync(targetPath);
132
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
133
+ } catch {
134
+ const resolved = path.resolve(targetPath);
135
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
136
+ }
137
+ }
138
+
139
+ function isImplicitHomeDirectory(targetPath) {
140
+ return Boolean(targetPath) && canonicalPath(targetPath) === canonicalPath(os.homedir());
141
+ }
142
+
143
+ function isUserProfileLikeDirectory(targetPath) {
144
+ if (!targetPath) {
145
+ return false;
146
+ }
147
+
148
+ const resolved = path.resolve(targetPath);
149
+ const normalized = resolved.replaceAll("\\", "/");
150
+ if (/^[A-Za-z]:\/Users\/[^/]+$/i.test(normalized)) {
151
+ return true;
152
+ }
153
+ if (/^\/Users\/[^/]+$/.test(normalized)) {
154
+ return true;
155
+ }
156
+ if (/^\/home\/[^/]+$/.test(normalized)) {
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+
102
162
  function choosePreferredCandidate(candidates, directory) {
103
163
  return candidates.find((candidate) => {
104
164
  const relativeToLeaf = path.relative(candidate, directory);
105
165
  return relativeToLeaf
106
166
  .split(/[\\/]+/)
107
167
  .filter(Boolean)
108
- .some((segment) => IGNORED_PROJECT_SEGMENTS.has(segment.toLowerCase()));
168
+ .some((segment) => PREFERRED_ANCESTOR_SEGMENTS.has(segment.toLowerCase()));
109
169
  })
110
170
  || candidates.find((candidate) => !hasIgnoredProjectBasename(candidate))
111
171
  || candidates[0]
@@ -233,6 +293,10 @@ export function findPreferredRepoRootFromPath(startPath) {
233
293
  return current;
234
294
  }
235
295
 
296
+ if (isImplicitHomeDirectory(current) || isUserProfileLikeDirectory(current)) {
297
+ continue;
298
+ }
299
+
236
300
  if (looksLikeStrongProjectRoot(current)) {
237
301
  strongCandidates.push(current);
238
302
  continue;
@@ -327,9 +327,7 @@ export function rememberEngineSelection(context, engineResolution, options = {})
327
327
  });
328
328
  }
329
329
 
330
- const userSettings = loadUserSettings(options);
331
330
  saveUserSettings({
332
- ...userSettings,
333
331
  lastSelectedEngine: engine,
334
332
  }, options);
335
333
  }
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
- import { fileExists, readJson, writeJson } from "./common.mjs";
4
+ import { fileExists, readJson, readText, timestampForFile, writeJson, writeText } from "./common.mjs";
5
5
  import { normalizeEngineName } from "./engine_metadata.mjs";
6
6
 
7
7
  function defaultEmailNotificationSettings() {
@@ -34,6 +34,57 @@ function defaultUserSettings() {
34
34
  };
35
35
  }
36
36
 
37
+ function cloneJsonValue(value) {
38
+ return JSON.parse(JSON.stringify(value));
39
+ }
40
+
41
+ function isPlainObject(value) {
42
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ function syncValueBySchema(schemaValue, currentValue) {
46
+ if (!isPlainObject(schemaValue)) {
47
+ return currentValue === undefined ? cloneJsonValue(schemaValue) : currentValue;
48
+ }
49
+
50
+ const source = isPlainObject(currentValue) ? currentValue : {};
51
+ const next = {};
52
+ for (const [key, childSchema] of Object.entries(schemaValue)) {
53
+ next[key] = syncValueBySchema(childSchema, Object.hasOwn(source, key) ? source[key] : undefined);
54
+ }
55
+ return next;
56
+ }
57
+
58
+ function mergeValueBySchema(schemaValue, baseValue, patchValue) {
59
+ if (!isPlainObject(schemaValue)) {
60
+ return patchValue === undefined ? baseValue : patchValue;
61
+ }
62
+
63
+ const baseObject = isPlainObject(baseValue) ? baseValue : {};
64
+ const patchObject = isPlainObject(patchValue) ? patchValue : {};
65
+ const next = {};
66
+ for (const [key, childSchema] of Object.entries(schemaValue)) {
67
+ const nextBaseValue = Object.hasOwn(baseObject, key) ? baseObject[key] : undefined;
68
+ const hasPatchedKey = isPlainObject(patchValue) && Object.hasOwn(patchObject, key);
69
+ next[key] = mergeValueBySchema(
70
+ childSchema,
71
+ nextBaseValue,
72
+ hasPatchedKey ? patchObject[key] : undefined,
73
+ );
74
+ }
75
+ return next;
76
+ }
77
+
78
+ export function syncUserSettingsShape(settings = {}) {
79
+ return syncValueBySchema(defaultUserSettings(), settings);
80
+ }
81
+
82
+ function readRawUserSettingsDocument(options = {}) {
83
+ const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
84
+ const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
85
+ return syncUserSettingsShape(settings);
86
+ }
87
+
37
88
  export function resolveUserSettingsHome() {
38
89
  return String(process.env.HELLOLOOP_HOME || "").trim()
39
90
  || path.join(os.homedir(), ".helloloop");
@@ -63,11 +114,9 @@ function normalizeEmailNotificationSettings(emailSettings = {}) {
63
114
  }
64
115
 
65
116
  export function loadUserSettingsDocument(options = {}) {
66
- const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
67
- const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
117
+ const settings = readRawUserSettingsDocument(options);
68
118
 
69
119
  return {
70
- ...defaultUserSettings(),
71
120
  ...settings,
72
121
  defaultEngine: normalizeEngineName(settings?.defaultEngine),
73
122
  lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine),
@@ -87,18 +136,50 @@ export function loadUserSettings(options = {}) {
87
136
  }
88
137
 
89
138
  export function saveUserSettings(settings, options = {}) {
90
- const currentSettings = loadUserSettingsDocument(options);
139
+ const currentSettings = readRawUserSettingsDocument(options);
140
+ const mergedSettings = mergeValueBySchema(
141
+ defaultUserSettings(),
142
+ currentSettings,
143
+ settings,
144
+ );
145
+
91
146
  writeJson(resolveUserSettingsFile(options.userSettingsFile), {
92
- ...currentSettings,
93
- ...settings,
94
- defaultEngine: normalizeEngineName(settings?.defaultEngine ?? currentSettings.defaultEngine),
95
- lastSelectedEngine: normalizeEngineName(settings?.lastSelectedEngine ?? currentSettings.lastSelectedEngine),
96
- notifications: {
97
- ...(currentSettings.notifications || {}),
98
- ...(settings?.notifications || {}),
99
- email: normalizeEmailNotificationSettings(
100
- settings?.notifications?.email ?? currentSettings.notifications?.email ?? {},
101
- ),
102
- },
147
+ ...syncUserSettingsShape(mergedSettings),
103
148
  });
104
149
  }
150
+
151
+ export function syncUserSettingsFile(options = {}) {
152
+ const settingsFile = resolveUserSettingsFile(options.userSettingsFile);
153
+ const defaults = defaultUserSettings();
154
+
155
+ if (!fileExists(settingsFile)) {
156
+ writeJson(settingsFile, defaults);
157
+ return {
158
+ settingsFile,
159
+ action: "created",
160
+ backupFile: "",
161
+ };
162
+ }
163
+
164
+ let parsed;
165
+ try {
166
+ parsed = readJson(settingsFile);
167
+ } catch (error) {
168
+ const backupFile = `${settingsFile}.invalid-${timestampForFile()}.bak`;
169
+ writeText(backupFile, readText(settingsFile));
170
+ writeJson(settingsFile, defaults);
171
+ return {
172
+ settingsFile,
173
+ action: "reset_invalid_json",
174
+ backupFile,
175
+ error: String(error?.message || error || ""),
176
+ };
177
+ }
178
+
179
+ writeJson(settingsFile, syncUserSettingsShape(parsed));
180
+ return {
181
+ settingsFile,
182
+ action: "synced",
183
+ backupFile: "",
184
+ };
185
+ }
package/src/install.mjs CHANGED
@@ -38,6 +38,7 @@ export function installPluginBundle(options = {}) {
38
38
  installedHosts,
39
39
  targetPluginRoot: codexResult?.targetRoot || "",
40
40
  marketplaceFile: codexResult?.marketplaceFile || "",
41
+ userSettings: options.userSettings || null,
41
42
  };
42
43
  }
43
44
 
@@ -6,13 +6,14 @@ import {
6
6
  CLAUDE_PLUGIN_KEY,
7
7
  assertPathInside,
8
8
  copyDirectory,
9
+ readExistingJsonOrThrow,
9
10
  removePathIfExists,
10
11
  removeTargetIfNeeded,
11
12
  resolveHomeDir,
12
13
  } from "./install_shared.mjs";
13
14
 
14
- function updateClaudeSettings(settingsFile, marketplaceRoot) {
15
- const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
15
+ function updateClaudeSettings(settingsFile, marketplaceRoot, existingSettings = null) {
16
+ const settings = existingSettings || (fileExists(settingsFile) ? readJson(settingsFile) : {});
16
17
 
17
18
  settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
18
19
  settings.enabledPlugins = settings.enabledPlugins || {};
@@ -27,12 +28,12 @@ function updateClaudeSettings(settingsFile, marketplaceRoot) {
27
28
  writeJson(settingsFile, settings);
28
29
  }
29
30
 
30
- function removeClaudeSettingsEntries(settingsFile) {
31
+ function removeClaudeSettingsEntries(settingsFile, existingSettings = null) {
31
32
  if (!fileExists(settingsFile)) {
32
33
  return false;
33
34
  }
34
35
 
35
- const settings = readJson(settingsFile);
36
+ const settings = existingSettings || readJson(settingsFile);
36
37
  let changed = false;
37
38
 
38
39
  if (settings.extraKnownMarketplaces && Object.hasOwn(settings.extraKnownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
@@ -50,8 +51,13 @@ function removeClaudeSettingsEntries(settingsFile) {
50
51
  return changed;
51
52
  }
52
53
 
53
- function updateClaudeKnownMarketplaces(knownMarketplacesFile, marketplaceRoot, updatedAt) {
54
- const knownMarketplaces = fileExists(knownMarketplacesFile) ? readJson(knownMarketplacesFile) : {};
54
+ function updateClaudeKnownMarketplaces(
55
+ knownMarketplacesFile,
56
+ marketplaceRoot,
57
+ updatedAt,
58
+ existingKnownMarketplaces = null,
59
+ ) {
60
+ const knownMarketplaces = existingKnownMarketplaces || (fileExists(knownMarketplacesFile) ? readJson(knownMarketplacesFile) : {});
55
61
  knownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
56
62
  source: {
57
63
  source: "directory",
@@ -63,12 +69,12 @@ function updateClaudeKnownMarketplaces(knownMarketplacesFile, marketplaceRoot, u
63
69
  writeJson(knownMarketplacesFile, knownMarketplaces);
64
70
  }
65
71
 
66
- function removeClaudeKnownMarketplace(knownMarketplacesFile) {
72
+ function removeClaudeKnownMarketplace(knownMarketplacesFile, existingKnownMarketplaces = null) {
67
73
  if (!fileExists(knownMarketplacesFile)) {
68
74
  return false;
69
75
  }
70
76
 
71
- const knownMarketplaces = readJson(knownMarketplacesFile);
77
+ const knownMarketplaces = existingKnownMarketplaces || readJson(knownMarketplacesFile);
72
78
  if (!Object.hasOwn(knownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
73
79
  return false;
74
80
  }
@@ -78,10 +84,18 @@ function removeClaudeKnownMarketplace(knownMarketplacesFile) {
78
84
  return true;
79
85
  }
80
86
 
81
- function updateClaudeInstalledPlugins(installedPluginsFile, pluginRoot, pluginVersion, updatedAt) {
82
- const installedPlugins = fileExists(installedPluginsFile)
83
- ? readJson(installedPluginsFile)
84
- : { version: 2, plugins: {} };
87
+ function updateClaudeInstalledPlugins(
88
+ installedPluginsFile,
89
+ pluginRoot,
90
+ pluginVersion,
91
+ updatedAt,
92
+ existingInstalledPlugins = null,
93
+ ) {
94
+ const installedPlugins = existingInstalledPlugins
95
+ ? existingInstalledPlugins
96
+ : (fileExists(installedPluginsFile)
97
+ ? readJson(installedPluginsFile)
98
+ : { version: 2, plugins: {} });
85
99
 
86
100
  installedPlugins.version = 2;
87
101
  installedPlugins.plugins = installedPlugins.plugins || {};
@@ -98,12 +112,12 @@ function updateClaudeInstalledPlugins(installedPluginsFile, pluginRoot, pluginVe
98
112
  writeJson(installedPluginsFile, installedPlugins);
99
113
  }
100
114
 
101
- function removeClaudeInstalledPlugin(installedPluginsFile) {
115
+ function removeClaudeInstalledPlugin(installedPluginsFile, existingInstalledPlugins = null) {
102
116
  if (!fileExists(installedPluginsFile)) {
103
117
  return false;
104
118
  }
105
119
 
106
- const installedPlugins = readJson(installedPluginsFile);
120
+ const installedPlugins = existingInstalledPlugins || readJson(installedPluginsFile);
107
121
  if (!installedPlugins.plugins || !Object.hasOwn(installedPlugins.plugins, CLAUDE_PLUGIN_KEY)) {
108
122
  return false;
109
123
  }
@@ -125,6 +139,9 @@ export function installClaudeHost(bundleRoot, options = {}) {
125
139
  const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
126
140
  const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
127
141
  const settingsFile = path.join(resolvedClaudeHome, "settings.json");
142
+ const existingSettings = readExistingJsonOrThrow(settingsFile, "Claude settings 配置");
143
+ const existingKnownMarketplaces = readExistingJsonOrThrow(knownMarketplacesFile, "Claude known_marketplaces 配置");
144
+ const existingInstalledPlugins = readExistingJsonOrThrow(installedPluginsFile, "Claude installed_plugins 配置");
128
145
 
129
146
  if (!fileExists(sourceManifest)) {
130
147
  throw new Error(`未找到 Claude 插件 manifest:${sourceManifest}`);
@@ -144,9 +161,20 @@ export function installClaudeHost(bundleRoot, options = {}) {
144
161
  copyDirectory(sourceMarketplaceRoot, targetMarketplaceRoot);
145
162
  copyDirectory(path.join(sourceMarketplaceRoot, "plugins", "helloloop"), targetInstalledPluginRoot);
146
163
  const updatedAt = nowIso();
147
- updateClaudeSettings(settingsFile, targetMarketplaceRoot);
148
- updateClaudeKnownMarketplaces(knownMarketplacesFile, targetMarketplaceRoot, updatedAt);
149
- updateClaudeInstalledPlugins(installedPluginsFile, targetInstalledPluginRoot, pluginVersion, updatedAt);
164
+ updateClaudeSettings(settingsFile, targetMarketplaceRoot, existingSettings);
165
+ updateClaudeKnownMarketplaces(
166
+ knownMarketplacesFile,
167
+ targetMarketplaceRoot,
168
+ updatedAt,
169
+ existingKnownMarketplaces,
170
+ );
171
+ updateClaudeInstalledPlugins(
172
+ installedPluginsFile,
173
+ targetInstalledPluginRoot,
174
+ pluginVersion,
175
+ updatedAt,
176
+ existingInstalledPlugins,
177
+ );
150
178
 
151
179
  return {
152
180
  host: "claude",
@@ -165,12 +193,15 @@ export function uninstallClaudeHost(options = {}) {
165
193
  const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
166
194
  const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
167
195
  const settingsFile = path.join(resolvedClaudeHome, "settings.json");
196
+ const existingSettings = readExistingJsonOrThrow(settingsFile, "Claude settings 配置");
197
+ const existingKnownMarketplaces = readExistingJsonOrThrow(knownMarketplacesFile, "Claude known_marketplaces 配置");
198
+ const existingInstalledPlugins = readExistingJsonOrThrow(installedPluginsFile, "Claude installed_plugins 配置");
168
199
 
169
200
  const removedMarketplaceDir = removePathIfExists(targetMarketplaceRoot);
170
201
  const removedCacheDir = removePathIfExists(targetCachePluginsRoot);
171
- const removedKnownMarketplace = removeClaudeKnownMarketplace(knownMarketplacesFile);
172
- const removedInstalledPlugin = removeClaudeInstalledPlugin(installedPluginsFile);
173
- const removedSettingsEntries = removeClaudeSettingsEntries(settingsFile);
202
+ const removedKnownMarketplace = removeClaudeKnownMarketplace(knownMarketplacesFile, existingKnownMarketplaces);
203
+ const removedInstalledPlugin = removeClaudeInstalledPlugin(installedPluginsFile, existingInstalledPlugins);
204
+ const removedSettingsEntries = removeClaudeSettingsEntries(settingsFile, existingSettings);
174
205
 
175
206
  return {
176
207
  host: "claude",
@@ -5,21 +5,23 @@ import {
5
5
  assertPathInside,
6
6
  codexBundleEntries,
7
7
  copyBundleEntries,
8
+ readExistingJsonOrThrow,
8
9
  removePathIfExists,
9
10
  removeTargetIfNeeded,
10
11
  resolveHomeDir,
11
12
  } from "./install_shared.mjs";
12
13
 
13
- function updateCodexMarketplace(marketplaceFile) {
14
- const marketplace = fileExists(marketplaceFile)
15
- ? readJson(marketplaceFile)
16
- : {
17
- name: "local-plugins",
18
- interface: {
19
- displayName: "Local Plugins",
20
- },
21
- plugins: [],
22
- };
14
+ function updateCodexMarketplace(marketplaceFile, existingMarketplace = null) {
15
+ const marketplace = existingMarketplace
16
+ || (fileExists(marketplaceFile)
17
+ ? readJson(marketplaceFile)
18
+ : {
19
+ name: "local-plugins",
20
+ interface: {
21
+ displayName: "Local Plugins",
22
+ },
23
+ plugins: [],
24
+ });
23
25
 
24
26
  marketplace.interface = marketplace.interface || {
25
27
  displayName: "Local Plugins",
@@ -49,12 +51,12 @@ function updateCodexMarketplace(marketplaceFile) {
49
51
  writeJson(marketplaceFile, marketplace);
50
52
  }
51
53
 
52
- function removeCodexMarketplaceEntry(marketplaceFile) {
54
+ function removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace = null) {
53
55
  if (!fileExists(marketplaceFile)) {
54
56
  return false;
55
57
  }
56
58
 
57
- const marketplace = readJson(marketplaceFile);
59
+ const marketplace = existingMarketplace || readJson(marketplaceFile);
58
60
  const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
59
61
  const nextPlugins = plugins.filter((plugin) => plugin?.name !== "helloloop");
60
62
  if (nextPlugins.length === plugins.length) {
@@ -72,6 +74,7 @@ export function installCodexHost(bundleRoot, options = {}) {
72
74
  const targetPluginRoot = path.join(targetPluginsRoot, "helloloop");
73
75
  const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
74
76
  const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
77
+ const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
75
78
 
76
79
  if (!fileExists(manifestFile)) {
77
80
  throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
@@ -86,7 +89,7 @@ export function installCodexHost(bundleRoot, options = {}) {
86
89
  removePathIfExists(path.join(targetPluginRoot, ".git"));
87
90
 
88
91
  ensureDir(path.dirname(marketplaceFile));
89
- updateCodexMarketplace(marketplaceFile);
92
+ updateCodexMarketplace(marketplaceFile, existingMarketplace);
90
93
 
91
94
  return {
92
95
  host: "codex",
@@ -100,9 +103,10 @@ export function uninstallCodexHost(options = {}) {
100
103
  const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
101
104
  const targetPluginRoot = path.join(resolvedCodexHome, "plugins", "helloloop");
102
105
  const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
106
+ const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
103
107
 
104
108
  const removedPlugin = removePathIfExists(targetPluginRoot);
105
- const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile);
109
+ const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile, existingMarketplace);
106
110
 
107
111
  return {
108
112
  host: "codex",
@@ -132,6 +132,17 @@ export function loadOrInitJson(filePath, fallbackValue) {
132
132
  return readJson(filePath);
133
133
  }
134
134
 
135
+ export function readExistingJsonOrThrow(filePath, label) {
136
+ if (!fileExists(filePath)) {
137
+ return null;
138
+ }
139
+ try {
140
+ return readJson(filePath);
141
+ } catch (error) {
142
+ throw new Error(`${label} 不是合法 JSON:${filePath}`);
143
+ }
144
+ }
145
+
135
146
  export function writeJsonFile(filePath, value) {
136
147
  ensureDir(path.dirname(filePath));
137
148
  writeJson(filePath, value);