@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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/lib/factory.ts +10 -0
- package/lib/funnel.ts +51 -0
- package/lib/index.ts +86 -0
- package/lib/modules/agents/funnel-agents.ts +105 -0
- package/lib/modules/channels/funnel-channels.ts +113 -0
- package/lib/modules/claude/funnel-claude.ts +136 -0
- package/lib/modules/connectors/funnel-connector-adapter.ts +9 -0
- package/lib/modules/connectors/funnel-connector-listener.ts +5 -0
- package/lib/modules/connectors/funnel-connectors.ts +124 -0
- package/lib/modules/connectors/funnel-discord-adapter.ts +56 -0
- package/lib/modules/connectors/funnel-discord-event-processor.ts +48 -0
- package/lib/modules/connectors/funnel-discord-listener.ts +65 -0
- package/lib/modules/connectors/funnel-gh-adapter.ts +51 -0
- package/lib/modules/connectors/funnel-gh-listener.ts +102 -0
- package/lib/modules/connectors/funnel-slack-adapter.ts +31 -0
- package/lib/modules/connectors/funnel-slack-event-processor.ts +91 -0
- package/lib/modules/connectors/funnel-slack-listener.ts +72 -0
- package/lib/modules/connectors/resolve-listener.ts +13 -0
- package/lib/modules/fs/funnel-file-system.ts +14 -0
- package/lib/modules/fs/memory-funnel-file-system.ts +85 -0
- package/lib/modules/fs/node-funnel-file-system.ts +56 -0
- package/lib/modules/gateway/daemon.ts +190 -0
- package/lib/modules/gateway/funnel-broadcaster.ts +37 -0
- package/lib/modules/gateway/funnel-event-logger.ts +59 -0
- package/lib/modules/gateway/funnel-gateway.ts +166 -0
- package/lib/modules/gateway/kill-competing-slack-gateways.ts +52 -0
- package/lib/modules/http/funnel-http-client.ts +17 -0
- package/lib/modules/http/memory-funnel-http-client.ts +40 -0
- package/lib/modules/http/node-funnel-http-client.ts +27 -0
- package/lib/modules/logger.ts +26 -0
- package/lib/modules/mcp/channel-server.ts +77 -0
- package/lib/modules/mcp/funnel-mcp.ts +107 -0
- package/lib/modules/process/funnel-process-runner.ts +28 -0
- package/lib/modules/process/memory-funnel-process-runner.ts +88 -0
- package/lib/modules/process/node-funnel-process-runner.ts +100 -0
- package/lib/modules/repos/funnel-repositories.ts +107 -0
- package/lib/modules/router/query-to-cli-args.ts +20 -0
- package/lib/modules/router/to-request.ts +122 -0
- package/lib/modules/router/validator.ts +27 -0
- package/lib/modules/settings/funnel-settings-reader.ts +6 -0
- package/lib/modules/settings/funnel-settings-store.ts +57 -0
- package/lib/modules/settings/mock-funnel-settings-reader.ts +27 -0
- package/lib/modules/settings/settings-schema.ts +67 -0
- package/lib/modules/tui/app.tsx +44 -0
- package/lib/modules/tui/tui.tsx +13 -0
- package/lib/routes/agents/add.help.ts +3 -0
- package/lib/routes/agents/add.ts +33 -0
- package/lib/routes/agents/group.help.ts +13 -0
- package/lib/routes/agents/group.ts +25 -0
- package/lib/routes/agents/launch.help.ts +3 -0
- package/lib/routes/agents/launch.ts +35 -0
- package/lib/routes/agents/remove.help.ts +3 -0
- package/lib/routes/agents/remove.ts +17 -0
- package/lib/routes/agents/rename.help.ts +5 -0
- package/lib/routes/agents/rename.ts +17 -0
- package/lib/routes/agents/routes.ts +17 -0
- package/lib/routes/agents/set.help.ts +5 -0
- package/lib/routes/agents/set.ts +32 -0
- package/lib/routes/channels/add.help.ts +3 -0
- package/lib/routes/channels/add.ts +21 -0
- package/lib/routes/channels/connectors-attach.help.ts +3 -0
- package/lib/routes/channels/connectors-attach.ts +17 -0
- package/lib/routes/channels/connectors-detach.help.ts +3 -0
- package/lib/routes/channels/connectors-detach.ts +17 -0
- package/lib/routes/channels/group.help.ts +16 -0
- package/lib/routes/channels/group.ts +22 -0
- package/lib/routes/channels/remove.help.ts +3 -0
- package/lib/routes/channels/remove.ts +17 -0
- package/lib/routes/channels/rename.help.ts +5 -0
- package/lib/routes/channels/rename.ts +17 -0
- package/lib/routes/channels/routes.ts +19 -0
- package/lib/routes/channels/show.help.ts +1 -0
- package/lib/routes/channels/show.ts +26 -0
- package/lib/routes/claude/claude.help.ts +11 -0
- package/lib/routes/claude/claude.ts +39 -0
- package/lib/routes/claude/routes.ts +4 -0
- package/lib/routes/connectors/add.help.ts +22 -0
- package/lib/routes/connectors/add.ts +55 -0
- package/lib/routes/connectors/call.help.ts +17 -0
- package/lib/routes/connectors/call.ts +43 -0
- package/lib/routes/connectors/group.help.ts +14 -0
- package/lib/routes/connectors/group.ts +18 -0
- package/lib/routes/connectors/remove.help.ts +3 -0
- package/lib/routes/connectors/remove.ts +17 -0
- package/lib/routes/connectors/rename.help.ts +5 -0
- package/lib/routes/connectors/rename.ts +17 -0
- package/lib/routes/connectors/routes.ts +19 -0
- package/lib/routes/connectors/set.help.ts +8 -0
- package/lib/routes/connectors/set.ts +30 -0
- package/lib/routes/connectors/show.help.ts +1 -0
- package/lib/routes/connectors/show.ts +32 -0
- package/lib/routes/gateway/group.help.ts +15 -0
- package/lib/routes/gateway/group.ts +28 -0
- package/lib/routes/gateway/logs.help.ts +13 -0
- package/lib/routes/gateway/logs.ts +100 -0
- package/lib/routes/gateway/restart.help.ts +10 -0
- package/lib/routes/gateway/restart.ts +35 -0
- package/lib/routes/gateway/routes.ts +18 -0
- package/lib/routes/gateway/run.help.ts +12 -0
- package/lib/routes/gateway/run.ts +35 -0
- package/lib/routes/gateway/start.help.ts +15 -0
- package/lib/routes/gateway/start.ts +32 -0
- package/lib/routes/gateway/status.help.ts +9 -0
- package/lib/routes/gateway/status.ts +28 -0
- package/lib/routes/gateway/stop.help.ts +8 -0
- package/lib/routes/gateway/stop.ts +21 -0
- package/lib/routes/repos/add.help.ts +5 -0
- package/lib/routes/repos/add.ts +19 -0
- package/lib/routes/repos/group.help.ts +11 -0
- package/lib/routes/repos/group.ts +18 -0
- package/lib/routes/repos/remove.help.ts +3 -0
- package/lib/routes/repos/remove.ts +17 -0
- package/lib/routes/repos/rename.help.ts +5 -0
- package/lib/routes/repos/rename.ts +17 -0
- package/lib/routes/repos/routes.ts +17 -0
- package/lib/routes/repos/set.help.ts +5 -0
- package/lib/routes/repos/set.ts +21 -0
- package/lib/routes/repos/show.help.ts +1 -0
- package/lib/routes/repos/show.ts +19 -0
- package/lib/routes/status/routes.ts +4 -0
- package/lib/routes/status/status.help.ts +6 -0
- package/lib/routes/status/status.ts +77 -0
- package/lib/routes.ts +36 -0
- 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
|
+
[](https://www.npmjs.com/package/@interactive-inc/claude-funnel)
|
|
4
|
+
[](./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
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
|
+
}
|