@interactive-inc/claude-funnel 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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/lib/factory.ts +10 -0
  4. package/lib/funnel.ts +51 -0
  5. package/lib/index.ts +86 -0
  6. package/lib/modules/agents/funnel-agents.ts +105 -0
  7. package/lib/modules/channels/funnel-channels.ts +113 -0
  8. package/lib/modules/claude/funnel-claude.ts +136 -0
  9. package/lib/modules/connectors/funnel-connector-adapter.ts +9 -0
  10. package/lib/modules/connectors/funnel-connector-listener.ts +5 -0
  11. package/lib/modules/connectors/funnel-connectors.ts +124 -0
  12. package/lib/modules/connectors/funnel-discord-adapter.ts +56 -0
  13. package/lib/modules/connectors/funnel-discord-event-processor.ts +48 -0
  14. package/lib/modules/connectors/funnel-discord-listener.ts +65 -0
  15. package/lib/modules/connectors/funnel-gh-adapter.ts +51 -0
  16. package/lib/modules/connectors/funnel-gh-listener.ts +102 -0
  17. package/lib/modules/connectors/funnel-slack-adapter.ts +31 -0
  18. package/lib/modules/connectors/funnel-slack-event-processor.ts +91 -0
  19. package/lib/modules/connectors/funnel-slack-listener.ts +72 -0
  20. package/lib/modules/connectors/resolve-listener.ts +13 -0
  21. package/lib/modules/fs/funnel-file-system.ts +14 -0
  22. package/lib/modules/fs/memory-funnel-file-system.ts +85 -0
  23. package/lib/modules/fs/node-funnel-file-system.ts +56 -0
  24. package/lib/modules/gateway/daemon.ts +190 -0
  25. package/lib/modules/gateway/funnel-broadcaster.ts +37 -0
  26. package/lib/modules/gateway/funnel-event-logger.ts +59 -0
  27. package/lib/modules/gateway/funnel-gateway.ts +166 -0
  28. package/lib/modules/gateway/kill-competing-slack-gateways.ts +52 -0
  29. package/lib/modules/http/funnel-http-client.ts +17 -0
  30. package/lib/modules/http/memory-funnel-http-client.ts +40 -0
  31. package/lib/modules/http/node-funnel-http-client.ts +27 -0
  32. package/lib/modules/logger.ts +26 -0
  33. package/lib/modules/mcp/channel-server.ts +77 -0
  34. package/lib/modules/mcp/funnel-mcp.ts +107 -0
  35. package/lib/modules/process/funnel-process-runner.ts +28 -0
  36. package/lib/modules/process/memory-funnel-process-runner.ts +88 -0
  37. package/lib/modules/process/node-funnel-process-runner.ts +100 -0
  38. package/lib/modules/repos/funnel-repositories.ts +107 -0
  39. package/lib/modules/router/query-to-cli-args.ts +20 -0
  40. package/lib/modules/router/to-request.ts +122 -0
  41. package/lib/modules/router/validator.ts +27 -0
  42. package/lib/modules/settings/funnel-settings-reader.ts +6 -0
  43. package/lib/modules/settings/funnel-settings-store.ts +57 -0
  44. package/lib/modules/settings/mock-funnel-settings-reader.ts +27 -0
  45. package/lib/modules/settings/settings-schema.ts +67 -0
  46. package/lib/modules/tui/app.tsx +44 -0
  47. package/lib/modules/tui/tui.tsx +13 -0
  48. package/lib/routes/agents/add.help.ts +3 -0
  49. package/lib/routes/agents/add.ts +33 -0
  50. package/lib/routes/agents/group.help.ts +13 -0
  51. package/lib/routes/agents/group.ts +25 -0
  52. package/lib/routes/agents/launch.help.ts +3 -0
  53. package/lib/routes/agents/launch.ts +35 -0
  54. package/lib/routes/agents/remove.help.ts +3 -0
  55. package/lib/routes/agents/remove.ts +17 -0
  56. package/lib/routes/agents/rename.help.ts +5 -0
  57. package/lib/routes/agents/rename.ts +17 -0
  58. package/lib/routes/agents/routes.ts +17 -0
  59. package/lib/routes/agents/set.help.ts +5 -0
  60. package/lib/routes/agents/set.ts +32 -0
  61. package/lib/routes/channels/add.help.ts +3 -0
  62. package/lib/routes/channels/add.ts +21 -0
  63. package/lib/routes/channels/connectors-attach.help.ts +3 -0
  64. package/lib/routes/channels/connectors-attach.ts +17 -0
  65. package/lib/routes/channels/connectors-detach.help.ts +3 -0
  66. package/lib/routes/channels/connectors-detach.ts +17 -0
  67. package/lib/routes/channels/group.help.ts +16 -0
  68. package/lib/routes/channels/group.ts +22 -0
  69. package/lib/routes/channels/remove.help.ts +3 -0
  70. package/lib/routes/channels/remove.ts +17 -0
  71. package/lib/routes/channels/rename.help.ts +5 -0
  72. package/lib/routes/channels/rename.ts +17 -0
  73. package/lib/routes/channels/routes.ts +19 -0
  74. package/lib/routes/channels/show.help.ts +1 -0
  75. package/lib/routes/channels/show.ts +26 -0
  76. package/lib/routes/claude/claude.help.ts +11 -0
  77. package/lib/routes/claude/claude.ts +39 -0
  78. package/lib/routes/claude/routes.ts +4 -0
  79. package/lib/routes/connectors/add.help.ts +22 -0
  80. package/lib/routes/connectors/add.ts +55 -0
  81. package/lib/routes/connectors/call.help.ts +17 -0
  82. package/lib/routes/connectors/call.ts +43 -0
  83. package/lib/routes/connectors/group.help.ts +14 -0
  84. package/lib/routes/connectors/group.ts +18 -0
  85. package/lib/routes/connectors/remove.help.ts +3 -0
  86. package/lib/routes/connectors/remove.ts +17 -0
  87. package/lib/routes/connectors/rename.help.ts +5 -0
  88. package/lib/routes/connectors/rename.ts +17 -0
  89. package/lib/routes/connectors/routes.ts +19 -0
  90. package/lib/routes/connectors/set.help.ts +8 -0
  91. package/lib/routes/connectors/set.ts +30 -0
  92. package/lib/routes/connectors/show.help.ts +1 -0
  93. package/lib/routes/connectors/show.ts +32 -0
  94. package/lib/routes/gateway/group.help.ts +15 -0
  95. package/lib/routes/gateway/group.ts +28 -0
  96. package/lib/routes/gateway/logs.help.ts +13 -0
  97. package/lib/routes/gateway/logs.ts +100 -0
  98. package/lib/routes/gateway/restart.help.ts +10 -0
  99. package/lib/routes/gateway/restart.ts +35 -0
  100. package/lib/routes/gateway/routes.ts +18 -0
  101. package/lib/routes/gateway/run.help.ts +12 -0
  102. package/lib/routes/gateway/run.ts +35 -0
  103. package/lib/routes/gateway/start.help.ts +15 -0
  104. package/lib/routes/gateway/start.ts +32 -0
  105. package/lib/routes/gateway/status.help.ts +9 -0
  106. package/lib/routes/gateway/status.ts +28 -0
  107. package/lib/routes/gateway/stop.help.ts +8 -0
  108. package/lib/routes/gateway/stop.ts +21 -0
  109. package/lib/routes/repos/add.help.ts +5 -0
  110. package/lib/routes/repos/add.ts +19 -0
  111. package/lib/routes/repos/group.help.ts +11 -0
  112. package/lib/routes/repos/group.ts +18 -0
  113. package/lib/routes/repos/remove.help.ts +3 -0
  114. package/lib/routes/repos/remove.ts +17 -0
  115. package/lib/routes/repos/rename.help.ts +5 -0
  116. package/lib/routes/repos/rename.ts +17 -0
  117. package/lib/routes/repos/routes.ts +17 -0
  118. package/lib/routes/repos/set.help.ts +5 -0
  119. package/lib/routes/repos/set.ts +21 -0
  120. package/lib/routes/repos/show.help.ts +1 -0
  121. package/lib/routes/repos/show.ts +19 -0
  122. package/lib/routes/status/routes.ts +4 -0
  123. package/lib/routes/status/status.help.ts +6 -0
  124. package/lib/routes/status/status.ts +77 -0
  125. package/lib/routes.ts +36 -0
  126. package/package.json +65 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Interactive Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @interactive-inc/claude-funnel
2
+
3
+ [![npm](https://img.shields.io/npm/v/@interactive-inc/claude-funnel.svg)](https://www.npmjs.com/package/@interactive-inc/claude-funnel)
4
+ [![license](https://img.shields.io/npm/l/@interactive-inc/claude-funnel.svg)](./LICENSE)
5
+
6
+ A hub CLI that connects multiple Claude Code agents to external services (Slack / GitHub / Discord). External events flow through subscription "channels" into Claude Code sessions, and outbound API calls from Claude are funneled through the same connectors.
7
+
8
+ The command is `funnel` or its shorthand `fnl`.
9
+
10
+ ## Overview
11
+
12
+ ```
13
+ Slack/others Connectors Channels Claude Code
14
+ (external APIs) ─→ (funnel) ─→ (subscription router) ──WS/MCP─→ (received as <channel> tags)
15
+
16
+ funnel MCP server (funnel mcp)
17
+ ```
18
+
19
+ ## Requirements
20
+
21
+ - [Bun](https://bun.sh) 1.3 or later (runtime)
22
+ - [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI
23
+ - A Slack / GitHub / Discord token or CLI, depending on which connectors you use
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ bun add -g @interactive-inc/claude-funnel
29
+ ```
30
+
31
+ After install, `funnel` and `fnl` are available globally.
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ # Register an external connection (Connector)
37
+ fnl connectors add my-slack --type slack --bot-token xoxb-... --app-token xapp-...
38
+
39
+ # Create a subscription box (Channel) and attach the connector
40
+ fnl channels add my-inbox
41
+ fnl channels my-inbox connectors attach my-slack
42
+
43
+ # Start the gateway (connects to Slack Socket Mode)
44
+ fnl gateway start
45
+
46
+ # Launch Claude (funnel is auto-registered in the current directory's .mcp.json)
47
+ fnl claude --channel my-inbox
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ ```
53
+ fnl connectors list
54
+ fnl connectors add <name> --type <t> --bot-token <t> --app-token <t>
55
+ fnl connectors <name> show details
56
+ fnl connectors <name> set [--bot-token ...] [--app-token ...]
57
+ fnl connectors rename <old> <new>
58
+ fnl connectors remove <name>
59
+ fnl connectors <name> <method> <path> [body] call API (get/post/put/delete/...)
60
+
61
+ fnl channels list
62
+ fnl channels add <name>
63
+ fnl channels <name> show details
64
+ fnl channels <name> connectors attach <connector>
65
+ fnl channels <name> connectors detach <connector>
66
+ fnl channels rename <old> <new>
67
+ fnl channels remove <name>
68
+
69
+ fnl agents list agent presets (extra)
70
+ fnl agents add <name> --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
71
+ fnl agents <name> launch (sugar for fnl claude)
72
+ fnl agents <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
73
+ fnl agents rename <old> <new>
74
+ fnl agents remove <name>
75
+
76
+ fnl repos list repositories (extra)
77
+ fnl repos add <name> --path <path> register funnel MCP into .mcp.json
78
+ fnl repos <name> show details
79
+ fnl repos <name> set [--path <path>]
80
+ fnl repos rename <old> <new>
81
+ fnl repos remove <name>
82
+
83
+ fnl claude --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
84
+ launch Claude Code
85
+ fnl mcp run as an MCP server (invoked from .mcp.json)
86
+
87
+ fnl gateway running status
88
+ fnl gateway start / stop / restart / run / logs
89
+ fnl status overall status (connectors / channels / agents / repos / gateway)
90
+
91
+ fnl --version
92
+ fnl --help (every subcommand has --help)
93
+ ```
94
+
95
+ ## Data model
96
+
97
+ ```
98
+ Connector =
99
+ | { type: "slack", name, botToken, appToken } Slack Socket Mode
100
+ | { type: "gh", name, pollInterval? } GitHub (gh CLI)
101
+ | { type: "discord", name, botToken } Discord Gateway
102
+
103
+ Channel = { name, connectors[] } subscription box
104
+ Repository = { name, path } extra
105
+ Agent = { name, channel, repo?, subAgent?, envFiles? } preset (extra)
106
+
107
+ Settings = { connectors[], channels[], repositories[], agents[] }
108
+ → ~/.funnel/settings.json
109
+ ```
110
+
111
+ ## Discord bot setup
112
+
113
+ - Create a bot in the Discord Developer Portal and obtain its token
114
+ - Enable `Message Content Intent` under Privileged Gateway Intents
115
+ - Invite the bot via OAuth2 → URL Generator with the `bot` scope and `View Channels` / `Send Messages` / `Read Message History` permissions
116
+
117
+ ## Environment variables
118
+
119
+ | Variable | Purpose |
120
+ | -------------------- | --------------------------------------------------------------------------------------- |
121
+ | `FUNNEL_CHANNEL_ID` | Injected into the child process by `fnl claude`; funnel MCP uses it to subscribe. |
122
+ | `FUNNEL_PORT` | Gateway port (default 9742). |
123
+ | `FUNNEL_GATEWAY_URL` | Gateway WebSocket URL used by MCP (default `ws://localhost:9742/ws`). |
124
+
125
+ ## File layout
126
+
127
+ - Config: `~/.funnel/settings.json`
128
+ - PID: `~/.funnel/gateway.pid`
129
+ - Event log: `/tmp/funnel/events/*.jsonl` (auto-deleted after 30 days)
130
+ - Process log: `/tmp/funnel/gateway.log`
131
+
132
+ ## Links
133
+
134
+ - [GitHub](https://github.com/interactive-inc/claude-funnel)
135
+ - [Issues](https://github.com/interactive-inc/claude-funnel/issues)
136
+ - Coding rules and design principles: [CLAUDE.md](https://github.com/interactive-inc/claude-funnel/blob/main/CLAUDE.md)
137
+ - Design notes: [`.docs/`](https://github.com/interactive-inc/claude-funnel/tree/main/.docs)
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ git clone https://github.com/interactive-inc/claude-funnel.git
143
+ cd claude-funnel
144
+ bun install
145
+ bun link # register funnel / fnl globally
146
+ bun test # run tests
147
+ bunx tsc -b # type check
148
+ ```
149
+
150
+ ## License
151
+
152
+ MIT © Interactive Inc.
package/lib/factory.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createFactory } from "hono/factory"
2
+ import type { Funnel } from "@/funnel"
3
+
4
+ export type Env = {
5
+ Variables: {
6
+ funnel: Funnel
7
+ }
8
+ }
9
+
10
+ export const factory = createFactory<Env>()
package/lib/funnel.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { FunnelAgents } from "@/modules/agents/funnel-agents"
2
+ import { FunnelChannels } from "@/modules/channels/funnel-channels"
3
+ import { FunnelClaude } from "@/modules/claude/funnel-claude"
4
+ import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
5
+ import { FunnelGateway } from "@/modules/gateway/funnel-gateway"
6
+ import { FunnelMcp } from "@/modules/mcp/funnel-mcp"
7
+ import { FunnelRepositories } from "@/modules/repos/funnel-repositories"
8
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
9
+
10
+ type Props = {
11
+ store: FunnelSettingsReader
12
+ }
13
+
14
+ export class Funnel {
15
+ constructor(private readonly props: Props) {
16
+ Object.freeze(this)
17
+ }
18
+
19
+ get connectors(): FunnelConnectors {
20
+ return new FunnelConnectors({ store: this.props.store })
21
+ }
22
+
23
+ get channels(): FunnelChannels {
24
+ return new FunnelChannels({ store: this.props.store })
25
+ }
26
+
27
+ get agents(): FunnelAgents {
28
+ return new FunnelAgents({ store: this.props.store })
29
+ }
30
+
31
+ get repositories(): FunnelRepositories {
32
+ return new FunnelRepositories({ store: this.props.store, mcp: this.mcp })
33
+ }
34
+
35
+ get claude(): FunnelClaude {
36
+ return new FunnelClaude({
37
+ channels: this.channels,
38
+ repositories: this.repositories,
39
+ mcp: this.mcp,
40
+ gateway: this.gateway,
41
+ })
42
+ }
43
+
44
+ get gateway(): FunnelGateway {
45
+ return new FunnelGateway()
46
+ }
47
+
48
+ get mcp(): FunnelMcp {
49
+ return new FunnelMcp()
50
+ }
51
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ import pkg from "../package.json" with { type: "json" }
3
+ import { startChannelServer } from "@/modules/mcp/channel-server"
4
+ import { toRequest } from "@/modules/router/to-request"
5
+ import { launchTui } from "@/modules/tui/tui"
6
+ import { app } from "@/routes"
7
+
8
+ process.title = "funnel"
9
+
10
+ const HELP = `funnel — Open Claude Funnel
11
+
12
+ usage: funnel [command]
13
+
14
+ commands:
15
+ (none) launch TUI
16
+ claude --channel <c> launch Claude Code
17
+ connectors manage external connections (Slack, etc.)
18
+ channels manage subscription boxes
19
+ agents manage agent presets (extra)
20
+ repos manage repositories (extra)
21
+ gateway manage the gateway
22
+ status show overall connection status
23
+ mcp run as an MCP server (invoked from .mcp.json)
24
+
25
+ options:
26
+ --help, -h show help
27
+ --version, -v show version
28
+
29
+ more: funnel <command> --help`
30
+
31
+ const args = process.argv.slice(2)
32
+
33
+ if (args.length === 0) {
34
+ await launchTui()
35
+ process.exit(0)
36
+ }
37
+
38
+ if (args[0] === "--version" || args[0] === "-v") {
39
+ process.stdout.write(`${pkg.version}\n`)
40
+ process.exit(0)
41
+ }
42
+
43
+ if (args[0] === "mcp") {
44
+ await startChannelServer()
45
+ } else {
46
+ const { method, url } = toRequest(args)
47
+
48
+ const parsed = new URL(url)
49
+
50
+ if (parsed.searchParams.has("help")) {
51
+ if (parsed.pathname === "/") {
52
+ process.stdout.write(`${HELP}\n`)
53
+ process.exit(0)
54
+ }
55
+
56
+ let res = await app.request(url, { method })
57
+
58
+ if (!res.ok && method !== "GET") {
59
+ res = await app.request(url, { method: "GET" })
60
+ }
61
+
62
+ if (!res.ok) {
63
+ const group = parsed.pathname.split("/").filter(Boolean)[0]
64
+
65
+ if (group) {
66
+ res = await app.request(`http://localhost/${group}?help=true`, { method: "GET" })
67
+ }
68
+ }
69
+
70
+ const text = res.ok ? await res.text() : HELP
71
+ process.stdout.write(`${text}\n`)
72
+ process.exit(0)
73
+ }
74
+
75
+ const res = await app.request(url, { method })
76
+
77
+ if (!res.ok) {
78
+ const text = await res.text()
79
+ if (text) process.stderr.write(`${text}\n`)
80
+ process.exit(1)
81
+ }
82
+
83
+ const body = await res.text()
84
+
85
+ if (body) process.stdout.write(`${body}\n`)
86
+ }
@@ -0,0 +1,105 @@
1
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
+ import type { AgentConfig } from "@/modules/settings/settings-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelSettingsReader
6
+ }
7
+
8
+ export class FunnelAgents {
9
+ private readonly store: FunnelSettingsReader
10
+
11
+ constructor(deps: Deps) {
12
+ this.store = deps.store
13
+ Object.freeze(this)
14
+ }
15
+
16
+ list(): AgentConfig[] {
17
+ return this.store.read().agents
18
+ }
19
+
20
+ get(name: string): AgentConfig | null {
21
+ return this.list().find((a) => a.name === name) ?? null
22
+ }
23
+
24
+ add(config: AgentConfig): void {
25
+ const settings = this.store.read()
26
+
27
+ if (settings.agents.some((a) => a.name === config.name)) {
28
+ throw new Error(`agent "${config.name}" already exists`)
29
+ }
30
+
31
+ if (!settings.channels.some((c) => c.name === config.channel)) {
32
+ throw new Error(`channel "${config.channel}" not found`)
33
+ }
34
+
35
+ if (config.repo && !settings.repositories.some((r) => r.name === config.repo)) {
36
+ throw new Error(`repo "${config.repo}" not found`)
37
+ }
38
+
39
+ settings.agents.push(config)
40
+
41
+ this.store.write(settings)
42
+ }
43
+
44
+ remove(name: string): void {
45
+ const settings = this.store.read()
46
+
47
+ const index = settings.agents.findIndex((a) => a.name === name)
48
+
49
+ if (index < 0) throw new Error(`agent "${name}" not found`)
50
+
51
+ settings.agents.splice(index, 1)
52
+
53
+ this.store.write(settings)
54
+ }
55
+
56
+ rename(oldName: string, newName: string): void {
57
+ const settings = this.store.read()
58
+
59
+ const agent = settings.agents.find((a) => a.name === oldName)
60
+
61
+ if (!agent) throw new Error(`agent "${oldName}" not found`)
62
+
63
+ if (settings.agents.some((a) => a.name === newName)) {
64
+ throw new Error(`agent "${newName}" already exists`)
65
+ }
66
+
67
+ agent.name = newName
68
+
69
+ this.store.write(settings)
70
+ }
71
+
72
+ update(name: string, fields: Partial<Omit<AgentConfig, "name">>): void {
73
+ const settings = this.store.read()
74
+
75
+ const agent = settings.agents.find((a) => a.name === name)
76
+
77
+ if (!agent) throw new Error(`agent "${name}" not found`)
78
+
79
+ if (fields.channel !== undefined) {
80
+ if (!settings.channels.some((c) => c.name === fields.channel)) {
81
+ throw new Error(`channel "${fields.channel}" not found`)
82
+ }
83
+
84
+ agent.channel = fields.channel
85
+ }
86
+
87
+ if (fields.repo !== undefined) {
88
+ if (fields.repo && !settings.repositories.some((r) => r.name === fields.repo)) {
89
+ throw new Error(`repo "${fields.repo}" not found`)
90
+ }
91
+
92
+ agent.repo = fields.repo || undefined
93
+ }
94
+
95
+ if (fields.subAgent !== undefined) {
96
+ agent.subAgent = fields.subAgent || undefined
97
+ }
98
+
99
+ if (fields.envFiles !== undefined) {
100
+ agent.envFiles = fields.envFiles
101
+ }
102
+
103
+ this.store.write(settings)
104
+ }
105
+ }
@@ -0,0 +1,113 @@
1
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
+ import type { ChannelConfig } from "@/modules/settings/settings-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelSettingsReader
6
+ }
7
+
8
+ export class FunnelChannels {
9
+ private readonly store: FunnelSettingsReader
10
+
11
+ constructor(deps: Deps) {
12
+ this.store = deps.store
13
+ Object.freeze(this)
14
+ }
15
+
16
+ list(): ChannelConfig[] {
17
+ return this.store.read().channels
18
+ }
19
+
20
+ get(name: string): ChannelConfig | null {
21
+ return this.list().find((c) => c.name === name) ?? null
22
+ }
23
+
24
+ add(config: ChannelConfig): void {
25
+ const settings = this.store.read()
26
+
27
+ if (settings.channels.some((c) => c.name === config.name)) {
28
+ throw new Error(`channel "${config.name}" already exists`)
29
+ }
30
+
31
+ for (const connectorName of config.connectors) {
32
+ if (!settings.connectors.some((c) => c.name === connectorName)) {
33
+ throw new Error(`connector "${connectorName}" not found`)
34
+ }
35
+ }
36
+
37
+ settings.channels.push(config)
38
+
39
+ this.store.write(settings)
40
+ }
41
+
42
+ remove(name: string): void {
43
+ const settings = this.store.read()
44
+
45
+ const index = settings.channels.findIndex((c) => c.name === name)
46
+
47
+ if (index < 0) throw new Error(`channel "${name}" not found`)
48
+
49
+ if (settings.agents.some((a) => a.channel === name)) {
50
+ throw new Error(`channel "${name}" is referenced by an agent`)
51
+ }
52
+
53
+ settings.channels.splice(index, 1)
54
+
55
+ this.store.write(settings)
56
+ }
57
+
58
+ rename(oldName: string, newName: string): void {
59
+ const settings = this.store.read()
60
+
61
+ const channel = settings.channels.find((c) => c.name === oldName)
62
+
63
+ if (!channel) throw new Error(`channel "${oldName}" not found`)
64
+
65
+ if (settings.channels.some((c) => c.name === newName)) {
66
+ throw new Error(`channel "${newName}" already exists`)
67
+ }
68
+
69
+ channel.name = newName
70
+
71
+ for (const agent of settings.agents) {
72
+ if (agent.channel === oldName) agent.channel = newName
73
+ }
74
+
75
+ this.store.write(settings)
76
+ }
77
+
78
+ attachConnector(name: string, connectorName: string): void {
79
+ const settings = this.store.read()
80
+
81
+ const channel = settings.channels.find((c) => c.name === name)
82
+
83
+ if (!channel) throw new Error(`channel "${name}" not found`)
84
+
85
+ if (!settings.connectors.some((c) => c.name === connectorName)) {
86
+ throw new Error(`connector "${connectorName}" not found`)
87
+ }
88
+
89
+ if (channel.connectors.includes(connectorName)) {
90
+ throw new Error(`connector "${connectorName}" is already attached`)
91
+ }
92
+
93
+ channel.connectors.push(connectorName)
94
+
95
+ this.store.write(settings)
96
+ }
97
+
98
+ detachConnector(name: string, connectorName: string): void {
99
+ const settings = this.store.read()
100
+
101
+ const channel = settings.channels.find((c) => c.name === name)
102
+
103
+ if (!channel) throw new Error(`channel "${name}" not found`)
104
+
105
+ const index = channel.connectors.indexOf(connectorName)
106
+
107
+ if (index < 0) throw new Error(`connector "${connectorName}" is not attached`)
108
+
109
+ channel.connectors.splice(index, 1)
110
+
111
+ this.store.write(settings)
112
+ }
113
+ }
@@ -0,0 +1,136 @@
1
+ import type { FunnelChannels } from "@/modules/channels/funnel-channels"
2
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
4
+ import type { FunnelGateway } from "@/modules/gateway/funnel-gateway"
5
+ import { logger } from "@/modules/logger"
6
+ import type { FunnelMcp } from "@/modules/mcp/funnel-mcp"
7
+ import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
8
+ import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
9
+ import type { FunnelRepositories } from "@/modules/repos/funnel-repositories"
10
+
11
+ export type LaunchOptions = {
12
+ channel: string
13
+ repo?: string
14
+ subAgent?: string
15
+ envFiles?: string[]
16
+ userArgs?: string[]
17
+ }
18
+
19
+ type Deps = {
20
+ channels: FunnelChannels
21
+ repositories: FunnelRepositories
22
+ mcp: FunnelMcp
23
+ gateway: FunnelGateway
24
+ process?: FunnelProcessRunner
25
+ fs?: FunnelFileSystem
26
+ }
27
+
28
+ const defaultProcess = new NodeFunnelProcessRunner()
29
+ const defaultFs = new NodeFunnelFileSystem()
30
+
31
+ export class FunnelClaude {
32
+ private readonly channels: FunnelChannels
33
+ private readonly repositories: FunnelRepositories
34
+ private readonly mcp: FunnelMcp
35
+ private readonly gateway: FunnelGateway
36
+ private readonly process: FunnelProcessRunner
37
+ private readonly fs: FunnelFileSystem
38
+
39
+ constructor(deps: Deps) {
40
+ this.channels = deps.channels
41
+ this.repositories = deps.repositories
42
+ this.mcp = deps.mcp
43
+ this.gateway = deps.gateway
44
+ this.process = deps.process ?? defaultProcess
45
+ this.fs = deps.fs ?? defaultFs
46
+ Object.freeze(this)
47
+ }
48
+
49
+ async launch(options: LaunchOptions): Promise<number> {
50
+ const channel = this.channels.get(options.channel)
51
+
52
+ if (!channel) {
53
+ throw new Error(`channel "${options.channel}" not found`)
54
+ }
55
+
56
+ const cwd = options.repo
57
+ ? this.repositories.resolvePath(options.repo)
58
+ : globalThis.process.cwd()
59
+
60
+ if (!this.mcp.findInstalledName(cwd)) {
61
+ this.mcp.install(cwd)
62
+
63
+ logger.info(`added funnel MCP to .mcp.json`, { cwd })
64
+ }
65
+
66
+ if (!this.gateway.isRunning()) {
67
+ logger.info(`starting gateway automatically`)
68
+ await this.gateway.start()
69
+ }
70
+
71
+ const claudeArgs = this.buildArgs(options, cwd)
72
+ const env = this.buildEnv(options, cwd)
73
+
74
+ logger.info(`claude launch`, {
75
+ channel: options.channel,
76
+ repo: options.repo,
77
+ subAgent: options.subAgent,
78
+ cwd,
79
+ })
80
+
81
+ return await this.process.attach(["claude", ...claudeArgs], { cwd, env })
82
+ }
83
+
84
+ private buildArgs(options: LaunchOptions, cwd: string): string[] {
85
+ const result = [...(options.userArgs ?? [])]
86
+
87
+ const mcpName = this.mcp.findInstalledName(cwd)
88
+
89
+ if (
90
+ mcpName &&
91
+ !result.includes("--dangerously-load-development-channels") &&
92
+ !result.includes("--channels")
93
+ ) {
94
+ result.push("--dangerously-load-development-channels", `server:${mcpName}`)
95
+ }
96
+
97
+ if (!result.includes("--agent") && options.subAgent) {
98
+ result.push("--agent", options.subAgent)
99
+ }
100
+
101
+ return result
102
+ }
103
+
104
+ private buildEnv(options: LaunchOptions, cwd: string): Record<string, string> {
105
+ const env: Record<string, string> = { ...globalThis.process.env } as Record<string, string>
106
+
107
+ if (options.envFiles) {
108
+ for (const file of options.envFiles) {
109
+ const filePath = `${cwd}/${file}`
110
+
111
+ if (!this.fs.existsSync(filePath)) continue
112
+
113
+ const content = this.fs.readFileSync(filePath)
114
+
115
+ for (const line of content.split("\n")) {
116
+ const trimmed = line.trim()
117
+
118
+ if (!trimmed || trimmed.startsWith("#")) continue
119
+
120
+ const eqIndex = trimmed.indexOf("=")
121
+
122
+ if (eqIndex < 0) continue
123
+
124
+ const key = trimmed.slice(0, eqIndex)
125
+ const value = trimmed.slice(eqIndex + 1).replace(/^["']|["']$/g, "")
126
+
127
+ env[key] = value
128
+ }
129
+ }
130
+ }
131
+
132
+ env.FUNNEL_CHANNEL_ID = options.channel
133
+
134
+ return env
135
+ }
136
+ }
@@ -0,0 +1,9 @@
1
+ export type CallInput = {
2
+ method: string
3
+ path: string
4
+ body?: unknown
5
+ }
6
+
7
+ export abstract class FunnelConnectorAdapter {
8
+ abstract call(input: CallInput): Promise<unknown>
9
+ }
@@ -0,0 +1,5 @@
1
+ export type NotifyFn = (content: string, meta?: Record<string, string>) => Promise<void>
2
+
3
+ export abstract class FunnelConnectorListener {
4
+ abstract start(notify: NotifyFn): Promise<void>
5
+ }