exec.bend
cswap-bend-cli@0.1.0.0 · 375 lines · 52 definitions
1import Base
2import cswap-bend-cli@0.1.0.0/cfgio.bend as Cfgio
3import cswap-bend-cli@0.1.0.0/cliops.bend as Cliops
4import cswap-bend-cli@0.1.0.0/clilist.bend as Clilist
5import cswap-bend-cli@0.1.0.0/cliswitch.bend as Cliswitch
6import cswap-bend-cli@0.1.0.0/profswitch.bend as Profswitch
7import cswap-bend-cli@0.1.0.0/profflag.bend as Profflag
8import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
9import cswap-bend-cli@0.1.0.0/cmd.bend as Cmd
10import cswap-bend-cli@0.1.0.0/args.bend as Args
11import cswap-bend-cli@0.1.0.0/store.bend as Store
12import cswap-bend-cli@0.1.0.0/settings.bend as Settings
13import cswap-bend-cli@0.1.0.0/tui/run.bend as Tui
14import cswap-bend-cli@0.1.0.0/daemon/entry.bend as Entry
15import cswap-bend-cli@0.1.0.0/clibase.bend as Clibase
16import cswap-bend-cli@0.1.0.0/gflags.bend as Gf
17import cswap-bend-cli@0.1.0.0/setload.bend as Setload
18import cswap-bend-cli@0.1.0.0/fmt/out.bend as O
19import cswap-bend-cli@0.1.0.0/fmt/code.bend as Code
20
21def help_text() -> String:
22 "usage: cswap <command> [args] [options]\n\nMulti-Account Switcher for Claude Code\n\nCommands:\n"
23 ++ " cswap help show this help\n"
24 ++ " cswap list list managed accounts\n"
25 ++ " cswap status show current account\n"
26 ++ " cswap switch rotate to the next account\n"
27 ++ " cswap switch <num|email> switch to a specific account\n"
28 ++ " cswap add add the current account\n"
29 ++ " cswap add-token [TOKEN|-] register a setup-token or API key\n"
30 ++ " cswap remove <num|email> remove an account\n"
31 ++ " cswap auto auto-switch when nearing rate limits\n"
32 ++ " cswap config [set KEY VALUE] show or change settings (settings.json)\n"
33 ++ " cswap profile [list|show|use|set|remove] behaviour profiles (strategy, threshold, models, cooldown, interval)\n"
34 ++ " cswap import-usage <path> adopt usage another machine read (list --json)\n"
35 ++ " cswap config export the whole settings.json (profiles included); --format json|yaml|toon|csv\n"
36 ++ " cswap config import FILE|- merge a settings file in (.json .yaml .yml .toon, .csv as key,value rows)\n"
37 ++ " cswap profile export [NAME] the profiles (or one) as a document\n"
38 ++ " cswap profile import FILE|- merge profiles in\n"
39 ++ " cswap rpc one request per line on stdin, one response per line on stdout (docs/API.md)\n"
40 ++ " cswap serve --socket PATH the same protocol on a unix socket (mode 0600)\n"
41 ++ " cswap tui read-only dashboard, redrawn every 2 s until killed (--once, --interval N)\n"
42 ++ " cswap watch same dashboard as tui (periodic refresh, no key bindings)\n"
43 ++ " cswap menubar not supported in cswap-bend (exit 2); use cswap tui\n"
44 ++ " cswap upgrade show how to upgrade this build (rebuild from the repo)\n\n"
45 ++ "Aliases: ls=list rm=remove update=upgrade\n\n"
46 ++ "options:\n"
47 ++ " -h, --help show this help message and exit\n"
48 ++ " --version show program's version number and exit\n"
49 ++ " --debug Enable debug logging\n"
50 ++ " --json Emit machine-readable JSON to stdout (use with 'list', 'status', or 'switch').\n"
51 ++ " --format FORMAT text (default), json (same as --json), csv, yaml or toon, for list, status, switch,\n"
52 ++ " config, profile, import-usage and the 'auto' event stream\n"
53 ++ " --no-input Never read standard input; a prompt becomes an error\n"
54 ++ " --yes Answer the 'remove' confirmation\n"
55 ++ " --slot NUM Specify slot number when adding account (use with 'add' or 'add-token')\n"
56 ++ " --email EMAIL Email address for the account. Optional with 'add-token'; defaults to\n"
57 ++ " setup-token-{slot}@token.local (or api-key-{slot}@token.local for API keys)\n"
58 ++ " --alias NAME Set a short display alias for the account (use with 'add')\n"
59 ++ " --hold SECONDS With 'import-usage': keep this machine from fetching the imported accounts\n"
60 ++ " for this many seconds (0 lifts an earlier hold)\n\n"
61 ++ "Flags combine with subcommands:\n"
62 ++ " cswap list --json\n"
63 ++ " cswap import-usage usage.json --hold 600 # adopt another machine's list --json\n"
64 ++ " cswap add --slot 3 # add to a specific slot\n"
65 ++ " cswap add-token sk-ant-oat01-... --email me@example.com\n"
66 ++ " cswap config set autoswitch.threshold 80\n"
67
68def with_ctx(f: SBase.Ctx -> IO(Cmd.Out)) -> IO(Cmd.Out):
69 IO.bind(SBase.Ctx, Cmd.Out, Store.ctx_env(), f)
70
71def not_yet(+name: String) -> Cmd.Out:
72 Cmd.Out{2, "", "cswap: " ++ name ++ " is not implemented yet in cswap-bend\n"}
73
74def upgrade_text() -> String:
75 "cswap-bend does not upgrade itself. It is built from source, so to upgrade this build:\n"
76 ++ " 1. git pull in the cswap-bend repo (your checkout)\n"
77 ++ " 2. just build (writes bin/cswap-bend)\n"
78 ++ " 3. deploy it: deploy/install-live copies the binary to ~/.local/share/cswap-bend/<sha>/ and reloads the launchd job (read its header first)\n"
79
80def menubar_text() -> String:
81 "cswap: menubar is not supported in cswap-bend; use 'cswap tui' (or 'cswap watch') for a live dashboard\n"
82
83def deferred_pick(+up: Bool, +mb: Bool, +name: String) -> Cmd.Out:
84 match up mb:
85 case True{} _:
86 Cmd.Out{0, upgrade_text(), ""}
87 case False{} True{}:
88 Cmd.Out{2, "", menubar_text()}
89 case False{} False{}:
90 not_yet(name)
91
92def deferred(+name: String) -> Cmd.Out:
93 deferred_pick(String.eq(name, "upgrade"), String.eq(name, "menubar"), name)
94
95def config_cmd(+ctx: SBase.Ctx, ts: List<&2, String>, +json: Bool, +ni: Cliops.Ni) -> IO(Cmd.Out):
96 Cfgio.config(SBase.root_of(ctx), ts, json, Cliops.ni_noin(ni))
97
98def account_cmd(+ni: Cliops.Ni, +p: Args.Parsed) -> IO(Cmd.Out):
99 match p:
100 case Args.PAdd{slot, alias}:
101 with_ctx(ctx => Clibase.run_act(False{}, Cliops.add_cmd(ctx, alias, slot, ni)))
102 case Args.PAddToken{token, email, slot}:
103 with_ctx(ctx => Clibase.run_act(False{}, Cliops.add_token_cmd(ctx, token, email, slot, ni)))
104 case Args.PRemove{ident}:
105 with_ctx(ctx => Clibase.run_act(False{}, Cliops.remove_cmd(ctx, ident, ni)))
106 case Args.PImportUsage{path, hold}:
107 with_ctx(ctx => Clibase.run_act(False{}, Cliops.import_cmd(ctx, path, hold)))
108 case Args.PDeferred{name}:
109 IO.pure(Cmd.Out, deferred(name))
110 case _:
111 IO.pure(Cmd.Out, not_yet("command"))
112
113def view_cmd(+ni: Cliops.Ni, +p: Args.Parsed) -> IO(Cmd.Out):
114 match p:
115 case Args.PList{+json, token_status}:
116 with_ctx(ctx => Clibase.run_act(json, Clilist.list_cmd(ctx, json)))
117 case Args.PStatus{+json}:
118 with_ctx(ctx => Clibase.run_act(json, Clilist.status_cmd(ctx, json)))
119 case Args.PSwitch{ident, force, strategy, model, json}:
120 with_ctx(ctx => Cliswitch.switch_cmd(ctx, ident, force, strategy, model, json))
121 case _:
122 account_cmd(ni, p)
123
124def other_cmd(+ni: Cliops.Ni, +p: Args.Parsed) -> IO(Cmd.Out):
125 match p:
126 case Args.PProfile{ts, json}:
127 with_ctx(ctx => Cfgio.profile(SBase.root_of(ctx), ts, json, Cliops.ni_noin(ni)))
128 case _:
129 view_cmd(ni, p)
130
131
132
133def dispatch(+ni: Cliops.Ni, +p: Args.Parsed) -> IO(Cmd.Out):
134 match p:
135 case Args.PHelp{}:
136 IO.pure(Cmd.Out, Cmd.Out{0, help_text(), ""})
137 case Args.PVersion{}:
138 IO.pure(Cmd.Out, Cmd.ok(Clibase.version()))
139 case Args.PBad{msg}:
140 IO.pure(Cmd.Out, Cmd.Out{2, "", msg ++ "\n"})
141 case Args.PTui{ts}:
142 IO.bind(Unit, Cmd.Out, Tui.main(Clibase.affine(ts), Clibase.version()), u => IO.pure(Cmd.Out, Cmd.silent()))
143 case Args.PAuto{ts}:
144 Entry.auto_cmd(ts, Clibase.version())
145 case Args.PConfig{ts, json}:
146 with_ctx(ctx => config_cmd(ctx, ts, json, ni))
147 case _:
148 other_cmd(ni, p)
149
150def shared(ts: List<String>) -> List<&2, String>:
151 match ts:
152 case Nil{}:
153 []
154 case Con{h, t}:
155 h <> shared(t)
156
157def tail_of(ts: List<String>) -> List<&2, String>:
158 match ts:
159 case Con{h, t}:
160 shared(t)
161 case Nil{}:
162 []
163
164
165
166def switch_route(+ni: Cliops.Ni, +over: Maybe<&2, String>, +p: Args.Parsed) -> IO(Cmd.Out):
167 match p:
168 case Args.PSwitch{ident, force, strategy, model, json}:
169 with_ctx(ctx => Profswitch.switch_cmd(ctx, over, ident, force, strategy, model, json))
170 case _:
171 dispatch(ni, p)
172
173def run5(+ni: Cliops.Ni, +ok: Bool, +h: String, +rest: List<&2, String>, +over: Maybe<&2, String>, +err: String) -> IO(Cmd.Out):
174 match ok:
175 case True{}:
176 switch_route(ni, over, Args.parse(h <> rest))
177 case False{}:
178 dispatch(ni, Args.PBad{Args.bad_text(err)})
179
180def run4(+ni: Cliops.Ni, +h: String, +sp: Profflag.Split) -> IO(Cmd.Out):
181 match sp:
182 case Profflag.Split{rest, over, err, pending}:
183 run5(ni, String.is_empty(err), h, rest, over, err)
184
185def run3(+ni: Cliops.Ni, +is_switch: Bool, +h: String, +t: List<&2, String>, +args: List<&2, String>) -> IO(Cmd.Out):
186 match is_switch:
187 case True{}:
188 run4(ni, h, Profflag.split(t))
189 case False{}:
190 dispatch(ni, Args.parse(args))
191
192def run2(+ni: Cliops.Ni, +args: List<&2, String>) -> IO(Cmd.Out):
193 match args:
194 case Nil{}:
195 dispatch(ni, Args.parse(args))
196 case Con{h, t}:
197 run3(ni, String.eq(Args.verb_of(h), "switch"), h, t, Con{h, t})
198
199
200
201def usage_out(+msg: String) -> Cmd.Out:
202 Cmd.Out{2, "", Args.bad_text(msg) ++ "\n"}
203
204def words.step(skip: Bool, flag: Bool, +h: String, rest: List<&2, String>) -> List<&2, String>:
205 match skip flag:
206 case True{} _:
207 rest
208 case False{} True{}:
209 rest
210 case False{} False{}:
211 h <> rest
212
213
214def words(args: List<&2, String>, skip: Bool) -> List<&2, String>:
215 match args:
216 case Nil{}:
217 []
218 case Con{+h, t}:
219 words.step(skip, String.starts_with(h, "-"), h, words(t, String.eq(h, "--format")))
220
221def first_verb(args: List<&2, String>) -> String:
222 match args:
223 case Nil{}:
224 ""
225 case Con{h, t}:
226 Args.verb_of(h)
227
228def word0(+args: List<&2, String>) -> String:
229 Maybe.default(&2, String, List.head(&2, String, words(args, False{})), "")
230
231def word1(+args: List<&2, String>) -> String:
232 Maybe.default(&2, String, List.head(&2, String, List.drop(&2, String, words(args, False{}), 1n)), "")
233
234
235def keeps_format(+v0: String, +v1: String) -> Bool:
236 (String.eq(v0, "config") || String.eq(v0, "profile")) && String.eq(v1, "import")
237
238
239
240def has_json(+v0: String, +v1: String) -> Bool:
241 String.eq(v0, "list") || String.eq(v0, "status") || String.eq(v0, "switch") || String.eq(v0, "profile")
242 || (String.eq(v0, "config") && (String.is_empty(v1) || String.eq(v1, "list") || String.eq(v1, "get") || String.eq(v1, "export") || String.eq(v1, "import")))
243
244def add_json(on: Bool, +args: List<&2, String>) -> List<&2, String>:
245 match on:
246 case True{}:
247 List.append(&2, String, args, ["--json"])
248 case False{}:
249 args
250
251def with_json(+args: List<&2, String>) -> List<&2, String>:
252 add_json(has_json(Args.verb_of(word0(args)), word1(args)) && Bool.not(Setload.has_str(args, "--json")), args)
253
254
255def shape_key2(+v0: String, +v1: String) -> String:
256 Bool.pick(String, String.eq(v0, "list"), "list",
257 Bool.pick(String, String.eq(v0, "status"), "status",
258 Bool.pick(String, String.eq(v0, "config") && (String.is_empty(v1) || String.eq(v1, "list")), "config-list",
259 Bool.pick(String, String.eq(v0, "profile") && (String.is_empty(v1) || String.eq(v1, "list") || String.eq(v1, "ls")), "profile-list",
260 Bool.pick(String, String.eq(v1, "export"), "export", v0)))))
261
262def shape_key(+args: List<&2, String>) -> String:
263 shape_key2(Args.verb_of(word0(args)), word1(args))
264
265def fmt_bad(+name: String) -> Cmd.Out:
266 usage_out("argument --format: invalid choice: '" ++ name ++ "' (choose from " ++ Code.choices() ++ ")")
267
268def ni_of(+g: Gf.G) -> Cliops.Ni:
269 match g:
270 case Gf.G{rest, fmt, noin, yes, err, p}:
271 Cliops.Ni{noin, yes}
272
273def g_rest(+g: Gf.G) -> List<&2, String>:
274 match g:
275 case Gf.G{rest, fmt, noin, yes, err, p}:
276 rest
277
278def g_fmt(+g: Gf.G) -> Maybe<&2, String>:
279 match g:
280 case Gf.G{rest, fmt, noin, yes, err, p}:
281 fmt
282
283def g_err(+g: Gf.G) -> String:
284 match g:
285 case Gf.G{rest, fmt, noin, yes, err, p}:
286 err
287
288def converted(+f: O.Fmt, +key: String, o: Cmd.Out) -> IO(Cmd.Out):
289 IO.pure(Cmd.Out, O.convert(f, key, o))
290
291def run_conv(+ni: Cliops.Ni, +f: O.Fmt, +args: List<&2, String>) -> IO(Cmd.Out):
292 IO.bind(Cmd.Out, Cmd.Out, run2(ni, with_json(args)), o => converted(f, shape_key(args), o))
293
294def run_fmt(+ni: Cliops.Ni, +f: O.Fmt, +args: List<&2, String>) -> IO(Cmd.Out):
295 match f:
296 case O.FText{}:
297 run2(ni, args)
298 case _:
299 run_conv(ni, f, args)
300
301
302def is_own(+v: String) -> Bool:
303 String.eq(v, "auto") || String.eq(v, "tui") || String.eq(v, "watch")
304
305def with_fmt_name(m: Maybe<&2, String>, +args: List<&2, String>) -> List<&2, String>:
306 match m:
307 case None{}:
308 args
309 case Some{n}:
310 List.append(&2, String, args, ["--format", n])
311
312def named.of(+n: String, m: Maybe<&2, O.Fmt>) -> Result<&2, &2, String, O.Fmt>:
313 match m:
314 case Some{f}:
315 Done{f}
316 case None{}:
317 Fail{n}
318
319def named(m: Maybe<&2, String>) -> Result<&2, &2, String, O.Fmt>:
320 match m:
321 case None{}:
322 Done{O.FText{}}
323 case Some{+n}:
324 named.of(n, O.of_name(n))
325
326def forced(force: Maybe<&2, O.Fmt>, m: Maybe<&2, String>) -> Result<&2, &2, String, O.Fmt>:
327 match force:
328 case Some{f}:
329 Done{f}
330 case None{}:
331 named(m)
332
333def exec_own(+g: Gf.G) -> IO(Cmd.Out):
334 run2(ni_of(g), with_fmt_name(g_fmt(g), g_rest(g)))
335
336def exec3(+g: Gf.G, r: Result<&2, &2, String, O.Fmt>) -> IO(Cmd.Out):
337 match r:
338 case Fail{n}:
339 IO.pure(Cmd.Out, fmt_bad(n))
340 case Done{f}:
341 run_fmt(ni_of(g), f, g_rest(g))
342
343def exec2b(own: Bool, +g: Gf.G, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out):
344 match own:
345 case True{}:
346 exec_own(g)
347 case False{}:
348 exec3(g, forced(force, g_fmt(g)))
349
350def exec2a(ok: Bool, +g: Gf.G, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out):
351 match ok:
352 case True{}:
353 exec2b(is_own(Args.verb_of(word0(g_rest(g)))), g, force)
354 case False{}:
355 IO.pure(Cmd.Out, usage_out(g_err(g)))
356
357def exec2(+g: Gf.G, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out):
358 exec2a(String.is_empty(g_err(g)), g, force)
359
360def exec_line2(own: Bool, +args: List<&2, String>, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out):
361 match own:
362 case True{}:
363 run2(Cliops.Ni{False{}, False{}}, args)
364 case False{}:
365 exec2(Gf.split(keeps_format(Args.verb_of(word0(args)), word1(args)), args), force)
366
367
368
369
370def exec_line(+args: List<&2, String>, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out):
371 exec_line2(is_own(first_verb(args)), args, force)
372
373
374def run(ts: List<String>) -> IO(Cmd.Out):
375 exec_line(tail_of(ts), None{})