@interactive-inc/claude-funnel 0.4.1 → 0.8.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/README.md +233 -111
- package/dist/bin.js +1417 -0
- package/dist/gateway/daemon.js +513 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/lib/bin.ts +78 -0
- package/lib/{modules → cli}/router/to-request.ts +13 -20
- package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +27 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +40 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +41 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +23 -0
- package/lib/cli/routes/channels.$channel.connectors.$connector.ts +26 -0
- package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +92 -0
- package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +22 -0
- package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +63 -0
- package/lib/cli/routes/channels.$channel.connectors.ts +26 -0
- package/lib/cli/routes/channels.$channel.rename.$newName.ts +22 -0
- package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +34 -0
- package/lib/cli/routes/channels.$channel.ts +34 -0
- package/lib/cli/routes/channels.add.$channel.ts +33 -0
- package/lib/cli/routes/channels.remove.$channel.ts +20 -0
- package/lib/cli/routes/channels.ts +39 -0
- package/lib/cli/routes/claude.ts +69 -0
- package/lib/cli/routes/gateway.listeners.ts +41 -0
- package/lib/cli/routes/gateway.logs.ts +123 -0
- package/lib/{routes/gateway/restart.ts → cli/routes/gateway.restart.ts} +20 -5
- package/lib/cli/routes/gateway.run.ts +41 -0
- package/lib/cli/routes/gateway.start.ts +50 -0
- package/lib/cli/routes/gateway.status.ts +19 -0
- package/lib/cli/routes/gateway.stop.ts +32 -0
- package/lib/cli/routes/gateway.ts +55 -0
- package/lib/cli/routes/index.ts +202 -0
- package/lib/cli/routes/profiles.$profile.as-default.ts +22 -0
- package/lib/cli/routes/profiles.$profile.rename.$newName.ts +22 -0
- package/lib/cli/routes/profiles.$profile.run.ts +36 -0
- package/lib/cli/routes/profiles.add.$profile.ts +46 -0
- package/lib/cli/routes/profiles.remove.$profile.ts +20 -0
- package/lib/cli/routes/profiles.set.$profile.ts +46 -0
- package/lib/cli/routes/profiles.ts +40 -0
- package/lib/cli/routes/status.ts +93 -0
- package/lib/cli/routes/update.ts +27 -0
- package/lib/connectors/connector-config-schema.ts +16 -0
- package/lib/connectors/connector-factory.ts +94 -0
- package/lib/connectors/connector-listener.ts +20 -0
- package/lib/{modules/connectors/funnel-discord-adapter.ts → connectors/discord-adapter.ts} +6 -11
- package/lib/{modules/connectors → connectors}/discord-connector-schema.ts +4 -1
- package/lib/connectors/discord-listener.ts +111 -0
- package/lib/{modules/connectors/funnel-gh-adapter.ts → connectors/gh-adapter.ts} +3 -6
- package/lib/{modules/connectors → connectors}/gh-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-gh-listener.ts → connectors/gh-listener.ts} +57 -22
- package/lib/{modules/connectors → connectors}/match-cron.ts +10 -4
- package/lib/connectors/schedule-connector-schema.ts +33 -0
- package/lib/connectors/schedule-listener.ts +207 -0
- package/lib/connectors/schedule-state-store.ts +54 -0
- package/lib/connectors/slack-adapter.ts +36 -0
- package/lib/{modules/connectors → connectors}/slack-connector-schema.ts +4 -1
- package/lib/{modules/connectors/funnel-slack-event-processor.ts → connectors/slack-event-processor.ts} +15 -9
- package/lib/{modules/connectors/funnel-slack-listener.ts → connectors/slack-listener.ts} +39 -14
- package/lib/engine/channels/channels.ts +520 -0
- package/lib/{modules/claude/funnel-claude.ts → engine/claude/claude.ts} +47 -62
- package/lib/engine/claude/gateway-controller.ts +4 -0
- package/lib/{modules/fs/funnel-file-system.ts → engine/fs/file-system.ts} +9 -0
- package/lib/{modules/fs/memory-funnel-file-system.ts → engine/fs/memory-file-system.ts} +20 -3
- package/lib/{modules/fs/node-funnel-file-system.ts → engine/fs/node-file-system.ts} +14 -2
- package/lib/{modules/http/memory-funnel-http-client.ts → engine/http/memory-http-client.ts} +1 -5
- package/lib/{modules/http/node-funnel-http-client.ts → engine/http/node-http-client.ts} +1 -5
- package/lib/engine/id/id-generator.ts +7 -0
- package/lib/engine/id/memory-id-generator.ts +20 -0
- package/lib/engine/id/node-id-generator.ts +7 -0
- package/lib/engine/logger/logger.ts +11 -0
- package/lib/engine/logger/memory-logger.ts +28 -0
- package/lib/engine/logger/node-logger.ts +49 -0
- package/lib/engine/logger/noop-logger.ts +9 -0
- package/lib/engine/mcp/channel-server.ts +204 -0
- package/lib/{modules/mcp/funnel-mcp.ts → engine/mcp/mcp.ts} +29 -10
- package/lib/{modules/process/memory-funnel-process-runner.ts → engine/process/memory-process-runner.ts} +1 -1
- package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts} +12 -21
- package/lib/{modules/process/funnel-process-runner.ts → engine/process/process-runner.ts} +5 -0
- package/lib/engine/profiles/profile-channel-checker.ts +7 -0
- package/lib/engine/profiles/profiles.ts +126 -0
- package/lib/{modules/settings/mock-funnel-settings-reader.ts → engine/settings/mock-settings-reader.ts} +4 -3
- package/lib/{modules/settings/funnel-settings-reader.ts → engine/settings/settings-reader.ts} +1 -1
- package/lib/engine/settings/settings-schema.ts +46 -0
- package/lib/engine/settings/settings-store.ts +110 -0
- package/lib/engine/time/clock.ts +15 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/funnel.ts +148 -56
- package/lib/gateway/auth-middleware.ts +44 -0
- package/lib/gateway/broadcaster.ts +319 -0
- package/lib/gateway/daemon.ts +47 -0
- package/lib/gateway/factory.ts +10 -0
- package/lib/gateway/funnel-event-store.ts +155 -0
- package/lib/gateway/gateway-server.ts +414 -0
- package/lib/gateway/gateway-token.ts +79 -0
- package/lib/{modules/gateway/funnel-gateway.ts → gateway/gateway.ts} +70 -27
- package/lib/{modules/gateway → gateway}/kill-competing-slack-gateways.ts +7 -3
- package/lib/gateway/listener-supervisor.ts +339 -0
- package/lib/gateway/listeners-client.ts +128 -0
- package/lib/gateway/resolve-daemon-script.ts +26 -0
- package/lib/gateway/routes/channels.connectors.call.ts +39 -0
- package/lib/gateway/routes/health.ts +13 -0
- package/lib/gateway/routes/index.ts +24 -0
- package/lib/gateway/routes/listeners.list.ts +6 -0
- package/lib/gateway/routes/listeners.restart.ts +15 -0
- package/lib/gateway/routes/listeners.start.ts +15 -0
- package/lib/gateway/routes/listeners.stop.ts +15 -0
- package/lib/gateway/routes/route-deps.ts +11 -0
- package/lib/gateway/routes/status.ts +15 -0
- package/lib/gateway/routes/validator.ts +17 -0
- package/lib/index.ts +50 -92
- package/lib/logger/leuco-human-file-writer.ts +65 -0
- package/lib/logger/leuco-human-logger.ts +98 -0
- package/lib/logger/leuco-human-record.ts +16 -0
- package/lib/logger/leuco-human-stdout-writer.ts +26 -0
- package/lib/logger/leuco-human-writer.ts +14 -0
- package/lib/logger/leuco-logger-memory-sink.ts +67 -0
- package/lib/logger/leuco-logger-record.ts +13 -0
- package/lib/logger/leuco-logger-sink.ts +33 -0
- package/lib/logger/leuco-logger-sqlite-sink.ts +355 -0
- package/lib/logger/leuco-logger.ts +135 -0
- package/lib/tui/app.tsx +357 -0
- package/lib/tui/components/add-row.tsx +18 -0
- package/lib/tui/components/brand.tsx +27 -0
- package/lib/tui/components/card.tsx +44 -0
- package/lib/tui/components/detail-bar.tsx +46 -0
- package/lib/tui/components/editable-field.tsx +33 -0
- package/lib/tui/components/empty-state.tsx +11 -0
- package/lib/tui/components/gateway-status.tsx +66 -0
- package/lib/tui/components/keymap.tsx +29 -0
- package/lib/tui/components/menu-item.tsx +73 -0
- package/lib/tui/components/menu.tsx +26 -0
- package/lib/tui/components/panel-header.tsx +22 -0
- package/lib/tui/components/readonly-field.tsx +18 -0
- package/lib/tui/components/section-header.tsx +25 -0
- package/lib/tui/components/selection-accent.tsx +32 -0
- package/lib/tui/components/session-item.tsx +33 -0
- package/lib/tui/components/session-list.tsx +33 -0
- package/lib/tui/components/ui/hascii/accordion-item.tsx +88 -0
- package/lib/tui/components/ui/hascii/accordion.tsx +96 -0
- package/lib/tui/components/ui/hascii/alert-dialog.tsx +43 -0
- package/lib/tui/components/ui/hascii/badge.tsx +51 -0
- package/lib/tui/components/ui/hascii/breadcrumb.tsx +58 -0
- package/lib/tui/components/ui/hascii/button.tsx +194 -0
- package/lib/tui/components/ui/hascii/card-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/card-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/card-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/card.tsx +27 -0
- package/lib/tui/components/ui/hascii/checkbox.tsx +65 -0
- package/lib/tui/components/ui/hascii/command.tsx +159 -0
- package/lib/tui/components/ui/hascii/dialog-content.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-description.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog-footer.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/dialog-title.tsx +13 -0
- package/lib/tui/components/ui/hascii/dialog.tsx +27 -0
- package/lib/tui/components/ui/hascii/file-tree.tsx +142 -0
- package/lib/tui/components/ui/hascii/focus-group.tsx +62 -0
- package/lib/tui/components/ui/hascii/form-item.tsx +43 -0
- package/lib/tui/components/ui/hascii/input-otp.tsx +86 -0
- package/lib/tui/components/ui/hascii/input.tsx +130 -0
- package/lib/tui/components/ui/hascii/pagination.tsx +105 -0
- package/lib/tui/components/ui/hascii/progress.tsx +28 -0
- package/lib/tui/components/ui/hascii/select.tsx +131 -0
- package/lib/tui/components/ui/hascii/separator.tsx +35 -0
- package/lib/tui/components/ui/hascii/sidebar-content.tsx +23 -0
- package/lib/tui/components/ui/hascii/sidebar-header.tsx +14 -0
- package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +67 -0
- package/lib/tui/components/ui/hascii/sidebar.tsx +24 -0
- package/lib/tui/components/ui/hascii/skeleton.tsx +60 -0
- package/lib/tui/components/ui/hascii/slider.tsx +91 -0
- package/lib/tui/components/ui/hascii/snackbar.tsx +75 -0
- package/lib/tui/components/ui/hascii/sparkline.tsx +53 -0
- package/lib/tui/components/ui/hascii/spinner.tsx +47 -0
- package/lib/tui/components/ui/hascii/stepper.tsx +54 -0
- package/lib/tui/components/ui/hascii/switch.tsx +66 -0
- package/lib/tui/components/ui/hascii/table.tsx +95 -0
- package/lib/tui/components/ui/hascii/tabs.tsx +59 -0
- package/lib/tui/components/ui/hascii/toggle-group-item.tsx +45 -0
- package/lib/tui/components/ui/hascii/toggle-group.tsx +99 -0
- package/lib/tui/components/ui/hascii/tree.tsx +104 -0
- package/lib/tui/components/view-shell.tsx +44 -0
- package/lib/tui/filter-input.tsx +33 -0
- package/lib/tui/hooks/hascii/use-pressable.ts +54 -0
- package/lib/tui/parse-comma-list.ts +14 -0
- package/lib/tui/profile-launcher.tsx +61 -0
- package/lib/tui/scrollbar-options.ts +19 -0
- package/lib/tui/sidebar.tsx +50 -0
- package/lib/tui/theme.ts +40 -0
- package/lib/tui/tui.tsx +20 -0
- package/lib/tui/types.ts +38 -0
- package/lib/tui/unique-name.ts +18 -0
- package/lib/tui/use-event-stream.ts +133 -0
- package/lib/tui/use-snapshot.ts +99 -0
- package/lib/tui/utils/hascii/form-item-context.tsx +23 -0
- package/lib/tui/utils/hascii/input-focus-context.tsx +31 -0
- package/lib/tui/utils/hascii/theme-context.tsx +26 -0
- package/lib/tui/utils/hascii/theme.ts +176 -0
- package/lib/tui/views/channels-view.tsx +108 -0
- package/lib/tui/views/connectors-view.tsx +164 -0
- package/lib/tui/views/events-view.tsx +160 -0
- package/lib/tui/views/listeners-view.tsx +80 -0
- package/lib/tui/views/profiles-view.tsx +152 -0
- package/package.json +51 -34
- package/lib/modules/channels/channel-connector-ref-updater.ts +0 -4
- package/lib/modules/channels/funnel-channels.ts +0 -155
- package/lib/modules/connectors/connector-config-schema.ts +0 -16
- package/lib/modules/connectors/connector-existence-checker.ts +0 -3
- package/lib/modules/connectors/funnel-callable-connector-store.ts +0 -9
- package/lib/modules/connectors/funnel-connector-listener.ts +0 -5
- package/lib/modules/connectors/funnel-connector-stores.ts +0 -24
- package/lib/modules/connectors/funnel-connector-type-store.ts +0 -24
- package/lib/modules/connectors/funnel-connectors.ts +0 -145
- package/lib/modules/connectors/funnel-discord-listener.ts +0 -65
- package/lib/modules/connectors/funnel-discord-store.ts +0 -84
- package/lib/modules/connectors/funnel-gh-store.ts +0 -84
- package/lib/modules/connectors/funnel-json-connector-store.ts +0 -100
- package/lib/modules/connectors/funnel-schedule-listener.ts +0 -124
- package/lib/modules/connectors/funnel-schedule-store.ts +0 -178
- package/lib/modules/connectors/funnel-slack-adapter.ts +0 -31
- package/lib/modules/connectors/funnel-slack-store.ts +0 -86
- package/lib/modules/connectors/migrate-legacy-connectors.ts +0 -77
- package/lib/modules/connectors/schedule-connector-schema.ts +0 -18
- package/lib/modules/connectors/schedule-last-fired-store.ts +0 -48
- package/lib/modules/gateway/daemon.ts +0 -207
- package/lib/modules/gateway/funnel-broadcaster.ts +0 -37
- package/lib/modules/gateway/funnel-event-logger.ts +0 -59
- package/lib/modules/logger.ts +0 -26
- package/lib/modules/mcp/channel-server.ts +0 -76
- package/lib/modules/profiles/funnel-profiles.ts +0 -123
- package/lib/modules/profiles/profile-channel-checker.ts +0 -3
- package/lib/modules/profiles/profile-channel-ref-updater.ts +0 -3
- package/lib/modules/repos/funnel-repositories.ts +0 -107
- package/lib/modules/schedule/funnel-schedule.ts +0 -34
- package/lib/modules/settings/funnel-settings-store.ts +0 -56
- package/lib/modules/settings/settings-schema.ts +0 -33
- package/lib/modules/tui/app.tsx +0 -44
- package/lib/modules/tui/tui.tsx +0 -13
- package/lib/routes/channels/add.help.ts +0 -3
- package/lib/routes/channels/add.ts +0 -21
- package/lib/routes/channels/connectors-attach.help.ts +0 -3
- package/lib/routes/channels/connectors-attach.ts +0 -17
- package/lib/routes/channels/connectors-detach.help.ts +0 -3
- package/lib/routes/channels/connectors-detach.ts +0 -17
- package/lib/routes/channels/group.help.ts +0 -16
- package/lib/routes/channels/group.ts +0 -22
- package/lib/routes/channels/remove.help.ts +0 -3
- package/lib/routes/channels/remove.ts +0 -17
- package/lib/routes/channels/rename.help.ts +0 -5
- package/lib/routes/channels/rename.ts +0 -17
- package/lib/routes/channels/routes.ts +0 -19
- package/lib/routes/channels/show.help.ts +0 -1
- package/lib/routes/channels/show.ts +0 -26
- package/lib/routes/claude/claude.help.ts +0 -16
- package/lib/routes/claude/claude.ts +0 -76
- package/lib/routes/claude/routes.ts +0 -4
- package/lib/routes/connectors/add.help.ts +0 -28
- package/lib/routes/connectors/add.ts +0 -64
- package/lib/routes/connectors/group.help.ts +0 -14
- package/lib/routes/connectors/group.ts +0 -18
- package/lib/routes/connectors/remove.help.ts +0 -3
- package/lib/routes/connectors/remove.ts +0 -17
- package/lib/routes/connectors/rename.help.ts +0 -5
- package/lib/routes/connectors/rename.ts +0 -17
- package/lib/routes/connectors/routes.ts +0 -23
- package/lib/routes/connectors/schedules-add.help.ts +0 -11
- package/lib/routes/connectors/schedules-add.ts +0 -33
- package/lib/routes/connectors/schedules-group.help.ts +0 -1
- package/lib/routes/connectors/schedules-group.ts +0 -38
- package/lib/routes/connectors/schedules-remove.help.ts +0 -3
- package/lib/routes/connectors/schedules-remove.ts +0 -17
- package/lib/routes/connectors/set.help.ts +0 -8
- package/lib/routes/connectors/set.ts +0 -72
- package/lib/routes/connectors/show.help.ts +0 -1
- package/lib/routes/connectors/show.ts +0 -41
- package/lib/routes/gateway/group.help.ts +0 -15
- package/lib/routes/gateway/group.ts +0 -28
- package/lib/routes/gateway/logs.help.ts +0 -13
- package/lib/routes/gateway/logs.ts +0 -100
- package/lib/routes/gateway/restart.help.ts +0 -10
- package/lib/routes/gateway/routes.ts +0 -18
- package/lib/routes/gateway/run.help.ts +0 -12
- package/lib/routes/gateway/run.ts +0 -35
- package/lib/routes/gateway/start.help.ts +0 -15
- package/lib/routes/gateway/start.ts +0 -32
- package/lib/routes/gateway/status.help.ts +0 -9
- package/lib/routes/gateway/status.ts +0 -28
- package/lib/routes/gateway/stop.help.ts +0 -8
- package/lib/routes/gateway/stop.ts +0 -21
- package/lib/routes/profiles/add.help.ts +0 -3
- package/lib/routes/profiles/add.ts +0 -33
- package/lib/routes/profiles/group.help.ts +0 -16
- package/lib/routes/profiles/group.ts +0 -25
- package/lib/routes/profiles/launch.help.ts +0 -4
- package/lib/routes/profiles/launch.ts +0 -36
- package/lib/routes/profiles/remove.help.ts +0 -3
- package/lib/routes/profiles/remove.ts +0 -17
- package/lib/routes/profiles/rename.help.ts +0 -5
- package/lib/routes/profiles/rename.ts +0 -17
- package/lib/routes/profiles/routes.ts +0 -18
- package/lib/routes/profiles/set.help.ts +0 -5
- package/lib/routes/profiles/set.ts +0 -32
- package/lib/routes/repos/add.help.ts +0 -6
- package/lib/routes/repos/add.ts +0 -20
- package/lib/routes/repos/group.help.ts +0 -11
- package/lib/routes/repos/group.ts +0 -18
- package/lib/routes/repos/remove.help.ts +0 -3
- package/lib/routes/repos/remove.ts +0 -17
- package/lib/routes/repos/rename.help.ts +0 -5
- package/lib/routes/repos/rename.ts +0 -17
- package/lib/routes/repos/routes.ts +0 -17
- package/lib/routes/repos/set.help.ts +0 -5
- package/lib/routes/repos/set.ts +0 -21
- package/lib/routes/repos/show.help.ts +0 -1
- package/lib/routes/repos/show.ts +0 -19
- package/lib/routes/request/discord-help.ts +0 -9
- package/lib/routes/request/discord.help.ts +0 -19
- package/lib/routes/request/discord.ts +0 -65
- package/lib/routes/request/group.help.ts +0 -15
- package/lib/routes/request/group.ts +0 -9
- package/lib/routes/request/routes.ts +0 -14
- package/lib/routes/request/slack-help.ts +0 -9
- package/lib/routes/request/slack.help.ts +0 -19
- package/lib/routes/request/slack.ts +0 -61
- package/lib/routes/status/routes.ts +0 -4
- package/lib/routes/status/status.help.ts +0 -6
- package/lib/routes/status/status.ts +0 -77
- package/lib/routes/update/routes.ts +0 -4
- package/lib/routes/update/update.help.ts +0 -5
- package/lib/routes/update/update.ts +0 -21
- package/lib/routes.ts +0 -40
- /package/lib/{factory.ts → cli/factory.ts} +0 -0
- /package/lib/{modules → cli}/router/query-to-cli-args.ts +0 -0
- /package/lib/{modules → cli}/router/validator.ts +0 -0
- /package/lib/{modules/connectors/funnel-connector-adapter.ts → connectors/connector-adapter.ts} +0 -0
- /package/lib/{modules/connectors/funnel-discord-event-processor.ts → connectors/discord-event-processor.ts} +0 -0
- /package/lib/{modules/http/funnel-http-client.ts → engine/http/http-client.ts} +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js"
|
|
10
|
+
import { FUNNEL_MCP_NAME } from "@/engine/mcp/mcp"
|
|
11
|
+
import { settingsSchema } from "@/engine/settings/settings-schema"
|
|
12
|
+
|
|
13
|
+
const GATEWAY_BASE_URL = process.env.FUNNEL_GATEWAY_URL ?? "http://localhost:9742"
|
|
14
|
+
const GATEWAY_WS_URL = `${GATEWAY_BASE_URL.replace(/^http/, "ws")}/ws`
|
|
15
|
+
const RECONNECT_DELAY = 1000
|
|
16
|
+
const MAX_RECONNECT_DELAY = 10000
|
|
17
|
+
const SETTINGS_PATH = join(homedir(), ".funnel", "settings.json")
|
|
18
|
+
const TOOL_CONNECTOR_TYPES = new Set(["slack", "gh", "discord"])
|
|
19
|
+
|
|
20
|
+
const readGatewayToken = (): string | null => {
|
|
21
|
+
const fromEnv = process.env.FUNNEL_GATEWAY_TOKEN
|
|
22
|
+
|
|
23
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv
|
|
24
|
+
|
|
25
|
+
const path = join(homedir(), ".funnel", "gateway.token")
|
|
26
|
+
|
|
27
|
+
if (!existsSync(path)) return null
|
|
28
|
+
|
|
29
|
+
const value = readFileSync(path, "utf-8").trim()
|
|
30
|
+
|
|
31
|
+
return value.length > 0 ? value : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const readChannelConnectors = (
|
|
35
|
+
channelId: string,
|
|
36
|
+
): { channelName: string; connectors: { name: string; type: string }[] } | null => {
|
|
37
|
+
if (!existsSync(SETTINGS_PATH)) return null
|
|
38
|
+
|
|
39
|
+
const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"))
|
|
40
|
+
const parsed = settingsSchema.safeParse(raw)
|
|
41
|
+
|
|
42
|
+
if (!parsed.success) return null
|
|
43
|
+
|
|
44
|
+
const channel = parsed.data.channels.find((c) => c.id === channelId)
|
|
45
|
+
|
|
46
|
+
if (!channel) return null
|
|
47
|
+
|
|
48
|
+
const connectors = channel.connectors
|
|
49
|
+
.filter((c) => TOOL_CONNECTOR_TYPES.has(c.type))
|
|
50
|
+
.map((c) => ({ name: c.name, type: c.type }))
|
|
51
|
+
|
|
52
|
+
return { channelName: channel.name, connectors }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const usageHintForType = (type: string): string => {
|
|
56
|
+
if (type === "slack") {
|
|
57
|
+
return "Slack Web API. method=POST path=chat.postMessage body={channel,text,thread_ts?}"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (type === "discord") {
|
|
61
|
+
return "Discord REST API. method=POST path=/channels/<id>/messages body={content,...}"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (type === "gh") {
|
|
65
|
+
return "GitHub REST via gh CLI. method=POST path=repos/owner/repo/issues/N/comments body={body}"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return "Generic adapter call."
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const startChannelServer = async (): Promise<void> => {
|
|
72
|
+
const channelId = process.env.FUNNEL_CHANNEL_ID
|
|
73
|
+
const channel = channelId ? readChannelConnectors(channelId) : null
|
|
74
|
+
const token = readGatewayToken()
|
|
75
|
+
|
|
76
|
+
const server = new Server(
|
|
77
|
+
{ name: FUNNEL_MCP_NAME, version: "1.0.0" },
|
|
78
|
+
{
|
|
79
|
+
capabilities: {
|
|
80
|
+
experimental: { "claude/channel": {} },
|
|
81
|
+
tools: {},
|
|
82
|
+
},
|
|
83
|
+
instructions: [
|
|
84
|
+
`Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
|
|
85
|
+
"",
|
|
86
|
+
"To reply or act, call the connector tool exposed by this MCP (one tool per connector configured on this channel). Each tool takes { method, path, body } matching the underlying adapter's CallInput.",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
92
|
+
const tools = (channel?.connectors ?? []).map((c) => ({
|
|
93
|
+
name: c.name,
|
|
94
|
+
description: `Call the "${c.name}" (${c.type}) connector. ${usageHintForType(c.type)}`,
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object" as const,
|
|
97
|
+
properties: {
|
|
98
|
+
method: { type: "string", description: "HTTP verb or API method (e.g. POST, chat.postMessage)" },
|
|
99
|
+
path: { type: "string", description: "API path or method name (adapter-specific)" },
|
|
100
|
+
body: { type: "object", description: "Request body / params (adapter-specific)" },
|
|
101
|
+
},
|
|
102
|
+
required: ["method", "path"],
|
|
103
|
+
},
|
|
104
|
+
}))
|
|
105
|
+
|
|
106
|
+
return { tools }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
110
|
+
if (!channel) {
|
|
111
|
+
throw new Error("FUNNEL_CHANNEL_ID is not set or channel not found in settings.json")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const connectorName = request.params.name
|
|
115
|
+
const args = (request.params.arguments ?? {}) as Record<string, unknown>
|
|
116
|
+
const method = typeof args.method === "string" ? args.method : ""
|
|
117
|
+
const path = typeof args.path === "string" ? args.path : ""
|
|
118
|
+
const body = args.body ?? {}
|
|
119
|
+
|
|
120
|
+
if (!method || !path) {
|
|
121
|
+
throw new Error("`method` and `path` are required")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const url = `${GATEWAY_BASE_URL}/channels/${encodeURIComponent(channel.channelName)}/connectors/${encodeURIComponent(connectorName)}/call`
|
|
125
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
126
|
+
|
|
127
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
128
|
+
|
|
129
|
+
const res = await fetch(url, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers,
|
|
132
|
+
body: JSON.stringify({ method, path, body }),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const text = await res.text()
|
|
136
|
+
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
throw new Error(`gateway call failed (${res.status}): ${text}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text }],
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const transport = new StdioServerTransport()
|
|
147
|
+
|
|
148
|
+
await server.connect(transport)
|
|
149
|
+
|
|
150
|
+
if (!channelId) return
|
|
151
|
+
|
|
152
|
+
const baseUrl = `${GATEWAY_WS_URL}?channel=${encodeURIComponent(channelId)}`
|
|
153
|
+
const protocols = token ? [`funnel.token.${token}`] : undefined
|
|
154
|
+
let reconnectDelay = RECONNECT_DELAY
|
|
155
|
+
let lastOffset = 0
|
|
156
|
+
|
|
157
|
+
const connect = () => {
|
|
158
|
+
const sinceQuery = lastOffset > 0 ? `&since=${lastOffset}` : ""
|
|
159
|
+
const wsUrl = `${baseUrl}${sinceQuery}`
|
|
160
|
+
const ws = new WebSocket(wsUrl, protocols)
|
|
161
|
+
|
|
162
|
+
ws.addEventListener("open", () => {
|
|
163
|
+
reconnectDelay = RECONNECT_DELAY
|
|
164
|
+
process.stderr.write(`funnel: connected (${wsUrl})\n`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
ws.addEventListener("message", async (event) => {
|
|
168
|
+
try {
|
|
169
|
+
const payload = JSON.parse(String(event.data))
|
|
170
|
+
const eventType = payload.meta?.event_type ?? "unknown"
|
|
171
|
+
|
|
172
|
+
if (typeof payload.offset === "number" && payload.offset > lastOffset) {
|
|
173
|
+
lastOffset = payload.offset
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.stderr.write(`funnel: received event (${eventType})\n`)
|
|
177
|
+
|
|
178
|
+
await server.notification({
|
|
179
|
+
method: "notifications/claude/channel",
|
|
180
|
+
params: {
|
|
181
|
+
content: payload.content,
|
|
182
|
+
meta: payload.meta,
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
} catch (error) {
|
|
186
|
+
process.stderr.write(
|
|
187
|
+
`funnel: error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
ws.addEventListener("close", () => {
|
|
193
|
+
process.stderr.write(`funnel: disconnected, reconnecting in ${reconnectDelay}ms\n`)
|
|
194
|
+
setTimeout(connect, reconnectDelay)
|
|
195
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
ws.addEventListener("error", () => {
|
|
199
|
+
// close handler will reconnect
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
connect()
|
|
204
|
+
}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
4
|
+
import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
|
|
4
5
|
|
|
5
6
|
export const FUNNEL_MCP_COMMAND = "funnel"
|
|
6
7
|
export const FUNNEL_MCP_NAME = "funnel"
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
command
|
|
10
|
-
args
|
|
11
|
-
}
|
|
9
|
+
const mcpEntrySchema = z.object({
|
|
10
|
+
command: z.string().optional(),
|
|
11
|
+
args: z.array(z.string()).optional(),
|
|
12
|
+
})
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
mcpServers
|
|
15
|
-
}
|
|
14
|
+
const mcpConfigSchema = z.object({
|
|
15
|
+
mcpServers: z.record(z.string(), mcpEntrySchema).optional(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type McpEntry = z.infer<typeof mcpEntrySchema>
|
|
19
|
+
type McpConfig = z.infer<typeof mcpConfigSchema>
|
|
16
20
|
|
|
17
21
|
type Deps = {
|
|
18
22
|
fs?: FunnelFileSystem
|
|
@@ -20,6 +24,11 @@ type Deps = {
|
|
|
20
24
|
|
|
21
25
|
const defaultFs = new NodeFunnelFileSystem()
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Installs/uninstalls the funnel MCP entry into a target repository's
|
|
29
|
+
* `.mcp.json`. Detects an existing entry by command match so renaming is
|
|
30
|
+
* preserved across re-installs.
|
|
31
|
+
*/
|
|
23
32
|
export class FunnelMcp {
|
|
24
33
|
private readonly fs: FunnelFileSystem
|
|
25
34
|
|
|
@@ -90,13 +99,23 @@ export class FunnelMcp {
|
|
|
90
99
|
|
|
91
100
|
if (!content) return {}
|
|
92
101
|
|
|
102
|
+
let parsed: unknown
|
|
103
|
+
|
|
93
104
|
try {
|
|
94
|
-
|
|
105
|
+
parsed = JSON.parse(content)
|
|
95
106
|
} catch (error) {
|
|
96
107
|
throw new Error(
|
|
97
108
|
`invalid .mcp.json (${mcpPath}): ${error instanceof Error ? error.message : String(error)}`,
|
|
98
109
|
)
|
|
99
110
|
}
|
|
111
|
+
|
|
112
|
+
const result = mcpConfigSchema.safeParse(parsed)
|
|
113
|
+
|
|
114
|
+
if (!result.success) {
|
|
115
|
+
throw new Error(`invalid .mcp.json (${mcpPath}): ${result.error.message}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result.data
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
private writeConfig(repoPath: string, config: McpConfig): void {
|
package/lib/{modules/process/node-funnel-process-runner.ts → engine/process/node-process-runner.ts}
RENAMED
|
@@ -4,12 +4,22 @@ import {
|
|
|
4
4
|
FunnelProcessRunner,
|
|
5
5
|
type RunOptions,
|
|
6
6
|
type RunResult,
|
|
7
|
-
} from "@/
|
|
7
|
+
} from "@/engine/process/process-runner"
|
|
8
8
|
|
|
9
9
|
const toEnv = (env?: Record<string, string>): Record<string, string> | undefined => {
|
|
10
10
|
if (!env) return undefined
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const merged: Record<string, string> = {}
|
|
13
|
+
|
|
14
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
15
|
+
if (typeof value === "string") merged[key] = value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const [key, value] of Object.entries(env)) {
|
|
19
|
+
merged[key] = value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return merged
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export class NodeFunnelProcessRunner extends FunnelProcessRunner {
|
|
@@ -59,25 +69,6 @@ export class NodeFunnelProcessRunner extends FunnelProcessRunner {
|
|
|
59
69
|
stdio: ["inherit", "inherit", "inherit"],
|
|
60
70
|
})
|
|
61
71
|
|
|
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
72
|
return await proc.exited
|
|
82
73
|
}
|
|
83
74
|
|
|
@@ -19,6 +19,11 @@ export type DetachOptions = {
|
|
|
19
19
|
env?: Record<string, string>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Process boundary covering one-shot runs, sync runs, foreground attach, and
|
|
24
|
+
* detached background spawns. Default is NodeFunnelProcessRunner (Bun.spawn);
|
|
25
|
+
* MemoryFunnelProcessRunner records calls and lets tests stub responses.
|
|
26
|
+
*/
|
|
22
27
|
export abstract class FunnelProcessRunner {
|
|
23
28
|
abstract run(command: string[], options?: RunOptions): Promise<RunResult>
|
|
24
29
|
abstract runSync(command: string[]): RunResult
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
|
|
2
|
+
import type { ProfileConfig } from "@/engine/settings/settings-schema"
|
|
3
|
+
|
|
4
|
+
type Deps = {
|
|
5
|
+
store: FunnelSettingsReader
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Named launch presets for `fnl claude`. Each profile bundles a working
|
|
10
|
+
* directory, a sub-agent name, and the channel id its Claude instance will
|
|
11
|
+
* subscribe to. Implements ProfileChannelChecker so FunnelChannels can refuse
|
|
12
|
+
* to remove a channel that is still referenced.
|
|
13
|
+
*
|
|
14
|
+
* The first entry in the persisted array is treated as the default profile;
|
|
15
|
+
* `asDefault` reorders the array to put a named profile first.
|
|
16
|
+
*
|
|
17
|
+
* `channelId` always stores the channel's stable id (uuid). CLI surfaces
|
|
18
|
+
* resolve channel name → id before calling `add`/`update` here.
|
|
19
|
+
*/
|
|
20
|
+
export class FunnelProfiles {
|
|
21
|
+
private readonly store: FunnelSettingsReader
|
|
22
|
+
|
|
23
|
+
constructor(deps: Deps) {
|
|
24
|
+
this.store = deps.store
|
|
25
|
+
Object.freeze(this)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
list(): ProfileConfig[] {
|
|
29
|
+
return this.store.read().profiles
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get(name: string): ProfileConfig | null {
|
|
33
|
+
return this.list().find((p) => p.name === name) ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getDefault(): ProfileConfig | null {
|
|
37
|
+
return this.list()[0] ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
add(config: ProfileConfig): void {
|
|
41
|
+
const settings = this.store.read()
|
|
42
|
+
|
|
43
|
+
if (settings.profiles.some((p) => p.name === config.name)) {
|
|
44
|
+
throw new Error(`profile "${config.name}" already exists`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!settings.channels.some((c) => c.id === config.channelId)) {
|
|
48
|
+
throw new Error(`channel id "${config.channelId}" not found`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
settings.profiles.push(config)
|
|
52
|
+
|
|
53
|
+
this.store.write(settings)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
remove(name: string): void {
|
|
57
|
+
const settings = this.store.read()
|
|
58
|
+
|
|
59
|
+
const index = settings.profiles.findIndex((p) => p.name === name)
|
|
60
|
+
|
|
61
|
+
if (index < 0) throw new Error(`profile "${name}" not found`)
|
|
62
|
+
|
|
63
|
+
settings.profiles.splice(index, 1)
|
|
64
|
+
|
|
65
|
+
this.store.write(settings)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
rename(oldName: string, newName: string): void {
|
|
69
|
+
const settings = this.store.read()
|
|
70
|
+
|
|
71
|
+
const profile = settings.profiles.find((p) => p.name === oldName)
|
|
72
|
+
|
|
73
|
+
if (!profile) throw new Error(`profile "${oldName}" not found`)
|
|
74
|
+
|
|
75
|
+
if (settings.profiles.some((p) => p.name === newName)) {
|
|
76
|
+
throw new Error(`profile "${newName}" already exists`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
profile.name = newName
|
|
80
|
+
|
|
81
|
+
this.store.write(settings)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
asDefault(name: string): void {
|
|
85
|
+
const settings = this.store.read()
|
|
86
|
+
|
|
87
|
+
const index = settings.profiles.findIndex((p) => p.name === name)
|
|
88
|
+
|
|
89
|
+
if (index < 0) throw new Error(`profile "${name}" not found`)
|
|
90
|
+
|
|
91
|
+
if (index === 0) return
|
|
92
|
+
|
|
93
|
+
const [profile] = settings.profiles.splice(index, 1)
|
|
94
|
+
|
|
95
|
+
if (!profile) return
|
|
96
|
+
|
|
97
|
+
settings.profiles.unshift(profile)
|
|
98
|
+
|
|
99
|
+
this.store.write(settings)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
hasChannelRef(channelId: string): boolean {
|
|
103
|
+
return this.store.read().profiles.some((p) => p.channelId === channelId)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
update(name: string, fields: Partial<Omit<ProfileConfig, "name">>): void {
|
|
107
|
+
const settings = this.store.read()
|
|
108
|
+
|
|
109
|
+
const profile = settings.profiles.find((p) => p.name === name)
|
|
110
|
+
|
|
111
|
+
if (!profile) throw new Error(`profile "${name}" not found`)
|
|
112
|
+
|
|
113
|
+
if (fields.channelId !== undefined) {
|
|
114
|
+
if (!settings.channels.some((c) => c.id === fields.channelId)) {
|
|
115
|
+
throw new Error(`channel id "${fields.channelId}" not found`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
profile.channelId = fields.channelId
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (fields.path !== undefined) profile.path = fields.path
|
|
122
|
+
if (fields.subAgent !== undefined) profile.subAgent = fields.subAgent
|
|
123
|
+
|
|
124
|
+
this.store.write(settings)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { FunnelSettingsReader } from "@/
|
|
2
|
-
import
|
|
1
|
+
import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
|
|
2
|
+
import { SETTINGS_VERSION } from "@/engine/settings/settings-schema"
|
|
3
|
+
import type { Settings } from "@/engine/settings/settings-schema"
|
|
3
4
|
|
|
4
5
|
export const createSettings = (partial: Partial<Settings> = {}): Settings => ({
|
|
6
|
+
version: SETTINGS_VERSION,
|
|
5
7
|
channels: [],
|
|
6
|
-
repositories: [],
|
|
7
8
|
profiles: [],
|
|
8
9
|
...partial,
|
|
9
10
|
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { connectorConfigSchema } from "@/connectors/connector-config-schema"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Routing mode when multiple WS clients are subscribed to the same channel.
|
|
6
|
+
*
|
|
7
|
+
* - `fanout` (default): every connected client receives every event. Right when each
|
|
8
|
+
* subscriber has its own job (e.g., TUI mirrors, distinct Claude profiles each running
|
|
9
|
+
* their own pipeline against the same source).
|
|
10
|
+
* - `exclusive`: each event is delivered to exactly one connected client, picked
|
|
11
|
+
* round-robin per channel. Right when subscribers are interchangeable workers and you
|
|
12
|
+
* want each event handled once. Tap=all clients (TUI dashboard) always receive,
|
|
13
|
+
* regardless of mode, so they can passively observe.
|
|
14
|
+
*/
|
|
15
|
+
export const channelDeliveryModeSchema = z.enum(["fanout", "exclusive"])
|
|
16
|
+
|
|
17
|
+
export type ChannelDeliveryMode = z.infer<typeof channelDeliveryModeSchema>
|
|
18
|
+
|
|
19
|
+
export const channelConfigSchema = z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
name: z.string(),
|
|
22
|
+
delivery: channelDeliveryModeSchema.default("fanout"),
|
|
23
|
+
connectors: z.array(connectorConfigSchema).default([]),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export type ChannelConfig = z.infer<typeof channelConfigSchema>
|
|
27
|
+
|
|
28
|
+
export const profileConfigSchema = z.object({
|
|
29
|
+
name: z.string(),
|
|
30
|
+
path: z.string(),
|
|
31
|
+
subAgent: z.string(),
|
|
32
|
+
channelId: z.string(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type ProfileConfig = z.infer<typeof profileConfigSchema>
|
|
36
|
+
|
|
37
|
+
export const SETTINGS_VERSION = 1
|
|
38
|
+
|
|
39
|
+
export const settingsSchema = z.object({
|
|
40
|
+
/** Schema version. New files always write the current version; older files without one are read as v1. */
|
|
41
|
+
version: z.literal(SETTINGS_VERSION).default(SETTINGS_VERSION),
|
|
42
|
+
channels: z.array(channelConfigSchema).default([]),
|
|
43
|
+
profiles: z.array(profileConfigSchema).default([]),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export type Settings = z.infer<typeof settingsSchema>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { FunnelFileSystem } from "@/engine/fs/file-system"
|
|
4
|
+
import { NodeFunnelFileSystem } from "@/engine/fs/node-file-system"
|
|
5
|
+
import { FunnelSettingsReader } from "@/engine/settings/settings-reader"
|
|
6
|
+
import { SETTINGS_VERSION, settingsSchema } from "@/engine/settings/settings-schema"
|
|
7
|
+
import type { Settings } from "@/engine/settings/settings-schema"
|
|
8
|
+
|
|
9
|
+
export const FUNNEL_DIR = join(homedir(), ".funnel")
|
|
10
|
+
export const SETTINGS_PATH = join(FUNNEL_DIR, "settings.json")
|
|
11
|
+
|
|
12
|
+
type Deps = {
|
|
13
|
+
path?: string
|
|
14
|
+
fs?: FunnelFileSystem
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultFs = new NodeFunnelFileSystem()
|
|
18
|
+
|
|
19
|
+
export class FunnelSettingsStore extends FunnelSettingsReader {
|
|
20
|
+
private readonly path: string
|
|
21
|
+
private readonly fs: FunnelFileSystem
|
|
22
|
+
|
|
23
|
+
constructor(deps: Deps = {}) {
|
|
24
|
+
super()
|
|
25
|
+
this.path = deps.path ?? SETTINGS_PATH
|
|
26
|
+
this.fs = deps.fs ?? defaultFs
|
|
27
|
+
Object.freeze(this)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
read(): Settings {
|
|
31
|
+
if (!this.fs.existsSync(this.path)) {
|
|
32
|
+
return {
|
|
33
|
+
version: SETTINGS_VERSION,
|
|
34
|
+
channels: [],
|
|
35
|
+
profiles: [],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const content = this.fs.readFileSync(this.path)
|
|
40
|
+
const parsed: unknown = JSON.parse(content)
|
|
41
|
+
|
|
42
|
+
if (this.looksLikeLegacy(parsed)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`legacy settings.json detected at ${this.path}. The schema changed (channel.connectors are now nested objects with ids; profile fields renamed). Migration is intentionally not provided. Back up and remove the old file:\n mv ${this.path} ${this.path}.bak`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
parsed &&
|
|
50
|
+
typeof parsed === "object" &&
|
|
51
|
+
"version" in parsed &&
|
|
52
|
+
parsed.version !== SETTINGS_VERSION
|
|
53
|
+
) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`unsupported settings.json version (${this.path}): expected ${SETTINGS_VERSION}, got ${String(parsed.version)}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = settingsSchema.safeParse(parsed)
|
|
60
|
+
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`invalid settings.json (${this.path}): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result.data
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private looksLikeLegacy(parsed: unknown): boolean {
|
|
71
|
+
if (!parsed || typeof parsed !== "object") return false
|
|
72
|
+
|
|
73
|
+
const obj = parsed as Record<string, unknown>
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(obj.channels)) {
|
|
76
|
+
for (const channel of obj.channels) {
|
|
77
|
+
if (!channel || typeof channel !== "object") continue
|
|
78
|
+
const ch = channel as Record<string, unknown>
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(ch.connectors) && ch.connectors.some((x) => typeof x === "string")) {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!("id" in ch) && "name" in ch) return true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (Array.isArray(obj.connectors)) return true
|
|
89
|
+
if (Array.isArray(obj.repositories)) return true
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(obj.profiles)) {
|
|
92
|
+
for (const profile of obj.profiles) {
|
|
93
|
+
if (!profile || typeof profile !== "object") continue
|
|
94
|
+
const p = profile as Record<string, unknown>
|
|
95
|
+
|
|
96
|
+
if ("repository" in p || "envFiles" in p || ("channel" in p && !("channelId" in p))) {
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
write(settings: Settings): void {
|
|
106
|
+
this.fs.mkdirSync(dirname(this.path), { recursive: true })
|
|
107
|
+
const versioned: Settings = { ...settings, version: SETTINGS_VERSION }
|
|
108
|
+
this.fs.writeFileSync(this.path, `${JSON.stringify(versioned, null, 2)}\n`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time boundary. Default NodeFunnelClock returns `new Date()`; MemoryFunnelClock
|
|
3
|
+
* is settable and `advance(ms)`-able for deterministic schedule / timeout tests.
|
|
4
|
+
*/
|
|
5
|
+
export abstract class FunnelClock {
|
|
6
|
+
abstract now(): Date
|
|
7
|
+
|
|
8
|
+
millis(): number {
|
|
9
|
+
return this.now().getTime()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
iso(): string {
|
|
13
|
+
return this.now().toISOString()
|
|
14
|
+
}
|
|
15
|
+
}
|