@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
|
@@ -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
|
+
}
|