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,105 @@
1
+ /**
2
+ * Shared output helpers for platform-agnostic hook responses.
3
+ *
4
+ * Hooks communicate with their host agent through stdout (context injection),
5
+ * stderr (advisory messages), and exit codes (allow/block decisions). The
6
+ * exact format varies by platform — this module abstracts those differences.
7
+ *
8
+ * Claude Code conventions (the primary platform):
9
+ * - stdout JSON with hookSpecificOutput.additionalContext for context injection
10
+ * - stderr for advisory suggestions
11
+ * - exit 0 = allow, exit 2 = block with message
12
+ */
13
+
14
+ import type { HookPlatform, HookResponse } from "./types.js";
15
+
16
+ /**
17
+ * Format a HookResponse for a specific platform's expected output format.
18
+ *
19
+ * For Claude Code: produces JSON with hookSpecificOutput wrapper.
20
+ * For other platforms: produces a simplified JSON response.
21
+ *
22
+ * @param response The platform-agnostic hook response
23
+ * @param platform The target platform
24
+ * @returns Formatted string ready to write to stdout
25
+ */
26
+ export function formatResponseForPlatform(response: HookResponse, platform: HookPlatform): string {
27
+ if (platform === "claude-code" || platform === "codex") {
28
+ // Claude Code / Codex use hookSpecificOutput wrapper
29
+ const output: Record<string, unknown> = {};
30
+
31
+ if (response.context) {
32
+ output.hookSpecificOutput = {
33
+ additionalContext: response.context,
34
+ };
35
+ }
36
+
37
+ if (response.updated_input) {
38
+ output.updatedInput = response.updated_input;
39
+ }
40
+
41
+ if (response.decision) {
42
+ output.decision = response.decision;
43
+ }
44
+
45
+ return JSON.stringify(output);
46
+ }
47
+
48
+ // Generic JSON format for other platforms
49
+ return JSON.stringify({
50
+ modified: response.modified,
51
+ decision: response.decision,
52
+ message: response.message,
53
+ context: response.context,
54
+ updated_input: response.updated_input,
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Write an advisory suggestion to stderr.
60
+ *
61
+ * Stderr messages appear as system messages to the host agent in Claude Code.
62
+ * Other platforms may handle stderr differently, but writing to stderr is
63
+ * universally safe (it never affects the hook's exit code or stdout response).
64
+ *
65
+ * @param message The suggestion text to display
66
+ */
67
+ export function writeSuggestion(message: string): void {
68
+ process.stderr.write(`${message}\n`);
69
+ }
70
+
71
+ /**
72
+ * Write context injection to stdout.
73
+ *
74
+ * For Claude Code, this injects content into Claude's context via the
75
+ * hookSpecificOutput.additionalContext mechanism. The output is JSON-formatted
76
+ * to match Claude Code's expected hook output schema.
77
+ *
78
+ * @param context The context string to inject
79
+ */
80
+ export function writeContext(context: string): void {
81
+ process.stdout.write(
82
+ JSON.stringify({
83
+ hookSpecificOutput: {
84
+ additionalContext: context,
85
+ },
86
+ }),
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Exit with a platform-appropriate exit code for allow/block decisions.
92
+ *
93
+ * Claude Code convention:
94
+ * - exit 0 = allow the tool call
95
+ * - exit 2 = block the tool call (with stderr message)
96
+ *
97
+ * Other platforms use the same convention unless they specify otherwise.
98
+ *
99
+ * @param decision "allow" or "block"
100
+ * @param _platform The target platform (reserved for future per-platform codes)
101
+ */
102
+ export function exitWithDecision(decision: "allow" | "block", _platform: HookPlatform): never {
103
+ const code = decision === "block" ? 2 : 0;
104
+ process.exit(code);
105
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Normalizers that convert platform-specific hook payloads to UnifiedHookEvent.
3
+ *
4
+ * Each platform adapter maps its native payload shape and event names
5
+ * to the shared UnifiedHookEvent interface. The normalizers are intentionally
6
+ * lenient — unknown fields are ignored, missing fields become undefined.
7
+ *
8
+ * Fail-open: any parsing error returns a minimal event with what we have.
9
+ */
10
+
11
+ import type { HookEventType, HookPlatform, UnifiedHookEvent } from "./types.js";
12
+ import { PLATFORM_EVENT_MAP } from "./types.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Reverse lookup: native event name -> HookEventType
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Build a reverse map from native event string to HookEventType for a platform. */
19
+ function buildReverseLookup(platform: HookPlatform): Map<string, HookEventType> {
20
+ const forward = PLATFORM_EVENT_MAP[platform];
21
+ const reverse = new Map<string, HookEventType>();
22
+ for (const [hookType, nativeName] of Object.entries(forward)) {
23
+ if (nativeName) {
24
+ reverse.set(nativeName, hookType as HookEventType);
25
+ }
26
+ }
27
+ return reverse;
28
+ }
29
+
30
+ const reverseLookups = new Map<HookPlatform, Map<string, HookEventType>>();
31
+
32
+ /** Resolve a native event type string to the normalized HookEventType. */
33
+ function resolveEventType(
34
+ platform: HookPlatform,
35
+ nativeEventType: string,
36
+ ): HookEventType | undefined {
37
+ let lookup = reverseLookups.get(platform);
38
+ if (!lookup) {
39
+ lookup = buildReverseLookup(platform);
40
+ reverseLookups.set(platform, lookup);
41
+ }
42
+ return lookup.get(nativeEventType);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Shared field extraction helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function str(v: unknown): string | undefined {
50
+ return typeof v === "string" ? v : undefined;
51
+ }
52
+
53
+ function obj(v: unknown): Record<string, unknown> | undefined {
54
+ return typeof v === "object" && v !== null ? (v as Record<string, unknown>) : undefined;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Per-platform field extraction config
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Describes how to extract prompt and last_message fields from a platform's
63
+ * payload. Most platforms use the same field names; Claude Code has a
64
+ * user_prompt fallback, and some use last_assistant_message vs last_message.
65
+ */
66
+ interface PlatformFieldConfig {
67
+ /** Default event_type when resolution fails */
68
+ fallbackEvent: HookEventType;
69
+ /** Fields to try for prompt (in order, first non-undefined wins) */
70
+ promptFields: string[];
71
+ /** Field name for session-end last message */
72
+ lastMessageField: string;
73
+ /** Field name for post_tool_use output (e.g., "tool_response" or "tool_output") */
74
+ toolOutputField: string;
75
+ }
76
+
77
+ const FIELD_CONFIG: Record<HookPlatform, PlatformFieldConfig> = {
78
+ "claude-code": {
79
+ fallbackEvent: "prompt_submit",
80
+ promptFields: ["prompt", "user_prompt"],
81
+ lastMessageField: "last_assistant_message",
82
+ toolOutputField: "tool_response",
83
+ },
84
+ codex: {
85
+ fallbackEvent: "prompt_submit",
86
+ promptFields: ["prompt"],
87
+ lastMessageField: "last_assistant_message",
88
+ toolOutputField: "tool_response",
89
+ },
90
+ opencode: {
91
+ fallbackEvent: "session_end",
92
+ promptFields: ["prompt"],
93
+ lastMessageField: "last_message",
94
+ toolOutputField: "tool_output",
95
+ },
96
+ cline: {
97
+ fallbackEvent: "session_end",
98
+ promptFields: ["prompt"],
99
+ lastMessageField: "last_message",
100
+ toolOutputField: "tool_output",
101
+ },
102
+ pi: {
103
+ fallbackEvent: "session_end",
104
+ promptFields: ["prompt"],
105
+ lastMessageField: "last_message",
106
+ toolOutputField: "tool_output",
107
+ },
108
+ };
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Unified normalizer
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Normalize a platform-specific hook payload to UnifiedHookEvent.
116
+ *
117
+ * This single function replaces per-platform normalizers by using
118
+ * FIELD_CONFIG to handle platform-specific field names.
119
+ */
120
+ function normalizeForPlatform(
121
+ platform: HookPlatform,
122
+ payload: unknown,
123
+ eventType: string,
124
+ ): UnifiedHookEvent {
125
+ const raw = obj(payload) ?? {};
126
+ const config = FIELD_CONFIG[platform];
127
+ const resolved = resolveEventType(platform, eventType);
128
+
129
+ const base: UnifiedHookEvent = {
130
+ platform,
131
+ event_type: resolved ?? config.fallbackEvent,
132
+ session_id: str(raw.session_id) ?? "unknown",
133
+ cwd: str(raw.cwd),
134
+ transcript_path: str(raw.transcript_path),
135
+ raw_payload: payload,
136
+ };
137
+
138
+ if (resolved === "pre_tool_use" || resolved === "post_tool_use") {
139
+ base.tool_name = str(raw.tool_name);
140
+ base.tool_input = obj(raw.tool_input);
141
+ if (resolved === "post_tool_use") {
142
+ base.tool_output = obj(raw[config.toolOutputField]);
143
+ }
144
+ } else if (resolved === "prompt_submit") {
145
+ for (const field of config.promptFields) {
146
+ const v = str(raw[field]);
147
+ if (v) {
148
+ base.prompt = v;
149
+ break;
150
+ }
151
+ }
152
+ } else if (resolved === "session_end") {
153
+ base.last_message = str(raw[config.lastMessageField]);
154
+ }
155
+
156
+ return base;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Public API — named exports for backward compatibility + unified entry point
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export function normalizeClaudeCode(payload: unknown, eventType: string): UnifiedHookEvent {
164
+ return normalizeForPlatform("claude-code", payload, eventType);
165
+ }
166
+
167
+ export function normalizeCodex(payload: unknown, eventType: string): UnifiedHookEvent {
168
+ return normalizeForPlatform("codex", payload, eventType);
169
+ }
170
+
171
+ export function normalizeOpenCode(payload: unknown, eventType: string): UnifiedHookEvent {
172
+ return normalizeForPlatform("opencode", payload, eventType);
173
+ }
174
+
175
+ export function normalizeCline(payload: unknown, eventType: string): UnifiedHookEvent {
176
+ return normalizeForPlatform("cline", payload, eventType);
177
+ }
178
+
179
+ export function normalizePi(payload: unknown, eventType: string): UnifiedHookEvent {
180
+ return normalizeForPlatform("pi", payload, eventType);
181
+ }
182
+
183
+ /**
184
+ * Auto-detect platform and normalize a hook payload to UnifiedHookEvent.
185
+ *
186
+ * @param payload Raw payload (typically parsed from stdin JSON)
187
+ * @param platform The host platform
188
+ * @param nativeEventType The platform-native event type string
189
+ */
190
+ export function normalizeHookEvent(
191
+ payload: unknown,
192
+ platform: HookPlatform,
193
+ nativeEventType: string,
194
+ ): UnifiedHookEvent {
195
+ return normalizeForPlatform(platform, payload, nativeEventType);
196
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Generic session state persistence for hooks.
3
+ *
4
+ * Extracted from the duplicate patterns in auto-activate.ts (loadSessionState/saveSessionState)
5
+ * and skill-change-guard.ts (loadGuardState/saveGuardState). Both follow the same pattern:
6
+ *
7
+ * 1. Read a JSON file keyed by session_id
8
+ * 2. If session_id matches, return persisted state; otherwise return defaults
9
+ * 3. Write state back after updates
10
+ *
11
+ * This module generalizes that pattern with a type-safe generic interface.
12
+ * Fail-open: corrupt or missing files return fresh defaults.
13
+ */
14
+
15
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ import type { SessionState } from "./types.js";
19
+
20
+ /**
21
+ * Load session state from a JSON file.
22
+ *
23
+ * The file is located at `{dir}/{prefix}-{sessionId}.json`. If the file does not
24
+ * exist, is corrupt, or belongs to a different session, fresh defaults are returned.
25
+ *
26
+ * @param dir Directory to store state files (e.g., SELFTUNE_CONFIG_DIR)
27
+ * @param prefix Filename prefix (e.g., "session-state", "guard-state")
28
+ * @param sessionId Current session ID — state is invalidated when it changes
29
+ * @param defaults Factory function returning fresh default state data
30
+ */
31
+ export function loadSessionState<T extends Record<string, unknown>>(
32
+ dir: string,
33
+ prefix: string,
34
+ sessionId: string,
35
+ defaults: () => T,
36
+ ): SessionState<T> {
37
+ const safeName = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
38
+ const filePath = join(dir, `${prefix}-${safeName}.json`);
39
+
40
+ try {
41
+ const raw = JSON.parse(readFileSync(filePath, "utf-8")) as SessionState<T>;
42
+ if (raw.session_id === sessionId && typeof raw.data === "object" && raw.data !== null) {
43
+ return raw;
44
+ }
45
+ } catch {
46
+ // ENOENT (missing) or corrupt JSON -- return fresh defaults
47
+ }
48
+
49
+ return {
50
+ session_id: sessionId,
51
+ created_at: new Date().toISOString(),
52
+ data: defaults(),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Save session state to a JSON file.
58
+ *
59
+ * The file is written to `{dir}/{prefix}-{state.session_id}.json`.
60
+ * The directory is created if it does not exist.
61
+ *
62
+ * @param dir Directory to store state files
63
+ * @param prefix Filename prefix (must match what was used in loadSessionState)
64
+ * @param state The session state to persist
65
+ */
66
+ export function saveSessionState<T extends Record<string, unknown>>(
67
+ dir: string,
68
+ prefix: string,
69
+ state: SessionState<T>,
70
+ ): void {
71
+ const safeName = state.session_id.replace(/[^a-zA-Z0-9_-]/g, "_");
72
+ const filePath = join(dir, `${prefix}-${safeName}.json`);
73
+
74
+ mkdirSync(dirname(filePath), { recursive: true });
75
+ writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared skill name and path extraction utilities for hooks.
3
+ *
4
+ * Extracted from duplicated logic in:
5
+ * - skill-eval.ts: extractSkillName (checks SKILL.MD basename)
6
+ * - skill-change-guard.ts: isSkillMdWrite, extractSkillNameFromPath
7
+ * - evolution-guard.ts: isSkillMdWrite, extractSkillName (identical copies)
8
+ *
9
+ * All three files independently implement the same SKILL.md detection pattern.
10
+ * This module provides a single source of truth.
11
+ */
12
+
13
+ import { basename, dirname } from "node:path";
14
+
15
+ /**
16
+ * Extract the skill folder name from a file path that ends in SKILL.md.
17
+ *
18
+ * The convention is that skill definitions live at `<skill-name>/SKILL.md`,
19
+ * so the parent directory name is the skill name.
20
+ *
21
+ * @param filePath Absolute or relative path to check
22
+ * @returns Skill folder name, or null if the path does not end in SKILL.md
23
+ */
24
+ export function extractSkillName(filePath: string): string | null {
25
+ if (!isSkillMdFile(filePath)) return null;
26
+ return basename(dirname(filePath)) || "unknown";
27
+ }
28
+
29
+ /**
30
+ * Check if a file path points to a SKILL.md file (case-insensitive).
31
+ *
32
+ * @param filePath Path to check
33
+ */
34
+ export function isSkillMdFile(filePath: string): boolean {
35
+ return basename(filePath).toUpperCase() === "SKILL.MD";
36
+ }
37
+
38
+ /**
39
+ * Check if a tool call is a Write or Edit operation targeting a SKILL.md file.
40
+ *
41
+ * Used by guard hooks (skill-change-guard, evolution-guard) to detect
42
+ * when an agent is about to modify a skill definition.
43
+ *
44
+ * @param toolName The tool being called (e.g., "Write", "Edit", "Read")
45
+ * @param filePath The file_path from tool_input
46
+ */
47
+ export function isSkillMdWrite(toolName: string, filePath: string): boolean {
48
+ if (toolName !== "Write" && toolName !== "Edit") return false;
49
+ return isSkillMdFile(filePath);
50
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Generalized stdin preview and keyword filtering for hooks.
3
+ *
4
+ * Hooks receive payloads via stdin. Most hooks only care about specific
5
+ * event types (e.g., skill-eval only handles PostToolUse). The existing
6
+ * stdin-preview.ts provides a fast-path optimization: read stdin once,
7
+ * check a preview slice for keywords, and skip JSON.parse entirely when
8
+ * the keyword is absent.
9
+ *
10
+ * This module wraps that pattern into a single readAndFilter call that
11
+ * combines the preview check with JSON parsing, returning null when
12
+ * the payload is irrelevant (caller should exit 0 immediately).
13
+ *
14
+ * Re-exports readStdinWithPreview for backward compatibility.
15
+ */
16
+
17
+ export { readStdinWithPreview } from "../hooks/stdin-preview.js";
18
+
19
+ /** Default preview size in characters (covers envelope fields). */
20
+ const DEFAULT_PREVIEW_BYTES = 4096;
21
+
22
+ /**
23
+ * Read stdin with fast-path keyword filtering.
24
+ *
25
+ * Reads all of stdin, checks the leading preview slice for the presence of
26
+ * ALL required keywords. If any keyword is missing, returns null (the caller
27
+ * should exit early). Otherwise, parses the full payload as JSON and returns it.
28
+ *
29
+ * This is the recommended way to read hook payloads when you know which
30
+ * keywords must appear in the envelope (e.g., event name, tool name).
31
+ *
32
+ * @param requiredKeywords Strings that must ALL appear in the preview slice.
33
+ * Typically quoted JSON values like '"PostToolUse"'.
34
+ * @param previewBytes Number of leading characters to check (default 4096).
35
+ * @returns Parsed payload and raw string, or null if keywords don't match.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const result = await readAndFilter<PostToolUsePayload>(['"PostToolUse"', '"Read"']);
40
+ * if (!result) process.exit(0);
41
+ * const { payload } = result;
42
+ * ```
43
+ */
44
+ export async function readAndFilter<T = unknown>(
45
+ requiredKeywords: string[],
46
+ previewBytes: number = DEFAULT_PREVIEW_BYTES,
47
+ ): Promise<{ payload: T; raw: string } | null> {
48
+ const raw = await Bun.stdin.text();
49
+ const preview = raw.slice(0, previewBytes);
50
+
51
+ for (const keyword of requiredKeywords) {
52
+ if (!preview.includes(keyword)) {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ const payload = JSON.parse(raw) as T;
58
+ return { payload, raw };
59
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Universal hook types for multi-agent abstraction.
3
+ * All platform adapters normalize their native events to these types.
4
+ */
5
+
6
+ /** Supported agent platforms */
7
+ export type HookPlatform = "claude-code" | "codex" | "opencode" | "cline" | "pi";
8
+
9
+ /** Normalized event types across all platforms */
10
+ export type HookEventType = "pre_tool_use" | "post_tool_use" | "prompt_submit" | "session_end";
11
+
12
+ /**
13
+ * Platform-agnostic hook event. Each adapter normalizes its native payload to this shape.
14
+ * Fields are optional because not all platforms provide all data.
15
+ */
16
+ export interface UnifiedHookEvent {
17
+ platform: HookPlatform;
18
+ event_type: HookEventType;
19
+ session_id: string;
20
+ cwd?: string;
21
+ transcript_path?: string;
22
+
23
+ // Tool-related (pre_tool_use / post_tool_use)
24
+ tool_name?: string;
25
+ tool_input?: Record<string, unknown>;
26
+ tool_output?: Record<string, unknown>;
27
+
28
+ // Prompt-related (prompt_submit)
29
+ prompt?: string;
30
+
31
+ // Session-related (session_end)
32
+ last_message?: string;
33
+
34
+ /** Original platform-specific payload, preserved for platform-specific logic */
35
+ raw_payload?: unknown;
36
+ }
37
+
38
+ /**
39
+ * Hook response returned to the host agent.
40
+ * Adapters translate this back to platform-specific format.
41
+ */
42
+ export interface HookResponse {
43
+ /** Whether the hook modified the input */
44
+ modified: boolean;
45
+ /** Decision for PreToolUse guards */
46
+ decision?: "allow" | "block" | "skip";
47
+ /** Modified tool input (for pre_tool_use hooks that modify commands) */
48
+ updated_input?: Record<string, unknown>;
49
+ /** Advisory message (stderr suggestions) */
50
+ message?: string;
51
+ /** Additional context to inject (stdout JSON for Claude Code) */
52
+ context?: string;
53
+ }
54
+
55
+ /** Generic session state for dedup/tracking across hook invocations */
56
+ export interface SessionState<T extends Record<string, unknown> = Record<string, unknown>> {
57
+ session_id: string;
58
+ created_at: string;
59
+ data: T;
60
+ }
61
+
62
+ /** Platform event mapping reference */
63
+ export const PLATFORM_EVENT_MAP: Record<HookPlatform, Partial<Record<HookEventType, string>>> = {
64
+ "claude-code": {
65
+ pre_tool_use: "PreToolUse",
66
+ post_tool_use: "PostToolUse",
67
+ prompt_submit: "UserPromptSubmit",
68
+ session_end: "Stop",
69
+ },
70
+ codex: {
71
+ pre_tool_use: "PreToolUse",
72
+ post_tool_use: "PostToolUse",
73
+ prompt_submit: "SessionStart",
74
+ session_end: "Stop",
75
+ },
76
+ opencode: {
77
+ pre_tool_use: "tool.execute.before",
78
+ post_tool_use: "tool.execute.after",
79
+ session_end: "session.idle",
80
+ },
81
+ cline: {
82
+ post_tool_use: "PostToolUse",
83
+ session_end: "TaskComplete",
84
+ },
85
+ pi: {
86
+ pre_tool_use: "tool_call",
87
+ post_tool_use: "tool_result",
88
+ session_end: "session_shutdown",
89
+ },
90
+ };
@@ -30,6 +30,9 @@
30
30
  * selftune telemetry — Manage anonymous usage analytics (status, enable, disable)
31
31
  * selftune alpha <subcommand> — Alpha program management (upload)
32
32
  * selftune hook <name> — Run a hook by name (prompt-log, session-stop, etc.)
33
+ * selftune codex <subcommand> — Codex platform hooks (hook, install)
34
+ * selftune opencode <sub> — OpenCode platform hooks (hook, install)
35
+ * selftune cline <subcommand> — Cline platform hooks (hook, install)
33
36
  */
34
37
 
35
38
  import { CLIError, handleCLIError } from "./utils/cli-error.js";
@@ -73,20 +76,26 @@ Commands:
73
76
  alpha <subcommand> Alpha program management (upload)
74
77
  telemetry Manage anonymous usage analytics (status, enable, disable)
75
78
  hook <name> Run a hook by name (prompt-log, session-stop, etc.)
79
+ codex <sub> Codex platform hooks (hook, install)
80
+ opencode <sub> OpenCode platform hooks (hook, install)
81
+ cline <sub> Cline platform hooks (hook, install)
76
82
 
77
83
  Run 'selftune <command> --help' for command-specific options.`);
78
84
  process.exit(0);
79
85
  }
80
86
 
81
- // Track command usage (lazy importavoids loading crypto/os on --help or no-op paths)
82
- if (command && command !== "--help" && command !== "-h") {
87
+ // Fast-path commands (real-time hooks)skip analytics and auto-update to minimize latency
88
+ const FAST_COMMANDS: ReadonlySet<string> = new Set(["hook", "codex", "opencode", "cline"]);
89
+
90
+ // Track command usage (lazy import — skip for hooks and --help to avoid loading crypto/os)
91
+ if (command && !FAST_COMMANDS.has(command) && command !== "--help" && command !== "-h") {
83
92
  import("./analytics.js")
84
93
  .then(({ trackEvent }) => trackEvent("command_run", { command }))
85
94
  .catch(() => {});
86
95
  }
87
96
 
88
- // Auto-update check (skip for hooks — they must be fast — and --help)
89
- if (command && command !== "hook" && command !== "--help" && command !== "-h") {
97
+ // Auto-update check (skip for hooks and platform hook commands — they must be fast — and --help)
98
+ if (command && !FAST_COMMANDS.has(command) && command !== "--help" && command !== "-h") {
90
99
  const { autoUpdate } = await import("./auto-update.js");
91
100
  await autoUpdate();
92
101
  }
@@ -815,6 +824,49 @@ Output:
815
824
  process.exit(result.status ?? 1);
816
825
  break;
817
826
  }
827
+ // ── Platform hook adapters ─────────────────────────────────────────
828
+
829
+ case "codex":
830
+ case "opencode":
831
+ case "cline": {
832
+ const platform = command;
833
+ const displayName = { codex: "Codex", opencode: "OpenCode", cline: "Cline" }[platform];
834
+ const sub = process.argv[2];
835
+ if (!sub || sub === "--help" || sub === "-h") {
836
+ console.log(`selftune ${platform} — ${displayName} platform hooks
837
+
838
+ Usage:
839
+ selftune ${platform} <subcommand> [options]
840
+
841
+ Subcommands:
842
+ hook Handle a real-time hook event from ${displayName}
843
+ install Install or remove selftune hooks in ${displayName} config
844
+
845
+ Run 'selftune ${platform} <subcommand> --help' for subcommand-specific options.`);
846
+ process.exit(0);
847
+ }
848
+ process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
849
+ switch (sub) {
850
+ case "hook": {
851
+ const { cliMain } = await import(`./adapters/${platform}/hook.js`);
852
+ await cliMain();
853
+ break;
854
+ }
855
+ case "install": {
856
+ const { cliMain } = await import(`./adapters/${platform}/install.js`);
857
+ await cliMain();
858
+ break;
859
+ }
860
+ default:
861
+ throw new CLIError(
862
+ `Unknown ${platform} subcommand: ${sub}`,
863
+ "UNKNOWN_COMMAND",
864
+ `selftune ${platform} --help`,
865
+ );
866
+ }
867
+ break;
868
+ }
869
+
818
870
  default:
819
871
  throw new CLIError(`Unknown command: ${command}`, "UNKNOWN_COMMAND", "selftune --help");
820
872
  }