@interactive-inc/claude-funnel 0.2.0 → 0.3.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 (56) hide show
  1. package/README.md +18 -12
  2. package/lib/funnel.ts +3 -3
  3. package/lib/index.ts +4 -2
  4. package/lib/modules/channels/funnel-channels.ts +4 -4
  5. package/lib/modules/claude/funnel-claude.ts +79 -1
  6. package/lib/modules/mcp/channel-server.ts +1 -2
  7. package/lib/modules/{agents/funnel-agents.ts → profiles/funnel-profiles.ts} +25 -25
  8. package/lib/modules/repos/funnel-repositories.ts +4 -4
  9. package/lib/modules/router/to-request.ts +2 -5
  10. package/lib/modules/settings/funnel-settings-store.ts +1 -1
  11. package/lib/modules/settings/mock-funnel-settings-reader.ts +1 -1
  12. package/lib/modules/settings/settings-schema.ts +3 -3
  13. package/lib/routes/claude/claude.help.ts +9 -4
  14. package/lib/routes/claude/claude.ts +44 -7
  15. package/lib/routes/connectors/routes.ts +0 -2
  16. package/lib/routes/profiles/add.help.ts +3 -0
  17. package/lib/routes/{agents → profiles}/add.ts +4 -4
  18. package/lib/routes/profiles/group.help.ts +16 -0
  19. package/lib/routes/profiles/group.ts +25 -0
  20. package/lib/routes/profiles/launch.help.ts +4 -0
  21. package/lib/routes/{agents → profiles}/launch.ts +9 -8
  22. package/lib/routes/profiles/remove.help.ts +3 -0
  23. package/lib/routes/{agents → profiles}/remove.ts +4 -4
  24. package/lib/routes/profiles/rename.help.ts +5 -0
  25. package/lib/routes/{agents → profiles}/rename.ts +4 -4
  26. package/lib/routes/profiles/routes.ts +18 -0
  27. package/lib/routes/profiles/set.help.ts +5 -0
  28. package/lib/routes/{agents → profiles}/set.ts +4 -4
  29. package/lib/routes/repos/add.help.ts +3 -2
  30. package/lib/routes/repos/add.ts +3 -2
  31. package/lib/routes/repos/group.help.ts +1 -1
  32. package/lib/routes/request/discord-help.ts +9 -0
  33. package/lib/routes/request/discord.help.ts +19 -0
  34. package/lib/routes/request/discord.ts +65 -0
  35. package/lib/routes/request/group.help.ts +15 -0
  36. package/lib/routes/request/group.ts +9 -0
  37. package/lib/routes/request/routes.ts +14 -0
  38. package/lib/routes/request/slack-help.ts +9 -0
  39. package/lib/routes/request/slack.help.ts +19 -0
  40. package/lib/routes/{connectors/call.ts → request/slack.ts} +24 -6
  41. package/lib/routes/status/status.help.ts +1 -1
  42. package/lib/routes/status/status.ts +7 -7
  43. package/lib/routes/update/routes.ts +4 -0
  44. package/lib/routes/update/update.help.ts +5 -0
  45. package/lib/routes/update/update.ts +21 -0
  46. package/lib/routes.ts +6 -2
  47. package/package.json +1 -1
  48. package/lib/routes/agents/add.help.ts +0 -3
  49. package/lib/routes/agents/group.help.ts +0 -13
  50. package/lib/routes/agents/group.ts +0 -25
  51. package/lib/routes/agents/launch.help.ts +0 -3
  52. package/lib/routes/agents/remove.help.ts +0 -3
  53. package/lib/routes/agents/rename.help.ts +0 -5
  54. package/lib/routes/agents/routes.ts +0 -17
  55. package/lib/routes/agents/set.help.ts +0 -5
  56. package/lib/routes/connectors/call.help.ts +0 -17
package/README.md CHANGED
@@ -56,7 +56,9 @@ fnl connectors <name> show details
56
56
  fnl connectors <name> set [--bot-token ...] [--app-token ...]
57
57
  fnl connectors rename <old> <new>
58
58
  fnl connectors remove <name>
59
- fnl connectors <name> <method> <path> [body] call API (get/post/put/delete/...)
59
+
60
+ fnl request slack post <path> [body] --connector <name> call Slack Web API
61
+ fnl request discord <method> <path> [body] --connector <name> call Discord REST API
60
62
 
61
63
  fnl channels list
62
64
  fnl channels add <name>
@@ -66,27 +68,31 @@ fnl channels <name> connectors detach <connector>
66
68
  fnl channels rename <old> <new>
67
69
  fnl channels remove <name>
68
70
 
69
- fnl agents list agent presets (extra)
70
- fnl agents add <name> --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
71
- fnl agents <name> launch (sugar for fnl claude)
72
- fnl agents <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
73
- fnl agents rename <old> <new>
74
- fnl agents remove <name>
71
+ fnl profiles list launch profiles
72
+ fnl profiles add <name> --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
73
+ fnl profiles <name> run launch (sugar for fnl claude)
74
+ fnl profiles <name> launch (alias for run)
75
+ fnl profiles <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
76
+ fnl profiles rename <old> <new>
77
+ fnl profiles remove <name>
75
78
 
76
79
  fnl repos list repositories (extra)
77
- fnl repos add <name> --path <path> register funnel MCP into .mcp.json
80
+ fnl repos add <name> [--path <path>] register funnel MCP (path defaults to cwd)
78
81
  fnl repos <name> show details
79
82
  fnl repos <name> set [--path <path>]
80
83
  fnl repos rename <old> <new>
81
84
  fnl repos remove <name>
82
85
 
86
+ fnl claude launch the "default" profile
87
+ fnl claude --profile <name> launch a named profile
83
88
  fnl claude --channel <c> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
84
- launch Claude Code
89
+ raw launch (no profile)
85
90
  fnl mcp run as an MCP server (invoked from .mcp.json)
86
91
 
87
92
  fnl gateway running status
88
93
  fnl gateway start / stop / restart / run / logs
89
- fnl status overall status (connectors / channels / agents / repos / gateway)
94
+ fnl update update funnel via bun i -g
95
+ fnl status overall status (connectors / channels / profiles / repos / gateway)
90
96
 
91
97
  fnl --version
92
98
  fnl --help (every subcommand has --help)
@@ -102,9 +108,9 @@ Connector =
102
108
 
103
109
  Channel = { name, connectors[] } subscription box
104
110
  Repository = { name, path } extra
105
- Agent = { name, channel, repo?, subAgent?, envFiles? } preset (extra)
111
+ Profile = { name, channel, repo?, subAgent?, envFiles? } launch profile
106
112
 
107
- Settings = { connectors[], channels[], repositories[], agents[] }
113
+ Settings = { connectors[], channels[], repositories[], profiles[] }
108
114
  → ~/.funnel/settings.json
109
115
  ```
110
116
 
package/lib/funnel.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { FunnelAgents } from "@/modules/agents/funnel-agents"
2
1
  import { FunnelChannels } from "@/modules/channels/funnel-channels"
3
2
  import { FunnelClaude } from "@/modules/claude/funnel-claude"
4
3
  import { FunnelConnectors } from "@/modules/connectors/funnel-connectors"
5
4
  import { FunnelGateway } from "@/modules/gateway/funnel-gateway"
6
5
  import { FunnelMcp } from "@/modules/mcp/funnel-mcp"
6
+ import { FunnelProfiles } from "@/modules/profiles/funnel-profiles"
7
7
  import { FunnelRepositories } from "@/modules/repos/funnel-repositories"
8
8
  import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
9
9
 
@@ -24,8 +24,8 @@ export class Funnel {
24
24
  return new FunnelChannels({ store: this.props.store })
25
25
  }
26
26
 
27
- get agents(): FunnelAgents {
28
- return new FunnelAgents({ store: this.props.store })
27
+ get profiles(): FunnelProfiles {
28
+ return new FunnelProfiles({ store: this.props.store })
29
29
  }
30
30
 
31
31
  get repositories(): FunnelRepositories {
package/lib/index.ts CHANGED
@@ -13,13 +13,15 @@ usage: funnel [command]
13
13
 
14
14
  commands:
15
15
  (none) launch TUI
16
- claude --channel <c> launch Claude Code
16
+ claude launch Claude Code (default profile or --profile)
17
17
  connectors manage external connections (Slack, etc.)
18
18
  channels manage subscription boxes
19
- agents manage agent presets (extra)
19
+ profiles manage launch profiles
20
+ request send an outbound API call via a connector
20
21
  repos manage repositories (extra)
21
22
  gateway manage the gateway
22
23
  status show overall connection status
24
+ update update funnel to the latest version
23
25
  mcp run as an MCP server (invoked from .mcp.json)
24
26
 
25
27
  options:
@@ -46,8 +46,8 @@ export class FunnelChannels {
46
46
 
47
47
  if (index < 0) throw new Error(`channel "${name}" not found`)
48
48
 
49
- if (settings.agents.some((a) => a.channel === name)) {
50
- throw new Error(`channel "${name}" is referenced by an agent`)
49
+ if (settings.profiles.some((p) => p.channel === name)) {
50
+ throw new Error(`channel "${name}" is referenced by a profile`)
51
51
  }
52
52
 
53
53
  settings.channels.splice(index, 1)
@@ -68,8 +68,8 @@ export class FunnelChannels {
68
68
 
69
69
  channel.name = newName
70
70
 
71
- for (const agent of settings.agents) {
72
- if (agent.channel === oldName) agent.channel = newName
71
+ for (const profile of settings.profiles) {
72
+ if (profile.channel === oldName) profile.channel = newName
73
73
  }
74
74
 
75
75
  this.store.write(settings)
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path"
1
2
  import type { FunnelChannels } from "@/modules/channels/funnel-channels"
2
3
  import { FunnelFileSystem } from "@/modules/fs/funnel-file-system"
3
4
  import { NodeFunnelFileSystem } from "@/modules/fs/node-funnel-file-system"
@@ -7,6 +8,9 @@ import type { FunnelMcp } from "@/modules/mcp/funnel-mcp"
7
8
  import { FunnelProcessRunner } from "@/modules/process/funnel-process-runner"
8
9
  import { NodeFunnelProcessRunner } from "@/modules/process/node-funnel-process-runner"
9
10
  import type { FunnelRepositories } from "@/modules/repos/funnel-repositories"
11
+ import { FUNNEL_DIR } from "@/modules/settings/funnel-settings-store"
12
+
13
+ const CLAUDE_PID_DIR = join(FUNNEL_DIR, "claude")
10
14
 
11
15
  export type LaunchOptions = {
12
16
  channel: string
@@ -14,6 +18,7 @@ export type LaunchOptions = {
14
18
  subAgent?: string
15
19
  envFiles?: string[]
16
20
  userArgs?: string[]
21
+ profileName?: string
17
22
  }
18
23
 
19
24
  type Deps = {
@@ -53,6 +58,10 @@ export class FunnelClaude {
53
58
  throw new Error(`channel "${options.channel}" not found`)
54
59
  }
55
60
 
61
+ if (options.profileName && this.isRunning(options.profileName)) {
62
+ throw new Error(`profile "${options.profileName}" is already running`)
63
+ }
64
+
56
65
  const cwd = options.repo
57
66
  ? this.repositories.resolvePath(options.repo)
58
67
  : globalThis.process.cwd()
@@ -68,6 +77,11 @@ export class FunnelClaude {
68
77
  await this.gateway.start()
69
78
  }
70
79
 
80
+ if (options.profileName) {
81
+ this.writePidFile(options.profileName)
82
+ this.installCleanup(options.profileName)
83
+ }
84
+
71
85
  const claudeArgs = this.buildArgs(options, cwd)
72
86
  const env = this.buildEnv(options, cwd)
73
87
 
@@ -78,7 +92,71 @@ export class FunnelClaude {
78
92
  cwd,
79
93
  })
80
94
 
81
- return await this.process.attach(["claude", ...claudeArgs], { cwd, env })
95
+ try {
96
+ return await this.process.attach(["claude", ...claudeArgs], { cwd, env })
97
+ } finally {
98
+ if (options.profileName) this.removePidFile(options.profileName)
99
+ }
100
+ }
101
+
102
+ isRunning(profileName: string): boolean {
103
+ const pid = this.readPid(profileName)
104
+
105
+ if (!pid) return false
106
+
107
+ return this.isProcessAlive(pid)
108
+ }
109
+
110
+ private pidPath(profileName: string): string {
111
+ return join(CLAUDE_PID_DIR, `${profileName}.pid`)
112
+ }
113
+
114
+ private readPid(profileName: string): number | null {
115
+ const path = this.pidPath(profileName)
116
+
117
+ if (!this.fs.existsSync(path)) return null
118
+
119
+ try {
120
+ const content = this.fs.readFileSync(path).trim()
121
+ const pid = Number(content)
122
+
123
+ if (!pid || pid <= 0) return null
124
+
125
+ return pid
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ private writePidFile(profileName: string): void {
132
+ this.fs.mkdirSync(CLAUDE_PID_DIR, { recursive: true })
133
+ this.fs.writeFileSync(this.pidPath(profileName), String(globalThis.process.pid))
134
+ }
135
+
136
+ private removePidFile(profileName: string): void {
137
+ const path = this.pidPath(profileName)
138
+
139
+ if (this.fs.existsSync(path)) this.fs.unlink(path)
140
+ }
141
+
142
+ private installCleanup(profileName: string): void {
143
+ const cleanup = () => this.removePidFile(profileName)
144
+
145
+ globalThis.process.once("exit", cleanup)
146
+ globalThis.process.once("SIGINT", cleanup)
147
+ globalThis.process.once("SIGTERM", cleanup)
148
+ }
149
+
150
+ private isProcessAlive(pid: number): boolean {
151
+ const result = this.process.runSync(["ps", "-p", String(pid), "-o", "state="])
152
+
153
+ if (result.exitCode !== 0) return false
154
+
155
+ const state = result.stdout.trim()
156
+
157
+ if (!state) return false
158
+
159
+ return !state.startsWith("Z")
82
160
  }
83
161
 
84
162
  private buildArgs(options: LaunchOptions, cwd: string): string[] {
@@ -16,8 +16,7 @@ export const startChannelServer = async (): Promise<void> => {
16
16
  instructions: [
17
17
  `Events arrive inside <channel source="${FUNNEL_MCP_NAME}"> tags. Use meta.event_type to discriminate.`,
18
18
  "",
19
- 'event_type="slack": a Slack message. meta includes channel_id, user_id, mentioned, thread_ts, etc. content is the Slack event JSON.',
20
- 'event_type="system": system event (connect / disconnect / startup, etc.).',
19
+ "To reply or act on an event, run `funnel request <platform> --help` via the Bash tool (e.g. `funnel request slack --help`). For general CLI usage, run `funnel --help`.",
21
20
  ].join("\n"),
22
21
  },
23
22
  )
@@ -1,11 +1,11 @@
1
1
  import { FunnelSettingsReader } from "@/modules/settings/funnel-settings-reader"
2
- import type { AgentConfig } from "@/modules/settings/settings-schema"
2
+ import type { ProfileConfig } from "@/modules/settings/settings-schema"
3
3
 
4
4
  type Deps = {
5
5
  store: FunnelSettingsReader
6
6
  }
7
7
 
8
- export class FunnelAgents {
8
+ export class FunnelProfiles {
9
9
  private readonly store: FunnelSettingsReader
10
10
 
11
11
  constructor(deps: Deps) {
@@ -13,19 +13,19 @@ export class FunnelAgents {
13
13
  Object.freeze(this)
14
14
  }
15
15
 
16
- list(): AgentConfig[] {
17
- return this.store.read().agents
16
+ list(): ProfileConfig[] {
17
+ return this.store.read().profiles
18
18
  }
19
19
 
20
- get(name: string): AgentConfig | null {
21
- return this.list().find((a) => a.name === name) ?? null
20
+ get(name: string): ProfileConfig | null {
21
+ return this.list().find((p) => p.name === name) ?? null
22
22
  }
23
23
 
24
- add(config: AgentConfig): void {
24
+ add(config: ProfileConfig): void {
25
25
  const settings = this.store.read()
26
26
 
27
- if (settings.agents.some((a) => a.name === config.name)) {
28
- throw new Error(`agent "${config.name}" already exists`)
27
+ if (settings.profiles.some((p) => p.name === config.name)) {
28
+ throw new Error(`profile "${config.name}" already exists`)
29
29
  }
30
30
 
31
31
  if (!settings.channels.some((c) => c.name === config.channel)) {
@@ -36,7 +36,7 @@ export class FunnelAgents {
36
36
  throw new Error(`repo "${config.repo}" not found`)
37
37
  }
38
38
 
39
- settings.agents.push(config)
39
+ settings.profiles.push(config)
40
40
 
41
41
  this.store.write(settings)
42
42
  }
@@ -44,11 +44,11 @@ export class FunnelAgents {
44
44
  remove(name: string): void {
45
45
  const settings = this.store.read()
46
46
 
47
- const index = settings.agents.findIndex((a) => a.name === name)
47
+ const index = settings.profiles.findIndex((p) => p.name === name)
48
48
 
49
- if (index < 0) throw new Error(`agent "${name}" not found`)
49
+ if (index < 0) throw new Error(`profile "${name}" not found`)
50
50
 
51
- settings.agents.splice(index, 1)
51
+ settings.profiles.splice(index, 1)
52
52
 
53
53
  this.store.write(settings)
54
54
  }
@@ -56,32 +56,32 @@ export class FunnelAgents {
56
56
  rename(oldName: string, newName: string): void {
57
57
  const settings = this.store.read()
58
58
 
59
- const agent = settings.agents.find((a) => a.name === oldName)
59
+ const profile = settings.profiles.find((p) => p.name === oldName)
60
60
 
61
- if (!agent) throw new Error(`agent "${oldName}" not found`)
61
+ if (!profile) throw new Error(`profile "${oldName}" not found`)
62
62
 
63
- if (settings.agents.some((a) => a.name === newName)) {
64
- throw new Error(`agent "${newName}" already exists`)
63
+ if (settings.profiles.some((p) => p.name === newName)) {
64
+ throw new Error(`profile "${newName}" already exists`)
65
65
  }
66
66
 
67
- agent.name = newName
67
+ profile.name = newName
68
68
 
69
69
  this.store.write(settings)
70
70
  }
71
71
 
72
- update(name: string, fields: Partial<Omit<AgentConfig, "name">>): void {
72
+ update(name: string, fields: Partial<Omit<ProfileConfig, "name">>): void {
73
73
  const settings = this.store.read()
74
74
 
75
- const agent = settings.agents.find((a) => a.name === name)
75
+ const profile = settings.profiles.find((p) => p.name === name)
76
76
 
77
- if (!agent) throw new Error(`agent "${name}" not found`)
77
+ if (!profile) throw new Error(`profile "${name}" not found`)
78
78
 
79
79
  if (fields.channel !== undefined) {
80
80
  if (!settings.channels.some((c) => c.name === fields.channel)) {
81
81
  throw new Error(`channel "${fields.channel}" not found`)
82
82
  }
83
83
 
84
- agent.channel = fields.channel
84
+ profile.channel = fields.channel
85
85
  }
86
86
 
87
87
  if (fields.repo !== undefined) {
@@ -89,15 +89,15 @@ export class FunnelAgents {
89
89
  throw new Error(`repo "${fields.repo}" not found`)
90
90
  }
91
91
 
92
- agent.repo = fields.repo || undefined
92
+ profile.repo = fields.repo || undefined
93
93
  }
94
94
 
95
95
  if (fields.subAgent !== undefined) {
96
- agent.subAgent = fields.subAgent || undefined
96
+ profile.subAgent = fields.subAgent || undefined
97
97
  }
98
98
 
99
99
  if (fields.envFiles !== undefined) {
100
- agent.envFiles = fields.envFiles
100
+ profile.envFiles = fields.envFiles
101
101
  }
102
102
 
103
103
  this.store.write(settings)
@@ -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
  }
@@ -33,7 +33,7 @@ export class FunnelSettingsStore extends FunnelSettingsReader {
33
33
  connectors: [],
34
34
  channels: [],
35
35
  repositories: [],
36
- agents: [],
36
+ profiles: [],
37
37
  }
38
38
  }
39
39
 
@@ -5,7 +5,7 @@ export const createSettings = (partial: Partial<Settings> = {}): Settings => ({
5
5
  connectors: [],
6
6
  channels: [],
7
7
  repositories: [],
8
- agents: [],
8
+ profiles: [],
9
9
  ...partial,
10
10
  })
11
11
 
@@ -47,7 +47,7 @@ export const repositoryConfigSchema = z.object({
47
47
 
48
48
  export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>
49
49
 
50
- export const agentConfigSchema = z.object({
50
+ export const profileConfigSchema = z.object({
51
51
  name: z.string(),
52
52
  channel: z.string(),
53
53
  repo: z.string().optional(),
@@ -55,13 +55,13 @@ export const agentConfigSchema = z.object({
55
55
  envFiles: z.array(z.string()).optional(),
56
56
  })
57
57
 
58
- export type AgentConfig = z.infer<typeof agentConfigSchema>
58
+ export type ProfileConfig = z.infer<typeof profileConfigSchema>
59
59
 
60
60
  export const settingsSchema = z.object({
61
61
  connectors: z.array(connectorConfigSchema).default([]),
62
62
  channels: z.array(channelConfigSchema).default([]),
63
63
  repositories: z.array(repositoryConfigSchema).default([]),
64
- agents: z.array(agentConfigSchema).default([]),
64
+ profiles: z.array(profileConfigSchema).default([]),
65
65
  })
66
66
 
67
67
  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,6 +1,5 @@
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"
@@ -10,7 +9,6 @@ import { connectorsShowHandler } from "@/routes/connectors/show"
10
9
  export const connectorsRoutes = factory
11
10
  .createApp()
12
11
  .get("/", ...connectorsGroupHandler)
13
- .get("/:name/call", ...connectorsCallHandler)
14
12
  .put("/:name/rename/:newName", ...connectorsRenameHandler)
15
13
  .put("/rename/:name/:newName", ...connectorsRenameHandler)
16
14
  .post("/:name", ...connectorsAddHandler)
@@ -0,0 +1,3 @@
1
+ export const help = `funnel profiles add — add a profile
2
+
3
+ usage: funnel profiles add <name> --channel <ch> [--repo <r>] [--sub-agent <s>] [--env-file <f>]`
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod"
2
2
  import { factory } from "@/factory"
3
3
  import { zValidator } from "@/modules/router/validator"
4
- import { help } from "@/routes/agents/add.help"
4
+ import { help } from "@/routes/profiles/add.help"
5
5
 
6
- export const agentsAddHandler = factory.createHandlers(
6
+ export const profilesAddHandler = factory.createHandlers(
7
7
  zValidator("param", z.object({ name: z.string() })),
8
8
  zValidator(
9
9
  "query",
@@ -20,7 +20,7 @@ export const agentsAddHandler = factory.createHandlers(
20
20
  const query = c.req.valid("query")
21
21
  const funnel = c.var.funnel
22
22
 
23
- funnel.agents.add({
23
+ funnel.profiles.add({
24
24
  name: param.name,
25
25
  channel: query.channel,
26
26
  repo: query.repo,
@@ -28,6 +28,6 @@ export const agentsAddHandler = factory.createHandlers(
28
28
  envFiles: query["env-file"] ? [query["env-file"]] : undefined,
29
29
  })
30
30
 
31
- return c.text(`added agent "${param.name}"`)
31
+ return c.text(`added profile "${param.name}"`)
32
32
  },
33
33
  )
@@ -0,0 +1,16 @@
1
+ export const help = `funnel profiles — manage launch profiles
2
+
3
+ usage: funnel profiles [subcommand]
4
+
5
+ subcommands:
6
+ (none) list
7
+ add <name> --channel <ch> [--repo <r>] [--sub-agent <s>] [--env-file <f>]
8
+ <name> set [--channel ...] [--repo ...] [--sub-agent ...] [--env-file ...]
9
+ rename <old> <new> rename
10
+ remove <name> remove
11
+ <name> run launch (sugar for fnl claude)
12
+ <name> launch (alias for run)
13
+
14
+ examples:
15
+ funnel profiles add cto --channel prod-inbox --repo myapp --sub-agent cto
16
+ funnel profiles cto run`