Gleam CLI client for Tangled (clone of aly.codes/tg)
1//// CLI command tree for gtg (Tangled client).
2
3import gleam/io
4import gleam/json
5import gleam/list
6import gleam/option.{type Option, None, Some}
7import gleam/result
8import gleam/string
9import gtg/app/service
10import gtg/app/types
11import gtg/config
12import gtg/knot/client as knot
13import gtg/output
14import gtg/sys
15import shellout
16import simplifile
17
18pub fn run(args: List(String)) -> Int {
19 let flags = config.parse_flags(args)
20 let settings = config.load(flags)
21 let cmd_args = config.strip_global_flags(args)
22 let svc = service.new(settings.appview, settings.account)
23 case dispatch(svc, settings, cmd_args) {
24 Ok(Nil) -> 0
25 Error(msg) -> {
26 io.println_error(msg)
27 1
28 }
29 }
30}
31
32fn dispatch(
33 svc: service.Service,
34 settings: config.Settings,
35 args: List(String),
36) -> Result(Nil, String) {
37 case args {
38 [] | ["-h"] | ["--help"] | ["help"] -> {
39 print_root_help()
40 Ok(Nil)
41 }
42 ["auth", ..rest] -> cmd_auth(svc, settings, rest)
43 ["repo", ..rest] -> cmd_repo(svc, settings, rest)
44 ["issue", ..rest] -> cmd_issue(svc, settings, rest)
45 ["pr", ..rest] -> cmd_pr(svc, settings, rest)
46 ["string", ..rest] -> cmd_string(svc, settings, rest)
47 ["ssh-key", ..rest] -> cmd_ssh_key(svc, settings, rest)
48 ["browse", ..rest] -> cmd_browse(svc, rest)
49 ["api", ..rest] -> cmd_api(svc, rest)
50 ["completion", ..] -> {
51 io.println("# gtg completion not yet implemented")
52 Ok(Nil)
53 }
54 [unknown, ..] -> Error("unknown command: " <> unknown)
55 }
56}
57
58fn map_err(r: Result(a, service.AppError)) -> Result(a, String) {
59 result.map_error(r, service.error_message)
60}
61
62// ---------- auth ----------
63
64fn cmd_auth(
65 svc: service.Service,
66 settings: config.Settings,
67 args: List(String),
68) -> Result(Nil, String) {
69 case args {
70 [] | ["-h"] | ["--help"] -> {
71 io.println(
72 "Manage authentication\n\nUsage:\n gtg auth <command>\n\nCommands:\n login Log in via OAuth (browser) or app password\n logout Log out\n status Show auth status\n token Print access token\n list List accounts\n switch Switch active account\n\nLogin:\n gtg auth login <handle> OAuth in browser\n gtg auth login <handle> <app-password> headless app password",
73 )
74 Ok(Nil)
75 }
76 ["login", handle] -> do_oauth_login(handle)
77 ["login", handle, "--password-stdin"] -> {
78 case sys.env("GTG_PASSWORD") {
79 Ok(pw) -> do_login(handle, pw)
80 Error(_) ->
81 Error(
82 "pass app password as argument, or set GTG_PASSWORD for --password-stdin in this build",
83 )
84 }
85 }
86 ["login", handle, password] -> do_login(handle, password)
87 ["logout", ..rest] -> {
88 let all = list.contains(rest, "--all")
89 use r <- result.try(map_err(service.logout(svc, all)))
90 case settings.json {
91 True ->
92 output.print_json(
93 json.object([#("wasLoggedIn", json.bool(r.was_logged_in))]),
94 )
95 False ->
96 case r.was_logged_in {
97 True -> io.println("Logged out.")
98 False -> io.println("Not logged in.")
99 }
100 }
101 Ok(Nil)
102 }
103 ["status"] -> {
104 use r <- result.try(map_err(service.auth_status(svc)))
105 case settings.json {
106 True -> output.print_json(types.auth_status_json(r))
107 False ->
108 case r.authenticated {
109 False -> io.println("Not logged in.")
110 True ->
111 case r.status {
112 "active" -> io.println("Logged in as " <> r.handle)
113 "expired" ->
114 io.println(
115 "Session expired. Run \"gtg auth login\" to re-authenticate.",
116 )
117 _ -> io.println("Unable to verify session (network error).")
118 }
119 }
120 }
121 Ok(Nil)
122 }
123 ["token"] -> {
124 use tok <- result.try(map_err(service.access_token(svc)))
125 io.println(tok)
126 Ok(Nil)
127 }
128 ["list"] -> {
129 use items <- result.try(map_err(service.auth_accounts(svc)))
130 case settings.json {
131 True ->
132 output.print_json(
133 json.preprocessed_array(list.map(items, types.auth_account_json)),
134 )
135 False ->
136 case items {
137 [] -> io.println("No accounts.")
138 _ ->
139 list.each(items, fn(item) {
140 let marker = case item.active {
141 True -> "*"
142 False -> " "
143 }
144 io.println(
145 marker
146 <> " "
147 <> item.handle
148 <> " "
149 <> item.did
150 <> " "
151 <> item.method,
152 )
153 })
154 }
155 }
156 Ok(Nil)
157 }
158 ["switch", selector] -> {
159 use r <- result.try(map_err(service.switch_account(selector)))
160 case settings.json {
161 True -> output.print_json(types.auth_account_json(r))
162 False -> io.println("Switched to " <> r.handle <> " (" <> r.did <> ")")
163 }
164 Ok(Nil)
165 }
166 _ -> Error("usage: gtg auth <login|logout|status|token|list|switch>")
167 }
168}
169
170fn do_login(handle: String, password: String) -> Result(Nil, String) {
171 use did <- result.try(map_err(service.login_with_password(handle, password)))
172 io.println("Logged in as " <> did)
173 Ok(Nil)
174}
175
176fn do_oauth_login(handle: String) -> Result(Nil, String) {
177 use did <- result.try(map_err(service.login_with_oauth(handle)))
178 io.println("Logged in as " <> did)
179 Ok(Nil)
180}
181
182// ---------- repo ----------
183
184fn cmd_repo(
185 svc: service.Service,
186 settings: config.Settings,
187 args: List(String),
188) -> Result(Nil, String) {
189 case args {
190 [] | ["-h"] | ["--help"] -> {
191 io.println(
192 "Manage repositories\n\nCommands:\n list [handle]\n view <handle/repo>\n clone <repo|handle/repo> [dir]\n create <name> [--description ...] [--knot ...] [--clone] [--push path]\n delete <handle/repo>\n set-default-branch <branch> [--repo handle/repo]\n edit [--description ...] [--repo handle/repo]",
193 )
194 Ok(Nil)
195 }
196 ["list"] -> repo_list(svc, settings, "")
197 ["list", handle] -> repo_list(svc, settings, handle)
198 ["view", target] -> {
199 use t <- result.try(map_err(service.parse_target(target)))
200 use item <- result.try(map_err(service.view_repo(svc, t)))
201 case settings.json {
202 True -> output.print_json(types.repo_item_json(item))
203 False -> {
204 io.println("Name: " <> item.name)
205 io.println("Description: " <> item.description)
206 io.println("URI: " <> item.uri)
207 io.println("Knot: " <> item.knot)
208 io.println("Created: " <> item.created_at)
209 case item.repo_did {
210 "" -> Nil
211 d -> io.println("Repo DID: " <> d)
212 }
213 }
214 }
215 Ok(Nil)
216 }
217 ["clone", arg] -> repo_clone(svc, settings, arg, None)
218 ["clone", arg, dest] -> repo_clone(svc, settings, arg, Some(dest))
219 ["create", name, ..flags] -> repo_create(svc, settings, name, flags)
220 ["delete", target] -> {
221 use t <- result.try(map_err(service.parse_target(target)))
222 use r <- result.try(map_err(service.delete_repo(svc, t)))
223 case settings.json {
224 True -> output.print_json(json.object([#("uri", json.string(r.uri))]))
225 False -> io.println("Deleted " <> r.uri)
226 }
227 Ok(Nil)
228 }
229 ["set-default-branch", branch, ..rest] -> {
230 let repo_flag = flag_value(rest, "--repo", "-R")
231 use t <- result.try(map_err(service.resolve_target_flag(repo_flag)))
232 use r <- result.try(map_err(service.set_default_branch(svc, t, branch)))
233 case settings.json {
234 True ->
235 output.print_json(
236 json.object([
237 #("uri", json.string(r.uri)),
238 #("branch", json.string(r.branch)),
239 ]),
240 )
241 False -> io.println("Set default branch to " <> r.branch)
242 }
243 Ok(Nil)
244 }
245 ["edit", ..rest] -> {
246 let repo_flag = flag_value(rest, "--repo", "-R")
247 let desc = flag_value(rest, "--description", "")
248 use t <- result.try(map_err(service.resolve_target_flag(repo_flag)))
249 let description = case desc {
250 "" -> None
251 d -> Some(d)
252 }
253 use r <- result.try(map_err(service.edit_repo(svc, t, description)))
254 case settings.json {
255 True ->
256 output.print_json(
257 json.object([
258 #("uri", json.string(r.uri)),
259 #("description", json.string(r.description)),
260 ]),
261 )
262 False -> io.println("Updated " <> r.uri)
263 }
264 Ok(Nil)
265 }
266 _ -> Error("usage: gtg repo <list|view|clone|create|delete|…>")
267 }
268}
269
270fn repo_list(
271 svc: service.Service,
272 settings: config.Settings,
273 handle: String,
274) -> Result(Nil, String) {
275 use handle <- result.try(map_err(service.handle_or_self(svc, handle)))
276 use items <- result.try(map_err(service.list_repos(svc, handle)))
277 case settings.json {
278 True ->
279 output.print_json(
280 json.preprocessed_array(list.map(items, types.repo_item_json)),
281 )
282 False -> output.print_repos(items)
283 }
284 Ok(Nil)
285}
286
287fn repo_clone(
288 svc: service.Service,
289 settings: config.Settings,
290 arg: String,
291 dest: Option(String),
292) -> Result(Nil, String) {
293 use t <- result.try(case string.contains(arg, "/") {
294 True -> map_err(service.parse_target(arg))
295 False -> {
296 use handle <- result.try(map_err(service.handle_or_self(svc, "")))
297 Ok(types.Target(handle:, repo: arg))
298 }
299 })
300 let destination = case dest {
301 Some(d) -> d
302 None -> t.repo
303 }
304 use r <- result.try(
305 map_err(service.clone_repo(svc, t.handle, t.repo, destination)),
306 )
307 case settings.json {
308 True ->
309 output.print_json(
310 json.object([
311 #("handle", json.string(r.handle)),
312 #("repo", json.string(r.repo)),
313 #("destination", json.string(r.destination)),
314 ]),
315 )
316 False ->
317 io.println(
318 "Cloned " <> r.handle <> "/" <> r.repo <> " into " <> r.destination,
319 )
320 }
321 Ok(Nil)
322}
323
324fn repo_create(
325 svc: service.Service,
326 settings: config.Settings,
327 name: String,
328 flags: List(String),
329) -> Result(Nil, String) {
330 let description = flag_value(flags, "--description", "")
331 let knot_host = case flag_value(flags, "--knot", "") {
332 "" -> knot.default_knot
333 k -> k
334 }
335 let do_clone = list.contains(flags, "--clone")
336 let push_path = flag_value(flags, "--push", "")
337 let remote = case flag_value(flags, "--remote", "") {
338 "" -> "origin"
339 r -> r
340 }
341 use r <- result.try(
342 map_err(service.create_repo(
343 svc,
344 name,
345 description,
346 knot_host,
347 do_clone,
348 push_path,
349 remote,
350 )),
351 )
352 case settings.json {
353 True -> output.print_json(types.repo_create_json(r))
354 False -> {
355 io.println("Created repository " <> r.handle <> "/" <> r.name)
356 case r.cloned {
357 True -> io.println("Cloned into " <> r.name)
358 False -> Nil
359 }
360 case r.pushed {
361 True -> io.println("Pushed to " <> r.name)
362 False -> Nil
363 }
364 list.each(r.warnings, fn(w) { io.println_error("warning: " <> w) })
365 }
366 }
367 Ok(Nil)
368}
369
370// ---------- issue ----------
371
372fn cmd_issue(
373 svc: service.Service,
374 settings: config.Settings,
375 args: List(String),
376) -> Result(Nil, String) {
377 case args {
378 [] | ["-h"] | ["--help"] -> {
379 io.println(
380 "Manage issues\n\nCommands:\n list [handle/repo]\n view <rkey> [-R handle/repo]\n create <title> [-b body] [-R handle/repo]\n comment <rkey> [-b body]\n close <rkey>\n reopen <rkey>",
381 )
382 Ok(Nil)
383 }
384 ["list", ..rest] -> {
385 let arg = case rest {
386 [a] -> [a]
387 _ -> []
388 }
389 use t <- result.try(map_err(service.resolve_target(arg)))
390 use items <- result.try(map_err(service.list_issues(svc, t)))
391 case settings.json {
392 True ->
393 output.print_json(
394 json.preprocessed_array(list.map(items, types.item_json)),
395 )
396 False -> output.print_items(items, "No issues found.")
397 }
398 Ok(Nil)
399 }
400 ["view", rkey, ..rest] -> {
401 let repo = flag_value(rest, "--repo", "-R")
402 use t <- result.try(map_err(service.resolve_target_flag(repo)))
403 use v <- result.try(map_err(service.view_issue(svc, t, rkey)))
404 case settings.json {
405 True -> output.print_json(types.view_json(v))
406 False -> {
407 io.println("Title: " <> v.title)
408 io.println("Author: " <> v.author.handle)
409 io.println("Created: " <> v.created_at)
410 case v.body {
411 "" -> Nil
412 b -> {
413 io.println("")
414 io.println(b)
415 }
416 }
417 }
418 }
419 Ok(Nil)
420 }
421 ["create", title, ..rest] -> {
422 let body = flag_value(rest, "--body", "-b")
423 let body = case flag_value(rest, "--body-file", "-F") {
424 "" -> body
425 path ->
426 case simplifile.read(path) {
427 Ok(c) -> c
428 Error(_) -> body
429 }
430 }
431 let repo = flag_value(rest, "--repo", "-R")
432 use t <- result.try(map_err(service.resolve_target_flag(repo)))
433 use r <- result.try(map_err(service.create_issue(svc, t, title, body)))
434 case settings.json {
435 True -> output.print_json(types.created_json(r))
436 False -> io.println("Created issue " <> r.uri)
437 }
438 Ok(Nil)
439 }
440 ["comment", rkey, ..rest] -> {
441 let body = flag_value(rest, "--body", "-b")
442 let repo = flag_value(rest, "--repo", "-R")
443 use t <- result.try(map_err(service.resolve_target_flag(repo)))
444 use r <- result.try(map_err(service.comment_issue(svc, t, rkey, body)))
445 case settings.json {
446 True -> output.print_json(types.created_json(r))
447 False -> io.println("Created comment " <> r.uri)
448 }
449 Ok(Nil)
450 }
451 ["close", rkey, ..rest] -> issue_state(svc, settings, rkey, rest, "closed")
452 ["reopen", rkey, ..rest] -> issue_state(svc, settings, rkey, rest, "open")
453 _ -> Error("usage: gtg issue <list|view|create|comment|close|reopen>")
454 }
455}
456
457fn issue_state(
458 svc: service.Service,
459 settings: config.Settings,
460 rkey: String,
461 rest: List(String),
462 state: String,
463) -> Result(Nil, String) {
464 let repo = flag_value(rest, "--repo", "-R")
465 use t <- result.try(map_err(service.resolve_target_flag(repo)))
466 use r <- result.try(map_err(service.set_issue_state(svc, t, rkey, state)))
467 case settings.json {
468 True -> output.print_json(types.state_json(r))
469 False -> io.println("Issue " <> r.rkey <> " is now " <> r.state)
470 }
471 Ok(Nil)
472}
473
474// ---------- pr ----------
475
476fn cmd_pr(
477 svc: service.Service,
478 settings: config.Settings,
479 args: List(String),
480) -> Result(Nil, String) {
481 case args {
482 [] | ["-h"] | ["--help"] -> {
483 io.println(
484 "Manage pull requests\n\nCommands:\n list [handle/repo]\n view <rkey>\n create --title …\n comment <rkey> -b body\n diff <rkey>\n checkout <rkey>\n close|reopen|merge <rkey>",
485 )
486 Ok(Nil)
487 }
488 ["list", ..rest] -> {
489 let arg = case rest {
490 [a] -> [a]
491 _ -> []
492 }
493 use t <- result.try(map_err(service.resolve_target(arg)))
494 use items <- result.try(map_err(service.list_pulls(svc, t)))
495 case settings.json {
496 True ->
497 output.print_json(
498 json.preprocessed_array(list.map(items, types.item_json)),
499 )
500 False -> output.print_items(items, "No pull requests found.")
501 }
502 Ok(Nil)
503 }
504 ["view", rkey, ..rest] -> {
505 let repo = flag_value(rest, "--repo", "-R")
506 use t <- result.try(map_err(service.resolve_target_flag(repo)))
507 use v <- result.try(map_err(service.view_pull(svc, t, rkey)))
508 case settings.json {
509 True -> output.print_json(types.view_json(v))
510 False -> {
511 io.println("Title: " <> v.title)
512 io.println("Author: " <> v.author.handle)
513 io.println("Created: " <> v.created_at)
514 io.println(
515 "Branch: " <> v.source_branch <> " → " <> v.target_branch,
516 )
517 case v.body {
518 "" -> Nil
519 b -> {
520 io.println("")
521 io.println(b)
522 }
523 }
524 }
525 }
526 Ok(Nil)
527 }
528 ["create", ..rest] -> {
529 let title = flag_value(rest, "--title", "-t")
530 case title {
531 "" -> Error("--title is required")
532 title -> {
533 let body = flag_value(rest, "--body", "-b")
534 let base = flag_value(rest, "--base", "-B")
535 let head = flag_value(rest, "--head", "-H")
536 let repo = flag_value(rest, "--repo", "-R")
537 let source = flag_value(rest, "--source-repo", "")
538 use t <- result.try(map_err(service.resolve_target_flag(repo)))
539 use source_t <- result.try(case source {
540 "" -> Ok(None)
541 s ->
542 map_err(service.parse_target(s))
543 |> result.map(Some)
544 })
545 use dir <- result.try(
546 sys.get_cwd() |> result.map_error(fn(e) { e }),
547 )
548 use r <- result.try(
549 map_err(service.create_pull(
550 svc,
551 t,
552 title,
553 body,
554 base,
555 head,
556 dir,
557 source_t,
558 )),
559 )
560 case settings.json {
561 True -> output.print_json(types.pr_create_json(r))
562 False ->
563 io.println(
564 "Created pull request "
565 <> r.uri
566 <> " ("
567 <> r.head
568 <> " -> "
569 <> r.base
570 <> ")",
571 )
572 }
573 Ok(Nil)
574 }
575 }
576 }
577 ["comment", rkey, ..rest] -> {
578 let body = flag_value(rest, "--body", "-b")
579 let repo = flag_value(rest, "--repo", "-R")
580 use t <- result.try(map_err(service.resolve_target_flag(repo)))
581 use r <- result.try(map_err(service.comment_pull(svc, t, rkey, body)))
582 case settings.json {
583 True -> output.print_json(types.created_json(r))
584 False -> io.println("Created comment " <> r.uri)
585 }
586 Ok(Nil)
587 }
588 ["diff", rkey, ..rest] -> {
589 let repo = flag_value(rest, "--repo", "-R")
590 use t <- result.try(map_err(service.resolve_target_flag(repo)))
591 use patch <- result.try(map_err(service.pull_diff(svc, t, rkey)))
592 io.println(patch)
593 Ok(Nil)
594 }
595 ["checkout", rkey, ..rest] -> {
596 let repo = flag_value(rest, "--repo", "-R")
597 use t <- result.try(map_err(service.resolve_target_flag(repo)))
598 use r <- result.try(map_err(service.checkout_pull(svc, t, rkey)))
599 case settings.json {
600 True ->
601 output.print_json(
602 json.object([
603 #("rkey", json.string(r.rkey)),
604 #("branch", json.string(r.branch)),
605 ]),
606 )
607 False -> io.println("Checked out " <> r.branch)
608 }
609 Ok(Nil)
610 }
611 ["close", rkey, ..rest] -> pr_state(svc, settings, rkey, rest, "closed")
612 ["reopen", rkey, ..rest] -> pr_state(svc, settings, rkey, rest, "open")
613 ["merge", rkey, ..rest] -> {
614 let repo = flag_value(rest, "--repo", "-R")
615 use t <- result.try(map_err(service.resolve_target_flag(repo)))
616 use r <- result.try(map_err(service.merge_pull(svc, t, rkey)))
617 case settings.json {
618 True -> output.print_json(types.state_json(r))
619 False -> io.println("Merged pull request " <> r.rkey)
620 }
621 Ok(Nil)
622 }
623 _ -> Error("usage: gtg pr <list|view|create|…>")
624 }
625}
626
627fn pr_state(
628 svc: service.Service,
629 settings: config.Settings,
630 rkey: String,
631 rest: List(String),
632 state: String,
633) -> Result(Nil, String) {
634 let repo = flag_value(rest, "--repo", "-R")
635 use t <- result.try(map_err(service.resolve_target_flag(repo)))
636 use r <- result.try(map_err(service.set_pull_state(svc, t, rkey, state)))
637 case settings.json {
638 True -> output.print_json(types.state_json(r))
639 False -> io.println("Pull request " <> r.rkey <> " is now " <> r.state)
640 }
641 Ok(Nil)
642}
643
644// ---------- string ----------
645
646fn cmd_string(
647 svc: service.Service,
648 settings: config.Settings,
649 args: List(String),
650) -> Result(Nil, String) {
651 case args {
652 [] | ["-h"] | ["--help"] -> {
653 io.println(
654 "Manage strings\n\nCommands:\n list [handle]\n view <rkey> [handle]\n create --filename … --contents …\n delete <rkey>",
655 )
656 Ok(Nil)
657 }
658 ["list"] -> string_list(svc, settings, "")
659 ["list", handle] -> string_list(svc, settings, handle)
660 ["view", rkey] -> string_view(svc, settings, "", rkey)
661 ["view", rkey, handle] -> string_view(svc, settings, handle, rkey)
662 ["create", ..rest] -> {
663 let filename = flag_value(rest, "--filename", "")
664 let description = flag_value(rest, "--description", "")
665 let contents = flag_value(rest, "--contents", "")
666 let contents = case flag_value(rest, "--contents-file", "") {
667 "" -> contents
668 path ->
669 case simplifile.read(path) {
670 Ok(c) -> c
671 Error(_) -> contents
672 }
673 }
674 case filename {
675 "" -> Error("--filename is required")
676 filename -> {
677 use r <- result.try(
678 map_err(service.create_string(svc, filename, description, contents)),
679 )
680 case settings.json {
681 True -> output.print_json(types.created_json(r))
682 False -> io.println("Created string " <> r.uri)
683 }
684 Ok(Nil)
685 }
686 }
687 }
688 ["delete", rkey] -> {
689 use r <- result.try(map_err(service.delete_string(svc, rkey)))
690 case settings.json {
691 True -> output.print_json(types.deleted_json(r))
692 False -> io.println("Deleted string " <> r.rkey)
693 }
694 Ok(Nil)
695 }
696 _ -> Error("usage: gtg string <list|view|create|delete>")
697 }
698}
699
700fn string_list(
701 svc: service.Service,
702 settings: config.Settings,
703 handle: String,
704) -> Result(Nil, String) {
705 use handle <- result.try(map_err(service.handle_or_self(svc, handle)))
706 use items <- result.try(map_err(service.list_strings(svc, handle)))
707 case settings.json {
708 True ->
709 output.print_json(
710 json.preprocessed_array(list.map(items, types.string_item_json)),
711 )
712 False -> output.print_strings(items)
713 }
714 Ok(Nil)
715}
716
717fn string_view(
718 svc: service.Service,
719 settings: config.Settings,
720 handle: String,
721 rkey: String,
722) -> Result(Nil, String) {
723 use handle <- result.try(map_err(service.handle_or_self(svc, handle)))
724 use v <- result.try(map_err(service.view_string(svc, handle, rkey)))
725 case settings.json {
726 True -> output.print_json(types.string_view_json(v))
727 False -> {
728 io.println("Filename: " <> v.filename)
729 io.println("Author: " <> v.author.handle)
730 io.println("Created: " <> v.created_at)
731 case v.description {
732 "" -> Nil
733 d -> io.println("Description: " <> d)
734 }
735 io.println("")
736 io.println(v.contents)
737 }
738 }
739 Ok(Nil)
740}
741
742// ---------- ssh-key ----------
743
744fn cmd_ssh_key(
745 svc: service.Service,
746 settings: config.Settings,
747 args: List(String),
748) -> Result(Nil, String) {
749 case args {
750 [] | ["-h"] | ["--help"] -> {
751 io.println(
752 "Manage SSH keys\n\nCommands:\n list [handle]\n add --name … --key …\n delete <rkey>",
753 )
754 Ok(Nil)
755 }
756 ["list"] -> ssh_list(svc, settings, "")
757 ["list", handle] -> ssh_list(svc, settings, handle)
758 ["add", ..rest] -> {
759 let name = flag_value(rest, "--name", "")
760 let key = flag_value(rest, "--key", "")
761 let key = case flag_value(rest, "--key-file", "") {
762 "" -> key
763 path ->
764 case simplifile.read(path) {
765 Ok(c) -> string.trim(c)
766 Error(_) -> key
767 }
768 }
769 case name == "" || key == "" {
770 True -> Error("--name and --key (or --key-file) are required")
771 False -> {
772 use r <- result.try(map_err(service.add_ssh_key(svc, name, key)))
773 case settings.json {
774 True ->
775 output.print_json(
776 json.object([
777 #("name", json.string(r.name)),
778 #("uri", json.string(r.uri)),
779 ]),
780 )
781 False -> io.println("Added SSH key " <> r.name <> " (" <> r.uri <> ")")
782 }
783 Ok(Nil)
784 }
785 }
786 }
787 ["delete", rkey] -> {
788 use r <- result.try(map_err(service.delete_ssh_key(svc, rkey)))
789 case settings.json {
790 True -> output.print_json(types.deleted_json(r))
791 False -> io.println("Deleted SSH key " <> r.rkey)
792 }
793 Ok(Nil)
794 }
795 _ -> Error("usage: gtg ssh-key <list|add|delete>")
796 }
797}
798
799fn ssh_list(
800 svc: service.Service,
801 settings: config.Settings,
802 handle: String,
803) -> Result(Nil, String) {
804 use handle <- result.try(map_err(service.handle_or_self(svc, handle)))
805 use items <- result.try(map_err(service.list_ssh_keys(svc, handle)))
806 case settings.json {
807 True ->
808 output.print_json(
809 json.preprocessed_array(list.map(items, types.ssh_key_json)),
810 )
811 False -> output.print_ssh_keys(items)
812 }
813 Ok(Nil)
814}
815
816// ---------- browse / api ----------
817
818fn cmd_browse(_svc: service.Service, args: List(String)) -> Result(Nil, String) {
819 use t <- result.try(map_err(service.resolve_target(args)))
820 let url = "https://tangled.org/" <> t.handle <> "/" <> t.repo
821 case shellout.command(run: "xdg-open", with: [url], in: ".", opt: []) {
822 Ok(_) -> Ok(Nil)
823 Error(_) -> {
824 io.println(url)
825 Ok(Nil)
826 }
827 }
828}
829
830fn cmd_api(svc: service.Service, args: List(String)) -> Result(Nil, String) {
831 case args {
832 [nsid, ..rest] -> {
833 let method = case flag_value(rest, "--method", "-X") {
834 "" -> "GET"
835 m -> m
836 }
837 let fields = collect_fields(rest, [])
838 use #(status, body) <- result.try(
839 map_err(service.call_api(svc, nsid, method, fields)),
840 )
841 case status >= 200 && status < 300 {
842 True -> {
843 io.println(body)
844 Ok(Nil)
845 }
846 False -> Error("HTTP " <> sys.int_to_string(status) <> ": " <> body)
847 }
848 }
849 [] -> Error("usage: gtg api <nsid> [-X GET|POST] [-f key=value]…")
850 }
851}
852
853fn collect_fields(
854 args: List(String),
855 acc: List(#(String, String)),
856) -> List(#(String, String)) {
857 case args {
858 [] -> list.reverse(acc)
859 ["-f", kv, ..rest] | ["--field", kv, ..rest] -> {
860 case string.split_once(kv, "=") {
861 Ok(pair) -> collect_fields(rest, [pair, ..acc])
862 Error(_) -> collect_fields(rest, acc)
863 }
864 }
865 [_, ..rest] -> collect_fields(rest, acc)
866 }
867}
868
869// ---------- flag helpers ----------
870
871/// Get flag value for long and optional short form.
872fn flag_value(args: List(String), long: String, short: String) -> String {
873 flag_value_loop(args, long, short)
874}
875
876fn flag_value_loop(args: List(String), long: String, short: String) -> String {
877 case args {
878 [] -> ""
879 [arg, ..rest] -> {
880 case arg == long || { short != "" && arg == short } {
881 True ->
882 case rest {
883 [v, ..] -> v
884 [] -> ""
885 }
886 False ->
887 case string.starts_with(arg, long <> "=") {
888 True -> string.drop_start(arg, string.length(long) + 1)
889 False -> flag_value_loop(rest, long, short)
890 }
891 }
892 }
893 }
894}
895
896fn print_root_help() -> Nil {
897 io.println(
898 string.join(
899 [
900 "A CLI for Tangled (Gleam port of tg)",
901 "",
902 "Usage:",
903 " gtg [command]",
904 "",
905 "Available Commands:",
906 " api Call an authenticated XRPC endpoint",
907 " auth Manage authentication",
908 " browse Open a Tangled repository in a browser",
909 " issue Manage issues on a Tangled repository",
910 " pr Manage pull requests on a Tangled repository",
911 " repo Manage repositories on Tangled",
912 " ssh-key Manage SSH keys on Tangled",
913 " string Manage strings on Tangled",
914 "",
915 "Flags:",
916 " --appview string Appview host URL (default \"https://bobbin.klbr.net\")",
917 " --config string Path to config file",
918 " --account string Account handle or DID",
919 " --json Output in JSON format",
920 " -h, --help Help",
921 ],
922 "\n",
923 ),
924 )
925}