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
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
25
26
27
28
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
48
49
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
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
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
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
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
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
333
334
335
336
337
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
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
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)))