@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,27 @@
1
+ import {
2
+ FunnelHttpClient,
3
+ type HttpRequest,
4
+ type HttpResponse,
5
+ } from "@/modules/http/funnel-http-client"
6
+
7
+ export class NodeFunnelHttpClient extends FunnelHttpClient {
8
+ constructor() {
9
+ super()
10
+ Object.freeze(this)
11
+ }
12
+
13
+ async fetch(request: HttpRequest): Promise<HttpResponse> {
14
+ const res = await globalThis.fetch(request.url, {
15
+ method: request.method,
16
+ headers: request.headers,
17
+ body: request.body,
18
+ })
19
+
20
+ return {
21
+ status: res.status,
22
+ ok: res.ok,
23
+ text: () => res.text(),
24
+ json: () => res.json(),
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,26 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+
4
+ const LOG_FILE = join("/tmp/funnel", "funnel.log")
5
+
6
+ type Level = "info" | "warn" | "error"
7
+
8
+ const write = (level: Level, message: string, meta?: Record<string, unknown>) => {
9
+ mkdirSync(dirname(LOG_FILE), { recursive: true })
10
+
11
+ const entry = {
12
+ time: new Date().toISOString(),
13
+ level,
14
+ message,
15
+ ...(meta ? { meta } : {}),
16
+ }
17
+
18
+ appendFileSync(LOG_FILE, `${JSON.stringify(entry)}\n`)
19
+ }
20
+
21
+ export const logger = {
22
+ info: (message: string, meta?: Record<string, unknown>) => write("info", message, meta),
23
+ warn: (message: string, meta?: Record<string, unknown>) => write("warn", message, meta),
24
+ error: (message: string, meta?: Record<string, unknown>) => write("error", message, meta),
25
+ file: LOG_FILE,
26
+ }
@@ -0,0 +1,77 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js"
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
+ import { FUNNEL_MCP_NAME } from "@/modules/mcp/funnel-mcp"
4
+
5
+ const GATEWAY_WS_URL = process.env.FUNNEL_GATEWAY_URL ?? "ws://localhost:9742/ws"
6
+ const RECONNECT_DELAY = 1000
7
+ const MAX_RECONNECT_DELAY = 10000
8
+
9
+ export const startChannelServer = async (): Promise<void> => {
10
+ const server = new Server(
11
+ { name: FUNNEL_MCP_NAME, version: "1.0.0" },
12
+ {
13
+ capabilities: {
14
+ experimental: { "claude/channel": {} },
15
+ },
16
+ instructions: [
17
+ `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
18
+ "",
19
+ 'event_type="slack": a Slack message. meta includes channel_id, user_id, mentioned, thread_ts, etc. content is the Slack event JSON.',
20
+ 'event_type="system": system event (connect / disconnect / startup, etc.).',
21
+ ].join("\n"),
22
+ },
23
+ )
24
+
25
+ const transport = new StdioServerTransport()
26
+
27
+ await server.connect(transport)
28
+
29
+ const channelId = process.env.FUNNEL_CHANNEL_ID
30
+
31
+ if (!channelId) return
32
+
33
+ const wsUrl = `${GATEWAY_WS_URL}?channel=${encodeURIComponent(channelId)}`
34
+ let reconnectDelay = RECONNECT_DELAY
35
+
36
+ const connect = () => {
37
+ const ws = new WebSocket(wsUrl)
38
+
39
+ ws.addEventListener("open", () => {
40
+ reconnectDelay = RECONNECT_DELAY
41
+ process.stderr.write(`funnel: connected (${wsUrl})\n`)
42
+ })
43
+
44
+ ws.addEventListener("message", async (event) => {
45
+ try {
46
+ const payload = JSON.parse(String(event.data))
47
+ const eventType = payload.meta?.event_type ?? "unknown"
48
+
49
+ process.stderr.write(`funnel: received event (${eventType})\n`)
50
+
51
+ await server.notification({
52
+ method: "notifications/claude/channel",
53
+ params: {
54
+ content: payload.content,
55
+ meta: payload.meta,
56
+ },
57
+ })
58
+ } catch (error) {
59
+ process.stderr.write(
60
+ `funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
61
+ )
62
+ }
63
+ })
64
+
65
+ ws.addEventListener("close", () => {
66
+ process.stderr.write(`funnel: disconnected, reconnecting in ${reconnectDelay}ms\n`)
67
+ setTimeout(connect, reconnectDelay)
68
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
69
+ })
70
+
71
+ ws.addEventListener("error", () => {
72
+ // close handler will reconnect
73
+ })
74
+ }
75
+
76
+ connect()
77
+ }
@@ -0,0 +1,107 @@
1
+ import { join } from "node:path"
2
+ import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
+ import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
4
+
5
+ export const FUNNEL_MCP_COMMAND = "funnel"
6
+ export const FUNNEL_MCP_NAME = "funnel"
7
+
8
+ type McpEntry = {
9
+ command?: string
10
+ args?: string[]
11
+ }
12
+
13
+ type McpConfig = {
14
+ mcpServers?: Record<string, McpEntry>
15
+ }
16
+
17
+ type Deps = {
18
+ fs?: FunnelFileSystem
19
+ }
20
+
21
+ const defaultFs = new NodeFunnelFileSystem()
22
+
23
+ export class FunnelMcp {
24
+ private readonly fs: FunnelFileSystem
25
+
26
+ constructor(deps: Deps = {}) {
27
+ this.fs = deps.fs ?? defaultFs
28
+ Object.freeze(this)
29
+ }
30
+
31
+ install(repoPath: string): void {
32
+ if (!this.fs.existsSync(repoPath)) {
33
+ throw new Error(`repository does not exist: ${repoPath}`)
34
+ }
35
+
36
+ const config = this.readConfig(repoPath)
37
+ const servers = config.mcpServers ?? {}
38
+
39
+ const existingName = this.findServerName(servers)
40
+ const targetName = existingName ?? FUNNEL_MCP_NAME
41
+
42
+ servers[targetName] = {
43
+ command: FUNNEL_MCP_COMMAND,
44
+ args: ["mcp"],
45
+ }
46
+
47
+ this.writeConfig(repoPath, { ...config, mcpServers: servers })
48
+ }
49
+
50
+ uninstall(repoPath: string): void {
51
+ if (!this.fs.existsSync(repoPath)) return
52
+
53
+ const config = this.readConfig(repoPath)
54
+ const servers = config.mcpServers ?? {}
55
+
56
+ const name = this.findServerName(servers)
57
+
58
+ if (!name) return
59
+
60
+ const next = { ...servers }
61
+
62
+ delete next[name]
63
+
64
+ this.writeConfig(repoPath, { ...config, mcpServers: next })
65
+ }
66
+
67
+ findInstalledName(cwd: string): string | null {
68
+ const config = this.readConfig(cwd)
69
+
70
+ return this.findServerName(config.mcpServers ?? {})
71
+ }
72
+
73
+ private findServerName(servers: Record<string, McpEntry>): string | null {
74
+ for (const entry of Object.entries(servers)) {
75
+ const name = entry[0]
76
+ const value = entry[1]
77
+
78
+ if (value?.command === FUNNEL_MCP_COMMAND) return name
79
+ }
80
+
81
+ return null
82
+ }
83
+
84
+ private readConfig(repoPath: string): McpConfig {
85
+ const mcpPath = join(repoPath, ".mcp.json")
86
+
87
+ if (!this.fs.existsSync(mcpPath)) return {}
88
+
89
+ const content = this.fs.readFileSync(mcpPath).trim()
90
+
91
+ if (!content) return {}
92
+
93
+ try {
94
+ return JSON.parse(content) as McpConfig
95
+ } catch (error) {
96
+ throw new Error(
97
+ `invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`,
98
+ )
99
+ }
100
+ }
101
+
102
+ private writeConfig(repoPath: string, config: McpConfig): void {
103
+ const mcpPath = join(repoPath, ".mcp.json")
104
+
105
+ this.fs.writeFileSync(mcpPath, `${JSON.stringify(config, null, 2)}\n`)
106
+ }
107
+ }
@@ -0,0 +1,28 @@
1
+ export type RunOptions = {
2
+ cwd?: string
3
+ env?: Record<string, string>
4
+ input?: string
5
+ }
6
+
7
+ export type RunResult = {
8
+ exitCode: number
9
+ stdout: string
10
+ stderr: string
11
+ }
12
+
13
+ export type AttachOptions = {
14
+ cwd?: string
15
+ env?: Record<string, string>
16
+ }
17
+
18
+ export type DetachOptions = {
19
+ env?: Record<string, string>
20
+ }
21
+
22
+ export abstract class FunnelProcessRunner {
23
+ abstract run(command: string[], options?: RunOptions): Promise<RunResult>
24
+ abstract runSync(command: string[]): RunResult
25
+ abstract attach(command: string[], options?: AttachOptions): Promise<number>
26
+ abstract detach(command: string[], options?: DetachOptions): void
27
+ abstract kill(pid: number, signal?: string): void
28
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ type AttachOptions,
3
+ type DetachOptions,
4
+ FunnelProcessRunner,
5
+ type RunOptions,
6
+ type RunResult,
7
+ } from "@/modules/process/funnel-process-runner"
8
+
9
+ export type MemoryProcessResponse = {
10
+ exitCode?: number
11
+ stdout?: string
12
+ stderr?: string
13
+ }
14
+
15
+ export type MemoryProcessHandler = (
16
+ command: string[],
17
+ ) => MemoryProcessResponse | Promise<MemoryProcessResponse>
18
+
19
+ export type MemoryProcessSyncHandler = (command: string[]) => MemoryProcessResponse
20
+
21
+ export type MemoryProcessCall =
22
+ | { kind: "run"; command: string[]; options: RunOptions }
23
+ | { kind: "runSync"; command: string[] }
24
+ | { kind: "attach"; command: string[]; options: AttachOptions }
25
+ | { kind: "detach"; command: string[]; options: DetachOptions }
26
+ | { kind: "kill"; command: string[] }
27
+
28
+ const empty: MemoryProcessResponse = { exitCode: 0, stdout: "", stderr: "" }
29
+
30
+ export class MemoryFunnelProcessRunner extends FunnelProcessRunner {
31
+ readonly calls: MemoryProcessCall[] = []
32
+ readonly killed: { pid: number; signal: string }[] = []
33
+ private handler: MemoryProcessHandler = () => empty
34
+ private syncHandler: MemoryProcessSyncHandler = () => empty
35
+
36
+ on(handler: MemoryProcessHandler): this {
37
+ this.handler = handler
38
+
39
+ return this
40
+ }
41
+
42
+ onSync(handler: MemoryProcessSyncHandler): this {
43
+ this.syncHandler = handler
44
+
45
+ return this
46
+ }
47
+
48
+ async run(command: string[], options: RunOptions = {}): Promise<RunResult> {
49
+ this.calls.push({ kind: "run", command, options })
50
+
51
+ const result = await this.handler(command)
52
+
53
+ return {
54
+ exitCode: result.exitCode ?? 0,
55
+ stdout: result.stdout ?? "",
56
+ stderr: result.stderr ?? "",
57
+ }
58
+ }
59
+
60
+ runSync(command: string[]): RunResult {
61
+ this.calls.push({ kind: "runSync", command })
62
+
63
+ const result = this.syncHandler(command)
64
+
65
+ return {
66
+ exitCode: result.exitCode ?? 0,
67
+ stdout: result.stdout ?? "",
68
+ stderr: result.stderr ?? "",
69
+ }
70
+ }
71
+
72
+ async attach(command: string[], options: AttachOptions = {}): Promise<number> {
73
+ this.calls.push({ kind: "attach", command, options })
74
+
75
+ const result = await this.handler(command)
76
+
77
+ return result.exitCode ?? 0
78
+ }
79
+
80
+ detach(command: string[], options: DetachOptions = {}): void {
81
+ this.calls.push({ kind: "detach", command, options })
82
+ }
83
+
84
+ kill(pid: number, signal: string = "SIGTERM"): void {
85
+ this.calls.push({ kind: "kill", command: [String(pid), signal] })
86
+ this.killed.push({ pid, signal })
87
+ }
88
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ type AttachOptions,
3
+ type DetachOptions,
4
+ FunnelProcessRunner,
5
+ type RunOptions,
6
+ type RunResult,
7
+ } from "@/modules/process/funnel-process-runner"
8
+
9
+ const toEnv = (env?: Record<string, string>): Record<string, string> | undefined => {
10
+ if (!env) return undefined
11
+
12
+ return { ...(process.env as Record<string, string>), ...env }
13
+ }
14
+
15
+ export class NodeFunnelProcessRunner extends FunnelProcessRunner {
16
+ constructor() {
17
+ super()
18
+ Object.freeze(this)
19
+ }
20
+
21
+ runSync(command: string[]): RunResult {
22
+ const result = Bun.spawnSync(command, {
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ })
26
+
27
+ return {
28
+ exitCode: result.exitCode ?? 0,
29
+ stdout: result.stdout.toString(),
30
+ stderr: result.stderr.toString(),
31
+ }
32
+ }
33
+
34
+ async run(command: string[], options: RunOptions = {}): Promise<RunResult> {
35
+ const proc = Bun.spawn(command, {
36
+ cwd: options.cwd,
37
+ env: toEnv(options.env),
38
+ stdin: options.input !== undefined ? "pipe" : "ignore",
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ })
42
+
43
+ if (options.input !== undefined && proc.stdin) {
44
+ proc.stdin.write(options.input)
45
+ proc.stdin.end()
46
+ }
47
+
48
+ const exitCode = await proc.exited
49
+ const stdout = await new Response(proc.stdout).text()
50
+ const stderr = await new Response(proc.stderr).text()
51
+
52
+ return { exitCode, stdout, stderr }
53
+ }
54
+
55
+ async attach(command: string[], options: AttachOptions = {}): Promise<number> {
56
+ const proc = Bun.spawn(command, {
57
+ cwd: options.cwd,
58
+ env: toEnv(options.env),
59
+ stdio: ["inherit", "inherit", "inherit"],
60
+ })
61
+
62
+ const forward = (signal: "SIGINT" | "SIGTERM") => {
63
+ try {
64
+ proc.kill(signal)
65
+ } catch {
66
+ // ignore
67
+ }
68
+
69
+ setTimeout(() => {
70
+ try {
71
+ proc.kill("SIGKILL")
72
+ } catch {
73
+ // ignore
74
+ }
75
+ }, 3000).unref()
76
+ }
77
+
78
+ process.on("SIGINT", () => forward("SIGINT"))
79
+ process.on("SIGTERM", () => forward("SIGTERM"))
80
+
81
+ return await proc.exited
82
+ }
83
+
84
+ detach(command: string[], options: DetachOptions = {}): void {
85
+ const proc = Bun.spawn(command, {
86
+ env: toEnv(options.env),
87
+ stdio: ["ignore", "ignore", "ignore"],
88
+ })
89
+
90
+ proc.unref()
91
+ }
92
+
93
+ kill(pid: number, signal: string = "SIGTERM"): void {
94
+ try {
95
+ process.kill(pid, signal)
96
+ } catch {
97
+ // ignore
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,107 @@
1
+ import type { FunnelMcp } from "@/modules/mcp/funnel-mcp"
2
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
3
+ import type { RepositoryConfig } from "@/modules/settings/settings-schema"
4
+
5
+ type Deps = {
6
+ store: FunnelSettingsReader
7
+ mcp: FunnelMcp
8
+ }
9
+
10
+ export class FunnelRepositories {
11
+ private readonly store: FunnelSettingsReader
12
+ private readonly mcp: FunnelMcp
13
+
14
+ constructor(deps: Deps) {
15
+ this.store = deps.store
16
+ this.mcp = deps.mcp
17
+ Object.freeze(this)
18
+ }
19
+
20
+ list(): RepositoryConfig[] {
21
+ return this.store.read().repositories
22
+ }
23
+
24
+ get(name: string): RepositoryConfig | null {
25
+ return this.list().find((r) => r.name === name) ?? null
26
+ }
27
+
28
+ add(config: RepositoryConfig): void {
29
+ const settings = this.store.read()
30
+
31
+ if (settings.repositories.some((r) => r.name === config.name)) {
32
+ throw new Error(`repo "${config.name}" already exists`)
33
+ }
34
+
35
+ this.mcp.install(config.path)
36
+
37
+ settings.repositories.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.repositories.findIndex((r) => r.name === name)
46
+
47
+ if (index < 0) throw new Error(`repo "${name}" not found`)
48
+
49
+ if (settings.agents.some((a) => a.repo === name)) {
50
+ throw new Error(`repo "${name}" is referenced by an agent`)
51
+ }
52
+
53
+ const repo = settings.repositories[index]!
54
+
55
+ this.mcp.uninstall(repo.path)
56
+
57
+ settings.repositories.splice(index, 1)
58
+
59
+ this.store.write(settings)
60
+ }
61
+
62
+ rename(oldName: string, newName: string): void {
63
+ const settings = this.store.read()
64
+
65
+ const repo = settings.repositories.find((r) => r.name === oldName)
66
+
67
+ if (!repo) throw new Error(`repo "${oldName}" not found`)
68
+
69
+ if (settings.repositories.some((r) => r.name === newName)) {
70
+ throw new Error(`repo "${newName}" already exists`)
71
+ }
72
+
73
+ repo.name = newName
74
+
75
+ for (const agent of settings.agents) {
76
+ if (agent.repo === oldName) agent.repo = newName
77
+ }
78
+
79
+ this.store.write(settings)
80
+ }
81
+
82
+ update(name: string, fields: Partial<Pick<RepositoryConfig, "path">>): void {
83
+ const settings = this.store.read()
84
+
85
+ const repo = settings.repositories.find((r) => r.name === name)
86
+
87
+ if (!repo) throw new Error(`repo "${name}" not found`)
88
+
89
+ if (fields.path !== undefined && fields.path !== repo.path) {
90
+ this.mcp.uninstall(repo.path)
91
+
92
+ this.mcp.install(fields.path)
93
+
94
+ repo.path = fields.path
95
+ }
96
+
97
+ this.store.write(settings)
98
+ }
99
+
100
+ resolvePath(name: string): string {
101
+ const repo = this.get(name)
102
+
103
+ if (!repo) throw new Error(`repo "${name}" not found`)
104
+
105
+ return repo.path
106
+ }
107
+ }
@@ -0,0 +1,20 @@
1
+ const BUILTIN_SKIP = new Set(["help"])
2
+
3
+ export const queryToCliArgs = (url: string, reservedKeys: string[] = []): string[] => {
4
+ const skipped = new Set([...BUILTIN_SKIP, ...reservedKeys])
5
+ const args: string[] = []
6
+ const searchParams = new URL(url).searchParams
7
+
8
+ for (const entry of searchParams.entries()) {
9
+ const key = entry[0]
10
+ const value = entry[1]
11
+
12
+ if (skipped.has(key)) continue
13
+
14
+ args.push(`--${key}`)
15
+
16
+ if (value !== "true") args.push(value)
17
+ }
18
+
19
+ return args
20
+ }
@@ -0,0 +1,122 @@
1
+ const SHORT_FLAGS: Record<string, string> = {
2
+ h: "help",
3
+ n: "name",
4
+ }
5
+
6
+ const STRIPPED_METHOD_KEYWORDS: Record<string, string> = {
7
+ add: "POST",
8
+ remove: "DELETE",
9
+ set: "PUT",
10
+ update: "PUT",
11
+ }
12
+
13
+ const KEPT_METHOD_KEYWORDS: Record<string, string> = {
14
+ rename: "PUT",
15
+ attach: "PUT",
16
+ detach: "DELETE",
17
+ default: "PUT",
18
+ }
19
+
20
+ const API_CALL_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"])
21
+
22
+ const isValue = (arg: string | undefined): arg is string => {
23
+ return typeof arg === "string" && !arg.startsWith("-")
24
+ }
25
+
26
+ const consumeApiCall = (args: string[], i: number, params: URLSearchParams): number => {
27
+ params.set("method", args[i]!)
28
+
29
+ const nextPath = args[i + 1]
30
+
31
+ if (!isValue(nextPath)) return 1
32
+
33
+ params.set("path", nextPath)
34
+
35
+ const nextBody = args[i + 2]
36
+
37
+ if (!isValue(nextBody)) return 2
38
+
39
+ params.set("body", nextBody)
40
+
41
+ return 3
42
+ }
43
+
44
+ export const toRequest = (args: string[]) => {
45
+ const segments: string[] = []
46
+ const params = new URLSearchParams()
47
+ let method = "GET"
48
+
49
+ let i = 0
50
+ while (i < args.length) {
51
+ const arg = args[i]!
52
+
53
+ if (arg.startsWith("--")) {
54
+ const key = arg.slice(2)
55
+ const next = args[i + 1]
56
+
57
+ if (isValue(next)) {
58
+ params.set(key, next)
59
+ i += 2
60
+ } else {
61
+ params.set(key, "true")
62
+ i++
63
+ }
64
+
65
+ continue
66
+ }
67
+
68
+ if (arg.startsWith("-") && arg.length === 2) {
69
+ const long = SHORT_FLAGS[arg[1]!]
70
+
71
+ if (!long) {
72
+ i++
73
+ continue
74
+ }
75
+
76
+ const next = args[i + 1]
77
+
78
+ if (isValue(next)) {
79
+ params.set(long, next)
80
+ i += 2
81
+ } else {
82
+ params.set(long, "true")
83
+ i++
84
+ }
85
+
86
+ continue
87
+ }
88
+
89
+ if (STRIPPED_METHOD_KEYWORDS[arg]) {
90
+ method = STRIPPED_METHOD_KEYWORDS[arg]!
91
+ i++
92
+ continue
93
+ }
94
+
95
+ if (KEPT_METHOD_KEYWORDS[arg]) {
96
+ method = KEPT_METHOD_KEYWORDS[arg]!
97
+ segments.push(arg)
98
+ i++
99
+ continue
100
+ }
101
+
102
+ if (API_CALL_METHODS.has(arg) && !params.has("method")) {
103
+ segments.push("call")
104
+ i += consumeApiCall(args, i, params)
105
+ continue
106
+ }
107
+
108
+ if (arg.includes("/") && !params.has("path")) {
109
+ params.set("path", arg)
110
+ i++
111
+ continue
112
+ }
113
+
114
+ segments.push(arg)
115
+ i++
116
+ }
117
+
118
+ const path = segments.length > 0 ? `/${segments.join("/")}` : "/"
119
+ const query = params.size > 0 ? `?${params}` : ""
120
+
121
+ return { method, path, url: `http://localhost${path}${query}` }
122
+ }