@pinta-ai/pinta-codex 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +40 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +136 -0
- package/README.md +214 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.js +100 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/identity.d.ts +1 -0
- package/dist/core/identity.js +3 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/otlp.d.ts +57 -0
- package/dist/core/otlp.js +182 -0
- package/dist/core/otlp.js.map +1 -0
- package/dist/core/redact.d.ts +57 -0
- package/dist/core/redact.js +149 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/retry-queue.d.ts +26 -0
- package/dist/core/retry-queue.js +127 -0
- package/dist/core/retry-queue.js.map +1 -0
- package/dist/core/trace.d.ts +10 -0
- package/dist/core/trace.js +61 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/transport.d.ts +10 -0
- package/dist/core/transport.js +77 -0
- package/dist/core/transport.js.map +1 -0
- package/dist/core/types.d.ts +48 -0
- package/dist/core/types.js +45 -0
- package/dist/core/types.js.map +1 -0
- package/dist/handlers/default.d.ts +6 -0
- package/dist/handlers/default.js +11 -0
- package/dist/handlers/default.js.map +1 -0
- package/dist/handlers/post-tool-use.d.ts +3 -0
- package/dist/handlers/post-tool-use.js +15 -0
- package/dist/handlers/post-tool-use.js.map +1 -0
- package/dist/handlers/pre-tool-use.d.ts +3 -0
- package/dist/handlers/pre-tool-use.js +15 -0
- package/dist/handlers/pre-tool-use.js.map +1 -0
- package/dist/handlers/session.d.ts +3 -0
- package/dist/handlers/session.js +15 -0
- package/dist/handlers/session.js.map +1 -0
- package/dist/handlers/stop.d.ts +3 -0
- package/dist/handlers/stop.js +15 -0
- package/dist/handlers/stop.js.map +1 -0
- package/dist/handlers/user-prompt.d.ts +3 -0
- package/dist/handlers/user-prompt.js +15 -0
- package/dist/handlers/user-prompt.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/hooks.json +54 -0
- package/package.json +43 -0
- package/tools/.data/events.json +1585 -0
- package/tools/_lib.ts +274 -0
- package/tools/doctor.ts +250 -0
- package/tools/install-hooks.ts +71 -0
- package/tools/mock-server.ts +436 -0
- package/tools/setup.ts +288 -0
- package/tools/test-otlp.ts +58 -0
- package/tools/test-redact.ts +286 -0
- package/tools/tsconfig.json +13 -0
- package/tools/uninstall-hooks.ts +39 -0
package/tools/_lib.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for setup / doctor / install-hooks / uninstall-hooks.
|
|
3
|
+
*
|
|
4
|
+
* Keeps path resolution, config-file I/O, and TOML toggling in one place so
|
|
5
|
+
* the scripts stay thin and stay consistent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
|
|
12
|
+
export const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
|
13
|
+
export const PLUGIN_ENTRY = path.join(PLUGIN_ROOT, "dist", "index.js");
|
|
14
|
+
|
|
15
|
+
export const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
16
|
+
export const CODEX_HOOKS_PATH = path.join(CODEX_HOME, "hooks.json");
|
|
17
|
+
export const CODEX_CONFIG_PATH = path.join(CODEX_HOME, "config.toml");
|
|
18
|
+
export const CODEX_ENV_PATH = path.join(CODEX_HOME, "pinta-codex.env");
|
|
19
|
+
|
|
20
|
+
export interface HookCommand {
|
|
21
|
+
type: "command";
|
|
22
|
+
command: string;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
statusMessage?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface HookMatcher {
|
|
27
|
+
matcher?: string;
|
|
28
|
+
hooks: HookCommand[];
|
|
29
|
+
}
|
|
30
|
+
export interface HooksFile {
|
|
31
|
+
hooks: Record<string, HookMatcher[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- JSON helpers ---
|
|
35
|
+
|
|
36
|
+
export function readJson<T>(p: string, fallback: T): T {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(fs.readFileSync(p, "utf-8")) as T;
|
|
39
|
+
} catch {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- env file ("KEY=VALUE" per line) ---
|
|
45
|
+
|
|
46
|
+
export function readEnvFile(p: string): Record<string, string> {
|
|
47
|
+
const out: Record<string, string> = {};
|
|
48
|
+
let content: string;
|
|
49
|
+
try {
|
|
50
|
+
content = fs.readFileSync(p, "utf-8");
|
|
51
|
+
} catch {
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
for (const raw of content.split("\n")) {
|
|
55
|
+
const line = raw.trim();
|
|
56
|
+
if (!line || line.startsWith("#")) continue;
|
|
57
|
+
const idx = line.indexOf("=");
|
|
58
|
+
if (idx < 0) continue;
|
|
59
|
+
const key = line.slice(0, idx).trim();
|
|
60
|
+
let value = line.slice(idx + 1).trim();
|
|
61
|
+
if (
|
|
62
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
63
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
64
|
+
) {
|
|
65
|
+
value = value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
if (key) out[key] = value;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeEnvFile(p: string, values: Record<string, string>): void {
|
|
73
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
74
|
+
const body = Object.entries(values)
|
|
75
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
76
|
+
.join("\n");
|
|
77
|
+
fs.writeFileSync(p, body + "\n", "utf-8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Migrate legacy `PINTA_CODEX_*` keys to OTel-spec names.
|
|
82
|
+
* - PINTA_CODEX_ENDPOINT → OTEL_EXPORTER_OTLP_ENDPOINT
|
|
83
|
+
* - PINTA_CODEX_API_KEY → OTEL_EXPORTER_OTLP_HEADERS=x-pinta-relay-token=<value>
|
|
84
|
+
*
|
|
85
|
+
* If migration occurs, writes `<path>.bak` with the original content first
|
|
86
|
+
* (idempotent — won't overwrite an existing .bak), then writes the file with
|
|
87
|
+
* the new keys. Existing OTel-spec keys win and are preserved as-is.
|
|
88
|
+
*
|
|
89
|
+
* Returns the list of legacy keys that were migrated, or empty array if no
|
|
90
|
+
* migration was needed.
|
|
91
|
+
*/
|
|
92
|
+
export function migrateLegacyEnvKeys(p: string): string[] {
|
|
93
|
+
if (!fs.existsSync(p)) return [];
|
|
94
|
+
const original = fs.readFileSync(p, "utf-8");
|
|
95
|
+
const values = readEnvFile(p);
|
|
96
|
+
const renamed: string[] = [];
|
|
97
|
+
|
|
98
|
+
if (values.PINTA_CODEX_ENDPOINT && !values.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
99
|
+
values.OTEL_EXPORTER_OTLP_ENDPOINT = values.PINTA_CODEX_ENDPOINT;
|
|
100
|
+
delete values.PINTA_CODEX_ENDPOINT;
|
|
101
|
+
renamed.push("PINTA_CODEX_ENDPOINT");
|
|
102
|
+
}
|
|
103
|
+
if (values.PINTA_CODEX_API_KEY && !values.OTEL_EXPORTER_OTLP_HEADERS) {
|
|
104
|
+
values.OTEL_EXPORTER_OTLP_HEADERS = `x-pinta-relay-token=${values.PINTA_CODEX_API_KEY}`;
|
|
105
|
+
delete values.PINTA_CODEX_API_KEY;
|
|
106
|
+
renamed.push("PINTA_CODEX_API_KEY");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (renamed.length === 0) return [];
|
|
110
|
+
|
|
111
|
+
const bakPath = `${p}.bak`;
|
|
112
|
+
if (!fs.existsSync(bakPath)) {
|
|
113
|
+
fs.writeFileSync(bakPath, original, "utf-8");
|
|
114
|
+
}
|
|
115
|
+
writeEnvFile(p, values);
|
|
116
|
+
return renamed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- TOML: minimal, targeted `[features].codex_hooks = true` toggle ---
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensure `[features] codex_hooks = true` is set in the TOML source.
|
|
123
|
+
* Preserves unrelated content; only touches the features section.
|
|
124
|
+
*
|
|
125
|
+
* Handles:
|
|
126
|
+
* - no file / empty file → append section
|
|
127
|
+
* - no [features] section → append section
|
|
128
|
+
* - [features] present, key missing → insert key
|
|
129
|
+
* - key present with different value → replace value
|
|
130
|
+
* - key already true → no change
|
|
131
|
+
*/
|
|
132
|
+
export function ensureCodexHooksEnabled(content: string): {
|
|
133
|
+
next: string;
|
|
134
|
+
changed: boolean;
|
|
135
|
+
} {
|
|
136
|
+
const sectionHeader = /^\[features\]\s*$/m;
|
|
137
|
+
const match = content.match(sectionHeader);
|
|
138
|
+
if (!match) {
|
|
139
|
+
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
140
|
+
const suffix = content.length === 0 ? "" : "\n";
|
|
141
|
+
return {
|
|
142
|
+
next: content + sep + suffix + "[features]\ncodex_hooks = true\n",
|
|
143
|
+
changed: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sectionStart = match.index! + match[0].length;
|
|
148
|
+
const rest = content.slice(sectionStart);
|
|
149
|
+
const nextSection = rest.match(/\n\[[^\]]+\]\s*$/m);
|
|
150
|
+
const sectionEnd = nextSection ? sectionStart + nextSection.index! : content.length;
|
|
151
|
+
const body = content.slice(sectionStart, sectionEnd);
|
|
152
|
+
|
|
153
|
+
const keyRe = /^(\s*)codex_hooks\s*=\s*(true|false|"[^"]*"|'[^']*')\s*$/m;
|
|
154
|
+
const keyMatch = body.match(keyRe);
|
|
155
|
+
if (keyMatch) {
|
|
156
|
+
if (keyMatch[2] === "true") return { next: content, changed: false };
|
|
157
|
+
const replaced = body.replace(keyRe, `${keyMatch[1] ?? ""}codex_hooks = true`);
|
|
158
|
+
return {
|
|
159
|
+
next: content.slice(0, sectionStart) + replaced + content.slice(sectionEnd),
|
|
160
|
+
changed: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sep = body.length === 0 || body.endsWith("\n") ? "" : "\n";
|
|
165
|
+
const insert = `${sep}codex_hooks = true\n`;
|
|
166
|
+
return {
|
|
167
|
+
next: content.slice(0, sectionStart) + body + insert + content.slice(sectionEnd),
|
|
168
|
+
changed: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isCodexHooksEnabled(content: string): boolean {
|
|
173
|
+
const sectionHeader = /^\[features\]\s*$/m;
|
|
174
|
+
const match = content.match(sectionHeader);
|
|
175
|
+
if (!match) return false;
|
|
176
|
+
const sectionStart = match.index! + match[0].length;
|
|
177
|
+
const rest = content.slice(sectionStart);
|
|
178
|
+
const nextSection = rest.match(/\n\[[^\]]+\]\s*$/m);
|
|
179
|
+
const sectionEnd = nextSection ? sectionStart + nextSection.index! : content.length;
|
|
180
|
+
const body = content.slice(sectionStart, sectionEnd);
|
|
181
|
+
const keyRe = /^\s*codex_hooks\s*=\s*(true|"true"|'true')\s*$/m;
|
|
182
|
+
return keyRe.test(body);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- hooks.json merge ---
|
|
186
|
+
|
|
187
|
+
const STALE_PINTA_CODEX_RE = /\/pinta-codex(?:\/[^\s]*)?\/dist\/index\.js\b/;
|
|
188
|
+
|
|
189
|
+
export function isPintaCodexEntry(cmd: HookCommand): boolean {
|
|
190
|
+
if (cmd.command.includes(PLUGIN_ENTRY)) return true;
|
|
191
|
+
return STALE_PINTA_CODEX_RE.test(cmd.command);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when the entry points at a pinta-codex path other than the current PLUGIN_ENTRY. */
|
|
195
|
+
export function isStalePintaCodexEntry(cmd: HookCommand): boolean {
|
|
196
|
+
if (cmd.command.includes(PLUGIN_ENTRY)) return false;
|
|
197
|
+
return STALE_PINTA_CODEX_RE.test(cmd.command);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Load the plugin's bundled template and resolve ${CODEX_PLUGIN_ROOT} → PLUGIN_ROOT. */
|
|
201
|
+
export function loadResolvedTemplate(): HooksFile {
|
|
202
|
+
const templatePath = path.join(PLUGIN_ROOT, "hooks.json");
|
|
203
|
+
const template: HooksFile = readJson(templatePath, { hooks: {} });
|
|
204
|
+
const out: HooksFile = { hooks: {} };
|
|
205
|
+
for (const [eventName, matchers] of Object.entries(template.hooks)) {
|
|
206
|
+
out.hooks[eventName] = matchers.map((m) => ({
|
|
207
|
+
...m,
|
|
208
|
+
hooks: m.hooks.map((h) => ({
|
|
209
|
+
...h,
|
|
210
|
+
command: h.command.replace(/\$\{CODEX_PLUGIN_ROOT\}/g, PLUGIN_ROOT),
|
|
211
|
+
})),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge `incoming` into `existing`, removing any prior pinta-codex entries
|
|
219
|
+
* first (both exact-path matches and stale entries from prior installs that
|
|
220
|
+
* used a different path).
|
|
221
|
+
*/
|
|
222
|
+
export function mergeHooks(
|
|
223
|
+
existing: HooksFile,
|
|
224
|
+
incoming: HooksFile,
|
|
225
|
+
): { next: HooksFile; staleRemoved: number } {
|
|
226
|
+
const merged: HooksFile = { hooks: { ...existing.hooks } };
|
|
227
|
+
let staleRemoved = 0;
|
|
228
|
+
for (const [eventName, incomingMatchers] of Object.entries(incoming.hooks)) {
|
|
229
|
+
const current = merged.hooks[eventName] ?? [];
|
|
230
|
+
const cleaned = current
|
|
231
|
+
.map((m) => {
|
|
232
|
+
const staleBefore = m.hooks.filter((h) => isStalePintaCodexEntry(h)).length;
|
|
233
|
+
staleRemoved += staleBefore;
|
|
234
|
+
const kept = m.hooks.filter((h) => !isPintaCodexEntry(h));
|
|
235
|
+
return { ...m, hooks: kept };
|
|
236
|
+
})
|
|
237
|
+
.filter((m) => m.hooks.length > 0);
|
|
238
|
+
merged.hooks[eventName] = [...cleaned, ...incomingMatchers];
|
|
239
|
+
}
|
|
240
|
+
return { next: merged, staleRemoved };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function stripPintaCodex(existing: HooksFile): {
|
|
244
|
+
next: HooksFile;
|
|
245
|
+
removed: number;
|
|
246
|
+
} {
|
|
247
|
+
const cleaned: HooksFile = { hooks: {} };
|
|
248
|
+
let removed = 0;
|
|
249
|
+
for (const [eventName, matchers] of Object.entries(existing.hooks)) {
|
|
250
|
+
const next = matchers
|
|
251
|
+
.map((m) => {
|
|
252
|
+
const before = m.hooks.length;
|
|
253
|
+
const kept = m.hooks.filter((h) => !isPintaCodexEntry(h));
|
|
254
|
+
removed += before - kept.length;
|
|
255
|
+
return { ...m, hooks: kept };
|
|
256
|
+
})
|
|
257
|
+
.filter((m) => m.hooks.length > 0);
|
|
258
|
+
if (next.length > 0) cleaned.hooks[eventName] = next;
|
|
259
|
+
}
|
|
260
|
+
return { next: cleaned, removed };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function countRegisteredEvents(existing: HooksFile): number {
|
|
264
|
+
let n = 0;
|
|
265
|
+
for (const matchers of Object.values(existing.hooks)) {
|
|
266
|
+
for (const m of matchers) {
|
|
267
|
+
if (m.hooks.some((h) => isPintaCodexEntry(h))) {
|
|
268
|
+
n++;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return n;
|
|
274
|
+
}
|
package/tools/doctor.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health check. Run when something looks off or before filing a bug.
|
|
3
|
+
*
|
|
4
|
+
* npm run doctor
|
|
5
|
+
*
|
|
6
|
+
* Never mutates state — read-only. Exits 0 if everything is green, 1 if any
|
|
7
|
+
* check fails. Yellow warnings do not fail the exit code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
CODEX_CONFIG_PATH,
|
|
14
|
+
CODEX_ENV_PATH,
|
|
15
|
+
CODEX_HOOKS_PATH,
|
|
16
|
+
PLUGIN_ENTRY,
|
|
17
|
+
PLUGIN_ROOT,
|
|
18
|
+
countRegisteredEvents,
|
|
19
|
+
isCodexHooksEnabled,
|
|
20
|
+
readEnvFile,
|
|
21
|
+
readJson,
|
|
22
|
+
type HooksFile,
|
|
23
|
+
} from "./_lib.js";
|
|
24
|
+
|
|
25
|
+
const ANSI = {
|
|
26
|
+
reset: "\x1b[0m",
|
|
27
|
+
bold: "\x1b[1m",
|
|
28
|
+
dim: "\x1b[2m",
|
|
29
|
+
green: "\x1b[32m",
|
|
30
|
+
yellow: "\x1b[33m",
|
|
31
|
+
red: "\x1b[31m",
|
|
32
|
+
cyan: "\x1b[36m",
|
|
33
|
+
};
|
|
34
|
+
const useColor = Boolean(process.stdout.isTTY);
|
|
35
|
+
const c = (code: string, s: string) => (useColor ? `${code}${s}${ANSI.reset}` : s);
|
|
36
|
+
|
|
37
|
+
type Status = "pass" | "warn" | "fail";
|
|
38
|
+
interface Result {
|
|
39
|
+
status: Status;
|
|
40
|
+
label: string;
|
|
41
|
+
detail?: string;
|
|
42
|
+
hint?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const results: Result[] = [];
|
|
46
|
+
function pass(label: string, detail?: string): void {
|
|
47
|
+
results.push({ status: "pass", label, detail });
|
|
48
|
+
}
|
|
49
|
+
function warn(label: string, detail: string, hint?: string): void {
|
|
50
|
+
results.push({ status: "warn", label, detail, hint });
|
|
51
|
+
}
|
|
52
|
+
function fail(label: string, detail: string, hint?: string): void {
|
|
53
|
+
results.push({ status: "fail", label, detail, hint });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- checks ---
|
|
57
|
+
|
|
58
|
+
function checkDist(): void {
|
|
59
|
+
if (!fs.existsSync(PLUGIN_ENTRY)) {
|
|
60
|
+
fail("dist/index.js", "missing", "Run 'npm run build' (or 'npm run setup').");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
pass("dist/index.js", PLUGIN_ENTRY);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkEnv(): { endpoint?: string; headers?: string } {
|
|
67
|
+
const fromFile = readEnvFile(CODEX_ENV_PATH);
|
|
68
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
69
|
+
?? fromFile.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
70
|
+
?? process.env.PINTA_CODEX_ENDPOINT
|
|
71
|
+
?? fromFile.PINTA_CODEX_ENDPOINT;
|
|
72
|
+
const headers = process.env.OTEL_EXPORTER_OTLP_HEADERS
|
|
73
|
+
?? fromFile.OTEL_EXPORTER_OTLP_HEADERS
|
|
74
|
+
?? (process.env.PINTA_CODEX_API_KEY
|
|
75
|
+
? `x-pinta-relay-token=${process.env.PINTA_CODEX_API_KEY}`
|
|
76
|
+
: undefined)
|
|
77
|
+
?? (fromFile.PINTA_CODEX_API_KEY
|
|
78
|
+
? `x-pinta-relay-token=${fromFile.PINTA_CODEX_API_KEY}`
|
|
79
|
+
: undefined);
|
|
80
|
+
|
|
81
|
+
const usingLegacy = !!(fromFile.PINTA_CODEX_ENDPOINT || fromFile.PINTA_CODEX_API_KEY
|
|
82
|
+
|| process.env.PINTA_CODEX_ENDPOINT || process.env.PINTA_CODEX_API_KEY);
|
|
83
|
+
|
|
84
|
+
if (endpoint) {
|
|
85
|
+
pass("OTLP endpoint", endpoint);
|
|
86
|
+
if (usingLegacy) {
|
|
87
|
+
warn("legacy keys", "PINTA_CODEX_* keys still in use", "Run 'npm run setup' to migrate to OTEL_EXPORTER_OTLP_* (auto-renames + writes .bak).");
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
fail("OTLP endpoint", "not configured", "Run 'npm run setup' or set OTEL_EXPORTER_OTLP_ENDPOINT.");
|
|
91
|
+
}
|
|
92
|
+
return { endpoint, headers };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkCodexHooksFlag(): void {
|
|
96
|
+
let content = "";
|
|
97
|
+
try {
|
|
98
|
+
content = fs.readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
99
|
+
} catch {
|
|
100
|
+
fail(
|
|
101
|
+
"codex_hooks flag",
|
|
102
|
+
`${CODEX_CONFIG_PATH} not found`,
|
|
103
|
+
"Run 'npm run setup' to create it.",
|
|
104
|
+
);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (isCodexHooksEnabled(content)) {
|
|
108
|
+
pass("codex_hooks flag", "[features].codex_hooks = true");
|
|
109
|
+
} else {
|
|
110
|
+
fail(
|
|
111
|
+
"codex_hooks flag",
|
|
112
|
+
"not enabled in config.toml",
|
|
113
|
+
"Add [features] codex_hooks = true, or run 'npm run setup'.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkHooksRegistered(): void {
|
|
119
|
+
if (!fs.existsSync(CODEX_HOOKS_PATH)) {
|
|
120
|
+
fail(
|
|
121
|
+
"hooks.json",
|
|
122
|
+
`${CODEX_HOOKS_PATH} not found`,
|
|
123
|
+
"Run 'npm run install-hooks' (or 'npm run setup').",
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const existing: HooksFile = readJson(CODEX_HOOKS_PATH, { hooks: {} });
|
|
128
|
+
const registered = countRegisteredEvents(existing);
|
|
129
|
+
if (registered === 0) {
|
|
130
|
+
fail(
|
|
131
|
+
"hooks.json",
|
|
132
|
+
"no pinta-codex entries",
|
|
133
|
+
"Run 'npm run install-hooks'.",
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
pass("hooks.json", `${registered} event(s) registered`);
|
|
138
|
+
|
|
139
|
+
for (const [eventName, matchers] of Object.entries(existing.hooks)) {
|
|
140
|
+
for (const m of matchers) {
|
|
141
|
+
for (const h of m.hooks) {
|
|
142
|
+
if (!h.command.includes(PLUGIN_ENTRY)) continue;
|
|
143
|
+
// Extract the file path from `node <abs-path>` and verify it exists.
|
|
144
|
+
const parts = h.command.split(/\s+/);
|
|
145
|
+
const script = parts[parts.length - 1];
|
|
146
|
+
if (!fs.existsSync(script)) {
|
|
147
|
+
warn(
|
|
148
|
+
`hooks.json:${eventName}`,
|
|
149
|
+
`command references missing file: ${script}`,
|
|
150
|
+
"Plugin may have moved — run 'npm run install-hooks' from the current location.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function checkEndpointReachability(endpoint?: string): Promise<void> {
|
|
159
|
+
if (!endpoint) {
|
|
160
|
+
warn("endpoint reachability", "skipped (no endpoint configured)");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const url = `${endpoint.replace(/\/+$/, "")}/traces`;
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
166
|
+
try {
|
|
167
|
+
// HEAD /traces commonly returns 404 or 405 on POST-only backends (e.g.
|
|
168
|
+
// Fastify won't auto-register HEAD for a POST route and falls through to
|
|
169
|
+
// 404). Any HTTP response proves the host is reachable; we only fail on
|
|
170
|
+
// transport-level errors (DNS, connect, TLS, timeout).
|
|
171
|
+
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
|
|
172
|
+
pass("endpoint reachability", `${url} → HTTP ${res.status}`);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
fail(
|
|
175
|
+
"endpoint reachability",
|
|
176
|
+
`cannot reach ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
177
|
+
"Check network / VPN / OTEL_EXPORTER_OTLP_ENDPOINT value.",
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function checkRetryQueue(): void {
|
|
185
|
+
const pluginData = process.env.CODEX_PLUGIN_DATA || path.join(PLUGIN_ROOT, ".plugin-data");
|
|
186
|
+
const queuePath = path.join(pluginData, "failed-spans.jsonl");
|
|
187
|
+
if (!fs.existsSync(queuePath)) {
|
|
188
|
+
pass("retry queue", "empty");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const content = fs.readFileSync(queuePath, "utf-8");
|
|
192
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0).length;
|
|
193
|
+
if (lines === 0) {
|
|
194
|
+
pass("retry queue", "empty");
|
|
195
|
+
} else if (lines < 100) {
|
|
196
|
+
pass("retry queue", `${lines} pending`);
|
|
197
|
+
} else if (lines < 1000) {
|
|
198
|
+
warn(
|
|
199
|
+
"retry queue",
|
|
200
|
+
`${lines} pending entries`,
|
|
201
|
+
"Large backlog — next hook will try to drain. Check endpoint reachability.",
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
fail(
|
|
205
|
+
"retry queue",
|
|
206
|
+
`${lines} entries (cap 1000)`,
|
|
207
|
+
"Queue is full. Drops will occur. Fix transport then wait for flush.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- reporter ---
|
|
213
|
+
|
|
214
|
+
function printResults(): number {
|
|
215
|
+
process.stdout.write(c(ANSI.bold, "\nPinta Codex — health check\n\n"));
|
|
216
|
+
let failed = 0;
|
|
217
|
+
for (const r of results) {
|
|
218
|
+
const glyph = r.status === "pass"
|
|
219
|
+
? c(ANSI.green, "✔")
|
|
220
|
+
: r.status === "warn"
|
|
221
|
+
? c(ANSI.yellow, "!")
|
|
222
|
+
: c(ANSI.red, "✘");
|
|
223
|
+
const label = c(ANSI.bold, r.label.padEnd(24));
|
|
224
|
+
const detail = r.detail ? c(ANSI.dim, r.detail) : "";
|
|
225
|
+
process.stdout.write(` ${glyph} ${label}${detail}\n`);
|
|
226
|
+
if (r.hint) {
|
|
227
|
+
process.stdout.write(` ${c(ANSI.cyan, "→ " + r.hint)}\n`);
|
|
228
|
+
}
|
|
229
|
+
if (r.status === "fail") failed++;
|
|
230
|
+
}
|
|
231
|
+
const summary = failed === 0
|
|
232
|
+
? c(ANSI.green, "\nAll checks passed.\n")
|
|
233
|
+
: c(ANSI.red, `\n${failed} check(s) failed.\n`);
|
|
234
|
+
process.stdout.write(summary);
|
|
235
|
+
return failed === 0 ? 0 : 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- main ---
|
|
239
|
+
|
|
240
|
+
async function main(): Promise<void> {
|
|
241
|
+
checkDist();
|
|
242
|
+
const { endpoint } = checkEnv();
|
|
243
|
+
checkCodexHooksFlag();
|
|
244
|
+
checkHooksRegistered();
|
|
245
|
+
await checkEndpointReachability(endpoint);
|
|
246
|
+
checkRetryQueue();
|
|
247
|
+
process.exit(printResults());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge this plugin's hooks into `~/.codex/hooks.json` with the plugin's
|
|
3
|
+
* absolute path baked in.
|
|
4
|
+
*
|
|
5
|
+
* See `tools/_lib.ts` for the heavy lifting. For the rationale, see README.md
|
|
6
|
+
* (section "Install") and doctor's "hooks.json" check.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx tools/install-hooks.ts # merge into ~/.codex/hooks.json
|
|
10
|
+
* npx tsx tools/install-hooks.ts --dry-run # print the final file, don't write
|
|
11
|
+
* CODEX_HOME=/abs/path npx tsx tools/install-hooks.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
CODEX_HOME,
|
|
17
|
+
CODEX_HOOKS_PATH,
|
|
18
|
+
PLUGIN_ENTRY,
|
|
19
|
+
loadResolvedTemplate,
|
|
20
|
+
mergeHooks,
|
|
21
|
+
readJson,
|
|
22
|
+
type HooksFile,
|
|
23
|
+
} from "./_lib.js";
|
|
24
|
+
|
|
25
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
26
|
+
|
|
27
|
+
function main(): void {
|
|
28
|
+
if (!fs.existsSync(PLUGIN_ENTRY)) {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`[install-hooks] plugin entry missing: ${PLUGIN_ENTRY}\n` +
|
|
31
|
+
` Run 'npm run build' before installing hooks.\n`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const incoming = loadResolvedTemplate();
|
|
37
|
+
const existing: HooksFile = readJson(CODEX_HOOKS_PATH, { hooks: {} });
|
|
38
|
+
const { next: merged, staleRemoved } = mergeHooks(existing, incoming);
|
|
39
|
+
const serialized = JSON.stringify(merged, null, 2) + "\n";
|
|
40
|
+
|
|
41
|
+
if (dryRun) {
|
|
42
|
+
process.stdout.write(`--- ${CODEX_HOOKS_PATH} (dry run) ---\n`);
|
|
43
|
+
process.stdout.write(serialized);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const previous = fs.existsSync(CODEX_HOOKS_PATH)
|
|
48
|
+
? fs.readFileSync(CODEX_HOOKS_PATH, "utf-8")
|
|
49
|
+
: null;
|
|
50
|
+
|
|
51
|
+
if (previous === serialized) {
|
|
52
|
+
process.stdout.write(`[install-hooks] already up to date: ${CODEX_HOOKS_PATH}\n`);
|
|
53
|
+
process.stdout.write(`[install-hooks] plugin entry: ${PLUGIN_ENTRY}\n`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fs.mkdirSync(CODEX_HOME, { recursive: true });
|
|
58
|
+
fs.writeFileSync(CODEX_HOOKS_PATH, serialized, "utf-8");
|
|
59
|
+
if (staleRemoved > 0) {
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
`[install-hooks] removed ${staleRemoved} stale pinta-codex ` +
|
|
62
|
+
`${staleRemoved === 1 ? "entry" : "entries"} (prior install at a different path)\n`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write(
|
|
66
|
+
`[install-hooks] ${previous === null ? "created" : "updated"} ${CODEX_HOOKS_PATH}\n`,
|
|
67
|
+
);
|
|
68
|
+
process.stdout.write(`[install-hooks] plugin entry: ${PLUGIN_ENTRY}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main();
|