store/base.bend

cswap-bend-cli@0.1.0.0 · 186 lines · 40 definitions

1import Base
2
3# ---- environment and paths -----------------------------------------------------------
4
5# `home` is $HOME; `config_home` is $CLAUDE_CONFIG_DIR or ~/.claude (it holds
6# .credentials.json); `global_base` is $CLAUDE_CONFIG_DIR or $HOME (it holds
7# .claude.json). The legacy <config home>/.config.json is not consulted.
8
9type Ctx is Data:
10  Ctx{home: String, config_home: String, global_base: String}
11
12def ctx_of2(+home: String, +dir: String, +unset: Bool) -> Ctx:
13  match unset:
14    case True{}:
15      Ctx{home, home ++ "/.claude", home}
16    case False{}:
17      Ctx{home, dir, dir}
18
19def ctx_of(+home: String, +claude_config_dir: String) -> Ctx:
20  ctx_of2(home, claude_config_dir, String.is_empty(claude_config_dir))
21
22def root_of(+ctx: Ctx) -> String:
23  match ctx:
24    case Ctx{home, c, g}:
25      home ++ "/.claude-swap-backup"
26
27def live_creds_path(+ctx: Ctx) -> String:
28  match ctx:
29    case Ctx{home, c, g}:
30      c ++ "/.credentials.json"
31
32def global_config_path(+ctx: Ctx) -> String:
33  match ctx:
34    case Ctx{home, c, g}:
35      g ++ "/.claude.json"
36
37def seq_path(+root: String) -> String:
38  root ++ "/sequence.json"
39
40def lock_path(+root: String) -> String:
41  root ++ "/.lock"
42
43def log_path(+root: String) -> String:
44  root ++ "/claude-swap.log"
45
46def creds_dir(+root: String) -> String:
47  root ++ "/credentials"
48
49def configs_dir(+root: String) -> String:
50  root ++ "/configs"
51
52def removed_dir(+root: String) -> String:
53  creds_dir(root) ++ "/.removed"
54
55def removed_configs_dir(+root: String) -> String:
56  configs_dir(root) ++ "/.removed"
57
58def enc_path(+root: String, +slot: String, +email: String) -> String:
59  creds_dir(root) ++ "/.creds-" ++ slot ++ "-" ++ email ++ ".enc"
60
61def prev_path(+root: String, +slot: String, +email: String) -> String:
62  enc_path(root, slot, email) ++ ".prev"
63
64def config_path(+root: String, +slot: String, +email: String) -> String:
65  configs_dir(root) ++ "/.claude-config-" ++ slot ++ "-" ++ email ++ ".json"
66
67# credentials/.removed/<utc-ts>-<slot>-<email>.enc, `ts` like 20261023T120000Z.
68def removed_enc_path(+root: String, +ts: String, +slot: String, +email: String) -> String:
69  removed_dir(root) ++ "/" ++ ts ++ "-" ++ slot ++ "-" ++ email ++ ".enc"
70
71def removed_config_path(+root: String, +ts: String, +slot: String, +email: String) -> String:
72  removed_configs_dir(root) ++ "/" ++ ts ++ "-" ++ slot ++ "-" ++ email ++ ".json"
73
74# ---- small string tools ------------------------------------------------------------
75
76def sel.s(+c: Bool, +x: String, +y: String) -> String:
77  match c:
78    case True{}:
79      x
80    case False{}:
81      y
82
83def strip_chars.pick(+skip: Bool, +c: Char, +rest: String) -> String:
84  match skip:
85    case True{}:
86      rest
87    case False{}:
88      SCon{c, rest}
89
90# Drop every char of `drop` from `s`.
91def strip_chars(+s: String, +drop: String) -> String:
92  match s:
93    case SNil{}:
94      SNil{}
95    case SCon{+c, t}:
96      strip_chars.pick(String.contains(drop, SCon{c, SNil{}}), c, strip_chars(t, drop))
97
98# 2026-10-23T12:00:00Z becomes 20261023T120000Z, safe in a file name.
99def compact_ts(+iso: String) -> String:
100  strip_chars(iso, "-:")
101
102# The reference's `%(asctime)s` stamp: `2026-10-23 12:00:00,000`.
103def log_stamp(+iso: String) -> String:
104  String.take(iso, 10n) ++ " " ++ String.take(String.drop(iso, 11n), 8n) ++ ",000"
105
106def log_line(+iso: String, +level: String, +msg: String) -> String:
107  log_stamp(iso) ++ " - " ++ level ++ " - " ++ msg ++ "\n"
108
109# ---- validation ---------------------------------------------------------------------
110
111def is_oauth_token(+t: String) -> Bool:
112  String.starts_with(t, "sk-ant-oat") || String.starts_with(t, "sk-ant-ort")
113
114def not_(+b: Bool) -> Bool:
115  Bool.not(b)
116
117def looks_like_api_key2(+t: String) -> Bool:
118  not_(String.starts_with(t, "{")) && String.starts_with(t, "sk-ant-") && not_(is_oauth_token(t))
119
120# A bare `sk-ant-` value that is not an OAuth token is a managed API key.
121def looks_like_api_key(+text: String) -> Bool:
122  looks_like_api_key2(String.trim(text))
123
124def email_char(+c: Char) -> Bool:
125  Char.is_digit(c) || Char.is_alpha(c) || Char.is_eq(c, '.') || Char.is_eq(c, '-') || Char.is_eq(c, '_') || Char.is_eq(c, '%') || Char.is_eq(c, '+')
126
127def domain_char(+c: Char) -> Bool:
128  Char.is_digit(c) || Char.is_alpha(c) || Char.is_eq(c, '.') || Char.is_eq(c, '-')
129
130def sel.b(+c: Bool, +x: Bool, +y: Bool) -> Bool:
131  match c:
132    case True{}:
133      x
134    case False{}:
135      y
136
137def all_chars(+s: String, +local: Bool) -> Bool:
138  match s:
139    case SNil{}:
140      True{}
141    case SCon{+c, t}:
142      sel.b(local, email_char(c), domain_char(c)) && all_chars(t, local)
143
144def all_alpha(+s: String) -> Bool:
145  match s:
146    case SNil{}:
147      True{}
148    case SCon{c, t}:
149      Char.is_alpha(c) && all_alpha(t)
150
151def nonempty(+s: String) -> Bool:
152  not_(String.is_empty(s))
153
154def valid_host2(+tld: Maybe<&2, String>, +n: Nat, +chars: Bool) -> Bool:
155  match tld:
156    case None{}:
157      False{}
158    case Some{t}:
159      chars && Nat.is_ge(n, 2n) && all_alpha(t) && Nat.is_ge(String.length(t), 2n)
160
161# host = prefix.tld: the last dot-separated part is 2+ letters and what is
162# left of it is a non-empty run of host characters.
163def valid_host(+segs: List<&2, String>, +host: String) -> Bool:
164  valid_host2(List.last(&2, String, segs), List.length(&2, String, segs), all_chars(host, False{}))
165
166def valid_email2(+parts: List<&2, String>) -> Bool:
167  match parts:
168    case Con{local, Con{host, Nil{}}}:
169      nonempty(local) && all_chars(local, True{}) && valid_host(String.split(host, '.'), host)
170    case _:
171      False{}
172
173# local@host.tld by `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`.
174def valid_email(+e: String) -> Bool:
175  valid_email2(String.split(e, '@'))
176
177def all_digits(+s: String) -> Bool:
178  match s:
179    case SNil{}:
180      True{}
181    case SCon{c, t}:
182      Char.is_digit(c) && all_digits(t)
183
184# models.normalize_alias: lowercase, non-empty, not all digits, no leading `-`.
185def alias_ok(+a: String) -> Bool:
186  nonempty(a) && not_(all_digits(a)) && not_(String.starts_with(a, "-"))