@interactive-inc/claude-funnel 0.2.0 → 0.4.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 (97) hide show
  1. package/README.md +82 -26
  2. package/lib/funnel.ts +49 -5
  3. package/lib/index.ts +8 -2
  4. package/lib/modules/channels/channel-connector-ref-updater.ts +4 -0
  5. package/lib/modules/channels/funnel-channels.ts +50 -8
  6. package/lib/modules/claude/funnel-claude.ts +79 -1
  7. package/lib/modules/connectors/connector-config-schema.ts +16 -0
  8. package/lib/modules/connectors/connector-existence-checker.ts +3 -0
  9. package/lib/modules/connectors/discord-connector-schema.ts +9 -0
  10. package/lib/modules/connectors/funnel-callable-connector-store.ts +9 -0
  11. package/lib/modules/connectors/funnel-connector-stores.ts +24 -0
  12. package/lib/modules/connectors/funnel-connector-type-store.ts +24 -0
  13. package/lib/modules/connectors/funnel-connectors.ts +98 -77
  14. package/lib/modules/connectors/funnel-discord-adapter.ts +1 -1
  15. package/lib/modules/connectors/funnel-discord-listener.ts +1 -1
  16. package/lib/modules/connectors/funnel-discord-store.ts +84 -0
  17. package/lib/modules/connectors/funnel-gh-listener.ts +1 -1
  18. package/lib/modules/connectors/funnel-gh-store.ts +84 -0
  19. package/lib/modules/connectors/funnel-json-connector-store.ts +100 -0
  20. package/lib/modules/connectors/funnel-schedule-listener.ts +124 -0
  21. package/lib/modules/connectors/funnel-schedule-store.ts +178 -0
  22. package/lib/modules/connectors/funnel-slack-adapter.ts +1 -1
  23. package/lib/modules/connectors/funnel-slack-listener.ts +1 -1
  24. package/lib/modules/connectors/funnel-slack-store.ts +86 -0
  25. package/lib/modules/connectors/gh-connector-schema.ts +9 -0
  26. package/lib/modules/connectors/match-cron.ts +72 -0
  27. package/lib/modules/connectors/migrate-legacy-connectors.ts +77 -0
  28. package/lib/modules/connectors/schedule-connector-schema.ts +18 -0
  29. package/lib/modules/connectors/schedule-last-fired-store.ts +48 -0
  30. package/lib/modules/connectors/slack-connector-schema.ts +10 -0
  31. package/lib/modules/gateway/daemon.ts +30 -13
  32. package/lib/modules/mcp/channel-server.ts +1 -2
  33. package/lib/modules/profiles/funnel-profiles.ts +123 -0
  34. package/lib/modules/profiles/profile-channel-checker.ts +3 -0
  35. package/lib/modules/profiles/profile-channel-ref-updater.ts +3 -0
  36. package/lib/modules/repos/funnel-repositories.ts +4 -4
  37. package/lib/modules/router/to-request.ts +2 -5
  38. package/lib/modules/schedule/funnel-schedule.ts +34 -0
  39. package/lib/modules/settings/funnel-settings-store.ts +1 -2
  40. package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -2
  41. package/lib/modules/settings/settings-schema.ts +3 -37
  42. package/lib/routes/claude/claude.help.ts +9 -4
  43. package/lib/routes/claude/claude.ts +44 -7
  44. package/lib/routes/connectors/add.help.ts +10 -4
  45. package/lib/routes/connectors/add.ts +10 -1
  46. package/lib/routes/connectors/routes.ts +6 -2
  47. package/lib/routes/connectors/schedules-add.help.ts +11 -0
  48. package/lib/routes/connectors/schedules-add.ts +33 -0
  49. package/lib/routes/connectors/schedules-group.help.ts +1 -0
  50. package/lib/routes/connectors/schedules-group.ts +38 -0
  51. package/lib/routes/connectors/schedules-remove.help.ts +3 -0
  52. package/lib/routes/connectors/schedules-remove.ts +17 -0
  53. package/lib/routes/connectors/set.ts +47 -5
  54. package/lib/routes/connectors/show.ts +9 -0
  55. package/lib/routes/profiles/add.help.ts +3 -0
  56. package/lib/routes/{agents → profiles}/add.ts +4 -4
  57. package/lib/routes/profiles/group.help.ts +16 -0
  58. package/lib/routes/profiles/group.ts +25 -0
  59. package/lib/routes/profiles/launch.help.ts +4 -0
  60. package/lib/routes/{agents → profiles}/launch.ts +9 -8
  61. package/lib/routes/profiles/remove.help.ts +3 -0
  62. package/lib/routes/{agents → profiles}/remove.ts +4 -4
  63. package/lib/routes/profiles/rename.help.ts +5 -0
  64. package/lib/routes/{agents → profiles}/rename.ts +4 -4
  65. package/lib/routes/profiles/routes.ts +18 -0
  66. package/lib/routes/profiles/set.help.ts +5 -0
  67. package/lib/routes/{agents → profiles}/set.ts +4 -4
  68. package/lib/routes/repos/add.help.ts +3 -2
  69. package/lib/routes/repos/add.ts +3 -2
  70. package/lib/routes/repos/group.help.ts +1 -1
  71. package/lib/routes/request/discord-help.ts +9 -0
  72. package/lib/routes/request/discord.help.ts +19 -0
  73. package/lib/routes/request/discord.ts +65 -0
  74. package/lib/routes/request/group.help.ts +15 -0
  75. package/lib/routes/request/group.ts +9 -0
  76. package/lib/routes/request/routes.ts +14 -0
  77. package/lib/routes/request/slack-help.ts +9 -0
  78. package/lib/routes/request/slack.help.ts +19 -0
  79. package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
  80. package/lib/routes/status/status.help.ts +1 -1
  81. package/lib/routes/status/status.ts +7 -7
  82. package/lib/routes/update/routes.ts +4 -0
  83. package/lib/routes/update/update.help.ts +5 -0
  84. package/lib/routes/update/update.ts +21 -0
  85. package/lib/routes.ts +6 -2
  86. package/package.json +1 -1
  87. package/lib/modules/agents/funnel-agents.ts +0 -105
  88. package/lib/modules/connectors/resolve-listener.ts +0 -13
  89. package/lib/routes/agents/add.help.ts +0 -3
  90. package/lib/routes/agents/group.help.ts +0 -13
  91. package/lib/routes/agents/group.ts +0 -25
  92. package/lib/routes/agents/launch.help.ts +0 -3
  93. package/lib/routes/agents/remove.help.ts +0 -3
  94. package/lib/routes/agents/rename.help.ts +0 -5
  95. package/lib/routes/agents/routes.ts +0 -17
  96. package/lib/routes/agents/set.help.ts +0 -5
  97. package/lib/routes/connectors/call.help.ts +0 -17
@@ -0,0 +1,123 @@
1
+ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
+ import type { ProfileConfig } from "@/modules/settings/settings-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelSettingsReader
6
+ }
7
+
8
+ export class FunnelProfiles {
9
+ private readonly store: FunnelSettingsReader
10
+
11
+ constructor(deps: Deps) {
12
+ this.store = deps.store
13
+ Object.freeze(this)
14
+ }
15
+
16
+ list(): ProfileConfig[] {
17
+ return this.store.read().profiles
18
+ }
19
+
20
+ get(name: string): ProfileConfig | null {
21
+ return this.list().find((p) => p.name === name) ?? null
22
+ }
23
+
24
+ add(config: ProfileConfig): void {
25
+ const settings = this.store.read()
26
+
27
+ if (settings.profiles.some((p) => p.name === config.name)) {
28
+ throw new Error(`profile "${config.name}" already exists`)
29
+ }
30
+
31
+ if (!settings.channels.some((c) => c.name === config.channel)) {
32
+ throw new Error(`channel "${config.channel}" not found`)
33
+ }
34
+
35
+ if (config.repo && !settings.repositories.some((r) => r.name === config.repo)) {
36
+ throw new Error(`repo "${config.repo}" not found`)
37
+ }
38
+
39
+ settings.profiles.push(config)
40
+
41
+ this.store.write(settings)
42
+ }
43
+
44
+ remove(name: string): void {
45
+ const settings = this.store.read()
46
+
47
+ const index = settings.profiles.findIndex((p) => p.name === name)
48
+
49
+ if (index < 0) throw new Error(`profile "${name}" not found`)
50
+
51
+ settings.profiles.splice(index, 1)
52
+
53
+ this.store.write(settings)
54
+ }
55
+
56
+ rename(oldName: string, newName: string): void {
57
+ const settings = this.store.read()
58
+
59
+ const profile = settings.profiles.find((p) => p.name === oldName)
60
+
61
+ if (!profile) throw new Error(`profile "${oldName}" not found`)
62
+
63
+ if (settings.profiles.some((p) => p.name === newName)) {
64
+ throw new Error(`profile "${newName}" already exists`)
65
+ }
66
+
67
+ profile.name = newName
68
+
69
+ this.store.write(settings)
70
+ }
71
+
72
+ hasChannelRef(channelName: string): boolean {
73
+ return this.store.read().profiles.some((p) => p.channel === channelName)
74
+ }
75
+
76
+ renameChannelRef(oldName: string, newName: string): void {
77
+ const settings = this.store.read()
78
+ let changed = false
79
+
80
+ for (const profile of settings.profiles) {
81
+ if (profile.channel === oldName) {
82
+ profile.channel = newName
83
+ changed = true
84
+ }
85
+ }
86
+
87
+ if (changed) this.store.write(settings)
88
+ }
89
+
90
+ update(name: string, fields: Partial<Omit<ProfileConfig, "name">>): void {
91
+ const settings = this.store.read()
92
+
93
+ const profile = settings.profiles.find((p) => p.name === name)
94
+
95
+ if (!profile) throw new Error(`profile "${name}" not found`)
96
+
97
+ if (fields.channel !== undefined) {
98
+ if (!settings.channels.some((c) => c.name === fields.channel)) {
99
+ throw new Error(`channel "${fields.channel}" not found`)
100
+ }
101
+
102
+ profile.channel = fields.channel
103
+ }
104
+
105
+ if (fields.repo !== undefined) {
106
+ if (fields.repo && !settings.repositories.some((r) => r.name === fields.repo)) {
107
+ throw new Error(`repo "${fields.repo}" not found`)
108
+ }
109
+
110
+ profile.repo = fields.repo || undefined
111
+ }
112
+
113
+ if (fields.subAgent !== undefined) {
114
+ profile.subAgent = fields.subAgent || undefined
115
+ }
116
+
117
+ if (fields.envFiles !== undefined) {
118
+ profile.envFiles = fields.envFiles
119
+ }
120
+
121
+ this.store.write(settings)
122
+ }
123
+ }
@@ -0,0 +1,3 @@
1
+ export type ProfileChannelChecker = {
2
+ hasChannelRef(channelName: string): boolean
3
+ }
@@ -0,0 +1,3 @@
1
+ export type ProfileChannelRefUpdater = {
2
+ renameChannelRef(oldName: string, newName: string): void
3
+ }
@@ -46,8 +46,8 @@ export class FunnelRepositories {
46
46
 
47
47
  if (index < 0) throw new Error(`repo "${name}" not found`)
48
48
 
49
- if (settings.agents.some((a) => a.repo === name)) {
50
- throw new Error(`repo "${name}" is referenced by an agent`)
49
+ if (settings.profiles.some((p) => p.repo === name)) {
50
+ throw new Error(`repo "${name}" is referenced by a profile`)
51
51
  }
52
52
 
53
53
  const repo = settings.repositories[index]!
@@ -72,8 +72,8 @@ export class FunnelRepositories {
72
72
 
73
73
  repo.name = newName
74
74
 
75
- for (const agent of settings.agents) {
76
- if (agent.repo === oldName) agent.repo = newName
75
+ for (const profile of settings.profiles) {
76
+ if (profile.repo === oldName) profile.repo = newName
77
77
  }
78
78
 
79
79
  this.store.write(settings)
@@ -7,7 +7,6 @@ const STRIPPED_METHOD_KEYWORDS: Record<string, string> = {
7
7
  add: "POST",
8
8
  remove: "DELETE",
9
9
  set: "PUT",
10
- update: "PUT",
11
10
  }
12
11
 
13
12
  const KEPT_METHOD_KEYWORDS: Record<string, string> = {
@@ -24,8 +23,6 @@ const isValue = (arg: string | undefined): arg is string => {
24
23
  }
25
24
 
26
25
  const consumeApiCall = (args: string[], i: number, params: URLSearchParams): number => {
27
- params.set("method", args[i]!)
28
-
29
26
  const nextPath = args[i + 1]
30
27
 
31
28
  if (!isValue(nextPath)) return 1
@@ -99,8 +96,8 @@ export const toRequest = (args: string[]) => {
99
96
  continue
100
97
  }
101
98
 
102
- if (API_CALL_METHODS.has(arg) && !params.has("method")) {
103
- segments.push("call")
99
+ if (API_CALL_METHODS.has(arg) && !params.has("path")) {
100
+ segments.push(arg)
104
101
  i += consumeApiCall(args, i, params)
105
102
  continue
106
103
  }
@@ -0,0 +1,34 @@
1
+ import type { FunnelScheduleStore } from "@/modules/connectors/funnel-schedule-store"
2
+ import type { ScheduleEntry } from "@/modules/connectors/schedule-connector-schema"
3
+
4
+ type Deps = {
5
+ store: FunnelScheduleStore
6
+ }
7
+
8
+ export class FunnelSchedule {
9
+ private readonly store: FunnelScheduleStore
10
+
11
+ constructor(deps: Deps) {
12
+ this.store = deps.store
13
+ Object.freeze(this)
14
+ }
15
+
16
+ listEntries(connector: string): ScheduleEntry[] {
17
+ const config = this.store.get(connector)
18
+
19
+ if (!config) throw new Error(`connector "${connector}" not found`)
20
+
21
+ return config.entries
22
+ }
23
+
24
+ addEntry(
25
+ connector: string,
26
+ entry: Omit<ScheduleEntry, "id"> & { id?: string },
27
+ ): ScheduleEntry {
28
+ return this.store.addEntry(connector, entry)
29
+ }
30
+
31
+ removeEntry(connector: string, id: string): void {
32
+ this.store.removeEntry(connector, id)
33
+ }
34
+ }
@@ -30,10 +30,9 @@ export class FunnelSettingsStore extends FunnelSettingsReader {
30
30
  read(): Settings {
31
31
  if (!this.fs.existsSync(this.path)) {
32
32
  return {
33
- connectors: [],
34
33
  channels: [],
35
34
  repositories: [],
36
- agents: [],
35
+ profiles: [],
37
36
  }
38
37
  }
39
38
 
@@ -2,10 +2,9 @@ import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
2
  import type { Settings } from "@/modules/settings/settings-schema"
3
3
 
4
4
  export const createSettings = (partial: Partial<Settings> = {}): Settings => ({
5
- connectors: [],
6
5
  channels: [],
7
6
  repositories: [],
8
- agents: [],
7
+ profiles: [],
9
8
  ...partial,
10
9
  })
11
10
 
@@ -1,38 +1,5 @@
1
1
  import { z } from "zod"
2
2
 
3
- export const slackConnectorSchema = z.object({
4
- type: z.literal("slack"),
5
- name: z.string(),
6
- botToken: z.string().startsWith("xoxb-"),
7
- appToken: z.string().startsWith("xapp-"),
8
- })
9
-
10
- export type SlackConnectorConfig = z.infer<typeof slackConnectorSchema>
11
-
12
- export const ghConnectorSchema = z.object({
13
- type: z.literal("gh"),
14
- name: z.string(),
15
- pollInterval: z.number().int().positive().optional(),
16
- })
17
-
18
- export type GhConnectorConfig = z.infer<typeof ghConnectorSchema>
19
-
20
- export const discordConnectorSchema = z.object({
21
- type: z.literal("discord"),
22
- name: z.string(),
23
- botToken: z.string().min(10),
24
- })
25
-
26
- export type DiscordConnectorConfig = z.infer<typeof discordConnectorSchema>
27
-
28
- export const connectorConfigSchema = z.discriminatedUnion("type", [
29
- slackConnectorSchema,
30
- ghConnectorSchema,
31
- discordConnectorSchema,
32
- ])
33
-
34
- export type ConnectorConfig = z.infer<typeof connectorConfigSchema>
35
-
36
3
  export const channelConfigSchema = z.object({
37
4
  name: z.string(),
38
5
  connectors: z.array(z.string()).default([]),
@@ -47,7 +14,7 @@ export const repositoryConfigSchema = z.object({
47
14
 
48
15
  export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>
49
16
 
50
- export const agentConfigSchema = z.object({
17
+ export const profileConfigSchema = z.object({
51
18
  name: z.string(),
52
19
  channel: z.string(),
53
20
  repo: z.string().optional(),
@@ -55,13 +22,12 @@ export const agentConfigSchema = z.object({
55
22
  envFiles: z.array(z.string()).optional(),
56
23
  })
57
24
 
58
- export type AgentConfig = z.infer<typeof agentConfigSchema>
25
+ export type ProfileConfig = z.infer<typeof profileConfigSchema>
59
26
 
60
27
  export const settingsSchema = z.object({
61
- connectors: z.array(connectorConfigSchema).default([]),
62
28
  channels: z.array(channelConfigSchema).default([]),
63
29
  repositories: z.array(repositoryConfigSchema).default([]),
64
- agents: z.array(agentConfigSchema).default([]),
30
+ profiles: z.array(profileConfigSchema).default([]),
65
31
  })
66
32
 
67
33
  export type Settings = z.infer<typeof settingsSchema>
@@ -1,11 +1,16 @@
1
1
  export const help = `funnel claude — launch Claude Code
2
2
 
3
- usage: funnel claude --channel <name> [--repo <name>] [--sub-agent <name>] [--env-file <file>] [additional claude args...]
3
+ usage:
4
+ funnel claude launch "default" profile (or the first profile)
5
+ funnel claude --profile <name> launch a named profile
6
+ funnel claude --channel <name> [opts] raw launch (no profile)
4
7
 
5
- options:
6
- --channel channel name to subscribe to (required)
7
- --repo switch working directory to the named repo (extra)
8
+ options (override profile / raw):
9
+ --profile profile name to launch
10
+ --channel channel name to subscribe to
11
+ --repo switch working directory to the named repo
8
12
  --sub-agent sub-agent name (passed to claude --agent)
9
13
  --env-file additional env file to load (relative path)
10
14
 
15
+ Any other arguments are forwarded to the claude CLI.
11
16
  On launch the FUNNEL_CHANNEL_ID env var is set and MCP connects to the gateway.`
@@ -1,16 +1,20 @@
1
+ import { HTTPException } from "hono/http-exception"
1
2
  import { z } from "zod"
2
3
  import { factory } from "@/factory"
3
4
  import { queryToCliArgs } from "@/modules/router/query-to-cli-args"
4
5
  import { zValidator } from "@/modules/router/validator"
5
6
  import { help } from "@/routes/claude/claude.help"
6
7
 
7
- const RESERVED_KEYS = ["channel", "repo", "sub-agent", "env-file"]
8
+ const RESERVED_KEYS = ["profile", "channel", "repo", "sub-agent", "env-file"]
9
+
10
+ const DEFAULT_PROFILE_NAME = "default"
8
11
 
9
12
  export const claudeHandler = factory.createHandlers(
10
13
  zValidator(
11
14
  "query",
12
15
  z
13
16
  .object({
17
+ profile: z.string().optional(),
14
18
  channel: z.string().optional(),
15
19
  repo: z.string().optional(),
16
20
  "sub-agent": z.string().optional(),
@@ -21,17 +25,50 @@ export const claudeHandler = factory.createHandlers(
21
25
  ),
22
26
  async (c) => {
23
27
  const query = c.req.valid("query")
28
+ const funnel = c.var.funnel
24
29
 
25
- if (!query.channel) return c.text(help)
30
+ if (query.profile) {
31
+ const profile = funnel.profiles.get(query.profile)
26
32
 
27
- const funnel = c.var.funnel
33
+ if (!profile) {
34
+ throw new HTTPException(404, { message: `profile "${query.profile}" not found` })
35
+ }
36
+
37
+ const exitCode = await funnel.claude.launch({
38
+ channel: query.channel ?? profile.channel,
39
+ repo: query.repo ?? profile.repo,
40
+ subAgent: query["sub-agent"] ?? profile.subAgent,
41
+ envFiles: query["env-file"] ? [query["env-file"]] : profile.envFiles,
42
+ userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
43
+ profileName: profile.name,
44
+ })
45
+
46
+ process.exit(exitCode)
47
+ }
48
+
49
+ if (query.channel) {
50
+ const exitCode = await funnel.claude.launch({
51
+ channel: query.channel,
52
+ repo: query.repo,
53
+ subAgent: query["sub-agent"],
54
+ envFiles: query["env-file"] ? [query["env-file"]] : undefined,
55
+ userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
56
+ })
57
+
58
+ process.exit(exitCode)
59
+ }
60
+
61
+ const defaultProfile = funnel.profiles.get(DEFAULT_PROFILE_NAME) ?? funnel.profiles.list()[0]
62
+
63
+ if (!defaultProfile) return c.text(help)
28
64
 
29
65
  const exitCode = await funnel.claude.launch({
30
- channel: query.channel,
31
- repo: query.repo,
32
- subAgent: query["sub-agent"],
33
- envFiles: query["env-file"] ? [query["env-file"]] : undefined,
66
+ channel: defaultProfile.channel,
67
+ repo: query.repo ?? defaultProfile.repo,
68
+ subAgent: query["sub-agent"] ?? defaultProfile.subAgent,
69
+ envFiles: query["env-file"] ? [query["env-file"]] : defaultProfile.envFiles,
34
70
  userArgs: queryToCliArgs(c.req.url, RESERVED_KEYS),
71
+ profileName: defaultProfile.name,
35
72
  })
36
73
 
37
74
  process.exit(exitCode)
@@ -1,9 +1,10 @@
1
1
  export const help = `funnel connectors add — add a connector
2
2
 
3
3
  usage:
4
- funnel connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
5
- funnel connectors add <name> --type gh [--poll-interval <seconds>]
6
- funnel connectors add <name> --type discord --bot-token <discord-bot-token>
4
+ funnel connectors add <name> --type slack --bot-token xoxb-... --app-token xapp-...
5
+ funnel connectors add <name> --type gh [--poll-interval <seconds>]
6
+ funnel connectors add <name> --type discord --bot-token <discord-bot-token>
7
+ funnel connectors add <name> --type schedule
7
8
 
8
9
  slack (Socket Mode):
9
10
  --bot-token Slack Bot Token (starts with xoxb-)
@@ -16,7 +17,12 @@ gh (GitHub, gh CLI):
16
17
  discord (Discord Gateway):
17
18
  --bot-token Discord Bot Token
18
19
 
20
+ schedule (cron-driven prompts):
21
+ no extra flags at connector level — add entries with:
22
+ funnel connectors <name> schedules add --cron "* * * * *" --prompt "..."
23
+
19
24
  examples:
20
25
  funnel connectors add prod-slack --type slack --bot-token xoxb-... --app-token xapp-...
21
26
  funnel connectors add my-gh --type gh --poll-interval 30
22
- funnel connectors add my-discord --type discord --bot-token MTI...`
27
+ funnel connectors add my-discord --type discord --bot-token MTI...
28
+ funnel connectors add my-cron --type schedule`
@@ -21,6 +21,9 @@ export const connectorsAddHandler = factory.createHandlers(
21
21
  type: z.literal("discord"),
22
22
  "bot-token": z.string().min(10),
23
23
  }),
24
+ z.object({
25
+ type: z.literal("schedule"),
26
+ }),
24
27
  ]),
25
28
  help,
26
29
  ),
@@ -42,12 +45,18 @@ export const connectorsAddHandler = factory.createHandlers(
42
45
  name: param.name,
43
46
  pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
44
47
  })
45
- } else {
48
+ } else if (query.type === "discord") {
46
49
  funnel.connectors.add({
47
50
  type: "discord",
48
51
  name: param.name,
49
52
  botToken: query["bot-token"],
50
53
  })
54
+ } else {
55
+ funnel.connectors.add({
56
+ type: "schedule",
57
+ name: param.name,
58
+ entries: [],
59
+ })
51
60
  }
52
61
 
53
62
  return c.text(`added connector "${param.name}"`)
@@ -1,18 +1,22 @@
1
1
  import { factory } from "@/factory"
2
2
  import { connectorsAddHandler } from "@/routes/connectors/add"
3
- import { connectorsCallHandler } from "@/routes/connectors/call"
4
3
  import { connectorsGroupHandler } from "@/routes/connectors/group"
5
4
  import { connectorsRemoveHandler } from "@/routes/connectors/remove"
6
5
  import { connectorsRenameHandler } from "@/routes/connectors/rename"
6
+ import { connectorsSchedulesAddHandler } from "@/routes/connectors/schedules-add"
7
+ import { connectorsSchedulesGroupHandler } from "@/routes/connectors/schedules-group"
8
+ import { connectorsSchedulesRemoveHandler } from "@/routes/connectors/schedules-remove"
7
9
  import { connectorsSetHandler } from "@/routes/connectors/set"
8
10
  import { connectorsShowHandler } from "@/routes/connectors/show"
9
11
 
10
12
  export const connectorsRoutes = factory
11
13
  .createApp()
12
14
  .get("/", ...connectorsGroupHandler)
13
- .get("/:name/call", ...connectorsCallHandler)
14
15
  .put("/:name/rename/:newName", ...connectorsRenameHandler)
15
16
  .put("/rename/:name/:newName", ...connectorsRenameHandler)
17
+ .post("/:name/schedules", ...connectorsSchedulesAddHandler)
18
+ .get("/:name/schedules", ...connectorsSchedulesGroupHandler)
19
+ .delete("/:name/schedules/:id", ...connectorsSchedulesRemoveHandler)
16
20
  .post("/:name", ...connectorsAddHandler)
17
21
  .put("/:name", ...connectorsSetHandler)
18
22
  .delete("/:name", ...connectorsRemoveHandler)
@@ -0,0 +1,11 @@
1
+ export const help = `funnel connectors <name> schedules add — add a schedule entry
2
+
3
+ usage: funnel connectors <name> schedules add --cron "<expr>" --prompt "<text>" [--disabled]
4
+
5
+ options:
6
+ --cron 5-field cron expression (min hour dom month dow)
7
+ --prompt prompt text delivered to subscribing channels when the cron fires
8
+ --disabled create the entry in disabled state
9
+
10
+ example:
11
+ funnel connectors my-cron schedules add --cron "*/5 * * * *" --prompt "status check"`
@@ -0,0 +1,33 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { matchCron } from "@/modules/connectors/match-cron"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/connectors/schedules-add.help"
6
+
7
+ export const connectorsSchedulesAddHandler = factory.createHandlers(
8
+ zValidator("param", z.object({ name: z.string() })),
9
+ zValidator(
10
+ "query",
11
+ z.object({
12
+ cron: z.string(),
13
+ prompt: z.string(),
14
+ disabled: z.string().optional(),
15
+ }),
16
+ help,
17
+ ),
18
+ (c) => {
19
+ const param = c.req.valid("param")
20
+ const query = c.req.valid("query")
21
+ const funnel = c.var.funnel
22
+
23
+ matchCron(query.cron, new Date())
24
+
25
+ const entry = funnel.schedule.addEntry(param.name, {
26
+ cron: query.cron,
27
+ prompt: query.prompt,
28
+ enabled: query.disabled !== "true",
29
+ })
30
+
31
+ return c.text(`added schedule entry "${entry.id}" to connector "${param.name}"`)
32
+ },
33
+ )
@@ -0,0 +1 @@
1
+ export const help = `funnel connectors <name> schedules — list schedule entries`
@@ -0,0 +1,38 @@
1
+ import { HTTPException } from "hono/http-exception"
2
+ import { z } from "zod"
3
+ import { factory } from "@/factory"
4
+ import { zValidator } from "@/modules/router/validator"
5
+ import { help } from "@/routes/connectors/schedules-group.help"
6
+
7
+ export const connectorsSchedulesGroupHandler = factory.createHandlers(
8
+ zValidator("param", z.object({ name: z.string() })),
9
+ zValidator("query", z.object({}), help),
10
+ (c) => {
11
+ const param = c.req.valid("param")
12
+ const funnel = c.var.funnel
13
+ const connector = funnel.connectors.get(param.name)
14
+
15
+ if (!connector) {
16
+ throw new HTTPException(404, { message: `connector "${param.name}" not found` })
17
+ }
18
+
19
+ if (connector.type !== "schedule") {
20
+ throw new HTTPException(400, {
21
+ message: `connector "${param.name}" is type "${connector.type}", not "schedule"`,
22
+ })
23
+ }
24
+
25
+ const entries = funnel.schedule.listEntries(param.name)
26
+
27
+ if (entries.length === 0) return c.text("no schedule entries")
28
+
29
+ const lines: string[] = []
30
+
31
+ for (const entry of entries) {
32
+ const status = entry.enabled ? "" : " (disabled)"
33
+ lines.push(`${entry.id}${status} ${entry.cron} ${entry.prompt}`)
34
+ }
35
+
36
+ return c.text(lines.join("\n"))
37
+ },
38
+ )
@@ -0,0 +1,3 @@
1
+ export const help = `funnel connectors <name> schedules remove — remove a schedule entry
2
+
3
+ usage: funnel connectors <name> schedules remove <id>`
@@ -0,0 +1,17 @@
1
+ import { z } from "zod"
2
+ import { factory } from "@/factory"
3
+ import { zValidator } from "@/modules/router/validator"
4
+ import { help } from "@/routes/connectors/schedules-remove.help"
5
+
6
+ export const connectorsSchedulesRemoveHandler = factory.createHandlers(
7
+ zValidator("param", z.object({ name: z.string(), id: z.string() })),
8
+ zValidator("query", z.object({}), help),
9
+ (c) => {
10
+ const param = c.req.valid("param")
11
+ const funnel = c.var.funnel
12
+
13
+ funnel.schedule.removeEntry(param.name, param.id)
14
+
15
+ return c.text(`removed schedule entry "${param.id}" from connector "${param.name}"`)
16
+ },
17
+ )
@@ -1,8 +1,28 @@
1
+ import { HTTPException } from "hono/http-exception"
1
2
  import { z } from "zod"
2
3
  import { factory } from "@/factory"
3
4
  import { zValidator } from "@/modules/router/validator"
4
5
  import { help } from "@/routes/connectors/set.help"
5
6
 
7
+ const SLACK_FIELDS = ["bot-token", "app-token"] as const
8
+ const GH_FIELDS = ["poll-interval"] as const
9
+ const DISCORD_FIELDS = ["bot-token"] as const
10
+
11
+ const rejectExtraneous = (
12
+ query: Record<string, string | undefined>,
13
+ allowed: ReadonlyArray<string>,
14
+ type: string,
15
+ ): void => {
16
+ for (const key of ["bot-token", "app-token", "poll-interval"]) {
17
+ if (query[key] === undefined) continue
18
+ if (allowed.includes(key)) continue
19
+
20
+ throw new HTTPException(400, {
21
+ message: `connector type "${type}" does not accept --${key}`,
22
+ })
23
+ }
24
+ }
25
+
6
26
  export const connectorsSetHandler = factory.createHandlers(
7
27
  zValidator("param", z.object({ name: z.string() })),
8
28
  zValidator(
@@ -19,11 +39,33 @@ export const connectorsSetHandler = factory.createHandlers(
19
39
  const query = c.req.valid("query")
20
40
  const funnel = c.var.funnel
21
41
 
22
- funnel.connectors.update(param.name, {
23
- botToken: query["bot-token"],
24
- appToken: query["app-token"],
25
- pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
26
- })
42
+ const current = funnel.connectors.get(param.name)
43
+
44
+ if (!current) {
45
+ throw new HTTPException(404, { message: `connector "${param.name}" not found` })
46
+ }
47
+
48
+ if (current.type === "slack") {
49
+ rejectExtraneous(query, SLACK_FIELDS, "slack")
50
+ funnel.connectors.updateSlack(param.name, {
51
+ botToken: query["bot-token"],
52
+ appToken: query["app-token"],
53
+ })
54
+ } else if (current.type === "gh") {
55
+ rejectExtraneous(query, GH_FIELDS, "gh")
56
+ funnel.connectors.updateGh(param.name, {
57
+ pollInterval: query["poll-interval"] ? Number(query["poll-interval"]) : undefined,
58
+ })
59
+ } else if (current.type === "discord") {
60
+ rejectExtraneous(query, DISCORD_FIELDS, "discord")
61
+ funnel.connectors.updateDiscord(param.name, {
62
+ botToken: query["bot-token"],
63
+ })
64
+ } else {
65
+ throw new HTTPException(400, {
66
+ message: `schedule connectors have no top-level fields — use schedules add/remove`,
67
+ })
68
+ }
27
69
 
28
70
  return c.text(`updated connector "${param.name}"`)
29
71
  },