selftune 0.2.20 → 0.2.22

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,289 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Install selftune hooks into Codex environment.
4
+ *
5
+ * Writes hook entries to ~/.codex/hooks.json so Codex pipes events to selftune.
6
+ * Preserves existing non-selftune hooks. Supports --dry-run and --uninstall.
7
+ *
8
+ * Usage:
9
+ * selftune codex install # Install hooks
10
+ * selftune codex install --dry-run # Preview changes without writing
11
+ * selftune codex install --uninstall # Remove selftune hooks
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface CodexHookEntry {
23
+ event: string;
24
+ command: string;
25
+ timeout_ms?: number;
26
+ matchers?: string[];
27
+ /** Marker field so selftune can identify its own hooks. */
28
+ _selftune?: boolean;
29
+ }
30
+
31
+ interface CodexHooksFile {
32
+ hooks?: CodexHookEntry[];
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Constants
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const DEFAULT_CODEX_HOME = join(homedir(), ".codex");
41
+ const HOOKS_FILENAME = "hooks.json";
42
+ const DEFAULT_TIMEOUT_MS = 10_000;
43
+ const SESSION_TIMEOUT_MS = 30_000;
44
+
45
+ /** The command Codex will run for each hook event. */
46
+ const HOOK_COMMAND =
47
+ 'bash -c \'if [ -n "$SELFTUNE_CLI_PATH" ]; then exec "$SELFTUNE_CLI_PATH" codex hook; else exec npx -y selftune@latest codex hook; fi\'';
48
+
49
+ /** Hook entries selftune installs into Codex. */
50
+ const SELFTUNE_HOOKS: CodexHookEntry[] = [
51
+ {
52
+ event: "SessionStart",
53
+ command: HOOK_COMMAND,
54
+ timeout_ms: SESSION_TIMEOUT_MS,
55
+ _selftune: true,
56
+ },
57
+ {
58
+ event: "PreToolUse",
59
+ command: HOOK_COMMAND,
60
+ timeout_ms: DEFAULT_TIMEOUT_MS,
61
+ _selftune: true,
62
+ },
63
+ {
64
+ event: "PostToolUse",
65
+ command: HOOK_COMMAND,
66
+ timeout_ms: DEFAULT_TIMEOUT_MS,
67
+ _selftune: true,
68
+ },
69
+ {
70
+ event: "Stop",
71
+ command: HOOK_COMMAND,
72
+ timeout_ms: SESSION_TIMEOUT_MS,
73
+ _selftune: true,
74
+ },
75
+ ];
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function getCodexHooksPath(): string {
82
+ const codexHome = process.env.CODEX_HOME ?? DEFAULT_CODEX_HOME;
83
+ return join(codexHome, HOOKS_FILENAME);
84
+ }
85
+
86
+ function getCodexHome(): string {
87
+ return process.env.CODEX_HOME ?? DEFAULT_CODEX_HOME;
88
+ }
89
+
90
+ /** Read and parse existing hooks.json, or return empty structure. */
91
+ function readHooksFile(path: string): CodexHooksFile {
92
+ if (!existsSync(path)) return { hooks: [] };
93
+ try {
94
+ const raw = readFileSync(path, "utf-8").trim();
95
+ if (!raw) return { hooks: [] };
96
+ const parsed = JSON.parse(raw) as CodexHooksFile;
97
+ if (parsed.hooks !== undefined && !Array.isArray(parsed.hooks)) {
98
+ throw new Error(`Invalid Codex hooks file: "hooks" must be an array`);
99
+ }
100
+ if (!Array.isArray(parsed.hooks)) parsed.hooks = [];
101
+ return parsed;
102
+ } catch (err) {
103
+ throw new Error(`Failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ }
106
+
107
+ /** Legacy command strings that identify selftune-installed hooks (before the _selftune marker). */
108
+ const LEGACY_SELFTUNE_COMMANDS = [
109
+ "npx selftune codex hook",
110
+ "npx -y selftune@latest codex hook",
111
+ "npx -y selftune codex hook",
112
+ ];
113
+
114
+ /** Check if a hook entry was installed by selftune. */
115
+ function isSelftuneHook(entry: CodexHookEntry): boolean {
116
+ if (entry._selftune === true) return true;
117
+ // Exact match against known legacy commands only
118
+ return typeof entry.command === "string" && LEGACY_SELFTUNE_COMMANDS.includes(entry.command);
119
+ }
120
+
121
+ /** Merge selftune hooks into existing hooks, replacing any previous selftune entries. */
122
+ export function mergeHooks(
123
+ existing: CodexHookEntry[],
124
+ incoming: CodexHookEntry[],
125
+ ): CodexHookEntry[] {
126
+ // Keep all non-selftune hooks
127
+ const preserved = existing.filter((h) => !isSelftuneHook(h));
128
+ // Append new selftune hooks
129
+ return [...preserved, ...incoming];
130
+ }
131
+
132
+ /** Remove all selftune hooks from the list. */
133
+ export function removeSelftuneHooks(existing: CodexHookEntry[]): CodexHookEntry[] {
134
+ return existing.filter((h) => !isSelftuneHook(h));
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Install / uninstall logic
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface InstallResult {
142
+ hooksPath: string;
143
+ action: "installed" | "uninstalled" | "no_change";
144
+ hooksWritten: number;
145
+ hooksRemoved: number;
146
+ dryRun: boolean;
147
+ }
148
+
149
+ export function installHooks(options: { dryRun?: boolean } = {}): InstallResult {
150
+ const hooksPath = getCodexHooksPath();
151
+ const codexHome = getCodexHome();
152
+ const hooksFile = readHooksFile(hooksPath);
153
+ const existingHooks = hooksFile.hooks ?? [];
154
+
155
+ const merged = mergeHooks(existingHooks, SELFTUNE_HOOKS);
156
+
157
+ // Check if anything changed
158
+ const existingJson = JSON.stringify(existingHooks);
159
+ const mergedJson = JSON.stringify(merged);
160
+
161
+ if (existingJson === mergedJson) {
162
+ return {
163
+ hooksPath,
164
+ action: "no_change",
165
+ hooksWritten: 0,
166
+ hooksRemoved: 0,
167
+ dryRun: options.dryRun ?? false,
168
+ };
169
+ }
170
+
171
+ if (!options.dryRun) {
172
+ if (!existsSync(codexHome)) {
173
+ mkdirSync(codexHome, { recursive: true });
174
+ }
175
+ hooksFile.hooks = merged;
176
+ writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2) + "\n", "utf-8");
177
+ }
178
+
179
+ return {
180
+ hooksPath,
181
+ action: "installed",
182
+ hooksWritten: SELFTUNE_HOOKS.length,
183
+ hooksRemoved: existingHooks.filter((h) => isSelftuneHook(h)).length,
184
+ dryRun: options.dryRun ?? false,
185
+ };
186
+ }
187
+
188
+ export function uninstallHooks(options: { dryRun?: boolean } = {}): InstallResult {
189
+ const hooksPath = getCodexHooksPath();
190
+ const hooksFile = readHooksFile(hooksPath);
191
+ const existingHooks = hooksFile.hooks ?? [];
192
+
193
+ const cleaned = removeSelftuneHooks(existingHooks);
194
+ const removedCount = existingHooks.length - cleaned.length;
195
+
196
+ if (removedCount === 0) {
197
+ return {
198
+ hooksPath,
199
+ action: "no_change",
200
+ hooksWritten: 0,
201
+ hooksRemoved: 0,
202
+ dryRun: options.dryRun ?? false,
203
+ };
204
+ }
205
+
206
+ if (!options.dryRun) {
207
+ hooksFile.hooks = cleaned;
208
+ writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2) + "\n", "utf-8");
209
+ }
210
+
211
+ return {
212
+ hooksPath,
213
+ action: "uninstalled",
214
+ hooksWritten: 0,
215
+ hooksRemoved: removedCount,
216
+ dryRun: options.dryRun ?? false,
217
+ };
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // CLI entry point
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * CLI entry point for `selftune codex install`.
226
+ */
227
+ export async function cliMain(): Promise<void> {
228
+ const args = process.argv.slice(2);
229
+ const dryRun = args.includes("--dry-run");
230
+ const uninstall = args.includes("--uninstall");
231
+
232
+ try {
233
+ if (uninstall) {
234
+ const result = uninstallHooks({ dryRun });
235
+
236
+ if (result.action === "no_change") {
237
+ console.log("No selftune hooks found in Codex configuration.");
238
+ console.log(`Config: ${result.hooksPath}`);
239
+ } else {
240
+ const prefix = dryRun ? "[dry-run] Would remove" : "Removed";
241
+ console.log(`${prefix} ${result.hooksRemoved} selftune hook(s) from Codex.`);
242
+ console.log(`Config: ${result.hooksPath}`);
243
+ }
244
+
245
+ if (dryRun) {
246
+ console.log("\nNo changes written (--dry-run).");
247
+ }
248
+ } else {
249
+ const result = installHooks({ dryRun });
250
+
251
+ if (result.action === "no_change") {
252
+ console.log("selftune hooks already installed in Codex. No changes needed.");
253
+ console.log(`Config: ${result.hooksPath}`);
254
+ } else {
255
+ const prefix = dryRun ? "[dry-run] Would install" : "Installed";
256
+ console.log(`${prefix} ${result.hooksWritten} selftune hook(s) into Codex.`);
257
+ console.log(`Config: ${result.hooksPath}`);
258
+ console.log("Events: SessionStart, PreToolUse, PostToolUse, Stop");
259
+
260
+ if (result.hooksRemoved > 0) {
261
+ console.log(`Replaced ${result.hooksRemoved} previous selftune hook(s).`);
262
+ }
263
+ }
264
+
265
+ if (dryRun) {
266
+ console.log("\nNo changes written (--dry-run).");
267
+ } else if (result.action === "installed") {
268
+ console.log("\nNext step: run `selftune doctor` to verify hook health.");
269
+ }
270
+ }
271
+ } catch (err) {
272
+ const message = err instanceof Error ? err.message : String(err);
273
+ console.error(`Error: ${message}`);
274
+ console.error("Next step: check that ~/.codex/ is writable and try again.");
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ // --- stdin main (only when executed directly, not when imported) ---
280
+ if (import.meta.main) {
281
+ try {
282
+ await cliMain();
283
+ } catch (err) {
284
+ console.error(
285
+ `[selftune] Codex install failed: ${err instanceof Error ? err.message : String(err)}`,
286
+ );
287
+ process.exit(1);
288
+ }
289
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * OpenCode hook adapter for selftune.
4
+ *
5
+ * Translates OpenCode hook events to selftune's shared hook logic.
6
+ * OpenCode pipes JSON on stdin; this adapter normalizes field names,
7
+ * dispatches to the appropriate selftune handler, and writes an
8
+ * OpenCode-format JSON response to stdout.
9
+ *
10
+ * Event mapping:
11
+ * tool.execute.before -> PreToolUse handlers (skill-change-guard, evolution-guard)
12
+ * tool.execute.after -> PostToolUse handlers (skill-eval, commit-track)
13
+ * session.idle -> session-stop handler
14
+ *
15
+ * Fail-open: never crashes, always outputs valid JSON, exits 0 on errors.
16
+ *
17
+ * Usage: echo '$HOOK_PAYLOAD' | selftune opencode hook
18
+ */
19
+
20
+ import type { PostToolUsePayload, PreToolUsePayload, StopPayload } from "../../types.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // OpenCode input / output types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ interface OpenCodeHookInput {
27
+ event: "tool.execute.before" | "tool.execute.after" | "session.idle";
28
+ session_id: string;
29
+ tool?: {
30
+ name?: string;
31
+ args?: Record<string, unknown>;
32
+ result?: Record<string, unknown>;
33
+ };
34
+ cwd?: string;
35
+ }
36
+
37
+ interface OpenCodeHookResponse {
38
+ modified: boolean;
39
+ args?: Record<string, unknown>;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Response helper
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function outputResponse(response: OpenCodeHookResponse): void {
47
+ process.stdout.write(JSON.stringify(response));
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Main entry
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export async function cliMain(): Promise<void> {
55
+ let eventName: string | undefined;
56
+
57
+ try {
58
+ const raw = await Bun.stdin.text();
59
+
60
+ if (!raw.trim()) {
61
+ outputResponse({ modified: false });
62
+ return;
63
+ }
64
+
65
+ // Fast-path: for tool.execute.before, skip full parse if not interesting
66
+ const preview = raw.slice(0, 8192);
67
+ const isBefore = preview.includes("tool.execute.before");
68
+ if (isBefore) {
69
+ // Only parse fully if it might be a git commit or SKILL.md write
70
+ const mightBeInteresting =
71
+ (preview.includes("git") && preview.includes("commit")) ||
72
+ preview.includes("SKILL.md") ||
73
+ preview.includes("skill.md");
74
+ if (!mightBeInteresting) {
75
+ outputResponse({ modified: false });
76
+ return;
77
+ }
78
+ }
79
+
80
+ let input: OpenCodeHookInput;
81
+ try {
82
+ input = JSON.parse(raw) as OpenCodeHookInput;
83
+ } catch {
84
+ outputResponse({ modified: false });
85
+ return;
86
+ }
87
+
88
+ eventName = input.event;
89
+ if (!eventName) {
90
+ outputResponse({ modified: false });
91
+ return;
92
+ }
93
+
94
+ switch (eventName) {
95
+ case "tool.execute.before":
96
+ await handleToolBefore(input);
97
+ outputResponse({ modified: false });
98
+ break;
99
+ case "tool.execute.after":
100
+ await handleToolAfter(input);
101
+ outputResponse({ modified: false });
102
+ break;
103
+ case "session.idle":
104
+ await handleSessionIdle(input);
105
+ outputResponse({ modified: false });
106
+ break;
107
+ default:
108
+ outputResponse({ modified: false });
109
+ }
110
+ } catch {
111
+ // Fail-open: never crash, always return valid JSON
112
+ outputResponse({ modified: false });
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // tool.execute.before -> PreToolUse handlers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async function handleToolBefore(input: OpenCodeHookInput): Promise<void> {
121
+ const toolName = input.tool?.name ?? "";
122
+ const toolInput = input.tool?.args ?? {};
123
+
124
+ const payload: PreToolUsePayload = {
125
+ session_id: input.session_id,
126
+ cwd: input.cwd,
127
+ tool_name: toolName,
128
+ tool_input: toolInput,
129
+ };
130
+
131
+ // Run skill-change-guard (advisory suggestion for SKILL.md writes)
132
+ try {
133
+ const { processPreToolUse } = await import("../../hooks/skill-change-guard.js");
134
+ const { SESSION_STATE_DIR } = await import("../../constants.js");
135
+ const safe = (input.session_id ?? "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
136
+ const statePath = `${SESSION_STATE_DIR}/guard-state-${safe}.json`;
137
+ const suggestion = processPreToolUse(payload, statePath);
138
+ if (suggestion) {
139
+ process.stderr.write(`[selftune] ${suggestion}\n`);
140
+ }
141
+ } catch {
142
+ /* fail-open */
143
+ }
144
+
145
+ // Run evolution-guard (may block SKILL.md writes on monitored skills)
146
+ try {
147
+ const { processEvolutionGuard } = await import("../../hooks/evolution-guard.js");
148
+ const { EVOLUTION_AUDIT_LOG, SELFTUNE_CONFIG_DIR } = await import("../../constants.js");
149
+ const result = await processEvolutionGuard(payload, {
150
+ auditLogPath: EVOLUTION_AUDIT_LOG,
151
+ selftuneDir: SELFTUNE_CONFIG_DIR,
152
+ });
153
+ if (result) {
154
+ // OpenCode does not support exit-code blocking like Claude Code.
155
+ // Emit the warning to stderr for agent visibility.
156
+ process.stderr.write(`${result.message}\n`);
157
+ }
158
+ } catch {
159
+ /* fail-open */
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // tool.execute.after -> PostToolUse handlers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ async function handleToolAfter(input: OpenCodeHookInput): Promise<void> {
168
+ const toolName = input.tool?.name ?? "";
169
+ const toolInput = input.tool?.args ?? {};
170
+ const toolResult = input.tool?.result ?? {};
171
+
172
+ const payload: PostToolUsePayload = {
173
+ session_id: input.session_id,
174
+ cwd: input.cwd,
175
+ tool_name: toolName,
176
+ tool_input: toolInput,
177
+ tool_response: toolResult,
178
+ };
179
+
180
+ // Run skill-eval (skill usage tracking)
181
+ try {
182
+ const { processToolUse } = await import("../../hooks/skill-eval.js");
183
+ await processToolUse(payload);
184
+ } catch {
185
+ /* fail-open */
186
+ }
187
+
188
+ // Run commit-track (git commit traceability)
189
+ try {
190
+ const { processCommitTrack } = await import("../../hooks/commit-track.js");
191
+ await processCommitTrack(payload);
192
+ } catch {
193
+ /* fail-open */
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // session.idle -> session-stop handler
199
+ // ---------------------------------------------------------------------------
200
+
201
+ async function handleSessionIdle(input: OpenCodeHookInput): Promise<void> {
202
+ const payload: StopPayload = {
203
+ session_id: input.session_id,
204
+ cwd: input.cwd,
205
+ };
206
+
207
+ try {
208
+ const { processSessionStop } = await import("../../hooks/session-stop.js");
209
+ await processSessionStop(payload);
210
+ } catch {
211
+ /* fail-open */
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // stdin main (only when executed directly, not when imported)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ if (import.meta.main) {
220
+ await cliMain();
221
+ process.exit(0);
222
+ }