@interactive-inc/claude-funnel 0.10.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -56
- package/dist/bin.js +532 -505
- package/dist/connectors/schedule.d.ts +2 -49
- package/dist/connectors/schedule.js +1 -1
- package/dist/connectors/slack.d.ts +4 -20
- package/dist/connectors/slack.js +1 -1
- package/dist/gateway/daemon.js +214 -212
- package/dist/index.d.ts +463 -164
- package/dist/index.js +561 -36
- package/dist/{schedule-connector-schema-CkuIQ0JQ.js → schedule-connector-schema-FxP7LPlx.js} +11 -0
- package/dist/{file-system-Co60LrmR.d.ts → schedule-listener-BPodvbld.d.ts} +56 -1
- package/dist/{slack-connector-schema-Cd22WiHB.js → slack-connector-schema-B4hsf3AY.js} +10 -1
- package/dist/slack-listener-CHj6uMY-.d.ts +74 -0
- package/funnel.schema.json +144 -0
- package/package.json +2 -1
- package/dist/slack-event-processor-CS-bAit9.d.ts +0 -43
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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-
|
|
5
|
-
import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-
|
|
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 { stderr, stdin } from "node:process";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { timingSafeEqual } from "node:crypto";
|
|
12
13
|
import { createFactory } from "hono/factory";
|
|
@@ -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) {
|
|
@@ -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,
|
|
@@ -1135,6 +1541,7 @@ var FunnelGateway = class {
|
|
|
1135
1541
|
process;
|
|
1136
1542
|
fs;
|
|
1137
1543
|
clock;
|
|
1544
|
+
dir;
|
|
1138
1545
|
pidFile;
|
|
1139
1546
|
logDir;
|
|
1140
1547
|
gatewayLog;
|
|
@@ -1145,9 +1552,9 @@ var FunnelGateway = class {
|
|
|
1145
1552
|
this.process = deps.process ?? defaultProcess$1;
|
|
1146
1553
|
this.fs = deps.fs ?? defaultFs$1;
|
|
1147
1554
|
this.clock = deps.clock ?? defaultClock;
|
|
1148
|
-
|
|
1555
|
+
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
1149
1556
|
this.tmpDir = deps.tmpDir ?? DEFAULT_TMP_DIR$1;
|
|
1150
|
-
this.pidFile = join(
|
|
1557
|
+
this.pidFile = join(this.dir, "gateway.pid");
|
|
1151
1558
|
this.logDir = join(this.tmpDir, "events");
|
|
1152
1559
|
this.gatewayLog = join(this.tmpDir, "gateway.log");
|
|
1153
1560
|
this.port = deps.port ?? DEFAULT_PORT$1;
|
|
@@ -1186,7 +1593,7 @@ var FunnelGateway = class {
|
|
|
1186
1593
|
return this.isRunning();
|
|
1187
1594
|
}
|
|
1188
1595
|
buildStartCommand(gatewayScript, options = {}) {
|
|
1189
|
-
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 &`;
|
|
1190
1597
|
}
|
|
1191
1598
|
async stop() {
|
|
1192
1599
|
const pid = this.readPid();
|
|
@@ -2087,12 +2494,15 @@ var FunnelListenerSupervisor = class FunnelListenerSupervisor {
|
|
|
2087
2494
|
//#region lib/gateway/kill-competing-slack-gateways.ts
|
|
2088
2495
|
const defaultProcess = new NodeFunnelProcessRunner();
|
|
2089
2496
|
const defaultLogger$1 = new NodeFunnelLogger();
|
|
2090
|
-
const
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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
|
+
*/
|
|
2096
2506
|
const killCompetingSlackGateways = async (props) => {
|
|
2097
2507
|
const runner = props.process ?? defaultProcess;
|
|
2098
2508
|
const logger = props.logger ?? defaultLogger$1;
|
|
@@ -2103,6 +2513,7 @@ const killCompetingSlackGateways = async (props) => {
|
|
|
2103
2513
|
"pid=,args="
|
|
2104
2514
|
]);
|
|
2105
2515
|
if (result.exitCode !== 0) return [];
|
|
2516
|
+
const expectedTitle = titleFor(props.dir);
|
|
2106
2517
|
const killed = [];
|
|
2107
2518
|
for (const raw of result.stdout.split("\n")) {
|
|
2108
2519
|
const line = raw.trim();
|
|
@@ -2113,8 +2524,7 @@ const killCompetingSlackGateways = async (props) => {
|
|
|
2113
2524
|
const args = match[2];
|
|
2114
2525
|
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
2115
2526
|
if (pid === props.selfPid) continue;
|
|
2116
|
-
if (!
|
|
2117
|
-
if (!looksLikeSlackGateway(args)) continue;
|
|
2527
|
+
if (!args.includes(expectedTitle)) continue;
|
|
2118
2528
|
runner.kill(pid, "SIGTERM");
|
|
2119
2529
|
killed.push(pid);
|
|
2120
2530
|
logger.info("killed competing Slack gateway process", {
|
|
@@ -2306,12 +2716,14 @@ var FunnelGatewayServer = class {
|
|
|
2306
2716
|
process;
|
|
2307
2717
|
logger;
|
|
2308
2718
|
selfPid;
|
|
2719
|
+
dir;
|
|
2309
2720
|
killCompetingSlack;
|
|
2310
2721
|
token;
|
|
2311
2722
|
broadcaster;
|
|
2312
2723
|
eventStore;
|
|
2313
2724
|
supervisor;
|
|
2314
2725
|
nowMs;
|
|
2726
|
+
extraRoutes;
|
|
2315
2727
|
startedAt = null;
|
|
2316
2728
|
server = null;
|
|
2317
2729
|
constructor(deps) {
|
|
@@ -2322,8 +2734,10 @@ var FunnelGatewayServer = class {
|
|
|
2322
2734
|
this.process = deps.process;
|
|
2323
2735
|
this.logger = deps.logger ?? defaultLogger;
|
|
2324
2736
|
this.selfPid = deps.selfPid ?? globalThis.process.pid;
|
|
2737
|
+
this.dir = deps.dir ?? FUNNEL_DIR;
|
|
2325
2738
|
this.killCompetingSlack = deps.killCompetingSlack ?? true;
|
|
2326
2739
|
this.token = deps.token ?? "";
|
|
2740
|
+
this.extraRoutes = deps.extraRoutes ?? null;
|
|
2327
2741
|
const clock = deps.clock;
|
|
2328
2742
|
this.nowMs = clock ? () => clock.millis() : () => Date.now();
|
|
2329
2743
|
if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true });
|
|
@@ -2485,7 +2899,7 @@ var FunnelGatewayServer = class {
|
|
|
2485
2899
|
base.use("/status", requireBearerToken({ expected: this.token }));
|
|
2486
2900
|
base.use("/channels/*", requireBearerToken({ expected: this.token }));
|
|
2487
2901
|
}
|
|
2488
|
-
return base.route("/", gatewayRoutes);
|
|
2902
|
+
return (this.extraRoutes ? base.route("/", this.extraRoutes) : base).route("/", gatewayRoutes);
|
|
2489
2903
|
}
|
|
2490
2904
|
/**
|
|
2491
2905
|
* Reads the bearer token from the WebSocket upgrade request. Accepts:
|
|
@@ -2515,6 +2929,7 @@ var FunnelGatewayServer = class {
|
|
|
2515
2929
|
if (this.killCompetingSlack && allConnectors.some((c) => c.type === "slack")) {
|
|
2516
2930
|
const killed = await killCompetingSlackGateways({
|
|
2517
2931
|
selfPid: this.selfPid,
|
|
2932
|
+
dir: this.dir,
|
|
2518
2933
|
process: this.process,
|
|
2519
2934
|
logger: this.logger
|
|
2520
2935
|
});
|
|
@@ -2811,7 +3226,9 @@ var Funnel = class Funnel {
|
|
|
2811
3226
|
fs: this.fs,
|
|
2812
3227
|
process: this.process,
|
|
2813
3228
|
logger: this.logger,
|
|
2814
|
-
dir: this.paths.dir
|
|
3229
|
+
dir: this.paths.dir,
|
|
3230
|
+
slackListenerOptions: this.props.slackListenerOptions,
|
|
3231
|
+
scheduleListenerOptions: this.props.scheduleListenerOptions
|
|
2815
3232
|
});
|
|
2816
3233
|
return this.memos.factory;
|
|
2817
3234
|
}
|
|
@@ -2831,6 +3248,30 @@ var Funnel = class Funnel {
|
|
|
2831
3248
|
if (!this.memos.profiles) this.memos.profiles = new FunnelProfiles({ store: this.store });
|
|
2832
3249
|
return this.memos.profiles;
|
|
2833
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;
|
|
3274
|
+
}
|
|
2834
3275
|
/** funnel MCP installer (writes/removes `.mcp.json` entries in target repos). */
|
|
2835
3276
|
get mcp() {
|
|
2836
3277
|
if (!this.memos.mcp) this.memos.mcp = new FunnelMcp({ fs: this.fs });
|
|
@@ -2917,7 +3358,8 @@ var Funnel = class Funnel {
|
|
|
2917
3358
|
clock: this.clock,
|
|
2918
3359
|
logger: this.logger,
|
|
2919
3360
|
killCompetingSlack: options.killCompetingSlack,
|
|
2920
|
-
token: options.token ?? this.gatewayToken.ensure()
|
|
3361
|
+
token: options.token ?? this.gatewayToken.ensure(),
|
|
3362
|
+
extraRoutes: options.extraRoutes
|
|
2921
3363
|
});
|
|
2922
3364
|
}
|
|
2923
3365
|
};
|
|
@@ -3104,6 +3546,41 @@ const startChannelServer = async (options = {}) => {
|
|
|
3104
3546
|
}).start();
|
|
3105
3547
|
};
|
|
3106
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
|
|
3107
3584
|
//#region lib/cli/factory.ts
|
|
3108
3585
|
const factory = createFactory();
|
|
3109
3586
|
//#endregion
|
|
@@ -3598,16 +4075,23 @@ examples:
|
|
|
3598
4075
|
const claudeHelp = `funnel claude — launch Claude Code
|
|
3599
4076
|
|
|
3600
4077
|
usage:
|
|
3601
|
-
funnel claude launch
|
|
4078
|
+
funnel claude launch using funnel.json in cwd, or the default profile
|
|
3602
4079
|
funnel claude -p <name> launch a named profile
|
|
3603
4080
|
funnel claude --profile <name> (long form)
|
|
3604
4081
|
funnel claude --channel <name> raw launch (no profile, cwd = current dir)
|
|
4082
|
+
funnel claude [...] any other argument is forwarded to the claude CLI
|
|
3605
4083
|
|
|
3606
|
-
|
|
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):
|
|
3607
4089
|
-p, --profile profile name to launch
|
|
3608
4090
|
--channel channel name (raw launch, ignored when --profile is given)
|
|
4091
|
+
-h, --help show this help
|
|
3609
4092
|
|
|
3610
|
-
|
|
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.
|
|
3611
4095
|
On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`;
|
|
3612
4096
|
const RESERVED_KEYS$1 = ["profile", "channel"];
|
|
3613
4097
|
const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
@@ -3616,25 +4100,48 @@ const claudeHandler = factory.createHandlers(zValidator$1("query", z.object({
|
|
|
3616
4100
|
}).passthrough(), claudeHelp), async (c) => {
|
|
3617
4101
|
const query = c.req.valid("query");
|
|
3618
4102
|
const funnel = c.var.funnel;
|
|
4103
|
+
const userArgs = queryToCliArgs(c.req.url, RESERVED_KEYS$1);
|
|
3619
4104
|
if (query.channel && !query.profile) {
|
|
3620
4105
|
const exitCode = await funnel.claude.launch({
|
|
3621
4106
|
channel: query.channel,
|
|
3622
|
-
userArgs
|
|
4107
|
+
userArgs
|
|
3623
4108
|
});
|
|
3624
4109
|
process.exit(exitCode);
|
|
3625
4110
|
}
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
if (
|
|
3629
|
-
|
|
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
|
|
4121
|
+
});
|
|
4122
|
+
process.exit(exitCode);
|
|
3630
4123
|
}
|
|
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);
|
|
4135
|
+
}
|
|
4136
|
+
const defaultProfile = funnel.profiles.getDefault();
|
|
4137
|
+
if (!defaultProfile) return c.text(claudeHelp);
|
|
3631
4138
|
const exitCode = await funnel.claude.launch({
|
|
3632
|
-
channel:
|
|
3633
|
-
cwd:
|
|
3634
|
-
subAgent:
|
|
3635
|
-
userArgs
|
|
3636
|
-
profileName:
|
|
3637
|
-
brief:
|
|
4139
|
+
channel: defaultProfile.channelId,
|
|
4140
|
+
cwd: defaultProfile.path,
|
|
4141
|
+
subAgent: defaultProfile.subAgent,
|
|
4142
|
+
userArgs,
|
|
4143
|
+
profileName: defaultProfile.name,
|
|
4144
|
+
brief: defaultProfile.brief
|
|
3638
4145
|
});
|
|
3639
4146
|
process.exit(exitCode);
|
|
3640
4147
|
});
|
|
@@ -3998,6 +4505,24 @@ examples:
|
|
|
3998
4505
|
});
|
|
3999
4506
|
return c.text(lines.join("\n"));
|
|
4000
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
|
+
});
|
|
4001
4526
|
//#endregion
|
|
4002
4527
|
//#region lib/cli/routes/status.ts
|
|
4003
4528
|
const statusHelp = `funnel status — show overall connection status
|
|
@@ -4091,7 +4616,7 @@ const createCliApp = (funnel) => {
|
|
|
4091
4616
|
if (error instanceof HTTPException) return c.text(`error: ${error.message}`, error.status);
|
|
4092
4617
|
return c.text(`error: ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
4093
4618
|
});
|
|
4094
|
-
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);
|
|
4095
4620
|
};
|
|
4096
4621
|
/** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
|
|
4097
4622
|
const app = createCliApp(new Funnel());
|
|
@@ -6306,4 +6831,4 @@ async function launchTui(funnel) {
|
|
|
6306
6831
|
});
|
|
6307
6832
|
}
|
|
6308
6833
|
//#endregion
|
|
6309
|
-
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, FunnelSlackEventProcessor, 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 };
|