@evo-dev/evodev 0.0.1-alpha → 0.0.1-alpha.10
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/dist/.claude-plugin/marketplace.json +2 -2
- package/dist/assets/agents/review/code-reviewer/examples.md +1 -1
- package/dist/assets/agents/review/code-reviewer/prompt.md +1 -1
- package/dist/assets/agents/review/code-reviewer/verification.md +1 -1
- package/dist/assets/skills/coding/knowledge-distillation/SKILL.md +251 -0
- package/dist/assets/skills/coding/knowledge-distillation/manifest.json +10 -0
- package/dist/assets/skills/coding/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
- package/dist/assets/team/agents/code-reviewer.md +48 -0
- package/dist/assets/team/agents/docs-maintainer.md +51 -0
- package/dist/assets/team/agents/implementation-engineer.md +51 -0
- package/dist/assets/team/agents/product-scope-analyst.md +58 -0
- package/dist/assets/team/agents/release-engineer.md +55 -0
- package/dist/assets/team/agents/security-boundary-reviewer.md +50 -0
- package/dist/assets/team/agents/solution-architect.md +51 -0
- package/dist/assets/team/agents/verification-engineer.md +51 -0
- package/dist/assets/team/team.md +102 -0
- package/dist/assets/workflows/rd-bug-fix/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-code-review/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-docs-update/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-feature-implementation/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-refactor/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-release-readiness/WORKFLOW.json +1 -1
- package/dist/assets/workflows/rd-security-boundary-review/WORKFLOW.json +2 -2
- package/dist/assets/workflows/rd-test-generation/WORKFLOW.json +1 -1
- package/dist/index.js +29774 -9310
- package/dist/plugins/evodev/.claude-plugin/plugin.json +2 -2
- package/dist/plugins/evodev/.codex-plugin/plugin.json +8 -6
- package/dist/plugins/evodev/.mcp.json +6 -0
- package/dist/plugins/evodev/hooks/codex-hooks.json +10 -10
- package/dist/plugins/evodev/hooks/codex.ts +596 -34
- package/dist/plugins/evodev/hooks/hooks.json +18 -18
- package/dist/plugins/evodev/hooks/hooks.ts +470 -25
- package/dist/plugins/evodev/hooks/index.ts +15 -0
- package/dist/plugins/evodev/hooks/paths.ts +44 -1
- package/dist/plugins/evodev/hooks/plugin.ts +160 -9
- package/dist/plugins/evodev/hooks/runtime.ts +234 -43
- package/dist/plugins/evodev/hooks/transform-agent.ts +30 -0
- package/dist/plugins/evodev/hooks/workspace-core.ts +154 -0
- package/dist/plugins/evodev/package.json +3 -3
- package/dist/plugins/evodev/skills/engineering-discipline/SKILL.md +63 -0
- package/dist/plugins/evodev/skills/engineering-discipline/anti-patterns.md +21 -0
- package/dist/plugins/evodev/skills/engineering-discipline/examples.md +19 -0
- package/dist/plugins/evodev/skills/engineering-discipline/verification.md +11 -0
- package/dist/plugins/evodev/skills/knowledge-distillation/SKILL.md +251 -0
- package/dist/plugins/evodev/skills/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
- package/dist/ui/app.js +9 -0
- package/dist/ui/styles.css +2 -0
- package/package.json +9 -7
- package/dist/evodev +0 -11
|
@@ -1,16 +1,31 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
1
5
|
import {
|
|
6
|
+
CANONICAL_HOOK_EVENT_TYPES,
|
|
2
7
|
type CanonicalHookEventType,
|
|
3
8
|
type HookEventV1,
|
|
4
9
|
type HookInputEventType,
|
|
5
10
|
normalizeHookEvent,
|
|
6
11
|
} from "@evo-dev/core";
|
|
7
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type ClaudePaths,
|
|
14
|
+
type CodexPaths,
|
|
15
|
+
resolveClaudePaths,
|
|
16
|
+
resolveCodexPaths,
|
|
17
|
+
} from "./paths.ts";
|
|
8
18
|
|
|
9
19
|
export interface ClaudeHookInstallDryRunInput {
|
|
10
20
|
homeDir: string;
|
|
11
21
|
paths?: ClaudePaths;
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
export interface CodexHookInstallDryRunInput {
|
|
25
|
+
homeDir: string;
|
|
26
|
+
paths?: CodexPaths;
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export interface ClaudeHookInstallDryRunPlan {
|
|
15
30
|
target: "claude";
|
|
16
31
|
settingsPath: string;
|
|
@@ -20,7 +35,27 @@ export interface ClaudeHookInstallDryRunPlan {
|
|
|
20
35
|
reason: string;
|
|
21
36
|
}>;
|
|
22
37
|
warnings: string[];
|
|
23
|
-
|
|
38
|
+
advisories: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CodexHookInstallDryRunPlan {
|
|
42
|
+
target: "codex";
|
|
43
|
+
configPath: string;
|
|
44
|
+
plannedWrites: Array<{
|
|
45
|
+
action: "merge-with-backup" | "create";
|
|
46
|
+
targetPath: string;
|
|
47
|
+
reason: string;
|
|
48
|
+
}>;
|
|
49
|
+
warnings: string[];
|
|
50
|
+
advisories: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HookInstallResult {
|
|
54
|
+
target: "claude" | "codex";
|
|
55
|
+
targetPath: string;
|
|
56
|
+
backupPath: string | null;
|
|
57
|
+
changed: boolean;
|
|
58
|
+
warnings: string[];
|
|
24
59
|
}
|
|
25
60
|
|
|
26
61
|
export interface ClaudeHookCommand {
|
|
@@ -44,9 +79,21 @@ export interface CodexHookConfig {
|
|
|
44
79
|
}
|
|
45
80
|
|
|
46
81
|
export const CLAUDE_HOOK_RUNTIME_COMMAND =
|
|
47
|
-
'
|
|
48
|
-
export const
|
|
49
|
-
|
|
82
|
+
'cd "${CLAUDE_PLUGIN_ROOT}" && bun run hooks/runtime.ts hook runtime --target claude';
|
|
83
|
+
export const DEFAULT_CLAUDE_PLUGIN_SELECTOR = "evodev@evo-dev";
|
|
84
|
+
export const CODEX_HOOK_RUNTIME_COMMAND = [
|
|
85
|
+
"sh -lc '",
|
|
86
|
+
'root="${PLUGIN_ROOT:-}"; ',
|
|
87
|
+
'if [ -z "$root" ]; then ',
|
|
88
|
+
'for candidate in "$HOME/.codex/plugins/cache/evodev/evodev"/* "$HOME/.codex/plugins/evodev"; do ',
|
|
89
|
+
'if [ -f "$candidate/hooks/runtime.ts" ]; then root="$candidate"; break; fi; ',
|
|
90
|
+
"done; ",
|
|
91
|
+
"fi; ",
|
|
92
|
+
'if [ -z "$root" ] || [ ! -f "$root/hooks/runtime.ts" ]; then exit 0; fi; ',
|
|
93
|
+
'cd "$root" && bun run hooks/runtime.ts hook runtime --target codex || exit 0',
|
|
94
|
+
"'",
|
|
95
|
+
].join("");
|
|
96
|
+
const CODEX_FEATURE_FLAGS = ["plugins", "plugin_hooks", "hooks"] as const;
|
|
50
97
|
const CLAUDE_TOOL_EVENTS = new Set<CanonicalHookEventType>([
|
|
51
98
|
"PreToolUse",
|
|
52
99
|
"PermissionRequest",
|
|
@@ -94,6 +141,10 @@ const CODEX_EVENT_MATCHERS: Partial<Record<CanonicalHookEventType, string>> = {
|
|
|
94
141
|
SubagentStop: "*",
|
|
95
142
|
Stop: "*",
|
|
96
143
|
};
|
|
144
|
+
const RUNTIME_HOOK_EVENT_TYPES = [...CANONICAL_HOOK_EVENT_TYPES, "AgentStop"] as const;
|
|
145
|
+
const RUNTIME_HOOK_EVENT_TYPE_BY_KEY = new Map<string, HookInputEventType>(
|
|
146
|
+
RUNTIME_HOOK_EVENT_TYPES.map((type) => [normalizeRuntimeHookEventName(type), type]),
|
|
147
|
+
);
|
|
97
148
|
|
|
98
149
|
export function planClaudeHookInstallDryRun(
|
|
99
150
|
input: ClaudeHookInstallDryRunInput,
|
|
@@ -107,17 +158,195 @@ export function planClaudeHookInstallDryRun(
|
|
|
107
158
|
action: "merge-with-backup",
|
|
108
159
|
targetPath: paths.settingsPath,
|
|
109
160
|
reason:
|
|
110
|
-
"
|
|
161
|
+
"would reconcile EvoDev-managed user-level hook entries with Claude plugin ownership without touching user-owned hooks",
|
|
111
162
|
},
|
|
112
163
|
],
|
|
113
|
-
warnings: [
|
|
114
|
-
|
|
115
|
-
|
|
164
|
+
warnings: ["Project .claude, .codex, CLAUDE.md, and AGENTS.md are not targeted."],
|
|
165
|
+
advisories: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function planCodexHookInstallDryRun(
|
|
170
|
+
input: CodexHookInstallDryRunInput,
|
|
171
|
+
): CodexHookInstallDryRunPlan {
|
|
172
|
+
const paths = input.paths ?? resolveCodexPaths({ homeDir: input.homeDir });
|
|
173
|
+
return {
|
|
174
|
+
target: "codex",
|
|
175
|
+
configPath: paths.configPath,
|
|
176
|
+
plannedWrites: [
|
|
177
|
+
{
|
|
178
|
+
action: "merge-with-backup",
|
|
179
|
+
targetPath: paths.configPath,
|
|
180
|
+
reason:
|
|
181
|
+
"would enable missing Codex plugin hook feature flags while preserving explicit user-owned false values",
|
|
182
|
+
},
|
|
116
183
|
],
|
|
117
|
-
|
|
184
|
+
warnings: ["Codex hook definitions are supplied by the EvoDev Codex plugin manifest."],
|
|
185
|
+
advisories: [],
|
|
118
186
|
};
|
|
119
187
|
}
|
|
120
188
|
|
|
189
|
+
export async function installClaudeHooks(input: {
|
|
190
|
+
homeDir: string;
|
|
191
|
+
paths?: ClaudePaths;
|
|
192
|
+
now?: string;
|
|
193
|
+
pluginSelector?: string;
|
|
194
|
+
pluginManaged?: boolean;
|
|
195
|
+
}): Promise<HookInstallResult> {
|
|
196
|
+
const paths = input.paths ?? resolveClaudePaths({ homeDir: input.homeDir });
|
|
197
|
+
const existing = await readTextIfExists(paths.settingsPath);
|
|
198
|
+
const pluginSelector = input.pluginSelector ?? DEFAULT_CLAUDE_PLUGIN_SELECTOR;
|
|
199
|
+
if (input.pluginManaged === true || isClaudePluginEnabled(existing, pluginSelector)) {
|
|
200
|
+
const next = removeClaudeManagedUserHooks(existing);
|
|
201
|
+
if (next === null) {
|
|
202
|
+
return {
|
|
203
|
+
target: "claude",
|
|
204
|
+
targetPath: paths.settingsPath,
|
|
205
|
+
backupPath: null,
|
|
206
|
+
changed: false,
|
|
207
|
+
warnings: [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return writeMergedConfig({
|
|
211
|
+
target: "claude",
|
|
212
|
+
path: paths.settingsPath,
|
|
213
|
+
existing,
|
|
214
|
+
next,
|
|
215
|
+
now: input.now,
|
|
216
|
+
warnings: [],
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const runtimeCommand = await resolveClaudeUserHookRuntimeCommand({
|
|
220
|
+
paths,
|
|
221
|
+
pluginSelector,
|
|
222
|
+
});
|
|
223
|
+
const next = mergeClaudeSettingsHooks(existing, createClaudeHookConfig(runtimeCommand).hooks);
|
|
224
|
+
return writeMergedConfig({
|
|
225
|
+
target: "claude",
|
|
226
|
+
path: paths.settingsPath,
|
|
227
|
+
existing,
|
|
228
|
+
next,
|
|
229
|
+
now: input.now,
|
|
230
|
+
warnings: [],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function resolveClaudeUserHookRuntimeCommand(input: {
|
|
235
|
+
paths: ClaudePaths;
|
|
236
|
+
pluginSelector: string;
|
|
237
|
+
}): Promise<string> {
|
|
238
|
+
const runtimePath =
|
|
239
|
+
(await resolveInstalledClaudePluginRuntimePath(input)) ?? (await resolveLocalRuntimePath());
|
|
240
|
+
const pluginRoot = dirname(dirname(runtimePath));
|
|
241
|
+
return `cd ${shellQuote(pluginRoot)} && bun run hooks/runtime.ts hook runtime --target claude`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveInstalledClaudePluginRuntimePath(input: {
|
|
245
|
+
paths: ClaudePaths;
|
|
246
|
+
pluginSelector: string;
|
|
247
|
+
}): Promise<string | null> {
|
|
248
|
+
const marketplaceName = extractMarketplaceNameFromSelector(input.pluginSelector);
|
|
249
|
+
const pluginName = extractPluginNameFromSelector(input.pluginSelector);
|
|
250
|
+
if (marketplaceName === null || pluginName === null) return null;
|
|
251
|
+
if (!isSafePluginCacheSegment(marketplaceName) || !isSafePluginCacheSegment(pluginName)) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const cacheRoot = join(input.paths.claudeDir, "plugins", "cache", marketplaceName, pluginName);
|
|
256
|
+
let entries: Dirent[];
|
|
257
|
+
try {
|
|
258
|
+
entries = await readdir(cacheRoot, { withFileTypes: true });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (isNotFoundError(error)) return null;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const candidates = (
|
|
265
|
+
await Promise.all(
|
|
266
|
+
entries
|
|
267
|
+
.filter((entry) => entry.isDirectory())
|
|
268
|
+
.map(async (entry) => {
|
|
269
|
+
const runtimePath = join(cacheRoot, entry.name, "hooks", "runtime.ts");
|
|
270
|
+
try {
|
|
271
|
+
const runtimeStat = await stat(runtimePath);
|
|
272
|
+
return runtimeStat.isFile()
|
|
273
|
+
? { path: runtimePath, mtimeMs: runtimeStat.mtimeMs }
|
|
274
|
+
: null;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (isNotFoundError(error)) return null;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
).filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null);
|
|
282
|
+
|
|
283
|
+
if (candidates.length === 0) return null;
|
|
284
|
+
candidates.sort(
|
|
285
|
+
(left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path),
|
|
286
|
+
);
|
|
287
|
+
return candidates[0].path;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractMarketplaceNameFromSelector(selector: string): string | null {
|
|
291
|
+
const separator = selector.lastIndexOf("@");
|
|
292
|
+
if (separator <= 0 || separator === selector.length - 1) return null;
|
|
293
|
+
const marketplaceName = selector.slice(separator + 1).trim();
|
|
294
|
+
return marketplaceName === "" ? null : marketplaceName;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractPluginNameFromSelector(selector: string): string | null {
|
|
298
|
+
const separator = selector.lastIndexOf("@");
|
|
299
|
+
const pluginName = (separator <= 0 ? selector : selector.slice(0, separator)).trim();
|
|
300
|
+
return pluginName === "" ? null : pluginName;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isSafePluginCacheSegment(segment: string): boolean {
|
|
304
|
+
return /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== "..";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function resolveLocalRuntimePath(): Promise<string> {
|
|
308
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
309
|
+
const candidates = [
|
|
310
|
+
join(currentDir, "runtime.ts"),
|
|
311
|
+
join(currentDir, "plugins", "evodev", "hooks", "runtime.ts"),
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
for (const candidate of candidates) {
|
|
315
|
+
try {
|
|
316
|
+
const candidateStat = await stat(candidate);
|
|
317
|
+
if (candidateStat.isFile()) return candidate;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (isNotFoundError(error)) continue;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Cannot resolve Claude user-level hook runtime path. Checked: ${candidates.join(", ")}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function installCodexHooks(input: {
|
|
330
|
+
homeDir: string;
|
|
331
|
+
paths?: CodexPaths;
|
|
332
|
+
now?: string;
|
|
333
|
+
}): Promise<HookInstallResult> {
|
|
334
|
+
const paths = input.paths ?? resolveCodexPaths({ homeDir: input.homeDir });
|
|
335
|
+
const existing = await readTextIfExists(paths.configPath);
|
|
336
|
+
const merged = mergeCodexFeatureFlags(existing);
|
|
337
|
+
return writeMergedConfig({
|
|
338
|
+
target: "codex",
|
|
339
|
+
path: paths.configPath,
|
|
340
|
+
existing,
|
|
341
|
+
next: merged.content,
|
|
342
|
+
now: input.now,
|
|
343
|
+
warnings: [
|
|
344
|
+
"Codex hook definitions are loaded from the installed EvoDev Codex plugin.",
|
|
345
|
+
...merged.warnings,
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
121
350
|
export function normalizeClaudeHookPayload(input: {
|
|
122
351
|
type: HookInputEventType;
|
|
123
352
|
payload: Record<string, unknown>;
|
|
@@ -136,12 +365,11 @@ export function normalizeClaudeRuntimeHookPayload(input: {
|
|
|
136
365
|
payload: Record<string, unknown>;
|
|
137
366
|
receivedAt?: string;
|
|
138
367
|
}): HookEventV1 {
|
|
139
|
-
const type = input.payload.hook_event_name ?? input.payload.hookEventName;
|
|
140
|
-
if (typeof type !== "string" || type.trim() === "") {
|
|
141
|
-
throw new Error("Claude hook payload is missing hook_event_name.");
|
|
142
|
-
}
|
|
143
368
|
return normalizeClaudeHookPayload({
|
|
144
|
-
type:
|
|
369
|
+
type: readRuntimeHookEventType(
|
|
370
|
+
input.payload.hook_event_name ?? input.payload.hookEventName,
|
|
371
|
+
"Claude",
|
|
372
|
+
),
|
|
145
373
|
payload: input.payload,
|
|
146
374
|
receivedAt: input.receivedAt,
|
|
147
375
|
});
|
|
@@ -165,21 +393,34 @@ export function normalizeCodexRuntimeHookPayload(input: {
|
|
|
165
393
|
payload: Record<string, unknown>;
|
|
166
394
|
receivedAt?: string;
|
|
167
395
|
}): HookEventV1 {
|
|
168
|
-
const type =
|
|
169
|
-
input.payload.hook_event_name ??
|
|
170
|
-
input.payload.hookEventName ??
|
|
171
|
-
input.payload.event ??
|
|
172
|
-
input.payload.name;
|
|
173
|
-
if (typeof type !== "string" || type.trim() === "") {
|
|
174
|
-
throw new Error("Codex hook payload is missing hook_event_name.");
|
|
175
|
-
}
|
|
176
396
|
return normalizeCodexHookPayload({
|
|
177
|
-
type:
|
|
397
|
+
type: readRuntimeHookEventType(
|
|
398
|
+
input.payload.hook_event_name ??
|
|
399
|
+
input.payload.hookEventName ??
|
|
400
|
+
input.payload.event ??
|
|
401
|
+
input.payload.name,
|
|
402
|
+
"Codex",
|
|
403
|
+
),
|
|
178
404
|
payload: input.payload,
|
|
179
405
|
receivedAt: input.receivedAt,
|
|
180
406
|
});
|
|
181
407
|
}
|
|
182
408
|
|
|
409
|
+
function readRuntimeHookEventType(value: unknown, target: "Claude" | "Codex"): HookInputEventType {
|
|
410
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
411
|
+
throw new Error(`${target} hook payload is missing hook_event_name.`);
|
|
412
|
+
}
|
|
413
|
+
const normalized = RUNTIME_HOOK_EVENT_TYPE_BY_KEY.get(normalizeRuntimeHookEventName(value));
|
|
414
|
+
if (normalized === undefined) {
|
|
415
|
+
throw new Error(`Unsupported hook event type: ${value}`);
|
|
416
|
+
}
|
|
417
|
+
return normalized;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function normalizeRuntimeHookEventName(value: string): string {
|
|
421
|
+
return value.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
422
|
+
}
|
|
423
|
+
|
|
183
424
|
export function createClaudeHookConfig(
|
|
184
425
|
command: string = CLAUDE_HOOK_RUNTIME_COMMAND,
|
|
185
426
|
): ClaudeHookConfig {
|
|
@@ -194,7 +435,7 @@ export function createClaudeHookConfig(
|
|
|
194
435
|
}
|
|
195
436
|
return {
|
|
196
437
|
description:
|
|
197
|
-
"EvoDev
|
|
438
|
+
"EvoDev observes Claude Code lifecycle events through evodev hook runtime for local trace and action-required advisories.",
|
|
198
439
|
hooks,
|
|
199
440
|
};
|
|
200
441
|
}
|
|
@@ -215,3 +456,207 @@ export function createCodexHookConfig(
|
|
|
215
456
|
}
|
|
216
457
|
return { hooks };
|
|
217
458
|
}
|
|
459
|
+
|
|
460
|
+
async function readTextIfExists(path: string): Promise<string | null> {
|
|
461
|
+
try {
|
|
462
|
+
return await readFile(path, "utf8");
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (isNotFoundError(error)) return null;
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function writeMergedConfig(input: {
|
|
470
|
+
target: "claude" | "codex";
|
|
471
|
+
path: string;
|
|
472
|
+
existing: string | null;
|
|
473
|
+
next: string;
|
|
474
|
+
now?: string;
|
|
475
|
+
warnings: string[];
|
|
476
|
+
}): Promise<HookInstallResult> {
|
|
477
|
+
if (input.existing === input.next) {
|
|
478
|
+
return {
|
|
479
|
+
target: input.target,
|
|
480
|
+
targetPath: input.path,
|
|
481
|
+
backupPath: null,
|
|
482
|
+
changed: false,
|
|
483
|
+
warnings: input.warnings,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await mkdir(dirname(input.path), { recursive: true });
|
|
488
|
+
let backupPath: string | null = null;
|
|
489
|
+
if (input.existing !== null && (await fileExists(input.path))) {
|
|
490
|
+
backupPath = `${input.path}.evodev-${backupTimestamp(input.now)}.bak`;
|
|
491
|
+
await copyFile(input.path, backupPath);
|
|
492
|
+
}
|
|
493
|
+
await writeFile(input.path, input.next, "utf8");
|
|
494
|
+
return {
|
|
495
|
+
target: input.target,
|
|
496
|
+
targetPath: input.path,
|
|
497
|
+
backupPath,
|
|
498
|
+
changed: true,
|
|
499
|
+
warnings: input.warnings,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function mergeClaudeSettingsHooks(
|
|
504
|
+
existing: string | null,
|
|
505
|
+
evodevHooks: ClaudeHookConfig["hooks"],
|
|
506
|
+
): string {
|
|
507
|
+
const settings = parseJsonConfig(existing, "Claude settings");
|
|
508
|
+
const currentHooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
509
|
+
const nextHooks: Record<string, unknown> = { ...currentHooks };
|
|
510
|
+
|
|
511
|
+
for (const [eventName, groups] of Object.entries(evodevHooks)) {
|
|
512
|
+
const existingGroups = Array.isArray(currentHooks[eventName]) ? currentHooks[eventName] : [];
|
|
513
|
+
nextHooks[eventName] = [
|
|
514
|
+
...existingGroups.filter((group) => !isEvoDevHookGroup(group, "claude")),
|
|
515
|
+
...(groups ?? []),
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return `${JSON.stringify({ ...settings, hooks: nextHooks }, null, 2)}\n`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isClaudePluginEnabled(existing: string | null, pluginSelector: string): boolean {
|
|
523
|
+
if (existing === null) return false;
|
|
524
|
+
const settings = parseJsonConfig(existing, "Claude settings");
|
|
525
|
+
const enabledPlugins = isRecord(settings.enabledPlugins) ? settings.enabledPlugins : {};
|
|
526
|
+
return enabledPlugins[pluginSelector] === true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function removeClaudeManagedUserHooks(existing: string | null): string | null {
|
|
530
|
+
if (existing === null) return null;
|
|
531
|
+
const settings = parseJsonConfig(existing, "Claude settings");
|
|
532
|
+
if (!isRecord(settings.hooks)) return existing;
|
|
533
|
+
|
|
534
|
+
const nextHooks: Record<string, unknown> = {};
|
|
535
|
+
let changed = false;
|
|
536
|
+
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
|
537
|
+
if (!Array.isArray(groups)) {
|
|
538
|
+
nextHooks[eventName] = groups;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const retained = groups.filter((group) => !isEvoDevHookGroup(group, "claude"));
|
|
542
|
+
changed ||= retained.length !== groups.length;
|
|
543
|
+
if (retained.length > 0 || groups.length === 0) nextHooks[eventName] = retained;
|
|
544
|
+
}
|
|
545
|
+
if (!changed) return existing;
|
|
546
|
+
|
|
547
|
+
const nextSettings = { ...settings };
|
|
548
|
+
if (Object.keys(nextHooks).length === 0) nextSettings.hooks = undefined;
|
|
549
|
+
else nextSettings.hooks = nextHooks;
|
|
550
|
+
return `${JSON.stringify(nextSettings, null, 2)}\n`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function parseJsonConfig(existing: string | null, label: string): Record<string, unknown> {
|
|
554
|
+
if (existing === null || existing.trim() === "") return {};
|
|
555
|
+
const parsed = JSON.parse(existing) as unknown;
|
|
556
|
+
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object.`);
|
|
557
|
+
return parsed;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isEvoDevHookGroup(group: unknown, target: "claude" | "codex"): boolean {
|
|
561
|
+
if (!isRecord(group) || !Array.isArray(group.hooks)) return false;
|
|
562
|
+
return group.hooks.some((hook) => {
|
|
563
|
+
if (!isRecord(hook) || typeof hook.command !== "string") return false;
|
|
564
|
+
return hook.command.includes(`hook runtime --target ${target}`);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function mergeCodexFeatureFlags(existing: string | null): { content: string; warnings: string[] } {
|
|
569
|
+
const source = existing ?? "";
|
|
570
|
+
const lines = source === "" ? [] : source.replace(/\n$/, "").split("\n");
|
|
571
|
+
const section = findTomlSection(lines, "features");
|
|
572
|
+
if (section === null) {
|
|
573
|
+
const prefix = lines.length === 0 ? [] : [...lines, ""];
|
|
574
|
+
return {
|
|
575
|
+
content: [
|
|
576
|
+
...prefix,
|
|
577
|
+
"[features]",
|
|
578
|
+
...CODEX_FEATURE_FLAGS.map((flag) => `${flag} = true`),
|
|
579
|
+
"",
|
|
580
|
+
].join("\n"),
|
|
581
|
+
warnings: [],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const nextLines = [...lines];
|
|
586
|
+
const seen = new Set<string>();
|
|
587
|
+
const preservedFalseFlags: string[] = [];
|
|
588
|
+
for (let index = section.start + 1; index < section.end; index += 1) {
|
|
589
|
+
const match = nextLines[index]?.match(/^(\s*)([A-Za-z0-9_-]+)(\s*=\s*)(.*)$/);
|
|
590
|
+
if (match === null) continue;
|
|
591
|
+
const key = match[2];
|
|
592
|
+
if ((CODEX_FEATURE_FLAGS as readonly string[]).includes(key)) {
|
|
593
|
+
seen.add(key);
|
|
594
|
+
if (isTomlBooleanFalse(match[4])) {
|
|
595
|
+
preservedFalseFlags.push(key);
|
|
596
|
+
} else {
|
|
597
|
+
nextLines[index] = `${match[1]}${key}${match[3]}true`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const missing = CODEX_FEATURE_FLAGS.filter((flag) => !seen.has(flag));
|
|
603
|
+
nextLines.splice(section.end, 0, ...missing.map((flag) => `${flag} = true`));
|
|
604
|
+
return {
|
|
605
|
+
content: `${nextLines.join("\n")}\n`,
|
|
606
|
+
warnings:
|
|
607
|
+
preservedFalseFlags.length === 0
|
|
608
|
+
? []
|
|
609
|
+
: [
|
|
610
|
+
`Explicitly disabled Codex feature flags were preserved: ${preservedFalseFlags.join(", ")}.`,
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function isTomlBooleanFalse(value: string): boolean {
|
|
616
|
+
return /^false(?:\s*(?:#.*)?)?$/i.test(value.trim());
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function findTomlSection(lines: string[], name: string): { start: number; end: number } | null {
|
|
620
|
+
const header = `[${name}]`;
|
|
621
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
622
|
+
if (start < 0) return null;
|
|
623
|
+
let end = lines.length;
|
|
624
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
625
|
+
if (/^\s*\[[^\]]+\]\s*$/.test(lines[index] ?? "")) {
|
|
626
|
+
end = index;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { start, end };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function backupTimestamp(value?: string): string {
|
|
634
|
+
const source = value ?? new Date().toISOString();
|
|
635
|
+
return source.replace(/[^0-9A-Za-z]/g, "").slice(0, 20) || "now";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function shellQuote(value: string): string {
|
|
639
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
643
|
+
try {
|
|
644
|
+
return (await stat(path)).isFile();
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (isNotFoundError(error)) return false;
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function isNotFoundError(error: unknown): boolean {
|
|
652
|
+
return (
|
|
653
|
+
typeof error === "object" &&
|
|
654
|
+
error !== null &&
|
|
655
|
+
"code" in error &&
|
|
656
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
661
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
662
|
+
}
|
|
@@ -2,15 +2,21 @@ export {
|
|
|
2
2
|
type CodexCommandResult,
|
|
3
3
|
type CodexCommandRunner,
|
|
4
4
|
type CodexPluginOptions,
|
|
5
|
+
type CodexSyncFileSystem,
|
|
5
6
|
codexPlugin,
|
|
6
7
|
createCodexPlugin,
|
|
7
8
|
} from "./codex.ts";
|
|
8
9
|
|
|
9
10
|
export { type ClaudeCommandResult, type ClaudeCommandRunner, detectClaudeCode } from "./detect.ts";
|
|
10
11
|
export {
|
|
12
|
+
type CodexPaths,
|
|
13
|
+
type ResolveCodexPathsOptions,
|
|
11
14
|
type ClaudePaths,
|
|
12
15
|
type ResolveClaudePathsOptions,
|
|
16
|
+
assertSafeCodexAssetName,
|
|
13
17
|
assertSafeClaudeAssetName,
|
|
18
|
+
resolveCodexAgentTargetPath,
|
|
19
|
+
resolveCodexPaths,
|
|
14
20
|
resolveClaudeAgentTargetPath,
|
|
15
21
|
resolveClaudePaths,
|
|
16
22
|
resolveClaudeSkillTargetPath,
|
|
@@ -23,13 +29,19 @@ export {
|
|
|
23
29
|
type ClaudeHookInstallDryRunInput,
|
|
24
30
|
type ClaudeHookInstallDryRunPlan,
|
|
25
31
|
type ClaudeHookMatcherGroup,
|
|
32
|
+
type CodexHookInstallDryRunInput,
|
|
33
|
+
type CodexHookInstallDryRunPlan,
|
|
26
34
|
type CodexHookConfig,
|
|
35
|
+
type HookInstallResult,
|
|
27
36
|
createCodexHookConfig,
|
|
28
37
|
createClaudeHookConfig,
|
|
38
|
+
installClaudeHooks,
|
|
39
|
+
installCodexHooks,
|
|
29
40
|
normalizeCodexHookPayload,
|
|
30
41
|
normalizeCodexRuntimeHookPayload,
|
|
31
42
|
normalizeClaudeHookPayload,
|
|
32
43
|
normalizeClaudeRuntimeHookPayload,
|
|
44
|
+
planCodexHookInstallDryRun,
|
|
33
45
|
planClaudeHookInstallDryRun,
|
|
34
46
|
} from "./hooks.ts";
|
|
35
47
|
export {
|
|
@@ -43,6 +55,9 @@ export {
|
|
|
43
55
|
export {
|
|
44
56
|
type ClaudeAgentTransformInput,
|
|
45
57
|
type ClaudeAgentTransformResult,
|
|
58
|
+
type CodexAgentTransformInput,
|
|
59
|
+
type CodexAgentTransformResult,
|
|
60
|
+
transformCodexAgent,
|
|
46
61
|
transformClaudeAgent,
|
|
47
62
|
} from "./transform-agent.ts";
|
|
48
63
|
export {
|
|
@@ -6,11 +6,24 @@ export interface ClaudePaths {
|
|
|
6
6
|
agentsDir: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface CodexPaths {
|
|
10
|
+
homeDir: string;
|
|
11
|
+
codexDir: string;
|
|
12
|
+
configPath: string;
|
|
13
|
+
hooksPath: string;
|
|
14
|
+
agentsDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
export interface ResolveClaudePathsOptions {
|
|
10
18
|
homeDir: string;
|
|
11
19
|
claudeDir?: string;
|
|
12
20
|
}
|
|
13
21
|
|
|
22
|
+
export interface ResolveCodexPathsOptions {
|
|
23
|
+
homeDir: string;
|
|
24
|
+
codexDir?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export function resolveClaudePaths(options: ResolveClaudePathsOptions): ClaudePaths {
|
|
15
28
|
const homeDir = stripTrailingSlash(options.homeDir);
|
|
16
29
|
const claudeDir = stripTrailingSlash(options.claudeDir ?? `${homeDir}/.claude`);
|
|
@@ -24,6 +37,19 @@ export function resolveClaudePaths(options: ResolveClaudePathsOptions): ClaudePa
|
|
|
24
37
|
};
|
|
25
38
|
}
|
|
26
39
|
|
|
40
|
+
export function resolveCodexPaths(options: ResolveCodexPathsOptions): CodexPaths {
|
|
41
|
+
const homeDir = stripTrailingSlash(options.homeDir);
|
|
42
|
+
const codexDir = stripTrailingSlash(options.codexDir ?? `${homeDir}/.codex`);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
homeDir,
|
|
46
|
+
codexDir,
|
|
47
|
+
configPath: `${codexDir}/config.toml`,
|
|
48
|
+
hooksPath: `${codexDir}/hooks.json`,
|
|
49
|
+
agentsDir: `${codexDir}/agents`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
export function resolveClaudeSkillTargetPath(skillName: string, paths: ClaudePaths): string {
|
|
28
54
|
const safeSkillName = assertSafeClaudeAssetName(skillName, "skillName");
|
|
29
55
|
return `${paths.skillsDir}/${safeSkillName}/SKILL.md`;
|
|
@@ -34,10 +60,27 @@ export function resolveClaudeAgentTargetPath(agentName: string, paths: ClaudePat
|
|
|
34
60
|
return `${paths.agentsDir}/${safeAgentName}.md`;
|
|
35
61
|
}
|
|
36
62
|
|
|
63
|
+
export function resolveCodexAgentTargetPath(agentName: string, paths: CodexPaths): string {
|
|
64
|
+
const safeAgentName = assertSafeCodexAssetName(agentName, "agentName");
|
|
65
|
+
return `${paths.agentsDir}/${safeAgentName}.toml`;
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
export function assertSafeClaudeAssetName(name: string, label = "name"): string {
|
|
69
|
+
return assertSafeCodeAgentAssetName(name, "Claude", label);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertSafeCodexAssetName(name: string, label = "name"): string {
|
|
73
|
+
return assertSafeCodeAgentAssetName(name, "Codex", label);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertSafeCodeAgentAssetName(
|
|
77
|
+
name: string,
|
|
78
|
+
target: "Claude" | "Codex",
|
|
79
|
+
label: string,
|
|
80
|
+
): string {
|
|
38
81
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name === "." || name === "..") {
|
|
39
82
|
throw new Error(
|
|
40
|
-
`Invalid
|
|
83
|
+
`Invalid ${target} asset ${label}: must be a single safe path segment using letters, numbers, dot, underscore, or hyphen`,
|
|
41
84
|
);
|
|
42
85
|
}
|
|
43
86
|
|