store.bend

cswap-bend-cli@0.1.0.0 · 358 lines · 64 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/fs.bend as Fs
4import cswap-bend-cli@0.1.0.0/act.bend as Act
5import cswap-bend-cli@0.1.0.0/lock.bend as Lock
6import cswap-bend-cli@0.1.0.0/clock.bend as Clock
7import cswap-bend-cli@0.1.0.0/http.bend as Http
8import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
9import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
10import cswap-bend-cli@0.1.0.0/store/plan.bend as SPlan
11import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
12import cswap-bend-cli@0.1.0.0/store/ops.bend as SOps
13
14# `do ActM<A>:` needs the monad defined in the file that uses it.
15def ActM(-A: Type) -> Type:
16  Act.of(A)
17
18def ActM.pure(-A: Type, x: A) -> Act.of(A):
19  Act.pure(A, x)
20
21def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
22  Act.bind(A, B, m, f)
23
24# The account store: ~/.claude-swap-backup (sequence.json, credentials/,
25# configs/), laid out exactly as the Python cswap writes it so both can share
26# one backup root. See parity/contract.md section 1.
27
28# ---- add (adopt the live login) -------------------------------------------------------
29
30def profile_url() -> String:
31  "https://api.anthropic.com/api/oauth/profile"
32
33def prof_if_ok(+ok: Bool, +body: String) -> SPlan.Prof:
34  match ok:
35    case True{}:
36      SPlan.profile_of_text(body)
37    case False{}:
38      SPlan.ProfNone{}
39
40def prof_of_reply(r: Result<&2, &2, String, Http.Reply>) -> SPlan.Prof:
41  match r:
42    case Fail{e}:
43      SPlan.ProfNone{}
44    case Done{Http.Reply{status, head, body}}:
45      prof_if_ok(U32.is_eq(status, 200), body)
46
47# GET /api/oauth/profile through Http.request (curl, the token on stdin, and the
48# CSWAP_TEST_HTTP_BASE seam). Any failure is ProfNone: "unresolvable", never
49# "wrong".
50def profile_http(+token: String) -> IO(SPlan.Prof):
51  IO.bind(Result<&2, &2, String, Http.Reply>, SPlan.Prof,
52    Http.request("GET", profile_url(), [("Authorization", "Bearer " ++ token), ("Content-Type", "application/json"), ("User-Agent", "claude-swap/1.0")], "", 5),
53    r => IO.pure(SPlan.Prof, prof_of_reply(r)))
54
55def alias_char(+c: Char) -> Bool:
56  Char.is_lower(c) || Char.is_digit(c) || Char.is_eq(c, '-') || Char.is_eq(c, '_') || Char.is_eq(c, '.')
57
58def alias_chars(+s: String) -> Bool:
59  match s:
60    case SNil{}:
61      True{}
62    case SCon{c, t}:
63      alias_char(c) && alias_chars(t)
64
65def alias_err2(+a: String, +name: String) -> String:
66  SBase.sel.s(String.is_empty(a), "alias cannot be empty",
67  SBase.sel.s(SBase.all_digits(a), "alias '" ++ name ++ "' cannot be purely numeric (reserved for slot numbers)",
68  SBase.sel.s(String.starts_with(a, "-"), "alias '" ++ name ++ "' cannot start with '-' (would be read as a command flag)",
69  SBase.sel.s(alias_chars(a), "", "alias '" ++ name ++ "' may only contain letters, digits, '-', '_', and '.'"))))
70
71# models.normalize_alias: the message for a bad alias, "" for a good one.
72def alias_err(+name: String) -> String:
73  alias_err2(String.to_lower(String.trim(name)), name)
74
75def alias_norm(+name: String) -> String:
76  String.to_lower(String.trim(name))
77
78# The alias, normalized; "" when none was asked for.
79def alias_of(+m: Maybe<&2, String>) -> String:
80  alias_norm(SSeq.str_or(m, ""))
81
82def alias_check(+m: Maybe<&2, String>) -> Act.of(Unit):
83  match m:
84    case None{}:
85      Act.pure(Unit, Unit{})
86    case Some{name}:
87      Act.guard(String.is_empty(alias_err(name)), 2, alias_err(name))
88
89# Who the live ~/.claude.json says is logged in.
90type Ident is Data:
91  Ident{email: String, org: String, uuid: String, org_name: String}
92
93def ident_of2(+oa: Json.Json) -> Ident:
94  Ident{SSeq.field_str(oa, "emailAddress"), SSeq.field_str(oa, "organizationUuid"), SSeq.field_str(oa, "accountUuid"), SSeq.field_str(oa, "organizationName")}
95
96def ident_of(+cfg: Json.Json) -> Ident:
97  ident_of2(SSeq.get_or("oauthAccount", cfg, Json.JNull{}))
98
99def notice_text(+email: String, +why: String) -> String:
100  "Notice: could not verify that the stored credential belongs to " ++ email ++ " (" ++ why
101    ++ "). Registering anyway; re-run where the check can complete to confirm.\n"
102
103# Fail on an owner mismatch; a notice when it could not be checked.
104def verdict_act(+v: SPlan.Verdict, +email: String) -> Act.of(String):
105  match v:
106    case SPlan.VOk{}:
107      Act.pure(String, "")
108    case SPlan.VUnverified{why}:
109      Act.pure(String, notice_text(email, why))
110    case SPlan.VBad{msg}:
111      Act.fail(String, 1, msg)
112
113def id_email(+id: Ident) -> String:
114  match id:
115    case Ident{e, o, u, n}:
116      e
117
118def id_org(+id: Ident) -> String:
119  match id:
120    case Ident{e, o, u, n}:
121      o
122
123def id_uuid(+id: Ident) -> String:
124  match id:
125    case Ident{e, o, u, n}:
126      u
127
128def verify_prof(+id: Ident, +prof: SPlan.Prof) -> Act.of(String):
129  verdict_act(SPlan.judge(id_email(id), id_org(id), id_uuid(id), prof), id_email(id))
130
131def verify_plan(+p: SPlan.Plan, +id: Ident, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
132  match p:
133    case SPlan.PSkip{why}:
134      Act.pure(String, notice_text(id_email(id), why))
135    case SPlan.PFetch{token}:
136      do ActM<String>:
137        prof : SPlan.Prof <- Act.lift(SPlan.Prof, fetch(token))
138        verify_prof(id, prof)
139
140def id_org_name(+id: Ident) -> String:
141  match id:
142    case Ident{e, o, u, n}:
143      n
144
145# The credential of the live login: .credentials.json, else primaryApiKey.
146def capture2(+ex: Bool, +path: String, +cfg: Json.Json) -> Act.of(String):
147  match ex:
148    case True{}:
149      Fs.read_file(path)
150    case False{}:
151      Act.pure(String, SSeq.field_str(cfg, "primaryApiKey"))
152
153def capture(+ctx: SBase.Ctx, +cfg: Json.Json) -> Act.of(String):
154  do ActM<String>:
155    ex : Bool <- Act.lift(Bool, Fs.exists(SBase.live_creds_path(ctx)))
156    capture2(ex, SBase.live_creds_path(ctx), cfg)
157
158def api_key_msg() -> String:
159  "Active login is an API-key account. Add it with 'cswap add-token sk-ant-...' instead of 'cswap add'."
160
161def alias_taken2(+m: Maybe<&2, String>, +exclude: String) -> Maybe<&2, String>:
162  match m:
163    case None{}:
164      None{}
165    case Some{n}:
166      SPlan.differs(Some{n}, exclude)
167
168def alias_taken(+seq: Json.Json, +alias: String, +exclude: String) -> Maybe<&2, String>:
169  alias_taken2(SSeq.find_alias(seq, alias), exclude)
170
171def alias_conflict2(+none_asked: Bool, +taken: Maybe<&2, String>, +alias: String) -> Act.of(Unit):
172  match none_asked taken:
173    case True{} _:
174      Act.pure(Unit, Unit{})
175    case False{} None{}:
176      Act.pure(Unit, Unit{})
177    case False{} Some{n}:
178      Act.fail(Unit, 2, "Alias '" ++ alias ++ "' is already used by account " ++ n)
179
180def alias_conflict(+seq: Json.Json, +alias: String, +exclude: String) -> Act.of(Unit):
181  alias_conflict2(String.is_empty(alias), alias_taken(seq, alias, exclude), alias)
182
183def same_alias(+m: Maybe<&2, Json.Json>, +id: Ident) -> String:
184  match m:
185    case None{}:
186      ""
187    case Some{r}:
188      SBase.sel.s(SSeq.rec_is(r, id_email(id), id_org(id)), SSeq.field_str(r, "alias"), "")
189
190def existing_alias2(+migrate: Maybe<&2, String>, +seq: Json.Json, +num: String, +id: Ident) -> String:
191  match migrate:
192    case Some{old}:
193      SSeq.field_str(SSeq.get_or(old, SSeq.accounts_of(seq), Json.JNull{}), "alias")
194    case None{}:
195      same_alias(SSeq.slot_record(seq, num), id)
196
197def existing_alias(+seq: Json.Json, +num: String, +migrate: Maybe<&2, String>, +id: Ident) -> String:
198  existing_alias2(migrate, seq, num, id)
199
200def carried_alias(+alias: String, +seq: Json.Json, +num: String, +migrate: Maybe<&2, String>, +id: Ident) -> String:
201  SBase.sel.s(String.is_empty(alias), existing_alias(seq, num, migrate, id), alias)
202
203def sel.j(+c: Bool, +x: Json.Json, +y: Json.Json) -> Json.Json:
204  match c:
205    case True{}:
206      x
207    case False{}:
208      y
209
210def refresh_files(+root: String, +id: Ident, +creds: String, +cfg_text: String, +num: String) -> Act.of(Unit):
211  do ActM<Unit>:
212    SIo.write_cred(root, num, id_email(id), creds)
213    SIo.write_config(root, num, id_email(id), cfg_text)
214
215def refresh_alias_seq(+seq: Json.Json, +alias: String, +num: String) -> Json.Json:
216  sel.j(String.is_empty(alias), seq, Json.set("accounts", Json.set(num, Json.set("alias", SSeq.jstr(alias), SSeq.get_or(num, SSeq.accounts_of(seq), Json.JObj{Json.JNil{}})), SSeq.accounts_of(seq)), seq))
217
218def commit_active(+root: String, +seq: Json.Json, +num: String, +ts: String) -> Act.of(Unit):
219  SIo.write_seq(root, SSeq.stamp(SSeq.set_active(seq, SSeq.str_u32(num)), ts))
220
221def refresh_msg(+id: Ident, +seq: Json.Json, +num: String) -> String:
222  "Updated credentials for Account " ++ num ++ " (" ++ id_email(id) ++ " [" ++ SSeq.org_tag(SSeq.get_or(num, SSeq.accounts_of(seq), Json.JNull{})) ++ "])."
223
224def current_refresh(+root: String, +id: Ident, +creds: String, +cfg_text: String, +alias: String, +ts: String, +seq: Json.Json, +num: String) -> Act.of(String):
225  do ActM<String>:
226    alias_conflict(seq, alias, num)
227    refresh_files(root, id, creds, cfg_text, num)
228    commit_active(root, refresh_alias_seq(seq, alias, num), num, ts)
229    SIo.log(root, "INFO", "Updated credentials for account " ++ num ++ ": " ++ id_email(id))
230    return refresh_msg(id, seq, num)
231
232def account_record(+email: String, +uuid: String, +org: String, +org_name: String, +ts: String, +alias: String) -> Json.Json:
233  SPlan.with_alias(alias, Json.JObj{Json.JField{"email", SSeq.jstr(email), Json.JField{"uuid", SSeq.jstr(uuid),
234    Json.JField{"organizationUuid", SSeq.jstr(org), Json.JField{"organizationName", SSeq.jstr(org_name), Json.JField{"added", SSeq.jstr(ts), Json.JNil{}}}}}}})
235
236def place_seq(+s2: Json.Json, +id: Ident, +num: String, +ts: String, +alias: String, +seq: Json.Json, +migrate: Maybe<&2, String>) -> Json.Json:
237  SSeq.put_account(s2, num, account_record(id_email(id), id_uuid(id), id_org(id), id_org_name(id), ts, carried_alias(alias, seq, num, migrate, id)))
238
239def place_log(+root: String, +id: Ident, +num: String) -> Act.of(Unit):
240  SIo.log(root, "INFO", "Added account " ++ num ++ ": " ++ id_email(id) ++ " (org: " ++ SBase.sel.s(String.is_empty(id_org(id)), "personal", id_org(id)) ++ ")")
241
242def place_msg(+id: Ident, +migrate: Maybe<&2, String>, +num: String) -> String:
243  SOps.moved_line(migrate, "", num) ++ "Added Account " ++ num ++ ": " ++ id_email(id) ++ " [" ++ SBase.sel.s(String.is_empty(id_org_name(id)), "personal", id_org_name(id)) ++ "]"
244
245def current_place2(+root: String, +id: Ident, +creds: String, +cfg_text: String, +alias: String, +ts: String, +seq: Json.Json, +num: String, +displace: Maybe<&2, String>, +migrate: Maybe<&2, String>) -> Act.of(String):
246  do ActM<String>:
247    alias_conflict(seq, alias, num)
248    s2 : Json.Json <- SIo.drop_pair(root, seq, displace, migrate, ts)
249    refresh_files(root, id, creds, cfg_text, num)
250    commit_active(root, place_seq(s2, id, num, ts, alias, seq, migrate), num, ts)
251    place_log(root, id, num)
252    return place_msg(id, migrate, num)
253
254def current_place(+root: String, +id: Ident, +creds: String, +cfg_text: String, +alias: String, +ts: String, +seq: Json.Json, +p: SPlan.SlotPlan) -> Act.of(String):
255  match p:
256    case SPlan.SPErr{code, msg}:
257      Act.fail(String, code, msg)
258    case SPlan.SPOk{num, displace, migrate}:
259      current_place2(root, id, creds, cfg_text, alias, ts, seq, num, displace, migrate)
260
261def current_branch2(+refresh: Maybe<&2, String>, +root: String, +id: Ident, +creds: String, +cfg_text: String, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
262  match refresh:
263    case Some{num}:
264      current_refresh(root, id, creds, cfg_text, alias, ts, seq, num)
265    case None{}:
266      current_place(root, id, creds, cfg_text, alias, ts, seq, SPlan.plan_slot(slot0, seq, id_email(id), id_org(id), assume_yes))
267
268def refresh_for(+slot0: Maybe<&2, U32>, +seq: Json.Json, +id: Ident) -> Maybe<&2, String>:
269  match slot0:
270    case Some{n}:
271      None{}
272    case None{}:
273      SSeq.find_slot(seq, id_email(id), id_org(id))
274
275def current_branch(+root: String, +id: Ident, +creds: String, +cfg_text: String, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json) -> Act.of(String):
276  current_branch2(refresh_for(slot0, seq, id), root, id, creds, cfg_text, alias, slot0, assume_yes, ts, seq)
277
278def creds_checked(+ctx: SBase.Ctx, +cfg: Json.Json) -> Act.of(String):
279  do ActM<String>:
280    +creds : String <- capture(ctx, cfg)
281    Act.guard(SBase.nonempty(creds), 3, "No credentials found for current account")
282    Act.guard(SBase.not_(SBase.looks_like_api_key(creds)), 2, api_key_msg())
283    return creds
284
285def notice_for(+creds: String, +cfg: Json.Json, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
286  do ActM<String>:
287    now : U32 <- Act.lift(U32, Clock.now_s())
288    verify_plan(SPlan.plan_for(creds, now), ident_of(cfg), fetch)
289
290def current4(+root: String, +ctx: SBase.Ctx, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json, +cfg_text: String, +cfg: Json.Json, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
291  do ActM<String>:
292    +creds : String <- creds_checked(ctx, cfg)
293    notice : String <- notice_for(creds, cfg, fetch)
294    msg : String <- current_branch(root, ident_of(cfg), creds, cfg_text, alias, slot0, assume_yes, ts, seq)
295    return notice ++ msg
296
297def no_login() -> String:
298  "No active Claude account found. Please log in first."
299
300def current3c(+none: Bool, +root: String, +ctx: SBase.Ctx, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json, +cfg_text: String, +cfg: Json.Json, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
301  match none:
302    case True{}:
303      Act.fail(String, 1, no_login())
304    case False{}:
305      current4(root, ctx, alias, slot0, assume_yes, ts, seq, cfg_text, cfg, fetch)
306
307def current3b(+root: String, +ctx: SBase.Ctx, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, +ts: String, +seq: Json.Json, +cfg_text: String, parsed: Result<&2, &2, String, Json.Json>, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
308  match parsed:
309    case Fail{e}:
310      Act.fail(String, 1, no_login())
311    case Done{+cfg}:
312      current3c(String.is_empty(id_email(ident_of(cfg))), root, ctx, alias, slot0, assume_yes, ts, seq, cfg_text, cfg, fetch)
313
314def read_config_text2(r: Fs.Res(String)) -> Act.of(String):
315  match r:
316    case Fail{e}:
317      Act.fail(String, 1, no_login())
318    case Done{t}:
319      Act.pure(String, t)
320
321def read_config_text(+ctx: SBase.Ctx) -> Act.of(String):
322  IO.bind(Fs.Res(String), Fs.Res(String), Fs.read_file(SBase.global_config_path(ctx)), r => read_config_text2(r))
323
324def current3(+root: String, +ctx: SBase.Ctx, +alias: String, +slot0: Maybe<&2, U32>, +assume_yes: Bool, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
325  do ActM<String>:
326    SIo.setup_dirs(root)
327    +ts : String <- SIo.now_iso()
328    seq : Json.Json <- SIo.ensure_seq(root, ts)
329    +cfg_text : String <- read_config_text(ctx)
330    current3b(root, ctx, alias, slot0, assume_yes, ts, seq, cfg_text, Json.parse(cfg_text), fetch)
331
332# Adopt the live login (~/.claude.json oauthAccount + .credentials.json) into a
333# slot. The credential's owner is looked up with `fetch` (profile_http in
334# production, a stub in tests); an owner that disagrees with the config's
335# (email, organization) is refused with the reference's wording and nothing is
336# written. An unresolvable owner registers with a Notice line, as the
337# reference does. An occupied slot needs `assume_yes` (see add_token).
338def add_current(+ctx: SBase.Ctx, +alias: Maybe<&2, String>, +slot0: Maybe<&2, U32>, +assume_yes: Bool, fetch: String -> IO(SPlan.Prof)) -> Act.of(String):
339  do ActM<String>:
340    alias_check(alias)
341    Lock.with_lock(String, SBase.lock_path(SBase.root_of(ctx)), 10000, current3(SBase.root_of(ctx), ctx, alias_of(alias), slot0, assume_yes, fetch))
342
343# ---- from the environment ------------------------------------------------------------------
344
345def env_or(r: Result<&1, &1, U32 & String, String>) -> String:
346  match r:
347    case Fail{e}:
348      ""
349    case Done{s}:
350      s
351
352def ctx_with(+home: String, r: Result<&1, &1, U32 & String, String>) -> IO(SBase.Ctx):
353  IO.pure(SBase.Ctx, SBase.ctx_of(home, env_or(r)))
354
355# Ctx from $HOME and $CLAUDE_CONFIG_DIR.
356def ctx_env() -> IO(SBase.Ctx):
357  IO.bind(String, SBase.Ctx, IO.try(String, IO.get_env("HOME")),
358    h => IO.bind(Result<&1, &1, U32 & String, String>, SBase.Ctx, IO.get_env("CLAUDE_CONFIG_DIR"), r => ctx_with(h, r)))