oxtail 0.4.0 → 0.6.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.
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Install the oxtail PreToolUse hook into ~/.claude/settings.json.
3
+ //
4
+ // Idempotent: re-running on an installed system reports "already installed"
5
+ // and exits 0 without writing. Format-preserving: edits use jsonc-parser so
6
+ // unrelated keys, whitespace, and comments survive.
7
+ //
8
+ // Reverse with: npx oxtail uninstall-hook
9
+
10
+ import { readFile, writeFile, mkdir, rename, chmod } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
13
+ import path from "node:path";
14
+ import { applyEdits, modify, parse } from "jsonc-parser";
15
+ import {
16
+ SETTINGS_PATH,
17
+ HOOK_MARKER_KEY,
18
+ HOOK_MARKER_VERSION,
19
+ HOOK_SCRIPT_PATH,
20
+ HOOK_COMMAND,
21
+ scriptHash,
22
+ } from "./hook-constants.mjs";
23
+
24
+ const SHIPPED_HOOK_PATH = new URL("../assets/pretooluse.sh", import.meta.url).pathname;
25
+ const FORMATTING = { tabSize: 2, insertSpaces: true };
26
+
27
+ function findOxtailHookIndex(parsed) {
28
+ const arr = parsed?.hooks?.PreToolUse;
29
+ if (!Array.isArray(arr)) return -1;
30
+ return arr.findIndex((entry) => {
31
+ if (!entry || typeof entry !== "object") return false;
32
+ if (!Array.isArray(entry.hooks)) return false;
33
+ return entry.hooks.some(
34
+ (h) =>
35
+ h &&
36
+ typeof h === "object" &&
37
+ typeof h.command === "string" &&
38
+ // Loose match: any command referencing our installed script path.
39
+ h.command.includes("oxtail/hooks/pretooluse.sh"),
40
+ );
41
+ });
42
+ }
43
+
44
+ export async function install() {
45
+ const shipped = await readFile(SHIPPED_HOOK_PATH, "utf8");
46
+ const wantHash = scriptHash(shipped);
47
+
48
+ let source = "{}\n";
49
+ if (existsSync(SETTINGS_PATH)) source = await readFile(SETTINGS_PATH, "utf8");
50
+ const parsed = parse(source) ?? {};
51
+
52
+ const marker = parsed[HOOK_MARKER_KEY];
53
+ const existingIdx = findOxtailHookIndex(parsed);
54
+ const upToDate =
55
+ marker &&
56
+ typeof marker === "object" &&
57
+ marker.version === HOOK_MARKER_VERSION &&
58
+ marker.scriptHash === wantHash &&
59
+ existingIdx >= 0 &&
60
+ existsSync(HOOK_SCRIPT_PATH);
61
+ if (upToDate) {
62
+ console.log(
63
+ `oxtail hook already installed (v${HOOK_MARKER_VERSION}, hash ${wantHash.slice(0, 8)}). No changes.`,
64
+ );
65
+ return;
66
+ }
67
+
68
+ // Detect competing PreToolUse hooks (e.g. Terminator's _terminatorHook).
69
+ // Behavior under multi-hook coexistence is determined live in Step 5
70
+ // case 11 — for now, warn so users know install order may matter.
71
+ const otherHooks = (parsed?.hooks?.PreToolUse ?? []).filter((entry, idx) => {
72
+ if (idx === existingIdx) return false;
73
+ if (!entry || !Array.isArray(entry.hooks)) return false;
74
+ return entry.hooks.some(
75
+ (h) => h && typeof h.command === "string" && !h.command.includes("oxtail/hooks/pretooluse.sh"),
76
+ );
77
+ });
78
+ if (otherHooks.length > 0) {
79
+ console.warn(
80
+ `[oxtail] note: ${otherHooks.length} other PreToolUse hook(s) already installed. ` +
81
+ `Multi-hook coexistence is supported but install order may matter; ` +
82
+ `see README "Hook coexistence" for details.`,
83
+ );
84
+ }
85
+
86
+ // Back up settings.json before mutating it (Skeptic M4).
87
+ if (existsSync(SETTINGS_PATH)) {
88
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
89
+ const backup = `${SETTINGS_PATH}.oxtail-backup.${stamp}`;
90
+ await writeFile(backup, source, "utf8");
91
+ console.log(`Backed up existing settings to ${backup}`);
92
+ }
93
+
94
+ // Install the shipped script atomically.
95
+ const hooksDir = path.dirname(HOOK_SCRIPT_PATH);
96
+ await mkdir(hooksDir, { recursive: true, mode: 0o755 });
97
+ const scriptTmp = `${HOOK_SCRIPT_PATH}.tmp-${randomBytes(6).toString("hex")}`;
98
+ await writeFile(scriptTmp, shipped, { mode: 0o755 });
99
+ // writeFile's `mode` option only applies on file creation; an existing
100
+ // tmp file would keep its previous perms. Explicit chmod for belt+braces.
101
+ await chmod(scriptTmp, 0o755);
102
+ await rename(scriptTmp, HOOK_SCRIPT_PATH);
103
+
104
+ // Edit settings.json. Replace any prior oxtail entry; else append.
105
+ let text = source;
106
+ const newEntry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
107
+ const arr = parsed?.hooks?.PreToolUse;
108
+ if (existingIdx >= 0) {
109
+ text = applyEdits(
110
+ text,
111
+ modify(text, ["hooks", "PreToolUse", existingIdx], newEntry, { formattingOptions: FORMATTING }),
112
+ );
113
+ } else {
114
+ const insertIdx = Array.isArray(arr) ? arr.length : 0;
115
+ text = applyEdits(
116
+ text,
117
+ modify(text, ["hooks", "PreToolUse", insertIdx], newEntry, { formattingOptions: FORMATTING }),
118
+ );
119
+ }
120
+ text = applyEdits(
121
+ text,
122
+ modify(
123
+ text,
124
+ [HOOK_MARKER_KEY],
125
+ {
126
+ version: HOOK_MARKER_VERSION,
127
+ installedAt: new Date().toISOString(),
128
+ scriptHash: wantHash,
129
+ },
130
+ { formattingOptions: FORMATTING },
131
+ ),
132
+ );
133
+
134
+ // Atomic write of settings.json.
135
+ const settingsTmp = `${SETTINGS_PATH}.oxtail-tmp-${randomBytes(6).toString("hex")}`;
136
+ await mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
137
+ await writeFile(settingsTmp, text, "utf8");
138
+ await rename(settingsTmp, SETTINGS_PATH);
139
+
140
+ console.log(`Installed oxtail PreToolUse hook in ${SETTINGS_PATH}.`);
141
+ console.log("Reverse with: npx oxtail uninstall-hook");
142
+ }
143
+
144
+ const invokedDirectly =
145
+ typeof process.argv[1] === "string" &&
146
+ import.meta.url === new URL(process.argv[1], "file:").href;
147
+ if (invokedDirectly) {
148
+ install().catch((err) => {
149
+ console.error("install-hook failed:", err?.message ?? err);
150
+ process.exit(1);
151
+ });
152
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Remove the oxtail PreToolUse hook entry and marker from
3
+ // ~/.claude/settings.json, and delete the installed ~/.oxtail/hooks/pretooluse.sh.
4
+ //
5
+ // Idempotent: a clean run on an uninstalled system exits 0 with "nothing to do."
6
+
7
+ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { randomBytes } from "node:crypto";
10
+ import path from "node:path";
11
+ import { applyEdits, modify, parse } from "jsonc-parser";
12
+ import {
13
+ SETTINGS_PATH,
14
+ HOOK_MARKER_KEY,
15
+ HOOK_SCRIPT_PATH,
16
+ } from "./hook-constants.mjs";
17
+
18
+ const FORMATTING = { tabSize: 2, insertSpaces: true };
19
+
20
+ function findOxtailHookIndex(parsed) {
21
+ const arr = parsed?.hooks?.PreToolUse;
22
+ if (!Array.isArray(arr)) return -1;
23
+ return arr.findIndex((entry) => {
24
+ if (!entry || typeof entry !== "object") return false;
25
+ if (!Array.isArray(entry.hooks)) return false;
26
+ return entry.hooks.some(
27
+ (h) =>
28
+ h &&
29
+ typeof h === "object" &&
30
+ typeof h.command === "string" &&
31
+ h.command.includes("oxtail/hooks/pretooluse.sh"),
32
+ );
33
+ });
34
+ }
35
+
36
+ export async function uninstall() {
37
+ if (!existsSync(SETTINGS_PATH)) {
38
+ console.log(`No ${SETTINGS_PATH} — nothing to do.`);
39
+ // Still try to remove the installed script in case it's a leftover.
40
+ if (existsSync(HOOK_SCRIPT_PATH)) {
41
+ try {
42
+ await unlink(HOOK_SCRIPT_PATH);
43
+ console.log(`Removed ${HOOK_SCRIPT_PATH}.`);
44
+ } catch (err) {
45
+ console.warn(`Could not remove ${HOOK_SCRIPT_PATH}: ${err?.message ?? err}`);
46
+ }
47
+ }
48
+ return;
49
+ }
50
+
51
+ const source = await readFile(SETTINGS_PATH, "utf8");
52
+ const parsed = parse(source) ?? {};
53
+
54
+ const idx = findOxtailHookIndex(parsed);
55
+ const hasMarker =
56
+ parsed[HOOK_MARKER_KEY] && typeof parsed[HOOK_MARKER_KEY] === "object";
57
+
58
+ if (idx < 0 && !hasMarker && !existsSync(HOOK_SCRIPT_PATH)) {
59
+ console.log("oxtail hook not installed — nothing to do.");
60
+ return;
61
+ }
62
+
63
+ let text = source;
64
+ if (idx >= 0) {
65
+ text = applyEdits(
66
+ text,
67
+ modify(text, ["hooks", "PreToolUse", idx], undefined, { formattingOptions: FORMATTING }),
68
+ );
69
+ }
70
+ if (hasMarker) {
71
+ text = applyEdits(
72
+ text,
73
+ modify(text, [HOOK_MARKER_KEY], undefined, { formattingOptions: FORMATTING }),
74
+ );
75
+ }
76
+
77
+ const settingsTmp = `${SETTINGS_PATH}.oxtail-tmp-${randomBytes(6).toString("hex")}`;
78
+ await mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
79
+ await writeFile(settingsTmp, text, "utf8");
80
+ await rename(settingsTmp, SETTINGS_PATH);
81
+ console.log(`Removed oxtail PreToolUse hook from ${SETTINGS_PATH}.`);
82
+
83
+ if (existsSync(HOOK_SCRIPT_PATH)) {
84
+ try {
85
+ await unlink(HOOK_SCRIPT_PATH);
86
+ console.log(`Removed ${HOOK_SCRIPT_PATH}.`);
87
+ } catch (err) {
88
+ console.warn(`Could not remove ${HOOK_SCRIPT_PATH}: ${err?.message ?? err}`);
89
+ }
90
+ }
91
+ }
92
+
93
+ const invokedDirectly =
94
+ typeof process.argv[1] === "string" &&
95
+ import.meta.url === new URL(process.argv[1], "file:").href;
96
+ if (invokedDirectly) {
97
+ uninstall().catch((err) => {
98
+ console.error("uninstall-hook failed:", err?.message ?? err);
99
+ process.exit(1);
100
+ });
101
+ }