selftune 0.2.21 → 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,149 @@
1
+ /**
2
+ * Shared git metadata extraction for hooks.
3
+ *
4
+ * Extracted from duplicated logic in session-stop.ts (branch/remote extraction)
5
+ * and commit-track.ts (commit detection, remote scrubbing, branch fallback).
6
+ *
7
+ * All functions are fail-open: git errors return undefined/null, never throw.
8
+ */
9
+
10
+ import { execSync } from "node:child_process";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Git metadata extracted from a working directory. */
17
+ export interface GitMetadata {
18
+ branch?: string;
19
+ repoRemote?: string;
20
+ }
21
+
22
+ /** Parsed commit information from git output. */
23
+ export interface ParsedCommit {
24
+ sha?: string;
25
+ title?: string;
26
+ branch?: string;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Pre-compiled regex patterns
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Matches git commands that produce commits: commit, merge, cherry-pick, revert. */
34
+ const GIT_COMMIT_CMD_RE = /\bgit\s+(commit|merge|cherry-pick|revert)\b/;
35
+
36
+ /**
37
+ * Matches standard git commit output: [branch SHA] title
38
+ * Supports optional parenthetical like (root-commit).
39
+ * Branch names can contain word chars, slashes, dots, hyphens, plus signs.
40
+ */
41
+ const COMMIT_OUTPUT_RE = /\[([\w/.+-]+)(?:\s+\([^)]+\))?\s+([a-f0-9]{7,40})\]\s+(.+)/;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Git metadata extraction
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Extract git branch and remote URL from a working directory.
49
+ *
50
+ * Uses short-timeout execSync calls. Returns partial results if one
51
+ * command fails (e.g., branch succeeds but remote is not configured).
52
+ * Returns empty object if cwd is not a git repo.
53
+ *
54
+ * @param cwd Working directory to inspect
55
+ */
56
+ export function extractGitMetadata(cwd: string): GitMetadata {
57
+ if (!cwd) return {};
58
+
59
+ const result: GitMetadata = {};
60
+
61
+ try {
62
+ result.branch =
63
+ execSync("git rev-parse --abbrev-ref HEAD", {
64
+ cwd,
65
+ timeout: 3000,
66
+ stdio: ["ignore", "pipe", "ignore"],
67
+ })
68
+ .toString()
69
+ .trim() || undefined;
70
+ } catch {
71
+ /* not a git repo or git not available */
72
+ }
73
+
74
+ try {
75
+ const rawRemote =
76
+ execSync("git remote get-url origin", {
77
+ cwd,
78
+ timeout: 3000,
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ })
81
+ .toString()
82
+ .trim() || undefined;
83
+ if (rawRemote) {
84
+ result.repoRemote = scrubRemoteUrl(rawRemote);
85
+ }
86
+ } catch {
87
+ /* no remote configured */
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // URL scrubbing
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Scrub credentials from a git remote URL.
99
+ *
100
+ * HTTP(S) URLs have username/password stripped. SSH URLs and other formats
101
+ * are returned as-is (they don't embed credentials in the URL structure).
102
+ *
103
+ * @param rawUrl Raw remote URL from `git remote get-url`
104
+ * @returns Scrubbed URL, or undefined for empty input
105
+ */
106
+ export function scrubRemoteUrl(rawUrl: string): string | undefined {
107
+ if (!rawUrl) return undefined;
108
+ try {
109
+ const parsed = new URL(rawUrl);
110
+ parsed.username = "";
111
+ parsed.password = "";
112
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
113
+ } catch {
114
+ // SSH or non-URL format -- safe as-is
115
+ return rawUrl;
116
+ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Commit detection
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Check if a command string contains a git commit-producing operation.
125
+ * Detects: git commit, git merge, git cherry-pick, git revert.
126
+ */
127
+ export function containsGitCommitCommand(command: string): boolean {
128
+ return GIT_COMMIT_CMD_RE.test(command);
129
+ }
130
+
131
+ /**
132
+ * Parse commit metadata from git's standard output format.
133
+ *
134
+ * Expects output like: `[main abc1234] Fix the bug`
135
+ * or with root-commit: `[main (root-commit) abc1234] Initial commit`
136
+ *
137
+ * @param stdout The stdout from a git commit/merge/cherry-pick/revert command
138
+ * @returns Parsed commit info, or null if output doesn't match
139
+ */
140
+ export function parseCommitFromOutput(stdout: string): ParsedCommit | null {
141
+ const match = stdout.match(COMMIT_OUTPUT_RE);
142
+ if (!match) return null;
143
+
144
+ return {
145
+ branch: match[1],
146
+ sha: match[2],
147
+ title: match[3].trim(),
148
+ };
149
+ }
@@ -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
+ }