@interactive-inc/claude-funnel 0.41.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +34 -9
  2. package/dist/bin.js +255 -256
  3. package/dist/claude-CB1WkV77.d.ts +115 -0
  4. package/dist/claude.d.ts +59 -0
  5. package/dist/claude.js +322 -0
  6. package/dist/{connector-diagnostic-log-OPpPi9V9.d.ts → connector-diagnostic-log-yTOojKUR.d.ts} +14 -14
  7. package/dist/{logger-Czli2OKh.js → connector-listener-DU54DN-f.js} +1 -9
  8. package/dist/connectors/discord.d.ts +3 -3
  9. package/dist/connectors/discord.js +2 -1
  10. package/dist/connectors/gh.d.ts +4 -3
  11. package/dist/connectors/gh.js +2 -1
  12. package/dist/connectors/schedule.d.ts +1 -1
  13. package/dist/connectors/schedule.js +2 -1
  14. package/dist/connectors/slack.d.ts +2 -2
  15. package/dist/connectors/slack.js +2 -1
  16. package/dist/discord-connector-schema-CBDyGdOI.js +21 -0
  17. package/dist/{discord-connector-schema-BeThExJp.js → discord-listener-_jSE3HsQ.js} +2 -22
  18. package/dist/file-system-BeOKXjlV.d.ts +26 -0
  19. package/dist/file-system-PWKKU7lA.js +9 -0
  20. package/dist/gateway/daemon.js +151 -152
  21. package/dist/gateway.d.ts +3 -0
  22. package/dist/gateway.js +2 -0
  23. package/dist/gh-connector-schema-eoTtHbY6.d.ts +14 -0
  24. package/dist/{gh-connector-schema-eYE4g77K.js → gh-connector-schema-o3Q1-ojL.js} +1 -176
  25. package/dist/gh-listener-DH-fClQm.js +178 -0
  26. package/dist/index-ChomoTZ5.d.ts +3404 -0
  27. package/dist/index.d.ts +11 -4214
  28. package/dist/index.js +195 -3869
  29. package/dist/local-config-json-schema-8IHjS4Q7.js +439 -0
  30. package/dist/local-config-sync-BdsrDZOu.d.ts +381 -0
  31. package/dist/local-config.d.ts +3 -0
  32. package/dist/local-config.js +3 -0
  33. package/dist/logger-BP6SisKt.js +9 -0
  34. package/dist/mcp-Dr-nIBwN.js +253 -0
  35. package/dist/memory-connector-diagnostic-log-CrW1ltLM.js +2245 -0
  36. package/dist/memory-token-prompter-B5FFCsGP.d.ts +57 -0
  37. package/dist/memory-token-prompter-CLerGsgM.js +61 -0
  38. package/dist/node-file-system-BcrmWN9I.js +48 -0
  39. package/dist/{gh-connector-schema-CQmEWzdV.d.ts → process-runner-DfniuWVU.d.ts} +1 -14
  40. package/dist/profiles-f0mNmEyP.d.ts +64 -0
  41. package/dist/profiles-wMRnjSid.js +129 -0
  42. package/dist/profiles.d.ts +2 -0
  43. package/dist/profiles.js +2 -0
  44. package/dist/schedule-connector-schema-iCI61gzU.js +31 -0
  45. package/dist/{schedule-listener-3M6WkH1Y.d.ts → schedule-listener-CUyUFFR1.d.ts} +22 -46
  46. package/dist/{schedule-connector-schema-CM-sRkac.js → schedule-listener-ePAjians.js} +3 -86
  47. package/dist/settings-reader-BSU6JyvM.d.ts +167 -0
  48. package/dist/settings-reader-DPqrpV7s.js +11 -0
  49. package/dist/settings-store-D2XSXTyt.js +186 -0
  50. package/dist/slack-connector-schema-BCNWluHM.js +32 -0
  51. package/dist/{slack-listener-9UdAn_ui.d.ts → slack-listener-Bv5xI9gC.d.ts} +31 -31
  52. package/dist/{slack-connector-schema-DDbSGPZn.js → slack-listener-ClQuHhEF.js} +2 -32
  53. package/package.json +16 -1
  54. /package/dist/{connector-adapter-VA6undzc.d.ts → connector-adapter-DKgsVuMH.d.ts} +0 -0
  55. /package/dist/{discord-connector-schema-DF4pL3Sc.d.ts → discord-connector-schema-R0Uu-3ns.d.ts} +0 -0
@@ -0,0 +1,439 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { stderr, stdin } from "node:process";
4
+ //#region lib/engine/local-config/local-config-schema.ts
5
+ /**
6
+ * Per-repo launch config (`funnel.json`).
7
+ *
8
+ * `fnl claude` reads this when no global --profile preset is used. It picks one
9
+ * of the declared channels (`--channel <name>` selects by name; otherwise the
10
+ * first entry wins) and materializes its transport (connectors / delivery) into
11
+ * the repo's scoped settings (`~/.funnel/projects/<id>/settings.json`) on launch.
12
+ * Connectors carry no tokens here — a token absent from settings is prompted for
13
+ * at launch (TTY) and saved there, never in the repo.
14
+ *
15
+ * The launch recipe (`options` / `env` / `resume`) lives on `profiles[]`, not on
16
+ * the channel: a channel only describes where events come from. `fnl claude`
17
+ * applies the first profile bound to the chosen channel; the recipe is passed
18
+ * straight to the launcher and is not persisted into the global profile list.
19
+ * These profiles are selected by their `channel` binding, not by name.
20
+ */
21
+ const slackConnectorSpecSchema = z.object({
22
+ type: z.literal("slack"),
23
+ name: z.string(),
24
+ /** Shrink raw Slack events before fanout. Defaults to true. */
25
+ minify: z.boolean().optional()
26
+ });
27
+ const discordConnectorSpecSchema = z.object({
28
+ type: z.literal("discord"),
29
+ name: z.string()
30
+ });
31
+ const ghConnectorSpecSchema = z.object({
32
+ type: z.literal("gh"),
33
+ name: z.string(),
34
+ pollInterval: z.number().int().positive().optional()
35
+ });
36
+ const scheduleConnectorSpecSchema = z.object({
37
+ type: z.literal("schedule"),
38
+ name: z.string()
39
+ });
40
+ const connectorSpecSchema = z.discriminatedUnion("type", [
41
+ slackConnectorSpecSchema,
42
+ discordConnectorSpecSchema,
43
+ ghConnectorSpecSchema,
44
+ scheduleConnectorSpecSchema
45
+ ]);
46
+ const channelSpecSchema = z.object({
47
+ name: z.string(),
48
+ connectors: z.array(connectorSpecSchema).optional()
49
+ });
50
+ const profileSpecSchema = z.object({
51
+ /** Handle for `fnl claude --profile <name>`. A profile is only launchable by this name. */
52
+ name: z.string(),
53
+ /** Name of the channel (declared in `channels[]`) this profile binds. The profile depends on the channel, never the reverse. */
54
+ channel: z.string(),
55
+ /** Args prepended to the claude argv on every launch through this profile. */
56
+ options: z.array(z.string()).optional(),
57
+ /** Env vars layered under the launched claude process. process.env wins on collision. */
58
+ env: z.record(z.string(), z.string()).optional(),
59
+ /**
60
+ * When true (the default), funnel injects `--session-id <uuid>` so that
61
+ * relaunching from the same cwd resumes the previous claude session
62
+ * without bleeding into other channels or workspaces. Set to false for
63
+ * profiles that should always start a fresh session.
64
+ */
65
+ resume: z.boolean().optional()
66
+ });
67
+ const localConfigSchema = z.object({
68
+ $schema: z.string().optional(),
69
+ /**
70
+ * Stable per-repo identifier. funnel writes this on first launch when absent;
71
+ * all funnel state for this repo lives under `~/.funnel/projects/<id>/`, so the
72
+ * repo itself never holds settings or tokens. Committed alongside funnel.json.
73
+ */
74
+ id: z.string().optional(),
75
+ /** Declared channels (transport only). First entry is the default; --channel <name> selects by name. */
76
+ channels: z.array(channelSpecSchema).min(1),
77
+ /** Launch presets bound to a channel. First entry bound to the chosen channel is the default. */
78
+ profiles: z.array(profileSpecSchema).optional()
79
+ });
80
+ const LOCAL_CONFIG_FILENAME = "funnel.json";
81
+ //#endregion
82
+ //#region lib/engine/local-config/local-config.ts
83
+ /**
84
+ * Reads `funnel.json` from a directory. Returns `null` when the file is
85
+ * absent so callers can fall through to other resolution paths (default
86
+ * profile, help). Throws on present-but-invalid files so misconfiguration
87
+ * surfaces loudly instead of silently launching the wrong channel.
88
+ */
89
+ var FunnelLocalConfig = class {
90
+ fs;
91
+ constructor(deps) {
92
+ this.fs = deps.fs;
93
+ Object.freeze(this);
94
+ }
95
+ read(cwd) {
96
+ const path = join(cwd, LOCAL_CONFIG_FILENAME);
97
+ if (!this.fs.existsSync(path)) return null;
98
+ const raw = this.fs.readFileSync(path);
99
+ const parsed = (() => {
100
+ try {
101
+ return JSON.parse(raw);
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ throw new Error(`${LOCAL_CONFIG_FILENAME} is not valid JSON: ${message}`);
105
+ }
106
+ })();
107
+ const result = localConfigSchema.safeParse(parsed);
108
+ if (!result.success) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: ${result.error.message}`);
109
+ this.assertProfilesValid(result.data);
110
+ return result.data;
111
+ }
112
+ assertProfilesValid(config) {
113
+ const profiles = config.profiles ?? [];
114
+ if (profiles.length === 0) return;
115
+ const channelNames = new Set(config.channels.map((channel) => channel.name));
116
+ const seenNames = /* @__PURE__ */ new Set();
117
+ for (const profile of profiles) {
118
+ if (!channelNames.has(profile.channel)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: profile "${profile.name}" binds channel "${profile.channel}", which is not declared in channels[]`);
119
+ if (seenNames.has(profile.name)) throw new Error(`${LOCAL_CONFIG_FILENAME} is invalid: more than one profile is named "${profile.name}" — names must be unique`);
120
+ seenNames.add(profile.name);
121
+ }
122
+ }
123
+ };
124
+ //#endregion
125
+ //#region lib/engine/token-prompter/token-prompter.ts
126
+ /**
127
+ * Asks the user for a secret value on stdin. Used as a last resort when a
128
+ * funnel.json token field is absent and not present in `~/.funnel`. The Node
129
+ * implementation refuses to prompt when stdin is not a TTY so non-interactive
130
+ * launches (CI, agent spawning agent, daemons) fail fast instead of hanging.
131
+ */
132
+ var FunnelTokenPrompter = class {};
133
+ //#endregion
134
+ //#region lib/engine/local-config/local-config-sync.ts
135
+ /**
136
+ * Reconciles a single funnel.json channel spec with `~/.funnel/settings.json`.
137
+ * The spec is the source of truth for the channel it declares:
138
+ *
139
+ * - missing channel → created
140
+ * - declared connector matched by name → tokens reconciled
141
+ * - declared connector matched by token in the same channel under a
142
+ * different name → renamed in place (then tokens reconciled)
143
+ * - declared connector with no match → added
144
+ * - any connector left in the channel that the spec did not touch → removed
145
+ *
146
+ * Removal only fires when the channel spec has a `connectors` field. An
147
+ * absent field means "do not manage connectors from here" and leaves
148
+ * everything in `~/.funnel` alone. Other channels in funnel.json (not
149
+ * passed to this call) are untouched.
150
+ *
151
+ * Returns the per-connector change set so callers (e.g. the claude launcher)
152
+ * can drive listener hot-reload on the gateway after settings are written.
153
+ */
154
+ var FunnelLocalConfigSync = class {
155
+ channels;
156
+ prompter;
157
+ constructor(deps) {
158
+ this.channels = deps.channels;
159
+ this.prompter = deps.prompter;
160
+ Object.freeze(this);
161
+ }
162
+ async ensure(channel) {
163
+ if (!this.channels.get(channel.name)) this.channels.add({ name: channel.name });
164
+ if (channel.connectors === void 0) return {
165
+ touched: [],
166
+ removed: []
167
+ };
168
+ const touched = [];
169
+ const touchedIds = /* @__PURE__ */ new Set();
170
+ for (const spec of channel.connectors) {
171
+ const outcome = await this.ensureConnector(channel.name, spec);
172
+ touched.push({
173
+ name: outcome.name,
174
+ changed: outcome.changed
175
+ });
176
+ touchedIds.add(outcome.id);
177
+ }
178
+ return {
179
+ touched,
180
+ removed: this.removeExtras(channel.name, touchedIds)
181
+ };
182
+ }
183
+ async ensureConnector(channelName, spec) {
184
+ if (spec.type === "slack") return await this.ensureSlack(channelName, spec);
185
+ if (spec.type === "discord") return await this.ensureDiscord(channelName, spec);
186
+ if (spec.type === "gh") return this.ensureGh(channelName, spec);
187
+ return this.ensureSchedule(channelName, spec);
188
+ }
189
+ async ensureSlack(channelName, spec) {
190
+ const byName = this.findExistingSlack(channelName, spec.name);
191
+ const bot = await this.resolveSlot({
192
+ label: `${spec.name}.botToken`,
193
+ existingLiteral: byName?.botToken,
194
+ existingEnv: byName?.botTokenEnv
195
+ });
196
+ const app = await this.resolveSlot({
197
+ label: `${spec.name}.appToken`,
198
+ existingLiteral: byName?.appToken,
199
+ existingEnv: byName?.appTokenEnv
200
+ });
201
+ const update = {
202
+ botToken: bot.token,
203
+ botTokenEnv: bot.tokenEnv,
204
+ appToken: app.token,
205
+ appTokenEnv: app.tokenEnv
206
+ };
207
+ if (byName) {
208
+ if (!(byName.botToken === bot.token && byName.botTokenEnv === bot.tokenEnv && byName.appToken === app.token && byName.appTokenEnv === app.tokenEnv)) {
209
+ this.channels.updateSlackConnector(channelName, spec.name, update);
210
+ return {
211
+ id: byName.id,
212
+ name: spec.name,
213
+ changed: true
214
+ };
215
+ }
216
+ return {
217
+ id: byName.id,
218
+ name: spec.name,
219
+ changed: false
220
+ };
221
+ }
222
+ return {
223
+ id: this.channels.addConnector(channelName, {
224
+ type: "slack",
225
+ name: spec.name,
226
+ ...update,
227
+ ...spec.minify !== void 0 ? { minify: spec.minify } : {}
228
+ }).id,
229
+ name: spec.name,
230
+ changed: true
231
+ };
232
+ }
233
+ async ensureDiscord(channelName, spec) {
234
+ const byName = this.findExistingDiscord(channelName, spec.name);
235
+ const bot = await this.resolveSlot({
236
+ label: `${spec.name}.botToken`,
237
+ existingLiteral: byName?.botToken,
238
+ existingEnv: byName?.botTokenEnv
239
+ });
240
+ const update = {
241
+ botToken: bot.token,
242
+ botTokenEnv: bot.tokenEnv
243
+ };
244
+ if (byName) {
245
+ if (byName.botToken !== bot.token || byName.botTokenEnv !== bot.tokenEnv) {
246
+ this.channels.updateDiscordConnector(channelName, spec.name, update);
247
+ return {
248
+ id: byName.id,
249
+ name: spec.name,
250
+ changed: true
251
+ };
252
+ }
253
+ return {
254
+ id: byName.id,
255
+ name: spec.name,
256
+ changed: false
257
+ };
258
+ }
259
+ return {
260
+ id: this.channels.addConnector(channelName, {
261
+ type: "discord",
262
+ name: spec.name,
263
+ ...update
264
+ }).id,
265
+ name: spec.name,
266
+ changed: true
267
+ };
268
+ }
269
+ ensureGh(channelName, spec) {
270
+ const existing = this.channels.getConnector(channelName, spec.name);
271
+ if (existing && existing.type !== "gh") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "gh"`);
272
+ if (existing && existing.type === "gh") {
273
+ if (spec.pollInterval !== void 0 && existing.pollInterval !== spec.pollInterval) {
274
+ this.channels.updateGhConnector(channelName, spec.name, { pollInterval: spec.pollInterval });
275
+ return {
276
+ id: existing.id,
277
+ name: spec.name,
278
+ changed: true
279
+ };
280
+ }
281
+ return {
282
+ id: existing.id,
283
+ name: spec.name,
284
+ changed: false
285
+ };
286
+ }
287
+ return {
288
+ id: this.channels.addConnector(channelName, {
289
+ type: "gh",
290
+ name: spec.name,
291
+ ...spec.pollInterval !== void 0 ? { pollInterval: spec.pollInterval } : {}
292
+ }).id,
293
+ name: spec.name,
294
+ changed: true
295
+ };
296
+ }
297
+ ensureSchedule(channelName, spec) {
298
+ const existing = this.channels.getConnector(channelName, spec.name);
299
+ if (existing && existing.type !== "schedule") throw new Error(`connector "${spec.name}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "schedule"`);
300
+ if (existing && existing.type === "schedule") return {
301
+ id: existing.id,
302
+ name: spec.name,
303
+ changed: false
304
+ };
305
+ return {
306
+ id: this.channels.addConnector(channelName, {
307
+ type: "schedule",
308
+ name: spec.name
309
+ }).id,
310
+ name: spec.name,
311
+ changed: true
312
+ };
313
+ }
314
+ findExistingSlack(channelName, connectorName) {
315
+ const existing = this.channels.getConnector(channelName, connectorName);
316
+ if (!existing) return null;
317
+ if (existing.type !== "slack") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "slack"`);
318
+ return existing;
319
+ }
320
+ findExistingDiscord(channelName, connectorName) {
321
+ const existing = this.channels.getConnector(channelName, connectorName);
322
+ if (!existing) return null;
323
+ if (existing.type !== "discord") throw new Error(`connector "${connectorName}" exists in channel "${channelName}" with type "${existing.type}", funnel.json declares "discord"`);
324
+ return existing;
325
+ }
326
+ removeExtras(channelName, touched) {
327
+ const channel = this.channels.get(channelName);
328
+ if (!channel) return [];
329
+ const stale = channel.connectors.filter((c) => !touched.has(c.id));
330
+ for (const connector of stale) this.channels.removeConnector(channelName, connector.name);
331
+ return stale.map((c) => c.name);
332
+ }
333
+ /**
334
+ * Decides how a single token slot is stored in settings.json. funnel.json
335
+ * never carries tokens, so the only sources are a value already in
336
+ * settings.json (carried over verbatim, whichever form it was — literal or an
337
+ * `env`-var reference set via the CLI) or, on first sync, a TTY prompt for a
338
+ * literal (throws when stdin is not a TTY). Either way the secret lands in the
339
+ * repo-scoped settings, never in the repo itself.
340
+ */
341
+ async resolveSlot(input) {
342
+ if (input.existingEnv !== void 0) return {
343
+ token: void 0,
344
+ tokenEnv: input.existingEnv
345
+ };
346
+ if (input.existingLiteral !== void 0) return {
347
+ token: input.existingLiteral,
348
+ tokenEnv: void 0
349
+ };
350
+ return {
351
+ token: await this.prompter.promptSecret(input.label),
352
+ tokenEnv: void 0
353
+ };
354
+ }
355
+ };
356
+ //#endregion
357
+ //#region lib/engine/token-prompter/node-token-prompter.ts
358
+ const STAR = "*";
359
+ const CR = "\r";
360
+ const LF = "\n";
361
+ const BACKSPACE = String.fromCharCode(8);
362
+ const DEL = String.fromCharCode(127);
363
+ const CTRL_C = String.fromCharCode(3);
364
+ const CTRL_D = String.fromCharCode(4);
365
+ /**
366
+ * Reads a secret from stdin in raw mode. Echoes a `*` per byte so the user
367
+ * can see progress without exposing the token. Refuses to prompt when stdin
368
+ * is not a TTY — callers should surface the resulting error with a hint
369
+ * pointing at the corresponding env var or CLI command.
370
+ */
371
+ var NodeFunnelTokenPrompter = class extends FunnelTokenPrompter {
372
+ async promptSecret(label) {
373
+ 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.`);
374
+ stderr.write(`${label}: `);
375
+ const wasRaw = stdin.isRaw;
376
+ stdin.setRawMode(true);
377
+ stdin.resume();
378
+ try {
379
+ return await this.readSecret();
380
+ } finally {
381
+ stdin.setRawMode(wasRaw);
382
+ stdin.pause();
383
+ stderr.write(LF);
384
+ }
385
+ }
386
+ readSecret() {
387
+ return new Promise((resolve, reject) => {
388
+ let buffer = "";
389
+ const onData = (chunk) => {
390
+ for (const byte of chunk) {
391
+ const char = String.fromCharCode(byte);
392
+ if (char === LF || char === CR) {
393
+ stdin.off("data", onData);
394
+ resolve(buffer);
395
+ return;
396
+ }
397
+ if (char === CTRL_C) {
398
+ stdin.off("data", onData);
399
+ reject(/* @__PURE__ */ new Error("prompt cancelled"));
400
+ return;
401
+ }
402
+ if (char === CTRL_D) {
403
+ stdin.off("data", onData);
404
+ if (buffer.length === 0) reject(/* @__PURE__ */ new Error("prompt cancelled"));
405
+ else resolve(buffer);
406
+ return;
407
+ }
408
+ if (char === BACKSPACE || char === DEL) {
409
+ if (buffer.length > 0) {
410
+ buffer = buffer.slice(0, -1);
411
+ stderr.write("\b \b");
412
+ }
413
+ continue;
414
+ }
415
+ buffer += char;
416
+ stderr.write(STAR);
417
+ }
418
+ };
419
+ stdin.on("data", onData);
420
+ });
421
+ }
422
+ };
423
+ //#endregion
424
+ //#region lib/engine/local-config/local-config-json-schema.ts
425
+ /**
426
+ * Generates the JSON Schema (draft 2020-12) for `funnel.json`. Useful for
427
+ * `$schema` references in committed `funnel.json` files so editors can give
428
+ * autocomplete and validation for channels[] (transport) and profiles[]
429
+ * (launch recipe) without anyone hand-maintaining a separate schema.
430
+ */
431
+ const funnelJsonSchema = () => {
432
+ return {
433
+ ...z.toJSONSchema(localConfigSchema, { target: "draft-2020-12" }),
434
+ title: "Funnel per-repo launch config",
435
+ description: "Used by `fnl claude` to declare channels (transport: connectors to materialize into ~/.funnel/settings.json on launch) and profiles (launch recipe: options / env / resume) bound to those channels."
436
+ };
437
+ };
438
+ //#endregion
439
+ export { FunnelLocalConfig as a, connectorSpecSchema as c, FunnelTokenPrompter as i, localConfigSchema as l, NodeFunnelTokenPrompter as n, LOCAL_CONFIG_FILENAME as o, FunnelLocalConfigSync as r, channelSpecSchema as s, funnelJsonSchema as t, profileSpecSchema as u };