@interactive-inc/claude-funnel 0.10.0 → 0.15.1

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 (236) hide show
  1. package/README.md +106 -56
  2. package/dist/bin.js +557 -530
  3. package/dist/connectors/schedule.d.ts +2 -49
  4. package/dist/connectors/schedule.js +1 -1
  5. package/dist/connectors/slack.d.ts +4 -48
  6. package/dist/connectors/slack.js +1 -1
  7. package/dist/gateway/daemon.js +213 -211
  8. package/dist/index.d.ts +465 -173
  9. package/dist/index.js +692 -154
  10. package/dist/{schedule-connector-schema-CkuIQ0JQ.js → schedule-connector-schema-FxP7LPlx.js} +11 -0
  11. package/dist/{file-system-Co60LrmR.d.ts → schedule-listener-BPodvbld.d.ts} +56 -1
  12. package/dist/{slack-connector-schema-Cd22WiHB.js → slack-connector-schema-B4hsf3AY.js} +10 -1
  13. package/dist/slack-listener-CHj6uMY-.d.ts +74 -0
  14. package/package.json +2 -6
  15. package/schemas/funnel.schema.json +144 -0
  16. package/dist/slack-connector-schema-D7zAHN8k.d.ts +0 -15
  17. package/lib/bin.ts +0 -3
  18. package/lib/cli/factory.ts +0 -10
  19. package/lib/cli/index.ts +0 -85
  20. package/lib/cli/router/query-to-cli-args.ts +0 -20
  21. package/lib/cli/router/to-request.ts +0 -113
  22. package/lib/cli/router/validator.ts +0 -27
  23. package/lib/cli/routes/channels.$channel.connectors.$connector.rename.$newName.ts +0 -27
  24. package/lib/cli/routes/channels.$channel.connectors.$connector.request.ts +0 -40
  25. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.add.$id.ts +0 -41
  26. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.remove.$id.ts +0 -22
  27. package/lib/cli/routes/channels.$channel.connectors.$connector.schedules.ts +0 -23
  28. package/lib/cli/routes/channels.$channel.connectors.$connector.ts +0 -26
  29. package/lib/cli/routes/channels.$channel.connectors.add.$connector.ts +0 -92
  30. package/lib/cli/routes/channels.$channel.connectors.remove.$connector.ts +0 -22
  31. package/lib/cli/routes/channels.$channel.connectors.set.$connector.ts +0 -63
  32. package/lib/cli/routes/channels.$channel.connectors.ts +0 -26
  33. package/lib/cli/routes/channels.$channel.publish.ts +0 -52
  34. package/lib/cli/routes/channels.$channel.rename.$newName.ts +0 -22
  35. package/lib/cli/routes/channels.$channel.set.delivery.$mode.ts +0 -34
  36. package/lib/cli/routes/channels.$channel.ts +0 -34
  37. package/lib/cli/routes/channels.add.$channel.ts +0 -33
  38. package/lib/cli/routes/channels.remove.$channel.ts +0 -20
  39. package/lib/cli/routes/channels.ts +0 -39
  40. package/lib/cli/routes/claude.ts +0 -70
  41. package/lib/cli/routes/gateway.listeners.ts +0 -41
  42. package/lib/cli/routes/gateway.logs.ts +0 -123
  43. package/lib/cli/routes/gateway.restart.ts +0 -50
  44. package/lib/cli/routes/gateway.run.ts +0 -41
  45. package/lib/cli/routes/gateway.start.ts +0 -50
  46. package/lib/cli/routes/gateway.status.ts +0 -19
  47. package/lib/cli/routes/gateway.stop.ts +0 -32
  48. package/lib/cli/routes/gateway.ts +0 -55
  49. package/lib/cli/routes/index.ts +0 -219
  50. package/lib/cli/routes/profiles.$profile.as-default.ts +0 -22
  51. package/lib/cli/routes/profiles.$profile.rename.$newName.ts +0 -22
  52. package/lib/cli/routes/profiles.$profile.run.ts +0 -36
  53. package/lib/cli/routes/profiles.add.$profile.ts +0 -49
  54. package/lib/cli/routes/profiles.remove.$profile.ts +0 -20
  55. package/lib/cli/routes/profiles.set.$profile.ts +0 -45
  56. package/lib/cli/routes/profiles.ts +0 -40
  57. package/lib/cli/routes/status.ts +0 -93
  58. package/lib/cli/routes/update.ts +0 -27
  59. package/lib/connectors/connector-adapter.ts +0 -9
  60. package/lib/connectors/connector-config-schema.ts +0 -16
  61. package/lib/connectors/connector-factory.ts +0 -94
  62. package/lib/connectors/connector-listener.ts +0 -20
  63. package/lib/connectors/discord-adapter.ts +0 -51
  64. package/lib/connectors/discord-connector-schema.ts +0 -12
  65. package/lib/connectors/discord-event-processor.ts +0 -48
  66. package/lib/connectors/discord-listener.ts +0 -111
  67. package/lib/connectors/discord.ts +0 -4
  68. package/lib/connectors/gh-adapter.ts +0 -48
  69. package/lib/connectors/gh-connector-schema.ts +0 -12
  70. package/lib/connectors/gh-listener.ts +0 -137
  71. package/lib/connectors/gh.ts +0 -3
  72. package/lib/connectors/match-cron.ts +0 -78
  73. package/lib/connectors/schedule-connector-schema.ts +0 -33
  74. package/lib/connectors/schedule-listener.ts +0 -207
  75. package/lib/connectors/schedule-state-store.ts +0 -54
  76. package/lib/connectors/schedule.ts +0 -4
  77. package/lib/connectors/slack-adapter.ts +0 -36
  78. package/lib/connectors/slack-connector-schema.ts +0 -13
  79. package/lib/connectors/slack-event-processor.ts +0 -97
  80. package/lib/connectors/slack-listener.ts +0 -97
  81. package/lib/connectors/slack.ts +0 -4
  82. package/lib/engine/channels/channels.ts +0 -520
  83. package/lib/engine/claude/claude.ts +0 -205
  84. package/lib/engine/claude/gateway-controller.ts +0 -4
  85. package/lib/engine/fs/file-system.ts +0 -23
  86. package/lib/engine/fs/memory-file-system.ts +0 -102
  87. package/lib/engine/fs/node-file-system.ts +0 -68
  88. package/lib/engine/http/http-client.ts +0 -17
  89. package/lib/engine/http/memory-http-client.ts +0 -36
  90. package/lib/engine/http/node-http-client.ts +0 -23
  91. package/lib/engine/id/id-generator.ts +0 -7
  92. package/lib/engine/id/memory-id-generator.ts +0 -20
  93. package/lib/engine/id/node-id-generator.ts +0 -7
  94. package/lib/engine/logger/logger.ts +0 -11
  95. package/lib/engine/logger/memory-logger.ts +0 -28
  96. package/lib/engine/logger/node-logger.ts +0 -49
  97. package/lib/engine/logger/noop-logger.ts +0 -9
  98. package/lib/engine/mcp/channel-server.ts +0 -123
  99. package/lib/engine/mcp/channel-subscriber.ts +0 -82
  100. package/lib/engine/mcp/mcp.ts +0 -126
  101. package/lib/engine/mcp/read-channel-connectors.ts +0 -34
  102. package/lib/engine/mcp/read-gateway-token.ts +0 -16
  103. package/lib/engine/mcp/usage-hint-for-type.ts +0 -15
  104. package/lib/engine/process/memory-process-runner.ts +0 -88
  105. package/lib/engine/process/node-process-runner.ts +0 -91
  106. package/lib/engine/process/process-runner.ts +0 -33
  107. package/lib/engine/profiles/profile-channel-checker.ts +0 -7
  108. package/lib/engine/profiles/profiles.ts +0 -126
  109. package/lib/engine/settings/mock-settings-reader.ts +0 -27
  110. package/lib/engine/settings/settings-reader.ts +0 -6
  111. package/lib/engine/settings/settings-schema.ts +0 -48
  112. package/lib/engine/settings/settings-store.ts +0 -110
  113. package/lib/engine/time/clock.ts +0 -15
  114. package/lib/engine/time/memory-clock.ts +0 -26
  115. package/lib/engine/time/node-clock.ts +0 -7
  116. package/lib/funnel.ts +0 -294
  117. package/lib/gateway/auth-middleware.ts +0 -44
  118. package/lib/gateway/broadcaster.ts +0 -319
  119. package/lib/gateway/channel-publisher.ts +0 -67
  120. package/lib/gateway/daemon.ts +0 -47
  121. package/lib/gateway/factory.ts +0 -10
  122. package/lib/gateway/funnel-event-store.ts +0 -155
  123. package/lib/gateway/gateway-server.ts +0 -426
  124. package/lib/gateway/gateway-token.ts +0 -79
  125. package/lib/gateway/gateway.ts +0 -209
  126. package/lib/gateway/kill-competing-slack-gateways.ts +0 -56
  127. package/lib/gateway/listener-supervisor.ts +0 -339
  128. package/lib/gateway/listeners-client.ts +0 -128
  129. package/lib/gateway/publish-schema.ts +0 -27
  130. package/lib/gateway/resolve-daemon-script.ts +0 -26
  131. package/lib/gateway/routes/channels.connectors.call.ts +0 -39
  132. package/lib/gateway/routes/channels.publish.ts +0 -44
  133. package/lib/gateway/routes/health.ts +0 -13
  134. package/lib/gateway/routes/index.ts +0 -26
  135. package/lib/gateway/routes/listeners.list.ts +0 -6
  136. package/lib/gateway/routes/listeners.restart.ts +0 -15
  137. package/lib/gateway/routes/listeners.start.ts +0 -15
  138. package/lib/gateway/routes/listeners.stop.ts +0 -15
  139. package/lib/gateway/routes/route-deps.ts +0 -19
  140. package/lib/gateway/routes/status.ts +0 -15
  141. package/lib/gateway/routes/validator.ts +0 -17
  142. package/lib/index.ts +0 -67
  143. package/lib/logger/leuco-human-file-writer.ts +0 -65
  144. package/lib/logger/leuco-human-logger.ts +0 -98
  145. package/lib/logger/leuco-human-record.ts +0 -16
  146. package/lib/logger/leuco-human-stdout-writer.ts +0 -26
  147. package/lib/logger/leuco-human-writer.ts +0 -14
  148. package/lib/logger/leuco-logger-memory-sink.ts +0 -67
  149. package/lib/logger/leuco-logger-record.ts +0 -13
  150. package/lib/logger/leuco-logger-sink.ts +0 -33
  151. package/lib/logger/leuco-logger-sqlite-sink.ts +0 -355
  152. package/lib/logger/leuco-logger.ts +0 -135
  153. package/lib/tui/app.tsx +0 -357
  154. package/lib/tui/components/add-row.tsx +0 -18
  155. package/lib/tui/components/brand.tsx +0 -27
  156. package/lib/tui/components/card.tsx +0 -44
  157. package/lib/tui/components/detail-bar.tsx +0 -46
  158. package/lib/tui/components/editable-field.tsx +0 -33
  159. package/lib/tui/components/empty-state.tsx +0 -11
  160. package/lib/tui/components/gateway-status.tsx +0 -66
  161. package/lib/tui/components/keymap.tsx +0 -29
  162. package/lib/tui/components/menu-item.tsx +0 -73
  163. package/lib/tui/components/menu.tsx +0 -26
  164. package/lib/tui/components/panel-header.tsx +0 -22
  165. package/lib/tui/components/readonly-field.tsx +0 -18
  166. package/lib/tui/components/section-header.tsx +0 -25
  167. package/lib/tui/components/selection-accent.tsx +0 -32
  168. package/lib/tui/components/session-item.tsx +0 -33
  169. package/lib/tui/components/session-list.tsx +0 -33
  170. package/lib/tui/components/ui/hascii/accordion-item.tsx +0 -88
  171. package/lib/tui/components/ui/hascii/accordion.tsx +0 -96
  172. package/lib/tui/components/ui/hascii/alert-dialog.tsx +0 -43
  173. package/lib/tui/components/ui/hascii/badge.tsx +0 -51
  174. package/lib/tui/components/ui/hascii/breadcrumb.tsx +0 -58
  175. package/lib/tui/components/ui/hascii/button.tsx +0 -194
  176. package/lib/tui/components/ui/hascii/card-content.tsx +0 -14
  177. package/lib/tui/components/ui/hascii/card-description.tsx +0 -13
  178. package/lib/tui/components/ui/hascii/card-footer.tsx +0 -14
  179. package/lib/tui/components/ui/hascii/card-header.tsx +0 -14
  180. package/lib/tui/components/ui/hascii/card-title.tsx +0 -13
  181. package/lib/tui/components/ui/hascii/card.tsx +0 -27
  182. package/lib/tui/components/ui/hascii/checkbox.tsx +0 -65
  183. package/lib/tui/components/ui/hascii/command.tsx +0 -159
  184. package/lib/tui/components/ui/hascii/dialog-content.tsx +0 -14
  185. package/lib/tui/components/ui/hascii/dialog-description.tsx +0 -13
  186. package/lib/tui/components/ui/hascii/dialog-footer.tsx +0 -14
  187. package/lib/tui/components/ui/hascii/dialog-header.tsx +0 -14
  188. package/lib/tui/components/ui/hascii/dialog-title.tsx +0 -13
  189. package/lib/tui/components/ui/hascii/dialog.tsx +0 -27
  190. package/lib/tui/components/ui/hascii/file-tree.tsx +0 -142
  191. package/lib/tui/components/ui/hascii/focus-group.tsx +0 -62
  192. package/lib/tui/components/ui/hascii/form-item.tsx +0 -43
  193. package/lib/tui/components/ui/hascii/input-otp.tsx +0 -86
  194. package/lib/tui/components/ui/hascii/input.tsx +0 -130
  195. package/lib/tui/components/ui/hascii/pagination.tsx +0 -105
  196. package/lib/tui/components/ui/hascii/progress.tsx +0 -28
  197. package/lib/tui/components/ui/hascii/select.tsx +0 -131
  198. package/lib/tui/components/ui/hascii/separator.tsx +0 -35
  199. package/lib/tui/components/ui/hascii/sidebar-content.tsx +0 -23
  200. package/lib/tui/components/ui/hascii/sidebar-header.tsx +0 -14
  201. package/lib/tui/components/ui/hascii/sidebar-menu-item.tsx +0 -67
  202. package/lib/tui/components/ui/hascii/sidebar.tsx +0 -24
  203. package/lib/tui/components/ui/hascii/skeleton.tsx +0 -60
  204. package/lib/tui/components/ui/hascii/slider.tsx +0 -91
  205. package/lib/tui/components/ui/hascii/snackbar.tsx +0 -75
  206. package/lib/tui/components/ui/hascii/sparkline.tsx +0 -53
  207. package/lib/tui/components/ui/hascii/spinner.tsx +0 -47
  208. package/lib/tui/components/ui/hascii/stepper.tsx +0 -54
  209. package/lib/tui/components/ui/hascii/switch.tsx +0 -66
  210. package/lib/tui/components/ui/hascii/table.tsx +0 -95
  211. package/lib/tui/components/ui/hascii/tabs.tsx +0 -59
  212. package/lib/tui/components/ui/hascii/toggle-group-item.tsx +0 -45
  213. package/lib/tui/components/ui/hascii/toggle-group.tsx +0 -99
  214. package/lib/tui/components/ui/hascii/tree.tsx +0 -104
  215. package/lib/tui/components/view-shell.tsx +0 -44
  216. package/lib/tui/filter-input.tsx +0 -33
  217. package/lib/tui/hooks/hascii/use-pressable.ts +0 -54
  218. package/lib/tui/parse-comma-list.ts +0 -14
  219. package/lib/tui/profile-launcher.tsx +0 -61
  220. package/lib/tui/scrollbar-options.ts +0 -19
  221. package/lib/tui/sidebar.tsx +0 -50
  222. package/lib/tui/theme.ts +0 -40
  223. package/lib/tui/tui.tsx +0 -20
  224. package/lib/tui/types.ts +0 -38
  225. package/lib/tui/unique-name.ts +0 -18
  226. package/lib/tui/use-event-stream.ts +0 -133
  227. package/lib/tui/use-snapshot.ts +0 -99
  228. package/lib/tui/utils/hascii/form-item-context.tsx +0 -23
  229. package/lib/tui/utils/hascii/input-focus-context.tsx +0 -31
  230. package/lib/tui/utils/hascii/theme-context.tsx +0 -26
  231. package/lib/tui/utils/hascii/theme.ts +0 -176
  232. package/lib/tui/views/channels-view.tsx +0 -108
  233. package/lib/tui/views/connectors-view.tsx +0 -164
  234. package/lib/tui/views/events-view.tsx +0 -160
  235. package/lib/tui/views/listeners-view.tsx +0 -80
  236. package/lib/tui/views/profiles-view.tsx +0 -152
package/dist/index.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-ygf5Df-2.js";
2
2
  import { n as FunnelLogger, r as FunnelConnectorListener, t as NodeFunnelLogger } from "./node-logger-DQz_BGOD.js";
3
3
  import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-2ml29MBC.js";
4
- import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CkuIQ0JQ.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, t as slackConnectorSchema } from "./slack-connector-schema-Cd22WiHB.js";
4
+ import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-FxP7LPlx.js";
5
+ import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-B4hsf3AY.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { z } from "zod";
9
9
  import { homedir } from "node:os";
10
- import "bun";
10
+ import { stderr, stdin } from "node:process";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { timingSafeEqual } from "node:crypto";
12
13
  import { createFactory } from "hono/factory";
13
14
  import { Database } from "bun:sqlite";
14
15
  import { HTTPException } from "hono/http-exception";
15
16
  import { zValidator } from "@hono/zod-validator";
16
- import { Server as Server$1 } from "@modelcontextprotocol/sdk/server/index.js";
17
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
19
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
19
20
  import { stringify } from "yaml";
@@ -133,23 +134,33 @@ const defaultLogger$5 = new NodeFunnelLogger();
133
134
  *
134
135
  * `dir` is the funnel home (defaults to ~/.funnel); per-connector state files
135
136
  * land at `<dir>/channels/<channel-id>/connectors/<connector-id>/state.json`.
137
+ *
138
+ * Host integrations can supply per-type listener hooks via
139
+ * `slackListenerOptions` / `scheduleListenerOptions` — e.g. to attach a
140
+ * Bolt `app.action` handler or to drop one-shot schedule entries on fire.
136
141
  */
137
142
  var FunnelConnectorFactory = class {
138
143
  fs;
139
144
  process;
140
145
  logger;
141
146
  dir;
147
+ slackListenerOptions;
148
+ scheduleListenerOptions;
142
149
  constructor(deps = {}) {
143
150
  this.fs = deps.fs ?? defaultFs$4;
144
151
  this.process = deps.process ?? defaultProcess$3;
145
152
  this.logger = deps.logger ?? defaultLogger$5;
146
153
  this.dir = deps.dir ?? FUNNEL_DIR;
154
+ this.slackListenerOptions = deps.slackListenerOptions ?? {};
155
+ this.scheduleListenerOptions = deps.scheduleListenerOptions ?? {};
147
156
  Object.freeze(this);
148
157
  }
149
158
  createListener(channelId, config) {
150
159
  if (config.type === "slack") return new FunnelSlackListener({
151
160
  config,
152
- logger: this.logger
161
+ logger: this.logger,
162
+ onAppCreated: this.slackListenerOptions.onAppCreated,
163
+ preprocessEvent: this.slackListenerOptions.preprocessEvent
153
164
  });
154
165
  if (config.type === "gh") return new FunnelGhListener({
155
166
  config,
@@ -166,7 +177,8 @@ var FunnelConnectorFactory = class {
166
177
  path: join(this.connectorDir(channelId, config.id), "state.json"),
167
178
  fs: this.fs
168
179
  }),
169
- logger: this.logger
180
+ logger: this.logger,
181
+ onFired: this.scheduleListenerOptions.onFired
170
182
  });
171
183
  }
172
184
  createAdapter(config) {
@@ -183,6 +195,40 @@ var FunnelConnectorFactory = class {
183
195
  }
184
196
  };
185
197
  //#endregion
198
+ //#region lib/engine/channels/connector-tokens.ts
199
+ /**
200
+ * Return every secret token contained in a connector config. Used by token
201
+ * collision detection at add/update time so the same Slack bot or Discord
202
+ * bot cannot be registered under two connectors. Centralizes the per-type
203
+ * switch so the channels facade does not embed type-specific knowledge.
204
+ */
205
+ function connectorTokens(connector) {
206
+ switch (connector.type) {
207
+ case "slack": return [connector.botToken, connector.appToken];
208
+ case "discord": return [connector.botToken];
209
+ case "gh":
210
+ case "schedule": return [];
211
+ }
212
+ }
213
+ //#endregion
214
+ //#region lib/engine/channels/require-connector.ts
215
+ function isConnectorOfType(connector, type) {
216
+ return connector.type === type;
217
+ }
218
+ /**
219
+ * Look up a connector by name and narrow its discriminated union to a single
220
+ * variant via a type predicate. Throws if the connector is missing or has the
221
+ * wrong `type`. Replaces per-type `requireXxxConnector` privates — adding a
222
+ * new connector type only touches the `ConnectorConfig` union, not this
223
+ * helper.
224
+ */
225
+ function requireConnectorOfType(channel, connectorName, type) {
226
+ const connector = channel.connectors.find((c) => c.name === connectorName);
227
+ if (!connector) throw new Error(`connector "${connectorName}" not found in channel "${channel.name}"`);
228
+ if (!isConnectorOfType(connector, type)) throw new Error(`connector "${connectorName}" is type "${connector.type}", not "${type}"`);
229
+ return connector;
230
+ }
231
+ //#endregion
186
232
  //#region lib/engine/time/clock.ts
187
233
  /**
188
234
  * Time boundary. Default NodeFunnelClock returns `new Date()`; MemoryFunnelClock
@@ -320,39 +366,41 @@ var FunnelChannels = class {
320
366
  const now = this.clock.iso();
321
367
  const createdAt = now;
322
368
  const updatedAt = now;
323
- if (input.type === "slack") return {
324
- id,
325
- type: "slack",
326
- name: input.name,
327
- botToken: input.botToken,
328
- appToken: input.appToken,
329
- createdAt,
330
- updatedAt
331
- };
332
- if (input.type === "gh") return {
333
- id,
334
- type: "gh",
335
- name: input.name,
336
- ...input.pollInterval !== void 0 ? { pollInterval: input.pollInterval } : {},
337
- createdAt,
338
- updatedAt
339
- };
340
- if (input.type === "discord") return {
341
- id,
342
- type: "discord",
343
- name: input.name,
344
- botToken: input.botToken,
345
- createdAt,
346
- updatedAt
347
- };
348
- return {
349
- id,
350
- type: "schedule",
351
- name: input.name,
352
- entries: input.entries ?? [],
353
- createdAt,
354
- updatedAt
355
- };
369
+ switch (input.type) {
370
+ case "slack": return {
371
+ id,
372
+ type: "slack",
373
+ name: input.name,
374
+ botToken: input.botToken,
375
+ appToken: input.appToken,
376
+ createdAt,
377
+ updatedAt
378
+ };
379
+ case "gh": return {
380
+ id,
381
+ type: "gh",
382
+ name: input.name,
383
+ ...input.pollInterval !== void 0 ? { pollInterval: input.pollInterval } : {},
384
+ createdAt,
385
+ updatedAt
386
+ };
387
+ case "discord": return {
388
+ id,
389
+ type: "discord",
390
+ name: input.name,
391
+ botToken: input.botToken,
392
+ createdAt,
393
+ updatedAt
394
+ };
395
+ case "schedule": return {
396
+ id,
397
+ type: "schedule",
398
+ name: input.name,
399
+ entries: input.entries ?? [],
400
+ createdAt,
401
+ updatedAt
402
+ };
403
+ }
356
404
  }
357
405
  removeConnector(channelName, connectorName) {
358
406
  const settings = this.store.read();
@@ -374,8 +422,7 @@ var FunnelChannels = class {
374
422
  }
375
423
  updateSlackConnector(channelName, connectorName, fields) {
376
424
  const settings = this.store.read();
377
- const channel = this.requireChannel(settings, channelName);
378
- const connector = this.requireSlackConnector(channel, connectorName);
425
+ const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "slack");
379
426
  const updated = {
380
427
  ...connector,
381
428
  botToken: fields.botToken ?? connector.botToken,
@@ -388,16 +435,14 @@ var FunnelChannels = class {
388
435
  }
389
436
  updateGhConnector(channelName, connectorName, fields) {
390
437
  const settings = this.store.read();
391
- const channel = this.requireChannel(settings, channelName);
392
- const connector = this.requireGhConnector(channel, connectorName);
438
+ const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "gh");
393
439
  if (fields.pollInterval !== void 0) connector.pollInterval = fields.pollInterval;
394
440
  connector.updatedAt = this.clock.iso();
395
441
  this.store.write(settings);
396
442
  }
397
443
  updateDiscordConnector(channelName, connectorName, fields) {
398
444
  const settings = this.store.read();
399
- const channel = this.requireChannel(settings, channelName);
400
- const connector = this.requireDiscordConnector(channel, connectorName);
445
+ const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "discord");
401
446
  const updated = {
402
447
  ...connector,
403
448
  botToken: fields.botToken ?? connector.botToken,
@@ -408,13 +453,11 @@ var FunnelChannels = class {
408
453
  this.store.write(settings);
409
454
  }
410
455
  listScheduleEntries(channelName, connectorName) {
411
- const channel = this.requireChannel(this.store.read(), channelName);
412
- return this.requireScheduleConnector(channel, connectorName).entries;
456
+ return requireConnectorOfType(this.requireChannel(this.store.read(), channelName), connectorName, "schedule").entries;
413
457
  }
414
458
  addScheduleEntry(channelName, connectorName, entry) {
415
459
  const settings = this.store.read();
416
- const channel = this.requireChannel(settings, channelName);
417
- const connector = this.requireScheduleConnector(channel, connectorName);
460
+ const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "schedule");
418
461
  const persisted = {
419
462
  id: entry.id ?? this.idGenerator.generate(),
420
463
  cron: entry.cron,
@@ -429,8 +472,7 @@ var FunnelChannels = class {
429
472
  }
430
473
  removeScheduleEntry(channelName, connectorName, id) {
431
474
  const settings = this.store.read();
432
- const channel = this.requireChannel(settings, channelName);
433
- const connector = this.requireScheduleConnector(channel, connectorName);
475
+ const connector = requireConnectorOfType(this.requireChannel(settings, channelName), connectorName, "schedule");
434
476
  const index = connector.entries.findIndex((e) => e.id === id);
435
477
  if (index < 0) throw new Error(`schedule entry "${id}" not found`);
436
478
  connector.entries.splice(index, 1);
@@ -470,44 +512,14 @@ var FunnelChannels = class {
470
512
  if (!channel) throw new Error(`channel "${name}" not found`);
471
513
  return channel;
472
514
  }
473
- requireConnector(channel, connectorName) {
474
- const connector = channel.connectors.find((c) => c.name === connectorName);
475
- if (!connector) throw new Error(`connector "${connectorName}" not found in channel "${channel.name}"`);
476
- return connector;
477
- }
478
- requireSlackConnector(channel, connectorName) {
479
- const connector = this.requireConnector(channel, connectorName);
480
- if (connector.type !== "slack") throw new Error(`connector "${connectorName}" is type "${connector.type}", not "slack"`);
481
- return connector;
482
- }
483
- requireGhConnector(channel, connectorName) {
484
- const connector = this.requireConnector(channel, connectorName);
485
- if (connector.type !== "gh") throw new Error(`connector "${connectorName}" is type "${connector.type}", not "gh"`);
486
- return connector;
487
- }
488
- requireDiscordConnector(channel, connectorName) {
489
- const connector = this.requireConnector(channel, connectorName);
490
- if (connector.type !== "discord") throw new Error(`connector "${connectorName}" is type "${connector.type}", not "discord"`);
491
- return connector;
492
- }
493
- requireScheduleConnector(channel, connectorName) {
494
- const connector = this.requireConnector(channel, connectorName);
495
- if (connector.type !== "schedule") throw new Error(`connector "${connectorName}" is type "${connector.type}", not "schedule"`);
496
- return connector;
497
- }
498
515
  assertNoTokenCollision(settings, candidate) {
499
- const tokens = this.tokensOf(candidate);
516
+ const tokens = connectorTokens(candidate);
500
517
  if (tokens.length === 0) return;
501
518
  for (const channel of settings.channels) for (const other of channel.connectors) {
502
519
  if (other.id === candidate.id) continue;
503
- for (const token of this.tokensOf(other)) if (tokens.includes(token)) throw new Error(`token already in use by connector "${other.name}" in channel "${channel.name}"`);
520
+ for (const token of connectorTokens(other)) if (tokens.includes(token)) throw new Error(`token already in use by connector "${other.name}" in channel "${channel.name}"`);
504
521
  }
505
522
  }
506
- tokensOf(connector) {
507
- if (connector.type === "slack") return [connector.botToken, connector.appToken];
508
- if (connector.type === "discord") return [connector.botToken];
509
- return [];
510
- }
511
523
  };
512
524
  //#endregion
513
525
  //#region lib/engine/claude/claude.ts
@@ -556,7 +568,7 @@ var FunnelClaude = class {
556
568
  this.installCleanup(options.profileName);
557
569
  }
558
570
  const claudeArgs = this.buildArgs(options, cwd);
559
- const env = this.buildEnv(channel.id);
571
+ const env = this.buildEnv(channel.id, options.extraEnv);
560
572
  this.logger.info(`claude launch`, {
561
573
  channel: options.channel,
562
574
  channelId: channel.id,
@@ -624,8 +636,9 @@ var FunnelClaude = class {
624
636
  if (options.brief && !result.includes("--brief")) result.push("--brief");
625
637
  return result;
626
638
  }
627
- buildEnv(channelId) {
639
+ buildEnv(channelId, extraEnv) {
628
640
  const env = {};
641
+ if (extraEnv) for (const [key, value] of Object.entries(extraEnv)) env[key] = value;
629
642
  for (const [key, value] of Object.entries(globalThis.process.env)) if (typeof value === "string") env[key] = value;
630
643
  env.FUNNEL_CHANNEL_ID = channelId;
631
644
  return env;
@@ -720,6 +733,332 @@ var MemoryFunnelIdGenerator = class extends FunnelIdGenerator {
720
733
  }
721
734
  };
722
735
  //#endregion
736
+ //#region lib/engine/local-config/local-config-schema.ts
737
+ /**
738
+ * Per-repo launch config (`funnel.json`).
739
+ *
740
+ * `fnl claude` reads this when no --profile / --channel is given and uses it
741
+ * to set the channel binding, sub-agent, and brief flag. When `connectors`
742
+ * is declared, missing channels/connectors are materialized into the local
743
+ * `~/.funnel/settings.json` on launch.
744
+ *
745
+ * Token fields per connector resolve in this order:
746
+ *
747
+ * 1. Literal value at the field itself (e.g. `botToken: "xoxb-..."`)
748
+ * 2. Env-var reference at `env.<field>` (e.g. `env: { botToken: "SLACK_BOT_TOKEN" }`);
749
+ * resolved from process.env first, then ./.env.local
750
+ * 3. Field omitted everywhere → prompted for once on a TTY and persisted to
751
+ * `~/.funnel/settings.json`; non-TTY launches fail fast.
752
+ *
753
+ * `funnel.json` itself is never written to. Only `channel` is required.
754
+ */
755
+ const slackEnvSchema = z.object({
756
+ botToken: z.string().optional(),
757
+ appToken: z.string().optional()
758
+ }).optional();
759
+ const slackConnectorSpecSchema = z.object({
760
+ type: z.literal("slack"),
761
+ name: z.string(),
762
+ botToken: z.string().optional(),
763
+ appToken: z.string().optional(),
764
+ env: slackEnvSchema
765
+ });
766
+ const discordEnvSchema = z.object({ botToken: z.string().optional() }).optional();
767
+ const discordConnectorSpecSchema = z.object({
768
+ type: z.literal("discord"),
769
+ name: z.string(),
770
+ botToken: z.string().optional(),
771
+ env: discordEnvSchema
772
+ });
773
+ const ghConnectorSpecSchema = z.object({
774
+ type: z.literal("gh"),
775
+ name: z.string(),
776
+ pollInterval: z.number().int().positive().optional()
777
+ });
778
+ const scheduleConnectorSpecSchema = z.object({
779
+ type: z.literal("schedule"),
780
+ name: z.string()
781
+ });
782
+ const connectorSpecSchema = z.discriminatedUnion("type", [
783
+ slackConnectorSpecSchema,
784
+ discordConnectorSpecSchema,
785
+ ghConnectorSpecSchema,
786
+ scheduleConnectorSpecSchema
787
+ ]);
788
+ const localConfigSchema = z.object({
789
+ $schema: z.string().optional(),
790
+ channel: z.string(),
791
+ /** Extra args forwarded to the claude CLI. Prepended before user-supplied CLI args so user args still win on collision (e.g. --model, --agent, --brief, --resume, positional session ids). */
792
+ options: z.array(z.string()).optional(),
793
+ env: z.record(z.string(), z.string()).optional(),
794
+ connectors: z.array(connectorSpecSchema).optional()
795
+ });
796
+ const LOCAL_CONFIG_FILENAME = "funnel.json";
797
+ const LOCAL_ENV_FILENAME = ".env.local";
798
+ //#endregion
799
+ //#region lib/engine/local-config/dotenv-reader.ts
800
+ const VARIABLE_LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
801
+ const unquote = (value) => {
802
+ if (value.length < 2) return value;
803
+ const first = value[0];
804
+ const last = value[value.length - 1];
805
+ if (first === "\"" && last === "\"") return value.slice(1, -1);
806
+ if (first === "'" && last === "'") return value.slice(1, -1);
807
+ return value;
808
+ };
809
+ /**
810
+ * Minimal `.env.local` parser. Supports `KEY=value` lines, blank lines, and
811
+ * `#` comments. Strips matching surrounding single or double quotes. No
812
+ * interpolation, no `export` prefix — anything fancier should live in a real
813
+ * env file loaded by the shell.
814
+ */
815
+ var FunnelDotenvReader = class {
816
+ fs;
817
+ constructor(deps) {
818
+ this.fs = deps.fs;
819
+ Object.freeze(this);
820
+ }
821
+ read(cwd) {
822
+ const path = join(cwd, LOCAL_ENV_FILENAME);
823
+ if (!this.fs.existsSync(path)) return {};
824
+ const raw = this.fs.readFileSync(path);
825
+ const out = {};
826
+ for (const line of raw.split("\n")) {
827
+ const trimmed = line.trim();
828
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
829
+ const match = trimmed.match(VARIABLE_LINE);
830
+ if (!match) continue;
831
+ const key = match[1];
832
+ const value = match[2];
833
+ if (!key) continue;
834
+ out[key] = unquote(value ?? "");
835
+ }
836
+ return out;
837
+ }
838
+ };
839
+ //#endregion
840
+ //#region lib/engine/local-config/local-config.ts
841
+ /**
842
+ * Reads `funnel.json` from a directory. Returns `null` when the file is
843
+ * absent so callers can fall through to other resolution paths (default
844
+ * profile, help). Throws on present-but-invalid files so misconfiguration
845
+ * surfaces loudly instead of silently launching the wrong channel.
846
+ */
847
+ var FunnelLocalConfig = class {
848
+ fs;
849
+ constructor(deps) {
850
+ this.fs = deps.fs;
851
+ Object.freeze(this);
852
+ }
853
+ read(cwd) {
854
+ const path = join(cwd, LOCAL_CONFIG_FILENAME);
855
+ if (!this.fs.existsSync(path)) return null;
856
+ const raw = this.fs.readFileSync(path);
857
+ const parsed = (() => {
858
+ try {
859
+ return JSON.parse(raw);
860
+ } catch (error) {
861
+ const message = error instanceof Error ? error.message : String(error);
862
+ throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
863
+ }
864
+ })();
865
+ const result = localConfigSchema.safeParse(parsed);
866
+ if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
867
+ return result.data;
868
+ }
869
+ };
870
+ //#endregion
871
+ //#region lib/engine/token-prompter/token-prompter.ts
872
+ /**
873
+ * Asks the user for a secret value on stdin. Used as a last resort when a
874
+ * funnel.json token field is absent and not present in `~/.funnel`. The Node
875
+ * implementation refuses to prompt when stdin is not a TTY so non-interactive
876
+ * launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
877
+ */
878
+ var FunnelTokenPrompter = class {};
879
+ //#endregion
880
+ //#region lib/engine/local-config/local-config-sync.ts
881
+ /**
882
+ * Reconciles a `funnel.json` spec with `~/.funnel/settings.json`. The spec
883
+ * is the source of truth for the channel it declares:
884
+ *
885
+ * - missing channel → created
886
+ * - declared connector matched by name → tokens reconciled
887
+ * - declared connector matched by token in the same channel under a
888
+ * different name → renamed in place (then tokens reconciled)
889
+ * - declared connector with no match → added
890
+ * - any connector left in the channel that the spec did not touch → removed
891
+ *
892
+ * Removal only fires when funnel.json has a `connectors` field. An absent
893
+ * field means "do not manage connectors from here" and leaves everything in
894
+ * `~/.funnel` alone.
895
+ */
896
+ var FunnelLocalConfigSync = class {
897
+ channels;
898
+ dotenv;
899
+ prompter;
900
+ env;
901
+ constructor(deps) {
902
+ this.channels = deps.channels;
903
+ this.dotenv = deps.dotenv;
904
+ this.prompter = deps.prompter;
905
+ this.env = deps.env ?? process.env;
906
+ Object.freeze(this);
907
+ }
908
+ async ensure(local, cwd) {
909
+ if (!this.channels.get(local.channel)) this.channels.add({ name: local.channel });
910
+ if (local.connectors === void 0) return;
911
+ const dotenv = this.dotenv.read(cwd);
912
+ const touched = /* @__PURE__ */ new Set();
913
+ for (const spec of local.connectors) {
914
+ const id = await this.ensureConnector(local.channel, spec, dotenv);
915
+ touched.add(id);
916
+ }
917
+ this.removeExtras(local.channel, touched);
918
+ }
919
+ async ensureConnector(channelName, spec, dotenv) {
920
+ if (spec.type === "slack") return await this.ensureSlack(channelName, spec, dotenv);
921
+ if (spec.type === "discord") return await this.ensureDiscord(channelName, spec, dotenv);
922
+ if (spec.type === "gh") return this.ensureGh(channelName, spec);
923
+ return this.ensureSchedule(channelName, spec);
924
+ }
925
+ async ensureSlack(channelName, spec, dotenv) {
926
+ const byName = this.findExistingSlack(channelName, spec.name);
927
+ const botToken = await this.resolveField({
928
+ literal: spec.botToken,
929
+ envVar: spec.env?.botToken,
930
+ dotenv,
931
+ label: `${spec.name}.botToken`,
932
+ existing: byName?.botToken
933
+ });
934
+ const appToken = await this.resolveField({
935
+ literal: spec.appToken,
936
+ envVar: spec.env?.appToken,
937
+ dotenv,
938
+ label: `${spec.name}.appToken`,
939
+ existing: byName?.appToken
940
+ });
941
+ if (byName) {
942
+ if (byName.botToken !== botToken || byName.appToken !== appToken) this.channels.updateSlackConnector(channelName, spec.name, {
943
+ botToken,
944
+ appToken
945
+ });
946
+ return byName.id;
947
+ }
948
+ const byToken = this.findSlackByToken(channelName, [botToken, appToken]);
949
+ if (byToken) {
950
+ this.channels.renameConnector(channelName, byToken.name, spec.name);
951
+ if (byToken.botToken !== botToken || byToken.appToken !== appToken) this.channels.updateSlackConnector(channelName, spec.name, {
952
+ botToken,
953
+ appToken
954
+ });
955
+ return byToken.id;
956
+ }
957
+ return this.channels.addConnector(channelName, {
958
+ type: "slack",
959
+ name: spec.name,
960
+ botToken,
961
+ appToken
962
+ }).id;
963
+ }
964
+ async ensureDiscord(channelName, spec, dotenv) {
965
+ const byName = this.findExistingDiscord(channelName, spec.name);
966
+ const botToken = await this.resolveField({
967
+ literal: spec.botToken,
968
+ envVar: spec.env?.botToken,
969
+ dotenv,
970
+ label: `${spec.name}.botToken`,
971
+ existing: byName?.botToken
972
+ });
973
+ if (byName) {
974
+ if (byName.botToken !== botToken) this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
975
+ return byName.id;
976
+ }
977
+ const byToken = this.findDiscordByToken(channelName, botToken);
978
+ if (byToken) {
979
+ this.channels.renameConnector(channelName, byToken.name, spec.name);
980
+ if (byToken.botToken !== botToken) this.channels.updateDiscordConnector(channelName, spec.name, { botToken });
981
+ return byToken.id;
982
+ }
983
+ return this.channels.addConnector(channelName, {
984
+ type: "discord",
985
+ name: spec.name,
986
+ botToken
987
+ }).id;
988
+ }
989
+ ensureGh(channelName, spec) {
990
+ const existing = this.channels.getConnector(channelName, spec.name);
991
+ if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
992
+ if (existing && existing.type === "gh") {
993
+ if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
994
+ return existing.id;
995
+ }
996
+ return this.channels.addConnector(channelName, {
997
+ type: "gh",
998
+ name: spec.name,
999
+ ...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
1000
+ }).id;
1001
+ }
1002
+ ensureSchedule(channelName, spec) {
1003
+ const existing = this.channels.getConnector(channelName, spec.name);
1004
+ if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
1005
+ if (existing && existing.type === "schedule") return existing.id;
1006
+ return this.channels.addConnector(channelName, {
1007
+ type: "schedule",
1008
+ name: spec.name
1009
+ }).id;
1010
+ }
1011
+ findExistingSlack(channelName, connectorName) {
1012
+ const existing = this.channels.getConnector(channelName, connectorName);
1013
+ if (!existing) return null;
1014
+ if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
1015
+ return existing;
1016
+ }
1017
+ findExistingDiscord(channelName, connectorName) {
1018
+ const existing = this.channels.getConnector(channelName, connectorName);
1019
+ if (!existing) return null;
1020
+ if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
1021
+ return existing;
1022
+ }
1023
+ findSlackByToken(channelName, tokens) {
1024
+ const channel = this.channels.get(channelName);
1025
+ if (!channel) return null;
1026
+ for (const connector of channel.connectors) {
1027
+ if (connector.type !== "slack") continue;
1028
+ if (tokens.includes(connector.botToken) || tokens.includes(connector.appToken)) return connector;
1029
+ }
1030
+ return null;
1031
+ }
1032
+ findDiscordByToken(channelName, token) {
1033
+ const channel = this.channels.get(channelName);
1034
+ if (!channel) return null;
1035
+ for (const connector of channel.connectors) {
1036
+ if (connector.type !== "discord") continue;
1037
+ if (connector.botToken === token) return connector;
1038
+ }
1039
+ return null;
1040
+ }
1041
+ removeExtras(channelName, touched) {
1042
+ const channel = this.channels.get(channelName);
1043
+ if (!channel) return;
1044
+ const stale = channel.connectors.filter((c) => !touched.has(c.id));
1045
+ for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
1046
+ }
1047
+ async resolveField(input) {
1048
+ if (input.literal !== void 0 && input.envVar !== void 0) throw new Error(`${input.label} is set both as a literal and as env.${input.label.split(".").pop()}; pick one`);
1049
+ if (input.literal !== void 0 && input.literal !== "") return input.literal;
1050
+ if (input.envVar !== void 0 && input.envVar !== "") {
1051
+ const fromProcessEnv = this.env[input.envVar];
1052
+ if (fromProcessEnv) return fromProcessEnv;
1053
+ const fromDotenv = input.dotenv[input.envVar];
1054
+ if (fromDotenv) return fromDotenv;
1055
+ throw new Error(`${input.label} references env var "${input.envVar}" but it is not set in process env or .env.local`);
1056
+ }
1057
+ if (input.existing) return input.existing;
1058
+ return await this.prompter.promptSecret(input.label);
1059
+ }
1060
+ };
1061
+ //#endregion
723
1062
  //#region lib/engine/logger/memory-logger.ts
724
1063
  var MemoryFunnelLogger = class extends FunnelLogger {
725
1064
  file = null;
@@ -977,6 +1316,73 @@ var FunnelProfiles = class {
977
1316
  }
978
1317
  };
979
1318
  //#endregion
1319
+ //#region lib/engine/token-prompter/node-token-prompter.ts
1320
+ const STAR = "*";
1321
+ const CR = "\r";
1322
+ const LF = "\n";
1323
+ const BACKSPACE = String.fromCharCode(8);
1324
+ const DEL = String.fromCharCode(127);
1325
+ const CTRL_C = String.fromCharCode(3);
1326
+ const CTRL_D = String.fromCharCode(4);
1327
+ /**
1328
+ * Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
1329
+ * can see progress without exposing the token. Refuses to prompt when stdin
1330
+ * is not a TTY — callers should surface the resulting error with a hint
1331
+ * pointing at the corresponding env var or CLI command.
1332
+ */
1333
+ var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
1334
+ async promptSecret(label) {
1335
+ if (!stdin.isTTY) throw new Error(`cannot prompt for "${label}": stdin is not a TTY. Set the matching env var or run \`fnl channels <ch> connectors add ...\` first.`);
1336
+ stderr.write(`${label}: `);
1337
+ const wasRaw = stdin.isRaw;
1338
+ stdin.setRawMode(true);
1339
+ stdin.resume();
1340
+ try {
1341
+ return await this.readSecret();
1342
+ } finally {
1343
+ stdin.setRawMode(wasRaw);
1344
+ stdin.pause();
1345
+ stderr.write(LF);
1346
+ }
1347
+ }
1348
+ readSecret() {
1349
+ return new Promise((resolve, reject) => {
1350
+ let buffer = "";
1351
+ const onData = (chunk) => {
1352
+ for (const byte of chunk) {
1353
+ const char = String.fromCharCode(byte);
1354
+ if (char === LF || char === CR) {
1355
+ stdin.off("data", onData);
1356
+ resolve(buffer);
1357
+ return;
1358
+ }
1359
+ if (char === CTRL_C) {
1360
+ stdin.off("data", onData);
1361
+ reject(/* @__PURE__ */ new Error("prompt cancelled"));
1362
+ return;
1363
+ }
1364
+ if (char === CTRL_D) {
1365
+ stdin.off("data", onData);
1366
+ if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
1367
+ else resolve(buffer);
1368
+ return;
1369
+ }
1370
+ if (char === BACKSPACE || char === DEL) {
1371
+ if (buffer.length > 0) {
1372
+ buffer = buffer.slice(0, -1);
1373
+ stderr.write("\b \b");
1374
+ }
1375
+ continue;
1376
+ }
1377
+ buffer += char;
1378
+ stderr.write(STAR);
1379
+ }
1380
+ };
1381
+ stdin.on("data", onData);
1382
+ });
1383
+ }
1384
+ };
1385
+ //#endregion
980
1386
  //#region lib/engine/settings/mock-settings-reader.ts
981
1387
  const createSettings = (partial = {}) => ({
982
1388
  version: 1,
@@ -1096,14 +1502,18 @@ var FunnelChannelPublisher = class {
1096
1502
  * The candidates cover:
1097
1503
  * 1. dev: this helper lives at lib/gateway/, so daemon.ts is its sibling
1098
1504
  * 2. built sibling: dist/gateway/daemon.js if the helper itself ends up at dist/gateway/
1099
- * 3. bundled: when this helper is inlined into dist/bin.js, import.meta.dir is dist/,
1505
+ * 3. bundled: when this helper is inlined into dist/bin.js, the helper's dir is dist/,
1100
1506
  * and daemon.js lives at dist/gateway/daemon.js
1507
+ *
1508
+ * `import.meta.url` works in both Bun and Node test runners; `import.meta.dir`
1509
+ * is Bun-only and breaks vitest.
1101
1510
  */
1102
1511
  const resolveDaemonScript = () => {
1512
+ const here = dirname(fileURLToPath(import.meta.url));
1103
1513
  const candidates = [
1104
- resolve(import.meta.dir, "./daemon.ts"),
1105
- resolve(import.meta.dir, "./daemon.js"),
1106
- resolve(import.meta.dir, "./gateway/daemon.js")
1514
+ resolve(here, "./daemon.ts"),
1515
+ resolve(here, "./daemon.js"),
1516
+ resolve(here, "./gateway/daemon.js")
1107
1517
  ];
1108
1518
  for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1109
1519
  throw new Error(`daemon script not found (looked in ${candidates.join(", ")})`);
@@ -1131,6 +1541,7 @@ var FunnelGateway = class {
1131
1541
  process;
1132
1542
  fs;
1133
1543
  clock;
1544
+ dir;
1134
1545
  pidFile;
1135
1546
  logDir;
1136
1547
  gatewayLog;
@@ -1141,9 +1552,9 @@ var FunnelGateway = class {
1141
1552
  this.process = deps.process ?? defaultProcess$1;
1142
1553
  this.fs = deps.fs ?? defaultFs$1;
1143
1554
  this.clock = deps.clock ?? defaultClock;
1144
- const baseDir = deps.dir ?? FUNNEL_DIR;
1555
+ this.dir = deps.dir ?? FUNNEL_DIR;
1145
1556
  this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR$1;
1146
- this.pidFile = join(baseDir, "gateway.pid");
1557
+ this.pidFile = join(this.dir, "gateway.pid");
1147
1558
  this.logDir = join(this.tmpDir, "events");
1148
1559
  this.gatewayLog = join(this.tmpDir, "gateway.log");
1149
1560
  this.port = deps.port ?? DEFAULT_PORT$1;
@@ -1174,15 +1585,15 @@ var FunnelGateway = class {
1174
1585
  "-c",
1175
1586
  command
1176
1587
  ]);
1177
- const deadline = Date.now() + STARTUP_TIMEOUT_MS;
1178
- while (Date.now() < deadline) {
1588
+ const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
1589
+ while (this.clock.millis() < deadline) {
1179
1590
  if (this.isRunning()) return true;
1180
1591
  await this.sleep(POLL_INTERVAL_MS$1);
1181
1592
  }
1182
1593
  return this.isRunning();
1183
1594
  }
1184
1595
  buildStartCommand(gatewayScript, options = {}) {
1185
- return `nohup ${options.caffeinate !== false && globalThis.process.platform === "darwin" ? "caffeinate -i " : ""}bun ${gatewayScript} >> ${this.gatewayLog} 2>&1 &`;
1596
+ return `nohup ${options.caffeinate !== false && globalThis.process.platform === "darwin" ? "caffeinate -i " : ""}bun ${gatewayScript} ${`funnel-gateway[${this.dir}]`} >> ${this.gatewayLog} 2>&1 &`;
1186
1597
  }
1187
1598
  async stop() {
1188
1599
  const pid = this.readPid();
@@ -2083,12 +2494,15 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
2083
2494
  //#region lib/gateway/kill-competing-slack-gateways.ts
2084
2495
  const defaultProcess = new NodeFunnelProcessRunner();
2085
2496
  const defaultLogger$1 = new NodeFunnelLogger();
2086
- const isBun = (args) => {
2087
- return args.includes("bun ") || /\/bun(\s|$)/.test(args);
2088
- };
2089
- const looksLikeSlackGateway = (args) => {
2090
- return /(gateway|bolt|slack)/i.test(args);
2091
- };
2497
+ const titleFor = (dir) => `funnel-gateway[${dir}]`;
2498
+ /**
2499
+ * Kills other funnel daemon processes that share the SAME funnel home dir,
2500
+ * which is the only situation that causes a real conflict (duplicate Slack
2501
+ * Socket Mode connections with the same tokens). Daemons rooted at a
2502
+ * different `~/.funnel/` are left alone — they hold different tokens and
2503
+ * speak to different Slack apps. The daemon advertises its dir via
2504
+ * `process.title = "funnel-gateway[<dir>]"`, which this routine matches.
2505
+ */
2092
2506
  const killCompetingSlackGateways = async (props) => {
2093
2507
  const runner = props.process ?? defaultProcess;
2094
2508
  const logger = props.logger ?? defaultLogger$1;
@@ -2099,6 +2513,7 @@ const killCompetingSlackGateways = async (props) => {
2099
2513
  "pid=,args="
2100
2514
  ]);
2101
2515
  if (result.exitCode !== 0) return [];
2516
+ const expectedTitle = titleFor(props.dir);
2102
2517
  const killed = [];
2103
2518
  for (const raw of result.stdout.split("\n")) {
2104
2519
  const line = raw.trim();
@@ -2109,8 +2524,7 @@ const killCompetingSlackGateways = async (props) => {
2109
2524
  const args = match[2];
2110
2525
  if (!Number.isInteger(pid) || pid <= 0) continue;
2111
2526
  if (pid === props.selfPid) continue;
2112
- if (!isBun(args)) continue;
2113
- if (!looksLikeSlackGateway(args)) continue;
2527
+ if (!args.includes(expectedTitle)) continue;
2114
2528
  runner.kill(pid, "SIGTERM");
2115
2529
  killed.push(pid);
2116
2530
  logger.info("killed competing Slack gateway process", {
@@ -2302,12 +2716,14 @@ var FunnelGatewayServer = class {
2302
2716
  process;
2303
2717
  logger;
2304
2718
  selfPid;
2719
+ dir;
2305
2720
  killCompetingSlack;
2306
2721
  token;
2307
2722
  broadcaster;
2308
2723
  eventStore;
2309
2724
  supervisor;
2310
2725
  nowMs;
2726
+ extraRoutes;
2311
2727
  startedAt = null;
2312
2728
  server = null;
2313
2729
  constructor(deps) {
@@ -2318,8 +2734,10 @@ var FunnelGatewayServer = class {
2318
2734
  this.process = deps.process;
2319
2735
  this.logger = deps.logger ?? defaultLogger;
2320
2736
  this.selfPid = deps.selfPid ?? globalThis.process.pid;
2737
+ this.dir = deps.dir ?? FUNNEL_DIR;
2321
2738
  this.killCompetingSlack = deps.killCompetingSlack ?? true;
2322
2739
  this.token = deps.token ?? "";
2740
+ this.extraRoutes = deps.extraRoutes ?? null;
2323
2741
  const clock = deps.clock;
2324
2742
  this.nowMs = clock ? () => clock.millis() : () => Date.now();
2325
2743
  if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true });
@@ -2481,7 +2899,7 @@ var FunnelGatewayServer = class {
2481
2899
  base.use("/status", requireBearerToken({ expected: this.token }));
2482
2900
  base.use("/channels/*", requireBearerToken({ expected: this.token }));
2483
2901
  }
2484
- return base.route("/", gatewayRoutes);
2902
+ return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
2485
2903
  }
2486
2904
  /**
2487
2905
  * Reads the bearer token from the WebSocket upgrade request. Accepts:
@@ -2511,6 +2929,7 @@ var FunnelGatewayServer = class {
2511
2929
  if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
2512
2930
  const killed = await killCompetingSlackGateways({
2513
2931
  selfPid: this.selfPid,
2932
+ dir: this.dir,
2514
2933
  process: this.process,
2515
2934
  logger: this.logger
2516
2935
  });
@@ -2737,7 +3156,7 @@ const SANDBOX_TMP_DIR = "/sandbox/tmp";
2737
3156
  * ```
2738
3157
  */
2739
3158
  var Funnel = class Funnel {
2740
- cache = /* @__PURE__ */ new Map();
3159
+ memos = {};
2741
3160
  constructor(props = {}) {
2742
3161
  this.props = props;
2743
3162
  Object.freeze(this);
@@ -2759,12 +3178,6 @@ var Funnel = class Funnel {
2759
3178
  tmpDir: props.tmpDir ?? SANDBOX_TMP_DIR
2760
3179
  });
2761
3180
  }
2762
- memo(key, build) {
2763
- if (this.cache.has(key)) return this.cache.get(key);
2764
- const value = build();
2765
- this.cache.set(key, value);
2766
- return value;
2767
- }
2768
3181
  /** Resolved on-disk paths the facade will read/write when methods are called. Pure compute, not memoized. */
2769
3182
  get paths() {
2770
3183
  const dir = this.props.dir ?? FUNNEL_DIR;
@@ -2776,61 +3189,97 @@ var Funnel = class Funnel {
2776
3189
  }
2777
3190
  /** Filesystem boundary. Defaults to NodeFunnelFileSystem. */
2778
3191
  get fs() {
2779
- return this.memo("fs", () => this.props.fs ?? new NodeFunnelFileSystem());
3192
+ if (!this.memos.fs) this.memos.fs = this.props.fs ?? new NodeFunnelFileSystem();
3193
+ return this.memos.fs;
2780
3194
  }
2781
3195
  /** Process runner boundary. Defaults to NodeFunnelProcessRunner. */
2782
3196
  get process() {
2783
- return this.memo("process", () => this.props.process ?? new NodeFunnelProcessRunner());
3197
+ if (!this.memos.process) this.memos.process = this.props.process ?? new NodeFunnelProcessRunner();
3198
+ return this.memos.process;
2784
3199
  }
2785
3200
  /** Logger boundary. Defaults to NodeFunnelLogger. */
2786
3201
  get logger() {
2787
- return this.memo("logger", () => this.props.logger ?? new NodeFunnelLogger());
3202
+ if (!this.memos.logger) this.memos.logger = this.props.logger ?? new NodeFunnelLogger();
3203
+ return this.memos.logger;
2788
3204
  }
2789
3205
  /** Clock boundary. Defaults to NodeFunnelClock. */
2790
3206
  get clock() {
2791
- return this.memo("clock", () => this.props.clock ?? new NodeFunnelClock());
3207
+ if (!this.memos.clock) this.memos.clock = this.props.clock ?? new NodeFunnelClock();
3208
+ return this.memos.clock;
2792
3209
  }
2793
3210
  /** ID generator boundary. Defaults to NodeFunnelIdGenerator. */
2794
3211
  get idGenerator() {
2795
- return this.memo("idGenerator", () => this.props.idGenerator ?? new NodeFunnelIdGenerator());
3212
+ if (!this.memos.idGenerator) this.memos.idGenerator = this.props.idGenerator ?? new NodeFunnelIdGenerator();
3213
+ return this.memos.idGenerator;
2796
3214
  }
2797
3215
  /** Settings reader. If not injected, a FunnelSettingsStore rooted at `dir` is created. */
2798
3216
  get store() {
2799
- return this.memo("store", () => this.props.store ?? new FunnelSettingsStore({
3217
+ if (!this.memos.store) this.memos.store = this.props.store ?? new FunnelSettingsStore({
2800
3218
  path: this.paths.settings,
2801
3219
  fs: this.fs
2802
- }));
3220
+ });
3221
+ return this.memos.store;
2803
3222
  }
2804
3223
  /** Pure factory that constructs per-type listeners and adapters from connector configs. */
2805
3224
  get factory() {
2806
- return this.memo("factory", () => new FunnelConnectorFactory({
3225
+ if (!this.memos.factory) this.memos.factory = new FunnelConnectorFactory({
2807
3226
  fs: this.fs,
2808
3227
  process: this.process,
2809
3228
  logger: this.logger,
2810
- dir: this.paths.dir
2811
- }));
3229
+ dir: this.paths.dir,
3230
+ slackListenerOptions: this.props.slackListenerOptions,
3231
+ scheduleListenerOptions: this.props.scheduleListenerOptions
3232
+ });
3233
+ return this.memos.factory;
2812
3234
  }
2813
3235
  /** Channel CRUD + nested connector CRUD + schedule entries + listener/adapter dispatch. */
2814
3236
  get channels() {
2815
- return this.memo("channels", () => new FunnelChannels({
3237
+ if (!this.memos.channels) this.memos.channels = new FunnelChannels({
2816
3238
  store: this.store,
2817
3239
  factory: this.factory,
2818
3240
  profileChecker: this.profiles,
2819
3241
  clock: this.clock,
2820
3242
  idGenerator: this.idGenerator
2821
- }));
3243
+ });
3244
+ return this.memos.channels;
2822
3245
  }
2823
3246
  /** Launch profiles (named presets for `fnl claude`: path + sub-agent + channel id). */
2824
3247
  get profiles() {
2825
- return this.memo("profiles", () => new FunnelProfiles({ store: this.store }));
3248
+ if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({ store: this.store });
3249
+ return this.memos.profiles;
3250
+ }
3251
+ /** Reads `funnel.json` from a cwd. `fnl claude` consults it before falling back to the default profile. */
3252
+ get localConfig() {
3253
+ if (!this.memos.localConfig) this.memos.localConfig = new FunnelLocalConfig({ fs: this.fs });
3254
+ return this.memos.localConfig;
3255
+ }
3256
+ /** Parses `.env.local` from a cwd (used by sync to back $VAR references). */
3257
+ get dotenv() {
3258
+ if (!this.memos.dotenv) this.memos.dotenv = new FunnelDotenvReader({ fs: this.fs });
3259
+ return this.memos.dotenv;
3260
+ }
3261
+ /** Secret prompter. Defaults to a TTY-only stdin reader; tests inject MemoryFunnelTokenPrompter. */
3262
+ get tokenPrompter() {
3263
+ if (!this.memos.tokenPrompter) this.memos.tokenPrompter = this.props.tokenPrompter ?? new NodeFunnelTokenPrompter();
3264
+ return this.memos.tokenPrompter;
3265
+ }
3266
+ /** Reconciles funnel.json's channel + connectors with `~/.funnel/settings.json` on launch. */
3267
+ get localConfigSync() {
3268
+ if (!this.memos.localConfigSync) this.memos.localConfigSync = new FunnelLocalConfigSync({
3269
+ channels: this.channels,
3270
+ dotenv: this.dotenv,
3271
+ prompter: this.tokenPrompter
3272
+ });
3273
+ return this.memos.localConfigSync;
2826
3274
  }
2827
3275
  /** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
2828
3276
  get mcp() {
2829
- return this.memo("mcp", () => new FunnelMcp({ fs: this.fs }));
3277
+ if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
3278
+ return this.memos.mcp;
2830
3279
  }
2831
3280
  /** Launch Claude Code with a channel injected via env, MCP installed, gateway ensured. */
2832
3281
  get claude() {
2833
- return this.memo("claude", () => new FunnelClaude({
3282
+ if (!this.memos.claude) this.memos.claude = new FunnelClaude({
2834
3283
  channels: this.channels,
2835
3284
  mcp: this.mcp,
2836
3285
  gateway: this.gateway,
@@ -2838,24 +3287,27 @@ var Funnel = class Funnel {
2838
3287
  process: this.process,
2839
3288
  logger: this.logger,
2840
3289
  dir: this.paths.dir
2841
- }));
3290
+ });
3291
+ return this.memos.claude;
2842
3292
  }
2843
3293
  /** Gateway daemon controller (PID-file, start/stop the separate `bun daemon.ts` process). */
2844
3294
  get gateway() {
2845
- return this.memo("gateway", () => new FunnelGateway({
3295
+ if (!this.memos.gateway) this.memos.gateway = new FunnelGateway({
2846
3296
  fs: this.fs,
2847
3297
  process: this.process,
2848
3298
  clock: this.clock,
2849
3299
  dir: this.paths.dir,
2850
3300
  tmpDir: this.paths.tmpDir
2851
- }));
3301
+ });
3302
+ return this.memos.gateway;
2852
3303
  }
2853
3304
  /** Read / generate the daemon's gateway token (mode 0600 file under `dir`). */
2854
3305
  get gatewayToken() {
2855
- return this.memo("gatewayToken", () => new FunnelGatewayToken({
3306
+ if (!this.memos.gatewayToken) this.memos.gatewayToken = new FunnelGatewayToken({
2856
3307
  fs: this.fs,
2857
3308
  dir: this.paths.dir
2858
- }));
3309
+ });
3310
+ return this.memos.gatewayToken;
2859
3311
  }
2860
3312
  /**
2861
3313
  * HTTP client for `POST /channels/:channel/publish` on the running gateway
@@ -2863,15 +3315,16 @@ var Funnel = class Funnel {
2863
3315
  * connector. Returns `{ state: "offline" }` if the daemon isn't up.
2864
3316
  */
2865
3317
  get publisher() {
2866
- return this.memo("publisher", () => {
3318
+ if (!this.memos.publisher) {
2867
3319
  const gateway = this.gateway;
2868
3320
  const token = this.gatewayToken;
2869
- return new FunnelChannelPublisher({
3321
+ this.memos.publisher = new FunnelChannelPublisher({
2870
3322
  port: gateway.getPort(),
2871
3323
  isDaemonRunning: () => gateway.isRunning(),
2872
3324
  getToken: () => token.read()
2873
3325
  });
2874
- });
3326
+ }
3327
+ return this.memos.publisher;
2875
3328
  }
2876
3329
  /**
2877
3330
  * HTTP client for listener operations on the running gateway daemon.
@@ -2879,15 +3332,16 @@ var Funnel = class Funnel {
2879
3332
  * paths stay write-only without parsing strings.
2880
3333
  */
2881
3334
  get listeners() {
2882
- return this.memo("listeners", () => {
3335
+ if (!this.memos.listeners) {
2883
3336
  const gateway = this.gateway;
2884
3337
  const token = this.gatewayToken;
2885
- return new FunnelListenersClient({
3338
+ this.memos.listeners = new FunnelListenersClient({
2886
3339
  port: gateway.getPort(),
2887
3340
  isDaemonRunning: () => gateway.isRunning(),
2888
3341
  getToken: () => token.read()
2889
3342
  });
2890
- });
3343
+ }
3344
+ return this.memos.listeners;
2891
3345
  }
2892
3346
  /**
2893
3347
  * In-process gateway server. Unlike `gateway.start()` (which spawns a daemon),
@@ -2904,7 +3358,8 @@ var Funnel = class Funnel {
2904
3358
  clock: this.clock,
2905
3359
  logger: this.logger,
2906
3360
  killCompetingSlack: options.killCompetingSlack,
2907
- token: options.token ?? this.gatewayToken.ensure()
3361
+ token: options.token ?? this.gatewayToken.ensure(),
3362
+ extraRoutes: options.extraRoutes
2908
3363
  });
2909
3364
  }
2910
3365
  };
@@ -3016,7 +3471,7 @@ const startChannelServer = async (options = {}) => {
3016
3471
  const channelId = options.channelId ?? process.env.FUNNEL_CHANNEL_ID;
3017
3472
  const channel = channelId ? readChannelConnectors(dir, channelId) : null;
3018
3473
  const token = options.token ?? readGatewayToken(dir);
3019
- const server = new Server$1({
3474
+ const server = new Server({
3020
3475
  name: FUNNEL_MCP_NAME,
3021
3476
  version: "1.0.0"
3022
3477
  }, {
@@ -3091,6 +3546,41 @@ const startChannelServer = async (options = {}) => {
3091
3546
  }).start();
3092
3547
  };
3093
3548
  //#endregion
3549
+ //#region lib/engine/local-config/local-config-json-schema.ts
3550
+ /**
3551
+ * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
3552
+ * `$schema` references in committed `funnel.json` files so editors can give
3553
+ * autocomplete and validation for channel / subAgent / env / connectors[]
3554
+ * without anyone hand-maintaining a separate schema.
3555
+ */
3556
+ const funnelJsonSchema = () => {
3557
+ return {
3558
+ ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
3559
+ title: "Funnel per-repo launch config",
3560
+ description: "Used by `fnl claude` when no --profile / --channel is given. Declares the channel to subscribe to, optional sub-agent and brief flag, environment variables to layer under process.env, and optional connectors to materialize into ~/.funnel/settings.json on launch."
3561
+ };
3562
+ };
3563
+ //#endregion
3564
+ //#region lib/engine/token-prompter/memory-token-prompter.ts
3565
+ /**
3566
+ * Pre-seeded answers keyed by prompt label. Tests configure the map up front;
3567
+ * unmapped labels throw so the test surfaces unexpected prompts loudly.
3568
+ */
3569
+ var MemoryFunnelTokenPrompter = class extends FunnelTokenPrompter {
3570
+ answers;
3571
+ asked = [];
3572
+ constructor(props = {}) {
3573
+ super();
3574
+ this.answers = new Map(Object.entries(props.answers ?? {}));
3575
+ }
3576
+ async promptSecret(label) {
3577
+ this.asked.push(label);
3578
+ const answer = this.answers.get(label);
3579
+ if (answer === void 0) throw new Error(`no answer seeded for prompt "${label}"`);
3580
+ return answer;
3581
+ }
3582
+ };
3583
+ //#endregion
3094
3584
  //#region lib/cli/factory.ts
3095
3585
  const factory = createFactory();
3096
3586
  //#endregion
@@ -3585,16 +4075,23 @@ examples:
3585
4075
  const claudeHelp = `funnel claude — launch Claude Code
3586
4076
 
3587
4077
  usage:
3588
- funnel claude launch the default profile (first in the list)
4078
+ funnel claude launch using funnel.json in cwd, or the default profile
3589
4079
  funnel claude -p <name> launch a named profile
3590
4080
  funnel claude --profile <name> (long form)
3591
4081
  funnel claude --channel <name> raw launch (no profile, cwd = current dir)
4082
+ funnel claude [...] any other argument is forwarded to the claude CLI
3592
4083
 
3593
- options:
4084
+ resolution order when no --profile / --channel is given:
4085
+ 1. ./funnel.json in the current directory
4086
+ 2. the default profile (first entry in fnl profiles)
4087
+
4088
+ funnel-specific options (everything else passes through to claude verbatim):
3594
4089
  -p, --profile profile name to launch
3595
4090
  --channel channel name (raw launch, ignored when --profile is given)
4091
+ -h, --help show this help
3596
4092
 
3597
- Any other arguments are forwarded to the claude CLI.
4093
+ Positional args, unknown short flags (e.g. -c, -r), and claude's own flags
4094
+ (--agent, --resume, --model, --print, --output-format ...) are all forwarded.
3598
4095
  On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`;
3599
4096
  const RESERVED_KEYS$1 = ["profile", "channel"];
3600
4097
  const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
@@ -3603,25 +4100,48 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
3603
4100
  }).passthrough(), claudeHelp), async (c) => {
3604
4101
  const query = c.req.valid("query");
3605
4102
  const funnel = c.var.funnel;
4103
+ const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
3606
4104
  if (query.channel && !query.profile) {
3607
4105
  const exitCode = await funnel.claude.launch({
3608
4106
  channel: query.channel,
3609
- userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS$1)
4107
+ userArgs
4108
+ });
4109
+ process.exit(exitCode);
4110
+ }
4111
+ if (query.profile) {
4112
+ const profile = funnel.profiles.get(query.profile);
4113
+ if (!profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
4114
+ const exitCode = await funnel.claude.launch({
4115
+ channel: profile.channelId,
4116
+ cwd: profile.path,
4117
+ subAgent: profile.subAgent,
4118
+ userArgs,
4119
+ profileName: profile.name,
4120
+ brief: profile.brief
3610
4121
  });
3611
4122
  process.exit(exitCode);
3612
4123
  }
3613
- const profile = query.profile ? funnel.profiles.get(query.profile) : funnel.profiles.getDefault();
3614
- if (!profile) {
3615
- if (query.profile) throw new HTTPException(404, { message: `profile "${query.profile}" not found` });
3616
- return c.text(claudeHelp);
4124
+ const cwd = process.cwd();
4125
+ const local = funnel.localConfig.read(cwd);
4126
+ if (local) {
4127
+ await funnel.localConfigSync.ensure(local, cwd);
4128
+ const exitCode = await funnel.claude.launch({
4129
+ channel: local.channel,
4130
+ cwd,
4131
+ userArgs: [...local.options ?? [], ...userArgs],
4132
+ extraEnv: local.env
4133
+ });
4134
+ process.exit(exitCode);
3617
4135
  }
4136
+ const defaultProfile = funnel.profiles.getDefault();
4137
+ if (!defaultProfile) return c.text(claudeHelp);
3618
4138
  const exitCode = await funnel.claude.launch({
3619
- channel: profile.channelId,
3620
- cwd: profile.path,
3621
- subAgent: profile.subAgent,
3622
- userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS$1),
3623
- profileName: profile.name,
3624
- brief: profile.brief
4139
+ channel: defaultProfile.channelId,
4140
+ cwd: defaultProfile.path,
4141
+ subAgent: defaultProfile.subAgent,
4142
+ userArgs,
4143
+ profileName: defaultProfile.name,
4144
+ brief: defaultProfile.brief
3625
4145
  });
3626
4146
  process.exit(exitCode);
3627
4147
  });
@@ -3985,6 +4505,24 @@ examples:
3985
4505
  });
3986
4506
  return c.text(lines.join("\n"));
3987
4507
  });
4508
+ const schemaHandler = factory.createHandlers(zValidator$1("query", z.object({}), `funnel schema — print the JSON Schema for funnel.json
4509
+
4510
+ usage: funnel schema
4511
+
4512
+ Outputs the draft 2020-12 JSON Schema describing the per-repo funnel.json
4513
+ file. Pipe it into a local file and reference it from funnel.json so editors
4514
+ can validate and autocomplete the config:
4515
+
4516
+ fnl schema > funnel.schema.json
4517
+
4518
+ # funnel.json
4519
+ {
4520
+ "$schema": "./funnel.schema.json",
4521
+ "channel": "ops"
4522
+ }`), async (c) => {
4523
+ const schema = funnelJsonSchema();
4524
+ return c.text(`${JSON.stringify(schema, null, 2)}\n`);
4525
+ });
3988
4526
  //#endregion
3989
4527
  //#region lib/cli/routes/status.ts
3990
4528
  const statusHelp = `funnel status — show overall connection status
@@ -4078,7 +4616,7 @@ const createCliApp = (funnel) => {
4078
4616
  if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
4079
4617
  return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
4080
4618
  });
4081
- return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
4619
+ return base.get("/claude", ...claudeHandler).get("/channels", ...channelsGroupHandler).post("/channels/add", ...helpRoute(addHelp$3)).post("/channels/add/:channel", ...channelsAddHandler).post("/channels/remove", ...helpRoute(removeHelp$1)).post("/channels/remove/:channel", ...channelsRemoveHandler).post("/channels/rename/:channel/:newName", ...channelsRenameHandler).post("/channels/:channel/rename/:newName", ...channelsRenameHandler).post("/channels/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/rename", ...helpRoute(renameHelp$1)).post("/channels/:channel/set/delivery/:mode", ...channelsSetDeliveryHandler).post("/channels/publish", ...helpRoute(publishHelp)).post("/channels/:channel/publish", ...channelsPublishHandler).get("/channels/:channel", ...channelsShowHandler).get("/channels/:channel/connectors", ...channelsConnectorsGroupHandler).post("/channels/:channel/connectors/add", ...helpRoute(addHelp$2)).post("/channels/:channel/connectors/add/:connector", ...channelsConnectorsAddHandler).post("/channels/:channel/connectors/remove", ...helpRoute(removeHelp$3)).post("/channels/:channel/connectors/remove/:connector", ...channelsConnectorsRemoveHandler).post("/channels/:channel/connectors/set", ...helpRoute(setHelp$1)).post("/channels/:channel/connectors/set/:connector", ...channelsConnectorsSetHandler).post("/channels/:channel/connectors/rename/:connector/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/:connector/rename/:newName", ...channelsConnectorsRenameHandler).post("/channels/:channel/connectors/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/rename", ...helpRoute(renameHelp$2)).post("/channels/:channel/connectors/:connector/request", ...channelsConnectorsRequestHandler).get("/channels/:channel/connectors/:connector", ...channelsConnectorsShowHandler).get("/channels/:channel/connectors/:connector/schedules", ...channelsConnectorsSchedulesGroupHandler).post("/channels/:channel/connectors/:connector/schedules/add", ...helpRoute(addHelp$1)).post("/channels/:channel/connectors/:connector/schedules/add/:id", ...channelsConnectorsSchedulesAddHandler).post("/channels/:channel/connectors/:connector/schedules/remove", ...helpRoute(removeHelp$2)).post("/channels/:channel/connectors/:connector/schedules/remove/:id", ...channelsConnectorsSchedulesRemoveHandler).get("/profiles", ...profilesGroupHandler).post("/profiles/add", ...helpRoute(addHelp)).post("/profiles/add/:profile", ...profilesAddHandler).post("/profiles/set", ...helpRoute(setHelp)).post("/profiles/set/:profile", ...profilesSetHandler).post("/profiles/remove", ...helpRoute(removeHelp)).post("/profiles/remove/:profile", ...profilesRemoveHandler).post("/profiles/rename/:profile/:newName", ...profilesRenameHandler).post("/profiles/:profile/rename/:newName", ...profilesRenameHandler).post("/profiles/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/rename", ...helpRoute(renameHelp)).post("/profiles/:profile/as-default", ...profilesAsDefaultHandler).get("/profiles/:profile/run", ...profilesLaunchHandler).get("/profiles/:profile", ...profilesLaunchHandler).get("/gateway", ...gatewayGroupHandler).get("/gateway/status", ...gatewayStatusHandler).get("/gateway/start", ...gatewayStartHandler).get("/gateway/stop", ...gatewayStopHandler).get("/gateway/restart", ...gatewayRestartHandler).get("/gateway/run", ...gatewayRunHandler).get("/gateway/logs", ...gatewayLogsHandler).get("/gateway/listeners", ...gatewayListenersHandler).get("/schema", ...schemaHandler).get("/status", ...statusHandler).get("/update", ...updateHandler);
4082
4620
  };
4083
4621
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
4084
4622
  const app = createCliApp(new Funnel());
@@ -6293,4 +6831,4 @@ async function launchTui(funnel) {
6293
6831
  });
6294
6832
  }
6295
6833
  //#endregion
6296
- export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, app as cliApp, connectorConfigSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, ghConnectorSchema, launchTui, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
6834
+ export { DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelDotenvReader, FunnelEventStore, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, LOCAL_ENV_FILENAME, MemoryFunnelClock, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, channelConfigSchema, channelDeliveryModeSchema, app as cliApp, connectorConfigSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };