@interactive-inc/claude-funnel 0.4.0 → 0.7.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.
Files changed (39) hide show
  1. package/README.md +105 -5
  2. package/lib/api.ts +54 -0
  3. package/lib/funnel.ts +120 -33
  4. package/lib/modules/channels/funnel-channels.ts +5 -0
  5. package/lib/modules/claude/funnel-claude.ts +21 -9
  6. package/lib/modules/connectors/funnel-connector-stores.ts +32 -4
  7. package/lib/modules/connectors/funnel-connectors.ts +6 -0
  8. package/lib/modules/connectors/funnel-discord-listener.ts +9 -3
  9. package/lib/modules/connectors/funnel-discord-store.ts +5 -1
  10. package/lib/modules/connectors/funnel-gh-listener.ts +14 -5
  11. package/lib/modules/connectors/funnel-gh-store.ts +18 -1
  12. package/lib/modules/connectors/funnel-schedule-listener.ts +8 -2
  13. package/lib/modules/connectors/funnel-schedule-store.ts +21 -4
  14. package/lib/modules/connectors/funnel-slack-listener.ts +8 -2
  15. package/lib/modules/connectors/funnel-slack-store.ts +5 -1
  16. package/lib/modules/connectors/migrate-legacy-connectors.ts +5 -1
  17. package/lib/modules/fs/funnel-file-system.ts +5 -0
  18. package/lib/modules/gateway/daemon.ts +10 -143
  19. package/lib/modules/gateway/funnel-gateway-server.ts +241 -0
  20. package/lib/modules/gateway/funnel-gateway.ts +49 -20
  21. package/lib/modules/gateway/kill-competing-slack-gateways.ts +5 -1
  22. package/lib/modules/id/funnel-id-generator.ts +7 -0
  23. package/lib/modules/id/memory-funnel-id-generator.ts +20 -0
  24. package/lib/modules/id/node-funnel-id-generator.ts +7 -0
  25. package/lib/modules/logger/funnel-logger.ts +11 -0
  26. package/lib/modules/logger/memory-funnel-logger.ts +28 -0
  27. package/lib/modules/logger/node-funnel-logger.ts +49 -0
  28. package/lib/modules/logger/noop-funnel-logger.ts +9 -0
  29. package/lib/modules/mcp/funnel-mcp.ts +5 -0
  30. package/lib/modules/process/funnel-process-runner.ts +5 -0
  31. package/lib/modules/profiles/funnel-profiles.ts +5 -0
  32. package/lib/modules/repos/funnel-repositories.ts +5 -0
  33. package/lib/modules/schedule/funnel-schedule.ts +5 -0
  34. package/lib/modules/time/funnel-clock.ts +15 -0
  35. package/lib/modules/time/memory-funnel-clock.ts +26 -0
  36. package/lib/modules/time/node-funnel-clock.ts +7 -0
  37. package/lib/routes/gateway/logs.ts +3 -1
  38. package/package.json +16 -5
  39. package/lib/modules/logger.ts +0 -26
package/README.md CHANGED
@@ -160,6 +160,106 @@ Connectors are stored per type, one file per connector:
160
160
  → ~/.funnel/connectors/<type>/<name>.(json|jsonl)
161
161
  ```
162
162
 
163
+ ## Programmable API (Bun)
164
+
165
+ `funnel` is also usable as a library — the same `Funnel` facade the CLI uses is exported from the package root, with no CLI side effects.
166
+
167
+ ```ts
168
+ import {
169
+ Funnel,
170
+ FunnelSettingsStore,
171
+ } from "@interactive-inc/claude-funnel"
172
+
173
+ const funnel = new Funnel({ store: new FunnelSettingsStore() })
174
+
175
+ funnel.connectors.add({
176
+ type: "slack",
177
+ name: "my-slack",
178
+ botToken: "xoxb-...",
179
+ appToken: "xapp-...",
180
+ })
181
+
182
+ funnel.channels.add({ name: "inbox", connectors: ["my-slack"] })
183
+
184
+ for (const c of funnel.connectors.list()) console.log(c.type, c.name)
185
+ ```
186
+
187
+ All Funnel facets — `connectors` / `channels` / `profiles` / `repositories` / `schedule` / `gateway` / `mcp` / `claude` — are reachable from the same instance:
188
+
189
+ ```ts
190
+ funnel.gateway.getStatus() // { running, pid, port }
191
+ await funnel.gateway.start() // spawns the daemon as a separate process
192
+ await funnel.claude.launch({ channel: "inbox" })
193
+ funnel.mcp.install("/path/to/repo") // writes .mcp.json
194
+ ```
195
+
196
+ Or run the gateway in-process (no daemon spawn — useful for tests, embedding, or custom hosts):
197
+
198
+ ```ts
199
+ const server = funnel.gatewayServer({ port: 9742 })
200
+ await server.start() // starts Bun.serve, boots all connector listeners
201
+ server.getStatus() // { clients, channels: [...] }
202
+ server.getBroadcaster().broadcast("hello", { connector: "my-slack" })
203
+ server.stop()
204
+ ```
205
+
206
+ Every side-effecting boundary is a DI seam. For tests / sandbox use, swap them all with the in-memory implementations and Funnel will not touch real disk, real processes, real time, or real UUIDs:
207
+
208
+ ```ts
209
+ import {
210
+ Funnel,
211
+ MemoryFunnelClock,
212
+ MemoryFunnelFileSystem,
213
+ MemoryFunnelIdGenerator,
214
+ MemoryFunnelLogger,
215
+ MemoryFunnelProcessRunner,
216
+ MockFunnelSettingsReader,
217
+ } from "@interactive-inc/claude-funnel"
218
+
219
+ const funnel = new Funnel({
220
+ store: new MockFunnelSettingsReader(),
221
+ fs: new MemoryFunnelFileSystem(),
222
+ process: new MemoryFunnelProcessRunner(),
223
+ logger: new MemoryFunnelLogger(),
224
+ clock: new MemoryFunnelClock({ start: new Date("2026-01-01T00:00:00Z") }),
225
+ idGenerator: new MemoryFunnelIdGenerator({ prefix: "test" }),
226
+ dir: "/sandbox/.funnel",
227
+ tmpDir: "/sandbox/tmp",
228
+ })
229
+ ```
230
+
231
+ Available abstractions (each has `Funnel*` interface, `Node*` default, and `Memory*` for tests): `FunnelFileSystem`, `FunnelProcessRunner`, `FunnelLogger`, `FunnelClock`, `FunnelIdGenerator`. Plus `NoopFunnelLogger` for silent operation.
232
+
233
+ The package ships TypeScript sources directly, so a Bun runtime is required. Importing `@interactive-inc/claude-funnel/cli` resolves to the CLI entry point (with side effects) — only do this if you're embedding the CLI rather than the library.
234
+
235
+ ## Claude Code skill
236
+
237
+ This repo ships a Claude Code skill at `.claude/skills/funnel/SKILL.md`. It briefs Claude on the architecture and command groups, and tells it to defer flag-level details to `funnel <command> --help`.
238
+
239
+ ### Project-scoped (auto)
240
+
241
+ If you run `claude` inside this repo, the skill is picked up automatically — no install step.
242
+
243
+ ### Global (use the skill in any project)
244
+
245
+ Claude Code does not currently provide a CLI to install skills from a remote URL, so copy the file into your personal skills directory:
246
+
247
+ ```bash
248
+ # from a clone of this repo
249
+ mkdir -p ~/.claude/skills/funnel
250
+ cp .claude/skills/funnel/SKILL.md ~/.claude/skills/funnel/
251
+ ```
252
+
253
+ Or fetch it directly without cloning:
254
+
255
+ ```bash
256
+ mkdir -p ~/.claude/skills/funnel
257
+ curl -fsSL https://raw.githubusercontent.com/interactive-inc/open-claude-funnel/main/.claude/skills/funnel/SKILL.md \
258
+ -o ~/.claude/skills/funnel/SKILL.md
259
+ ```
260
+
261
+ After this, Claude Code will load the skill in any session.
262
+
163
263
  ## Discord bot setup
164
264
 
165
265
  - Create a bot in the Discord Developer Portal and obtain its token
@@ -187,15 +287,15 @@ Connectors are stored per type, one file per connector:
187
287
 
188
288
  ## Links
189
289
 
190
- - [GitHub](https://github.com/interactive-inc/claude-funnel)
191
- - [Issues](https://github.com/interactive-inc/claude-funnel/issues)
192
- - Coding rules and design principles: [CLAUDE.md](https://github.com/interactive-inc/claude-funnel/blob/main/CLAUDE.md)
193
- - Design notes: [`.docs/`](https://github.com/interactive-inc/claude-funnel/tree/main/.docs)
290
+ - [GitHub](https://github.com/interactive-inc/open-claude-funnel)
291
+ - [Issues](https://github.com/interactive-inc/open-claude-funnel/issues)
292
+ - Coding rules and design principles: [CLAUDE.md](https://github.com/interactive-inc/open-claude-funnel/blob/main/CLAUDE.md)
293
+ - Design notes: [`.docs/`](https://github.com/interactive-inc/open-claude-funnel/tree/main/.docs)
194
294
 
195
295
  ## Development
196
296
 
197
297
  ```bash
198
- git clone https://github.com/interactive-inc/claude-funnel.git
298
+ git clone https://github.com/interactive-inc/open-claude-funnel.git
199
299
  cd claude-funnel
200
300
  bun install
201
301
  bun link # register funnel / fnl globally
package/lib/api.ts ADDED
@@ -0,0 +1,54 @@
1
+ export { Funnel } from "@/funnel"
2
+
3
+ export { FunnelChannels } from "@/modules/channels/funnel-channels"
4
+ export { FunnelClaude } from "@/modules/claude/funnel-claude"
5
+ export { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
6
+ export {
7
+ type ConnectorStoresBundle,
8
+ createConnectorStores,
9
+ } from "@/modules/connectors/funnel-connector-stores"
10
+ export { FunnelGateway } from "@/modules/gateway/funnel-gateway"
11
+ export { FunnelGatewayServer } from "@/modules/gateway/funnel-gateway-server"
12
+ export { FunnelBroadcaster } from "@/modules/gateway/funnel-broadcaster"
13
+ export { FunnelEventLogger } from "@/modules/gateway/funnel-event-logger"
14
+ export { FunnelMcp } from "@/modules/mcp/funnel-mcp"
15
+ export { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
16
+ export { FunnelRepositories } from "@/modules/repos/funnel-repositories"
17
+ export { FunnelSchedule } from "@/modules/schedule/funnel-schedule"
18
+
19
+ export { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
20
+ export { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
21
+ export { MemoryFunnelFileSystem } from "@/modules/fs/memory-funnel-file-system"
22
+
23
+ export { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
24
+ export { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
25
+ export { MemoryFunnelProcessRunner } from "@/modules/process/memory-funnel-process-runner"
26
+
27
+ export { FunnelLogger } from "@/modules/logger/funnel-logger"
28
+ export { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
29
+ export { MemoryFunnelLogger, type LogEntry } from "@/modules/logger/memory-funnel-logger"
30
+ export { NoopFunnelLogger } from "@/modules/logger/noop-funnel-logger"
31
+
32
+ export { FunnelClock } from "@/modules/time/funnel-clock"
33
+ export { NodeFunnelClock } from "@/modules/time/node-funnel-clock"
34
+ export { MemoryFunnelClock } from "@/modules/time/memory-funnel-clock"
35
+
36
+ export { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
37
+ export { NodeFunnelIdGenerator } from "@/modules/id/node-funnel-id-generator"
38
+ export { MemoryFunnelIdGenerator } from "@/modules/id/memory-funnel-id-generator"
39
+
40
+ export { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
41
+ export { FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
42
+ export { MockFunnelSettingsReader } from "@/modules/settings/mock-funnel-settings-reader"
43
+
44
+ export type { ConnectorConfig } from "@/modules/connectors/connector-config-schema"
45
+ export type {
46
+ ChannelConfig,
47
+ ProfileConfig,
48
+ RepositoryConfig,
49
+ Settings,
50
+ } from "@/modules/settings/settings-schema"
51
+ export type {
52
+ ScheduleConnectorConfig,
53
+ ScheduleEntry,
54
+ } from "@/modules/connectors/schedule-connector-schema"
package/lib/funnel.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path"
1
2
  import { FunnelChannels } from "@/modules/channels/funnel-channels"
2
3
  import { FunnelClaude } from "@/modules/claude/funnel-claude"
3
4
  import {
@@ -7,89 +8,175 @@ import {
7
8
  import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
8
9
  import type { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
9
10
  import { FunnelGateway } from "@/modules/gateway/funnel-gateway"
11
+ import { FunnelGatewayServer } from "@/modules/gateway/funnel-gateway-server"
12
+ import type { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
13
+ import type { FunnelLogger } from "@/modules/logger/funnel-logger"
10
14
  import { FunnelMcp } from "@/modules/mcp/funnel-mcp"
15
+ import type { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
11
16
  import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
12
17
  import { FunnelRepositories } from "@/modules/repos/funnel-repositories"
13
18
  import { FunnelSchedule } from "@/modules/schedule/funnel-schedule"
14
19
  import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
20
+ import { FUNNEL_DIR, FunnelSettingsStore } from "@/modules/settings/funnel-settings-store"
21
+ import type { FunnelClock } from "@/modules/time/funnel-clock"
15
22
 
16
23
  type Props = {
17
- store: FunnelSettingsReader
24
+ /** Settings persistence (channels / repositories / profiles). Defaults to a FunnelSettingsStore rooted at `dir`. */
25
+ store?: FunnelSettingsReader
26
+ /** Filesystem boundary. Replace with MemoryFunnelFileSystem to sandbox all disk I/O. */
18
27
  fs?: FunnelFileSystem
28
+ /** Process runner used by gateway / claude / gh listener. Replace with MemoryFunnelProcessRunner for tests. */
29
+ process?: FunnelProcessRunner
30
+ /** Logger flowed into every facet. Replace with MemoryFunnelLogger or NoopFunnelLogger to silence/inspect. */
31
+ logger?: FunnelLogger
32
+ /** Clock used by schedule listener, gh poll watermarks, and gateway timeouts. */
33
+ clock?: FunnelClock
34
+ /** ID generator for schedule entry ids. Use MemoryFunnelIdGenerator for deterministic tests. */
35
+ idGenerator?: FunnelIdGenerator
36
+ /** Funnel home directory (settings.json + connectors/<type>/). Defaults to ~/.funnel. */
19
37
  dir?: string
38
+ /** Temp / runtime directory (gateway logs and PID adjacent files). Defaults to /tmp/funnel. */
39
+ tmpDir?: string
40
+ /** Pre-built connector stores. Useful when sharing stores between multiple Funnel instances. */
20
41
  connectorStores?: ConnectorStoresBundle
21
42
  }
22
43
 
44
+ /**
45
+ * Facade exposing every funnel facet as a getter.
46
+ *
47
+ * The same `Funnel` is used by the CLI, the TUI, and as a programmable library.
48
+ * All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
49
+ * injectable via `Props` — passing memory implementations gives a fully sandboxed
50
+ * Funnel that touches no real disk, processes, or wall-clock time.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const funnel = new Funnel({})
55
+ * funnel.connectors.add({ type: "slack", name: "ops", botToken, appToken })
56
+ * funnel.channels.add({ name: "inbox", connectors: ["ops"] })
57
+ * await funnel.gatewayServer({ port: 9742 }).start()
58
+ * ```
59
+ */
23
60
  export class Funnel {
24
- constructor(private readonly props: Props) {
61
+ constructor(private readonly props: Props = {}) {
25
62
  Object.freeze(this)
26
63
  }
27
64
 
65
+ /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
66
+ get store(): FunnelSettingsReader {
67
+ return (
68
+ this.props.store ??
69
+ new FunnelSettingsStore({
70
+ path: join(this.props.dir ?? FUNNEL_DIR, "settings.json"),
71
+ fs: this.props.fs,
72
+ })
73
+ )
74
+ }
75
+
76
+ /** Per-type connector stores (slack / gh / discord / schedule), all DI'd from Props. */
28
77
  get stores(): ConnectorStoresBundle {
29
78
  return (
30
79
  this.props.connectorStores ??
31
- createConnectorStores({ fs: this.props.fs, dir: this.props.dir })
80
+ createConnectorStores({
81
+ fs: this.props.fs,
82
+ process: this.props.process,
83
+ logger: this.props.logger,
84
+ clock: this.props.clock,
85
+ idGenerator: this.props.idGenerator,
86
+ dir: this.props.dir,
87
+ })
32
88
  )
33
89
  }
34
90
 
91
+ /** Connector CRUD + per-type call/listener APIs. Wired with channels in a forward-const closure. */
35
92
  get connectors(): FunnelConnectors {
36
- const stores = this.stores
37
- const profiles = this.profiles
38
- const channels: FunnelChannels = new FunnelChannels({
39
- store: this.props.store,
40
- connectorChecker: { has: (name) => connectors.has(name) },
41
- profileChecker: profiles,
42
- profileRefUpdater: profiles,
43
- })
44
- const connectors: FunnelConnectors = new FunnelConnectors({
45
- ...stores,
46
- refUpdater: channels,
47
- })
48
- return connectors
93
+ return this.wirePair().connectors
49
94
  }
50
95
 
96
+ /** Channel CRUD + connector attach/detach. Wired with connectors and profiles via DI. */
51
97
  get channels(): FunnelChannels {
52
- const stores = this.stores
53
- const profiles = this.profiles
54
- const channels: FunnelChannels = new FunnelChannels({
55
- store: this.props.store,
56
- connectorChecker: { has: (name) => connectors.has(name) },
57
- profileChecker: profiles,
58
- profileRefUpdater: profiles,
59
- })
60
- const connectors: FunnelConnectors = new FunnelConnectors({
61
- ...stores,
62
- refUpdater: channels,
63
- })
64
- return channels
98
+ return this.wirePair().channels
65
99
  }
66
100
 
101
+ /** Schedule connector entry CRUD (cron lines). */
67
102
  get schedule(): FunnelSchedule {
68
103
  return new FunnelSchedule({ store: this.stores.schedule })
69
104
  }
70
105
 
106
+ /** Launch profiles (named presets for `fnl claude`). */
71
107
  get profiles(): FunnelProfiles {
72
- return new FunnelProfiles({ store: this.props.store })
108
+ return new FunnelProfiles({ store: this.store })
73
109
  }
74
110
 
111
+ /** Repository registry; writes the funnel MCP entry into each repo's .mcp.json. */
75
112
  get repositories(): FunnelRepositories {
76
- return new FunnelRepositories({ store: this.props.store, mcp: this.mcp })
113
+ return new FunnelRepositories({ store: this.store, mcp: this.mcp })
77
114
  }
78
115
 
116
+ /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
79
117
  get claude(): FunnelClaude {
80
118
  return new FunnelClaude({
81
119
  channels: this.channels,
82
120
  repositories: this.repositories,
83
121
  mcp: this.mcp,
84
122
  gateway: this.gateway,
123
+ fs: this.props.fs,
124
+ process: this.props.process,
125
+ logger: this.props.logger,
126
+ dir: this.props.dir,
85
127
  })
86
128
  }
87
129
 
130
+ /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
88
131
  get gateway(): FunnelGateway {
89
- return new FunnelGateway()
132
+ return new FunnelGateway({
133
+ fs: this.props.fs,
134
+ process: this.props.process,
135
+ clock: this.props.clock,
136
+ dir: this.props.dir,
137
+ tmpDir: this.props.tmpDir,
138
+ })
90
139
  }
91
140
 
141
+ /**
142
+ * In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
143
+ * this returns a class that runs `Bun.serve` + listeners inside the current process —
144
+ * useful for tests, embedding, or custom hosts.
145
+ */
146
+ gatewayServer(
147
+ options: { port?: number; logDir?: string; killCompetingSlack?: boolean } = {},
148
+ ): FunnelGatewayServer {
149
+ return new FunnelGatewayServer({
150
+ connectors: this.connectors,
151
+ settings: this.store,
152
+ port: options.port,
153
+ logDir: options.logDir,
154
+ fs: this.props.fs,
155
+ process: this.props.process,
156
+ clock: this.props.clock,
157
+ logger: this.props.logger,
158
+ killCompetingSlack: options.killCompetingSlack,
159
+ })
160
+ }
161
+
162
+ /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
92
163
  get mcp(): FunnelMcp {
93
- return new FunnelMcp()
164
+ return new FunnelMcp({ fs: this.props.fs })
165
+ }
166
+
167
+ private wirePair(): { channels: FunnelChannels; connectors: FunnelConnectors } {
168
+ const stores = this.stores
169
+ const profiles = this.profiles
170
+ const channels: FunnelChannels = new FunnelChannels({
171
+ store: this.store,
172
+ connectorChecker: { has: (name) => connectors.has(name) },
173
+ profileChecker: profiles,
174
+ profileRefUpdater: profiles,
175
+ })
176
+ const connectors: FunnelConnectors = new FunnelConnectors({
177
+ ...stores,
178
+ refUpdater: channels,
179
+ })
180
+ return { channels, connectors }
94
181
  }
95
182
  }
@@ -11,6 +11,11 @@ type Deps = {
11
11
  profileRefUpdater: ProfileChannelRefUpdater
12
12
  }
13
13
 
14
+ /**
15
+ * Subscription boxes that fan connector events out to Claude Code sessions.
16
+ * A channel attaches one or more connectors; the gateway's WebSocket clients
17
+ * subscribe by channel name. Name changes propagate to profile channel refs.
18
+ */
14
19
  export class FunnelChannels {
15
20
  private readonly store: FunnelSettingsReader
16
21
  private readonly connectorChecker: ConnectorExistenceChecker
@@ -3,15 +3,14 @@ import type { FunnelChannels } from "@/modules/channels/funnel-channels"
3
3
  import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
4
4
  import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
5
5
  import type { FunnelGateway } from "@/modules/gateway/funnel-gateway"
6
- import { logger } from "@/modules/logger"
6
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
7
+ import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
7
8
  import type { FunnelMcp } from "@/modules/mcp/funnel-mcp"
8
9
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
9
10
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
10
11
  import type { FunnelRepositories } from "@/modules/repos/funnel-repositories"
11
12
  import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
12
13
 
13
- const CLAUDE_PID_DIR = join(FUNNEL_DIR, "claude")
14
-
15
14
  export type LaunchOptions = {
16
15
  channel: string
17
16
  repo?: string
@@ -28,11 +27,20 @@ type Deps = {
28
27
  gateway: FunnelGateway
29
28
  process?: FunnelProcessRunner
30
29
  fs?: FunnelFileSystem
30
+ logger?: FunnelLogger
31
+ dir?: string
31
32
  }
32
33
 
33
34
  const defaultProcess = new NodeFunnelProcessRunner()
34
35
  const defaultFs = new NodeFunnelFileSystem()
35
-
36
+ const defaultLogger = new NodeFunnelLogger()
37
+
38
+ /**
39
+ * Launches Claude Code with funnel pre-wired: ensures the gateway is running,
40
+ * installs the funnel MCP into the target repo's `.mcp.json` if missing,
41
+ * injects `FUNNEL_CHANNEL_ID` into the child env, and writes a per-profile
42
+ * PID file to enforce singleton launches.
43
+ */
36
44
  export class FunnelClaude {
37
45
  private readonly channels: FunnelChannels
38
46
  private readonly repositories: FunnelRepositories
@@ -40,6 +48,8 @@ export class FunnelClaude {
40
48
  private readonly gateway: FunnelGateway
41
49
  private readonly process: FunnelProcessRunner
42
50
  private readonly fs: FunnelFileSystem
51
+ private readonly logger: FunnelLogger
52
+ private readonly pidDir: string
43
53
 
44
54
  constructor(deps: Deps) {
45
55
  this.channels = deps.channels
@@ -48,6 +58,8 @@ export class FunnelClaude {
48
58
  this.gateway = deps.gateway
49
59
  this.process = deps.process ?? defaultProcess
50
60
  this.fs = deps.fs ?? defaultFs
61
+ this.logger = deps.logger ?? defaultLogger
62
+ this.pidDir = join(deps.dir ?? FUNNEL_DIR, "claude")
51
63
  Object.freeze(this)
52
64
  }
53
65
 
@@ -69,11 +81,11 @@ export class FunnelClaude {
69
81
  if (!this.mcp.findInstalledName(cwd)) {
70
82
  this.mcp.install(cwd)
71
83
 
72
- logger.info(`added funnel MCP to .mcp.json`, { cwd })
84
+ this.logger.info(`added funnel MCP to .mcp.json`, { cwd })
73
85
  }
74
86
 
75
87
  if (!this.gateway.isRunning()) {
76
- logger.info(`starting gateway automatically`)
88
+ this.logger.info(`starting gateway automatically`)
77
89
  await this.gateway.start()
78
90
  }
79
91
 
@@ -85,7 +97,7 @@ export class FunnelClaude {
85
97
  const claudeArgs = this.buildArgs(options, cwd)
86
98
  const env = this.buildEnv(options, cwd)
87
99
 
88
- logger.info(`claude launch`, {
100
+ this.logger.info(`claude launch`, {
89
101
  channel: options.channel,
90
102
  repo: options.repo,
91
103
  subAgent: options.subAgent,
@@ -108,7 +120,7 @@ export class FunnelClaude {
108
120
  }
109
121
 
110
122
  private pidPath(profileName: string): string {
111
- return join(CLAUDE_PID_DIR, `${profileName}.pid`)
123
+ return join(this.pidDir, `${profileName}.pid`)
112
124
  }
113
125
 
114
126
  private readPid(profileName: string): number | null {
@@ -129,7 +141,7 @@ export class FunnelClaude {
129
141
  }
130
142
 
131
143
  private writePidFile(profileName: string): void {
132
- this.fs.mkdirSync(CLAUDE_PID_DIR, { recursive: true })
144
+ this.fs.mkdirSync(this.pidDir, { recursive: true })
133
145
  this.fs.writeFileSync(this.pidPath(profileName), String(globalThis.process.pid))
134
146
  }
135
147
 
@@ -3,6 +3,10 @@ import { FunnelGhStore } from "@/modules/connectors/funnel-gh-store"
3
3
  import { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
4
4
  import { FunnelSlackStore } from "@/modules/connectors/funnel-slack-store"
5
5
  import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
6
+ import { FunnelIdGenerator } from "@/modules/id/funnel-id-generator"
7
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
8
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
9
+ import { FunnelClock } from "@/modules/time/funnel-clock"
6
10
 
7
11
  export type ConnectorStoresBundle = {
8
12
  slack: FunnelSlackStore
@@ -13,12 +17,36 @@ export type ConnectorStoresBundle = {
13
17
 
14
18
  type Deps = {
15
19
  fs?: FunnelFileSystem
20
+ process?: FunnelProcessRunner
21
+ logger?: FunnelLogger
22
+ clock?: FunnelClock
23
+ idGenerator?: FunnelIdGenerator
16
24
  dir?: string
17
25
  }
18
26
 
19
27
  export const createConnectorStores = (deps: Deps = {}): ConnectorStoresBundle => ({
20
- slack: new FunnelSlackStore(deps),
21
- gh: new FunnelGhStore(deps),
22
- discord: new FunnelDiscordStore(deps),
23
- schedule: new FunnelScheduleStore(deps),
28
+ slack: new FunnelSlackStore({
29
+ fs: deps.fs,
30
+ dir: deps.dir,
31
+ logger: deps.logger,
32
+ }),
33
+ gh: new FunnelGhStore({
34
+ fs: deps.fs,
35
+ dir: deps.dir,
36
+ process: deps.process,
37
+ logger: deps.logger,
38
+ clock: deps.clock,
39
+ }),
40
+ discord: new FunnelDiscordStore({
41
+ fs: deps.fs,
42
+ dir: deps.dir,
43
+ logger: deps.logger,
44
+ }),
45
+ schedule: new FunnelScheduleStore({
46
+ fs: deps.fs,
47
+ dir: deps.dir,
48
+ logger: deps.logger,
49
+ idGenerator: deps.idGenerator,
50
+ clock: deps.clock,
51
+ }),
24
52
  })
@@ -18,6 +18,12 @@ type Deps = {
18
18
  refUpdater: ChannelConnectorRefUpdater
19
19
  }
20
20
 
21
+ /**
22
+ * Aggregates per-type connector stores (slack / gh / discord / schedule) behind a single facade.
23
+ * Add / remove / rename mutate the underlying type-specific store and propagate name changes
24
+ * to channel references via `refUpdater`. Per-type APIs (`updateSlack`, `callDiscord`, ...) keep
25
+ * field-level operations type-narrowed without runtime defense.
26
+ */
21
27
  export class FunnelConnectors {
22
28
  private readonly slack: FunnelSlackStore
23
29
  private readonly gh: FunnelGhStore
@@ -4,19 +4,25 @@ import {
4
4
  type NotifyFn,
5
5
  } from "@/modules/connectors/funnel-connector-listener"
6
6
  import { FunnelDiscordEventProcessor } from "@/modules/connectors/funnel-discord-event-processor"
7
- import { logger } from "@/modules/logger"
7
+ import { FunnelLogger } from "@/modules/logger/funnel-logger"
8
+ import { NodeFunnelLogger } from "@/modules/logger/node-funnel-logger"
8
9
  import type { DiscordConnectorConfig } from "@/modules/connectors/discord-connector-schema"
9
10
 
10
11
  type Deps = {
11
12
  config: DiscordConnectorConfig
13
+ logger?: FunnelLogger
12
14
  }
13
15
 
16
+ const defaultLogger = new NodeFunnelLogger()
17
+
14
18
  export class FunnelDiscordListener extends FunnelConnectorListener {
15
19
  private readonly config: DiscordConnectorConfig
20
+ private readonly logger: FunnelLogger
16
21
 
17
22
  constructor(deps: Deps) {
18
23
  super()
19
24
  this.config = deps.config
25
+ this.logger = deps.logger ?? defaultLogger
20
26
  Object.freeze(this)
21
27
  }
22
28
 
@@ -48,14 +54,14 @@ export class FunnelDiscordListener extends FunnelConnectorListener {
48
54
  try {
49
55
  await notify(result.content, result.meta)
50
56
  } catch (error) {
51
- logger.error("discord notify error", {
57
+ this.logger.error("discord notify error", {
52
58
  error: error instanceof Error ? error.message : String(error),
53
59
  })
54
60
  }
55
61
  })
56
62
 
57
63
  client.on("error", (error) => {
58
- logger.error("discord client error", {
64
+ this.logger.error("discord client error", {
59
65
  error: error instanceof Error ? error.message : String(error),
60
66
  })
61
67
  })
@@ -12,10 +12,12 @@ import {
12
12
  discordConnectorSchema,
13
13
  } from "@/modules/connectors/discord-connector-schema"
14
14
  import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
15
+ import type { FunnelLogger } from "@/modules/logger/funnel-logger"
15
16
 
16
17
  type Deps = {
17
18
  fs?: FunnelFileSystem
18
19
  dir?: string
20
+ logger?: FunnelLogger
19
21
  }
20
22
 
21
23
  export type DiscordUpdateFields = {
@@ -25,6 +27,7 @@ export type DiscordUpdateFields = {
25
27
  export class FunnelDiscordStore extends FunnelCallableConnectorStore<DiscordConnectorConfig> {
26
28
  readonly type = "discord" as const
27
29
  private readonly store: FunnelJsonConnectorStore<DiscordConnectorConfig>
30
+ private readonly logger?: FunnelLogger
28
31
 
29
32
  constructor(deps: Deps = {}) {
30
33
  super()
@@ -34,6 +37,7 @@ export class FunnelDiscordStore extends FunnelCallableConnectorStore<DiscordConn
34
37
  fs: deps.fs,
35
38
  dir: deps.dir ?? DEFAULT_FUNNEL_DIR,
36
39
  })
40
+ this.logger = deps.logger
37
41
  Object.freeze(this)
38
42
  }
39
43
 
@@ -75,7 +79,7 @@ export class FunnelDiscordStore extends FunnelCallableConnectorStore<DiscordConn
75
79
  }
76
80
 
77
81
  createListener(config: DiscordConnectorConfig): FunnelConnectorListener {
78
- return new FunnelDiscordListener({ config })
82
+ return new FunnelDiscordListener({ config, logger: this.logger })
79
83
  }
80
84
 
81
85
  createAdapter(config: DiscordConnectorConfig): FunnelConnectorAdapter {