claude-slack-channel-bots 0.1.0 → 0.1.2

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 CHANGED
@@ -105,6 +105,7 @@ A skeleton file is created by postinstall. Populate it before running `start`.
105
105
  | `session_restart_delay` | number | `60` | Seconds to wait before auto-restarting a dead session. Set to `0` to disable auto-restart. Must be non-negative. |
106
106
  | `health_check_interval` | number | `120` | Seconds between periodic liveness polls. Set to `0` to disable. Must be non-negative. |
107
107
  | `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
108
+ | `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. |
108
109
 
109
110
  ---
110
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Multi-session Slack-to-Claude bridge — run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,15 @@
1
+ # Role
2
+ You are an orchestration agent that bridges between the User and worker-Claude sessions you manage.
3
+
4
+ # Communication with the User
5
+ The User only communicate to you via Slack so always use the mcp__slack-channel-router__reply tool to send messages.
6
+ **Important**: Nothing you send to the TUI will be seen by the User
7
+
8
+ # Development process
9
+ You use the development process defined in the readme in ~/projects/apiary/README.MD.
10
+ You use the Apiary skills listed in that project to manage software development from ideation to completion.
11
+
12
+ # Spawning workers
13
+ You do not do any work yourself, you always spawn Claude sessions as workers to do the work.
14
+ This keeps you available to interact with the User and orchestrate the work.
15
+ You use `waggle` to spawn workers and monitor their status.
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: claude-slack-channels-config
3
+ description: Manage Slack channel bot access control — pairing, allowlist, channel opt-in, ack reactions, chunking
4
+ version: 1.0.0
5
+ author: Gabe Mahoney
6
+ license: MIT
7
+ user-invocable: true
8
+ argument-hint: "pair <code> | policy <mode> | add <user_id> | remove <user_id> | channel <id> [opts] | ack <emoji|off> | chunking <limit> [mode] | status"
9
+ allowed-tools: [Read, Write, Edit, Bash]
10
+ ---
11
+
12
+ # /claude-slack-channels-config
13
+
14
+ Manage who can reach Claude Code sessions through Slack, and configure
15
+ message handling (ack reactions, chunking).
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ /claude-slack-channels-config pair <code> # Approve a pending pairing
21
+ /claude-slack-channels-config policy <pairing|allowlist|disabled> # Set DM policy
22
+ /claude-slack-channels-config add <slack_user_id> # Add user to allowlist
23
+ /claude-slack-channels-config remove <slack_user_id> # Remove from allowlist
24
+ /claude-slack-channels-config channel <channel_id> [--mention] [--allow <user_id,...>] # Opt in a channel
25
+ /claude-slack-channels-config channel remove <channel_id> # Remove channel opt-in
26
+ /claude-slack-channels-config ack <emoji|off> # Set or clear ack reaction
27
+ /claude-slack-channels-config chunking <limit> [length|newline] # Set text chunk limit and mode
28
+ /claude-slack-channels-config status # Show current config
29
+ ```
30
+
31
+ ## State File
32
+
33
+ ```bash
34
+ STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
35
+ # access.json lives at $STATE_DIR/access.json
36
+ # routing.json lives at $STATE_DIR/routing.json
37
+ ```
38
+
39
+ Always resolve the state directory using `$SLACK_STATE_DIR` with fallback to
40
+ `~/.claude/channels/slack/`.
41
+
42
+ ## Instructions
43
+
44
+ Parse `$ARGUMENTS` and execute the matching subcommand:
45
+
46
+ ### `pair <code>`
47
+ 1. Load `access.json`
48
+ 2. Find the pending entry matching `<code>` (case-insensitive)
49
+ 3. If not found: show "No pending pairing with that code."
50
+ 4. If found:
51
+ - Add `entry.senderId` to `allowFrom`
52
+ - Remove the pending entry
53
+ - Save `access.json` with permissions 0o600
54
+ - Show: `Approved! User <senderId> can now DM this session.`
55
+ - Send a confirmation message to the user in Slack via the reply tool
56
+
57
+ ### `policy <mode>`
58
+ 1. Validate mode is one of: `pairing`, `allowlist`, `disabled`
59
+ 2. Update `dmPolicy` in `access.json`
60
+ 3. Save with 0o600
61
+ 4. Show the new policy and what it means:
62
+ - `pairing`: New DMs get a code to approve (default)
63
+ - `allowlist`: Only pre-approved users can DM
64
+ - `disabled`: No DMs accepted
65
+
66
+ ### `add <user_id>`
67
+ 1. Add the Slack user ID to `allowFrom` (deduplicate)
68
+ 2. Save with 0o600
69
+ 3. Show confirmation
70
+
71
+ ### `remove <user_id>`
72
+ 1. Remove from `allowFrom`
73
+ 2. Also remove from any channel-level `allowFrom` lists
74
+ 3. Save with 0o600
75
+ 4. Show confirmation
76
+
77
+ ### `channel <channel_id> [--mention] [--allow <ids>]`
78
+ 1. Parse options:
79
+ - `--mention`: require @mention to trigger (default: false)
80
+ - `--allow <id1,id2>`: restrict to specific users in that channel
81
+ 2. Add/update `channels[channel_id]` in `access.json`
82
+ 3. Save with 0o600
83
+ 4. Show the channel policy
84
+
85
+ ### `channel remove <channel_id>`
86
+ 1. Delete `channels[channel_id]`
87
+ 2. Save with 0o600
88
+ 3. Show confirmation
89
+
90
+ ### `ack <emoji|off>`
91
+ 1. If argument is `off`: remove `ackReaction` from `access.json`
92
+ 2. Otherwise: set `ackReaction` to the provided emoji name (without colons)
93
+ 3. Save with 0o600
94
+ 4. Show confirmation: "Ack reaction set to :<emoji>:" or "Ack reaction disabled."
95
+
96
+ ### `chunking <limit> [length|newline]`
97
+ 1. Parse `<limit>` as a positive integer — this sets `textChunkLimit`
98
+ 2. If a second argument is provided, validate it is `length` or `newline` — this sets `chunkMode`
99
+ 3. If no second argument, leave `chunkMode` unchanged
100
+ 4. Save with 0o600
101
+ 5. Show confirmation with the new values
102
+
103
+ ### `status`
104
+ 1. Load `access.json`
105
+ 2. Load `routing.json` from the same state directory
106
+ 3. Display:
107
+ - DM policy
108
+ - Allowlisted user IDs
109
+ - Opted-in channels with their policies, showing two categories:
110
+ - **Implicit** — channels present in `routing.json` routes (automatically opted-in)
111
+ - **Explicit** — channels configured in `access.json` channels (with their `requireMention` and `allowFrom` settings)
112
+ - Pending pairings (code + sender ID + expiry)
113
+ - Ack reaction setting (or "not set")
114
+ - Text chunk limit (or "not set, default: 4000")
115
+ - Chunk mode (or "not set, default: newline")
116
+
117
+ ## Security
118
+
119
+ - Always use atomic writes (write to .tmp then rename) for `access.json`
120
+ - Always set 0o600 permissions on `access.json`
121
+ - If `access.json` is corrupt, move it aside and start fresh
@@ -225,7 +225,49 @@ their defaults unless asked.
225
225
 
226
226
  ---
227
227
 
228
- ### Step 5 — Check access.json
228
+ ### Step 5 — Configure custom CLAUDE.md (optional)
229
+
230
+ Worker sessions launched by the server can receive a custom system-prompt
231
+ append via `append_system_prompt_file` in `routing.json`. This is useful for
232
+ giving all workers project-specific instructions: communication rules,
233
+ development process, how to spawn sub-workers, and so on.
234
+
235
+ An example template is included with the package. Show the operator where it
236
+ lives:
237
+
238
+ ```bash
239
+ ls "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
240
+ || echo "NOT_FOUND"
241
+ ```
242
+
243
+ If the file is found, print its path so the operator can inspect it as a
244
+ starting point.
245
+
246
+ Ask the operator: **"Do you want to configure a custom CLAUDE.md file for
247
+ worker sessions?"**
248
+
249
+ **If yes:**
250
+
251
+ 1. Prompt for the absolute path to their CLAUDE.md file.
252
+ 2. Verify the file exists:
253
+ ```bash
254
+ test -f "<provided-path>" && echo "ok" || echo "not found"
255
+ ```
256
+ Expand `~` before checking. If the file does not exist, warn the operator
257
+ and re-prompt until a valid path is given or they choose to skip.
258
+ 3. Read `routing.json`, add or update the top-level field:
259
+ ```json
260
+ "append_system_prompt_file": "<provided-path>"
261
+ ```
262
+ Write the updated file, preserving all other fields.
263
+
264
+ **If skipped:**
265
+
266
+ Do not write `append_system_prompt_file` to `routing.json`. Move on.
267
+
268
+ ---
269
+
270
+ ### Step 6 — Check access.json
229
271
 
230
272
  ```bash
231
273
  STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
@@ -264,7 +306,7 @@ Do not modify `access.json` during setup unless the user asks to.
264
306
 
265
307
  ---
266
308
 
267
- ### Step 6 — Check hooks
309
+ ### Step 7 — Check hooks
268
310
 
269
311
  Check whether the relay hooks exist and are executable:
270
312
 
@@ -312,7 +354,7 @@ chmod +x ~/.claude/hooks/permission-relay.sh ~/.claude/hooks/ask-relay.sh
312
354
 
313
355
  ---
314
356
 
315
- ### Step 7 — Check Claude Code settings.json for hook entries
357
+ ### Step 8 — Check Claude Code settings.json for hook entries
316
358
 
317
359
  Read `~/.claude/settings.json` and check whether the `PermissionRequest` and
318
360
  `PreToolUse` hook entries for the relay scripts are present.
@@ -365,13 +407,14 @@ If the user agrees, make the targeted edits, preserving all existing content.
365
407
 
366
408
  ---
367
409
 
368
- ### Step 8 — Summary
410
+ ### Step 9 — Summary
369
411
 
370
412
  Print a final summary of what was checked and configured:
371
413
 
372
414
  - Environment variables: set / missing
373
415
  - Token format: valid / invalid
374
416
  - routing.json: populated (N routes) / skeleton
417
+ - append_system_prompt_file: configured / skipped
375
418
  - access.json: present / missing
376
419
  - permission-relay.sh hook: present and executable / missing
377
420
  - ask-relay.sh hook: present and executable / missing
package/src/config.ts CHANGED
@@ -38,6 +38,7 @@ export interface RoutingConfigInput {
38
38
  session_restart_delay?: number
39
39
  health_check_interval?: number
40
40
  mcp_config_path?: string
41
+ append_system_prompt_file?: string
41
42
  }
42
43
 
43
44
  /** Validated, fully-resolved routing configuration with all defaults applied. */
@@ -50,6 +51,7 @@ export interface RoutingConfig {
50
51
  session_restart_delay: number
51
52
  health_check_interval: number
52
53
  mcp_config_path: string
54
+ append_system_prompt_file?: string
53
55
  }
54
56
 
55
57
  // ---------------------------------------------------------------------------
@@ -70,6 +72,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
70
72
  session_restart_delay: input.session_restart_delay ?? 60,
71
73
  health_check_interval: input.health_check_interval ?? 120,
72
74
  mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
75
+ append_system_prompt_file: input.append_system_prompt_file,
73
76
  }
74
77
  }
75
78
 
@@ -166,6 +169,9 @@ export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
166
169
  ? resolve(expandTilde(withDefaults.default_dm_session))
167
170
  : undefined,
168
171
  mcp_config_path: resolve(expandTilde(withDefaults.mcp_config_path)),
172
+ append_system_prompt_file: withDefaults.append_system_prompt_file !== undefined
173
+ ? resolve(expandTilde(withDefaults.append_system_prompt_file))
174
+ : undefined,
169
175
  }
170
176
 
171
177
  validateConfig(config)
@@ -9,9 +9,9 @@
9
9
  * SPDX-License-Identifier: MIT
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, writeFileSync } from 'fs'
12
+ import { existsSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, unlinkSync } from 'fs'
13
13
  import { homedir } from 'os'
14
- import { dirname, join } from 'path'
14
+ import { dirname, join, resolve } from 'path'
15
15
  import { defaultAccess } from './lib.ts'
16
16
  import { MCP_SERVER_NAME } from './config.ts'
17
17
 
@@ -65,6 +65,36 @@ export function runPostinstall(options: PostinstallOptions = {}): void {
65
65
  console.log(`created: ${accessPath}`)
66
66
  }
67
67
 
68
+ // Symlink skills into ~/.claude/skills/
69
+ const skillsTarget = join(homedir(), '.claude', 'skills')
70
+ mkdirSync(skillsTarget, { recursive: true })
71
+
72
+ const skillNames = ['claude-slack-channels-config']
73
+ const packageSkillsDir = resolve(dirname(import.meta.filename), '..', 'skills')
74
+ for (const name of skillNames) {
75
+ const src = join(packageSkillsDir, name)
76
+ const dest = join(skillsTarget, name)
77
+ if (existsSync(src)) {
78
+ try {
79
+ // Remove stale symlink or directory if it points elsewhere
80
+ if (existsSync(dest)) {
81
+ try {
82
+ const current = readlinkSync(dest)
83
+ if (resolve(current) === resolve(src)) {
84
+ console.log(`skipped: ${dest} (already linked)`)
85
+ continue
86
+ }
87
+ } catch { /* not a symlink — remove it */ }
88
+ unlinkSync(dest)
89
+ }
90
+ symlinkSync(src, dest)
91
+ console.log(`linked: ${dest} -> ${src}`)
92
+ } catch (err) {
93
+ console.log(`warning: could not symlink ${name}: ${err}`)
94
+ }
95
+ }
96
+ }
97
+
68
98
  // slack-mcp.json
69
99
  if (existsSync(mcpConfigPath)) {
70
100
  console.log(`skipped: ${mcpConfigPath}`)
package/src/server.ts CHANGED
@@ -18,7 +18,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
18
18
  import { SocketModeClient } from '@slack/socket-mode'
19
19
  import { WebClient } from '@slack/web-api'
20
20
  import { homedir } from 'os'
21
- import { join, resolve } from 'path'
21
+ import { join, resolve, relative, isAbsolute } from 'path'
22
22
  import { fileURLToPath } from 'url'
23
23
  import {
24
24
  readFileSync,
@@ -73,6 +73,30 @@ import {
73
73
  // Re-export constants so they stay in one place (lib.ts)
74
74
  export { MAX_PENDING, MAX_PAIRING_REPLIES, PAIRING_EXPIRY_MS } from './lib.ts'
75
75
 
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Find the channel whose route CWD is the closest ancestor of (or equal to)
82
+ * the given absolute path. Returns undefined when no route matches.
83
+ */
84
+ function findChannelByCwd(absoluteCwd: string, routes: RoutingConfig['routes']): string | undefined {
85
+ let bestChannel: string | undefined
86
+ let bestLen = -1
87
+ for (const [channelId, route] of Object.entries(routes)) {
88
+ const routeCwd = resolve(expandTilde(route.cwd))
89
+ const rel = relative(routeCwd, absoluteCwd)
90
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
91
+ if (routeCwd.length > bestLen) {
92
+ bestLen = routeCwd.length
93
+ bestChannel = channelId
94
+ }
95
+ }
96
+ }
97
+ return bestChannel
98
+ }
99
+
76
100
  // ---------------------------------------------------------------------------
77
101
  // Constants
78
102
  // ---------------------------------------------------------------------------
@@ -1003,12 +1027,10 @@ export async function main(): Promise<void> {
1003
1027
  )
1004
1028
  }
1005
1029
 
1006
- // Normalize CWD and find matching channel
1030
+ // Find the most specific route whose CWD is an ancestor of (or equal to) the request CWD
1007
1031
  const normalizedCwd = resolve(expandTilde(cwd))
1008
1032
  const matchedChannelId = routingConfig
1009
- ? Object.entries(routingConfig.routes).find(
1010
- ([, route]) => resolve(expandTilde(route.cwd)) === normalizedCwd,
1011
- )?.[0]
1033
+ ? findChannelByCwd(normalizedCwd, routingConfig.routes)
1012
1034
  : undefined
1013
1035
 
1014
1036
  if (!matchedChannelId) {
@@ -1150,11 +1172,10 @@ export async function main(): Promise<void> {
1150
1172
  )
1151
1173
  }
1152
1174
 
1175
+ // Find the most specific route whose CWD is an ancestor of (or equal to) the request CWD
1153
1176
  const normalizedCwd = resolve(expandTilde(cwd as string))
1154
1177
  const matchedChannelId = routingConfig
1155
- ? Object.entries(routingConfig.routes).find(
1156
- ([, route]) => resolve(expandTilde(route.cwd)) === normalizedCwd,
1157
- )?.[0]
1178
+ ? findChannelByCwd(normalizedCwd, routingConfig.routes)
1158
1179
  : undefined
1159
1180
 
1160
1181
  if (!matchedChannelId) {
@@ -9,7 +9,7 @@
9
9
  * SPDX-License-Identifier: MIT
10
10
  */
11
11
 
12
- import { readdirSync, readFileSync } from 'fs'
12
+ import { readdirSync, readFileSync, accessSync, constants } from 'fs'
13
13
  import { join } from 'path'
14
14
  import { homedir } from 'os'
15
15
  import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
@@ -109,7 +109,17 @@ export async function launchSession(
109
109
  const resumeSessionId = options?.sessionId
110
110
 
111
111
  const escapedConfigPath = routingConfig.mcp_config_path.replace(/'/g, "'\\''")
112
- const baseCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
112
+ let baseCmd = `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
113
+
114
+ if (routingConfig.append_system_prompt_file !== undefined) {
115
+ try {
116
+ accessSync(routingConfig.append_system_prompt_file, constants.R_OK)
117
+ const escapedPromptPath = routingConfig.append_system_prompt_file.replace(/'/g, "'\\''")
118
+ baseCmd += ` --append-system-prompt-file '${escapedPromptPath}'`
119
+ } catch {
120
+ // file missing or unreadable — skip
121
+ }
122
+ }
113
123
 
114
124
  const POLL_START_MS = 500
115
125
  const POLL_CAP_MS = 5_000