OCaml CLI and library to the Karakeep bookmarking app

Add XDG-compliant config storage and profile-based auth to okarakeep

Modernize the CLI to use proper config management:
- Store credentials in ~/.config/karakeep/profiles/<name>/credentials.toml
- Add auth commands: login, logout, status, profile list/switch/current
- Support multiple profiles for different Karakeep instances
- Maintain backward compatibility with legacy .karakeep-api file and env vars

New modules: karakeep_config (TOML config storage), karakeep_auth_cmd (auth CLI)
Dependencies: xdge, tomlt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+745 -152
+117 -102
bin/main.ml
··· 1 1 open Cmdliner 2 2 open Karakeep_cmd 3 3 4 + (* Helper to run with Eio env *) 5 + let run_with_client config_opt f = 6 + Eio_main.run @@ fun env -> 7 + Eio.Switch.run @@ fun sw -> 8 + with_client ~env ~sw config_opt f 9 + 4 10 (* Bookmark commands *) 5 11 6 12 let list_bookmarks_cmd = 7 - let run config fmt () limit cursor archived favourited include_content = 13 + let run config_opt fmt () limit cursor archived favourited include_content = 8 14 handle_errors (fun () -> 9 - with_client config (fun client -> 15 + run_with_client config_opt (fun client -> 10 16 let result = 11 17 Karakeep.fetch_bookmarks client ?limit ?cursor ~include_content 12 18 ?archived ?favourited () ··· 21 27 let info = Cmd.info "list" ~doc in 22 28 Cmd.v info 23 29 Term.( 24 - const run $ config_term $ output_format_term $ setup_logging $ limit_term 30 + const run $ config_opt_term $ output_format_term $ setup_logging $ limit_term 25 31 $ cursor_term $ archived_term $ favourited_term $ include_content_term) 26 32 27 33 let list_all_bookmarks_cmd = 28 - let run config fmt () archived favourited = 34 + let run config_opt fmt () archived favourited = 29 35 handle_errors (fun () -> 30 - with_client config (fun client -> 36 + run_with_client config_opt (fun client -> 31 37 let bookmarks = 32 38 Karakeep.fetch_all_bookmarks client ?archived ?favourited () 33 39 in ··· 38 44 let info = Cmd.info "list-all" ~doc in 39 45 Cmd.v info 40 46 Term.( 41 - const run $ config_term $ output_format_term $ setup_logging 47 + const run $ config_opt_term $ output_format_term $ setup_logging 42 48 $ archived_term $ favourited_term) 43 49 44 50 let get_bookmark_cmd = 45 - let run config fmt () bookmark_id = 51 + let run config_opt fmt () bookmark_id = 46 52 handle_errors (fun () -> 47 - with_client config (fun client -> 53 + run_with_client config_opt (fun client -> 48 54 let bookmark = Karakeep.fetch_bookmark_details client bookmark_id in 49 55 print_bookmark fmt bookmark); 50 56 0) ··· 52 58 let doc = "Get details of a specific bookmark." in 53 59 let info = Cmd.info "get" ~doc in 54 60 Cmd.v info 55 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 61 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 56 62 57 63 let create_bookmark_cmd = 58 - let run config fmt () url title note summary tags favourited archived = 64 + let run config_opt fmt () url title note summary tags favourited archived = 59 65 handle_errors (fun () -> 60 - with_client config (fun client -> 66 + run_with_client config_opt (fun client -> 61 67 let tags = if tags = [] then None else Some tags in 62 68 let bookmark = 63 69 Karakeep.create_bookmark client ~url ?title ?note ?summary ?tags ··· 78 84 let arch_opt = Term.(const (fun b -> if b then Some true else None) $ arch_term) in 79 85 Cmd.v info 80 86 Term.( 81 - const run $ config_term $ output_format_term $ setup_logging $ url_term 87 + const run $ config_opt_term $ output_format_term $ setup_logging $ url_term 82 88 $ title_term $ note_term $ summary_term $ tags_term $ fav_opt $ arch_opt) 83 89 84 90 let update_bookmark_cmd = 85 - let run config fmt () bookmark_id title note summary = 91 + let run config_opt fmt () bookmark_id title note summary = 86 92 handle_errors (fun () -> 87 - with_client config (fun client -> 93 + run_with_client config_opt (fun client -> 88 94 let bookmark = 89 95 Karakeep.update_bookmark client bookmark_id ?title ?note ?summary () 90 96 in ··· 95 101 let info = Cmd.info "update" ~doc in 96 102 Cmd.v info 97 103 Term.( 98 - const run $ config_term $ output_format_term $ setup_logging 104 + const run $ config_opt_term $ output_format_term $ setup_logging 99 105 $ bookmark_id_term $ title_term $ note_term $ summary_term) 100 106 101 107 let delete_bookmark_cmd = 102 - let run config () bookmark_id = 108 + let run config_opt () bookmark_id = 103 109 handle_errors (fun () -> 104 - with_client config (fun client -> 110 + run_with_client config_opt (fun client -> 105 111 Karakeep.delete_bookmark client bookmark_id; 106 112 Logs.app (fun m -> m "Deleted bookmark %s" bookmark_id)); 107 113 0) 108 114 in 109 115 let doc = "Delete a bookmark." in 110 116 let info = Cmd.info "delete" ~doc in 111 - Cmd.v info Term.(const run $ config_term $ setup_logging $ bookmark_id_term) 117 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ bookmark_id_term) 112 118 113 119 let archive_bookmark_cmd = 114 - let run config fmt () bookmark_id = 120 + let run config_opt fmt () bookmark_id = 115 121 handle_errors (fun () -> 116 - with_client config (fun client -> 122 + run_with_client config_opt (fun client -> 117 123 let bookmark = 118 124 Karakeep.update_bookmark client bookmark_id ~archived:true () 119 125 in ··· 123 129 let doc = "Archive a bookmark." in 124 130 let info = Cmd.info "archive" ~doc in 125 131 Cmd.v info 126 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 132 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 127 133 128 134 let unarchive_bookmark_cmd = 129 - let run config fmt () bookmark_id = 135 + let run config_opt fmt () bookmark_id = 130 136 handle_errors (fun () -> 131 - with_client config (fun client -> 137 + run_with_client config_opt (fun client -> 132 138 let bookmark = 133 139 Karakeep.update_bookmark client bookmark_id ~archived:false () 134 140 in ··· 138 144 let doc = "Unarchive a bookmark." in 139 145 let info = Cmd.info "unarchive" ~doc in 140 146 Cmd.v info 141 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 147 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 142 148 143 149 let favourite_bookmark_cmd = 144 - let run config fmt () bookmark_id = 150 + let run config_opt fmt () bookmark_id = 145 151 handle_errors (fun () -> 146 - with_client config (fun client -> 152 + run_with_client config_opt (fun client -> 147 153 let bookmark = 148 154 Karakeep.update_bookmark client bookmark_id ~favourited:true () 149 155 in ··· 153 159 let doc = "Mark a bookmark as favourite." in 154 160 let info = Cmd.info "fav" ~doc in 155 161 Cmd.v info 156 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 162 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 157 163 158 164 let unfavourite_bookmark_cmd = 159 - let run config fmt () bookmark_id = 165 + let run config_opt fmt () bookmark_id = 160 166 handle_errors (fun () -> 161 - with_client config (fun client -> 167 + run_with_client config_opt (fun client -> 162 168 let bookmark = 163 169 Karakeep.update_bookmark client bookmark_id ~favourited:false () 164 170 in ··· 168 174 let doc = "Remove favourite mark from a bookmark." in 169 175 let info = Cmd.info "unfav" ~doc in 170 176 Cmd.v info 171 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 177 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 172 178 173 179 let summarize_bookmark_cmd = 174 - let run config fmt () bookmark_id = 180 + let run config_opt fmt () bookmark_id = 175 181 handle_errors (fun () -> 176 - with_client config (fun client -> 182 + run_with_client config_opt (fun client -> 177 183 let response = Karakeep.summarize_bookmark client bookmark_id in 178 184 match fmt with 179 185 | Text -> print_endline response.summary ··· 188 194 let doc = "Generate an AI summary for a bookmark." in 189 195 let info = Cmd.info "summarize" ~doc in 190 196 Cmd.v info 191 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 197 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 192 198 193 199 let search_bookmarks_cmd = 194 - let run config fmt () query limit cursor = 200 + let run config_opt fmt () query limit cursor = 195 201 handle_errors (fun () -> 196 - with_client config (fun client -> 202 + run_with_client config_opt (fun client -> 197 203 let result = 198 204 Karakeep.search_bookmarks client ~query ?limit ?cursor () 199 205 in ··· 207 213 let info = Cmd.info "search" ~doc in 208 214 Cmd.v info 209 215 Term.( 210 - const run $ config_term $ output_format_term $ setup_logging 216 + const run $ config_opt_term $ output_format_term $ setup_logging 211 217 $ search_query_term $ limit_term $ cursor_term) 212 218 213 219 let bookmarks_cmd = ··· 232 238 (* Tag commands *) 233 239 234 240 let list_tags_cmd = 235 - let run config fmt () = 241 + let run config_opt fmt () = 236 242 handle_errors (fun () -> 237 - with_client config (fun client -> 243 + run_with_client config_opt (fun client -> 238 244 let tags = Karakeep.fetch_all_tags client in 239 245 print_tags fmt tags); 240 246 0) 241 247 in 242 248 let doc = "List all tags." in 243 249 let info = Cmd.info "list" ~doc in 244 - Cmd.v info Term.(const run $ config_term $ output_format_term $ setup_logging) 250 + Cmd.v info Term.(const run $ config_opt_term $ output_format_term $ setup_logging) 245 251 246 252 let get_tag_cmd = 247 - let run config fmt () tag_id = 253 + let run config_opt fmt () tag_id = 248 254 handle_errors (fun () -> 249 - with_client config (fun client -> 255 + run_with_client config_opt (fun client -> 250 256 let tag = Karakeep.fetch_tag_details client tag_id in 251 257 print_tag fmt tag); 252 258 0) ··· 254 260 let doc = "Get details of a specific tag." in 255 261 let info = Cmd.info "get" ~doc in 256 262 Cmd.v info 257 - Term.(const run $ config_term $ output_format_term $ setup_logging $ tag_id_term) 263 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ tag_id_term) 258 264 259 265 let tag_bookmarks_cmd = 260 - let run config fmt () tag_id limit cursor = 266 + let run config_opt fmt () tag_id limit cursor = 261 267 handle_errors (fun () -> 262 - with_client config (fun client -> 268 + run_with_client config_opt (fun client -> 263 269 let result = 264 270 Karakeep.fetch_bookmarks_with_tag client ?limit ?cursor tag_id 265 271 in ··· 270 276 let info = Cmd.info "bookmarks" ~doc in 271 277 Cmd.v info 272 278 Term.( 273 - const run $ config_term $ output_format_term $ setup_logging $ tag_id_term 279 + const run $ config_opt_term $ output_format_term $ setup_logging $ tag_id_term 274 280 $ limit_term $ cursor_term) 275 281 276 282 let rename_tag_cmd = 277 - let run config fmt () tag_id name = 283 + let run config_opt fmt () tag_id name = 278 284 handle_errors (fun () -> 279 - with_client config (fun client -> 285 + run_with_client config_opt (fun client -> 280 286 let tag = Karakeep.update_tag client ~name tag_id in 281 287 print_tag fmt tag); 282 288 0) ··· 285 291 let info = Cmd.info "rename" ~doc in 286 292 Cmd.v info 287 293 Term.( 288 - const run $ config_term $ output_format_term $ setup_logging $ tag_id_term 294 + const run $ config_opt_term $ output_format_term $ setup_logging $ tag_id_term 289 295 $ name_term) 290 296 291 297 let delete_tag_cmd = 292 - let run config () tag_id = 298 + let run config_opt () tag_id = 293 299 handle_errors (fun () -> 294 - with_client config (fun client -> 300 + run_with_client config_opt (fun client -> 295 301 Karakeep.delete_tag client tag_id; 296 302 Logs.app (fun m -> m "Deleted tag %s" tag_id)); 297 303 0) 298 304 in 299 305 let doc = "Delete a tag." in 300 306 let info = Cmd.info "delete" ~doc in 301 - Cmd.v info Term.(const run $ config_term $ setup_logging $ tag_id_term) 307 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ tag_id_term) 302 308 303 309 let attach_tags_cmd = 304 - let run config () bookmark_id tags = 310 + let run config_opt () bookmark_id tags = 305 311 handle_errors (fun () -> 306 - with_client config (fun client -> 312 + run_with_client config_opt (fun client -> 307 313 let tag_refs = List.map (fun t -> `TagName t) tags in 308 314 let _ = Karakeep.attach_tags client ~tag_refs bookmark_id in 309 315 Logs.app (fun m -> ··· 313 319 let doc = "Attach tags to a bookmark." in 314 320 let info = Cmd.info "attach" ~doc in 315 321 Cmd.v info 316 - Term.(const run $ config_term $ setup_logging $ bookmark_id_term $ tags_term) 322 + Term.(const run $ config_opt_term $ setup_logging $ bookmark_id_term $ tags_term) 317 323 318 324 let detach_tags_cmd = 319 - let run config () bookmark_id tags = 325 + let run config_opt () bookmark_id tags = 320 326 handle_errors (fun () -> 321 - with_client config (fun client -> 327 + run_with_client config_opt (fun client -> 322 328 let tag_refs = List.map (fun t -> `TagName t) tags in 323 329 let _ = Karakeep.detach_tags client ~tag_refs bookmark_id in 324 330 Logs.app (fun m -> ··· 328 334 let doc = "Detach tags from a bookmark." in 329 335 let info = Cmd.info "detach" ~doc in 330 336 Cmd.v info 331 - Term.(const run $ config_term $ setup_logging $ bookmark_id_term $ tags_term) 337 + Term.(const run $ config_opt_term $ setup_logging $ bookmark_id_term $ tags_term) 332 338 333 339 let tags_cmd = 334 340 let doc = "Tag operations." in ··· 347 353 (* List commands *) 348 354 349 355 let list_lists_cmd = 350 - let run config fmt () = 356 + let run config_opt fmt () = 351 357 handle_errors (fun () -> 352 - with_client config (fun client -> 358 + run_with_client config_opt (fun client -> 353 359 let lists = Karakeep.fetch_all_lists client in 354 360 print_lists fmt lists); 355 361 0) 356 362 in 357 363 let doc = "List all lists." in 358 364 let info = Cmd.info "list" ~doc in 359 - Cmd.v info Term.(const run $ config_term $ output_format_term $ setup_logging) 365 + Cmd.v info Term.(const run $ config_opt_term $ output_format_term $ setup_logging) 360 366 361 367 let get_list_cmd = 362 - let run config fmt () list_id = 368 + let run config_opt fmt () list_id = 363 369 handle_errors (fun () -> 364 - with_client config (fun client -> 370 + run_with_client config_opt (fun client -> 365 371 let lst = Karakeep.fetch_list_details client list_id in 366 372 print_list fmt lst); 367 373 0) ··· 369 375 let doc = "Get details of a specific list." in 370 376 let info = Cmd.info "get" ~doc in 371 377 Cmd.v info 372 - Term.(const run $ config_term $ output_format_term $ setup_logging $ list_id_term) 378 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ list_id_term) 373 379 374 380 let create_list_cmd = 375 - let run config fmt () name icon description parent_id query = 381 + let run config_opt fmt () name icon description parent_id query = 376 382 handle_errors (fun () -> 377 - with_client config (fun client -> 383 + run_with_client config_opt (fun client -> 378 384 let list_type = 379 385 match query with Some _ -> Some Karakeep.Smart | None -> None 380 386 in ··· 389 395 let info = Cmd.info "create" ~doc in 390 396 Cmd.v info 391 397 Term.( 392 - const run $ config_term $ output_format_term $ setup_logging $ name_term 398 + const run $ config_opt_term $ output_format_term $ setup_logging $ name_term 393 399 $ icon_term $ description_term $ parent_id_term $ query_term) 394 400 395 401 let update_list_cmd = 396 - let run config fmt () list_id name icon description query = 402 + let run config_opt fmt () list_id name icon description query = 397 403 handle_errors (fun () -> 398 - with_client config (fun client -> 404 + run_with_client config_opt (fun client -> 399 405 let lst = 400 406 Karakeep.update_list client ?name ?icon ?description ?query list_id 401 407 in ··· 406 412 let info = Cmd.info "update" ~doc in 407 413 Cmd.v info 408 414 Term.( 409 - const run $ config_term $ output_format_term $ setup_logging $ list_id_term 415 + const run $ config_opt_term $ output_format_term $ setup_logging $ list_id_term 410 416 $ name_opt_term $ icon_opt_term $ description_term $ query_term) 411 417 412 418 let delete_list_cmd = 413 - let run config () list_id = 419 + let run config_opt () list_id = 414 420 handle_errors (fun () -> 415 - with_client config (fun client -> 421 + run_with_client config_opt (fun client -> 416 422 Karakeep.delete_list client list_id; 417 423 Logs.app (fun m -> m "Deleted list %s" list_id)); 418 424 0) 419 425 in 420 426 let doc = "Delete a list." in 421 427 let info = Cmd.info "delete" ~doc in 422 - Cmd.v info Term.(const run $ config_term $ setup_logging $ list_id_term) 428 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ list_id_term) 423 429 424 430 let list_bookmarks_in_list_cmd = 425 - let run config fmt () list_id limit cursor = 431 + let run config_opt fmt () list_id limit cursor = 426 432 handle_errors (fun () -> 427 - with_client config (fun client -> 433 + run_with_client config_opt (fun client -> 428 434 let result = 429 435 Karakeep.fetch_bookmarks_in_list client ?limit ?cursor list_id 430 436 in ··· 435 441 let info = Cmd.info "bookmarks" ~doc in 436 442 Cmd.v info 437 443 Term.( 438 - const run $ config_term $ output_format_term $ setup_logging $ list_id_term 444 + const run $ config_opt_term $ output_format_term $ setup_logging $ list_id_term 439 445 $ limit_term $ cursor_term) 440 446 441 447 let add_to_list_cmd = 442 - let run config () list_id bookmark_id = 448 + let run config_opt () list_id bookmark_id = 443 449 handle_errors (fun () -> 444 - with_client config (fun client -> 450 + run_with_client config_opt (fun client -> 445 451 Karakeep.add_bookmark_to_list client list_id bookmark_id; 446 452 Logs.app (fun m -> 447 453 m "Added bookmark %s to list %s" bookmark_id list_id)); ··· 453 459 let doc = "Bookmark ID to add." in 454 460 Arg.(required & pos 1 (some string) None & info [] ~docv:"BOOKMARK_ID" ~doc) 455 461 in 456 - Cmd.v info Term.(const run $ config_term $ setup_logging $ list_id_term $ bid_term) 462 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ list_id_term $ bid_term) 457 463 458 464 let remove_from_list_cmd = 459 - let run config () list_id bookmark_id = 465 + let run config_opt () list_id bookmark_id = 460 466 handle_errors (fun () -> 461 - with_client config (fun client -> 467 + run_with_client config_opt (fun client -> 462 468 Karakeep.remove_bookmark_from_list client list_id bookmark_id; 463 469 Logs.app (fun m -> 464 470 m "Removed bookmark %s from list %s" bookmark_id list_id)); ··· 470 476 let doc = "Bookmark ID to remove." in 471 477 Arg.(required & pos 1 (some string) None & info [] ~docv:"BOOKMARK_ID" ~doc) 472 478 in 473 - Cmd.v info Term.(const run $ config_term $ setup_logging $ list_id_term $ bid_term) 479 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ list_id_term $ bid_term) 474 480 475 481 let lists_cmd = 476 482 let doc = "List operations." in ··· 490 496 (* Highlight commands *) 491 497 492 498 let list_highlights_cmd = 493 - let run config fmt () limit cursor = 499 + let run config_opt fmt () limit cursor = 494 500 handle_errors (fun () -> 495 - with_client config (fun client -> 501 + run_with_client config_opt (fun client -> 496 502 let result = Karakeep.fetch_all_highlights client ?limit ?cursor () in 497 503 print_highlights fmt result.highlights); 498 504 0) ··· 501 507 let info = Cmd.info "list" ~doc in 502 508 Cmd.v info 503 509 Term.( 504 - const run $ config_term $ output_format_term $ setup_logging $ limit_term 510 + const run $ config_opt_term $ output_format_term $ setup_logging $ limit_term 505 511 $ cursor_term) 506 512 507 513 let get_highlight_cmd = 508 - let run config fmt () highlight_id = 514 + let run config_opt fmt () highlight_id = 509 515 handle_errors (fun () -> 510 - with_client config (fun client -> 516 + run_with_client config_opt (fun client -> 511 517 let highlight = Karakeep.fetch_highlight_details client highlight_id in 512 518 print_highlight fmt highlight); 513 519 0) ··· 515 521 let doc = "Get details of a specific highlight." in 516 522 let info = Cmd.info "get" ~doc in 517 523 Cmd.v info 518 - Term.(const run $ config_term $ output_format_term $ setup_logging $ highlight_id_term) 524 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ highlight_id_term) 519 525 520 526 let bookmark_highlights_cmd = 521 - let run config fmt () bookmark_id = 527 + let run config_opt fmt () bookmark_id = 522 528 handle_errors (fun () -> 523 - with_client config (fun client -> 529 + run_with_client config_opt (fun client -> 524 530 let highlights = Karakeep.fetch_bookmark_highlights client bookmark_id in 525 531 print_highlights fmt highlights); 526 532 0) ··· 528 534 let doc = "List highlights for a bookmark." in 529 535 let info = Cmd.info "for-bookmark" ~doc in 530 536 Cmd.v info 531 - Term.(const run $ config_term $ output_format_term $ setup_logging $ bookmark_id_term) 537 + Term.(const run $ config_opt_term $ output_format_term $ setup_logging $ bookmark_id_term) 532 538 533 539 let delete_highlight_cmd = 534 - let run config () highlight_id = 540 + let run config_opt () highlight_id = 535 541 handle_errors (fun () -> 536 - with_client config (fun client -> 542 + run_with_client config_opt (fun client -> 537 543 Karakeep.delete_highlight client highlight_id; 538 544 Logs.app (fun m -> m "Deleted highlight %s" highlight_id)); 539 545 0) 540 546 in 541 547 let doc = "Delete a highlight." in 542 548 let info = Cmd.info "delete" ~doc in 543 - Cmd.v info Term.(const run $ config_term $ setup_logging $ highlight_id_term) 549 + Cmd.v info Term.(const run $ config_opt_term $ setup_logging $ highlight_id_term) 544 550 545 551 let highlights_cmd = 546 552 let doc = "Highlight operations." in ··· 556 562 (* User commands *) 557 563 558 564 let whoami_cmd = 559 - let run config fmt () = 565 + let run config_opt fmt () = 560 566 handle_errors (fun () -> 561 - with_client config (fun client -> 567 + run_with_client config_opt (fun client -> 562 568 let user = Karakeep.get_current_user client in 563 569 print_user fmt user); 564 570 0) 565 571 in 566 572 let doc = "Show current user info." in 567 573 let info = Cmd.info "whoami" ~doc in 568 - Cmd.v info Term.(const run $ config_term $ output_format_term $ setup_logging) 574 + Cmd.v info Term.(const run $ config_opt_term $ output_format_term $ setup_logging) 569 575 570 576 let stats_cmd = 571 - let run config fmt () = 577 + let run config_opt fmt () = 572 578 handle_errors (fun () -> 573 - with_client config (fun client -> 579 + run_with_client config_opt (fun client -> 574 580 let stats = Karakeep.get_user_stats client in 575 581 print_stats fmt stats); 576 582 0) 577 583 in 578 584 let doc = "Show user statistics." in 579 585 let info = Cmd.info "stats" ~doc in 580 - Cmd.v info Term.(const run $ config_term $ output_format_term $ setup_logging) 586 + Cmd.v info Term.(const run $ config_opt_term $ output_format_term $ setup_logging) 581 587 582 588 (* Main command *) 583 589 ··· 589 595 `P 590 596 "karakeep is a command-line tool for interacting with a Karakeep \ 591 597 bookmark service instance."; 598 + `S "AUTHENTICATION"; 599 + `P "Credentials are stored in XDG-compliant locations:"; 600 + `P " ~/.config/karakeep/profiles/<profile>/credentials.toml"; 601 + `P "Use $(b,karakeep auth login) to configure credentials interactively."; 602 + `P "Multiple profiles are supported for different Karakeep instances."; 592 603 `S Manpage.s_common_options; 593 604 `P "These options are common to all commands."; 605 + `P "$(b,--profile)=$(i,NAME), $(b,-P) $(i,NAME)"; 606 + `Noblank; 607 + `P " Use a specific profile (default: current profile)."; 594 608 `P "$(b,--base-url)=$(i,URL), $(b,-u) $(i,URL)"; 595 609 `Noblank; 596 - `P " Base URL of the Karakeep instance (default: https://hoard.recoil.org)."; 610 + `P " Base URL of the Karakeep instance (overrides profile)."; 597 611 `P "$(b,--api-key)=$(i,KEY), $(b,-k) $(i,KEY)"; 598 612 `Noblank; 599 - `P " API key for authentication."; 613 + `P " API key for authentication (overrides profile)."; 600 614 `P "$(b,--api-key-file)=$(i,FILE)"; 601 615 `Noblank; 602 - `P " File containing the API key (default: .karakeep-api)."; 616 + `P " Legacy: Read API key from file (default: .karakeep-api)."; 603 617 `P "$(b,-v), $(b,--verbose)"; 604 618 `Noblank; 605 619 `P " Increase verbosity. Repeatable."; ··· 625 639 let info = Cmd.info "karakeep" ~version:"0.1.0" ~doc ~man in 626 640 Cmd.group info 627 641 [ 642 + Karakeep_auth_cmd.auth_cmd (); 628 643 bookmarks_cmd; 629 644 tags_cmd; 630 645 lists_cmd;
+2
dune-project
··· 30 30 (uri (>= "4.0.0")) 31 31 (cmdliner (>= "1.3.0")) 32 32 (logs (>= "0.7.0")) 33 + xdge 34 + tomlt 33 35 (odoc :with-doc)))
+2
karakeep.opam
··· 24 24 "uri" {>= "4.0.0"} 25 25 "cmdliner" {>= "1.3.0"} 26 26 "logs" {>= "0.7.0"} 27 + "xdge" 28 + "tomlt" 27 29 "odoc" {with-doc} 28 30 ] 29 31 build: [
+3 -1
lib/cmd/dune
··· 13 13 eio 14 14 eio_main 15 15 jsont 16 - jsont.bytesrw)) 16 + jsont.bytesrw 17 + xdge 18 + tomlt.eio))
+221
lib/cmd/karakeep_auth_cmd.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Cmdliner 7 + 8 + (* Common terms *) 9 + 10 + let profile_term = 11 + let doc = "Profile name (default: current profile)." in 12 + Arg.( 13 + value 14 + & opt (some string) None 15 + & info [ "profile"; "P" ] ~docv:"PROFILE" ~doc) 16 + 17 + let base_url_term = 18 + let doc = "Base URL of the Karakeep instance." in 19 + let env = Cmd.Env.info "KARAKEEP_BASE_URL" ~doc in 20 + Arg.( 21 + value 22 + & opt string Karakeep_config.default_base_url 23 + & info [ "base-url"; "u" ] ~docv:"URL" ~doc ~env) 24 + 25 + let profile_name_arg = 26 + let doc = "Profile name." in 27 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROFILE" ~doc) 28 + 29 + (* Helper to run with filesystem *) 30 + let with_fs f = 31 + Eio_main.run @@ fun env -> f env#fs 32 + 33 + (* Login command *) 34 + 35 + let login_action ~profile ~base_url fs = 36 + (* Prompt for API key *) 37 + Fmt.pr "API Key: @?"; 38 + let api_key = read_line () |> String.trim in 39 + if api_key = "" then begin 40 + Fmt.epr "Error: API key cannot be empty.@."; 41 + 1 42 + end else begin 43 + let creds : Karakeep_config.credentials = { api_key; base_url } in 44 + (* Determine profile name *) 45 + let profile_name = match profile with 46 + | Some p -> p 47 + | None -> 48 + let profiles = Karakeep_config.list_profiles fs in 49 + if profiles = [] then Karakeep_config.default_profile 50 + else Karakeep_config.get_current_profile fs 51 + in 52 + (* Save credentials *) 53 + Karakeep_config.save_credentials fs ~profile:profile_name creds; 54 + (* Set as current profile if first login or explicitly requested *) 55 + let profiles = Karakeep_config.list_profiles fs in 56 + if List.length profiles <= 1 || Option.is_some profile then 57 + Karakeep_config.set_current_profile fs profile_name; 58 + Fmt.pr "Configured profile '%s' for %s@." profile_name base_url; 59 + 0 60 + end 61 + 62 + let login_cmd () = 63 + let doc = "Configure API credentials for a Karakeep instance." in 64 + let man = [ 65 + `S Manpage.s_description; 66 + `P "Prompts for an API key and saves it to the specified profile."; 67 + `P "API keys can be generated from your Karakeep instance settings."; 68 + `S "EXAMPLES"; 69 + `P "$(b,karakeep auth login)"; 70 + `Noblank; 71 + `P " Login with default profile."; 72 + `P "$(b,karakeep auth login --profile work --base-url https://work.example.com)"; 73 + `Noblank; 74 + `P " Configure a work profile with a different instance."; 75 + ] in 76 + let info = Cmd.info "login" ~doc ~man in 77 + let login' profile base_url = with_fs (login_action ~profile ~base_url) in 78 + Cmd.v info Term.(const login' $ profile_term $ base_url_term) 79 + 80 + (* Logout command *) 81 + 82 + let logout_action ~profile fs = 83 + let profile_name = match profile with 84 + | Some p -> p 85 + | None -> Karakeep_config.get_current_profile fs 86 + in 87 + match Karakeep_config.load_credentials fs ~profile:profile_name () with 88 + | None -> 89 + Fmt.pr "Profile '%s' has no stored credentials.@." profile_name; 90 + 0 91 + | Some _ -> 92 + Karakeep_config.clear_credentials fs ~profile:profile_name (); 93 + Fmt.pr "Removed credentials for profile '%s'.@." profile_name; 94 + 0 95 + 96 + let logout_cmd () = 97 + let doc = "Remove stored credentials." in 98 + let info = Cmd.info "logout" ~doc in 99 + let logout' profile = with_fs (logout_action ~profile) in 100 + Cmd.v info Term.(const logout' $ profile_term) 101 + 102 + (* Status command *) 103 + 104 + let status_action ~profile fs = 105 + let home = Sys.getenv "HOME" in 106 + Fmt.pr "Config directory: %s/.config/%s@." home Karakeep_config.app_name; 107 + let current = Karakeep_config.get_current_profile fs in 108 + Fmt.pr "Current profile: %s@." current; 109 + let profiles = Karakeep_config.list_profiles fs in 110 + if profiles <> [] then 111 + Fmt.pr "Available profiles: %s@." (String.concat ", " profiles); 112 + Fmt.pr "@."; 113 + let profile_name = match profile with 114 + | Some p -> p 115 + | None -> current 116 + in 117 + match Karakeep_config.load_credentials fs ~profile:profile_name () with 118 + | None -> 119 + Fmt.pr "Profile '%s': Not configured.@." profile_name; 120 + Fmt.pr "Use 'karakeep auth login' to configure.@."; 121 + 0 122 + | Some creds -> 123 + Fmt.pr "Profile '%s':@." profile_name; 124 + Fmt.pr " Base URL: %s@." creds.base_url; 125 + Fmt.pr " API Key: %s...%s@." 126 + (String.sub creds.api_key 0 4) 127 + (String.sub creds.api_key (String.length creds.api_key - 4) 4); 128 + 0 129 + 130 + let status_cmd () = 131 + let doc = "Show authentication status." in 132 + let info = Cmd.info "status" ~doc in 133 + let status' profile = with_fs (status_action ~profile) in 134 + Cmd.v info Term.(const status' $ profile_term) 135 + 136 + (* Profile list command *) 137 + 138 + let profile_list_action fs = 139 + let current = Karakeep_config.get_current_profile fs in 140 + let profiles = Karakeep_config.list_profiles fs in 141 + if profiles = [] then begin 142 + Fmt.pr "No profiles found. Use 'karakeep auth login' to create one.@."; 143 + 0 144 + end else begin 145 + Fmt.pr "Profiles:@."; 146 + List.iter (fun p -> 147 + let marker = if p = current then " (current)" else "" in 148 + match Karakeep_config.load_credentials fs ~profile:p () with 149 + | Some creds -> Fmt.pr " %s%s - %s@." p marker creds.base_url 150 + | None -> Fmt.pr " %s%s@." p marker 151 + ) profiles; 152 + 0 153 + end 154 + 155 + let profile_list_cmd () = 156 + let doc = "List available profiles." in 157 + let info = Cmd.info "list" ~doc in 158 + let list' () = with_fs profile_list_action in 159 + Cmd.v info Term.(const list' $ const ()) 160 + 161 + (* Profile switch command *) 162 + 163 + let profile_switch_action ~profile fs = 164 + let profiles = Karakeep_config.list_profiles fs in 165 + if List.mem profile profiles then begin 166 + Karakeep_config.set_current_profile fs profile; 167 + Fmt.pr "Switched to profile: %s@." profile; 168 + 0 169 + end else begin 170 + Fmt.epr "Profile '%s' not found.@." profile; 171 + if profiles <> [] then 172 + Fmt.epr "Available profiles: %s@." (String.concat ", " profiles); 173 + 1 174 + end 175 + 176 + let profile_switch_cmd () = 177 + let doc = "Switch to a different profile." in 178 + let info = Cmd.info "switch" ~doc in 179 + let switch' profile = with_fs (profile_switch_action ~profile) in 180 + Cmd.v info Term.(const switch' $ profile_name_arg) 181 + 182 + (* Profile current command *) 183 + 184 + let profile_current_action fs = 185 + let current = Karakeep_config.get_current_profile fs in 186 + Fmt.pr "%s@." current; 187 + 0 188 + 189 + let profile_current_cmd () = 190 + let doc = "Show current profile name." in 191 + let info = Cmd.info "current" ~doc in 192 + let current' () = with_fs profile_current_action in 193 + Cmd.v info Term.(const current' $ const ()) 194 + 195 + (* Profile command group *) 196 + 197 + let profile_cmd () = 198 + let doc = "Profile management commands." in 199 + let info = Cmd.info "profile" ~doc in 200 + Cmd.group info [ 201 + profile_list_cmd (); 202 + profile_switch_cmd (); 203 + profile_current_cmd (); 204 + ] 205 + 206 + (* Auth command group *) 207 + 208 + let auth_cmd () = 209 + let doc = "Authentication commands." in 210 + let man = [ 211 + `S Manpage.s_description; 212 + `P "Manage authentication credentials for Karakeep instances."; 213 + `P "Credentials are stored in ~/.config/karakeep/profiles/<name>/credentials.toml"; 214 + ] in 215 + let info = Cmd.info "auth" ~doc ~man in 216 + Cmd.group info [ 217 + login_cmd (); 218 + logout_cmd (); 219 + status_cmd (); 220 + profile_cmd (); 221 + ]
+39
lib/cmd/karakeep_auth_cmd.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Karakeep authentication CLI commands. 7 + 8 + Provides commands for managing API key credentials across multiple profiles. *) 9 + 10 + (** {1 Command Line Terms} *) 11 + 12 + val profile_term : string option Cmdliner.Term.t 13 + (** Cmdliner term for [--profile] / [-P] flag. *) 14 + 15 + (** {1 Commands} *) 16 + 17 + val login_cmd : unit -> int Cmdliner.Cmd.t 18 + (** [karakeep auth login] - Configure API credentials for a profile. *) 19 + 20 + val logout_cmd : unit -> int Cmdliner.Cmd.t 21 + (** [karakeep auth logout] - Remove stored credentials. *) 22 + 23 + val status_cmd : unit -> int Cmdliner.Cmd.t 24 + (** [karakeep auth status] - Show authentication status. *) 25 + 26 + val profile_list_cmd : unit -> int Cmdliner.Cmd.t 27 + (** [karakeep auth profile list] - List available profiles. *) 28 + 29 + val profile_switch_cmd : unit -> int Cmdliner.Cmd.t 30 + (** [karakeep auth profile switch] - Switch to a different profile. *) 31 + 32 + val profile_current_cmd : unit -> int Cmdliner.Cmd.t 33 + (** [karakeep auth profile current] - Show current profile name. *) 34 + 35 + val profile_cmd : unit -> int Cmdliner.Cmd.t 36 + (** [karakeep auth profile] - Profile management command group. *) 37 + 38 + val auth_cmd : unit -> int Cmdliner.Cmd.t 39 + (** [karakeep auth] - Authentication command group. *)
+83 -30
lib/cmd/karakeep_cmd.ml
··· 19 19 String.trim key 20 20 with _ -> "" 21 21 22 - (* Base URL term *) 23 - let base_url_term = 24 - let doc = "Base URL of the Karakeep instance." in 22 + (* Profile term - shared with karakeep_auth_cmd *) 23 + let profile_term = Karakeep_auth_cmd.profile_term 24 + 25 + (* Base URL term - only used as override *) 26 + let base_url_opt_term = 27 + let doc = "Base URL of the Karakeep instance (overrides profile)." in 25 28 let env = Cmd.Env.info "KARAKEEP_BASE_URL" ~doc in 26 29 Arg.( 27 30 value 28 - & opt string "https://hoard.recoil.org" 31 + & opt (some string) None 29 32 & info [ "base-url"; "u" ] ~docv:"URL" ~doc ~env) 30 33 31 - (* API key file term *) 34 + (* API key file term - legacy support *) 32 35 let api_key_file_term = 33 - let doc = "File containing the API key (one key per line)." in 36 + let doc = "File containing the API key (legacy, use 'auth login' instead)." in 34 37 Arg.( 35 38 value 36 39 & opt string ".karakeep-api" ··· 38 41 39 42 (* API key direct term *) 40 43 let api_key_direct_term = 41 - let doc = "API key for authentication." in 44 + let doc = "API key for authentication (overrides profile)." in 42 45 let env = Cmd.Env.info "KARAKEEP_API_KEY" ~doc in 43 46 Arg.(value & opt (some string) None & info [ "api-key"; "k" ] ~docv:"KEY" ~doc ~env) 44 47 45 - (* Combined API key term *) 46 - let api_key_term = 47 - let resolve direct file = 48 - match direct with 49 - | Some key -> key 48 + (* Config options from CLI - not yet resolved *) 49 + type config_opt = { 50 + api_key_direct : string option; 51 + base_url_opt : string option; 52 + profile : string option; 53 + api_key_file : string; 54 + } 55 + 56 + let config_opt_term = 57 + let make api_key_direct base_url_opt profile api_key_file = 58 + { api_key_direct; base_url_opt; profile; api_key_file } 59 + in 60 + Term.(const make $ api_key_direct_term $ base_url_opt_term $ profile_term $ api_key_file_term) 61 + 62 + (* Resolve config with Eio filesystem *) 63 + let resolve_config ~fs (opt : config_opt) : config = 64 + (* Priority: 65 + 1. --api-key flag (if provided) 66 + 2. KARAKEEP_API_KEY env var 67 + 3. XDG profile credentials 68 + 4. Legacy .karakeep-api file *) 69 + let api_key, base_url = 70 + match opt.api_key_direct with 71 + | Some key -> 72 + (* Direct API key provided, use default or env base URL *) 73 + let url = match opt.base_url_opt with 74 + | Some u -> u 75 + | None -> Karakeep_config.default_base_url 76 + in 77 + (key, url) 50 78 | None -> 51 - let env_key = 52 - try Sys.getenv "KARAKEEP_API_KEY" with Not_found -> "" 53 - in 54 - if env_key <> "" then env_key 55 - else 56 - let file_key = read_api_key_file file in 57 - if file_key <> "" then file_key 58 - else failwith "No API key provided. Use --api-key, KARAKEEP_API_KEY, or --api-key-file" 79 + (* Check environment variable *) 80 + let env_key = try Sys.getenv "KARAKEEP_API_KEY" with Not_found -> "" in 81 + if env_key <> "" then begin 82 + let url = match opt.base_url_opt with 83 + | Some u -> u 84 + | None -> try Sys.getenv "KARAKEEP_BASE_URL" 85 + with Not_found -> Karakeep_config.default_base_url 86 + in 87 + (env_key, url) 88 + end else begin 89 + (* Try XDG profile credentials *) 90 + let profile_name = match opt.profile with 91 + | Some p -> p 92 + | None -> Karakeep_config.get_current_profile fs 93 + in 94 + match Karakeep_config.load_credentials fs ~profile:profile_name () with 95 + | Some creds -> 96 + (* Apply base_url override if provided *) 97 + let url = match opt.base_url_opt with 98 + | Some u -> u 99 + | None -> creds.Karakeep_config.base_url 100 + in 101 + (creds.Karakeep_config.api_key, url) 102 + | None -> 103 + (* Fall back to legacy .karakeep-api file *) 104 + let file_key = read_api_key_file opt.api_key_file in 105 + if file_key <> "" then begin 106 + let url = match opt.base_url_opt with 107 + | Some u -> u 108 + | None -> Karakeep_config.default_base_url 109 + in 110 + (file_key, url) 111 + end else 112 + failwith "No credentials found. Use 'karakeep auth login' or --api-key" 113 + end 59 114 in 60 - Term.(const resolve $ api_key_direct_term $ api_key_file_term) 61 - 62 - (* Config term *) 63 - let config_term = 64 - let make base_url api_key = { base_url; api_key } in 65 - Term.(const make $ base_url_term $ api_key_term) 115 + { base_url; api_key } 66 116 67 117 (* Pagination terms *) 68 118 let limit_term = ··· 219 269 in 220 270 Term.(const setup $ fmt_styler_term $ logs_term $ verbose_http_term) 221 271 222 - (* Client helper *) 223 - let with_client config f = 224 - Eio_main.run @@ fun env -> 225 - Eio.Switch.run @@ fun sw -> 272 + (* Client helper - takes env and config_opt, resolves and creates client *) 273 + let with_client ~env ~sw config_opt f = 274 + let config = resolve_config ~fs:env#fs config_opt in 226 275 let client = 227 276 Karakeep.create ~sw ~env ~base_url:config.base_url ~api_key:config.api_key 228 277 in ··· 349 398 | e -> 350 399 Logs.err (fun m -> m "Unexpected error: %s" (Printexc.to_string e)); 351 400 1 401 + 402 + (* Re-export modules for public access *) 403 + module Karakeep_config = Karakeep_config 404 + module Karakeep_auth_cmd = Karakeep_auth_cmd
+43 -19
lib/cmd/karakeep_cmd.mli
··· 32 32 } 33 33 (** Configuration for connecting to a Karakeep instance. *) 34 34 35 - val config_term : config Cmdliner.Term.t 36 - (** Cmdliner term that parses configuration from command-line arguments 37 - and environment variables. 35 + type config_opt 36 + (** Configuration options from CLI, not yet resolved. 37 + Use {!resolve_config} or {!with_client} with an Eio env to resolve. *) 38 + 39 + val config_opt_term : config_opt Cmdliner.Term.t 40 + (** Cmdliner term that parses configuration options from command-line arguments. 41 + The actual credentials are resolved at runtime by {!resolve_config} or 42 + {!with_client} when given an Eio environment. 43 + 44 + Configuration is resolved in priority order: 45 + 1. [--api-key KEY] flag 46 + 2. [KARAKEEP_API_KEY] environment variable 47 + 3. XDG profile credentials (~/.config/karakeep/profiles/...) 48 + 4. Legacy [--api-key-file FILE] (default: .karakeep-api) 38 49 39 50 Options: 40 - - [--base-url URL] or [KARAKEEP_BASE_URL]: Karakeep instance URL 41 - - [--api-key KEY] or [KARAKEEP_API_KEY]: API key 42 - - [--api-key-file FILE]: Read API key from file (default: .karakeep-api) 51 + - [--profile NAME] or [-P NAME]: Select a specific profile 52 + - [--base-url URL] or [KARAKEEP_BASE_URL]: Override instance URL 53 + - [--api-key KEY] or [KARAKEEP_API_KEY]: Override API key 54 + - [--api-key-file FILE]: Legacy API key file (default: .karakeep-api) *) 43 55 44 - The API key is resolved in order: [--api-key], [KARAKEEP_API_KEY], 45 - contents of [--api-key-file]. *) 56 + val resolve_config : fs:Eio.Fs.dir_ty Eio.Path.t -> config_opt -> config 57 + (** [resolve_config ~fs config_opt] resolves credentials using the filesystem. 58 + @raise Failure if no credentials are found. *) 46 59 47 60 (** {1 Common Terms} *) 48 61 49 - val base_url_term : string Cmdliner.Term.t 50 - (** Term for the base URL option only. *) 51 - 52 - val api_key_term : string Cmdliner.Term.t 53 - (** Term for the API key option only. *) 62 + val profile_term : string option Cmdliner.Term.t 63 + (** Term for [--profile NAME] / [-P NAME] option to select a profile. *) 54 64 55 65 (** {1 Pagination Terms} *) 56 66 ··· 158 168 (** {1 Client Helpers} *) 159 169 160 170 val with_client : 161 - config -> 171 + env:< clock : _ Eio.Time.clock ; fs : Eio.Fs.dir_ty Eio.Path.t ; net : _ Eio.Net.t ; .. > -> 172 + sw:Eio.Switch.t -> 173 + config_opt -> 162 174 (Karakeep.t -> 'a) -> 163 175 'a 164 - (** [with_client config f] runs [f] with a Karakeep client configured 165 - using the given config. Handles Eio setup internally. 176 + (** [with_client ~env ~sw config_opt f] resolves configuration and runs [f] 177 + with a Karakeep client. 166 178 167 179 {[ 168 - let run config = 169 - with_client config (fun client -> 180 + let run config_opt = 181 + Eio_main.run @@ fun env -> 182 + Eio.Switch.run @@ fun sw -> 183 + with_client ~env ~sw config_opt (fun client -> 170 184 let bookmarks = Karakeep.fetch_all_bookmarks client () in 171 185 (* ... *)) 172 - ]} *) 186 + ]} 187 + 188 + @raise Failure if no credentials are found. *) 173 189 174 190 (** {1 Output Helpers} *) 175 191 ··· 214 230 - 1: General error 215 231 - 2: API error (e.g., not found, unauthorized) 216 232 - 3: Network error *) 233 + 234 + (** {1 Re-exported Modules} *) 235 + 236 + module Karakeep_config = Karakeep_config 237 + (** Configuration storage with XDG support. *) 238 + 239 + module Karakeep_auth_cmd = Karakeep_auth_cmd 240 + (** Authentication CLI commands. *)
+149
lib/cmd/karakeep_config.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type credentials = { 7 + api_key : string; 8 + base_url : string; 9 + } 10 + 11 + let app_name = "karakeep" 12 + let default_base_url = "https://hoard.recoil.org" 13 + let default_profile = "default" 14 + 15 + (* TOML codec for credentials *) 16 + let credentials_tomlt = 17 + Tomlt.(Table.( 18 + obj (fun api_key base_url -> { api_key; base_url }) 19 + |> mem "api_key" string ~enc:(fun c -> c.api_key) 20 + |> mem "base_url" string ~enc:(fun c -> c.base_url) ~dec_absent:default_base_url 21 + |> finish 22 + )) 23 + 24 + (* App config stores current profile name *) 25 + type app_config = { current_profile : string } 26 + 27 + let app_config_tomlt = 28 + Tomlt.(Table.( 29 + obj (fun current_profile -> { current_profile }) 30 + |> mem "current_profile" string ~enc:(fun c -> c.current_profile) ~dec_absent:default_profile 31 + |> finish 32 + )) 33 + 34 + (* Directory helpers *) 35 + 36 + let mkdir_if_not_exists path = 37 + try Eio.Path.mkdir ~perm:0o700 path 38 + with Eio.Io (Eio.Fs.E (Eio.Fs.Already_exists _), _) -> () 39 + 40 + let base_config_dir fs = 41 + let home = Sys.getenv "HOME" in 42 + let config_path = Eio.Path.(fs / home / ".config" / app_name) in 43 + mkdir_if_not_exists Eio.Path.(fs / home / ".config"); 44 + mkdir_if_not_exists config_path; 45 + config_path 46 + 47 + let profiles_dir fs = 48 + let base = base_config_dir fs in 49 + let profiles = Eio.Path.(base / "profiles") in 50 + mkdir_if_not_exists profiles; 51 + profiles 52 + 53 + let profile_dir fs profile = 54 + let profiles = profiles_dir fs in 55 + let dir = Eio.Path.(profiles / profile) in 56 + mkdir_if_not_exists dir; 57 + dir 58 + 59 + (* Config file paths *) 60 + 61 + let app_config_file fs = Eio.Path.(base_config_dir fs / "config.toml") 62 + let credentials_file fs profile = Eio.Path.(profile_dir fs profile / "credentials.toml") 63 + 64 + (* App config operations *) 65 + 66 + let load_app_config fs = 67 + let path = app_config_file fs in 68 + try 69 + match Tomlt_eio.decode_file app_config_tomlt path with 70 + | Ok config -> Some config 71 + | Error _ -> None 72 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 73 + 74 + let save_app_config fs config = 75 + let path = app_config_file fs in 76 + Tomlt_eio.encode_file app_config_tomlt config path 77 + 78 + (* Profile management *) 79 + 80 + let get_current_profile fs = 81 + match load_app_config fs with 82 + | Some config -> config.current_profile 83 + | None -> default_profile 84 + 85 + let set_current_profile fs profile = 86 + save_app_config fs { current_profile = profile } 87 + 88 + let list_profiles fs = 89 + let profiles = profiles_dir fs in 90 + try 91 + Eio.Path.read_dir profiles 92 + |> List.filter (fun name -> 93 + let dir = Eio.Path.(profiles / name) in 94 + let creds = Eio.Path.(dir / "credentials.toml") in 95 + try 96 + ignore (Eio.Path.load creds); 97 + true 98 + with _ -> false) 99 + |> List.sort String.compare 100 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> [] 101 + 102 + (* Credential operations *) 103 + 104 + let load_credentials fs ?profile () = 105 + let profile = match profile with 106 + | Some p -> p 107 + | None -> get_current_profile fs 108 + in 109 + let path = credentials_file fs profile in 110 + try 111 + match Tomlt_eio.decode_file credentials_tomlt path with 112 + | Ok creds -> Some creds 113 + | Error _ -> None 114 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 115 + 116 + let save_credentials fs ?profile creds = 117 + let profile = match profile with 118 + | Some p -> p 119 + | None -> get_current_profile fs 120 + in 121 + let path = credentials_file fs profile in 122 + Tomlt_eio.encode_file credentials_tomlt creds path 123 + 124 + let clear_credentials fs ?profile () = 125 + let profile = match profile with 126 + | Some p -> p 127 + | None -> get_current_profile fs 128 + in 129 + let path = credentials_file fs profile in 130 + try Eio.Path.unlink path 131 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> () 132 + 133 + (* Legacy migration *) 134 + 135 + let read_api_key_file path = 136 + try 137 + let ic = open_in path in 138 + let key = input_line ic in 139 + close_in ic; 140 + Some (String.trim key) 141 + with _ -> None 142 + 143 + let load_legacy_api_key () = 144 + (* First try environment variable *) 145 + match Sys.getenv_opt "KARAKEEP_API_KEY" with 146 + | Some key when key <> "" -> Some key 147 + | _ -> 148 + (* Then try .karakeep-api file *) 149 + read_api_key_file ".karakeep-api"
+86
lib/cmd/karakeep_config.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Karakeep configuration management with XDG support. 7 + 8 + This module provides profile-based credential storage following XDG 9 + Base Directory conventions. Configuration is stored in TOML format at: 10 + 11 + {v 12 + ~/.config/karakeep/ 13 + ├── config.toml # Global settings (current profile) 14 + └── profiles/ 15 + ├── default/ 16 + │ └── credentials.toml 17 + └── work/ 18 + └── credentials.toml 19 + v} 20 + *) 21 + 22 + (** {1 Configuration Types} *) 23 + 24 + type credentials = { 25 + api_key : string; 26 + base_url : string; 27 + } 28 + (** Stored credentials for a Karakeep instance. *) 29 + 30 + (** {1 Constants} *) 31 + 32 + val app_name : string 33 + (** The application name ["karakeep"], used for XDG directory paths. *) 34 + 35 + val default_base_url : string 36 + (** Default Karakeep instance URL. *) 37 + 38 + val default_profile : string 39 + (** Name of the default profile. *) 40 + 41 + (** {1 Directory Paths} *) 42 + 43 + val base_config_dir : Eio.Fs.dir_ty Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 44 + (** [base_config_dir fs] returns the base config directory for karakeep. 45 + Creates the directory if it doesn't exist. *) 46 + 47 + val profiles_dir : Eio.Fs.dir_ty Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 48 + (** [profiles_dir fs] returns the profiles subdirectory. 49 + Creates the directory if it doesn't exist. *) 50 + 51 + val profile_dir : Eio.Fs.dir_ty Eio.Path.t -> string -> Eio.Fs.dir_ty Eio.Path.t 52 + (** [profile_dir fs profile] returns the directory for a specific profile. 53 + Creates the directory if it doesn't exist. *) 54 + 55 + (** {1 Profile Management} *) 56 + 57 + val get_current_profile : Eio.Fs.dir_ty Eio.Path.t -> string 58 + (** [get_current_profile fs] returns the current profile name. 59 + Returns ["default"] if no profile is set. *) 60 + 61 + val set_current_profile : Eio.Fs.dir_ty Eio.Path.t -> string -> unit 62 + (** [set_current_profile fs name] sets the current profile. *) 63 + 64 + val list_profiles : Eio.Fs.dir_ty Eio.Path.t -> string list 65 + (** [list_profiles fs] returns all available profile names. *) 66 + 67 + (** {1 Credential Storage} *) 68 + 69 + val load_credentials : Eio.Fs.dir_ty Eio.Path.t -> ?profile:string -> unit -> credentials option 70 + (** [load_credentials fs ?profile ()] loads credentials for a profile. 71 + Uses current profile if not specified. Returns [None] if not found. *) 72 + 73 + val save_credentials : Eio.Fs.dir_ty Eio.Path.t -> ?profile:string -> credentials -> unit 74 + (** [save_credentials fs ?profile creds] saves credentials to a profile. 75 + Uses current profile if not specified. *) 76 + 77 + val clear_credentials : Eio.Fs.dir_ty Eio.Path.t -> ?profile:string -> unit -> unit 78 + (** [clear_credentials fs ?profile ()] removes credentials for a profile. 79 + Uses current profile if not specified. *) 80 + 81 + (** {1 Legacy Migration} *) 82 + 83 + val load_legacy_api_key : unit -> string option 84 + (** [load_legacy_api_key ()] attempts to read API key from legacy locations: 85 + 1. KARAKEEP_API_KEY environment variable 86 + 2. .karakeep-api file in current directory *)