1 import Base 2 import ./cfgio.bend as Cfgio 3 import ./cliops.bend as Cliops 4 import ./clilist.bend as Clilist 5 import ./cliswitch.bend as Cliswitch 6 import ./profswitch.bend as Profswitch 7 import ./profflag.bend as Profflag 8 import ./store/base.bend as SBase 9 import ./cmd.bend as Cmd 10 import ./args.bend as Args 11 import ./store.bend as Store 12 import ./settings.bend as Settings 13 import ./tui/run.bend as Tui 14 import ./daemon/entry.bend as Entry 15 import ./clibase.bend as Clibase 16 import ./gflags.bend as Gf 17 import ./setload.bend as Setload 18 import ./fmt/out.bend as O 19 import ./fmt/code.bend as Code 20 21 def help_text() -> String: 22 "usage: cswap [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 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 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 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 68 def with_ctx(f: SBase.Ctx -> IO(Cmd.Out)) -> IO(Cmd.Out): 69 IO.bind(SBase.Ctx, Cmd.Out, Store.ctx_env(), f) 70 71 def not_yet(+name: String) -> Cmd.Out: 72 Cmd.Out{2, "", "cswap: " ++ name ++ " is not implemented yet in cswap-bend\n"} 73 74 def 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// and reloads the launchd job (read its header first)\n" 79 80 def menubar_text() -> String: 81 "cswap: menubar is not supported in cswap-bend; use 'cswap tui' (or 'cswap watch') for a live dashboard\n" 82 83 def 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 92 def deferred(+name: String) -> Cmd.Out: 93 deferred_pick(String.eq(name, "upgrade"), String.eq(name, "menubar"), name) 94 95 def 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 98 def 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 113 def 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 124 def 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. 133 def 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 150 def shared(ts: List) -> List<&2, String>: 151 match ts: 152 case Nil{}: 153 [] 154 case Con{h, t}: 155 h <> shared(t) 156 157 def tail_of(ts: List) -> 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. 166 def 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 173 def 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 180 def 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 185 def 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 192 def 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 201 def usage_out(+msg: String) -> Cmd.Out: 202 Cmd.Out{2, "", Args.bad_text(msg) ++ "\n"} 203 204 def 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). 214 def 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 221 def 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 228 def word0(+args: List<&2, String>) -> String: 229 Maybe.default(&2, String, List.head(&2, String, words(args, False{})), "") 230 231 def 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. 235 def 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. 240 def 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 244 def 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 251 def 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. 255 def 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 262 def shape_key(+args: List<&2, String>) -> String: 263 shape_key2(Args.verb_of(word0(args)), word1(args)) 264 265 def fmt_bad(+name: String) -> Cmd.Out: 266 usage_out("argument --format: invalid choice: '" ++ name ++ "' (choose from " ++ Code.choices() ++ ")") 267 268 def 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 273 def g_rest(+g: Gf.G) -> List<&2, String>: 274 match g: 275 case Gf.G{rest, fmt, noin, yes, err, p}: 276 rest 277 278 def g_fmt(+g: Gf.G) -> Maybe<&2, String>: 279 match g: 280 case Gf.G{rest, fmt, noin, yes, err, p}: 281 fmt 282 283 def g_err(+g: Gf.G) -> String: 284 match g: 285 case Gf.G{rest, fmt, noin, yes, err, p}: 286 err 287 288 def converted(+f: O.Fmt, +key: String, o: Cmd.Out) -> IO(Cmd.Out): 289 IO.pure(Cmd.Out, O.convert(f, key, o)) 290 291 def 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 294 def 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). 302 def is_own(+v: String) -> Bool: 303 String.eq(v, "auto") || String.eq(v, "tui") || String.eq(v, "watch") 304 305 def 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 312 def 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 319 def 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 326 def 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 333 def exec_own(+g: Gf.G) -> IO(Cmd.Out): 334 run2(ni_of(g), with_fmt_name(g_fmt(g), g_rest(g))) 335 336 def 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 343 def 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 350 def 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 357 def exec2(+g: Gf.G, force: Maybe<&2, O.Fmt>) -> IO(Cmd.Out): 358 exec2a(String.is_empty(g_err(g)), g, force) 359 360 def 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. 370 def 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). 374 def run(ts: List) -> IO(Cmd.Out): 375 exec_line(tail_of(ts), None{})