oxtail 0.8.0 → 0.9.1
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/README.md +43 -17
- package/assets/pretooluse.sh +68 -50
- package/assets/stop.sh +171 -0
- package/assets/userpromptsubmit.sh +55 -0
- package/dist/claims.js +228 -0
- package/dist/clients.js +4 -4
- package/dist/mailbox.js +1 -4
- package/dist/server.js +233 -53
- package/package.json +1 -1
- package/scripts/hook-constants.mjs +44 -6
- package/scripts/install-hook.mjs +69 -57
- package/scripts/uninstall-hook.mjs +40 -32
package/scripts/install-hook.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Install the oxtail PreToolUse
|
|
2
|
+
// Install the oxtail hooks (PreToolUse + Stop) into ~/.claude/settings.json.
|
|
3
3
|
//
|
|
4
|
-
// Idempotent: re-running on an
|
|
4
|
+
// Idempotent: re-running on an up-to-date system reports "already installed"
|
|
5
5
|
// and exits 0 without writing. Format-preserving: edits use jsonc-parser so
|
|
6
6
|
// unrelated keys, whitespace, and comments survive.
|
|
7
7
|
//
|
|
@@ -16,16 +16,17 @@ import {
|
|
|
16
16
|
SETTINGS_PATH,
|
|
17
17
|
HOOK_MARKER_KEY,
|
|
18
18
|
HOOK_MARKER_VERSION,
|
|
19
|
-
|
|
20
|
-
HOOK_COMMAND,
|
|
19
|
+
MANAGED_HOOKS,
|
|
21
20
|
scriptHash,
|
|
22
21
|
} from "./hook-constants.mjs";
|
|
23
22
|
|
|
24
|
-
const SHIPPED_HOOK_PATH = new URL("../assets/pretooluse.sh", import.meta.url).pathname;
|
|
25
23
|
const FORMATTING = { tabSize: 2, insertSpaces: true };
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Find an oxtail-managed hook entry by its installed script filename
|
|
26
|
+
// (e.g. "oxtail/hooks/pretooluse.sh"). Loose substring match tolerates the
|
|
27
|
+
// "$HOME/..."-quoted command form. -F (fixed-string) is unsafe.
|
|
28
|
+
function findOxtailHookIndex(parsed, event, asset) {
|
|
29
|
+
const arr = parsed?.hooks?.[event];
|
|
29
30
|
if (!Array.isArray(arr)) return -1;
|
|
30
31
|
return arr.findIndex((entry) => {
|
|
31
32
|
if (!entry || typeof entry !== "object") return false;
|
|
@@ -35,55 +36,64 @@ function findOxtailHookIndex(parsed) {
|
|
|
35
36
|
h &&
|
|
36
37
|
typeof h === "object" &&
|
|
37
38
|
typeof h.command === "string" &&
|
|
38
|
-
|
|
39
|
-
h.command.includes("oxtail/hooks/pretooluse.sh"),
|
|
39
|
+
h.command.includes(`oxtail/hooks/${asset}`),
|
|
40
40
|
);
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export async function install() {
|
|
45
|
-
|
|
46
|
-
const
|
|
45
|
+
// Read shipped scripts + compute hashes up front.
|
|
46
|
+
const shipped = [];
|
|
47
|
+
for (const hook of MANAGED_HOOKS) {
|
|
48
|
+
const shippedPath = new URL(`../assets/${hook.asset}`, import.meta.url).pathname;
|
|
49
|
+
const text = await readFile(shippedPath, "utf8");
|
|
50
|
+
shipped.push({ ...hook, text, hash: scriptHash(text) });
|
|
51
|
+
}
|
|
47
52
|
|
|
48
53
|
let source = "{}\n";
|
|
49
54
|
if (existsSync(SETTINGS_PATH)) source = await readFile(SETTINGS_PATH, "utf8");
|
|
50
55
|
const parsed = parse(source) ?? {};
|
|
51
56
|
|
|
52
57
|
const marker = parsed[HOOK_MARKER_KEY];
|
|
53
|
-
const
|
|
58
|
+
const markerHashes = (marker && typeof marker === "object" && marker.hashes) || {};
|
|
54
59
|
const upToDate =
|
|
55
60
|
marker &&
|
|
56
61
|
typeof marker === "object" &&
|
|
57
62
|
marker.version === HOOK_MARKER_VERSION &&
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
`oxtail hook already installed (v${HOOK_MARKER_VERSION}, hash ${wantHash.slice(0, 8)}). No changes.`,
|
|
63
|
+
shipped.every(
|
|
64
|
+
(h) =>
|
|
65
|
+
markerHashes[h.id] === h.hash &&
|
|
66
|
+
findOxtailHookIndex(parsed, h.event, h.asset) >= 0 &&
|
|
67
|
+
existsSync(h.scriptPath),
|
|
64
68
|
);
|
|
69
|
+
if (upToDate) {
|
|
70
|
+
console.log(`oxtail hooks already installed (v${HOOK_MARKER_VERSION}). No changes.`);
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
// Detect competing
|
|
69
|
-
// Behavior under multi-hook coexistence is determined live
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
// Detect competing hooks on the same events (e.g. Terminator's _terminatorHook).
|
|
75
|
+
// Behavior under multi-hook coexistence is determined live; for now, warn.
|
|
76
|
+
let otherCount = 0;
|
|
77
|
+
for (const h of shipped) {
|
|
78
|
+
const arr = parsed?.hooks?.[h.event] ?? [];
|
|
79
|
+
const mine = findOxtailHookIndex(parsed, h.event, h.asset);
|
|
80
|
+
otherCount += arr.filter((entry, idx) => {
|
|
81
|
+
if (idx === mine) return false;
|
|
82
|
+
if (!entry || !Array.isArray(entry.hooks)) return false;
|
|
83
|
+
return entry.hooks.some(
|
|
84
|
+
(x) => x && typeof x.command === "string" && !x.command.includes("oxtail/hooks/"),
|
|
85
|
+
);
|
|
86
|
+
}).length;
|
|
87
|
+
}
|
|
88
|
+
if (otherCount > 0) {
|
|
79
89
|
console.warn(
|
|
80
|
-
`[oxtail] note: ${
|
|
90
|
+
`[oxtail] note: ${otherCount} other hook(s) already present on managed events. ` +
|
|
81
91
|
`Multi-hook coexistence is supported but install order may matter; ` +
|
|
82
92
|
`see README "Hook coexistence" for details.`,
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
// Back up settings.json before mutating it
|
|
96
|
+
// Back up settings.json before mutating it.
|
|
87
97
|
if (existsSync(SETTINGS_PATH)) {
|
|
88
98
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
89
99
|
const backup = `${SETTINGS_PATH}.oxtail-backup.${stamp}`;
|
|
@@ -91,42 +101,42 @@ export async function install() {
|
|
|
91
101
|
console.log(`Backed up existing settings to ${backup}`);
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
// Install
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
// Install each shipped script atomically.
|
|
105
|
+
for (const h of shipped) {
|
|
106
|
+
const hooksDir = path.dirname(h.scriptPath);
|
|
107
|
+
await mkdir(hooksDir, { recursive: true, mode: 0o755 });
|
|
108
|
+
const scriptTmp = `${h.scriptPath}.tmp-${randomBytes(6).toString("hex")}`;
|
|
109
|
+
// writeFile's `mode` only applies on creation; explicit chmod for belt+braces.
|
|
110
|
+
await writeFile(scriptTmp, h.text, { mode: 0o755 });
|
|
111
|
+
await chmod(scriptTmp, 0o755);
|
|
112
|
+
await rename(scriptTmp, h.scriptPath);
|
|
113
|
+
}
|
|
103
114
|
|
|
104
|
-
// Edit settings.json
|
|
115
|
+
// Edit settings.json: replace any prior oxtail entry per event, else append.
|
|
116
|
+
// Re-parse each iteration so indices reflect the prior edit. The two events
|
|
117
|
+
// are independent, but re-parsing keeps append indices correct regardless.
|
|
105
118
|
let text = source;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
for (const h of shipped) {
|
|
120
|
+
const cur = parse(text) ?? {};
|
|
121
|
+
const existingIdx = findOxtailHookIndex(cur, h.event, h.asset);
|
|
122
|
+
const arr = cur?.hooks?.[h.event];
|
|
123
|
+
const targetIdx = existingIdx >= 0 ? existingIdx : (Array.isArray(arr) ? arr.length : 0);
|
|
124
|
+
const newEntry = { hooks: [{ type: "command", command: h.command }] };
|
|
109
125
|
text = applyEdits(
|
|
110
126
|
text,
|
|
111
|
-
modify(text, ["hooks",
|
|
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 }),
|
|
127
|
+
modify(text, ["hooks", h.event, targetIdx], newEntry, { formattingOptions: FORMATTING }),
|
|
118
128
|
);
|
|
119
129
|
}
|
|
130
|
+
|
|
131
|
+
// Write the marker with per-hook hashes.
|
|
132
|
+
const hashes = {};
|
|
133
|
+
for (const h of shipped) hashes[h.id] = h.hash;
|
|
120
134
|
text = applyEdits(
|
|
121
135
|
text,
|
|
122
136
|
modify(
|
|
123
137
|
text,
|
|
124
138
|
[HOOK_MARKER_KEY],
|
|
125
|
-
{
|
|
126
|
-
version: HOOK_MARKER_VERSION,
|
|
127
|
-
installedAt: new Date().toISOString(),
|
|
128
|
-
scriptHash: wantHash,
|
|
129
|
-
},
|
|
139
|
+
{ version: HOOK_MARKER_VERSION, installedAt: new Date().toISOString(), hashes },
|
|
130
140
|
{ formattingOptions: FORMATTING },
|
|
131
141
|
),
|
|
132
142
|
);
|
|
@@ -137,7 +147,9 @@ export async function install() {
|
|
|
137
147
|
await writeFile(settingsTmp, text, "utf8");
|
|
138
148
|
await rename(settingsTmp, SETTINGS_PATH);
|
|
139
149
|
|
|
140
|
-
console.log(
|
|
150
|
+
console.log(
|
|
151
|
+
`Installed oxtail hooks (${MANAGED_HOOKS.map((h) => h.event).join(", ")}) in ${SETTINGS_PATH}.`,
|
|
152
|
+
);
|
|
141
153
|
console.log("Reverse with: npx oxtail uninstall-hook");
|
|
142
154
|
}
|
|
143
155
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Remove the oxtail PreToolUse
|
|
3
|
-
// ~/.claude/settings.json, and delete the installed
|
|
2
|
+
// Remove the oxtail hook entries (PreToolUse + Stop) and marker from
|
|
3
|
+
// ~/.claude/settings.json, and delete the installed scripts under
|
|
4
|
+
// ~/.oxtail/hooks/.
|
|
4
5
|
//
|
|
5
6
|
// Idempotent: a clean run on an uninstalled system exits 0 with "nothing to do."
|
|
6
7
|
|
|
@@ -12,13 +13,13 @@ import { applyEdits, modify, parse } from "jsonc-parser";
|
|
|
12
13
|
import {
|
|
13
14
|
SETTINGS_PATH,
|
|
14
15
|
HOOK_MARKER_KEY,
|
|
15
|
-
|
|
16
|
+
MANAGED_HOOKS,
|
|
16
17
|
} from "./hook-constants.mjs";
|
|
17
18
|
|
|
18
19
|
const FORMATTING = { tabSize: 2, insertSpaces: true };
|
|
19
20
|
|
|
20
|
-
function findOxtailHookIndex(parsed) {
|
|
21
|
-
const arr = parsed?.hooks?.
|
|
21
|
+
function findOxtailHookIndex(parsed, event, asset) {
|
|
22
|
+
const arr = parsed?.hooks?.[event];
|
|
22
23
|
if (!Array.isArray(arr)) return -1;
|
|
23
24
|
return arr.findIndex((entry) => {
|
|
24
25
|
if (!entry || typeof entry !== "object") return false;
|
|
@@ -28,44 +29,58 @@ function findOxtailHookIndex(parsed) {
|
|
|
28
29
|
h &&
|
|
29
30
|
typeof h === "object" &&
|
|
30
31
|
typeof h.command === "string" &&
|
|
31
|
-
h.command.includes(
|
|
32
|
+
h.command.includes(`oxtail/hooks/${asset}`),
|
|
32
33
|
);
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
async function removeScripts() {
|
|
38
|
+
for (const h of MANAGED_HOOKS) {
|
|
39
|
+
if (!existsSync(h.scriptPath)) continue;
|
|
40
|
+
try {
|
|
41
|
+
await unlink(h.scriptPath);
|
|
42
|
+
console.log(`Removed ${h.scriptPath}.`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn(`Could not remove ${h.scriptPath}: ${err?.message ?? err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
export async function uninstall() {
|
|
37
50
|
if (!existsSync(SETTINGS_PATH)) {
|
|
38
51
|
console.log(`No ${SETTINGS_PATH} — nothing to do.`);
|
|
39
|
-
// Still try to remove
|
|
40
|
-
|
|
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
|
-
}
|
|
52
|
+
// Still try to remove installed scripts in case they're leftovers.
|
|
53
|
+
await removeScripts();
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
const source = await readFile(SETTINGS_PATH, "utf8");
|
|
52
58
|
const parsed = parse(source) ?? {};
|
|
53
59
|
|
|
54
|
-
const idx = findOxtailHookIndex(parsed);
|
|
55
60
|
const hasMarker =
|
|
56
61
|
parsed[HOOK_MARKER_KEY] && typeof parsed[HOOK_MARKER_KEY] === "object";
|
|
62
|
+
const anyEntry = MANAGED_HOOKS.some(
|
|
63
|
+
(h) => findOxtailHookIndex(parsed, h.event, h.asset) >= 0,
|
|
64
|
+
);
|
|
65
|
+
const anyScript = MANAGED_HOOKS.some((h) => existsSync(h.scriptPath));
|
|
57
66
|
|
|
58
|
-
if (
|
|
59
|
-
console.log("oxtail
|
|
67
|
+
if (!anyEntry && !hasMarker && !anyScript) {
|
|
68
|
+
console.log("oxtail hooks not installed — nothing to do.");
|
|
60
69
|
return;
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
// Remove each event's oxtail entry. Re-parse per iteration so indices stay
|
|
73
|
+
// valid after the prior edit.
|
|
63
74
|
let text = source;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
for (const h of MANAGED_HOOKS) {
|
|
76
|
+
const cur = parse(text) ?? {};
|
|
77
|
+
const idx = findOxtailHookIndex(cur, h.event, h.asset);
|
|
78
|
+
if (idx >= 0) {
|
|
79
|
+
text = applyEdits(
|
|
80
|
+
text,
|
|
81
|
+
modify(text, ["hooks", h.event, idx], undefined, { formattingOptions: FORMATTING }),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
69
84
|
}
|
|
70
85
|
if (hasMarker) {
|
|
71
86
|
text = applyEdits(
|
|
@@ -78,16 +93,9 @@ export async function uninstall() {
|
|
|
78
93
|
await mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
79
94
|
await writeFile(settingsTmp, text, "utf8");
|
|
80
95
|
await rename(settingsTmp, SETTINGS_PATH);
|
|
81
|
-
console.log(`Removed oxtail
|
|
96
|
+
console.log(`Removed oxtail hooks from ${SETTINGS_PATH}.`);
|
|
82
97
|
|
|
83
|
-
|
|
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
|
-
}
|
|
98
|
+
await removeScripts();
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
const invokedDirectly =
|