ccus-cli 0.1.26 → 0.2.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.
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.fetchCodexQuota = fetchCodexQuota;
7
+ exports.readCodexQuotaCacheSync = readCodexQuotaCacheSync;
8
+ exports.resolveCodexQuota = resolveCodexQuota;
9
+ const node_child_process_1 = require("node:child_process");
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const promises_1 = __importDefault(require("node:fs/promises"));
12
+ const node_os_1 = require("node:os");
13
+ const node_path_1 = require("node:path");
14
+ const debug_1 = require("./debug");
15
+ const paths_1 = require("./paths");
16
+ // app-server 子进程参数:read-only + untrusted,Codex 官方推荐的最低权限拉额度姿态。
17
+ const CODEX_ARGS = ["-s", "read-only", "-a", "untrusted", "app-server"];
18
+ const DEFAULT_TIMEOUT_MS = 10_000;
19
+ // notify 每 turn 触发,额度缓存 5 分钟,命中秒回避免阻塞 Codex 主流程。
20
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function toFiniteNumber(value) {
25
+ if (typeof value === "number" && Number.isFinite(value)) {
26
+ return value;
27
+ }
28
+ if (typeof value === "string" && value.trim() !== "") {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ return null;
33
+ }
34
+ function clampPercent(value) {
35
+ if (value === null) {
36
+ return null;
37
+ }
38
+ return Math.min(100, Math.max(0, value));
39
+ }
40
+ /**
41
+ * 读窗口使用率:驼峰优先(app-server RPC 实际返回 usedPercent),
42
+ * used_percentage / usedPercentage fallback(对齐 ccus 生态字段名,防御未来协议变更)。
43
+ */
44
+ function readUsedPercent(window) {
45
+ if (!isRecord(window)) {
46
+ return null;
47
+ }
48
+ return clampPercent(toFiniteNumber(window.usedPercent ?? window.used_percentage ?? window.usedPercentage));
49
+ }
50
+ /**
51
+ * 读重置时间:Codex app-server 返回 Unix 秒;< 10^10 视为秒转毫秒,否则按毫秒。
52
+ */
53
+ function readResetsAtMs(window) {
54
+ if (!isRecord(window)) {
55
+ return null;
56
+ }
57
+ const raw = window.resetsAt ?? window.reset_at ?? window.resetsAtMs;
58
+ const n = toFiniteNumber(raw);
59
+ if (n === null) {
60
+ return null;
61
+ }
62
+ return n < 10_000_000_000 ? n * 1000 : n;
63
+ }
64
+ /**
65
+ * 解析 account/rateLimits/read 的 result:primary→5h、secondary→weekly(Codex 约定)。
66
+ * 字段缺失时对应窗口为 null,不阻断另一窗口。
67
+ */
68
+ function parseRateLimitsResult(result) {
69
+ const wrapper = isRecord(result) && isRecord(result.rateLimits) ? result.rateLimits : null;
70
+ if (!wrapper) {
71
+ return { fiveHour: null, sevenDay: null, resetsAt: null };
72
+ }
73
+ const fiveHour = readUsedPercent(wrapper.primary);
74
+ const sevenDay = readUsedPercent(wrapper.secondary);
75
+ const resetsAt = readResetsAtMs(wrapper.primary) ?? readResetsAtMs(wrapper.secondary);
76
+ return { fiveHour, sevenDay, resetsAt };
77
+ }
78
+ function getCodexHome(codexHomePath) {
79
+ return codexHomePath ?? process.env.CODEX_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex");
80
+ }
81
+ function buildRpcMessage(id, method, params) {
82
+ return `${JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} })}\n`;
83
+ }
84
+ function buildRpcNotification(method) {
85
+ return `${JSON.stringify({ jsonrpc: "2.0", method, params: {} })}\n`;
86
+ }
87
+ // 真实 spawn:Windows 上 codex 多为 codex.cmd,不带 shell 会失败,故 win32 走 shell。
88
+ const defaultSpawn = (command, args, options) => {
89
+ const merged = process.platform === "win32" ? { ...options, shell: true } : options;
90
+ return (0, node_child_process_1.spawn)(command, args, merged);
91
+ };
92
+ /**
93
+ * spawn `codex app-server` 走 JSON-RPC 握手 + `account/rateLimits/read` 拉额度。
94
+ *
95
+ * 握手必须 initialize → 收响应 → 发 initialized 通知,否则后续方法被拒为 "Not initialized"。
96
+ * 全程不抛错:ENOENT → unavailable,超时 / RPC error / 进程退出 → error。
97
+ */
98
+ async function fetchCodexQuota(options = {}) {
99
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
100
+ const env = options.env ?? process.env;
101
+ const spawnFn = options.spawn ?? defaultSpawn;
102
+ const childEnv = { ...env, CODEX_HOME: getCodexHome(options.codexHomePath) };
103
+ const empty = (status) => ({
104
+ fiveHour: null,
105
+ sevenDay: null,
106
+ resetsAt: null,
107
+ status,
108
+ });
109
+ return new Promise((resolve) => {
110
+ let settled = false;
111
+ let buffer = "";
112
+ let rpcId = 0;
113
+ let rateLimitsId = null;
114
+ let child = null;
115
+ let timer = null;
116
+ const finish = (outcome, kill = false) => {
117
+ if (settled) {
118
+ return;
119
+ }
120
+ settled = true;
121
+ if (timer) {
122
+ clearTimeout(timer);
123
+ timer = null;
124
+ }
125
+ // settled 标志已保证 resolve 幂等,无需再手动摘 listener;kill 终止子进程后其 stdio 自然关闭。
126
+ if (kill && child) {
127
+ try {
128
+ child.kill();
129
+ }
130
+ catch {
131
+ // kill 失败不影响结果。
132
+ }
133
+ }
134
+ resolve(outcome);
135
+ };
136
+ try {
137
+ child = spawnFn("codex", CODEX_ARGS, {
138
+ env: childEnv,
139
+ stdio: ["pipe", "pipe", "pipe"],
140
+ windowsHide: true,
141
+ });
142
+ }
143
+ catch (error) {
144
+ (0, debug_1.debugLog)("codex", "spawn threw", error instanceof Error ? error.message : String(error));
145
+ resolve(empty("error"));
146
+ return;
147
+ }
148
+ timer = setTimeout(() => {
149
+ (0, debug_1.debugLog)("codex", "rpc timeout", { timeoutMs });
150
+ finish(empty("error"), true);
151
+ }, timeoutMs);
152
+ const send = (message) => {
153
+ try {
154
+ child?.stdin.write(message);
155
+ }
156
+ catch (error) {
157
+ (0, debug_1.debugLog)("codex", "stdin write failed", error instanceof Error ? error.message : String(error));
158
+ }
159
+ };
160
+ const initId = (rpcId += 1);
161
+ send(buildRpcMessage(initId, "initialize", { clientInfo: { name: "ccus", version: "1.0.0" } }));
162
+ child.stdout.on("data", (chunk) => {
163
+ buffer += chunk.toString();
164
+ let newline;
165
+ while ((newline = buffer.indexOf("\n")) !== -1) {
166
+ const line = buffer.slice(0, newline).trim();
167
+ buffer = buffer.slice(newline + 1);
168
+ if (!line) {
169
+ continue;
170
+ }
171
+ let message;
172
+ try {
173
+ message = JSON.parse(line);
174
+ }
175
+ catch {
176
+ continue; // 非 JSON 行(server 日志等)忽略。
177
+ }
178
+ if (message.id === undefined) {
179
+ continue; // server 通知无 id。
180
+ }
181
+ if (message.id === initId) {
182
+ // 握手成功,发 initialized 通知后再请求额度。
183
+ send(buildRpcNotification("initialized"));
184
+ rateLimitsId = rpcId += 1;
185
+ send(buildRpcMessage(rateLimitsId, "account/rateLimits/read"));
186
+ continue;
187
+ }
188
+ if (rateLimitsId !== null && message.id === rateLimitsId) {
189
+ if (message.error) {
190
+ (0, debug_1.debugLog)("codex", "rateLimits rpc error", message.error.message ?? "unknown");
191
+ finish(empty("error"), true);
192
+ return;
193
+ }
194
+ const quota = parseRateLimitsResult(message.result);
195
+ (0, debug_1.debugLog)("codex", "quota parsed", quota);
196
+ finish({ ...quota, status: "ok" }, true);
197
+ }
198
+ }
199
+ });
200
+ child.on("error", (err) => {
201
+ const enoent = err.code === "ENOENT";
202
+ (0, debug_1.debugLog)("codex", "spawn error", { code: err.code, message: err.message });
203
+ finish(empty(enoent ? "unavailable" : "error"));
204
+ });
205
+ child.on("close", (code) => {
206
+ (0, debug_1.debugLog)("codex", "app-server exited before quota", { code });
207
+ finish(empty("error"));
208
+ });
209
+ });
210
+ }
211
+ /** 同步读取额度缓存;损坏 / 缺失返回 null。 */
212
+ function readCodexQuotaCacheSync(dataDir) {
213
+ try {
214
+ const raw = node_fs_1.default.readFileSync((0, paths_1.getCodexQuotaCachePath)(dataDir), "utf8");
215
+ const parsed = JSON.parse(raw);
216
+ if (typeof parsed.fetchedAt !== "string") {
217
+ return null;
218
+ }
219
+ return {
220
+ fetchedAt: parsed.fetchedAt,
221
+ fiveHour: typeof parsed.fiveHour === "number" ? parsed.fiveHour : null,
222
+ sevenDay: typeof parsed.sevenDay === "number" ? parsed.sevenDay : null,
223
+ resetsAt: typeof parsed.resetsAt === "number" ? parsed.resetsAt : null,
224
+ };
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ /** 写入额度缓存;失败静默(不能因缓存写失败影响采集主流程)。 */
231
+ async function writeCodexQuotaCache(dataDir, cache) {
232
+ try {
233
+ await promises_1.default.mkdir(dataDir, { recursive: true });
234
+ await promises_1.default.writeFile((0, paths_1.getCodexQuotaCachePath)(dataDir), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
235
+ }
236
+ catch (error) {
237
+ (0, debug_1.debugLog)("codex", "failed to write quota cache", error instanceof Error ? error.message : String(error));
238
+ }
239
+ }
240
+ function isCodexCacheFresh(cache, ttlMs, now) {
241
+ const fetched = Date.parse(cache.fetchedAt);
242
+ return Number.isFinite(fetched) && now.getTime() - fetched < ttlMs;
243
+ }
244
+ function quotaFromCache(cache) {
245
+ return { fiveHour: cache.fiveHour, sevenDay: cache.sevenDay, resetsAt: cache.resetsAt };
246
+ }
247
+ function hasAnyQuota(quota) {
248
+ return quota.fiveHour !== null || quota.sevenDay !== null;
249
+ }
250
+ /**
251
+ * 缓存优先解析额度:新鲜直接返回;过期拉一次(带超时);拉取失败 / 无有效数据回退旧缓存,全失败返回 null。
252
+ *
253
+ * notify 路径用,全程不抛错、不写 stdout。`options.fetcher` 供测试注入。
254
+ */
255
+ async function resolveCodexQuota(dataDir, options = {}) {
256
+ const now = options.now ?? new Date();
257
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
258
+ const fetcher = options.fetcher ?? fetchCodexQuota;
259
+ const cached = readCodexQuotaCacheSync(dataDir);
260
+ if (cached && isCodexCacheFresh(cached, ttlMs, now)) {
261
+ return quotaFromCache(cached);
262
+ }
263
+ try {
264
+ const outcome = await fetcher(options.fetchOptions);
265
+ if (outcome.status === "ok" && hasAnyQuota(outcome)) {
266
+ const quota = {
267
+ fiveHour: outcome.fiveHour,
268
+ sevenDay: outcome.sevenDay,
269
+ resetsAt: outcome.resetsAt,
270
+ };
271
+ await writeCodexQuotaCache(dataDir, { ...quota, fetchedAt: now.toISOString() });
272
+ return quota;
273
+ }
274
+ (0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
275
+ }
276
+ catch (error) {
277
+ (0, debug_1.debugLog)("codex", "fetch threw, fallback to cache", error instanceof Error ? error.message : String(error));
278
+ }
279
+ return cached ? quotaFromCache(cached) : null;
280
+ }
281
+ //# sourceMappingURL=codex-fetcher.js.map
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.installCodexNotify = installCodexNotify;
7
+ exports.uninstallCodexNotify = uninstallCodexNotify;
8
+ exports.installCodexHook = installCodexHook;
9
+ exports.uninstallCodexHook = uninstallCodexHook;
10
+ const promises_1 = __importDefault(require("node:fs/promises"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ /** 序列化 notify 数组为 TOML 数组字面量(字符串元素,元素间带空格,符合 TOML 习惯)。 */
13
+ function serializeNotify(notify) {
14
+ return `[${notify.map((value) => JSON.stringify(value)).join(", ")}]`;
15
+ }
16
+ /**
17
+ * 从 notify 赋值行的 `=` 之后提取字符串元素(兼容双引号 / 单引号)。
18
+ * 提取不到任何字符串返回空数组(调用方另判多行数组)。
19
+ */
20
+ function parseNotifyElements(line) {
21
+ const eq = line.indexOf("=");
22
+ if (eq < 0) {
23
+ return [];
24
+ }
25
+ const value = line.slice(eq + 1);
26
+ return [...value.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((match) => match[1] ?? match[2] ?? "");
27
+ }
28
+ /** 判断一行是否是顶层 notify 赋值(非注释、非 table 头、key 为 notify)。 */
29
+ function isTopLevelNotifyLine(line) {
30
+ const trimmed = line.trim();
31
+ if (trimmed.startsWith("#") || trimmed.startsWith("[")) {
32
+ return false;
33
+ }
34
+ return /^\s*notify\s*=/.test(line);
35
+ }
36
+ /** 判断一行是否是 TOML table 头([section] / [[array-of-table]])。 */
37
+ function isTableHeader(line) {
38
+ return /^\s*\[\[?/.test(line);
39
+ }
40
+ /** 顶层区域结束行号:第一个 table 头的索引,没有则为行数。 */
41
+ function findTopLevelEnd(lines) {
42
+ for (let index = 0; index < lines.length; index += 1) {
43
+ if (lines[index].trim().startsWith("#")) {
44
+ continue;
45
+ }
46
+ if (isTableHeader(lines[index])) {
47
+ return index;
48
+ }
49
+ }
50
+ return lines.length;
51
+ }
52
+ /** 读现有 config.toml;缺失返回空文本。 */
53
+ async function readConfig(configPath) {
54
+ try {
55
+ const raw = await promises_1.default.readFile(configPath, "utf8");
56
+ return { raw, existed: true };
57
+ }
58
+ catch (error) {
59
+ if (error.code === "ENOENT") {
60
+ return { raw: "", existed: false };
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+ /** 统一以 \n 行尾写回,避免 split/join 产生多余空行。 */
66
+ async function writeConfig(configPath, lines) {
67
+ await promises_1.default.mkdir(node_path_1.default.dirname(configPath), { recursive: true });
68
+ const content = `${lines.join("\n").replace(/\n+$/, "")}\n`;
69
+ await promises_1.default.writeFile(configPath, content, "utf8");
70
+ }
71
+ function arrayEqual(left, right) {
72
+ return left.length === right.length && left.every((value, index) => value === right[index]);
73
+ }
74
+ /**
75
+ * 把 notify 写进 Codex config.toml 的顶层。
76
+ *
77
+ * 只改顶层 notify 赋值行(table 头之前),保留其它顶层 key、所有 table、注释与格式。
78
+ * 已等于目标时不动文件;现有 notify 是跨行数组时拒绝改写(避免破坏多行结构)。
79
+ */
80
+ async function installCodexNotify(configPath, notify) {
81
+ const { raw, existed } = await readConfig(configPath);
82
+ const lines = raw.length > 0 ? raw.split(/\r?\n/) : [];
83
+ const topLevelEnd = findTopLevelEnd(lines);
84
+ const notifyIdx = lines.slice(0, topLevelEnd).findIndex((line) => isTopLevelNotifyLine(line));
85
+ if (notifyIdx >= 0) {
86
+ const line = lines[notifyIdx];
87
+ const valuePart = line.slice(line.indexOf("=") + 1);
88
+ // 跨行 notify 数组(行内有 [ 但无闭合 ])无法安全单行替换,拒绝以免破坏结构。
89
+ if (valuePart.includes("[") && !valuePart.includes("]")) {
90
+ throw new Error(`Cannot update multi-line notify array in ${configPath}. Fold it into a single line, then retry.`);
91
+ }
92
+ const current = parseNotifyElements(line);
93
+ const unchanged = arrayEqual(current, notify);
94
+ if (!unchanged) {
95
+ lines[notifyIdx] = `notify = ${serializeNotify(notify)}`;
96
+ await writeConfig(configPath, lines);
97
+ }
98
+ return {
99
+ configPath,
100
+ notify,
101
+ previousNotify: current.length > 0 ? current : null,
102
+ created: !existed,
103
+ unchanged,
104
+ };
105
+ }
106
+ // 顶层无 notify:顶层有实质 key 时紧跟其后插入(与上方空一行);顶层为空则插到最前。
107
+ const hasTopLevelContent = lines
108
+ .slice(0, topLevelEnd)
109
+ .some((line) => line.trim() !== "" && !line.trim().startsWith("#"));
110
+ const notifyLine = `notify = ${serializeNotify(notify)}`;
111
+ const insertAt = hasTopLevelContent ? topLevelEnd : 0;
112
+ const needsBlankPrefix = hasTopLevelContent && insertAt > 0 && lines[insertAt - 1].trim() !== "";
113
+ lines.splice(insertAt, 0, ...(needsBlankPrefix ? ["", notifyLine] : [notifyLine]));
114
+ await writeConfig(configPath, lines);
115
+ return { configPath, notify, previousNotify: null, created: !existed, unchanged: false };
116
+ }
117
+ /** 移除 Codex config.toml 顶层的 notify 赋值;不存在或无 notify 时 removed=false。 */
118
+ async function uninstallCodexNotify(configPath) {
119
+ const { raw, existed } = await readConfig(configPath);
120
+ if (!existed) {
121
+ return { configPath, removed: false, previousNotify: null };
122
+ }
123
+ const lines = raw.split(/\r?\n/);
124
+ const topLevelEnd = findTopLevelEnd(lines);
125
+ const notifyIdx = lines.slice(0, topLevelEnd).findIndex((line) => isTopLevelNotifyLine(line));
126
+ if (notifyIdx < 0) {
127
+ return { configPath, removed: false, previousNotify: null };
128
+ }
129
+ const previousNotify = parseNotifyElements(lines[notifyIdx]);
130
+ lines.splice(notifyIdx, 1);
131
+ await writeConfig(configPath, lines);
132
+ return {
133
+ configPath,
134
+ removed: true,
135
+ previousNotify: previousNotify.length > 0 ? previousNotify : null,
136
+ };
137
+ }
138
+ /** Stop hook 超时(秒):ccus 缓存命中秒回、过期拉额度 ~10s,给足余量。 */
139
+ const CODEX_HOOK_TIMEOUT_SECONDS = 60;
140
+ /** 判断一个 hook 条目是否是 ccus 装的(command 含 `__codex-hook`,兼容带 `--data-dir`)。 */
141
+ function isCcusCodexHookEntry(entry) {
142
+ if (typeof entry !== "object" || entry === null) {
143
+ return false;
144
+ }
145
+ const command = entry.command;
146
+ return typeof command === "string" && command.includes("__codex-hook");
147
+ }
148
+ /**
149
+ * 读 hooks.json;缺失返回空结构,非法 JSON 抛错(不静默覆盖,避免破坏 orca 等外部工具写入的文件)。
150
+ */
151
+ async function readHooksFile(hooksPath) {
152
+ try {
153
+ const raw = await promises_1.default.readFile(hooksPath, "utf8");
154
+ if (raw.trim() === "") {
155
+ return { data: {}, existed: true };
156
+ }
157
+ const parsed = JSON.parse(raw);
158
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
159
+ throw new Error(`${hooksPath} is not a JSON object`);
160
+ }
161
+ return { data: parsed, existed: true };
162
+ }
163
+ catch (error) {
164
+ if (error.code === "ENOENT") {
165
+ return { data: {}, existed: false };
166
+ }
167
+ throw error;
168
+ }
169
+ }
170
+ /** 以 2 空格缩进 + 末尾换行写回,保持 hooks.json 可读、对 git 友好。 */
171
+ async function writeHooksFile(hooksPath, data) {
172
+ await promises_1.default.mkdir(node_path_1.default.dirname(hooksPath), { recursive: true });
173
+ await promises_1.default.writeFile(hooksPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
174
+ }
175
+ /**
176
+ * 把 ccus 的 Stop hook 挂进 Codex hooks.json。
177
+ *
178
+ * 只往 `hooks.Stop` 追加一条 command hook,保留其它事件、其它 hook(如 orca 的 codex-hook.cmd)、
179
+ * description 与格式。Stop 已有相同 command 时不动文件(幂等);Stop 不存在则新建一个分组。
180
+ * 同一 Stop 事件下多条 hook 由 Codex 并发执行,互不阻塞。
181
+ */
182
+ async function installCodexHook(hooksPath, command) {
183
+ const { data, existed } = await readHooksFile(hooksPath);
184
+ const hooksMap = data.hooks ?? {};
185
+ const stopGroups = hooksMap.Stop ?? [];
186
+ const stopExisted = stopGroups.length > 0;
187
+ for (const group of stopGroups) {
188
+ if ((group.hooks ?? []).some((entry) => entry.command === command)) {
189
+ return { hooksPath, command, created: !existed, unchanged: true, stopExisted };
190
+ }
191
+ }
192
+ const ccusHook = { type: "command", command, timeout: CODEX_HOOK_TIMEOUT_SECONDS };
193
+ if (stopGroups.length > 0) {
194
+ const first = stopGroups[0];
195
+ first.hooks = [...(first.hooks ?? []), ccusHook];
196
+ }
197
+ else {
198
+ stopGroups.push({ hooks: [ccusHook] });
199
+ }
200
+ hooksMap.Stop = stopGroups;
201
+ data.hooks = hooksMap;
202
+ await writeHooksFile(hooksPath, data);
203
+ return { hooksPath, command, created: !existed, unchanged: false, stopExisted };
204
+ }
205
+ /** 移除 hooks.json 里 ccus 装的 Stop hook(command 含 `__codex-hook`);不存在时 removed=false。 */
206
+ async function uninstallCodexHook(hooksPath) {
207
+ const { data, existed } = await readHooksFile(hooksPath);
208
+ if (!existed) {
209
+ return { hooksPath, removed: false };
210
+ }
211
+ const hooksMap = data.hooks ?? {};
212
+ const stopGroups = hooksMap.Stop ?? [];
213
+ let removed = false;
214
+ for (const group of stopGroups) {
215
+ const before = group.hooks?.length ?? 0;
216
+ group.hooks = (group.hooks ?? []).filter((entry) => !isCcusCodexHookEntry(entry));
217
+ if (group.hooks.length !== before) {
218
+ removed = true;
219
+ }
220
+ }
221
+ if (removed) {
222
+ hooksMap.Stop = stopGroups;
223
+ data.hooks = hooksMap;
224
+ await writeHooksFile(hooksPath, data);
225
+ }
226
+ return { hooksPath, removed };
227
+ }
228
+ //# sourceMappingURL=codex-install.js.map