@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
@@ -0,0 +1 @@
1
+ export const help = `funnel channels <name> — show channel details`
@@ -0,0 +1,26 @@
1
+ import { HTTPException } from "hono/http-exception"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/channels/show.help"
6
+
7
+ export const channelsShowHandler = factory.createHandlers(
8
+ zValidator("param", z.object({ name: z.string() })),
9
+ zValidator("query", z.object({}), help),
10
+ (c) => {
11
+ const param = c.req.valid("param")
12
+ const funnel = c.var.funnel
13
+ const channel = funnel.channels.get(param.name)
14
+
15
+ if (!channel) {
16
+ throw new HTTPException(404, { message: `channel "${param.name}" not found` })
17
+ }
18
+
19
+ const lines = [
20
+ `name: ${channel.name}`,
21
+ `connectors: ${channel.connectors.length > 0 ? channel.connectors.join(", ") : "(none)"}`,
22
+ ]
23
+
24
+ return c.text(lines.join("\n"))
25
+ },
26
+ )
@@ -0,0 +1,11 @@
1
+ export const help = `funnel claude — launch Claude Code
2
+
3
+ usage: funnel claude --channel <name> [--repo <name>] [--sub-agent <name>] [--env-file <file>] [additional claude args...]
4
+
5
+ options:
6
+ --channel channel name to subscribe to (required)
7
+ --repo switch working directory to the named repo (extra)
8
+ --sub-agent sub-agent name (passed to claude --agent)
9
+ --env-file additional env file to load (relative path)
10
+
11
+ On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`
@@ -0,0 +1,39 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { queryToCliArgs } from "@/modules/router/query-to-cli-args"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/claude/claude.help"
6
+
7
+ const RESERVED_KEYS = ["channel", "repo", "sub-agent", "env-file"]
8
+
9
+ export const claudeHandler = factory.createHandlers(
10
+ zValidator(
11
+ "query",
12
+ z
13
+ .object({
14
+ channel: z.string().optional(),
15
+ repo: z.string().optional(),
16
+ "sub-agent": z.string().optional(),
17
+ "env-file": z.string().optional(),
18
+ })
19
+ .passthrough(),
20
+ help,
21
+ ),
22
+ async (c) => {
23
+ const query = c.req.valid("query")
24
+
25
+ if (!query.channel) return c.text(help)
26
+
27
+ const funnel = c.var.funnel
28
+
29
+ const exitCode = await funnel.claude.launch({
30
+ channel: query.channel,
31
+ repo: query.repo,
32
+ subAgent: query["sub-agent"],
33
+ envFiles: query["env-file"] ? [query["env-file"]] : undefined,
34
+ userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
35
+ })
36
+
37
+ process.exit(exitCode)
38
+ },
39
+ )
@@ -0,0 +1,4 @@
1
+ import { factory } from "@/factory"
2
+ import { claudeHandler } from "@/routes/claude/claude"
3
+
4
+ export const claudeRoutes = factory.createApp().get("/", ...claudeHandler)
@@ -0,0 +1,22 @@
1
+ export const help = `funnel connectors add — add a connector
2
+
3
+ usage:
4
+ funnel connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
5
+ funnel connectors add <name> --type gh [--poll-interval <seconds>]
6
+ funnel connectors add <name> --type discord --bot-token <discord-bot-token>
7
+
8
+ slack (Socket Mode):
9
+ --bot-token Slack Bot Token (starts with xoxb-)
10
+ --app-token Slack App Token (starts with xapp-)
11
+
12
+ gh (GitHub, gh CLI):
13
+ --poll-interval polling interval for /notifications (seconds, default 60)
14
+ note: uses the gh CLI (must be authenticated via gh auth login); no token required
15
+
16
+ discord (Discord Gateway):
17
+ --bot-token Discord Bot Token
18
+
19
+ examples:
20
+ funnel connectors add prod-slack --type slack --bot-token xoxb-... --app-token xapp-...
21
+ funnel connectors add my-gh --type gh --poll-interval 30
22
+ funnel connectors add my-discord --type discord --bot-token MTI...`
@@ -0,0 +1,55 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/add.help"
5
+
6
+ export const connectorsAddHandler = factory.createHandlers(
7
+ zValidator("param", z.object({ name: z.string() })),
8
+ zValidator(
9
+ "query",
10
+ z.discriminatedUnion("type", [
11
+ z.object({
12
+ type: z.literal("slack"),
13
+ "bot-token": z.string().startsWith("xoxb-"),
14
+ "app-token": z.string().startsWith("xapp-"),
15
+ }),
16
+ z.object({
17
+ type: z.literal("gh"),
18
+ "poll-interval": z.string().optional(),
19
+ }),
20
+ z.object({
21
+ type: z.literal("discord"),
22
+ "bot-token": z.string().min(10),
23
+ }),
24
+ ]),
25
+ help,
26
+ ),
27
+ (c) => {
28
+ const param = c.req.valid("param")
29
+ const query = c.req.valid("query")
30
+ const funnel = c.var.funnel
31
+
32
+ if (query.type === "slack") {
33
+ funnel.connectors.add({
34
+ type: "slack",
35
+ name: param.name,
36
+ botToken: query["bot-token"],
37
+ appToken: query["app-token"],
38
+ })
39
+ } else if (query.type === "gh") {
40
+ funnel.connectors.add({
41
+ type: "gh",
42
+ name: param.name,
43
+ pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
44
+ })
45
+ } else {
46
+ funnel.connectors.add({
47
+ type: "discord",
48
+ name: param.name,
49
+ botToken: query["bot-token"],
50
+ })
51
+ }
52
+
53
+ return c.text(`added connector "${param.name}"`)
54
+ },
55
+ )
@@ -0,0 +1,17 @@
1
+ export const help = `funnel connectors <name> <method> <path> [body] — call a connector API
2
+
3
+ usage:
4
+ funnel connectors <name> <method> <path> [<json-body>]
5
+
6
+ <method>:
7
+ get / post / put / patch / delete / head / options
8
+
9
+ Slack examples (every API is posted via POST internally):
10
+ funnel connectors my-slack post chat.postMessage '{"channel":"D...","text":"hi"}'
11
+ funnel connectors my-slack post chat.update '{"channel":"D...","ts":"...","text":"edit"}'
12
+ funnel connectors my-slack post chat.delete '{"channel":"D...","ts":"..."}'
13
+ funnel connectors my-slack post users.info '{"user":"U..."}'
14
+
15
+ Discord examples (per HTTP method):
16
+ funnel connectors my-discord post channels/C/messages '{"content":"hi"}'
17
+ funnel connectors my-discord delete channels/C/messages/M`
@@ -0,0 +1,43 @@
1
+ import { HTTPException } from "hono/http-exception"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/connectors/call.help"
6
+
7
+ const parseBody = (raw: string | undefined): unknown => {
8
+ if (!raw) return {}
9
+
10
+ try {
11
+ return JSON.parse(raw)
12
+ } catch (error) {
13
+ throw new HTTPException(400, {
14
+ message: `body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
15
+ })
16
+ }
17
+ }
18
+
19
+ export const connectorsCallHandler = factory.createHandlers(
20
+ zValidator("param", z.object({ name: z.string() })),
21
+ zValidator(
22
+ "query",
23
+ z.object({
24
+ method: z.string(),
25
+ path: z.string(),
26
+ body: z.string().optional(),
27
+ }),
28
+ help,
29
+ ),
30
+ async (c) => {
31
+ const param = c.req.valid("param")
32
+ const query = c.req.valid("query")
33
+ const funnel = c.var.funnel
34
+
35
+ const result = await funnel.connectors.call(param.name, {
36
+ method: query.method,
37
+ path: query.path,
38
+ body: parseBody(query.body),
39
+ })
40
+
41
+ return c.text(JSON.stringify(result, null, 2))
42
+ },
43
+ )
@@ -0,0 +1,14 @@
1
+ export const help = `funnel connectors — manage external connections (Slack, etc.)
2
+
3
+ usage: funnel connectors [subcommand]
4
+
5
+ subcommands:
6
+ (none) list
7
+ add <name> --type slack --bot-token <t> --app-token <t>
8
+ remove <name>
9
+ <name> show details
10
+
11
+ examples:
12
+ funnel connectors add prod-slack --type slack --bot-token xoxb-... --app-token xapp-...
13
+ funnel connectors
14
+ funnel connectors remove prod-slack`
@@ -0,0 +1,18 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/group.help"
5
+
6
+ export const connectorsGroupHandler = factory.createHandlers(
7
+ zValidator("query", z.object({}), help),
8
+ (c) => {
9
+ const funnel = c.var.funnel
10
+ const connectors = funnel.connectors.list()
11
+
12
+ if (connectors.length === 0) return c.text("no connectors")
13
+
14
+ const lines = connectors.map((con) => `${con.name} (${con.type})`)
15
+
16
+ return c.text(lines.join("\n"))
17
+ },
18
+ )
@@ -0,0 +1,3 @@
1
+ export const help = `funnel connectors remove — remove a connector
2
+
3
+ usage: funnel connectors remove <name>`
@@ -0,0 +1,17 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/remove.help"
5
+
6
+ export const connectorsRemoveHandler = factory.createHandlers(
7
+ zValidator("param", z.object({ name: z.string() })),
8
+ zValidator("query", z.object({}), help),
9
+ (c) => {
10
+ const param = c.req.valid("param")
11
+ const funnel = c.var.funnel
12
+
13
+ funnel.connectors.remove(param.name)
14
+
15
+ return c.text(`removed connector "${param.name}"`)
16
+ },
17
+ )
@@ -0,0 +1,5 @@
1
+ export const help = `funnel connectors rename — rename a connector
2
+
3
+ usage:
4
+ funnel connectors rename <old> <new>
5
+ funnel connectors <old> rename <new>`
@@ -0,0 +1,17 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/rename.help"
5
+
6
+ export const connectorsRenameHandler = factory.createHandlers(
7
+ zValidator("param", z.object({ name: z.string(), newName: z.string() })),
8
+ zValidator("query", z.object({}), help),
9
+ (c) => {
10
+ const param = c.req.valid("param")
11
+ const funnel = c.var.funnel
12
+
13
+ funnel.connectors.rename(param.name, param["newName"])
14
+
15
+ return c.text(`renamed connector "${param.name}" to "${param["newName"]}"`)
16
+ },
17
+ )
@@ -0,0 +1,19 @@
1
+ import { factory } from "@/factory"
2
+ import { connectorsAddHandler } from "@/routes/connectors/add"
3
+ import { connectorsCallHandler } from "@/routes/connectors/call"
4
+ import { connectorsGroupHandler } from "@/routes/connectors/group"
5
+ import { connectorsRemoveHandler } from "@/routes/connectors/remove"
6
+ import { connectorsRenameHandler } from "@/routes/connectors/rename"
7
+ import { connectorsSetHandler } from "@/routes/connectors/set"
8
+ import { connectorsShowHandler } from "@/routes/connectors/show"
9
+
10
+ export const connectorsRoutes = factory
11
+ .createApp()
12
+ .get("/", ...connectorsGroupHandler)
13
+ .get("/:name/call", ...connectorsCallHandler)
14
+ .put("/:name/rename/:newName", ...connectorsRenameHandler)
15
+ .put("/rename/:name/:newName", ...connectorsRenameHandler)
16
+ .post("/:name", ...connectorsAddHandler)
17
+ .put("/:name", ...connectorsSetHandler)
18
+ .delete("/:name", ...connectorsRemoveHandler)
19
+ .get("/:name", ...connectorsShowHandler)
@@ -0,0 +1,8 @@
1
+ export const help = `funnel connectors <name> set — update a connector
2
+
3
+ usage: funnel connectors <name> set [--bot-token ...] [--app-token ...] [--poll-interval ...]
4
+
5
+ fields available per type:
6
+ slack : --bot-token / --app-token
7
+ gh : --poll-interval
8
+ discord : --bot-token`
@@ -0,0 +1,30 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/set.help"
5
+
6
+ export const connectorsSetHandler = factory.createHandlers(
7
+ zValidator("param", z.object({ name: z.string() })),
8
+ zValidator(
9
+ "query",
10
+ z.object({
11
+ "bot-token": z.string().optional(),
12
+ "app-token": z.string().optional(),
13
+ "poll-interval": z.string().optional(),
14
+ }),
15
+ help,
16
+ ),
17
+ (c) => {
18
+ const param = c.req.valid("param")
19
+ const query = c.req.valid("query")
20
+ const funnel = c.var.funnel
21
+
22
+ funnel.connectors.update(param.name, {
23
+ botToken: query["bot-token"],
24
+ appToken: query["app-token"],
25
+ pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
26
+ })
27
+
28
+ return c.text(`updated connector "${param.name}"`)
29
+ },
30
+ )
@@ -0,0 +1 @@
1
+ export const help = `funnel connectors <name> — show connector details`
@@ -0,0 +1,32 @@
1
+ import { HTTPException } from "hono/http-exception"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/connectors/show.help"
6
+
7
+ export const connectorsShowHandler = factory.createHandlers(
8
+ zValidator("param", z.object({ name: z.string() })),
9
+ zValidator("query", z.object({}), help),
10
+ (c) => {
11
+ const param = c.req.valid("param")
12
+ const funnel = c.var.funnel
13
+ const connector = funnel.connectors.get(param.name)
14
+
15
+ if (!connector) {
16
+ throw new HTTPException(404, { message: `connector "${param.name}" not found` })
17
+ }
18
+
19
+ const lines: string[] = [`name: ${connector.name}`, `type: ${connector.type}`]
20
+
21
+ if (connector.type === "slack") {
22
+ lines.push(`botToken: ${connector.botToken.slice(0, 8)}...`)
23
+ lines.push(`appToken: ${connector.appToken.slice(0, 8)}...`)
24
+ } else if (connector.type === "gh") {
25
+ lines.push(`pollInterval: ${connector.pollInterval ?? 60}s`)
26
+ } else if (connector.type === "discord") {
27
+ lines.push(`botToken: ${connector.botToken.slice(0, 8)}...`)
28
+ }
29
+
30
+ return c.text(lines.join("\n"))
31
+ },
32
+ )
@@ -0,0 +1,15 @@
1
+ export const help = `funnel gateway — manage the gateway
2
+
3
+ usage: funnel gateway [subcommand]
4
+
5
+ subcommands:
6
+ status show running status (default)
7
+ start start in background
8
+ stop stop
9
+ restart stop then start
10
+ run start in foreground (for developers)
11
+ logs [-n <N>] show event logs
12
+
13
+ examples:
14
+ funnel gateway check status
15
+ funnel gateway restart restart`
@@ -0,0 +1,28 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/gateway/group.help"
5
+
6
+ export const gatewayGroupHandler = factory.createHandlers(
7
+ zValidator("query", z.object({}), help),
8
+ async (c) => {
9
+ const funnel = c.var.funnel
10
+ const status = funnel.gateway.getStatus()
11
+
12
+ if (!status.running) {
13
+ return c.text("funnel gateway: not running", 503)
14
+ }
15
+
16
+ const res = await fetch(`http://localhost:${status.port}/health`).catch(() => null)
17
+
18
+ if (!res) {
19
+ return c.text(`funnel gateway: running (pid ${status.pid}) — health check failed`)
20
+ }
21
+
22
+ const health = (await res.json()) as Record<string, unknown>
23
+
24
+ return c.text(
25
+ `funnel gateway: running (pid ${status.pid})\n port: ${status.port}\n clients: ${health.clients ?? 0}`,
26
+ )
27
+ },
28
+ )
@@ -0,0 +1,13 @@
1
+ export const help = `funnel gateway logs — tail event logs
2
+
3
+ usage: funnel gateway logs [-n <N>]
4
+
5
+ options:
6
+ -n <N> number of trailing lines to show (default: 20)
7
+
8
+ Tails the latest /tmp/funnel/events/*.jsonl file. Exit with SIGINT.
9
+ Output is formatted as YAML.
10
+
11
+ examples:
12
+ funnel gateway logs
13
+ funnel gateway logs -n 100`
@@ -0,0 +1,100 @@
1
+ import { readdirSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { stringify } from "yaml"
4
+ import { z } from "zod"
5
+ import { factory } from "@/factory"
6
+ import { logger } from "@/modules/logger"
7
+ import { zValidator } from "@/modules/router/validator"
8
+ import { help } from "@/routes/gateway/logs.help"
9
+
10
+ const tryParseJson = (s: string): unknown => {
11
+ try {
12
+ return JSON.parse(s)
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ export const gatewayLogsHandler = factory.createHandlers(
19
+ zValidator(
20
+ "query",
21
+ z.object({
22
+ n: z.string().optional(),
23
+ }),
24
+ help,
25
+ ),
26
+ async (c) => {
27
+ const query = c.req.valid("query")
28
+ const funnel = c.var.funnel
29
+ const logDir = funnel.gateway.getLogDir()
30
+
31
+ const files = readdirSync(logDir)
32
+ .filter((name) => name.endsWith(".jsonl"))
33
+ .sort()
34
+
35
+ if (files.length === 0) {
36
+ return c.text("no logs")
37
+ }
38
+
39
+ const latestFile = join(logDir, files[files.length - 1]!)
40
+ const lineCount = query.n ? Number(query.n) : 20
41
+
42
+ const tail = Bun.spawn(["tail", "-f", "-n", String(lineCount), latestFile], {
43
+ stdout: "pipe",
44
+ stderr: "inherit",
45
+ })
46
+
47
+ const forward = (signal: "SIGINT" | "SIGTERM") => {
48
+ tail.kill(signal)
49
+ }
50
+
51
+ process.on("SIGINT", () => forward("SIGINT"))
52
+ process.on("SIGTERM", () => forward("SIGTERM"))
53
+
54
+ const reader = tail.stdout.getReader()
55
+ const decoder = new TextDecoder()
56
+ let buffer = ""
57
+
58
+ logger.info("gateway.logs tail start", { file: latestFile })
59
+
60
+ while (true) {
61
+ const result = await reader.read()
62
+
63
+ if (result.done) break
64
+
65
+ buffer += decoder.decode(result.value, { stream: true })
66
+
67
+ const lines = buffer.split("\n")
68
+ buffer = lines.pop() ?? ""
69
+
70
+ for (const line of lines) {
71
+ if (!line.trim()) continue
72
+
73
+ const entry = tryParseJson(line) as {
74
+ timestamp: string
75
+ eventType: string
76
+ meta?: unknown
77
+ content: string
78
+ } | null
79
+
80
+ if (!entry) {
81
+ process.stdout.write(`${line}\n`)
82
+ continue
83
+ }
84
+
85
+ const parsedContent = tryParseJson(entry.content) ?? entry.content
86
+ const output = {
87
+ time: entry.timestamp,
88
+ type: entry.eventType,
89
+ ...(entry.meta ? { meta: entry.meta } : {}),
90
+ content: parsedContent,
91
+ }
92
+
93
+ process.stdout.write(`---\n${stringify(output)}`)
94
+ }
95
+ }
96
+
97
+ await tail.exited
98
+ process.exit(0)
99
+ },
100
+ )
@@ -0,0 +1,10 @@
1
+ export const help = `funnel gateway restart — restart the gateway
2
+
3
+ usage: funnel gateway restart [--no-caffeine]
4
+
5
+ Stops the running process then starts it again in background.
6
+ On macOS wraps with caffeinate -i by default. Use --no-caffeine to disable.
7
+
8
+ examples:
9
+ funnel gateway restart
10
+ funnel gateway restart --no-caffeine`
@@ -0,0 +1,35 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/gateway/restart.help"
5
+
6
+ export const gatewayRestartHandler = factory.createHandlers(
7
+ zValidator(
8
+ "query",
9
+ z.object({
10
+ "no-caffeine": z.string().optional(),
11
+ }),
12
+ help,
13
+ ),
14
+ async (c) => {
15
+ const query = c.req.valid("query")
16
+ const funnel = c.var.funnel
17
+
18
+ const result = await funnel.gateway.restart({
19
+ caffeinate: query["no-caffeine"] !== "true",
20
+ })
21
+ const lines: string[] = []
22
+
23
+ if (result.wasRunning) {
24
+ lines.push(result.stopped ? "funnel gateway: stopped" : "funnel gateway: failed to stop")
25
+ }
26
+
27
+ if (result.stopped) {
28
+ lines.push(result.started ? "funnel gateway: started" : "funnel gateway: failed to start")
29
+ }
30
+
31
+ const body = lines.join("\n")
32
+
33
+ return result.ok ? c.text(body) : c.text(body, 500)
34
+ },
35
+ )
@@ -0,0 +1,18 @@
1
+ import { factory } from "@/factory"
2
+ import { gatewayGroupHandler } from "@/routes/gateway/group"
3
+ import { gatewayLogsHandler } from "@/routes/gateway/logs"
4
+ import { gatewayRestartHandler } from "@/routes/gateway/restart"
5
+ import { gatewayRunHandler } from "@/routes/gateway/run"
6
+ import { gatewayStartHandler } from "@/routes/gateway/start"
7
+ import { gatewayStatusHandler } from "@/routes/gateway/status"
8
+ import { gatewayStopHandler } from "@/routes/gateway/stop"
9
+
10
+ export const gatewayRoutes = factory
11
+ .createApp()
12
+ .get("/", ...gatewayGroupHandler)
13
+ .get("/status", ...gatewayStatusHandler)
14
+ .get("/start", ...gatewayStartHandler)
15
+ .get("/stop", ...gatewayStopHandler)
16
+ .get("/restart", ...gatewayRestartHandler)
17
+ .get("/run", ...gatewayRunHandler)
18
+ .get("/logs", ...gatewayLogsHandler)
@@ -0,0 +1,12 @@
1
+ export const help = `funnel gateway run — run the gateway in foreground
2
+
3
+ usage: funnel gateway run [--no-caffeine]
4
+
5
+ For developers. The process is tied to the current terminal and exits on SIGINT / SIGTERM.
6
+ On macOS wraps with caffeinate -i by default. Use --no-caffeine to disable.
7
+
8
+ For normal usage prefer funnel gateway start.
9
+
10
+ examples:
11
+ funnel gateway run
12
+ funnel gateway run --no-caffeine`