|
1
|
#!/usr/bin/env bash |
|
2
|
# Install the tracked Claude relay hooks plus binary launcher into a local setup. |
|
3
|
|
|
4
|
set -euo pipefail |
|
5
|
|
|
6
|
usage() { |
|
7
|
cat <<'EOF' |
|
8
|
Usage: |
|
9
|
bash skills/scuttlebot-relay/scripts/install-claude-relay.sh [options] |
|
10
|
|
|
11
|
Options: |
|
12
|
--url URL Set SCUTTLEBOT_URL in the shared env file. |
|
13
|
--token TOKEN Set SCUTTLEBOT_TOKEN in the shared env file. |
|
14
|
--channel CHANNEL Set SCUTTLEBOT_CHANNEL in the shared env file. |
|
15
|
--channels CSV Set SCUTTLEBOT_CHANNELS in the shared env file. |
|
16
|
--transport MODE Set SCUTTLEBOT_TRANSPORT (http or irc). Default: irc. |
|
17
|
--irc-addr ADDR Set SCUTTLEBOT_IRC_ADDR. Default: 127.0.0.1:6667. |
|
18
|
--irc-pass PASS Write SCUTTLEBOT_IRC_PASS for fixed-identity IRC mode. |
|
19
|
--auto-register Remove SCUTTLEBOT_IRC_PASS so IRC mode auto-registers session nicks. Default. |
|
20
|
--enabled Write SCUTTLEBOT_HOOKS_ENABLED=1. Default. |
|
21
|
--disabled Write SCUTTLEBOT_HOOKS_ENABLED=0. |
|
22
|
--config-file PATH Shared env file path. Default: ~/.config/scuttlebot-relay.env |
|
23
|
--hooks-dir PATH Claude hooks install dir. Default: ~/.claude/hooks |
|
24
|
--settings-json PATH Claude settings JSON. Default: ~/.claude/settings.json |
|
25
|
--bin-dir PATH Launcher install dir. Default: ~/.local/bin |
|
26
|
--help Show this help. |
|
27
|
|
|
28
|
Environment defaults: |
|
29
|
SCUTTLEBOT_URL |
|
30
|
SCUTTLEBOT_TOKEN |
|
31
|
SCUTTLEBOT_CHANNEL |
|
32
|
SCUTTLEBOT_CHANNELS |
|
33
|
SCUTTLEBOT_TRANSPORT |
|
34
|
SCUTTLEBOT_IRC_ADDR |
|
35
|
SCUTTLEBOT_IRC_PASS |
|
36
|
SCUTTLEBOT_IRC_DELETE_ON_CLOSE |
|
37
|
SCUTTLEBOT_HOOKS_ENABLED |
|
38
|
SCUTTLEBOT_INTERRUPT_ON_MESSAGE |
|
39
|
SCUTTLEBOT_POLL_INTERVAL |
|
40
|
SCUTTLEBOT_PRESENCE_HEARTBEAT |
|
41
|
SCUTTLEBOT_CONFIG_FILE |
|
42
|
CLAUDE_HOOKS_DIR |
|
43
|
CLAUDE_SETTINGS_JSON |
|
44
|
CLAUDE_BIN_DIR |
|
45
|
|
|
46
|
Examples: |
|
47
|
bash skills/scuttlebot-relay/scripts/install-claude-relay.sh \ |
|
48
|
--url http://localhost:8080 \ |
|
49
|
--token "$(./run.sh token)" \ |
|
50
|
--channel general |
|
51
|
|
|
52
|
SCUTTLEBOT_HOOKS_ENABLED=0 make install-claude-relay |
|
53
|
EOF |
|
54
|
} |
|
55
|
|
|
56
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) |
|
57
|
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) |
|
58
|
|
|
59
|
SCUTTLEBOT_URL_VALUE="${SCUTTLEBOT_URL:-}" |
|
60
|
SCUTTLEBOT_TOKEN_VALUE="${SCUTTLEBOT_TOKEN:-}" |
|
61
|
SCUTTLEBOT_CHANNEL_VALUE="${SCUTTLEBOT_CHANNEL:-}" |
|
62
|
SCUTTLEBOT_CHANNELS_VALUE="${SCUTTLEBOT_CHANNELS:-}" |
|
63
|
SCUTTLEBOT_TRANSPORT_VALUE="${SCUTTLEBOT_TRANSPORT:-irc}" |
|
64
|
SCUTTLEBOT_IRC_ADDR_VALUE="${SCUTTLEBOT_IRC_ADDR:-127.0.0.1:6667}" |
|
65
|
if [ -n "${SCUTTLEBOT_IRC_PASS:-}" ]; then |
|
66
|
SCUTTLEBOT_IRC_PASS_MODE="fixed" |
|
67
|
SCUTTLEBOT_IRC_PASS_VALUE="$SCUTTLEBOT_IRC_PASS" |
|
68
|
else |
|
69
|
SCUTTLEBOT_IRC_PASS_MODE="auto" |
|
70
|
SCUTTLEBOT_IRC_PASS_VALUE="" |
|
71
|
fi |
|
72
|
SCUTTLEBOT_IRC_DELETE_ON_CLOSE_VALUE="${SCUTTLEBOT_IRC_DELETE_ON_CLOSE:-1}" |
|
73
|
SCUTTLEBOT_HOOKS_ENABLED_VALUE="${SCUTTLEBOT_HOOKS_ENABLED:-1}" |
|
74
|
SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE="${SCUTTLEBOT_INTERRUPT_ON_MESSAGE:-1}" |
|
75
|
SCUTTLEBOT_POLL_INTERVAL_VALUE="${SCUTTLEBOT_POLL_INTERVAL:-2s}" |
|
76
|
SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE="${SCUTTLEBOT_PRESENCE_HEARTBEAT:-60s}" |
|
77
|
|
|
78
|
CONFIG_FILE="${SCUTTLEBOT_CONFIG_FILE:-$HOME/.config/scuttlebot-relay.env}" |
|
79
|
HOOKS_DIR="${CLAUDE_HOOKS_DIR:-$HOME/.claude/hooks}" |
|
80
|
SETTINGS_JSON="${CLAUDE_SETTINGS_JSON:-$HOME/.claude/settings.json}" |
|
81
|
BIN_DIR="${CLAUDE_BIN_DIR:-$HOME/.local/bin}" |
|
82
|
|
|
83
|
while [ $# -gt 0 ]; do |
|
84
|
case "$1" in |
|
85
|
--url) |
|
86
|
SCUTTLEBOT_URL_VALUE="${2:?missing value for --url}" |
|
87
|
shift 2 |
|
88
|
;; |
|
89
|
--token) |
|
90
|
SCUTTLEBOT_TOKEN_VALUE="${2:?missing value for --token}" |
|
91
|
shift 2 |
|
92
|
;; |
|
93
|
--channel) |
|
94
|
SCUTTLEBOT_CHANNEL_VALUE="${2:?missing value for --channel}" |
|
95
|
shift 2 |
|
96
|
;; |
|
97
|
--channels) |
|
98
|
SCUTTLEBOT_CHANNELS_VALUE="${2:?missing value for --channels}" |
|
99
|
shift 2 |
|
100
|
;; |
|
101
|
--transport) |
|
102
|
SCUTTLEBOT_TRANSPORT_VALUE="${2:?missing value for --transport}" |
|
103
|
shift 2 |
|
104
|
;; |
|
105
|
--irc-addr) |
|
106
|
SCUTTLEBOT_IRC_ADDR_VALUE="${2:?missing value for --irc-addr}" |
|
107
|
shift 2 |
|
108
|
;; |
|
109
|
--irc-pass) |
|
110
|
SCUTTLEBOT_IRC_PASS_MODE="fixed" |
|
111
|
SCUTTLEBOT_IRC_PASS_VALUE="${2:?missing value for --irc-pass}" |
|
112
|
shift 2 |
|
113
|
;; |
|
114
|
--auto-register) |
|
115
|
SCUTTLEBOT_IRC_PASS_MODE="auto" |
|
116
|
SCUTTLEBOT_IRC_PASS_VALUE="" |
|
117
|
shift |
|
118
|
;; |
|
119
|
--enabled) |
|
120
|
SCUTTLEBOT_HOOKS_ENABLED_VALUE=1 |
|
121
|
shift |
|
122
|
;; |
|
123
|
--disabled) |
|
124
|
SCUTTLEBOT_HOOKS_ENABLED_VALUE=0 |
|
125
|
shift |
|
126
|
;; |
|
127
|
--config-file) |
|
128
|
CONFIG_FILE="${2:?missing value for --config-file}" |
|
129
|
shift 2 |
|
130
|
;; |
|
131
|
--hooks-dir) |
|
132
|
HOOKS_DIR="${2:?missing value for --hooks-dir}" |
|
133
|
shift 2 |
|
134
|
;; |
|
135
|
--settings-json) |
|
136
|
SETTINGS_JSON="${2:?missing value for --settings-json}" |
|
137
|
shift 2 |
|
138
|
;; |
|
139
|
--bin-dir) |
|
140
|
BIN_DIR="${2:?missing value for --bin-dir}" |
|
141
|
shift 2 |
|
142
|
;; |
|
143
|
--help|-h) |
|
144
|
usage |
|
145
|
exit 0 |
|
146
|
;; |
|
147
|
*) |
|
148
|
printf 'install-claude-relay: unknown argument %s\n' "$1" >&2 |
|
149
|
usage >&2 |
|
150
|
exit 2 |
|
151
|
;; |
|
152
|
esac |
|
153
|
done |
|
154
|
|
|
155
|
need_cmd() { |
|
156
|
if ! command -v "$1" >/dev/null 2>&1; then |
|
157
|
printf 'install-claude-relay: required command not found: %s\n' "$1" >&2 |
|
158
|
exit 1 |
|
159
|
fi |
|
160
|
} |
|
161
|
|
|
162
|
backup_file() { |
|
163
|
local path="$1" |
|
164
|
if [ -f "$path" ] && [ ! -f "${path}.bak" ]; then |
|
165
|
cp "$path" "${path}.bak" |
|
166
|
fi |
|
167
|
} |
|
168
|
|
|
169
|
ensure_parent_dir() { |
|
170
|
mkdir -p "$(dirname "$1")" |
|
171
|
} |
|
172
|
|
|
173
|
normalize_channels() { |
|
174
|
local primary="$1" |
|
175
|
local raw="$2" |
|
176
|
local IFS=',' |
|
177
|
local items=() |
|
178
|
local extra_items=() |
|
179
|
local item channel seen="" |
|
180
|
|
|
181
|
if [ -n "$primary" ]; then |
|
182
|
items+=("$primary") |
|
183
|
fi |
|
184
|
if [ -n "$raw" ]; then |
|
185
|
read -r -a extra_items <<< "$raw" |
|
186
|
items+=("${extra_items[@]}") |
|
187
|
fi |
|
188
|
|
|
189
|
for item in "${items[@]}"; do |
|
190
|
channel="${item//[$' \t\r\n']/}" |
|
191
|
channel="${channel#\#}" |
|
192
|
[ -n "$channel" ] || continue |
|
193
|
case ",$seen," in |
|
194
|
*,"$channel",*) ;; |
|
195
|
*) seen="${seen:+$seen,}$channel" ;; |
|
196
|
esac |
|
197
|
done |
|
198
|
|
|
199
|
printf '%s' "$seen" |
|
200
|
} |
|
201
|
|
|
202
|
first_channel() { |
|
203
|
local channels |
|
204
|
channels=$(normalize_channels "" "$1") |
|
205
|
printf '%s' "${channels%%,*}" |
|
206
|
} |
|
207
|
|
|
208
|
if [ -z "$SCUTTLEBOT_CHANNEL_VALUE" ] && [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then |
|
209
|
SCUTTLEBOT_CHANNEL_VALUE="$(first_channel "$SCUTTLEBOT_CHANNELS_VALUE")" |
|
210
|
fi |
|
211
|
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
|
212
|
SCUTTLEBOT_CHANNELS_VALUE="$(normalize_channels "$SCUTTLEBOT_CHANNEL_VALUE" "$SCUTTLEBOT_CHANNELS_VALUE")" |
|
213
|
fi |
|
214
|
|
|
215
|
upsert_env_var() { |
|
216
|
local file="$1" |
|
217
|
local key="$2" |
|
218
|
local value="$3" |
|
219
|
local escaped |
|
220
|
escaped=$(printf '%q' "$value") |
|
221
|
awk -v key="$key" -v value="$escaped" ' |
|
222
|
BEGIN { done = 0 } |
|
223
|
$0 ~ "^(export[[:space:]]+)?" key "=" { |
|
224
|
if (!done) { |
|
225
|
print key "=" value |
|
226
|
done = 1 |
|
227
|
} |
|
228
|
next |
|
229
|
} |
|
230
|
{ print } |
|
231
|
END { |
|
232
|
if (!done) { |
|
233
|
print key "=" value |
|
234
|
} |
|
235
|
} |
|
236
|
' "$file" > "${file}.tmp" |
|
237
|
mv "${file}.tmp" "$file" |
|
238
|
} |
|
239
|
|
|
240
|
remove_env_var() { |
|
241
|
local file="$1" |
|
242
|
local key="$2" |
|
243
|
awk -v key="$key" ' |
|
244
|
$0 ~ "^(export[[:space:]]+)?" key "=" { next } |
|
245
|
{ print } |
|
246
|
' "$file" > "${file}.tmp" |
|
247
|
mv "${file}.tmp" "$file" |
|
248
|
} |
|
249
|
|
|
250
|
need_cmd jq |
|
251
|
need_cmd go |
|
252
|
|
|
253
|
POST_CMD="$HOOKS_DIR/scuttlebot-post.sh" |
|
254
|
CHECK_CMD="$HOOKS_DIR/scuttlebot-check.sh" |
|
255
|
LAUNCHER_DST="$BIN_DIR/claude-relay" |
|
256
|
|
|
257
|
mkdir -p "$HOOKS_DIR" "$BIN_DIR" |
|
258
|
ensure_parent_dir "$SETTINGS_JSON" |
|
259
|
ensure_parent_dir "$CONFIG_FILE" |
|
260
|
|
|
261
|
backup_file "$POST_CMD" |
|
262
|
backup_file "$CHECK_CMD" |
|
263
|
backup_file "$LAUNCHER_DST" |
|
264
|
install -m 0755 "$REPO_ROOT/skills/scuttlebot-relay/hooks/scuttlebot-post.sh" "$POST_CMD" |
|
265
|
install -m 0755 "$REPO_ROOT/skills/scuttlebot-relay/hooks/scuttlebot-check.sh" "$CHECK_CMD" |
|
266
|
|
|
267
|
printf 'Building claude-relay binary...\n' |
|
268
|
(cd "$REPO_ROOT" && go build -o "$LAUNCHER_DST" ./cmd/claude-relay) |
|
269
|
|
|
270
|
backup_file "$SETTINGS_JSON" |
|
271
|
if [ -f "$SETTINGS_JSON" ]; then |
|
272
|
jq --arg pre_matcher "Bash|Edit|Write" \ |
|
273
|
--arg pre_cmd "$CHECK_CMD" \ |
|
274
|
--arg post_matcher "Bash|Read|Edit|Write|Glob|Grep|Agent" \ |
|
275
|
--arg post_cmd "$POST_CMD" ' |
|
276
|
def ensure_matcher_entry(section; matcher; cmd): |
|
277
|
.hooks = (.hooks // {}) |
|
278
|
| .hooks[section] = (.hooks[section] // []) |
|
279
|
| if any(.hooks[section][]?; .matcher == matcher) then |
|
280
|
.hooks[section] |= map( |
|
281
|
if .matcher == matcher then |
|
282
|
(.hooks = (.hooks // [])) |
|
283
|
| if any(.hooks[]?; .type == "command" and .command == cmd) then . else .hooks += [{"type":"command","command":cmd}] end |
|
284
|
else |
|
285
|
. |
|
286
|
end |
|
287
|
) |
|
288
|
else |
|
289
|
.hooks[section] += [{"matcher":matcher,"hooks":[{"type":"command","command":cmd}]}] |
|
290
|
end; |
|
291
|
ensure_matcher_entry("PreToolUse"; $pre_matcher; $pre_cmd) |
|
292
|
| ensure_matcher_entry("PostToolUse"; $post_matcher; $post_cmd) |
|
293
|
' "$SETTINGS_JSON" > "${SETTINGS_JSON}.tmp" |
|
294
|
else |
|
295
|
jq -n \ |
|
296
|
--arg pre_cmd "$CHECK_CMD" \ |
|
297
|
--arg post_cmd "$POST_CMD" ' |
|
298
|
{ |
|
299
|
hooks: { |
|
300
|
"PreToolUse": [ |
|
301
|
{ |
|
302
|
matcher: "Bash|Edit|Write", |
|
303
|
hooks: [{type: "command", command: $pre_cmd}] |
|
304
|
} |
|
305
|
], |
|
306
|
"PostToolUse": [ |
|
307
|
{ |
|
308
|
matcher: "Bash|Read|Edit|Write|Glob|Grep|Agent", |
|
309
|
hooks: [{type: "command", command: $post_cmd}] |
|
310
|
} |
|
311
|
] |
|
312
|
} |
|
313
|
} |
|
314
|
' > "${SETTINGS_JSON}.tmp" |
|
315
|
fi |
|
316
|
mv "${SETTINGS_JSON}.tmp" "$SETTINGS_JSON" |
|
317
|
|
|
318
|
backup_file "$CONFIG_FILE" |
|
319
|
if [ ! -f "$CONFIG_FILE" ]; then |
|
320
|
: > "$CONFIG_FILE" |
|
321
|
fi |
|
322
|
if [ -n "$SCUTTLEBOT_URL_VALUE" ]; then |
|
323
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_URL "$SCUTTLEBOT_URL_VALUE" |
|
324
|
fi |
|
325
|
if [ -n "$SCUTTLEBOT_TOKEN_VALUE" ]; then |
|
326
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TOKEN "$SCUTTLEBOT_TOKEN_VALUE" |
|
327
|
fi |
|
328
|
if [ -n "$SCUTTLEBOT_CHANNEL_VALUE" ]; then |
|
329
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNEL "${SCUTTLEBOT_CHANNEL_VALUE#\#}" |
|
330
|
fi |
|
331
|
if [ -n "$SCUTTLEBOT_CHANNELS_VALUE" ]; then |
|
332
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_CHANNELS "$SCUTTLEBOT_CHANNELS_VALUE" |
|
333
|
fi |
|
334
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_TRANSPORT "$SCUTTLEBOT_TRANSPORT_VALUE" |
|
335
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_ADDR "$SCUTTLEBOT_IRC_ADDR_VALUE" |
|
336
|
if [ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ]; then |
|
337
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS "$SCUTTLEBOT_IRC_PASS_VALUE" |
|
338
|
else |
|
339
|
remove_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_PASS |
|
340
|
fi |
|
341
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_IRC_DELETE_ON_CLOSE "$SCUTTLEBOT_IRC_DELETE_ON_CLOSE_VALUE" |
|
342
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_HOOKS_ENABLED "$SCUTTLEBOT_HOOKS_ENABLED_VALUE" |
|
343
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_INTERRUPT_ON_MESSAGE "$SCUTTLEBOT_INTERRUPT_ON_MESSAGE_VALUE" |
|
344
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_POLL_INTERVAL "$SCUTTLEBOT_POLL_INTERVAL_VALUE" |
|
345
|
upsert_env_var "$CONFIG_FILE" SCUTTLEBOT_PRESENCE_HEARTBEAT "$SCUTTLEBOT_PRESENCE_HEARTBEAT_VALUE" |
|
346
|
|
|
347
|
printf 'Installed Claude relay files:\n' |
|
348
|
printf ' hooks: %s\n' "$HOOKS_DIR" |
|
349
|
printf ' settings: %s\n' "$SETTINGS_JSON" |
|
350
|
printf ' launcher: %s\n' "$LAUNCHER_DST" |
|
351
|
printf ' env: %s\n' "$CONFIG_FILE" |
|
352
|
printf ' irc auth: %s\n' "$([ "$SCUTTLEBOT_IRC_PASS_MODE" = "fixed" ] && printf 'fixed-pass override' || printf 'auto-register')" |
|
353
|
printf '\n' |
|
354
|
printf 'Next steps:\n' |
|
355
|
printf ' 1. Launch with: %s\n' "$LAUNCHER_DST" |
|
356
|
printf ' 2. Watch IRC for: claude-{repo}-{session}\n' |
|
357
|
printf ' 3. Mention that nick to interrupt before the next action\n' |
|
358
|
printf '\n' |
|
359
|
printf 'Disable without uninstalling:\n' |
|
360
|
printf ' SCUTTLEBOT_HOOKS_ENABLED=0 %s\n' "$LAUNCHER_DST" |
|
361
|
|