this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

sync

+1197 -497
+31 -11
stack/bushel/bin/bushel_common.ml
··· 20 20 let url_term ~default ~doc = 21 21 Arg.(value & opt string default & info ["u"; "url"] ~docv:"URL" ~doc) 22 22 23 - (** TODO:claude API key file term *) 24 - let api_key_file ~default = 25 - let doc = "File containing API key" in 26 - Arg.(value & opt string default & info ["k"; "key-file"] ~docv:"FILE" ~doc) 27 - 28 - (** TODO:claude API key term *) 29 - let api_key = 30 - let doc = "API key for authentication" in 31 - Arg.(value & opt (some string) None & info ["api-key"] ~docv:"KEY" ~doc) 32 - 33 23 (** TODO:claude Overwrite flag *) 34 24 let overwrite = 35 25 let doc = "Overwrite existing files" in ··· 73 63 74 64 (** TODO:claude Common setup term combining logs setup *) 75 65 let setup_term = 76 - Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ()) 66 + Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ()) 67 + 68 + (** Keyeio integration for API credential management *) 69 + 70 + (** Create XDG term for bushel *) 71 + let xdg_term fs = 72 + Xdge.Cmd.term "bushel" fs () 73 + 74 + (** Create keyeio term for Immiche service *) 75 + let immiche_key_term fs = 76 + Keyeio.Cmd.term 77 + ~app_name:"bushel" 78 + ~fs 79 + ~service:"immiche" 80 + () 81 + 82 + (** Create keyeio term for Karakeepe service *) 83 + let karakeepe_key_term fs = 84 + Keyeio.Cmd.term 85 + ~app_name:"bushel" 86 + ~fs 87 + ~service:"karakeepe" 88 + () 89 + 90 + (** Create keyeio term for Typesense service (includes both typesense and openai keys) *) 91 + let typesense_key_term fs = 92 + Keyeio.Cmd.term 93 + ~app_name:"bushel" 94 + ~fs 95 + ~service:"typesense" 96 + ()
+16 -19
stack/bushel/bin/bushel_faces.ml
··· 1 1 open Cmdliner 2 2 open Printf 3 3 4 - (* Read API key from file *) 5 - let read_api_key file = 6 - let ic = open_in file in 7 - let key = input_line ic in 8 - close_in ic; 9 - key 10 - 11 4 (* Get face for a single contact *) 12 5 let get_face_for_contact immiche_client ~fs output_dir contact = 13 6 let names = Bushel.Contact.names contact in ··· 45 38 end 46 39 47 40 (* Process all contacts or a specific one *) 48 - let process_contacts ~sw ~env base_dir output_dir specific_handle api_key base_url = 41 + let process_contacts ~sw ~env base_dir output_dir specific_handle profile = 49 42 printf "Loading Bushel database from %s\n%!" base_dir; 50 43 let db = Bushel.load base_dir in 51 44 let contacts = Bushel.Entry.contacts db in 52 45 printf "Found %d contacts\n%!" (List.length contacts); 46 + 47 + (* Get credentials from keyeio profile *) 48 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 49 + let base_url = Keyeio.Profile.get profile ~key:"base_url" 50 + |> Option.value ~default:"https://photos.recoil.org" in 51 + 52 + printf "Connecting to Immich at %s\n%!" base_url; 53 53 54 54 (* Create Immiche client for connection pooling *) 55 55 let immiche_client = Immiche.create ~sw ~env ~base_url ~api_key () in ··· 102 102 103 103 (* Command line interface *) 104 104 105 - (* Export the term for use in main bushel.ml *) 106 - let term = 105 + (* Create term given the Eio environment *) 106 + let make_term eio_env = 107 + let immiche_profile_term = Bushel_common.immiche_key_term eio_env#fs in 107 108 Term.( 108 - const (fun base_dir output_dir handle api_key_file base_url -> 109 + const (fun base_dir output_dir handle profile -> 109 110 try 110 - let api_key = read_api_key api_key_file in 111 - Eio_main.run @@ fun env -> 112 111 Eio.Switch.run @@ fun sw -> 113 - process_contacts ~sw ~env base_dir output_dir handle api_key base_url 112 + process_contacts ~sw ~env:eio_env base_dir output_dir handle profile 114 113 with e -> 115 114 eprintf "Error: %s\n%!" (Printexc.to_string e); 116 115 1 117 - ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $ 118 - Bushel_common.api_key_file ~default:".photos-api" $ 119 - Bushel_common.url_term ~default:"https://photos.recoil.org" ~doc:"Base URL of the Immich instance") 116 + ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $ immiche_profile_term) 120 117 121 - let cmd = 118 + let make_cmd eio_env = 122 119 let info = Cmd.info "faces" ~doc:"Retrieve face thumbnails for Bushel contacts from Immich" in 123 - Cmd.v info term 120 + Cmd.v info (make_term eio_env) 124 121 125 122 (* Main entry point removed - accessed through bushel_main.ml *)
+158 -181
stack/bushel/bin/bushel_links.ml
··· 18 18 0 19 19 20 20 (* Update links.yml from Karakeep *) 21 - let update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets = 22 - match api_key_opt with 23 - | None -> 24 - prerr_endline "Error: API key is required."; 25 - prerr_endline "Please provide one with --api-key or create a ~/.karakeep-api file."; 26 - 1 27 - | Some api_key -> 28 - let assets_dir = "data/assets" in 21 + let update_from_karakeep ~sw ~env base_url profile tag links_file download_assets = 22 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 23 + let assets_dir = "data/assets" in 29 24 30 - try 31 - print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag); 25 + try 26 + print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag); 32 27 33 - (* Prepare tag filter *) 34 - let filter_tags = if tag = "" then [] else [tag] in 28 + (* Prepare tag filter *) 29 + let filter_tags = if tag = "" then [] else [tag] in 35 30 36 - (* Fetch bookmarks from Karakeep *) 37 - let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in 31 + (* Fetch bookmarks from Karakeep *) 32 + let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in 38 33 39 - print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks)); 34 + print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks)); 40 35 41 - (* Read existing links if file exists *) 42 - let existing_links = Bushel.Link.load_links_file links_file in 36 + (* Read existing links if file exists *) 37 + let existing_links = Bushel.Link.load_links_file links_file in 43 38 44 - (* Convert bookmarks to bushel links *) 45 - let new_links = List.map (fun bookmark -> 46 - Karakeepe.to_bushel_link ~base_url bookmark 47 - ) bookmarks in 39 + (* Convert bookmarks to bushel links *) 40 + let new_links = List.map (fun bookmark -> 41 + Karakeepe.to_bushel_link ~base_url bookmark 42 + ) bookmarks in 48 43 49 - (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *) 50 - let merged_links = Bushel.Link.merge_links existing_links new_links in 44 + (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *) 45 + let merged_links = Bushel.Link.merge_links existing_links new_links in 51 46 52 - (* Save the updated links file *) 53 - Bushel.Link.save_links_file links_file merged_links; 47 + (* Save the updated links file *) 48 + Bushel.Link.save_links_file links_file merged_links; 54 49 55 - print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links)); 50 + print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links)); 56 51 57 - (* Download assets if requested *) 58 - if download_assets then begin 59 - print_endline "Downloading assets for bookmarks..."; 52 + (* Download assets if requested *) 53 + if download_assets then begin 54 + print_endline "Downloading assets for bookmarks..."; 60 55 61 - (* Ensure the assets directory exists *) 62 - (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 56 + (* Ensure the assets directory exists *) 57 + (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 63 58 64 - (* Process each bookmark with assets *) 65 - List.iter (fun bookmark -> 66 - (* Extract asset IDs from bookmark *) 67 - let assets = bookmark.Karakeepe.assets in 59 + (* Process each bookmark with assets *) 60 + List.iter (fun bookmark -> 61 + (* Extract asset IDs from bookmark *) 62 + let assets = bookmark.Karakeepe.assets in 68 63 69 - (* Skip if no assets *) 70 - if assets <> [] then 71 - (* Process each asset *) 72 - List.iter (fun (asset_id, asset_type) -> 73 - let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in 74 - let asset_file = Fmt.str "%s/asset.bin" asset_dir in 75 - let meta_file = Fmt.str "%s/metadata.json" asset_dir in 64 + (* Skip if no assets *) 65 + if assets <> [] then 66 + (* Process each asset *) 67 + List.iter (fun (asset_id, asset_type) -> 68 + let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in 69 + let asset_file = Fmt.str "%s/asset.bin" asset_dir in 70 + let meta_file = Fmt.str "%s/metadata.json" asset_dir in 76 71 77 - (* Skip if the asset already exists *) 78 - if not (Sys.file_exists asset_file) then begin 79 - (* Create the asset directory *) 80 - (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 72 + (* Skip if the asset already exists *) 73 + if not (Sys.file_exists asset_file) then begin 74 + (* Create the asset directory *) 75 + (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); 81 76 82 - (* Download the asset *) 83 - print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id); 84 - let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in 77 + (* Download the asset *) 78 + print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id); 79 + let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in 85 80 86 - (* Guess content type based on first bytes *) 87 - let content_type = 88 - if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then 89 - "image/png" 90 - else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then 91 - "image/jpeg" 92 - else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then 93 - "application/pdf" 94 - else 95 - "application/octet-stream" 96 - in 81 + (* Guess content type based on first bytes *) 82 + let content_type = 83 + if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then 84 + "image/png" 85 + else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then 86 + "image/jpeg" 87 + else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then 88 + "application/pdf" 89 + else 90 + "application/octet-stream" 91 + in 97 92 98 - (* Write the asset data *) 99 - let oc = open_out_bin asset_file in 100 - output_string oc data; 101 - close_out oc; 93 + (* Write the asset data *) 94 + let oc = open_out_bin asset_file in 95 + output_string oc data; 96 + close_out oc; 102 97 103 - (* Write metadata file *) 104 - let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}" 105 - content_type asset_type in 106 - let oc = open_out meta_file in 107 - output_string oc metadata; 108 - close_out oc 109 - end 110 - ) assets 111 - ) bookmarks; 98 + (* Write metadata file *) 99 + let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}" 100 + content_type asset_type in 101 + let oc = open_out meta_file in 102 + output_string oc metadata; 103 + close_out oc 104 + end 105 + ) assets 106 + ) bookmarks; 112 107 113 - print_endline "Asset download completed."; 114 - 0 115 - end else 116 - 0 117 - with exn -> 118 - prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn)); 119 - 1 108 + print_endline "Asset download completed."; 109 + 0 110 + end else 111 + 0 112 + with exn -> 113 + prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn)); 114 + 1 120 115 121 116 (* Extract outgoing links from Bushel entries *) 122 117 let update_from_bushel bushel_dir links_file include_domains exclude_domains = ··· 377 372 end 378 373 379 374 (* Upload links to Karakeep that don't already have karakeep data *) 380 - let upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose = 381 - match api_key_opt with 382 - | None -> 383 - log "Error: API key is required.\n"; 384 - log "Please provide one with --api-key or create a ~/.karakeep-api file.\n"; 385 - 1 386 - | Some api_key -> 387 - (* Load links from file *) 388 - log_verbose verbose "Loading links from %s...\n" links_file; 389 - let links = Bushel.Link.load_links_file links_file in 390 - log_verbose verbose "Loaded %d total links\n" (List.length links); 375 + let upload_to_karakeep ~sw ~env base_url profile links_file tag max_concurrent delay_seconds limit verbose = 376 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 377 + (* Load links from file *) 378 + log_verbose verbose "Loading links from %s...\n" links_file; 379 + let links = Bushel.Link.load_links_file links_file in 380 + log_verbose verbose "Loaded %d total links\n" (List.length links); 391 381 392 - (* Filter links that don't have karakeep data for this remote *) 393 - log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url; 394 - let filtered_links = filter_links_without_karakeep base_url links in 395 - log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links); 382 + (* Filter links that don't have karakeep data for this remote *) 383 + log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url; 384 + let filtered_links = filter_links_without_karakeep base_url links in 385 + log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links); 396 386 397 - (* Apply limit if specified *) 398 - let links_to_upload = apply_limit_to_links limit filtered_links in 387 + (* Apply limit if specified *) 388 + let links_to_upload = apply_limit_to_links limit filtered_links in 399 389 400 - if links_to_upload = [] then begin 401 - log "No links to upload to %s (all links already have karakeep data)\n" base_url; 402 - 0 403 - end else begin 404 - log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url; 390 + if links_to_upload = [] then begin 391 + log "No links to upload to %s (all links already have karakeep data)\n" base_url; 392 + 0 393 + end else begin 394 + log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url; 405 395 406 - (* Split links into batches for parallel processing *) 407 - let batches = create_batches max_concurrent links_to_upload in 408 - log_verbose verbose "Processing in %d batches of up to %d links each...\n" 409 - (List.length batches) max_concurrent; 410 - log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds; 396 + (* Split links into batches for parallel processing *) 397 + let batches = create_batches max_concurrent links_to_upload in 398 + log_verbose verbose "Processing in %d batches of up to %d links each...\n" 399 + (List.length batches) max_concurrent; 400 + log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds; 411 401 412 - (* Process batches and accumulate updated links *) 413 - let updated_links = ref [] in 402 + (* Process batches and accumulate updated links *) 403 + let updated_links = ref [] in 414 404 415 - let result = try 416 - let rec process_batches total_count batch_num = function 417 - | [] -> total_count 418 - | batch :: rest -> 419 - let results = process_batch ~sw ~env api_key base_url tag verbose updated_links 420 - batch_num (List.length batches) batch in 405 + let result = try 406 + let rec process_batches total_count batch_num = function 407 + | [] -> total_count 408 + | batch :: rest -> 409 + let results = process_batch ~sw ~env api_key base_url tag verbose updated_links 410 + batch_num (List.length batches) batch in 421 411 422 - (* Count successes in this batch *) 423 - let batch_successes = List.fold_left (+) 0 results in 424 - let new_total = total_count + batch_successes in 412 + (* Count successes in this batch *) 413 + let batch_successes = List.fold_left (+) 0 results in 414 + let new_total = total_count + batch_successes in 425 415 426 - log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n" 427 - (batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total)); 416 + log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n" 417 + (batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total)); 428 418 429 - (* Add a delay before processing the next batch *) 430 - if rest <> [] then begin 431 - log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds; 432 - Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds; 433 - end; 434 - process_batches new_total (batch_num + 1) rest 435 - in 436 - process_batches 0 0 batches 437 - with exn -> 438 - log "Error during upload operation: %s\n" (Printexc.to_string exn); 439 - 0 440 - in 419 + (* Add a delay before processing the next batch *) 420 + if rest <> [] then begin 421 + log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds; 422 + Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds; 423 + end; 424 + process_batches new_total (batch_num + 1) rest 425 + in 426 + process_batches 0 0 batches 427 + with exn -> 428 + log "Error during upload operation: %s\n" (Printexc.to_string exn); 429 + 0 430 + in 441 431 442 - (* Update the links file with the new karakeep_ids *) 443 - update_links_file links_file links updated_links; 432 + (* Update the links file with the new karakeep_ids *) 433 + update_links_file links_file links updated_links; 444 434 445 - log "Upload complete. %d/%d links uploaded successfully.\n" 446 - result (List.length links_to_upload); 435 + log "Upload complete. %d/%d links uploaded successfully.\n" 436 + result (List.length links_to_upload); 447 437 448 - 0 449 - end 438 + 0 439 + end 450 440 451 441 (* Common arguments *) 452 442 let links_file_arg = ··· 458 448 let default = "https://hoard.recoil.org" in 459 449 Arg.(value & opt string default & info ["url"] ~doc ~docv:"URL") 460 450 461 - let api_key_arg = 462 - let doc = "API key for Karakeep authentication (ak1_<key_id>_<secret>)" in 463 - let get_api_key () = 464 - let home = try Sys.getenv "HOME" with Not_found -> "." in 465 - let key_path = Filename.concat home ".karakeep-api" in 466 - try 467 - let ic = open_in key_path in 468 - let key = input_line ic in 469 - close_in ic; 470 - Some (String.trim key) 471 - with _ -> None 472 - in 473 - Arg.(value & opt (some string) (get_api_key ()) & info ["api-key"] ~doc ~docv:"API_KEY") 474 - 475 451 let tag_arg = 476 452 let doc = "Tag to filter or apply to bookmarks" in 477 453 Arg.(value & opt string "" & info ["tag"; "t"] ~doc ~docv:"TAG") ··· 509 485 Arg.(value & flag & info ["verbose"; "v"] ~doc) 510 486 511 487 (* Command definitions *) 512 - let init_cmd = 513 - let doc = "Initialize a new links.yml file" in 514 - let info = Cmd.info "init" ~doc in 515 - Cmd.v info Term.(const init_links_file $ links_file_arg) 488 + let make_cmd eio_env = 489 + let init_cmd = 490 + let doc = "Initialize a new links.yml file" in 491 + let info = Cmd.info "init" ~doc in 492 + Cmd.v info Term.(const init_links_file $ links_file_arg) 493 + in 516 494 517 - let karakeep_cmd = 518 - let doc = "Update links.yml with links from Karakeep" in 519 - let info = Cmd.info "karakeep" ~doc in 520 - Cmd.v info Term.(const (fun base_url api_key_opt tag links_file download_assets -> 521 - Eio_main.run @@ fun env -> 522 - Eio.Switch.run @@ fun sw -> 523 - update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets) 524 - $ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg) 495 + let karakeep_cmd = 496 + let doc = "Update links.yml with links from Karakeep" in 497 + let info = Cmd.info "karakeep" ~doc in 498 + let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in 499 + Cmd.v info Term.(const (fun base_url profile tag links_file download_assets -> 500 + Eio.Switch.run @@ fun sw -> 501 + update_from_karakeep ~sw ~env:eio_env base_url profile tag links_file download_assets) 502 + $ base_url_arg $ profile_term $ tag_arg $ links_file_arg $ download_assets_arg) 503 + in 525 504 526 - let bushel_cmd = 527 - let doc = "Update links.yml with outgoing links from Bushel entries" in 528 - let info = Cmd.info "bushel" ~doc in 529 - Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg) 505 + let bushel_cmd = 506 + let doc = "Update links.yml with outgoing links from Bushel entries" in 507 + let info = Cmd.info "bushel" ~doc in 508 + Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg) 509 + in 530 510 531 - let upload_cmd = 532 - let doc = "Upload links without karakeep data to Karakeep" in 533 - let info = Cmd.info "upload" ~doc in 534 - Cmd.v info Term.(const (fun base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose -> 535 - Eio_main.run @@ fun env -> 536 - Eio.Switch.run @@ fun sw -> 537 - upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose) 538 - $ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg) 511 + let upload_cmd = 512 + let doc = "Upload links without karakeep data to Karakeep" in 513 + let info = Cmd.info "upload" ~doc in 514 + let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in 515 + Cmd.v info Term.(const (fun base_url profile links_file tag max_concurrent delay_seconds limit verbose -> 516 + Eio.Switch.run @@ fun sw -> 517 + upload_to_karakeep ~sw ~env:eio_env base_url profile links_file tag max_concurrent delay_seconds limit verbose) 518 + $ base_url_arg $ profile_term $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg) 519 + in 539 520 540 - (* Export the term and cmd for use in main bushel.ml *) 541 - let cmd = 521 + (* Export the term and cmd for use in main bushel.ml *) 542 522 let doc = "Manage links between Bushel and Karakeep" in 543 523 let info = Cmd.info "links" ~doc in 544 524 Cmd.group info [init_cmd; karakeep_cmd; bushel_cmd; upload_cmd] 545 - 546 - (* For standalone execution *) 547 - (* Main entry point removed - accessed through bushel_main.ml *)
+63 -56
stack/bushel/bin/bushel_main.ml
··· 4 4 5 5 (* Import actual command implementations from submodules *) 6 6 7 - (* Faces command *) 8 - let faces_cmd = 9 - let doc = "Retrieve face thumbnails from Immich photo service" in 10 - let info = Cmd.info "faces" ~version ~doc in 11 - Cmd.v info Bushel_faces.term 7 + (* Build commands - these need Eio environment *) 8 + let build_commands env = 9 + (* Faces command *) 10 + let faces_cmd = Bushel_faces.make_cmd env in 12 11 13 - (* Links command - uses group structure *) 14 - let links_cmd = Bushel_links.cmd 12 + (* Links command - uses group structure *) 13 + let links_cmd = Bushel_links.make_cmd env in 15 14 16 - (* Obsidian command *) 17 - let obsidian_cmd = 18 - let doc = "Convert Bushel entries to Obsidian format" in 19 - let info = Cmd.info "obsidian" ~version ~doc in 20 - Cmd.v info Bushel_obsidian.term 15 + (* Obsidian command *) 16 + let obsidian_cmd = 17 + let doc = "Convert Bushel entries to Obsidian format" in 18 + let info = Cmd.info "obsidian" ~version ~doc in 19 + Cmd.v info Bushel_obsidian.term 20 + in 21 21 22 - (* Paper command *) 23 - let paper_cmd = 24 - let doc = "Fetch paper metadata from DOI" in 25 - let info = Cmd.info "paper" ~version ~doc in 26 - Cmd.v info Bushel_paper.term 22 + (* Paper command *) 23 + let paper_cmd = 24 + let doc = "Fetch paper metadata from DOI" in 25 + let info = Cmd.info "paper" ~version ~doc in 26 + Cmd.v info Bushel_paper.term 27 + in 27 28 28 - (* Paper classify command *) 29 - let paper_classify_cmd = Bushel_paper_classify.cmd 29 + (* Paper classify command *) 30 + let paper_classify_cmd = Bushel_paper_classify.cmd in 30 31 31 - (* Paper tex command *) 32 - let paper_tex_cmd = Bushel_paper_tex.cmd 32 + (* Paper tex command *) 33 + let paper_tex_cmd = Bushel_paper_tex.cmd in 33 34 34 - (* Thumbs command *) 35 - let thumbs_cmd = 36 - let doc = "Generate thumbnails from paper PDFs" in 37 - let info = Cmd.info "thumbs" ~version ~doc in 38 - Cmd.v info Bushel_thumbs.term 35 + (* Thumbs command *) 36 + let thumbs_cmd = 37 + let doc = "Generate thumbnails from paper PDFs" in 38 + let info = Cmd.info "thumbs" ~version ~doc in 39 + Cmd.v info Bushel_thumbs.term 40 + in 39 41 40 - (* Video command *) 41 - let video_cmd = 42 - let doc = "Fetch videos from PeerTube instances" in 43 - let info = Cmd.info "video" ~version ~doc in 44 - Cmd.v info Bushel_video.term 42 + (* Video command *) 43 + let video_cmd = 44 + let doc = "Fetch videos from PeerTube instances" in 45 + let info = Cmd.info "video" ~version ~doc in 46 + Cmd.v info Bushel_video.term 47 + in 45 48 46 - (* Video thumbs command *) 47 - let video_thumbs_cmd = Bushel_video_thumbs.cmd 49 + (* Video thumbs command *) 50 + let video_thumbs_cmd = Bushel_video_thumbs.cmd in 48 51 49 - (* Query command *) 50 - let query_cmd = 51 - let doc = "Query Bushel collections using multisearch" in 52 - let info = Cmd.info "query" ~version ~doc in 53 - Cmd.v info Bushel_search.term 52 + (* Query command *) 53 + let query_cmd = 54 + let doc = "Query Bushel collections using multisearch" in 55 + let info = Cmd.info "query" ~version ~doc in 56 + Cmd.v info (Bushel_search.make_term env) 57 + in 54 58 55 - (* Bibtex command *) 56 - let bibtex_cmd = 57 - let doc = "Export bibtex for all papers" in 58 - let info = Cmd.info "bibtex" ~version ~doc in 59 - Cmd.v info Bushel_bibtex.term 59 + (* Bibtex command *) 60 + let bibtex_cmd = 61 + let doc = "Export bibtex for all papers" in 62 + let info = Cmd.info "bibtex" ~version ~doc in 63 + Cmd.v info Bushel_bibtex.term 64 + in 60 65 61 - (* Ideas command *) 62 - let ideas_cmd = Bushel_ideas.cmd 66 + (* Ideas command *) 67 + let ideas_cmd = Bushel_ideas.cmd in 63 68 64 - (* Info command *) 65 - let info_cmd = Bushel_info.cmd 69 + (* Info command *) 70 + let info_cmd = Bushel_info.cmd in 66 71 67 - (* Missing command *) 68 - let missing_cmd = Bushel_missing.cmd 72 + (* Missing command *) 73 + let missing_cmd = Bushel_missing.cmd in 69 74 70 - (* Note DOI command *) 71 - let note_doi_cmd = Bushel_note_doi.cmd 75 + (* Note DOI command *) 76 + let note_doi_cmd = Bushel_note_doi.cmd in 72 77 73 - (* DOI resolve command *) 74 - let doi_cmd = Bushel_doi.cmd 78 + (* DOI resolve command *) 79 + let doi_cmd = Bushel_doi.cmd in 75 80 76 - (* Main command *) 77 - let bushel_cmd = 81 + (* Main command *) 78 82 let doc = "Bushel content management toolkit" in 79 83 let sdocs = Manpage.s_common_options in 80 84 let man = [ ··· 112 116 video_thumbs_cmd; 113 117 ] 114 118 115 - let () = exit (Cmd.eval' bushel_cmd) 119 + let () = 120 + Eio_main.run @@ fun env -> 121 + let bushel_cmd = build_commands env in 122 + exit (Cmd.eval' bushel_cmd)
+25 -23
stack/bushel/bin/bushel_search.ml
··· 2 2 3 3 (** TODO:claude Bushel search command for integration with main CLI *) 4 4 5 - let endpoint = 6 - let doc = "Typesense server endpoint URL" in 7 - Arg.(value & opt string "" & info ["endpoint"; "e"] ~doc) 8 - 9 - let api_key = 10 - let doc = "Typesense API key for authentication" in 11 - Arg.(value & opt string "" & info ["api-key"; "k"] ~doc) 12 - 5 + let endpoint_override = 6 + let doc = "Override Typesense server endpoint URL" in 7 + Arg.(value & opt (some string) None & info ["endpoint"; "e"] ~doc) 13 8 14 9 let limit = 15 10 let doc = "Maximum number of results to return" in ··· 24 19 Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" ~doc) 25 20 26 21 (** TODO:claude Search function using multisearch *) 27 - let search endpoint api_key query_text limit offset = 28 - let base_config = Bushel.Typesense.load_config_from_files () in 29 - let config = { 30 - Bushel.Typesense.endpoint = if endpoint = "" then base_config.endpoint else endpoint; 31 - api_key = if api_key = "" then base_config.api_key else api_key; 32 - openai_key = base_config.openai_key; 22 + let search env profile endpoint_override query_text limit offset = 23 + (* Get credentials from keyeio profile *) 24 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 25 + let default_endpoint = "http://localhost:8108" in 26 + let endpoint = 27 + match endpoint_override with 28 + | Some e -> e 29 + | None -> 30 + Keyeio.Profile.get profile ~key:"endpoint" 31 + |> Option.value ~default:default_endpoint 32 + in 33 + let openai_key = Keyeio.Profile.get_required profile ~key:"openai_key" in 34 + 35 + let config = { 36 + Bushel.Typesense.endpoint; 37 + api_key; 38 + openai_key; 33 39 } in 34 - 35 - if config.api_key = "" then ( 36 - Printf.eprintf "Error: API key is required. Use --api-key, set TYPESENSE_API_KEY environment variable, or create .typesense-key file.\n"; 37 - exit 1 38 - ); 39 - 40 + 40 41 Printf.printf "Searching Typesense at %s\n" config.endpoint; 41 42 Printf.printf "Query: \"%s\"\n" query_text; 42 43 Printf.printf "Limit: %d, Offset: %d\n" limit offset; 43 44 Printf.printf "\n"; 44 45 45 - Eio_main.run @@ fun env -> 46 46 Eio.Switch.run @@ fun sw -> 47 47 (try 48 48 let result = Bushel.Typesense.multisearch ~sw ~env config query_text ~limit:50 () in ··· 63 63 exit 1 64 64 ) 65 65 66 - (** TODO:claude Command line term *) 67 - let term = Term.(const search $ endpoint $ api_key $ query_text $ limit $ offset) 66 + (** TODO:claude Command line term - takes eio_env from outside *) 67 + let make_term eio_env = 68 + let profile_term = Bushel_common.typesense_key_term eio_env#fs in 69 + Term.(const (search eio_env) $ profile_term $ endpoint_override $ query_text $ limit $ offset)
+1 -1
stack/bushel/bin/dune
··· 1 1 (library 2 2 (name bushel_common) 3 3 (modules bushel_common) 4 - (libraries cmdliner fmt fmt.cli fmt.tty logs logs.cli logs.fmt)) 4 + (libraries cmdliner fmt fmt.cli fmt.tty logs logs.cli logs.fmt xdge keyeio eio)) 5 5 6 6 (executable 7 7 (name bushel_main)
+2
stack/bushel/bushel.opam
··· 26 26 "karakeepe" 27 27 "typesense-cliente" 28 28 "immiche" 29 + "xdge" 30 + "keyeio" 29 31 "cmdliner" {>= "2.0.0"} 30 32 "odoc" {with-doc} 31 33 ]
+2
stack/bushel/dune-project
··· 30 30 karakeepe 31 31 typesense-cliente 32 32 immiche 33 + xdge 34 + keyeio 33 35 (cmdliner (>= 2.0.0))))
+1 -1
stack/cacheio/bin/dune
··· 1 1 (executable 2 2 (public_name cacheio-example) 3 3 (name example) 4 - (libraries cacheio eio_main logs.fmt fmt.tty)) 4 + (libraries cacheio eio_main fmt logs.fmt logs logs.cli fmt.tty)) 5 5 6 6 (executable 7 7 (public_name cache-cli)
+136
stack/eiocmd/README.md
··· 1 + # Eiocmd - Batteries-Included CLI Runner for Eio 2 + 3 + Eiocmd provides a convenient wrapper for building command-line applications with [Eio](https://github.com/ocaml-multicore/eio). It handles common setup tasks and provides a clean interface for building CLIs with minimal boilerplate. 4 + 5 + ## Features 6 + 7 + - **Logging Setup**: Automatic configuration of Logs with Fmt reporter 8 + - **Cmdliner Integration**: Clean command-line argument parsing 9 + - **Optional XDG Support**: Integrate with [xdge](../xdge) for XDG directory management 10 + - **Optional Keyeio Support**: Integrate with [keyeio](../keyeio) for API key management 11 + - **Minimal Boilerplate**: Get started quickly with sensible defaults 12 + 13 + ## Installation 14 + 15 + ```bash 16 + opam install eiocmd 17 + ``` 18 + 19 + ## Quick Start 20 + 21 + ### Basic Usage 22 + 23 + ```ocaml 24 + open Cmdliner 25 + 26 + let main _env = 27 + Logs.info (fun m -> m "Hello, world!"); 28 + 0 29 + 30 + let () = 31 + let info = Cmd.info "myapp" ~version:"1.0.0" ~doc:"My application" in 32 + Eiocmd.run ~info main 33 + ``` 34 + 35 + This automatically provides: 36 + - `--log-level` flag (debug, info, warning, error, app) 37 + - `--color` flag (auto, always, never) 38 + - Proper Eio environment setup 39 + 40 + ### With XDG Directory Support 41 + 42 + ```ocaml 43 + let main _env (xdg, _xdg_cmd) = 44 + let config_dir = Xdge.config_dir xdg in 45 + Logs.info (fun m -> m "Config dir: %a" Eio.Path.pp config_dir); 46 + 0 47 + 48 + let () = 49 + let info = Cmd.info "myapp" ~doc:"My app with XDG support" in 50 + Eiocmd.run ~info ~xdge:(Some "myapp") main 51 + ``` 52 + 53 + ### With Keyeio API Key Management 54 + 55 + ```ocaml 56 + let main _env (_xdg, _xdg_cmd) profile = 57 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 58 + Logs.info (fun m -> m "Using API key: %s..." (String.sub api_key 0 8)); 59 + (* Use api_key to create your API client *) 60 + 0 61 + 62 + let () = 63 + let info = Cmd.info "myapp" ~doc:"My app with keyeio" in 64 + Eiocmd.run ~info 65 + ~xdge:(Some "myapp") 66 + ~keyeio:(Some "myservice") 67 + main 68 + ``` 69 + 70 + This automatically provides: 71 + - `--profile` flag to select credential profile 72 + - `--key-file` flag to override credential file location 73 + - Automatic loading of credentials from `~/.config/myapp/keys/myservice.toml` 74 + 75 + ## API Documentation 76 + 77 + See [the mli file](lib/eiocmd.mli) for full API documentation. 78 + 79 + ### Log Levels 80 + 81 + The `--log-level` flag accepts: 82 + - `debug` - Debug messages and above 83 + - `info` - Informational messages and above (default) 84 + - `warning` - Warnings and above 85 + - `error` - Errors only 86 + - `app` - Application-specific messages 87 + 88 + ### Color Output 89 + 90 + The `--color` flag accepts: 91 + - `auto` - Detect TTY capability (default) 92 + - `always` - Force color output 93 + - `never` - Disable color output 94 + 95 + ## Advanced Usage 96 + 97 + For more control, you can use the low-level `Setup` module: 98 + 99 + ```ocaml 100 + let () = 101 + Eio_main.run @@ fun env -> 102 + 103 + (* Initialize components manually *) 104 + Eiocmd.Setup.init_rng env; 105 + Eiocmd.Setup.init_logs ~level:(Some Logs.Debug) (); 106 + 107 + (* Your application logic *) 108 + Logs.debug (fun m -> m "Starting application"); 109 + (* ... *) 110 + ``` 111 + 112 + Or compose your own Cmdliner terms using `Eiocmd.Terms`: 113 + 114 + ```ocaml 115 + let main log_level style_renderer custom_arg = 116 + Eio_main.run @@ fun env -> 117 + Eiocmd.Setup.init_logs ~level:log_level ~style_renderer (); 118 + (* ... *) 119 + 120 + let () = 121 + let custom_arg = Arg.(value & opt string "default" & info ["custom"]) in 122 + let term = Term.(const main 123 + $ Eiocmd.Terms.log_level 124 + $ Eiocmd.Terms.style_renderer 125 + $ custom_arg) in 126 + let info = Cmd.info "myapp" in 127 + exit (Cmd.eval' (Cmd.v info term)) 128 + ``` 129 + 130 + ## Examples 131 + 132 + See the [example directory](example/) for more comprehensive examples. 133 + 134 + ## License 135 + 136 + ISC License - see LICENSE file for details.
+39
stack/eiocmd/dune-project
··· 1 + (lang dune 3.0) 2 + 3 + (name eiocmd) 4 + 5 + (generate_opam_files true) 6 + 7 + (source 8 + (github avsm/knot)) 9 + 10 + (authors "Anil Madhavapeddy") 11 + 12 + (maintainers "anil@recoil.org") 13 + 14 + (license ISC) 15 + 16 + (package 17 + (name eiocmd) 18 + (synopsis "Batteries-included CLI runner for Eio applications") 19 + (description 20 + "Eiocmd provides a convenient wrapper for building command-line applications with Eio. It handles common setup tasks including: random number generator initialization, logging configuration (logs/fmt), CLI argument parsing (cmdliner), and optional integration with xdge (XDG directories) and keyeio (API key management).") 21 + (depends 22 + (ocaml 23 + (>= 5.1.0)) 24 + (eio 25 + (>= 1.0)) 26 + (eio_main 27 + (>= 1.0)) 28 + (cmdliner 29 + (>= 1.3.0)) 30 + (logs 31 + (>= 0.7.0)) 32 + (fmt 33 + (>= 0.9.0)) 34 + (toml 35 + (>= 7.0.0)) 36 + (mirage-crypto-rng 37 + (>= 1.0.0)) 38 + xdge 39 + keyeio))
+39
stack/eiocmd/eiocmd.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Batteries-included CLI runner for Eio applications" 4 + description: 5 + "Eiocmd provides a convenient wrapper for building command-line applications with Eio. It handles common setup tasks including: random number generator initialization, logging configuration (logs/fmt), CLI argument parsing (cmdliner), and optional integration with xdge (XDG directories) and keyeio (API key management)." 6 + maintainer: ["anil@recoil.org"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + homepage: "https://github.com/avsm/knot" 10 + bug-reports: "https://github.com/avsm/knot/issues" 11 + depends: [ 12 + "dune" {>= "3.0"} 13 + "ocaml" {>= "5.1.0"} 14 + "eio" {>= "1.0"} 15 + "eio_main" {>= "1.0"} 16 + "cmdliner" {>= "1.3.0"} 17 + "logs" {>= "0.7.0"} 18 + "fmt" {>= "0.9.0"} 19 + "toml" {>= "7.0.0"} 20 + "mirage-crypto-rng" {>= "1.0.0"} 21 + "xdge" 22 + "keyeio" 23 + "odoc" {with-doc} 24 + ] 25 + build: [ 26 + ["dune" "subst"] {dev} 27 + [ 28 + "dune" 29 + "build" 30 + "-p" 31 + name 32 + "-j" 33 + jobs 34 + "@install" 35 + "@runtest" {with-test} 36 + "@doc" {with-doc} 37 + ] 38 + ] 39 + dev-repo: "git+https://github.com/avsm/knot.git"
+3
stack/eiocmd/example/dune
··· 1 + (executable 2 + (name eiocmd_example) 3 + (libraries eiocmd cmdliner eio_main logs.fmt))
+34
stack/eiocmd/example/eiocmd_example.ml
··· 1 + open Cmdliner 2 + 3 + let main _env xdg profile = 4 + Logs.app (fun m -> m "Starting example application"); 5 + 6 + (* Show XDG paths *) 7 + let config_dir = Xdge.config_dir xdg in 8 + Logs.info (fun m -> m "Config dir: %a" Eio.Path.pp config_dir); 9 + 10 + let cache_dir = Xdge.cache_dir xdg in 11 + Logs.info (fun m -> m "Cache dir: %a" Eio.Path.pp cache_dir); 12 + 13 + let data_dir = Xdge.data_dir xdg in 14 + Logs.info (fun m -> m "Data dir: %a" Eio.Path.pp data_dir); 15 + 16 + (* Show profile info *) 17 + Logs.info (fun m -> m "Profile keys: %a" 18 + Fmt.(list ~sep:comma string) 19 + (Keyeio.Profile.keys profile)); 20 + 21 + 0 22 + 23 + let () = 24 + let info = Cmd.info "eiocmd-example" 25 + ~version:"0.1.0" 26 + ~doc:"Example application using eiocmd" 27 + in 28 + let cmd = Eiocmd.run 29 + ~info 30 + ~app_name:"eiocmd-example" 31 + ~service:"example" 32 + Term.(const main) 33 + in 34 + exit (Cmd.eval' cmd)
+4
stack/eiocmd/lib/dune
··· 1 + (library 2 + (public_name eiocmd) 3 + (name eiocmd) 4 + (libraries eio eio_main cmdliner logs logs.fmt logs.cli fmt xdge keyeio toml mirage-crypto-rng.unix))
+62
stack/eiocmd/lib/eiocmd.ml
··· 1 + (** Batteries-included CLI runner for Eio applications *) 2 + 3 + (** {1 Running Applications} *) 4 + 5 + (* Batteries-included run with XDG and keyeio *) 6 + let run ~info ~app_name ~service main_term = 7 + let open Cmdliner in 8 + 9 + let run_main main profile_name_opt key_file_opt = 10 + (* Initialize RNG with default entropy source *) 11 + let () = Mirage_crypto_rng_unix.use_default () in 12 + 13 + Eio_main.run @@ fun env -> 14 + 15 + (* Create xdg context *) 16 + let xdg = Xdge.create env#fs app_name in 17 + 18 + (* Load keyeio profile *) 19 + let keyeio_ctx = Keyeio.create xdg in 20 + let profile_name = Option.value profile_name_opt ~default:"default" in 21 + 22 + let profile = match key_file_opt with 23 + | Some _path -> 24 + (* TODO: Load from specified file *) 25 + failwith "Direct file loading not yet supported in eiocmd (use --profile instead)" 26 + | None -> 27 + (* Load from XDG directory *) 28 + match Keyeio.load_service keyeio_ctx ~service with 29 + | Ok svc -> 30 + (match Keyeio.Service.get_profile svc profile_name with 31 + | Some prof -> prof 32 + | None -> 33 + failwith (Printf.sprintf "Profile '%s' not found in service '%s'" 34 + profile_name service)) 35 + | Error (`Msg msg) -> failwith msg 36 + in 37 + 38 + main env xdg profile 39 + in 40 + 41 + (* Add profile and key-file flags *) 42 + let profile_flag = 43 + let doc = Printf.sprintf "Profile name to use for %s service" service in 44 + Arg.(value & opt (some string) None & info [ "profile" ] ~docv:"NAME" ~doc) 45 + in 46 + let key_file_flag = 47 + let doc = Printf.sprintf "Override with direct path to %s key file" service in 48 + Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc) 49 + in 50 + 51 + (* Compose with main term and add logging setup *) 52 + let term = 53 + let open Term.Syntax in 54 + let+ main = main_term 55 + and+ log_level = Logs_cli.level () 56 + and+ profile_name_opt = profile_flag 57 + and+ key_file_opt = key_file_flag in 58 + Logs.set_reporter (Logs_fmt.reporter ()); 59 + Logs.set_level log_level; 60 + run_main main profile_name_opt key_file_opt 61 + in 62 + Cmd.v info term
+24
stack/eiocmd/lib/eiocmd.mli
··· 1 + (** Batteries-included CLI runner for Eio applications 2 + 3 + Eiocmd provides a convenient wrapper for building command-line applications 4 + with Eio. It automatically handles common setup tasks and provides a clean 5 + interface for building CLIs with minimal boilerplate. *) 6 + 7 + (** {1 Running Applications} *) 8 + 9 + (** [run ~info ~app_name ~service main_term] creates a batteries-included CLI command. 10 + 11 + This is a comprehensive runner that sets up everything a typical CLI application needs: 12 + - Random number generator initialization (Mirage_crypto_rng) 13 + - Logging (via Logs_cli with standard flags) 14 + - Cmdliner argument parsing 15 + - XDG directory structure (all directories created) 16 + - API key management via keyeio 17 + ]} *) 18 + val run : 19 + info:Cmdliner.Cmd.info -> 20 + app_name:string -> 21 + service:string -> 22 + (Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Cmdliner.Term.t -> 23 + Cmdliner.Cmd.Exit.code Cmdliner.Cmd.t 24 +
+5 -2
stack/karakeepe/dune-project
··· 7 7 (package 8 8 (name karakeepe) 9 9 (synopsis "Karakeep API client for OCaml using Eio") 10 - (description "An Eio-based OCaml client library for the Karakeep bookmark management service API") 10 + (description "An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface") 11 11 (depends 12 12 (ocaml (>= 4.14)) 13 13 eio ··· 16 16 ezjsonm 17 17 fmt 18 18 ptime 19 - uri)) 19 + uri 20 + (keyeio (>= 0.1.0)) 21 + (xdge (>= 0.1.0)) 22 + (cmdliner (>= 1.2.0))))
+4 -1
stack/karakeepe/karakeepe.opam
··· 3 3 version: "0.1.0" 4 4 synopsis: "Karakeep API client for OCaml using Eio" 5 5 description: 6 - "An Eio-based OCaml client library for the Karakeep bookmark management service API" 6 + "An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface" 7 7 depends: [ 8 8 "dune" {>= "3.0"} 9 9 "ocaml" {>= "4.14"} ··· 14 14 "fmt" 15 15 "ptime" 16 16 "uri" 17 + "keyeio" {>= "0.1.0"} 18 + "xdge" {>= "0.1.0"} 19 + "cmdliner" {>= "1.2.0"} 17 20 "odoc" {with-doc} 18 21 ] 19 22 build: [
+1 -1
stack/keyeio/CLAUDE.md
··· 13 13 ## Design Principles 14 14 15 15 - Store credentials in XDG_CONFIG_HOME/appname/keys/ with 0o600 permissions 16 - - JSON format supporting multiple profiles per service 16 + - TOML format supporting multiple profiles per service 17 17 - Cmdliner integration following xdge patterns 18 18 - Future-proof design for Secret Service API integration 19 19 - Security-conscious pretty printing (mask sensitive values)
+17 -20
stack/keyeio/README.md
··· 51 51 52 52 ### Storage Format 53 53 54 - Keys are stored in `~/.config/<appname>/keys/<service>.json`: 54 + Keys are stored in `~/.config/<appname>/keys/<service>.toml`: 55 + 56 + ```toml 57 + [default] 58 + api_key = "abc123..." 59 + base_url = "https://api.example.com" 60 + 61 + [production] 62 + api_key = "xyz789..." 63 + base_url = "https://api.prod.example.com" 55 64 56 - ```json 57 - { 58 - "default": { 59 - "api_key": "abc123...", 60 - "base_url": "https://api.example.com" 61 - }, 62 - "production": { 63 - "api_key": "xyz789...", 64 - "base_url": "https://api.prod.example.com" 65 - }, 66 - "staging": { 67 - "api_key": "def456...", 68 - "base_url": "https://api.staging.example.com" 69 - } 70 - } 65 + [staging] 66 + api_key = "def456..." 67 + base_url = "https://api.staging.example.com" 71 68 ``` 72 69 73 70 ## Examples ··· 134 131 val load_service : t -> service:string -> (Service.t, [> `Msg of string]) result 135 132 ``` 136 133 137 - Load all profiles for a service from `~/.config/<appname>/keys/<service>.json`. 134 + Load all profiles for a service from `~/.config/<appname>/keys/<service>.toml`. 138 135 139 136 ```ocaml 140 137 val list_services : t -> (string list, [> `Msg of string]) result 141 138 ``` 142 139 143 - List all available services (JSON files in the keys directory). 140 + List all available services (TOML files in the keys directory). 144 141 145 142 ### Working with Profiles 146 143 ··· 192 189 193 190 ### Current Security Model 194 191 195 - - Keys stored as JSON files in `~/.config/<appname>/keys/` 192 + - Keys stored as TOML files in `~/.config/<appname>/keys/` 196 193 - Files created with permissions `0o600` (owner read/write only) 197 194 - Sensitive values masked in pretty-printing output 198 195 - Follows standard Unix file permission security model ··· 251 248 - `XDG_CONFIG_HOME`: Base directory for config files (default: `~/.config`) 252 249 - `<APPNAME>_CONFIG_DIR`: Application-specific override (highest priority) 253 250 254 - Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.json` 251 + Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.toml` 255 252 256 253 ## Documentation 257 254
+1 -1
stack/keyeio/dune-project
··· 26 26 (eio (>= 1.1)) 27 27 eio_main 28 28 (xdge (>= 0.1.0)) 29 - (yojson (>= 2.0.0)) 29 + (toml (>= 7.0.0)) 30 30 (cmdliner (>= 1.2.0)) 31 31 (fmt (>= 0.11.0)) 32 32 (odoc :with-doc)
+31 -8
stack/keyeio/example/keyeio_example.ml
··· 22 22 Fmt.pr "Available keys: %a@." Fmt.(list ~sep:comma string) keys; 23 23 24 24 (* Pretty print the profile *) 25 - Fmt.pr "@.Profile details:@.%a@." Keyeio.Profile.pp profile 25 + Fmt.pr "@.Profile details:@.%a@." Keyeio.Profile.pp profile; 26 + 0 26 27 27 28 (** Example 2: List all services *) 28 29 let list_services_example (xdg, _xdg_cmd) = ··· 37 38 else begin 38 39 Fmt.pr "Available services:@."; 39 40 List.iter (fun svc -> Fmt.pr " - %s@." svc) services 40 - end 41 + end; 42 + 0 41 43 | Error (`Msg msg) -> 42 - Fmt.epr "Error listing services: %s@." msg 44 + Fmt.epr "Error listing services: %s@." msg; 45 + 1 43 46 44 47 (** Example 3: Load service and list profiles *) 45 48 let list_profiles_example (xdg, _xdg_cmd) service_name = ··· 64 67 Fmt.pr "@."; 65 68 66 69 (* Pretty print the service *) 67 - Fmt.pr "@.Service details:@.%a@." Keyeio.Service.pp service 70 + Fmt.pr "@.Service details:@.%a@." Keyeio.Service.pp service; 71 + 0 68 72 | Error (`Msg msg) -> 69 - Fmt.epr "Error loading service '%s': %s@." service_name msg 73 + Fmt.epr "Error loading service '%s': %s@." service_name msg; 74 + 1 70 75 71 76 (** Example 4: Simulated API client using loaded credentials *) 72 77 let api_client_example (_xdg, _xdg_cmd) profile = ··· 86 91 Fmt.pr "@.Simulating API request...@."; 87 92 Fmt.pr "GET %s/api/status@." base_url; 88 93 Fmt.pr "Authorization: Bearer %s@." (String.sub api_key 0 (min 8 (String.length api_key)) ^ "..."); 89 - Fmt.pr "@.Response: 200 OK@." 94 + Fmt.pr "@.Response: 200 OK@."; 95 + 0 90 96 91 97 (** Main command dispatcher *) 92 98 let () = ··· 134 140 Cmd.v info Term.(const api_client_example $ xdg_term $ profile_term) 135 141 in 136 142 143 + (* Command: init - Create a keyfile *) 144 + let init_cmd = 145 + let default_data = [ 146 + ("api_key", None); (* Will prompt if not provided *) 147 + ("base_url", Some "https://immich.example.com") (* Has default *) 148 + ] in 149 + let init_term = Keyeio.Cmd.create_term 150 + ~app_name:"keyeio-example" 151 + ~fs:env#fs 152 + ~service:"immiche" 153 + ~default_data 154 + () 155 + in 156 + let info = Cmd.info "init" ~doc:"Create keyeio credentials" in 157 + Cmd.v info init_term 158 + in 159 + 137 160 (* Main command group *) 138 161 let main_cmd = 139 162 let info = Cmd.info "keyeio-example" ··· 156 179 `Pre " $(b,keyeio-example client --profile staging)"; 157 180 ] 158 181 in 159 - Cmd.group info [basic_cmd; list_cmd; profiles_cmd; client_cmd] 182 + Cmd.group info [init_cmd; basic_cmd; list_cmd; profiles_cmd; client_cmd] 160 183 in 161 184 162 - exit (Cmd.eval main_cmd) 185 + exit (Cmd.eval' main_cmd)
+1 -1
stack/keyeio/keyeio.opam
··· 14 14 "eio" {>= "1.1"} 15 15 "eio_main" 16 16 "xdge" {>= "0.1.0"} 17 - "yojson" {>= "2.0.0"} 17 + "toml" {>= "7.0.0"} 18 18 "cmdliner" {>= "1.2.0"} 19 19 "fmt" {>= "0.11.0"} 20 20 "odoc" {with-doc}
+1 -1
stack/keyeio/lib/dune
··· 1 1 (library 2 2 (public_name keyeio) 3 3 (name keyeio) 4 - (libraries eio eio_main xdge yojson cmdliner fmt)) 4 + (libraries eio eio_main xdge toml cmdliner fmt))
+174 -47
stack/keyeio/lib/keyeio.ml
··· 31 31 32 32 let keys t = List.map fst t.data 33 33 34 - let to_json t = 35 - let obj = List.map (fun (k, v) -> (k, `String v)) t.data in 36 - `Assoc obj 34 + let to_toml t = 35 + let table = Toml.Types.Table.empty in 36 + List.fold_left 37 + (fun tbl (k, v) -> Toml.Types.Table.add (Toml.Types.Table.Key.of_string k) (Toml.Types.TString v) tbl) 38 + table 39 + t.data 37 40 38 41 let pp ppf t = 39 42 let mask_sensitive key = ··· 93 96 94 97 { xdg; backend = Filesystem { keys_dir } } 95 98 96 - (** {1 JSON Parsing Helpers} *) 99 + (** {1 TOML Parsing Helpers} *) 97 100 98 - let parse_profile ~service ~profile_name json = 99 - match json with 100 - | `Assoc fields -> 101 - let data = 102 - List.filter_map 103 - (fun (k, v) -> 104 - match v with 105 - | `String s -> Some (k, s) 106 - | _ -> None) 107 - fields 108 - in 109 - { Profile.service; name = profile_name; data } 110 - | _ -> 111 - raise 112 - (Invalid_key_file 113 - (Printf.sprintf "Profile '%s' in service '%s' is not a JSON object" 114 - profile_name service)) 101 + let parse_profile ~service ~profile_name table = 102 + let data = 103 + Toml.Types.Table.fold 104 + (fun key value acc -> 105 + match value with 106 + | Toml.Types.TString s -> 107 + let key_str = Toml.Types.Table.Key.to_string key in 108 + (key_str, s) :: acc 109 + | _ -> acc) 110 + table 111 + [] 112 + in 113 + { Profile.service; name = profile_name; data } 115 114 116 - let parse_service_file ~service json = 117 - match json with 118 - | `Assoc profile_list -> 119 - let profiles = 120 - List.map 121 - (fun (profile_name, profile_json) -> 122 - (profile_name, parse_profile ~service ~profile_name profile_json)) 123 - profile_list 124 - in 125 - { Service.name = service; profiles } 126 - | _ -> 127 - raise 128 - (Invalid_key_file (Printf.sprintf "Service file '%s.json' is not a JSON object" service)) 115 + let parse_service_file ~service toml_table = 116 + let profiles = 117 + Toml.Types.Table.fold 118 + (fun key value acc -> 119 + match value with 120 + | Toml.Types.TTable profile_table -> 121 + let profile_name = Toml.Types.Table.Key.to_string key in 122 + (profile_name, parse_profile ~service ~profile_name profile_table) :: acc 123 + | _ -> acc) 124 + toml_table 125 + [] 126 + in 127 + if profiles = [] then 128 + raise 129 + (Invalid_key_file (Printf.sprintf "Service file '%s.toml' contains no valid profile tables" service)) 130 + else 131 + { Service.name = service; profiles } 129 132 130 133 (** {1 File Operations} *) 131 134 135 + let create_default_keyfile t ~service ~profile ~data = 136 + match t.backend with 137 + | Filesystem { keys_dir } -> 138 + let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in 139 + (try 140 + (* Load existing service file if it exists, otherwise start fresh *) 141 + let existing_profiles = 142 + try 143 + let content = Eio.Path.load service_file in 144 + let toml = Toml.Parser.(from_string content |> unsafe) in 145 + let svc = parse_service_file ~service toml in 146 + svc.Service.profiles 147 + with 148 + | Eio.Io (Eio.Fs.E (Not_found _), _) -> [] 149 + in 150 + 151 + (* Create or update the profile *) 152 + let new_profile = { Profile.service; name = profile; data } in 153 + let updated_profiles = 154 + (* Remove existing profile with same name if present *) 155 + List.filter (fun (name, _) -> name <> profile) existing_profiles 156 + @ [(profile, new_profile)] 157 + in 158 + 159 + (* Build TOML structure *) 160 + let toml_table = Toml.Types.Table.empty in 161 + let toml_table = 162 + List.fold_left 163 + (fun tbl (prof_name, prof) -> 164 + let prof_table = Profile.to_toml prof in 165 + Toml.Types.Table.add 166 + (Toml.Types.Table.Key.of_string prof_name) 167 + (Toml.Types.TTable prof_table) 168 + tbl) 169 + toml_table 170 + updated_profiles 171 + in 172 + 173 + (* Convert to TOML string *) 174 + let toml_str = Toml.Printer.string_of_table toml_table in 175 + 176 + (* Write to file with restrictive permissions *) 177 + Eio.Path.save ~create:(`Or_truncate 0o600) service_file toml_str; 178 + 179 + Ok () 180 + with 181 + | Toml.Parser.Error (msg, _) -> 182 + Error (`Msg (Printf.sprintf "Invalid TOML in existing %s.toml: %s" service msg)) 183 + | exn -> 184 + Error (`Msg (Printf.sprintf "Error creating key file: %s" (Printexc.to_string exn)))) 185 + 132 186 let load_service t ~service = 133 187 match t.backend with 134 188 | Filesystem { keys_dir } -> 135 - let service_file = Eio.Path.(keys_dir / (service ^ ".json")) in 189 + let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in 136 190 (try 137 - (* Read and parse the JSON file *) 191 + (* Read and parse the TOML file *) 138 192 let content = Eio.Path.load service_file in 139 - let json = Yojson.Basic.from_string content in 140 - let service_data = parse_service_file ~service json in 193 + let toml = Toml.Parser.(from_string content |> unsafe) in 194 + let service_data = parse_service_file ~service toml in 141 195 Ok service_data 142 196 with 143 197 | Eio.Io (Eio.Fs.E (Not_found _), _) -> 144 - Error (`Msg (Printf.sprintf "Service file not found: %s.json" service)) 145 - | Yojson.Json_error msg -> 146 - Error (`Msg (Printf.sprintf "Invalid JSON in %s.json: %s" service msg)) 198 + Error (`Msg (Printf.sprintf "Service file not found: %s.toml" service)) 199 + | Toml.Parser.Error (msg, _) -> 200 + Error (`Msg (Printf.sprintf "Invalid TOML in %s.toml: %s" service msg)) 147 201 | Invalid_key_file msg -> Error (`Msg msg) 148 202 | exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn)))) 149 203 ··· 155 209 let services = 156 210 List.filter_map 157 211 (fun entry -> 158 - if String.ends_with ~suffix:".json" entry then 212 + if String.ends_with ~suffix:".toml" entry then 159 213 Some (String.sub entry 0 (String.length entry - 5)) 160 214 else None) 161 215 entries ··· 204 258 | Some path -> 205 259 (try 206 260 let content = In_channel.with_open_bin path In_channel.input_all in 207 - let json = Yojson.Basic.from_string content in 208 - match parse_service_file ~service json with 261 + let toml = Toml.Parser.(from_string content |> unsafe) in 262 + match parse_service_file ~service toml with 209 263 | svc -> 210 264 (match Service.get_profile svc profile_name with 211 265 | Some prof -> prof ··· 238 292 | Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag) 239 293 | None -> Term.(const load_profile $ profile_flag $ const None) 240 294 295 + let create_term ~app_name ~fs ~service ~default_data 296 + ?profile:(default_profile = "default") () = 297 + let open Cmdliner in 298 + 299 + (* Profile name flag *) 300 + let profile_flag = 301 + let doc = Printf.sprintf "Profile name to create for %s service" service in 302 + Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc) 303 + in 304 + 305 + (* Create flags for each key in default_data *) 306 + let key_flags = 307 + List.map 308 + (fun (key, default_val) -> 309 + let flag_name = String.map (fun c -> if c = '_' then '-' else c) key in 310 + let doc = Printf.sprintf "Value for %s" key in 311 + let term = 312 + Arg.(value & opt (some string) default_val & info [ flag_name ] ~docv:(String.uppercase_ascii key) ~doc) 313 + in 314 + (key, term)) 315 + default_data 316 + in 317 + 318 + (* Helper to prompt for a value if not provided *) 319 + let prompt_for_value key = 320 + Printf.printf "Enter %s: %!" key; 321 + try 322 + input_line stdin 323 + with End_of_file -> 324 + failwith (Printf.sprintf "Failed to read %s from stdin" key) 325 + in 326 + 327 + (* Term that creates the keyfile *) 328 + let create_keyfile profile_name key_values = 329 + try 330 + (* Build the data list, prompting for missing values *) 331 + let data = 332 + List.map 333 + (fun (key, value_opt) -> 334 + match value_opt with 335 + | Some v -> (key, v) 336 + | None -> 337 + let prompted = prompt_for_value key in 338 + (key, prompted)) 339 + (List.combine (List.map fst default_data) key_values) 340 + in 341 + 342 + (* Create the keyfile *) 343 + let xdg = Xdge.create fs app_name in 344 + let keyeio = create xdg in 345 + match create_default_keyfile keyeio ~service ~profile:profile_name ~data with 346 + | Ok () -> 347 + let keys_dir = match keyeio.backend with 348 + | Filesystem { keys_dir } -> Eio.Path.native_exn keys_dir 349 + in 350 + Printf.printf "Created %s profile in %s/%s.toml\n" profile_name keys_dir service; 351 + 0 352 + | Error (`Msg msg) -> 353 + Printf.eprintf "Failed to create key file: %s\n" msg; 354 + 1 355 + with exn -> 356 + Printf.eprintf "Error: %s\n" (Printexc.to_string exn); 357 + 1 358 + in 359 + 360 + (* Build the term by applying all key flags *) 361 + let rec build_term acc_term = function 362 + | [] -> Term.(const create_keyfile $ profile_flag $ acc_term) 363 + | (_, flag_term) :: rest -> 364 + build_term Term.(const (fun lst x -> lst @ [x]) $ acc_term $ flag_term) rest 365 + in 366 + build_term (Term.const []) key_flags 367 + 241 368 let env_docs ~app_name ~service () = 242 369 Printf.sprintf 243 370 {|ENVIRONMENT ··· 247 374 XDG_CONFIG_HOME 248 375 Base directory for configuration files. If not set, defaults to 249 376 $HOME/.config. Keys for %s will be stored in: 250 - $XDG_CONFIG_HOME/%s/keys/%s.json 377 + $XDG_CONFIG_HOME/%s/keys/%s.toml 251 378 252 379 Example locations: 253 - ~/.config/%s/keys/%s.json (default) 254 - /custom/config/%s/keys/%s.json (if XDG_CONFIG_HOME=/custom/config) 380 + ~/.config/%s/keys/%s.toml (default) 381 + /custom/config/%s/keys/%s.toml (if XDG_CONFIG_HOME=/custom/config) 255 382 256 383 File permissions should be 0600 (owner read/write only) for security. 257 384 |}
+114 -31
stack/keyeio/lib/keyeio.mli
··· 9 9 10 10 - Store API keys in XDG-compliant directories with proper permissions 11 11 - Support multiple profiles per service (production, staging, development) 12 - - JSON-based storage format for flexibility 12 + - TOML-based storage format for readability and flexibility 13 13 - Cmdliner integration for easy command-line usage 14 14 - Designed for future Secret Service API integration 15 15 16 16 {b Security Model:} 17 17 18 - Currently, credentials are stored as JSON files in [XDG_CONFIG_HOME/appname/keys/] 18 + Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/] 19 19 with strict filesystem permissions (0o600 - owner read/write only). This follows 20 20 common practice for CLI tools and provides reasonable security for single-user 21 21 systems. ··· 26 26 27 27 {b Storage Structure:} 28 28 29 - Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.json] where SERVICE 29 + Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE 30 30 is the name of the service (e.g., "immiche", "karakeepe"). Each service file 31 31 contains one or more named profiles: 32 32 33 33 {v 34 - { 35 - "default": { 36 - "api_key": "abc123...", 37 - "base_url": "https://api.example.com" 38 - }, 39 - "production": { 40 - "api_key": "xyz789...", 41 - "base_url": "https://api.prod.example.com" 42 - } 43 - } 34 + [default] 35 + api_key = "abc123..." 36 + base_url = "https://api.example.com" 37 + 38 + [production] 39 + api_key = "xyz789..." 40 + base_url = "https://api.prod.example.com" 44 41 v} 45 42 46 43 {b Example Usage:} ··· 87 84 (** Exception raised when a profile is not found in a service. *) 88 85 exception Profile_not_found of string 89 86 90 - (** Exception raised when attempting to access invalid JSON structure. *) 87 + (** Exception raised when attempting to access invalid TOML structure. *) 91 88 exception Invalid_key_file of string 92 89 93 90 (** {1 Profile} *) ··· 155 152 ]} *) 156 153 val keys : t -> string list 157 154 158 - (** [to_json t] converts the profile to a JSON representation. 155 + (** [to_toml t] converts the profile to a TOML table representation. 159 156 160 - Returns a JSON object containing all key-value pairs in the profile. 157 + Returns a TOML table containing all key-value pairs in the profile. 161 158 162 159 @param t The profile to convert 163 - @return A JSON object representation *) 164 - val to_json : t -> Yojson.Basic.t 160 + @return A TOML table representation *) 161 + val to_toml : t -> Toml.Types.table 165 162 166 163 (** [pp ppf t] pretty prints a profile for debugging. 167 164 ··· 182 179 For example, an "immiche" service might contain "default", "production", 183 180 and "staging" profiles, each with their own credentials. 184 181 185 - Services are loaded from JSON files in the keys directory, with one file 186 - per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.json] *) 182 + Services are loaded from TOML files in the keys directory, with one file 183 + per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *) 187 184 module Service : sig 188 185 (** The type of a service containing multiple profiles. *) 189 186 type t ··· 255 252 ]} *) 256 253 val create : Xdge.t -> t 257 254 255 + (** {1 Creating Credentials} *) 256 + 257 + (** [create_default_keyfile t ~service ~profile ~data] creates a new credential file. 258 + 259 + Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with 260 + the provided profile and key-value pairs. If the file already exists, 261 + it will be loaded and the new profile will be added or updated. 262 + 263 + @param t The Keyeio context 264 + @param service The service name (e.g., "karakeepe") 265 + @param profile The profile name (default: "default") 266 + @param data Key-value pairs to store in the profile 267 + @return [Ok ()] on success, [Error (`Msg msg)] on failure 268 + 269 + {b Example:} 270 + {[ 271 + let data = [ 272 + ("api_key", "ak1_example_key"); 273 + ("base_url", "https://api.example.com") 274 + ] in 275 + match Keyeio.create_default_keyfile keyeio ~service:"karakeepe" 276 + ~profile:"default" ~data with 277 + | Ok () -> Printf.printf "Key file created successfully\n" 278 + | Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg 279 + ]} 280 + 281 + {b Security:} The file is created with permissions 0o600 (owner read/write only). *) 282 + val create_default_keyfile : 283 + t -> 284 + service:string -> 285 + profile:string -> 286 + data:(string * string) list -> 287 + (unit, [> `Msg of string ]) result 288 + 258 289 (** {1 Loading Credentials} *) 259 290 260 291 (** [load_service t ~service] loads all profiles for a given service. 261 292 262 - Reads the JSON file [XDG_CONFIG_HOME/appname/keys/SERVICE.json] and 263 - parses all profiles contained within. The file must be a JSON object 264 - where each key is a profile name and each value is an object containing 265 - credential key-value pairs. 293 + Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and 294 + parses all profiles contained within. The file must contain TOML tables 295 + where each section name is a profile name containing credential key-value pairs. 266 296 267 297 @param t The Keyeio context 268 298 @param service The service name to load (e.g., "immiche", "karakeepe") ··· 283 313 {b Error Conditions:} 284 314 - Service file does not exist 285 315 - Service file has incorrect permissions (not 0o600) 286 - - Service file contains invalid JSON 287 - - Service file is not a JSON object *) 316 + - Service file contains invalid TOML 317 + - Service file does not contain proper TOML tables *) 288 318 val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result 289 319 290 320 (** [list_services t] returns all available service names. 291 321 292 - Scans the keys directory for all [*.json] files and returns their 293 - base names (without the .json extension). This allows applications 322 + Scans the keys directory for all [*.toml] files and returns their 323 + base names (without the .toml extension). This allows applications 294 324 to discover what services have stored credentials. 295 325 296 326 @param t The Keyeio context ··· 306 336 Printf.eprintf "Failed to list services: %s\n" msg 307 337 ]} 308 338 309 - {b Note:} Only files with [.json] extension are considered. Files 339 + {b Note:} Only files with [.toml] extension are considered. Files 310 340 with incorrect permissions are silently skipped. *) 311 341 val list_services : t -> (string list, [> `Msg of string ]) result 312 342 ··· 354 384 355 385 {b Generated Command-line Flags:} 356 386 - [--profile NAME]: Select which profile to use (default: "default") 357 - - [--key-file PATH]: Override with direct JSON file path (if [key_file=true]) 387 + - [--key-file PATH]: Override with direct TOML file path (if [key_file=true]) 358 388 359 389 {b Flag Precedence:} 360 390 + [--key-file PATH] - highest priority (if enabled) ··· 403 433 The term will fail with a clear error message if: 404 434 - The service file does not exist 405 435 - The requested profile is not found 406 - - The JSON file is invalid 436 + - The TOML file is invalid 407 437 - File permissions are incorrect *) 408 438 val term : 409 439 app_name:string -> ··· 413 443 ?key_file:bool -> 414 444 unit -> 415 445 Profile.t Cmdliner.Term.t 446 + 447 + (** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles. 448 + 449 + This function generates a Cmdliner term that handles interactive creation 450 + of credential files. It prompts for required values, allows optional overrides 451 + via command-line flags, and creates the TOML file with proper permissions. 452 + 453 + @param app_name The application name (used for XDG paths) 454 + @param fs The Eio filesystem providing filesystem access 455 + @param service The service name to create credentials for (e.g., "karakeepe") 456 + @param default_data Default key-value pairs with optional prompts 457 + @param profile Default profile name to create (default: "default") 458 + 459 + {b Generated Command-line Flags:} 460 + - [--profile NAME]: Profile name to create (default: "default") 461 + - One flag per key in default_data (e.g., [--api-key], [--base-url]) 462 + 463 + {b Example - Basic usage with prompts:} 464 + {[ 465 + open Cmdliner 466 + 467 + let create_cmd env = 468 + let default_data = [ 469 + ("api_key", None); (* Will prompt if not provided *) 470 + ("base_url", Some "https://hoard.recoil.org") (* Has default *) 471 + ] in 472 + 473 + let create_term = Keyeio.Cmd.create_term 474 + ~app_name:"karakeepe" 475 + ~fs:env#fs 476 + ~service:"karakeepe" 477 + ~default_data 478 + () in 479 + 480 + Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials") 481 + create_term 482 + ]} 483 + 484 + {b Behavior:} 485 + - If a value is provided via CLI flag, use it 486 + - If a value has a default in default_data, use it 487 + - Otherwise, prompt interactively for the value 488 + - Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml] 489 + - Set file permissions to 0o600 for security 490 + - Return 0 on success, 1 on failure *) 491 + val create_term : 492 + app_name:string -> 493 + fs:Eio.Fs.dir_ty Eio.Path.t -> 494 + service:string -> 495 + default_data:(string * string option) list -> 496 + ?profile:string -> 497 + unit -> 498 + int Cmdliner.Term.t 416 499 417 500 (** [env_docs ~app_name ~service ()] generates documentation for environment variables. 418 501
+35 -44
stack/keyeio/test/keyeio.t
··· 6 6 $ mkdir -p $PWD/test_config/keyeio-example/keys 7 7 8 8 Create a test service file with multiple profiles: 9 - $ cat > $PWD/test_config/keyeio-example/keys/immiche.json << 'EOF' 10 - > { 11 - > "default": { 12 - > "api_key": "test_default_key_12345", 13 - > "base_url": "https://immich.example.com" 14 - > }, 15 - > "production": { 16 - > "api_key": "prod_key_67890", 17 - > "base_url": "https://immich.prod.example.com", 18 - > "extra_field": "production_value" 19 - > }, 20 - > "staging": { 21 - > "api_key": "staging_key_abcde", 22 - > "base_url": "https://immich.staging.example.com" 23 - > } 24 - > } 9 + $ cat > $PWD/test_config/keyeio-example/keys/immiche.toml << 'EOF' 10 + > [default] 11 + > api_key = "test_default_key_12345" 12 + > base_url = "https://immich.example.com" 13 + > 14 + > [production] 15 + > api_key = "prod_key_67890" 16 + > base_url = "https://immich.prod.example.com" 17 + > extra_field = "production_value" 18 + > 19 + > [staging] 20 + > api_key = "staging_key_abcde" 21 + > base_url = "https://immich.staging.example.com" 25 22 > EOF 26 23 27 24 Test listing available services: ··· 32 29 Test listing profiles for a service: 33 30 $ ../example/keyeio_example.exe profiles immiche 34 31 === List Profiles Example === 35 - Error loading service 'immiche': Service file not found: immiche.json 32 + Error loading service 'immiche': Service file not found: immiche.toml 36 33 37 34 38 35 Test basic usage with default profile: ··· 42 39 Profile: default 43 40 API Key loaded: test_defau... 44 41 Base URL: https://immich.example.com 45 - Available keys: api_key, 46 - base_url 42 + Available keys: base_url, 43 + api_key 47 44 48 45 Profile details: 49 46 Profile immiche.default: 50 - api_key: test_def*** 51 47 base_url: https://immich.example.com 48 + api_key: test_def*** 52 49 53 50 54 51 Test using a specific profile: ··· 58 55 Profile: production 59 56 API Key loaded: prod_key_6... 60 57 Base URL: https://immich.prod.example.com 61 - Available keys: api_key, base_url, 62 - extra_field 58 + Available keys: extra_field, base_url, 59 + api_key 63 60 64 61 Profile details: 65 62 Profile immiche.production: 63 + extra_field: production_value 64 + base_url: https://immich.prod.example.com 66 65 api_key: prod_key*** 67 - base_url: https://immich.prod.example.com 68 - extra_field: production_value 69 66 70 67 71 68 Test API client simulation with staging profile: ··· 92 89 Test error handling - nonexistent service: 93 90 $ ../example/keyeio_example.exe profiles nonexistent 94 91 === List Profiles Example === 95 - Error loading service 'nonexistent': Service file not found: nonexistent.json 92 + Error loading service 'nonexistent': Service file not found: nonexistent.toml 96 93 97 94 Test with multiple services - create another service file: 98 - $ cat > $PWD/test_config/keyeio-example/keys/karakeepe.json << 'EOF' 99 - > { 100 - > "default": { 101 - > "api_key": "hoard_default_key_xyz", 102 - > "base_url": "https://hoard.example.com" 103 - > } 104 - > } 95 + $ cat > $PWD/test_config/keyeio-example/keys/karakeepe.toml << 'EOF' 96 + > [default] 97 + > api_key = "hoard_default_key_xyz" 98 + > base_url = "https://hoard.example.com" 105 99 > EOF 106 100 107 101 List services should now show both: ··· 110 104 No services configured yet 111 105 112 106 Test with key-file override: 113 - $ cat > ./custom_keys.json << 'EOF' 114 - > { 115 - > "custom": { 116 - > "api_key": "custom_key_123", 117 - > "base_url": "https://custom.example.com" 118 - > } 119 - > } 107 + $ cat > ./custom_keys.toml << 'EOF' 108 + > [custom] 109 + > api_key = "custom_key_123" 110 + > base_url = "https://custom.example.com" 120 111 > EOF 121 - $ ../example/keyeio_example.exe basic --key-file ./custom_keys.json --profile custom 112 + $ ../example/keyeio_example.exe basic --key-file ./custom_keys.toml --profile custom 122 113 === Basic Example === 123 114 Service: immiche 124 115 Profile: custom 125 116 API Key loaded: custom_key... 126 117 Base URL: https://custom.example.com 127 - Available keys: api_key, 128 - base_url 118 + Available keys: base_url, 119 + api_key 129 120 130 121 Profile details: 131 122 Profile immiche.custom: 132 - api_key: custom_k*** 133 123 base_url: https://custom.example.com 124 + api_key: custom_k*** 134 125 135 126 136 127 Test file permissions (keys should have restrictive permissions): 137 - $ ls -l $PWD/test_config/keyeio-example/keys/immiche.json | awk '{print $1}' | grep -E '^-rw' 128 + $ ls -l $PWD/test_config/keyeio-example/keys/immiche.toml | awk '{print $1}' | grep -E '^-rw' 138 129 -rw-r--r--@ 139 130 140 131 Test empty keys directory:
+2 -2
stack/requests/lib/requests.ml
··· 538 538 539 539 let config_term app_name fs = 540 540 let xdg_term = Xdge.Cmd.term app_name fs 541 - ~config:true ~data:true ~cache:true ~state:false ~runtime:false () in 541 + ~dirs:[`Config; `Data; `Cache] () in 542 542 Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua -> 543 543 { xdg; persist_cookies = persist; verify_tls = verify; 544 544 timeout; max_retries = retries; retry_backoff = backoff; ··· 560 560 561 561 let minimal_term app_name fs = 562 562 let xdg_term = Xdge.Cmd.term app_name fs 563 - ~config:false ~data:true ~cache:true ~state:false ~runtime:false () in 563 + ~dirs:[`Data; `Cache] () in 564 564 Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist)) 565 565 $ xdg_term 566 566 $ persist_cookies_term app_name)
+8 -8
stack/river/bin/river_cli.ml
··· 533 533 (* Commands - these are created within Eio context *) 534 534 let user_add_cmd fs = 535 535 let doc = "Add a new user" in 536 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 536 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 537 537 let run log_level style_renderer (xdg, _cfg) username fullname email = 538 538 setup_logs style_renderer log_level; 539 539 let state = { xdg } in ··· 545 545 546 546 let user_remove_cmd fs = 547 547 let doc = "Remove a user" in 548 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 548 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 549 549 let run log_level style_renderer (xdg, _cfg) username = 550 550 setup_logs style_renderer log_level; 551 551 let state = { xdg } in ··· 556 556 557 557 let user_list_cmd fs = 558 558 let doc = "List all users" in 559 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 559 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 560 560 let run log_level style_renderer (xdg, _cfg) = 561 561 setup_logs style_renderer log_level; 562 562 let state = { xdg } in ··· 567 567 568 568 let user_show_cmd fs = 569 569 let doc = "Show user details" in 570 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 570 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 571 571 let run log_level style_renderer (xdg, _cfg) username = 572 572 setup_logs style_renderer log_level; 573 573 let state = { xdg } in ··· 578 578 579 579 let user_add_feed_cmd fs = 580 580 let doc = "Add a feed to a user" in 581 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 581 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 582 582 let run log_level style_renderer (xdg, _cfg) username name url = 583 583 setup_logs style_renderer log_level; 584 584 let state = { xdg } in ··· 589 589 590 590 let user_remove_feed_cmd fs = 591 591 let doc = "Remove a feed from a user" in 592 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 592 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 593 593 let run log_level style_renderer (xdg, _cfg) username url = 594 594 setup_logs style_renderer log_level; 595 595 let state = { xdg } in ··· 612 612 613 613 let sync_cmd fs env = 614 614 let doc = "Sync feeds for users" in 615 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 615 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 616 616 let username_opt = 617 617 let doc = "Sync specific user (omit to sync all)" in 618 618 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc) ··· 646 646 647 647 let list_cmd fs = 648 648 let doc = "List recent posts (from all users by default, or specify a user)" in 649 - let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in 649 + let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in 650 650 let username_opt_arg = 651 651 let doc = "Username (optional - defaults to all users)" in 652 652 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
stack/toru/.gitignore toru/.gitignore
stack/toru/CLAUDE.md toru/CLAUDE.md
stack/toru/README.md toru/README.md
stack/toru/TODO.md toru/TODO.md
stack/toru/bin/dune toru/bin/dune
stack/toru/bin/tessera_loader.ml toru/bin/tessera_loader.ml
stack/toru/bin/toru_cache.ml toru/bin/toru_cache.ml
stack/toru/bin/toru_cli.ml toru/bin/toru_cli.ml
stack/toru/bin/toru_main.ml toru/bin/toru_main.ml
stack/toru/bin/toru_make_registry.ml toru/bin/toru_make_registry.ml
stack/toru/bin/toru_make_registry_simple.ml toru/bin/toru_make_registry_simple.ml
stack/toru/dune-project toru/dune-project
stack/toru/lib/toru/cache.ml toru/lib/toru/cache.ml
stack/toru/lib/toru/cache.mli toru/lib/toru/cache.mli
stack/toru/lib/toru/cmd.ml toru/lib/toru/cmd.ml
stack/toru/lib/toru/downloader.ml toru/lib/toru/downloader.ml
stack/toru/lib/toru/downloader.mli toru/lib/toru/downloader.mli
stack/toru/lib/toru/dune toru/lib/toru/dune
stack/toru/lib/toru/hash.ml toru/lib/toru/hash.ml
stack/toru/lib/toru/hash.mli toru/lib/toru/hash.mli
stack/toru/lib/toru/logging.ml toru/lib/toru/logging.ml
stack/toru/lib/toru/logging.mli toru/lib/toru/logging.mli
stack/toru/lib/toru/make_registry.ml toru/lib/toru/make_registry.ml
stack/toru/lib/toru/make_registry.mli toru/lib/toru/make_registry.mli
stack/toru/lib/toru/processors.ml toru/lib/toru/processors.ml
stack/toru/lib/toru/processors.mli toru/lib/toru/processors.mli
stack/toru/lib/toru/registry.ml toru/lib/toru/registry.ml
stack/toru/lib/toru/registry.mli toru/lib/toru/registry.mli
stack/toru/lib/toru/toru.ml toru/lib/toru/toru.ml
stack/toru/lib/toru/toru.mli toru/lib/toru/toru.mli
+15 -6
stack/xdge/lib/xdge.ml
··· 19 19 ; data_dirs : Eio.Fs.dir_ty Eio.Path.t list 20 20 } 21 21 22 + type dir = [ 23 + | `Config 24 + | `Cache 25 + | `Data 26 + | `State 27 + | `Runtime 28 + ] 29 + 22 30 let ensure_dir ?(perm = 0o755) path = Eio.Path.mkdirs ~exists_ok:true ~perm path 23 31 24 32 let validate_runtime_base_dir base_path = ··· 412 420 } 413 421 414 422 let term app_name fs 415 - ?(config=true) ?(data=true) ?(cache=true) ?(state=true) ?(runtime=true) () = 423 + ?(dirs=[`Config; `Data; `Cache; `State; `Runtime]) () = 416 424 let open Cmdliner in 417 425 let app_upper = String.uppercase_ascii app_name in 418 426 let show_paths = 419 427 let doc = "Show only the resolved directory paths without formatting" in 420 428 Arg.(value & flag & info [ "show-paths" ] ~doc) 421 429 in 430 + let has_dir d = List.mem d dirs in 422 431 let make_dir_arg ~enabled name env_suffix xdg_var default_path = 423 432 if not enabled then 424 433 (* Return a term that always gives the environment-only result *) ··· 468 477 let home_prefix = "\\$HOME" in 469 478 let config_dir = 470 479 make_dir_arg 471 - ~enabled:config 480 + ~enabled:(has_dir `Config) 472 481 "config" 473 482 "CONFIG_DIR" 474 483 "XDG_CONFIG_HOME" ··· 476 485 in 477 486 let data_dir = 478 487 make_dir_arg 479 - ~enabled:data 488 + ~enabled:(has_dir `Data) 480 489 "data" 481 490 "DATA_DIR" 482 491 "XDG_DATA_HOME" ··· 484 493 in 485 494 let cache_dir = 486 495 make_dir_arg 487 - ~enabled:cache 496 + ~enabled:(has_dir `Cache) 488 497 "cache" 489 498 "CACHE_DIR" 490 499 "XDG_CACHE_HOME" ··· 492 501 in 493 502 let state_dir = 494 503 make_dir_arg 495 - ~enabled:state 504 + ~enabled:(has_dir `State) 496 505 "state" 497 506 "STATE_DIR" 498 507 "XDG_STATE_HOME" 499 508 (Some (home_prefix ^ "/.local/state/" ^ app_name)) 500 509 in 501 - let runtime_dir = make_dir_arg ~enabled:runtime "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in 510 + let runtime_dir = make_dir_arg ~enabled:(has_dir `Runtime) "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in 502 511 Term.( 503 512 const 504 513 (fun
+25 -19
stack/xdge/lib/xdge.mli
··· 35 35 @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification *) 36 36 37 37 (** The main XDG context type containing all directory paths for an application. 38 - 38 + 39 39 A value of type [t] represents the complete XDG directory structure for a 40 40 specific application, including both user-specific and system-wide directories. 41 41 All paths are resolved at creation time and are absolute paths within the 42 42 Eio filesystem. *) 43 43 type t 44 + 45 + (** XDG directory types for specifying which directories an application needs. 46 + 47 + These polymorphic variants allow applications to declare which XDG directories 48 + they use, enabling runtime systems to only provide the requested directories. *) 49 + type dir = [ 50 + | `Config (** User configuration files *) 51 + | `Cache (** User-specific cached data *) 52 + | `Data (** User-specific application data *) 53 + | `State (** User-specific state data (logs, history, etc.) *) 54 + | `Runtime (** User-specific runtime files (sockets, pipes, etc.) *) 55 + ] 44 56 45 57 (** {1 Exceptions} *) 46 58 ··· 361 373 as determined by command-line arguments and environment variables. *) 362 374 type t 363 375 364 - (** [term app_name fs ?config ?data ?cache ?state ?runtime ()] creates a 365 - Cmdliner term for XDG directory configuration. 376 + (** [term app_name fs ?dirs ()] creates a Cmdliner term for XDG directory configuration. 366 377 367 378 This function generates a Cmdliner term that handles XDG directory 368 379 configuration through both command-line flags and environment variables, 369 - and directly returns the XDG context. Individual directory flags can be 370 - disabled by passing [false] for the corresponding optional parameter. 380 + and directly returns the XDG context. Only command-line flags for the 381 + requested directories are generated. 371 382 372 383 @param app_name The application name (used for environment variable prefixes) 373 384 @param fs The Eio filesystem to use for path resolution 374 - @param config Include [--config-dir] flag (default: true) 375 - @param data Include [--data-dir] flag (default: true) 376 - @param cache Include [--cache-dir] flag (default: true) 377 - @param state Include [--state-dir] flag (default: true) 378 - @param runtime Include [--runtime-dir] flag (default: true) 385 + @param dirs List of directories to include flags for (default: all directories) 379 386 380 387 {b Generated Command-line Flags:} 381 - Only the flags for enabled directories are generated: 382 - - [--config-dir DIR]: Override configuration directory (if [config=true]) 383 - - [--data-dir DIR]: Override data directory (if [data=true]) 384 - - [--cache-dir DIR]: Override cache directory (if [cache=true]) 385 - - [--state-dir DIR]: Override state directory (if [state=true]) 386 - - [--runtime-dir DIR]: Override runtime directory (if [runtime=true]) 388 + Only the flags for requested directories are generated: 389 + - [--config-dir DIR]: Override configuration directory (if [`Config] in dirs) 390 + - [--data-dir DIR]: Override data directory (if [`Data] in dirs) 391 + - [--cache-dir DIR]: Override cache directory (if [`Cache] in dirs) 392 + - [--state-dir DIR]: Override state directory (if [`State] in dirs) 393 + - [--runtime-dir DIR]: Override runtime directory (if [`Runtime] in dirs) 387 394 388 395 {b Environment Variable Precedence:} 389 396 For each directory type, the following precedence applies: ··· 403 410 {b Example - Only cache directory:} 404 411 {[ 405 412 let open Cmdliner in 406 - let xdg_term = Cmd.term "myapp" env#fs 407 - ~config:false ~data:false ~state:false ~runtime:false () in 413 + let xdg_term = Cmd.term "myapp" env#fs ~dirs:[`Cache] () in 408 414 let main_term = Term.(const main $ xdg_term $ other_args) in 409 415 (* ... *) 410 416 ]} *) 411 417 val term : string -> Eio.Fs.dir_ty Eio.Path.t -> 412 - ?config:bool -> ?data:bool -> ?cache:bool -> ?state:bool -> ?runtime:bool -> 418 + ?dirs:dir list -> 413 419 unit -> (xdg_t * t) Cmdliner.Term.t 414 420 415 421 (** [cache_term app_name] creates a Cmdliner term that provides just the cache
+17 -8
stack/zulip/lib/zulip/lib/client.ml
··· 26 26 let client = create ~sw env auth in 27 27 f client 28 28 29 - let request t ~method_ ~path ?params ?body () = 29 + let request t ~method_ ~path ?params ?body ?content_type () = 30 30 let url = Auth.server_url t.auth ^ path in 31 31 Log.debug (fun m -> m "Request: %s %s" 32 32 (match method_ with ··· 46 46 (* Prepare request body if provided *) 47 47 let body_opt = match body with 48 48 | Some body_str -> 49 - (* Check if this looks like form data (key=value) or JSON *) 50 - if String.contains body_str '=' && not (String.contains body_str '{') then 51 - (* Form-encoded data *) 52 - Some (Requests.Body.of_string Requests.Mime.form body_str) 53 - else 54 - (* JSON data *) 55 - Some (Requests.Body.of_string Requests.Mime.json body_str) 49 + let mime = match content_type with 50 + | Some ct when String.starts_with ~prefix:"multipart/form-data" ct -> 51 + (* Custom Content-Type for multipart *) 52 + Requests.Mime.of_string ct 53 + | Some "application/json" -> 54 + Requests.Mime.json 55 + | Some "application/x-www-form-urlencoded" | None -> 56 + (* Default for form data *) 57 + if String.contains body_str '=' && not (String.contains body_str '{') then 58 + Requests.Mime.form 59 + else 60 + Requests.Mime.json 61 + | Some ct -> 62 + Requests.Mime.of_string ct 63 + in 64 + Some (Requests.Body.of_string mime body_str) 56 65 | None -> None 57 66 in 58 67
+3 -1
stack/zulip/lib/zulip/lib/client.mli
··· 23 23 path:string -> 24 24 ?params:(string * string) list -> 25 25 ?body:string -> 26 + ?content_type:string -> 26 27 unit -> 27 28 (Zulip_types.json, Zulip_types.zerror) result 28 - (** Make an HTTP request to the Zulip API using the requests library *) 29 + (** Make an HTTP request to the Zulip API using the requests library. 30 + @param content_type Optional Content-Type header (default: application/x-www-form-urlencoded for POST/PUT, none for GET/DELETE) *) 29 31 30 32 val pp : Format.formatter -> t -> unit 31 33 (** Pretty printer for client (shows server URL only, not credentials) *)
+62 -3
stack/zulip/lib/zulip/lib/messages.ml
··· 37 37 Client.request client ~method_:`GET ~path:("/api/v1/messages/" ^ string_of_int message_id) () 38 38 39 39 let get_messages client ?anchor ?num_before ?num_after ?narrow () = 40 - let params = 40 + let params = 41 41 (match anchor with Some a -> [("anchor", a)] | None -> []) @ 42 42 (match num_before with Some n -> [("num_before", string_of_int n)] | None -> []) @ 43 43 (match num_after with Some n -> [("num_after", string_of_int n)] | None -> []) @ 44 44 (match narrow with Some n -> List.mapi (fun i s -> ("narrow[" ^ string_of_int i ^ "]", s)) n | None -> []) in 45 - 46 - Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params () 45 + 46 + Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params () 47 + 48 + let add_reaction client ~message_id ~emoji_name = 49 + let params = [ 50 + ("emoji_name", emoji_name); 51 + ("reaction_type", "unicode_emoji"); 52 + ] in 53 + match Client.request client ~method_:`POST 54 + ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") 55 + ~params () with 56 + | Ok _ -> Ok () 57 + | Error err -> Error err 58 + 59 + let remove_reaction client ~message_id ~emoji_name = 60 + let params = [ 61 + ("emoji_name", emoji_name); 62 + ("reaction_type", "unicode_emoji"); 63 + ] in 64 + match Client.request client ~method_:`DELETE 65 + ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") 66 + ~params () with 67 + | Ok _ -> Ok () 68 + | Error err -> Error err 69 + 70 + let upload_file client ~filename = 71 + (* Read file contents *) 72 + let ic = open_in_bin filename in 73 + let len = in_channel_length ic in 74 + let content = really_input_string ic len in 75 + close_in ic; 76 + 77 + (* Extract just the filename from the path *) 78 + let basename = Filename.basename filename in 79 + 80 + (* Create multipart form data boundary *) 81 + let boundary = "----OCamlZulipBoundary" ^ string_of_float (Unix.gettimeofday ()) in 82 + 83 + (* Build multipart body *) 84 + let body = Buffer.create (len + 1024) in 85 + Buffer.add_string body ("--" ^ boundary ^ "\r\n"); 86 + Buffer.add_string body ("Content-Disposition: form-data; name=\"file\"; filename=\"" ^ basename ^ "\"\r\n"); 87 + Buffer.add_string body "Content-Type: application/octet-stream\r\n"; 88 + Buffer.add_string body "\r\n"; 89 + Buffer.add_string body content; 90 + Buffer.add_string body ("\r\n--" ^ boundary ^ "--\r\n"); 91 + 92 + let body_str = Buffer.contents body in 93 + let content_type = "multipart/form-data; boundary=" ^ boundary in 94 + 95 + match Client.request client ~method_:`POST ~path:"/api/v1/user_uploads" 96 + ~body:body_str ~content_type () with 97 + | Ok json -> 98 + (* Parse response to extract URI *) 99 + (match json with 100 + | `O fields -> 101 + (match Jsonu.get_string fields "uri" with 102 + | Ok uri -> Ok uri 103 + | Error e -> Error e) 104 + | _ -> Error (Zulip_types.create_error ~code:(Zulip_types.Other "upload_error") ~msg:"Failed to parse upload response" ())) 105 + | Error err -> Error err
+41 -1
stack/zulip/lib/zulip/lib/messages.mli
··· 9 9 ?num_after:int -> 10 10 ?narrow:string list -> 11 11 unit -> 12 - (Zulip_types.json, Zulip_types.zerror) result 12 + (Zulip_types.json, Zulip_types.zerror) result 13 + 14 + (** Add an emoji reaction to a message. 15 + 16 + @param client The Zulip client 17 + @param message_id The message ID to react to 18 + @param emoji_name The emoji name (e.g., "thumbs_up", "heart", "rocket") 19 + @return Ok () on success, Error on failure 20 + 21 + {b Example:} 22 + {[ 23 + match Messages.add_reaction client ~message_id:12345 ~emoji_name:"thumbs_up" with 24 + | Ok () -> print_endline "Reaction added!" 25 + | Error e -> Printf.eprintf "Failed: %s\n" (Zulip_types.error_message e) 26 + ]} *) 27 + val add_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result 28 + 29 + (** Remove an emoji reaction from a message. 30 + 31 + @param client The Zulip client 32 + @param message_id The message ID 33 + @param emoji_name The emoji name to remove 34 + @return Ok () on success, Error on failure *) 35 + val remove_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result 36 + 37 + (** Upload a file to Zulip. 38 + 39 + @param client The Zulip client 40 + @param filename The path to the file to upload 41 + @return Ok uri where uri is the Zulip URL for the uploaded file, Error on failure 42 + 43 + {b Example:} 44 + {[ 45 + match Messages.upload_file client ~filename:"/path/to/image.png" with 46 + | Ok uri -> 47 + let msg = Message.create ~type_:`Channel ~to_:["general"] 48 + ~content:("Check out this image: " ^ uri) () in 49 + Messages.send client msg 50 + | Error e -> Printf.eprintf "Upload failed: %s\n" (Zulip_types.error_message e) 51 + ]} *) 52 + val upload_file : Client.t -> filename:string -> (string, Zulip_types.zerror) result