@rokrokss/claude-slack-channel 0.1.0 → 0.2.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/CLAUDE.md CHANGED
@@ -12,6 +12,7 @@
12
12
  - `event.ts` — DM 판별, 스레드 해석, stale/empty 필터링
13
13
  - `resilience.ts` — 이벤트 중복 제거 (EventDeduplicator)
14
14
  - `permalink.ts` — Slack 퍼마링크 빌더
15
+ - `process.ts` — 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
15
16
  - `index.ts` — barrel re-export
16
17
  - `server.test.ts` — lib/ 테스트 (bun:test)
17
18
 
package/README.md CHANGED
@@ -37,15 +37,21 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
37
37
  ```
38
38
  main()
39
39
 
40
- ├── startSocketMode()
40
+ ├── hasChannelsFlag()
41
+ │ └── 부모 프로세스(Claude Code)의 command line에서
42
+ │ --dangerously-load-development-channels 또는 --channels 확인
43
+
44
+ ├── [플래그 있음] startSocketMode()
41
45
  │ ├── web.auth.test() → botUserId 확인
42
46
  │ │ └── 실패 ──► process.exit(1)
43
47
  │ └── socket.start() → Slack 이벤트 수신 시작
44
48
 
49
+ ├── [플래그 없음] Socket Mode 스킵 (Tools-only mode)
50
+
45
51
  └── mcp.connect(transport) → MCP stdio 연결
46
52
  ```
47
53
 
48
- Socket Mode는 항상 연결됩니다. Claude Code `--dangerously-load-development-channels` 플래그 없이 실행되면 channel notification을 무시하므로, 서버 측에서 별도 게이팅 없이도 안전합니다.
54
+ Socket Mode는 부모 프로세스(Claude Code)에 channels 플래그가 있을 때만 연결됩니다. 플래그 없이 실행된 세션은 도구만 제공하며 Socket Mode를 건드리지 않으므로, 기존 channels 세션의 연결이 보호됩니다.
49
55
 
50
56
  ## 인바운드 메시지 파이프라인
51
57
 
@@ -139,7 +145,8 @@ Claude (tool call)
139
145
  server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
140
146
  tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
141
147
  lib/
142
- gate.ts 접근 제어 (Access, GateOptions, gate, clientSupportsChannels)
148
+ gate.ts 접근 제어 (Access, GateOptions, gate)
149
+ process.ts 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
143
150
  security.ts 아웃바운드 게이트 (assertOutboundAllowed)
144
151
  formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
145
152
  audit.ts 감사 로그 (AuditEntry, auditLog)
package/bun.lock CHANGED
@@ -45,7 +45,7 @@
45
45
 
46
46
  "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
47
47
 
48
- "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
48
+ "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
49
49
 
50
50
  "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
51
51
 
@@ -199,7 +199,7 @@
199
199
 
200
200
  "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
201
201
 
202
- "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
202
+ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
203
203
 
204
204
  "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
205
205
 
@@ -255,7 +255,7 @@
255
255
 
256
256
  "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
257
257
 
258
- "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
258
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
259
259
 
260
260
  "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
261
261
 
package/lib/gate.ts CHANGED
@@ -18,16 +18,6 @@ export function defaultAccess(): Access {
18
18
  return { allowFrom: [] }
19
19
  }
20
20
 
21
- /**
22
- * Check if the MCP client supports the channel capability.
23
- */
24
- export function clientSupportsChannels(capabilities: Record<string, unknown> | undefined): boolean {
25
- if (!capabilities) return false
26
- const experimental = capabilities['experimental']
27
- if (!experimental || typeof experimental !== 'object') return false
28
- return 'claude/channel' in experimental
29
- }
30
-
31
21
  export function gate(event: unknown, opts: GateOptions): GateResult {
32
22
  const ev = event as Record<string, unknown>
33
23
 
package/lib/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from './audit.ts'
5
5
  export * from './event.ts'
6
6
  export * from './resilience.ts'
7
7
  export * from './permalink.ts'
8
+ export * from './process.ts'
package/lib/process.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { execSync } from 'child_process'
2
+
3
+ /**
4
+ * Check if a command line string contains channels-related flags.
5
+ */
6
+ export function hasChannelsFlag(commandLine?: string): boolean {
7
+ // Environment variable override — always wins
8
+ if (process.env['SLACK_FORCE_SOCKET_MODE'] === '1') return true
9
+
10
+ const cmd = commandLine ?? findAncestorClaudeCommand()
11
+ return (
12
+ cmd.includes('dangerously-load-development-channels') ||
13
+ /--channels\b/.test(cmd)
14
+ )
15
+ }
16
+
17
+ /**
18
+ * Walk up the process tree from ppid to find a `claude` ancestor.
19
+ * Returns its command line, or '' if not found.
20
+ * Stops after 5 levels to avoid runaway traversal.
21
+ * Works on macOS/Linux (ps) and Windows (wmic).
22
+ */
23
+ function findAncestorClaudeCommand(): string {
24
+ const isWindows = process.platform === 'win32'
25
+ try {
26
+ let pid = process.ppid
27
+ for (let i = 0; i < 5 && pid > 1; i++) {
28
+ const { ppid, cmd } = isWindows
29
+ ? getProcessInfoWindows(pid)
30
+ : getProcessInfoUnix(pid)
31
+ if (!cmd) break
32
+ if (/\bclaude\b/.test(cmd)) return cmd
33
+ pid = ppid
34
+ }
35
+ } catch { /* process query failure → return empty */ }
36
+ return ''
37
+ }
38
+
39
+ function getProcessInfoUnix(pid: number): { ppid: number; cmd: string } {
40
+ const line = execSync(`ps -o ppid=,command= -p ${pid}`, {
41
+ encoding: 'utf-8',
42
+ timeout: 3000,
43
+ }).trim()
44
+ const match = line.match(/^\s*(\d+)\s+(.+)$/)
45
+ if (!match) return { ppid: 0, cmd: '' }
46
+ return { ppid: Number(match[1]), cmd: match[2] }
47
+ }
48
+
49
+ function getProcessInfoWindows(pid: number): { ppid: number; cmd: string } {
50
+ const line = execSync(
51
+ `wmic process where ProcessId=${pid} get ParentProcessId,CommandLine /format:csv`,
52
+ { encoding: 'utf-8', timeout: 3000 },
53
+ ).trim()
54
+ // CSV format: Node,CommandLine,ParentProcessId
55
+ // First line is header, second line is data
56
+ const rows = line.split(/\r?\n/).filter(Boolean)
57
+ if (rows.length < 2) return { ppid: 0, cmd: '' }
58
+ const parts = rows[1].split(',')
59
+ if (parts.length < 3) return { ppid: 0, cmd: '' }
60
+ const cmd = parts.slice(1, -1).join(',') // CommandLine may contain commas
61
+ const ppid = Number(parts[parts.length - 1])
62
+ return { ppid, cmd }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokrokss/claude-slack-channel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Two-way Slack channel for Claude Code — Slack DM과 채널 멘션으로 Claude Code 세션과 대화",
5
5
  "type": "module",
6
6
  "bin": "./server.ts",
package/server.test.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  isStaleEvent,
15
15
  isEmptyMessage,
16
16
  EventDeduplicator,
17
- clientSupportsChannels,
17
+ hasChannelsFlag,
18
18
  type Access,
19
19
  type AuditEntry,
20
20
  type GateOptions,
@@ -668,55 +668,51 @@ describe('EventDeduplicator', () => {
668
668
  })
669
669
 
670
670
  // ---------------------------------------------------------------------------
671
- // clientSupportsChannels()
671
+ // hasChannelsFlag()
672
672
  // ---------------------------------------------------------------------------
673
673
 
674
- describe('clientSupportsChannels', () => {
675
- test('returns false for undefined capabilities', () => {
676
- expect(clientSupportsChannels(undefined)).toBe(false)
674
+ describe('hasChannelsFlag', () => {
675
+ test('returns true for --dangerously-load-development-channels', () => {
676
+ expect(hasChannelsFlag('claude --dangerously-load-development-channels server:slack-channel')).toBe(true)
677
677
  })
678
678
 
679
- test('returns false for empty capabilities', () => {
680
- expect(clientSupportsChannels({})).toBe(false)
679
+ test('returns true for --channels flag', () => {
680
+ expect(hasChannelsFlag('claude --channels plugin:telegram@anthropic')).toBe(true)
681
681
  })
682
682
 
683
- test('returns false when no experimental field', () => {
684
- expect(clientSupportsChannels({ tools: {} })).toBe(false)
683
+ test('returns false for plain claude', () => {
684
+ expect(hasChannelsFlag('claude')).toBe(false)
685
685
  })
686
686
 
687
- test('returns false when experimental is empty', () => {
688
- expect(clientSupportsChannels({ experimental: {} })).toBe(false)
687
+ test('returns false for other flags', () => {
688
+ expect(hasChannelsFlag('claude --allow-dangerously-skip-permissions')).toBe(false)
689
689
  })
690
690
 
691
- test('returns false when experimental has other keys but not claude/channel', () => {
692
- expect(clientSupportsChannels({
693
- experimental: { 'some/other': {} },
694
- })).toBe(false)
691
+ test('returns false for empty string', () => {
692
+ expect(hasChannelsFlag('')).toBe(false)
695
693
  })
696
694
 
697
- test('returns true when claude/channel is present', () => {
698
- expect(clientSupportsChannels({
699
- experimental: { 'claude/channel': {} },
700
- })).toBe(true)
695
+ test('returns true when flag is among multiple args', () => {
696
+ expect(hasChannelsFlag('claude --verbose --dangerously-load-development-channels server:slack-channel --model opus')).toBe(true)
701
697
  })
702
698
 
703
- test('returns true when claude/channel is present alongside other keys', () => {
704
- expect(clientSupportsChannels({
705
- experimental: {
706
- 'claude/channel': {},
707
- 'claude/channel/permission': {},
708
- },
709
- })).toBe(true)
699
+ test('does not match --channels as substring of another flag', () => {
700
+ expect(hasChannelsFlag('claude --channelsFoo bar')).toBe(false)
710
701
  })
711
702
 
712
- test('returns true even if claude/channel value is null', () => {
713
- expect(clientSupportsChannels({
714
- experimental: { 'claude/channel': null },
715
- })).toBe(true)
703
+ test('matches --channels=value syntax', () => {
704
+ expect(hasChannelsFlag('claude --channels=plugin:foo@bar')).toBe(true)
716
705
  })
717
706
 
718
- test('returns false when experimental is not an object', () => {
719
- expect(clientSupportsChannels({ experimental: 'string' })).toBe(false)
707
+ test('SLACK_FORCE_SOCKET_MODE=1 overrides detection', () => {
708
+ const prev = process.env['SLACK_FORCE_SOCKET_MODE']
709
+ try {
710
+ process.env['SLACK_FORCE_SOCKET_MODE'] = '1'
711
+ expect(hasChannelsFlag('')).toBe(true)
712
+ } finally {
713
+ if (prev === undefined) delete process.env['SLACK_FORCE_SOCKET_MODE']
714
+ else process.env['SLACK_FORCE_SOCKET_MODE'] = prev
715
+ }
720
716
  })
721
717
  })
722
718
 
package/server.ts CHANGED
@@ -17,6 +17,10 @@ import { join } from 'path'
17
17
  import {
18
18
  mkdirSync,
19
19
  appendFileSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ unlinkSync,
23
+ existsSync,
20
24
  } from 'fs'
21
25
  import { z } from 'zod'
22
26
  import {
@@ -29,6 +33,7 @@ import {
29
33
  isStaleEvent,
30
34
  isEmptyMessage,
31
35
  EventDeduplicator,
36
+ hasChannelsFlag,
32
37
  type Access,
33
38
  type GateResult,
34
39
  } from './lib/index.ts'
@@ -153,7 +158,7 @@ async function resolveUserName(userId: string): Promise<string> {
153
158
  // ---------------------------------------------------------------------------
154
159
 
155
160
  const mcp = new McpServer(
156
- { name: 'slack-channel', version: '0.1.0' },
161
+ { name: 'slack-channel', version: '0.2.0' },
157
162
  {
158
163
  capabilities: {
159
164
  experimental: {
@@ -374,7 +379,43 @@ socket.on('app_mention', async ({ event, ack }) => {
374
379
  // Startup
375
380
  // ---------------------------------------------------------------------------
376
381
 
382
+ const PID_FILE = join(STATE_DIR, 'socket.pid')
383
+
384
+ function killPreviousInstance(): void {
385
+ if (!existsSync(PID_FILE)) return
386
+ try {
387
+ const oldPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
388
+ if (isNaN(oldPid) || oldPid === process.pid) return
389
+ // Check if process is alive (signal 0 doesn't kill, just checks)
390
+ process.kill(oldPid, 0)
391
+ debugLog(`[slack] Killing previous Socket Mode instance (pid ${oldPid})`)
392
+ process.kill(oldPid, 'SIGTERM')
393
+ } catch {
394
+ // Process doesn't exist or no permission — safe to proceed
395
+ }
396
+ }
397
+
398
+ function writePidFile(): void {
399
+ writeFileSync(PID_FILE, String(process.pid), 'utf-8')
400
+ }
401
+
402
+ function cleanupPidFile(): void {
403
+ try {
404
+ if (existsSync(PID_FILE) && readFileSync(PID_FILE, 'utf-8').trim() === String(process.pid)) {
405
+ unlinkSync(PID_FILE)
406
+ }
407
+ } catch {}
408
+ }
409
+
410
+ process.on('exit', cleanupPidFile)
411
+ process.on('SIGTERM', () => { cleanupPidFile(); process.exit(0) })
412
+ process.on('SIGINT', () => { cleanupPidFile(); process.exit(0) })
413
+
377
414
  async function startSocketMode(): Promise<void> {
415
+ // Kill previous instance if still running
416
+ killPreviousInstance()
417
+ writePidFile()
418
+
378
419
  // Resolve bot's own user ID (for mention detection + self-filtering)
379
420
  try {
380
421
  const auth = await web.auth.test()
@@ -390,17 +431,15 @@ async function startSocketMode(): Promise<void> {
390
431
  }
391
432
 
392
433
  async function main(): Promise<void> {
393
- // Start Socket Mode (Slack WebSocket) — always connect regardless of client
394
- // channel capability. Claude Code determines channel support on its side via
395
- // --channels flag; the server cannot detect this from the MCP handshake.
396
- // If the client registered channel handlers, notifications are delivered;
397
- // otherwise they are silently ignored.
398
- await startSocketMode().catch((err) => {
399
- console.error('[slack] Socket Mode init failed:', err)
400
- process.exit(1)
401
- })
434
+ if (hasChannelsFlag()) {
435
+ await startSocketMode().catch((err) => {
436
+ console.error('[slack] Socket Mode init failed:', err)
437
+ process.exit(1)
438
+ })
439
+ } else {
440
+ debugLog('[slack] Socket Mode skipped — parent process has no channels flag. Tools-only mode.')
441
+ }
402
442
 
403
- // Connect MCP stdio (server ↔ Claude Code)
404
443
  const transport = new StdioServerTransport()
405
444
  await mcp.connect(transport)
406
445
  debugLog('[slack] MCP server running on stdio')