@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.
- package/README.md +105 -5
- package/lib/api.ts +54 -0
- package/lib/funnel.ts +120 -33
- package/lib/modules/channels/funnel-channels.ts +5 -0
- package/lib/modules/claude/funnel-claude.ts +21 -9
- package/lib/modules/connectors/funnel-connector-stores.ts +32 -4
- package/lib/modules/connectors/funnel-connectors.ts +6 -0
- package/lib/modules/connectors/funnel-discord-listener.ts +9 -3
- package/lib/modules/connectors/funnel-discord-store.ts +5 -1
- package/lib/modules/connectors/funnel-gh-listener.ts +14 -5
- package/lib/modules/connectors/funnel-gh-store.ts +18 -1
- package/lib/modules/connectors/funnel-schedule-listener.ts +8 -2
- package/lib/modules/connectors/funnel-schedule-store.ts +21 -4
- package/lib/modules/connectors/funnel-slack-listener.ts +8 -2
- package/lib/modules/connectors/funnel-slack-store.ts +5 -1
- package/lib/modules/connectors/migrate-legacy-connectors.ts +5 -1
- package/lib/modules/fs/funnel-file-system.ts +5 -0
- package/lib/modules/gateway/daemon.ts +10 -143
- package/lib/modules/gateway/funnel-gateway-server.ts +241 -0
- package/lib/modules/gateway/funnel-gateway.ts +49 -20
- package/lib/modules/gateway/kill-competing-slack-gateways.ts +5 -1
- package/lib/modules/id/funnel-id-generator.ts +7 -0
- package/lib/modules/id/memory-funnel-id-generator.ts +20 -0
- package/lib/modules/id/node-funnel-id-generator.ts +7 -0
- package/lib/modules/logger/funnel-logger.ts +11 -0
- package/lib/modules/logger/memory-funnel-logger.ts +28 -0
- package/lib/modules/logger/node-funnel-logger.ts +49 -0
- package/lib/modules/logger/noop-funnel-logger.ts +9 -0
- package/lib/modules/mcp/funnel-mcp.ts +5 -0
- package/lib/modules/process/funnel-process-runner.ts +5 -0
- package/lib/modules/profiles/funnel-profiles.ts +5 -0
- package/lib/modules/repos/funnel-repositories.ts +5 -0
- package/lib/modules/schedule/funnel-schedule.ts +5 -0
- package/lib/modules/time/funnel-clock.ts +15 -0
- package/lib/modules/time/memory-funnel-clock.ts +26 -0
- package/lib/modules/time/node-funnel-clock.ts +7 -0
- package/lib/routes/gateway/logs.ts +3 -1
- package/package.json +16 -5
- 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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 {
|
|
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 {
|