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# One case per command family; a command that arrives later (auto) is one more
132# case here and one more entry in Args.build.cmd.
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# `switch` follows a behaviour profile when `--profile NAME` (or the active
165# profile) names one; every other command runs as parsed.
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# ---- --format, --no-input, --yes ---------------------------------------------------------------------
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# The words that are not options (and not the value of --format).
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# `config import` and `profile import` use --format for the input file.
235def keeps_format(+v0: String, +v1: String) -> Bool:
236  (String.eq(v0, "config") || String.eq(v0, "profile")) && String.eq(v1, "import")
237
238# `--json` for the verbs that print a JSON document of their own; every other
239# command's text is wrapped by O.convert.
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# Which CSV shape (src/fmt/shape.bend) a command's document has.
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# `auto` and the dashboard take their own options (--format included).
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# Run a command line (the words after the program name). `force` fixes the
368# output format (the rpc loop asks for json); without it `--format` decides.
369# A line that starts with `auto`, `tui` or `watch` is passed on untouched.
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# The command for `IO.args` (its head is the program name).
374def run(ts: List<String>) -> IO(Cmd.Out):
375  exec_line(tail_of(ts), None{})