claude-slack-channel-bots 0.4.2 → 0.5.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.
- package/README.md +18 -17
- package/hooks/ask-relay.sh +14 -5
- package/hooks/permission-relay.sh +16 -15
- package/package.json +1 -1
- package/skills/claude-slack-channels-config/SKILL.md +3 -3
- package/skills/setup-slack-channel-bots/SKILL.md +23 -10
- package/src/cli.ts +3 -3
- package/src/config.ts +14 -3
- package/src/lib.ts +2 -2
- package/src/postinstall.ts +16 -8
- package/src/server.ts +44 -2
- package/src/session-manager.ts +19 -2
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
|
|
|
56
56
|
|---|---|
|
|
57
57
|
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-…`). Required. Granted by the OAuth install flow. |
|
|
58
58
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
|
|
59
|
-
| `SLACK_STATE_DIR` | Override the directory where `
|
|
59
|
+
| `SLACK_STATE_DIR` | Override the directory where `config.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
|
|
60
60
|
| `SLACK_ACCESS_MODE` | Set to `static` to load `access.json` once at startup and cache it for the lifetime of the process rather than re-reading it on every event. Useful in high-throughput environments where disk reads are a concern. |
|
|
61
61
|
| `SLACK_DRY_RUN` | Set to `1` to start the server without Slack credentials. Token validation is skipped, Socket Mode and `web.auth.test()` are not called, and MCP tool calls (`reply`, `react`, etc.) are logged instead of sent. Useful for integration testing. |
|
|
62
62
|
|
|
@@ -74,9 +74,9 @@ export SLACK_DRY_RUN=1
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
-
### Routing (
|
|
77
|
+
### Routing (config.json)
|
|
78
78
|
|
|
79
|
-
`
|
|
79
|
+
`config.json` is read from `~/.claude/channels/slack/config.json` by default. Override the directory with `SLACK_STATE_DIR`.
|
|
80
80
|
|
|
81
81
|
A skeleton file is created by postinstall. Populate it before running `start`.
|
|
82
82
|
|
|
@@ -116,15 +116,16 @@ A skeleton file is created by postinstall. Populate it before running `start`.
|
|
|
116
116
|
| `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
|
|
117
117
|
| `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
|
|
118
118
|
| `append_system_prompt_file` | string | — | Path to a file appended to every managed session's system prompt via `--append-system-prompt-file`. Missing file silently skipped. See `skills/EXAMPLE_CLAUDE.md` for a template. |
|
|
119
|
+
| `system_prompt_mode` | string | `"append"` | Controls how `append_system_prompt_file` is applied. `"append"`: the custom prompt file is appended on top of `CLAUDE.md` (default, current behavior). `"none"`: only `CLAUDE.md` is used; `append_system_prompt_file` is ignored even if set. Use `"none"` when the project's `CLAUDE.md` already contains everything the bot needs. |
|
|
119
120
|
| `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
|
|
120
121
|
|
|
121
122
|
---
|
|
122
123
|
|
|
123
124
|
### Access Control (access.json)
|
|
124
125
|
|
|
125
|
-
`access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `
|
|
126
|
+
`access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `config.json`). A skeleton file with defaults is created by postinstall. The file is written with `0600` permissions.
|
|
126
127
|
|
|
127
|
-
Channels in `
|
|
128
|
+
Channels in `config.json` are automatically allowed — you do not need to list them here. The `channels` map is only needed for per-channel overrides like requiring @mentions or restricting which users can trigger the bot.
|
|
128
129
|
|
|
129
130
|
The `slack-channel-access` skill manages pairings and allowlist entries at runtime.
|
|
130
131
|
|
|
@@ -153,7 +154,7 @@ The `slack-channel-access` skill manages pairings and allowlist entries at runti
|
|
|
153
154
|
|---|---|---|---|
|
|
154
155
|
| `dmPolicy` | `"pairing"` \| `"allowlist"` \| `"disabled"` | `"pairing"` | Controls who can DM the bot. `pairing`: unknown users receive a one-time code and are added to `allowFrom` after verification. `allowlist`: only users in `allowFrom` are accepted. `disabled`: all DMs are dropped. |
|
|
155
156
|
| `allowFrom` | string[] | `[]` | Slack user IDs allowed to DM the bot unconditionally (regardless of `dmPolicy`). |
|
|
156
|
-
| `channels` | object | `{}` | Optional per-channel overrides. Channels in `
|
|
157
|
+
| `channels` | object | `{}` | Optional per-channel overrides. Channels in `config.json` are allowed automatically — only add entries here to customize behavior (e.g. require @mention or restrict users). Each entry is a `ChannelPolicy`. |
|
|
157
158
|
| `channels[id].requireMention` | boolean | `false` | When `true`, messages in that channel are only delivered if the bot is `@mentioned`. |
|
|
158
159
|
| `channels[id].allowFrom` | string[] | `[]` | When non-empty, restricts delivery to the listed Slack user IDs for that channel. |
|
|
159
160
|
| `pending` | object | `{}` | Managed by the server. Stores in-flight pairing codes indexed by code string. Do not edit manually. |
|
|
@@ -178,7 +179,7 @@ Claude Code sessions need a config file pointing at the MCP server. A skeleton i
|
|
|
178
179
|
}
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
If you changed `port` or `bind` in `
|
|
182
|
+
If you changed `port` or `bind` in `config.json`, update the `url` here to match. The server-managed session launcher uses `mcp_config_path` from `config.json` to locate this file.
|
|
182
183
|
|
|
183
184
|
---
|
|
184
185
|
|
|
@@ -195,7 +196,7 @@ Checks prerequisites, then daemonizes the server.
|
|
|
195
196
|
1. `tmux` is on `PATH` — fails with `missing prerequisite: tmux` if not found.
|
|
196
197
|
2. `SLACK_BOT_TOKEN` is set — fails with `missing prerequisite: SLACK_BOT_TOKEN environment variable` if absent.
|
|
197
198
|
3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
|
|
198
|
-
4. `
|
|
199
|
+
4. `config.json` exists at `STATE_DIR/config.json` — fails with the full path if not found.
|
|
199
200
|
|
|
200
201
|
If all checks pass, the parent process spawns a detached child process and exits immediately, printing the child PID. The child starts the server and writes its PID to `STATE_DIR/server.pid`. Conversation context is preserved across server restarts when possible.
|
|
201
202
|
|
|
@@ -276,7 +277,7 @@ When Claude Code requires tool approval, the permission relay surfaces an intera
|
|
|
276
277
|
|
|
277
278
|
The `ask-relay.sh` hook intercepts `AskUserQuestion` tool calls via `PreToolUse`, posts the question and its options to Slack as interactive buttons, and waits for the user's selection. The answer is returned to Claude Code via `updatedInput` without blocking the TUI.
|
|
278
279
|
|
|
279
|
-
Both hooks are **scope-guarded**: they
|
|
280
|
+
Both hooks are **scope-guarded**: on each invocation they call the server's `GET /is-managed?pid=$PPID` endpoint to verify that the calling process belongs to a server-managed Claude session. If the server is not running or does not recognize the PID, the hooks exit silently (no-op). This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot.
|
|
280
281
|
|
|
281
282
|
Both hooks use a **two-phase long-poll protocol**:
|
|
282
283
|
|
|
@@ -328,7 +329,7 @@ To enable it: open your Slack app config → **Interactivity & Shortcuts** → t
|
|
|
328
329
|
|
|
329
330
|
`permission-relay.sh` relays tool permission requests (Allow/Deny) to Slack via `PermissionRequest`. `ask-relay.sh` relays `AskUserQuestion` calls to Slack via `PreToolUse`, returning the user's selection without blocking the TUI.
|
|
330
331
|
|
|
331
|
-
Both hooks auto-detect the server port from `
|
|
332
|
+
Both hooks auto-detect the server port from `config.json`. They read `${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json` and use the `port` field (defaulting to `3100`), so they stay in sync if you change the port in routing config.
|
|
332
333
|
|
|
333
334
|
### Setup skill
|
|
334
335
|
|
|
@@ -341,23 +342,23 @@ The `update-config` skill can automate hook installation. It copies or symlinks
|
|
|
341
342
|
**Missing environment variables**
|
|
342
343
|
`start` exits with `missing prerequisite: SLACK_BOT_TOKEN environment variable` or `SLACK_APP_TOKEN environment variable`. Export both tokens in your shell profile and open a new terminal before running `start`.
|
|
343
344
|
|
|
344
|
-
**
|
|
345
|
-
`start` exits with `missing prerequisite:
|
|
345
|
+
**config.json not found**
|
|
346
|
+
`start` exits with `missing prerequisite: config.json not found at <path>`. Run `bun postinstall.ts` to create a skeleton, or create the file manually. Verify `SLACK_STATE_DIR` matches the directory you populated.
|
|
346
347
|
|
|
347
|
-
**
|
|
348
|
-
If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `
|
|
348
|
+
**config.json CWD mismatch**
|
|
349
|
+
If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `config.json`. Confirm the session's working directory matches the entry exactly (after tilde expansion). Duplicate CWDs across multiple routes are rejected at startup.
|
|
349
350
|
|
|
350
351
|
**Bot not receiving messages in a new channel**
|
|
351
352
|
After inviting the bot to a channel, Slack may not deliver messages until the bot is @mentioned for the first time. This is a Slack Socket Mode behavior — the first @mention activates event delivery for that channel. After that, all messages flow normally regardless of `requireMention` settings.
|
|
352
353
|
|
|
353
354
|
**Channel not in access.json**
|
|
354
|
-
Messages to channels not listed in `access.json → channels` and not present in `
|
|
355
|
+
Messages to channels not listed in `access.json → channels` and not present in `config.json → routes` are silently dropped. Use the `claude-slack-channels-config` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
|
|
355
356
|
|
|
356
357
|
**Permission relay not working**
|
|
357
|
-
Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `
|
|
358
|
+
Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `config.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks call `GET /is-managed?pid=$PPID` and exit silently if the server is unreachable or the PID is not recognized. Sessions launched manually will not trigger the relay.
|
|
358
359
|
|
|
359
360
|
**Session not restarting after crash**
|
|
360
|
-
After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `
|
|
361
|
+
After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `config.json`.
|
|
361
362
|
|
|
362
363
|
**Session stuck during clean_restart**
|
|
363
364
|
If a session does not exit within `exit_timeout` seconds (default 120s), `clean_restart` force-kills its tmux session and proceeds. To manually recover, run `tmux kill-session -t <session-name>` for any remaining sessions, then `claude-slack-channel-bots stop && claude-slack-channel-bots start`.
|
package/hooks/ask-relay.sh
CHANGED
|
@@ -2,8 +2,20 @@
|
|
|
2
2
|
# AskUserQuestion relay — intercepts via PreToolUse, posts to Slack, returns answer
|
|
3
3
|
set -euo pipefail
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
if
|
|
5
|
+
# Check dependencies (must be before guard since guard needs curl+jq)
|
|
6
|
+
if ! command -v jq &>/dev/null; then
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
if ! command -v curl &>/dev/null; then
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
# Read port from config.json
|
|
14
|
+
PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json" 2>/dev/null) || PORT=3100
|
|
15
|
+
|
|
16
|
+
# Guard: only relay for bot-managed sessions (server PID check)
|
|
17
|
+
HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
|
|
18
|
+
if [ "$HTTP_STATUS" != "200" ]; then
|
|
7
19
|
exit 0
|
|
8
20
|
fi
|
|
9
21
|
|
|
@@ -23,9 +35,6 @@ if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ] || [ -z "$CWD" ]; then
|
|
|
23
35
|
exit 0
|
|
24
36
|
fi
|
|
25
37
|
|
|
26
|
-
# Read port from routing config
|
|
27
|
-
PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json" 2>/dev/null) || PORT=3100
|
|
28
|
-
|
|
29
38
|
# Phase 1: POST question to server
|
|
30
39
|
RESPONSE=$(curl -s -f -X POST "http://127.0.0.1:${PORT}/ask" \
|
|
31
40
|
-H 'Content-Type: application/json' \
|
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
# permission-relay.sh - Claude Code PermissionRequest hook
|
|
3
3
|
# Implements two-phase long-poll to relay permission decisions via Slack channel server
|
|
4
4
|
|
|
5
|
-
# Guard: only relay for bot-managed sessions
|
|
6
|
-
if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
|
|
7
|
-
exit 0
|
|
8
|
-
fi
|
|
9
|
-
|
|
10
5
|
# Check dependencies
|
|
11
6
|
if ! command -v jq &>/dev/null; then
|
|
12
7
|
exit 0
|
|
@@ -15,6 +10,22 @@ if ! command -v curl &>/dev/null; then
|
|
|
15
10
|
exit 0
|
|
16
11
|
fi
|
|
17
12
|
|
|
13
|
+
# Read port from config.json, default to 3100
|
|
14
|
+
CONFIG_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json"
|
|
15
|
+
PORT=3100
|
|
16
|
+
if [ -f "$CONFIG_FILE" ]; then
|
|
17
|
+
ROUTED_PORT=$(jq -r '.port // empty' "$CONFIG_FILE" 2>/dev/null) || true
|
|
18
|
+
if [ -n "${ROUTED_PORT:-}" ]; then
|
|
19
|
+
PORT="$ROUTED_PORT"
|
|
20
|
+
fi
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Guard: only relay for bot-managed sessions (server PID check)
|
|
24
|
+
HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
|
|
25
|
+
if [ "$HTTP_STATUS" != "200" ]; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
18
29
|
# Read stdin
|
|
19
30
|
INPUT=$(cat)
|
|
20
31
|
|
|
@@ -23,16 +34,6 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
|
|
|
23
34
|
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || exit 0
|
|
24
35
|
CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0
|
|
25
36
|
|
|
26
|
-
# Read port from routing.json, default to 3100
|
|
27
|
-
ROUTING_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json"
|
|
28
|
-
PORT=3100
|
|
29
|
-
if [ -f "$ROUTING_FILE" ]; then
|
|
30
|
-
ROUTED_PORT=$(jq -r '.port // empty' "$ROUTING_FILE" 2>/dev/null) || true
|
|
31
|
-
if [ -n "${ROUTED_PORT:-}" ]; then
|
|
32
|
-
PORT="$ROUTED_PORT"
|
|
33
|
-
fi
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
37
|
BASE_URL="http://127.0.0.1:${PORT}"
|
|
37
38
|
|
|
38
39
|
# Phase 1 — Create permission request
|
package/package.json
CHANGED
|
@@ -33,7 +33,7 @@ message handling (ack reactions, chunking).
|
|
|
33
33
|
```bash
|
|
34
34
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
35
35
|
# access.json lives at $STATE_DIR/access.json
|
|
36
|
-
#
|
|
36
|
+
# config.json lives at $STATE_DIR/config.json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Always resolve the state directory using `$SLACK_STATE_DIR` with fallback to
|
|
@@ -102,12 +102,12 @@ Parse `$ARGUMENTS` and execute the matching subcommand:
|
|
|
102
102
|
|
|
103
103
|
### `status`
|
|
104
104
|
1. Load `access.json`
|
|
105
|
-
2. Load `
|
|
105
|
+
2. Load `config.json` from the same state directory
|
|
106
106
|
3. Display:
|
|
107
107
|
- DM policy
|
|
108
108
|
- Allowlisted user IDs
|
|
109
109
|
- Opted-in channels with their policies, showing two categories:
|
|
110
|
-
- **Implicit** — channels present in `
|
|
110
|
+
- **Implicit** — channels present in `config.json` routes (automatically opted-in)
|
|
111
111
|
- **Explicit** — channels configured in `access.json` channels (with their `requireMention` and `allowFrom` settings)
|
|
112
112
|
- Pending pairings (code + sender ID + expiry)
|
|
113
113
|
- Ack reaction setting (or "not set")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup-slack-channel-bots
|
|
3
|
-
description: Interactive setup wizard for claude-slack-channel-bots — checks tokens,
|
|
3
|
+
description: Interactive setup wizard for claude-slack-channel-bots — checks tokens, config.json, access.json, hooks, and Claude Code settings
|
|
4
4
|
version: 1.0.0
|
|
5
5
|
author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
6
6
|
license: MIT
|
|
@@ -122,14 +122,14 @@ want confirmation.
|
|
|
122
122
|
|
|
123
123
|
---
|
|
124
124
|
|
|
125
|
-
### Step 4 — Check
|
|
125
|
+
### Step 4 — Check config.json
|
|
126
126
|
|
|
127
127
|
State directory defaults to `~/.claude/channels/slack/`. Respect
|
|
128
128
|
`$SLACK_STATE_DIR` if set.
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
131
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
132
|
-
cat "$STATE_DIR/
|
|
132
|
+
cat "$STATE_DIR/config.json" 2>/dev/null || echo "NOT_FOUND"
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
**If the file does not exist:**
|
|
@@ -169,7 +169,7 @@ test -d "<expanded-path>" && echo "ok" || echo "not found"
|
|
|
169
169
|
Expand `~` before checking. If the directory does not exist, warn the user and
|
|
170
170
|
ask whether to proceed anyway or provide a different path.
|
|
171
171
|
|
|
172
|
-
After collecting at least one route, write the updated `
|
|
172
|
+
After collecting at least one route, write the updated `config.json`.
|
|
173
173
|
|
|
174
174
|
**Important:** Remind the user to invite the bot to each channel they configured.
|
|
175
175
|
In Slack, type `/invite @Claude Slack Channel Bots` in each channel (or whatever
|
|
@@ -210,7 +210,7 @@ either value:
|
|
|
210
210
|
|
|
211
211
|
**Validation before writing:**
|
|
212
212
|
|
|
213
|
-
Before writing `
|
|
213
|
+
Before writing `config.json`, verify that any value supplied for
|
|
214
214
|
`default_route` or `default_dm_session` exactly matches one of the `cwd`
|
|
215
215
|
values in `routes`. If a value does not match:
|
|
216
216
|
|
|
@@ -221,7 +221,7 @@ values in `routes`. If a value does not match:
|
|
|
221
221
|
|
|
222
222
|
Only write the field once a valid value is confirmed.
|
|
223
223
|
|
|
224
|
-
Write the final `
|
|
224
|
+
Write the final `config.json` with only the fields the user explicitly set
|
|
225
225
|
(plus the required `routes`). Do not write optional fields the user left at
|
|
226
226
|
their defaults unless asked.
|
|
227
227
|
|
|
@@ -230,7 +230,7 @@ their defaults unless asked.
|
|
|
230
230
|
### Step 5 — Configure custom system prompt for worker sessions
|
|
231
231
|
|
|
232
232
|
Worker sessions launched by the server can receive a custom system prompt
|
|
233
|
-
via `append_system_prompt_file` in `
|
|
233
|
+
via `append_system_prompt_file` in `config.json`. This controls how the
|
|
234
234
|
bots behave — their role, communication style, and capabilities.
|
|
235
235
|
|
|
236
236
|
**This is important.** Without a system prompt, bots won't know to communicate
|
|
@@ -275,7 +275,7 @@ Examples to offer if they're unsure:
|
|
|
275
275
|
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
276
276
|
```
|
|
277
277
|
|
|
278
|
-
3. Read `
|
|
278
|
+
3. Read `config.json`, add or update:
|
|
279
279
|
```json
|
|
280
280
|
"append_system_prompt_file": "~/.claude/channels/slack/system-prompt.md"
|
|
281
281
|
```
|
|
@@ -286,9 +286,22 @@ Examples to offer if they're unsure:
|
|
|
286
286
|
|
|
287
287
|
**If the user explicitly skips:**
|
|
288
288
|
|
|
289
|
-
Do not write `append_system_prompt_file` to `
|
|
289
|
+
Do not write `append_system_prompt_file` to `config.json`, but warn them
|
|
290
290
|
that bots won't know to communicate via Slack without a system prompt.
|
|
291
291
|
|
|
292
|
+
If the project's `CLAUDE.md` already contains all the instructions the bot
|
|
293
|
+
needs (including Slack communication directives), the user can opt out of a
|
|
294
|
+
separate prompt file by setting `system_prompt_mode: "none"` in `config.json`:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
"system_prompt_mode": "none"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
With this setting, `append_system_prompt_file` is ignored even if present, and
|
|
301
|
+
only `CLAUDE.md` is used. This is the recommended approach when the bot's
|
|
302
|
+
behavior is fully defined in the project's `CLAUDE.md` and a separate
|
|
303
|
+
`system-prompt.md` would be redundant.
|
|
304
|
+
|
|
292
305
|
---
|
|
293
306
|
|
|
294
307
|
### Step 6 — Check access.json
|
|
@@ -437,7 +450,7 @@ Print a final summary of what was checked and configured:
|
|
|
437
450
|
|
|
438
451
|
- Environment variables: set / missing
|
|
439
452
|
- Token format: valid / invalid
|
|
440
|
-
-
|
|
453
|
+
- config.json: populated (N routes) / skeleton
|
|
441
454
|
- append_system_prompt_file: configured / skipped
|
|
442
455
|
- access.json: present / missing
|
|
443
456
|
- permission-relay.sh hook: present and executable / missing
|
package/src/cli.ts
CHANGED
|
@@ -105,11 +105,11 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Check
|
|
108
|
+
// Check config.json exists
|
|
109
109
|
const stateDir = deps.resolveStateDir()
|
|
110
|
-
const routingJson = join(stateDir, '
|
|
110
|
+
const routingJson = join(stateDir, 'config.json')
|
|
111
111
|
if (!deps.existsSync(routingJson)) {
|
|
112
|
-
console.error(`missing prerequisite:
|
|
112
|
+
console.error(`missing prerequisite: config.json not found at ${routingJson}`)
|
|
113
113
|
deps.exit(1)
|
|
114
114
|
}
|
|
115
115
|
|
package/src/config.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { resolve } from 'path'
|
|
|
18
18
|
|
|
19
19
|
export const MCP_SERVER_NAME = 'slack-channel-router'
|
|
20
20
|
export const ALLOWED_PRESCRIPTIONS = ['gentle', 'standard', 'aggressive']
|
|
21
|
+
export const ALLOWED_SYSTEM_PROMPT_MODES = ['append', 'none']
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Types
|
|
@@ -27,7 +28,7 @@ export interface RouteEntry {
|
|
|
27
28
|
cwd: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/** Raw shape of
|
|
31
|
+
/** Raw shape of config.json as parsed from disk. All optional fields may be absent. */
|
|
31
32
|
export interface RoutingConfigInput {
|
|
32
33
|
routes: Record<string, RouteEntry>
|
|
33
34
|
/** CWD path to use when a message arrives on a channel with no explicit entry in routes. */
|
|
@@ -43,6 +44,7 @@ export interface RoutingConfigInput {
|
|
|
43
44
|
mcp_config_path?: string
|
|
44
45
|
append_system_prompt_file?: string
|
|
45
46
|
cozempic_prescription?: string
|
|
47
|
+
system_prompt_mode?: string
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** Validated, fully-resolved routing configuration with all defaults applied. */
|
|
@@ -59,6 +61,7 @@ export interface RoutingConfig {
|
|
|
59
61
|
mcp_config_path: string
|
|
60
62
|
append_system_prompt_file?: string
|
|
61
63
|
cozempic_prescription: string
|
|
64
|
+
system_prompt_mode: string
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// ---------------------------------------------------------------------------
|
|
@@ -83,6 +86,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
|
|
|
83
86
|
mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
|
|
84
87
|
append_system_prompt_file: input.append_system_prompt_file,
|
|
85
88
|
cozempic_prescription: input.cozempic_prescription ?? 'standard',
|
|
89
|
+
system_prompt_mode: input.system_prompt_mode ?? 'append',
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -163,6 +167,13 @@ export function validateConfig(config: RoutingConfig): void {
|
|
|
163
167
|
)
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
// system_prompt_mode must be one of the allowed values
|
|
171
|
+
if (!ALLOWED_SYSTEM_PROMPT_MODES.includes(config.system_prompt_mode)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Routing config validation error: system_prompt_mode "${config.system_prompt_mode}" is invalid. Allowed values are: ${ALLOWED_SYSTEM_PROMPT_MODES.join(', ')}.`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
// default_dm_session must reference an existing route CWD
|
|
167
178
|
if (config.default_dm_session !== undefined) {
|
|
168
179
|
if (!seen.has(config.default_dm_session)) {
|
|
@@ -213,14 +224,14 @@ export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
|
|
|
213
224
|
// I/O wrapper
|
|
214
225
|
// ---------------------------------------------------------------------------
|
|
215
226
|
|
|
216
|
-
const DEFAULT_CONFIG_PATH = '~/.claude/channels/slack/
|
|
227
|
+
const DEFAULT_CONFIG_PATH = '~/.claude/channels/slack/config.json'
|
|
217
228
|
|
|
218
229
|
/**
|
|
219
230
|
* Reads routing configuration from disk, parses it, and returns a validated
|
|
220
231
|
* RoutingConfig. Throws a descriptive error for missing files, malformed JSON,
|
|
221
232
|
* or validation failures.
|
|
222
233
|
*
|
|
223
|
-
* @param path Path to
|
|
234
|
+
* @param path Path to config.json. Defaults to ~/.claude/channels/slack/config.json.
|
|
224
235
|
*/
|
|
225
236
|
export function loadConfig(path?: string): RoutingConfig {
|
|
226
237
|
const configPath = resolve(expandTilde(path ?? DEFAULT_CONFIG_PATH))
|
package/src/lib.ts
CHANGED
|
@@ -179,7 +179,7 @@ export interface GateOptions {
|
|
|
179
179
|
/** Current bot user ID for mention detection */
|
|
180
180
|
botUserId: string
|
|
181
181
|
/**
|
|
182
|
-
* Set of channel IDs from
|
|
182
|
+
* Set of channel IDs from config.json. Any channel in this set is
|
|
183
183
|
* implicitly opted-in even if it has no entry in access.json's channels map.
|
|
184
184
|
* access.json entries still take precedence for per-channel overrides.
|
|
185
185
|
*/
|
|
@@ -244,7 +244,7 @@ export async function gate(event: unknown, opts: GateOptions): Promise<GateResul
|
|
|
244
244
|
// 5. Channel handling — opt-in per channel ID
|
|
245
245
|
//
|
|
246
246
|
// A channel is allowed if it has an explicit entry in access.json OR if it
|
|
247
|
-
// appears in
|
|
247
|
+
// appears in config.json (routeChannels). Channels in config.json are
|
|
248
248
|
// implicitly opted-in; access.json entries provide per-channel overrides
|
|
249
249
|
// (requireMention, allowFrom). If neither applies, drop.
|
|
250
250
|
const channel = ev['channel'] as string
|
package/src/postinstall.ts
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* postinstall.ts — Scaffold skeleton config files for the Slack Channel Router.
|
|
4
4
|
*
|
|
5
5
|
* Creates STATE_DIR and the MCP config parent if missing, then writes
|
|
6
|
-
*
|
|
6
|
+
* config.json, access.json, and slack-mcp.json only when they do not
|
|
7
7
|
* already exist. Safe to re-run: existing files are never modified.
|
|
8
|
+
* Migrates routing.json → config.json if the old file is present.
|
|
8
9
|
*
|
|
9
10
|
* SPDX-License-Identifier: MIT
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import { existsSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, unlinkSync } from 'fs'
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, unlinkSync, renameSync } from 'fs'
|
|
13
14
|
import { homedir } from 'os'
|
|
14
15
|
import { dirname, join, resolve } from 'path'
|
|
15
16
|
import { defaultAccess } from './lib.ts'
|
|
@@ -43,13 +44,20 @@ export function runPostinstall(options: PostinstallOptions = {}): void {
|
|
|
43
44
|
mkdirSync(stateDir, { recursive: true })
|
|
44
45
|
mkdirSync(dirname(mcpConfigPath), { recursive: true })
|
|
45
46
|
|
|
46
|
-
// routing.json
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// config.json — migrate from routing.json if needed
|
|
48
|
+
const configPath = join(stateDir, 'config.json')
|
|
49
|
+
const legacyPath = join(stateDir, 'routing.json')
|
|
50
|
+
if (existsSync(legacyPath) && !existsSync(configPath)) {
|
|
51
|
+
renameSync(legacyPath, configPath)
|
|
52
|
+
console.log(`Migrated routing.json → config.json`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create skeleton config.json if neither old nor new file exists
|
|
56
|
+
if (existsSync(configPath)) {
|
|
57
|
+
console.log(`skipped: ${configPath}`)
|
|
50
58
|
} else {
|
|
51
|
-
writeFileSync(
|
|
52
|
-
console.log(`created: ${
|
|
59
|
+
writeFileSync(configPath, JSON.stringify({ routes: {} }, null, 2) + '\n')
|
|
60
|
+
console.log(`created: ${configPath}`)
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
// access.json (permissions 0o600)
|
package/src/server.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
} from './lib.ts'
|
|
41
41
|
import { loadConfig, expandTilde, type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
|
|
42
42
|
import { readSessions, writeSessions, rotateSessions } from './sessions.ts'
|
|
43
|
-
import { defaultTmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
43
|
+
import { defaultTmuxClient, sessionName, isClaudeRunning, getClaudePid } from './tmux.ts'
|
|
44
44
|
import { startupSessionManager, launchSession } from './session-manager.ts'
|
|
45
45
|
import { cleanSession, getCozempicAvailable } from './cozempic.ts'
|
|
46
46
|
import {
|
|
@@ -912,7 +912,7 @@ process.on('SIGINT', () => { shutdown('SIGINT').catch(() => process.exit(1)) })
|
|
|
912
912
|
//
|
|
913
913
|
// All Claude Code sessions point to the same URL: http://<host>:<port>/mcp
|
|
914
914
|
// Route assignment happens after the MCP initialized notification when the
|
|
915
|
-
// server calls roots/list and matches the CWD against
|
|
915
|
+
// server calls roots/list and matches the CWD against config.json.
|
|
916
916
|
// ---------------------------------------------------------------------------
|
|
917
917
|
|
|
918
918
|
export async function main(): Promise<void> {
|
|
@@ -1003,6 +1003,7 @@ export async function main(): Promise<void> {
|
|
|
1003
1003
|
// Already decided — return immediately
|
|
1004
1004
|
const existingDecision = completedDecisions.get(pollRequestId)
|
|
1005
1005
|
if (existingDecision !== undefined) {
|
|
1006
|
+
completedDecisions.delete(pollRequestId)
|
|
1006
1007
|
return new Response(JSON.stringify({ status: 'decided', decision: existingDecision }), {
|
|
1007
1008
|
status: 200,
|
|
1008
1009
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1058,6 +1059,7 @@ export async function main(): Promise<void> {
|
|
|
1058
1059
|
})
|
|
1059
1060
|
}
|
|
1060
1061
|
|
|
1062
|
+
completedDecisions.delete(pollRequestId)
|
|
1061
1063
|
return new Response(JSON.stringify({ status: 'decided', decision }), {
|
|
1062
1064
|
status: 200,
|
|
1063
1065
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1169,6 +1171,7 @@ export async function main(): Promise<void> {
|
|
|
1169
1171
|
|
|
1170
1172
|
const existingAnswer = completedAnswers.get(pollRequestId)
|
|
1171
1173
|
if (existingAnswer !== undefined) {
|
|
1174
|
+
completedAnswers.delete(pollRequestId)
|
|
1172
1175
|
return new Response(JSON.stringify({ status: 'decided', answer: existingAnswer }), {
|
|
1173
1176
|
status: 200,
|
|
1174
1177
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1215,6 +1218,7 @@ export async function main(): Promise<void> {
|
|
|
1215
1218
|
})
|
|
1216
1219
|
|
|
1217
1220
|
if (answer !== null) {
|
|
1221
|
+
completedAnswers.delete(pollRequestId)
|
|
1218
1222
|
return new Response(JSON.stringify({ status: 'decided', answer }), {
|
|
1219
1223
|
status: 200,
|
|
1220
1224
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1319,6 +1323,44 @@ export async function main(): Promise<void> {
|
|
|
1319
1323
|
})
|
|
1320
1324
|
}
|
|
1321
1325
|
|
|
1326
|
+
// /is-managed — PID-based session membership check for hook guards
|
|
1327
|
+
if (url.pathname === '/is-managed') {
|
|
1328
|
+
if (req.method !== 'GET') {
|
|
1329
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
1330
|
+
}
|
|
1331
|
+
// Validate localhost
|
|
1332
|
+
const remoteAddr = server.requestIP(req)
|
|
1333
|
+
const remoteHost = remoteAddr?.address ?? ''
|
|
1334
|
+
if (remoteHost !== '127.0.0.1' && remoteHost !== '::1' && !remoteHost.startsWith('::ffff:127.')) {
|
|
1335
|
+
return new Response('Forbidden', { status: 403 })
|
|
1336
|
+
}
|
|
1337
|
+
const pidStr = url.searchParams.get('pid')
|
|
1338
|
+
const pid = pidStr ? parseInt(pidStr, 10) : NaN
|
|
1339
|
+
if (isNaN(pid) || pid <= 0) {
|
|
1340
|
+
return new Response(JSON.stringify({ error: 'Missing or invalid pid parameter' }), {
|
|
1341
|
+
status: 400,
|
|
1342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1343
|
+
})
|
|
1344
|
+
}
|
|
1345
|
+
// Check all configured routes
|
|
1346
|
+
if (routingConfig) {
|
|
1347
|
+
for (const [, route] of Object.entries(routingConfig.routes)) {
|
|
1348
|
+
const name = sessionName(route.cwd)
|
|
1349
|
+
const claudePid = await getClaudePid(name, defaultTmuxClient)
|
|
1350
|
+
if (claudePid === pid) {
|
|
1351
|
+
return new Response(JSON.stringify({ managed: true }), {
|
|
1352
|
+
status: 200,
|
|
1353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1354
|
+
})
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return new Response(JSON.stringify({ managed: false }), {
|
|
1359
|
+
status: 404,
|
|
1360
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1361
|
+
})
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1322
1364
|
// Only /mcp is the MCP endpoint — everything else is a 404
|
|
1323
1365
|
if (url.pathname !== '/mcp') {
|
|
1324
1366
|
return new Response(
|
package/src/session-manager.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
|
|
|
14
14
|
import { type CleanSessionFn, checkCozempicAvailable, getCozempicAvailable, cleanSession as defaultCleanSession, resolveJsonlPath } from './cozempic.ts'
|
|
15
15
|
import { type SessionsMap, type SessionRecord } from './sessions.ts'
|
|
16
16
|
import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
|
|
17
|
+
import { isDryRun } from './tokens.ts'
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// JSONL existence helper
|
|
@@ -78,9 +79,13 @@ export async function launchSession(
|
|
|
78
79
|
const resumeSessionId = options?.sessionId
|
|
79
80
|
|
|
80
81
|
const escapedConfigPath = routingConfig.mcp_config_path.replace(/'/g, "'\\''")
|
|
81
|
-
|
|
82
|
+
// In dry-run mode, skip --dangerously-load-development-channels (requires OAuth which isn't
|
|
83
|
+
// available in Docker/CI). MCP still connects via --mcp-config; only channel routing is lost.
|
|
84
|
+
let baseCmd = isDryRun()
|
|
85
|
+
? `claude --mcp-config '${escapedConfigPath}'`
|
|
86
|
+
: `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
|
|
82
87
|
|
|
83
|
-
if (routingConfig.append_system_prompt_file !== undefined) {
|
|
88
|
+
if (routingConfig.system_prompt_mode === 'append' && routingConfig.append_system_prompt_file !== undefined) {
|
|
84
89
|
try {
|
|
85
90
|
accessSync(routingConfig.append_system_prompt_file, constants.R_OK)
|
|
86
91
|
const escapedPromptPath = routingConfig.append_system_prompt_file.replace(/'/g, "'\\''")
|
|
@@ -147,6 +152,18 @@ export async function launchSession(
|
|
|
147
152
|
sessionId: safeResumeId ?? 'pending',
|
|
148
153
|
}
|
|
149
154
|
}
|
|
155
|
+
|
|
156
|
+
// If trust was pre-accepted (e.g. in Docker CI), Claude skips the safety
|
|
157
|
+
// prompt and goes straight to the ready prompt. Detect the Claude Code
|
|
158
|
+
// welcome banner which appears when Claude is fully loaded and ready.
|
|
159
|
+
if (pane.includes('Claude Code v') && pane.includes('❯')) {
|
|
160
|
+
console.error(`[slack] Claude ready (no safety prompt) in session: ${sessionName_}`)
|
|
161
|
+
return {
|
|
162
|
+
tmuxSession: sessionName_,
|
|
163
|
+
lastLaunch: new Date().toISOString(),
|
|
164
|
+
sessionId: safeResumeId ?? 'pending',
|
|
165
|
+
}
|
|
166
|
+
}
|
|
150
167
|
}
|
|
151
168
|
|
|
152
169
|
return null
|