···2020let url_term ~default ~doc =
2121 Arg.(value & opt string default & info ["u"; "url"] ~docv:"URL" ~doc)
22222323-(** TODO:claude API key file term *)
2424-let api_key_file ~default =
2525- let doc = "File containing API key" in
2626- Arg.(value & opt string default & info ["k"; "key-file"] ~docv:"FILE" ~doc)
2727-2828-(** TODO:claude API key term *)
2929-let api_key =
3030- let doc = "API key for authentication" in
3131- Arg.(value & opt (some string) None & info ["api-key"] ~docv:"KEY" ~doc)
3232-3323(** TODO:claude Overwrite flag *)
3424let overwrite =
3525 let doc = "Overwrite existing files" in
···73637464(** TODO:claude Common setup term combining logs setup *)
7565let setup_term =
7676- Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())6666+ Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())
6767+6868+(** Keyeio integration for API credential management *)
6969+7070+(** Create XDG term for bushel *)
7171+let xdg_term fs =
7272+ Xdge.Cmd.term "bushel" fs ()
7373+7474+(** Create keyeio term for Immiche service *)
7575+let immiche_key_term fs =
7676+ Keyeio.Cmd.term
7777+ ~app_name:"bushel"
7878+ ~fs
7979+ ~service:"immiche"
8080+ ()
8181+8282+(** Create keyeio term for Karakeepe service *)
8383+let karakeepe_key_term fs =
8484+ Keyeio.Cmd.term
8585+ ~app_name:"bushel"
8686+ ~fs
8787+ ~service:"karakeepe"
8888+ ()
8989+9090+(** Create keyeio term for Typesense service (includes both typesense and openai keys) *)
9191+let typesense_key_term fs =
9292+ Keyeio.Cmd.term
9393+ ~app_name:"bushel"
9494+ ~fs
9595+ ~service:"typesense"
9696+ ()
+16-19
stack/bushel/bin/bushel_faces.ml
···11open Cmdliner
22open Printf
3344-(* Read API key from file *)
55-let read_api_key file =
66- let ic = open_in file in
77- let key = input_line ic in
88- close_in ic;
99- key
1010-114(* Get face for a single contact *)
125let get_face_for_contact immiche_client ~fs output_dir contact =
136 let names = Bushel.Contact.names contact in
···4538 end
46394740(* Process all contacts or a specific one *)
4848-let process_contacts ~sw ~env base_dir output_dir specific_handle api_key base_url =
4141+let process_contacts ~sw ~env base_dir output_dir specific_handle profile =
4942 printf "Loading Bushel database from %s\n%!" base_dir;
5043 let db = Bushel.load base_dir in
5144 let contacts = Bushel.Entry.contacts db in
5245 printf "Found %d contacts\n%!" (List.length contacts);
4646+4747+ (* Get credentials from keyeio profile *)
4848+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
4949+ let base_url = Keyeio.Profile.get profile ~key:"base_url"
5050+ |> Option.value ~default:"https://photos.recoil.org" in
5151+5252+ printf "Connecting to Immich at %s\n%!" base_url;
53535454 (* Create Immiche client for connection pooling *)
5555 let immiche_client = Immiche.create ~sw ~env ~base_url ~api_key () in
···102102103103(* Command line interface *)
104104105105-(* Export the term for use in main bushel.ml *)
106106-let term =
105105+(* Create term given the Eio environment *)
106106+let make_term eio_env =
107107+ let immiche_profile_term = Bushel_common.immiche_key_term eio_env#fs in
107108 Term.(
108108- const (fun base_dir output_dir handle api_key_file base_url ->
109109+ const (fun base_dir output_dir handle profile ->
109110 try
110110- let api_key = read_api_key api_key_file in
111111- Eio_main.run @@ fun env ->
112111 Eio.Switch.run @@ fun sw ->
113113- process_contacts ~sw ~env base_dir output_dir handle api_key base_url
112112+ process_contacts ~sw ~env:eio_env base_dir output_dir handle profile
114113 with e ->
115114 eprintf "Error: %s\n%!" (Printexc.to_string e);
116115 1
117117- ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
118118- Bushel_common.api_key_file ~default:".photos-api" $
119119- Bushel_common.url_term ~default:"https://photos.recoil.org" ~doc:"Base URL of the Immich instance")
116116+ ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $ immiche_profile_term)
120117121121-let cmd =
118118+let make_cmd eio_env =
122119 let info = Cmd.info "faces" ~doc:"Retrieve face thumbnails for Bushel contacts from Immich" in
123123- Cmd.v info term
120120+ Cmd.v info (make_term eio_env)
124121125122(* Main entry point removed - accessed through bushel_main.ml *)
+158-181
stack/bushel/bin/bushel_links.ml
···1818 0
19192020(* Update links.yml from Karakeep *)
2121-let update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets =
2222- match api_key_opt with
2323- | None ->
2424- prerr_endline "Error: API key is required.";
2525- prerr_endline "Please provide one with --api-key or create a ~/.karakeep-api file.";
2626- 1
2727- | Some api_key ->
2828- let assets_dir = "data/assets" in
2121+let update_from_karakeep ~sw ~env base_url profile tag links_file download_assets =
2222+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
2323+ let assets_dir = "data/assets" in
29243030- try
3131- print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
2525+ try
2626+ print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
32273333- (* Prepare tag filter *)
3434- let filter_tags = if tag = "" then [] else [tag] in
2828+ (* Prepare tag filter *)
2929+ let filter_tags = if tag = "" then [] else [tag] in
35303636- (* Fetch bookmarks from Karakeep *)
3737- let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
3131+ (* Fetch bookmarks from Karakeep *)
3232+ let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
38333939- print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
3434+ print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
40354141- (* Read existing links if file exists *)
4242- let existing_links = Bushel.Link.load_links_file links_file in
3636+ (* Read existing links if file exists *)
3737+ let existing_links = Bushel.Link.load_links_file links_file in
43384444- (* Convert bookmarks to bushel links *)
4545- let new_links = List.map (fun bookmark ->
4646- Karakeepe.to_bushel_link ~base_url bookmark
4747- ) bookmarks in
3939+ (* Convert bookmarks to bushel links *)
4040+ let new_links = List.map (fun bookmark ->
4141+ Karakeepe.to_bushel_link ~base_url bookmark
4242+ ) bookmarks in
48434949- (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
5050- let merged_links = Bushel.Link.merge_links existing_links new_links in
4444+ (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
4545+ let merged_links = Bushel.Link.merge_links existing_links new_links in
51465252- (* Save the updated links file *)
5353- Bushel.Link.save_links_file links_file merged_links;
4747+ (* Save the updated links file *)
4848+ Bushel.Link.save_links_file links_file merged_links;
54495555- print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
5050+ print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
56515757- (* Download assets if requested *)
5858- if download_assets then begin
5959- print_endline "Downloading assets for bookmarks...";
5252+ (* Download assets if requested *)
5353+ if download_assets then begin
5454+ print_endline "Downloading assets for bookmarks...";
60556161- (* Ensure the assets directory exists *)
6262- (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
5656+ (* Ensure the assets directory exists *)
5757+ (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
63586464- (* Process each bookmark with assets *)
6565- List.iter (fun bookmark ->
6666- (* Extract asset IDs from bookmark *)
6767- let assets = bookmark.Karakeepe.assets in
5959+ (* Process each bookmark with assets *)
6060+ List.iter (fun bookmark ->
6161+ (* Extract asset IDs from bookmark *)
6262+ let assets = bookmark.Karakeepe.assets in
68636969- (* Skip if no assets *)
7070- if assets <> [] then
7171- (* Process each asset *)
7272- List.iter (fun (asset_id, asset_type) ->
7373- let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
7474- let asset_file = Fmt.str "%s/asset.bin" asset_dir in
7575- let meta_file = Fmt.str "%s/metadata.json" asset_dir in
6464+ (* Skip if no assets *)
6565+ if assets <> [] then
6666+ (* Process each asset *)
6767+ List.iter (fun (asset_id, asset_type) ->
6868+ let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
6969+ let asset_file = Fmt.str "%s/asset.bin" asset_dir in
7070+ let meta_file = Fmt.str "%s/metadata.json" asset_dir in
76717777- (* Skip if the asset already exists *)
7878- if not (Sys.file_exists asset_file) then begin
7979- (* Create the asset directory *)
8080- (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
7272+ (* Skip if the asset already exists *)
7373+ if not (Sys.file_exists asset_file) then begin
7474+ (* Create the asset directory *)
7575+ (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
81768282- (* Download the asset *)
8383- print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
8484- let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
7777+ (* Download the asset *)
7878+ print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
7979+ let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
85808686- (* Guess content type based on first bytes *)
8787- let content_type =
8888- if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
8989- "image/png"
9090- else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
9191- "image/jpeg"
9292- else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
9393- "application/pdf"
9494- else
9595- "application/octet-stream"
9696- in
8181+ (* Guess content type based on first bytes *)
8282+ let content_type =
8383+ if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
8484+ "image/png"
8585+ else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
8686+ "image/jpeg"
8787+ else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
8888+ "application/pdf"
8989+ else
9090+ "application/octet-stream"
9191+ in
97929898- (* Write the asset data *)
9999- let oc = open_out_bin asset_file in
100100- output_string oc data;
101101- close_out oc;
9393+ (* Write the asset data *)
9494+ let oc = open_out_bin asset_file in
9595+ output_string oc data;
9696+ close_out oc;
10297103103- (* Write metadata file *)
104104- let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
105105- content_type asset_type in
106106- let oc = open_out meta_file in
107107- output_string oc metadata;
108108- close_out oc
109109- end
110110- ) assets
111111- ) bookmarks;
9898+ (* Write metadata file *)
9999+ let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
100100+ content_type asset_type in
101101+ let oc = open_out meta_file in
102102+ output_string oc metadata;
103103+ close_out oc
104104+ end
105105+ ) assets
106106+ ) bookmarks;
112107113113- print_endline "Asset download completed.";
114114- 0
115115- end else
116116- 0
117117- with exn ->
118118- prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
119119- 1
108108+ print_endline "Asset download completed.";
109109+ 0
110110+ end else
111111+ 0
112112+ with exn ->
113113+ prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
114114+ 1
120115121116(* Extract outgoing links from Bushel entries *)
122117let update_from_bushel bushel_dir links_file include_domains exclude_domains =
···377372 end
378373379374(* Upload links to Karakeep that don't already have karakeep data *)
380380-let upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
381381- match api_key_opt with
382382- | None ->
383383- log "Error: API key is required.\n";
384384- log "Please provide one with --api-key or create a ~/.karakeep-api file.\n";
385385- 1
386386- | Some api_key ->
387387- (* Load links from file *)
388388- log_verbose verbose "Loading links from %s...\n" links_file;
389389- let links = Bushel.Link.load_links_file links_file in
390390- log_verbose verbose "Loaded %d total links\n" (List.length links);
375375+let upload_to_karakeep ~sw ~env base_url profile links_file tag max_concurrent delay_seconds limit verbose =
376376+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
377377+ (* Load links from file *)
378378+ log_verbose verbose "Loading links from %s...\n" links_file;
379379+ let links = Bushel.Link.load_links_file links_file in
380380+ log_verbose verbose "Loaded %d total links\n" (List.length links);
391381392392- (* Filter links that don't have karakeep data for this remote *)
393393- log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
394394- let filtered_links = filter_links_without_karakeep base_url links in
395395- log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
382382+ (* Filter links that don't have karakeep data for this remote *)
383383+ log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
384384+ let filtered_links = filter_links_without_karakeep base_url links in
385385+ log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
396386397397- (* Apply limit if specified *)
398398- let links_to_upload = apply_limit_to_links limit filtered_links in
387387+ (* Apply limit if specified *)
388388+ let links_to_upload = apply_limit_to_links limit filtered_links in
399389400400- if links_to_upload = [] then begin
401401- log "No links to upload to %s (all links already have karakeep data)\n" base_url;
402402- 0
403403- end else begin
404404- log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
390390+ if links_to_upload = [] then begin
391391+ log "No links to upload to %s (all links already have karakeep data)\n" base_url;
392392+ 0
393393+ end else begin
394394+ log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
405395406406- (* Split links into batches for parallel processing *)
407407- let batches = create_batches max_concurrent links_to_upload in
408408- log_verbose verbose "Processing in %d batches of up to %d links each...\n"
409409- (List.length batches) max_concurrent;
410410- log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
396396+ (* Split links into batches for parallel processing *)
397397+ let batches = create_batches max_concurrent links_to_upload in
398398+ log_verbose verbose "Processing in %d batches of up to %d links each...\n"
399399+ (List.length batches) max_concurrent;
400400+ log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
411401412412- (* Process batches and accumulate updated links *)
413413- let updated_links = ref [] in
402402+ (* Process batches and accumulate updated links *)
403403+ let updated_links = ref [] in
414404415415- let result = try
416416- let rec process_batches total_count batch_num = function
417417- | [] -> total_count
418418- | batch :: rest ->
419419- let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
420420- batch_num (List.length batches) batch in
405405+ let result = try
406406+ let rec process_batches total_count batch_num = function
407407+ | [] -> total_count
408408+ | batch :: rest ->
409409+ let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
410410+ batch_num (List.length batches) batch in
421411422422- (* Count successes in this batch *)
423423- let batch_successes = List.fold_left (+) 0 results in
424424- let new_total = total_count + batch_successes in
412412+ (* Count successes in this batch *)
413413+ let batch_successes = List.fold_left (+) 0 results in
414414+ let new_total = total_count + batch_successes in
425415426426- log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
427427- (batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
416416+ log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
417417+ (batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
428418429429- (* Add a delay before processing the next batch *)
430430- if rest <> [] then begin
431431- log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
432432- Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
433433- end;
434434- process_batches new_total (batch_num + 1) rest
435435- in
436436- process_batches 0 0 batches
437437- with exn ->
438438- log "Error during upload operation: %s\n" (Printexc.to_string exn);
439439- 0
440440- in
419419+ (* Add a delay before processing the next batch *)
420420+ if rest <> [] then begin
421421+ log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
422422+ Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
423423+ end;
424424+ process_batches new_total (batch_num + 1) rest
425425+ in
426426+ process_batches 0 0 batches
427427+ with exn ->
428428+ log "Error during upload operation: %s\n" (Printexc.to_string exn);
429429+ 0
430430+ in
441431442442- (* Update the links file with the new karakeep_ids *)
443443- update_links_file links_file links updated_links;
432432+ (* Update the links file with the new karakeep_ids *)
433433+ update_links_file links_file links updated_links;
444434445445- log "Upload complete. %d/%d links uploaded successfully.\n"
446446- result (List.length links_to_upload);
435435+ log "Upload complete. %d/%d links uploaded successfully.\n"
436436+ result (List.length links_to_upload);
447437448448- 0
449449- end
438438+ 0
439439+ end
450440451441(* Common arguments *)
452442let links_file_arg =
···458448 let default = "https://hoard.recoil.org" in
459449 Arg.(value & opt string default & info ["url"] ~doc ~docv:"URL")
460450461461-let api_key_arg =
462462- let doc = "API key for Karakeep authentication (ak1_<key_id>_<secret>)" in
463463- let get_api_key () =
464464- let home = try Sys.getenv "HOME" with Not_found -> "." in
465465- let key_path = Filename.concat home ".karakeep-api" in
466466- try
467467- let ic = open_in key_path in
468468- let key = input_line ic in
469469- close_in ic;
470470- Some (String.trim key)
471471- with _ -> None
472472- in
473473- Arg.(value & opt (some string) (get_api_key ()) & info ["api-key"] ~doc ~docv:"API_KEY")
474474-475451let tag_arg =
476452 let doc = "Tag to filter or apply to bookmarks" in
477453 Arg.(value & opt string "" & info ["tag"; "t"] ~doc ~docv:"TAG")
···509485 Arg.(value & flag & info ["verbose"; "v"] ~doc)
510486511487(* Command definitions *)
512512-let init_cmd =
513513- let doc = "Initialize a new links.yml file" in
514514- let info = Cmd.info "init" ~doc in
515515- Cmd.v info Term.(const init_links_file $ links_file_arg)
488488+let make_cmd eio_env =
489489+ let init_cmd =
490490+ let doc = "Initialize a new links.yml file" in
491491+ let info = Cmd.info "init" ~doc in
492492+ Cmd.v info Term.(const init_links_file $ links_file_arg)
493493+ in
516494517517-let karakeep_cmd =
518518- let doc = "Update links.yml with links from Karakeep" in
519519- let info = Cmd.info "karakeep" ~doc in
520520- Cmd.v info Term.(const (fun base_url api_key_opt tag links_file download_assets ->
521521- Eio_main.run @@ fun env ->
522522- Eio.Switch.run @@ fun sw ->
523523- update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets)
524524- $ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
495495+ let karakeep_cmd =
496496+ let doc = "Update links.yml with links from Karakeep" in
497497+ let info = Cmd.info "karakeep" ~doc in
498498+ let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in
499499+ Cmd.v info Term.(const (fun base_url profile tag links_file download_assets ->
500500+ Eio.Switch.run @@ fun sw ->
501501+ update_from_karakeep ~sw ~env:eio_env base_url profile tag links_file download_assets)
502502+ $ base_url_arg $ profile_term $ tag_arg $ links_file_arg $ download_assets_arg)
503503+ in
525504526526-let bushel_cmd =
527527- let doc = "Update links.yml with outgoing links from Bushel entries" in
528528- let info = Cmd.info "bushel" ~doc in
529529- Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg)
505505+ let bushel_cmd =
506506+ let doc = "Update links.yml with outgoing links from Bushel entries" in
507507+ let info = Cmd.info "bushel" ~doc in
508508+ Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg)
509509+ in
530510531531-let upload_cmd =
532532- let doc = "Upload links without karakeep data to Karakeep" in
533533- let info = Cmd.info "upload" ~doc in
534534- Cmd.v info Term.(const (fun base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose ->
535535- Eio_main.run @@ fun env ->
536536- Eio.Switch.run @@ fun sw ->
537537- upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose)
538538- $ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
511511+ let upload_cmd =
512512+ let doc = "Upload links without karakeep data to Karakeep" in
513513+ let info = Cmd.info "upload" ~doc in
514514+ let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in
515515+ Cmd.v info Term.(const (fun base_url profile links_file tag max_concurrent delay_seconds limit verbose ->
516516+ Eio.Switch.run @@ fun sw ->
517517+ upload_to_karakeep ~sw ~env:eio_env base_url profile links_file tag max_concurrent delay_seconds limit verbose)
518518+ $ base_url_arg $ profile_term $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
519519+ in
539520540540-(* Export the term and cmd for use in main bushel.ml *)
541541-let cmd =
521521+ (* Export the term and cmd for use in main bushel.ml *)
542522 let doc = "Manage links between Bushel and Karakeep" in
543523 let info = Cmd.info "links" ~doc in
544524 Cmd.group info [init_cmd; karakeep_cmd; bushel_cmd; upload_cmd]
545545-546546-(* For standalone execution *)
547547-(* Main entry point removed - accessed through bushel_main.ml *)
+63-56
stack/bushel/bin/bushel_main.ml
···4455(* Import actual command implementations from submodules *)
6677-(* Faces command *)
88-let faces_cmd =
99- let doc = "Retrieve face thumbnails from Immich photo service" in
1010- let info = Cmd.info "faces" ~version ~doc in
1111- Cmd.v info Bushel_faces.term
77+(* Build commands - these need Eio environment *)
88+let build_commands env =
99+ (* Faces command *)
1010+ let faces_cmd = Bushel_faces.make_cmd env in
12111313-(* Links command - uses group structure *)
1414-let links_cmd = Bushel_links.cmd
1212+ (* Links command - uses group structure *)
1313+ let links_cmd = Bushel_links.make_cmd env in
15141616-(* Obsidian command *)
1717-let obsidian_cmd =
1818- let doc = "Convert Bushel entries to Obsidian format" in
1919- let info = Cmd.info "obsidian" ~version ~doc in
2020- Cmd.v info Bushel_obsidian.term
1515+ (* Obsidian command *)
1616+ let obsidian_cmd =
1717+ let doc = "Convert Bushel entries to Obsidian format" in
1818+ let info = Cmd.info "obsidian" ~version ~doc in
1919+ Cmd.v info Bushel_obsidian.term
2020+ in
21212222-(* Paper command *)
2323-let paper_cmd =
2424- let doc = "Fetch paper metadata from DOI" in
2525- let info = Cmd.info "paper" ~version ~doc in
2626- Cmd.v info Bushel_paper.term
2222+ (* Paper command *)
2323+ let paper_cmd =
2424+ let doc = "Fetch paper metadata from DOI" in
2525+ let info = Cmd.info "paper" ~version ~doc in
2626+ Cmd.v info Bushel_paper.term
2727+ in
27282828-(* Paper classify command *)
2929-let paper_classify_cmd = Bushel_paper_classify.cmd
2929+ (* Paper classify command *)
3030+ let paper_classify_cmd = Bushel_paper_classify.cmd in
30313131-(* Paper tex command *)
3232-let paper_tex_cmd = Bushel_paper_tex.cmd
3232+ (* Paper tex command *)
3333+ let paper_tex_cmd = Bushel_paper_tex.cmd in
33343434-(* Thumbs command *)
3535-let thumbs_cmd =
3636- let doc = "Generate thumbnails from paper PDFs" in
3737- let info = Cmd.info "thumbs" ~version ~doc in
3838- Cmd.v info Bushel_thumbs.term
3535+ (* Thumbs command *)
3636+ let thumbs_cmd =
3737+ let doc = "Generate thumbnails from paper PDFs" in
3838+ let info = Cmd.info "thumbs" ~version ~doc in
3939+ Cmd.v info Bushel_thumbs.term
4040+ in
39414040-(* Video command *)
4141-let video_cmd =
4242- let doc = "Fetch videos from PeerTube instances" in
4343- let info = Cmd.info "video" ~version ~doc in
4444- Cmd.v info Bushel_video.term
4242+ (* Video command *)
4343+ let video_cmd =
4444+ let doc = "Fetch videos from PeerTube instances" in
4545+ let info = Cmd.info "video" ~version ~doc in
4646+ Cmd.v info Bushel_video.term
4747+ in
45484646-(* Video thumbs command *)
4747-let video_thumbs_cmd = Bushel_video_thumbs.cmd
4949+ (* Video thumbs command *)
5050+ let video_thumbs_cmd = Bushel_video_thumbs.cmd in
48514949-(* Query command *)
5050-let query_cmd =
5151- let doc = "Query Bushel collections using multisearch" in
5252- let info = Cmd.info "query" ~version ~doc in
5353- Cmd.v info Bushel_search.term
5252+ (* Query command *)
5353+ let query_cmd =
5454+ let doc = "Query Bushel collections using multisearch" in
5555+ let info = Cmd.info "query" ~version ~doc in
5656+ Cmd.v info (Bushel_search.make_term env)
5757+ in
54585555-(* Bibtex command *)
5656-let bibtex_cmd =
5757- let doc = "Export bibtex for all papers" in
5858- let info = Cmd.info "bibtex" ~version ~doc in
5959- Cmd.v info Bushel_bibtex.term
5959+ (* Bibtex command *)
6060+ let bibtex_cmd =
6161+ let doc = "Export bibtex for all papers" in
6262+ let info = Cmd.info "bibtex" ~version ~doc in
6363+ Cmd.v info Bushel_bibtex.term
6464+ in
60656161-(* Ideas command *)
6262-let ideas_cmd = Bushel_ideas.cmd
6666+ (* Ideas command *)
6767+ let ideas_cmd = Bushel_ideas.cmd in
63686464-(* Info command *)
6565-let info_cmd = Bushel_info.cmd
6969+ (* Info command *)
7070+ let info_cmd = Bushel_info.cmd in
66716767-(* Missing command *)
6868-let missing_cmd = Bushel_missing.cmd
7272+ (* Missing command *)
7373+ let missing_cmd = Bushel_missing.cmd in
69747070-(* Note DOI command *)
7171-let note_doi_cmd = Bushel_note_doi.cmd
7575+ (* Note DOI command *)
7676+ let note_doi_cmd = Bushel_note_doi.cmd in
72777373-(* DOI resolve command *)
7474-let doi_cmd = Bushel_doi.cmd
7878+ (* DOI resolve command *)
7979+ let doi_cmd = Bushel_doi.cmd in
75807676-(* Main command *)
7777-let bushel_cmd =
8181+ (* Main command *)
7882 let doc = "Bushel content management toolkit" in
7983 let sdocs = Manpage.s_common_options in
8084 let man = [
···112116 video_thumbs_cmd;
113117 ]
114118115115-let () = exit (Cmd.eval' bushel_cmd)119119+let () =
120120+ Eio_main.run @@ fun env ->
121121+ let bushel_cmd = build_commands env in
122122+ exit (Cmd.eval' bushel_cmd)
+25-23
stack/bushel/bin/bushel_search.ml
···2233(** TODO:claude Bushel search command for integration with main CLI *)
4455-let endpoint =
66- let doc = "Typesense server endpoint URL" in
77- Arg.(value & opt string "" & info ["endpoint"; "e"] ~doc)
88-99-let api_key =
1010- let doc = "Typesense API key for authentication" in
1111- Arg.(value & opt string "" & info ["api-key"; "k"] ~doc)
1212-55+let endpoint_override =
66+ let doc = "Override Typesense server endpoint URL" in
77+ Arg.(value & opt (some string) None & info ["endpoint"; "e"] ~doc)
138149let limit =
1510 let doc = "Maximum number of results to return" in
···2419 Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" ~doc)
25202621(** TODO:claude Search function using multisearch *)
2727-let search endpoint api_key query_text limit offset =
2828- let base_config = Bushel.Typesense.load_config_from_files () in
2929- let config = {
3030- Bushel.Typesense.endpoint = if endpoint = "" then base_config.endpoint else endpoint;
3131- api_key = if api_key = "" then base_config.api_key else api_key;
3232- openai_key = base_config.openai_key;
2222+let search env profile endpoint_override query_text limit offset =
2323+ (* Get credentials from keyeio profile *)
2424+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
2525+ let default_endpoint = "http://localhost:8108" in
2626+ let endpoint =
2727+ match endpoint_override with
2828+ | Some e -> e
2929+ | None ->
3030+ Keyeio.Profile.get profile ~key:"endpoint"
3131+ |> Option.value ~default:default_endpoint
3232+ in
3333+ let openai_key = Keyeio.Profile.get_required profile ~key:"openai_key" in
3434+3535+ let config = {
3636+ Bushel.Typesense.endpoint;
3737+ api_key;
3838+ openai_key;
3339 } in
3434-3535- if config.api_key = "" then (
3636- Printf.eprintf "Error: API key is required. Use --api-key, set TYPESENSE_API_KEY environment variable, or create .typesense-key file.\n";
3737- exit 1
3838- );
3939-4040+4041 Printf.printf "Searching Typesense at %s\n" config.endpoint;
4142 Printf.printf "Query: \"%s\"\n" query_text;
4243 Printf.printf "Limit: %d, Offset: %d\n" limit offset;
4344 Printf.printf "\n";
44454545- Eio_main.run @@ fun env ->
4646 Eio.Switch.run @@ fun sw ->
4747 (try
4848 let result = Bushel.Typesense.multisearch ~sw ~env config query_text ~limit:50 () in
···6363 exit 1
6464 )
65656666-(** TODO:claude Command line term *)
6767-let term = Term.(const search $ endpoint $ api_key $ query_text $ limit $ offset)6666+(** TODO:claude Command line term - takes eio_env from outside *)
6767+let make_term eio_env =
6868+ let profile_term = Bushel_common.typesense_key_term eio_env#fs in
6969+ Term.(const (search eio_env) $ profile_term $ endpoint_override $ query_text $ limit $ offset)
···11+# Eiocmd - Batteries-Included CLI Runner for Eio
22+33+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.
44+55+## Features
66+77+- **Logging Setup**: Automatic configuration of Logs with Fmt reporter
88+- **Cmdliner Integration**: Clean command-line argument parsing
99+- **Optional XDG Support**: Integrate with [xdge](../xdge) for XDG directory management
1010+- **Optional Keyeio Support**: Integrate with [keyeio](../keyeio) for API key management
1111+- **Minimal Boilerplate**: Get started quickly with sensible defaults
1212+1313+## Installation
1414+1515+```bash
1616+opam install eiocmd
1717+```
1818+1919+## Quick Start
2020+2121+### Basic Usage
2222+2323+```ocaml
2424+open Cmdliner
2525+2626+let main _env =
2727+ Logs.info (fun m -> m "Hello, world!");
2828+ 0
2929+3030+let () =
3131+ let info = Cmd.info "myapp" ~version:"1.0.0" ~doc:"My application" in
3232+ Eiocmd.run ~info main
3333+```
3434+3535+This automatically provides:
3636+- `--log-level` flag (debug, info, warning, error, app)
3737+- `--color` flag (auto, always, never)
3838+- Proper Eio environment setup
3939+4040+### With XDG Directory Support
4141+4242+```ocaml
4343+let main _env (xdg, _xdg_cmd) =
4444+ let config_dir = Xdge.config_dir xdg in
4545+ Logs.info (fun m -> m "Config dir: %a" Eio.Path.pp config_dir);
4646+ 0
4747+4848+let () =
4949+ let info = Cmd.info "myapp" ~doc:"My app with XDG support" in
5050+ Eiocmd.run ~info ~xdge:(Some "myapp") main
5151+```
5252+5353+### With Keyeio API Key Management
5454+5555+```ocaml
5656+let main _env (_xdg, _xdg_cmd) profile =
5757+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
5858+ Logs.info (fun m -> m "Using API key: %s..." (String.sub api_key 0 8));
5959+ (* Use api_key to create your API client *)
6060+ 0
6161+6262+let () =
6363+ let info = Cmd.info "myapp" ~doc:"My app with keyeio" in
6464+ Eiocmd.run ~info
6565+ ~xdge:(Some "myapp")
6666+ ~keyeio:(Some "myservice")
6767+ main
6868+```
6969+7070+This automatically provides:
7171+- `--profile` flag to select credential profile
7272+- `--key-file` flag to override credential file location
7373+- Automatic loading of credentials from `~/.config/myapp/keys/myservice.toml`
7474+7575+## API Documentation
7676+7777+See [the mli file](lib/eiocmd.mli) for full API documentation.
7878+7979+### Log Levels
8080+8181+The `--log-level` flag accepts:
8282+- `debug` - Debug messages and above
8383+- `info` - Informational messages and above (default)
8484+- `warning` - Warnings and above
8585+- `error` - Errors only
8686+- `app` - Application-specific messages
8787+8888+### Color Output
8989+9090+The `--color` flag accepts:
9191+- `auto` - Detect TTY capability (default)
9292+- `always` - Force color output
9393+- `never` - Disable color output
9494+9595+## Advanced Usage
9696+9797+For more control, you can use the low-level `Setup` module:
9898+9999+```ocaml
100100+let () =
101101+ Eio_main.run @@ fun env ->
102102+103103+ (* Initialize components manually *)
104104+ Eiocmd.Setup.init_rng env;
105105+ Eiocmd.Setup.init_logs ~level:(Some Logs.Debug) ();
106106+107107+ (* Your application logic *)
108108+ Logs.debug (fun m -> m "Starting application");
109109+ (* ... *)
110110+```
111111+112112+Or compose your own Cmdliner terms using `Eiocmd.Terms`:
113113+114114+```ocaml
115115+let main log_level style_renderer custom_arg =
116116+ Eio_main.run @@ fun env ->
117117+ Eiocmd.Setup.init_logs ~level:log_level ~style_renderer ();
118118+ (* ... *)
119119+120120+let () =
121121+ let custom_arg = Arg.(value & opt string "default" & info ["custom"]) in
122122+ let term = Term.(const main
123123+ $ Eiocmd.Terms.log_level
124124+ $ Eiocmd.Terms.style_renderer
125125+ $ custom_arg) in
126126+ let info = Cmd.info "myapp" in
127127+ exit (Cmd.eval' (Cmd.v info term))
128128+```
129129+130130+## Examples
131131+132132+See the [example directory](example/) for more comprehensive examples.
133133+134134+## License
135135+136136+ISC License - see LICENSE file for details.
+39
stack/eiocmd/dune-project
···11+(lang dune 3.0)
22+33+(name eiocmd)
44+55+(generate_opam_files true)
66+77+(source
88+ (github avsm/knot))
99+1010+(authors "Anil Madhavapeddy")
1111+1212+(maintainers "anil@recoil.org")
1313+1414+(license ISC)
1515+1616+(package
1717+ (name eiocmd)
1818+ (synopsis "Batteries-included CLI runner for Eio applications")
1919+ (description
2020+ "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).")
2121+ (depends
2222+ (ocaml
2323+ (>= 5.1.0))
2424+ (eio
2525+ (>= 1.0))
2626+ (eio_main
2727+ (>= 1.0))
2828+ (cmdliner
2929+ (>= 1.3.0))
3030+ (logs
3131+ (>= 0.7.0))
3232+ (fmt
3333+ (>= 0.9.0))
3434+ (toml
3535+ (>= 7.0.0))
3636+ (mirage-crypto-rng
3737+ (>= 1.0.0))
3838+ xdge
3939+ keyeio))
+39
stack/eiocmd/eiocmd.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Batteries-included CLI runner for Eio applications"
44+description:
55+ "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)."
66+maintainer: ["anil@recoil.org"]
77+authors: ["Anil Madhavapeddy"]
88+license: "ISC"
99+homepage: "https://github.com/avsm/knot"
1010+bug-reports: "https://github.com/avsm/knot/issues"
1111+depends: [
1212+ "dune" {>= "3.0"}
1313+ "ocaml" {>= "5.1.0"}
1414+ "eio" {>= "1.0"}
1515+ "eio_main" {>= "1.0"}
1616+ "cmdliner" {>= "1.3.0"}
1717+ "logs" {>= "0.7.0"}
1818+ "fmt" {>= "0.9.0"}
1919+ "toml" {>= "7.0.0"}
2020+ "mirage-crypto-rng" {>= "1.0.0"}
2121+ "xdge"
2222+ "keyeio"
2323+ "odoc" {with-doc}
2424+]
2525+build: [
2626+ ["dune" "subst"] {dev}
2727+ [
2828+ "dune"
2929+ "build"
3030+ "-p"
3131+ name
3232+ "-j"
3333+ jobs
3434+ "@install"
3535+ "@runtest" {with-test}
3636+ "@doc" {with-doc}
3737+ ]
3838+]
3939+dev-repo: "git+https://github.com/avsm/knot.git"
···11+(** Batteries-included CLI runner for Eio applications *)
22+33+(** {1 Running Applications} *)
44+55+(* Batteries-included run with XDG and keyeio *)
66+let run ~info ~app_name ~service main_term =
77+ let open Cmdliner in
88+99+ let run_main main profile_name_opt key_file_opt =
1010+ (* Initialize RNG with default entropy source *)
1111+ let () = Mirage_crypto_rng_unix.use_default () in
1212+1313+ Eio_main.run @@ fun env ->
1414+1515+ (* Create xdg context *)
1616+ let xdg = Xdge.create env#fs app_name in
1717+1818+ (* Load keyeio profile *)
1919+ let keyeio_ctx = Keyeio.create xdg in
2020+ let profile_name = Option.value profile_name_opt ~default:"default" in
2121+2222+ let profile = match key_file_opt with
2323+ | Some _path ->
2424+ (* TODO: Load from specified file *)
2525+ failwith "Direct file loading not yet supported in eiocmd (use --profile instead)"
2626+ | None ->
2727+ (* Load from XDG directory *)
2828+ match Keyeio.load_service keyeio_ctx ~service with
2929+ | Ok svc ->
3030+ (match Keyeio.Service.get_profile svc profile_name with
3131+ | Some prof -> prof
3232+ | None ->
3333+ failwith (Printf.sprintf "Profile '%s' not found in service '%s'"
3434+ profile_name service))
3535+ | Error (`Msg msg) -> failwith msg
3636+ in
3737+3838+ main env xdg profile
3939+ in
4040+4141+ (* Add profile and key-file flags *)
4242+ let profile_flag =
4343+ let doc = Printf.sprintf "Profile name to use for %s service" service in
4444+ Arg.(value & opt (some string) None & info [ "profile" ] ~docv:"NAME" ~doc)
4545+ in
4646+ let key_file_flag =
4747+ let doc = Printf.sprintf "Override with direct path to %s key file" service in
4848+ Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc)
4949+ in
5050+5151+ (* Compose with main term and add logging setup *)
5252+ let term =
5353+ let open Term.Syntax in
5454+ let+ main = main_term
5555+ and+ log_level = Logs_cli.level ()
5656+ and+ profile_name_opt = profile_flag
5757+ and+ key_file_opt = key_file_flag in
5858+ Logs.set_reporter (Logs_fmt.reporter ());
5959+ Logs.set_level log_level;
6060+ run_main main profile_name_opt key_file_opt
6161+ in
6262+ Cmd.v info term
+24
stack/eiocmd/lib/eiocmd.mli
···11+(** Batteries-included CLI runner for Eio applications
22+33+ Eiocmd provides a convenient wrapper for building command-line applications
44+ with Eio. It automatically handles common setup tasks and provides a clean
55+ interface for building CLIs with minimal boilerplate. *)
66+77+(** {1 Running Applications} *)
88+99+(** [run ~info ~app_name ~service main_term] creates a batteries-included CLI command.
1010+1111+ This is a comprehensive runner that sets up everything a typical CLI application needs:
1212+ - Random number generator initialization (Mirage_crypto_rng)
1313+ - Logging (via Logs_cli with standard flags)
1414+ - Cmdliner argument parsing
1515+ - XDG directory structure (all directories created)
1616+ - API key management via keyeio
1717+ ]} *)
1818+val run :
1919+ info:Cmdliner.Cmd.info ->
2020+ app_name:string ->
2121+ service:string ->
2222+ (Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Cmdliner.Term.t ->
2323+ Cmdliner.Cmd.Exit.code Cmdliner.Cmd.t
2424+
+5-2
stack/karakeepe/dune-project
···77(package
88 (name karakeepe)
99 (synopsis "Karakeep API client for OCaml using Eio")
1010- (description "An Eio-based OCaml client library for the Karakeep bookmark management service API")
1010+ (description "An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface")
1111 (depends
1212 (ocaml (>= 4.14))
1313 eio
···1616 ezjsonm
1717 fmt
1818 ptime
1919- uri))
1919+ uri
2020+ (keyeio (>= 0.1.0))
2121+ (xdge (>= 0.1.0))
2222+ (cmdliner (>= 1.2.0))))
+4-1
stack/karakeepe/karakeepe.opam
···33version: "0.1.0"
44synopsis: "Karakeep API client for OCaml using Eio"
55description:
66- "An Eio-based OCaml client library for the Karakeep bookmark management service API"
66+ "An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface"
77depends: [
88 "dune" {>= "3.0"}
99 "ocaml" {>= "4.14"}
···1414 "fmt"
1515 "ptime"
1616 "uri"
1717+ "keyeio" {>= "0.1.0"}
1818+ "xdge" {>= "0.1.0"}
1919+ "cmdliner" {>= "1.2.0"}
1720 "odoc" {with-doc}
1821]
1922build: [
+1-1
stack/keyeio/CLAUDE.md
···1313## Design Principles
14141515- Store credentials in XDG_CONFIG_HOME/appname/keys/ with 0o600 permissions
1616-- JSON format supporting multiple profiles per service
1616+- TOML format supporting multiple profiles per service
1717- Cmdliner integration following xdge patterns
1818- Future-proof design for Secret Service API integration
1919- Security-conscious pretty printing (mask sensitive values)
+17-20
stack/keyeio/README.md
···51515252### Storage Format
53535454-Keys are stored in `~/.config/<appname>/keys/<service>.json`:
5454+Keys are stored in `~/.config/<appname>/keys/<service>.toml`:
5555+5656+```toml
5757+[default]
5858+api_key = "abc123..."
5959+base_url = "https://api.example.com"
6060+6161+[production]
6262+api_key = "xyz789..."
6363+base_url = "https://api.prod.example.com"
55645656-```json
5757-{
5858- "default": {
5959- "api_key": "abc123...",
6060- "base_url": "https://api.example.com"
6161- },
6262- "production": {
6363- "api_key": "xyz789...",
6464- "base_url": "https://api.prod.example.com"
6565- },
6666- "staging": {
6767- "api_key": "def456...",
6868- "base_url": "https://api.staging.example.com"
6969- }
7070-}
6565+[staging]
6666+api_key = "def456..."
6767+base_url = "https://api.staging.example.com"
7168```
72697370## Examples
···134131val load_service : t -> service:string -> (Service.t, [> `Msg of string]) result
135132```
136133137137-Load all profiles for a service from `~/.config/<appname>/keys/<service>.json`.
134134+Load all profiles for a service from `~/.config/<appname>/keys/<service>.toml`.
138135139136```ocaml
140137val list_services : t -> (string list, [> `Msg of string]) result
141138```
142139143143-List all available services (JSON files in the keys directory).
140140+List all available services (TOML files in the keys directory).
144141145142### Working with Profiles
146143···192189193190### Current Security Model
194191195195-- Keys stored as JSON files in `~/.config/<appname>/keys/`
192192+- Keys stored as TOML files in `~/.config/<appname>/keys/`
196193- Files created with permissions `0o600` (owner read/write only)
197194- Sensitive values masked in pretty-printing output
198195- Follows standard Unix file permission security model
···251248- `XDG_CONFIG_HOME`: Base directory for config files (default: `~/.config`)
252249- `<APPNAME>_CONFIG_DIR`: Application-specific override (highest priority)
253250254254-Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.json`
251251+Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.toml`
255252256253## Documentation
257254
···31313232 let keys t = List.map fst t.data
33333434- let to_json t =
3535- let obj = List.map (fun (k, v) -> (k, `String v)) t.data in
3636- `Assoc obj
3434+ let to_toml t =
3535+ let table = Toml.Types.Table.empty in
3636+ List.fold_left
3737+ (fun tbl (k, v) -> Toml.Types.Table.add (Toml.Types.Table.Key.of_string k) (Toml.Types.TString v) tbl)
3838+ table
3939+ t.data
37403841 let pp ppf t =
3942 let mask_sensitive key =
···93969497 { xdg; backend = Filesystem { keys_dir } }
95989696-(** {1 JSON Parsing Helpers} *)
9999+(** {1 TOML Parsing Helpers} *)
971009898-let parse_profile ~service ~profile_name json =
9999- match json with
100100- | `Assoc fields ->
101101- let data =
102102- List.filter_map
103103- (fun (k, v) ->
104104- match v with
105105- | `String s -> Some (k, s)
106106- | _ -> None)
107107- fields
108108- in
109109- { Profile.service; name = profile_name; data }
110110- | _ ->
111111- raise
112112- (Invalid_key_file
113113- (Printf.sprintf "Profile '%s' in service '%s' is not a JSON object"
114114- profile_name service))
101101+let parse_profile ~service ~profile_name table =
102102+ let data =
103103+ Toml.Types.Table.fold
104104+ (fun key value acc ->
105105+ match value with
106106+ | Toml.Types.TString s ->
107107+ let key_str = Toml.Types.Table.Key.to_string key in
108108+ (key_str, s) :: acc
109109+ | _ -> acc)
110110+ table
111111+ []
112112+ in
113113+ { Profile.service; name = profile_name; data }
115114116116-let parse_service_file ~service json =
117117- match json with
118118- | `Assoc profile_list ->
119119- let profiles =
120120- List.map
121121- (fun (profile_name, profile_json) ->
122122- (profile_name, parse_profile ~service ~profile_name profile_json))
123123- profile_list
124124- in
125125- { Service.name = service; profiles }
126126- | _ ->
127127- raise
128128- (Invalid_key_file (Printf.sprintf "Service file '%s.json' is not a JSON object" service))
115115+let parse_service_file ~service toml_table =
116116+ let profiles =
117117+ Toml.Types.Table.fold
118118+ (fun key value acc ->
119119+ match value with
120120+ | Toml.Types.TTable profile_table ->
121121+ let profile_name = Toml.Types.Table.Key.to_string key in
122122+ (profile_name, parse_profile ~service ~profile_name profile_table) :: acc
123123+ | _ -> acc)
124124+ toml_table
125125+ []
126126+ in
127127+ if profiles = [] then
128128+ raise
129129+ (Invalid_key_file (Printf.sprintf "Service file '%s.toml' contains no valid profile tables" service))
130130+ else
131131+ { Service.name = service; profiles }
129132130133(** {1 File Operations} *)
131134135135+let create_default_keyfile t ~service ~profile ~data =
136136+ match t.backend with
137137+ | Filesystem { keys_dir } ->
138138+ let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in
139139+ (try
140140+ (* Load existing service file if it exists, otherwise start fresh *)
141141+ let existing_profiles =
142142+ try
143143+ let content = Eio.Path.load service_file in
144144+ let toml = Toml.Parser.(from_string content |> unsafe) in
145145+ let svc = parse_service_file ~service toml in
146146+ svc.Service.profiles
147147+ with
148148+ | Eio.Io (Eio.Fs.E (Not_found _), _) -> []
149149+ in
150150+151151+ (* Create or update the profile *)
152152+ let new_profile = { Profile.service; name = profile; data } in
153153+ let updated_profiles =
154154+ (* Remove existing profile with same name if present *)
155155+ List.filter (fun (name, _) -> name <> profile) existing_profiles
156156+ @ [(profile, new_profile)]
157157+ in
158158+159159+ (* Build TOML structure *)
160160+ let toml_table = Toml.Types.Table.empty in
161161+ let toml_table =
162162+ List.fold_left
163163+ (fun tbl (prof_name, prof) ->
164164+ let prof_table = Profile.to_toml prof in
165165+ Toml.Types.Table.add
166166+ (Toml.Types.Table.Key.of_string prof_name)
167167+ (Toml.Types.TTable prof_table)
168168+ tbl)
169169+ toml_table
170170+ updated_profiles
171171+ in
172172+173173+ (* Convert to TOML string *)
174174+ let toml_str = Toml.Printer.string_of_table toml_table in
175175+176176+ (* Write to file with restrictive permissions *)
177177+ Eio.Path.save ~create:(`Or_truncate 0o600) service_file toml_str;
178178+179179+ Ok ()
180180+ with
181181+ | Toml.Parser.Error (msg, _) ->
182182+ Error (`Msg (Printf.sprintf "Invalid TOML in existing %s.toml: %s" service msg))
183183+ | exn ->
184184+ Error (`Msg (Printf.sprintf "Error creating key file: %s" (Printexc.to_string exn))))
185185+132186let load_service t ~service =
133187 match t.backend with
134188 | Filesystem { keys_dir } ->
135135- let service_file = Eio.Path.(keys_dir / (service ^ ".json")) in
189189+ let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in
136190 (try
137137- (* Read and parse the JSON file *)
191191+ (* Read and parse the TOML file *)
138192 let content = Eio.Path.load service_file in
139139- let json = Yojson.Basic.from_string content in
140140- let service_data = parse_service_file ~service json in
193193+ let toml = Toml.Parser.(from_string content |> unsafe) in
194194+ let service_data = parse_service_file ~service toml in
141195 Ok service_data
142196 with
143197 | Eio.Io (Eio.Fs.E (Not_found _), _) ->
144144- Error (`Msg (Printf.sprintf "Service file not found: %s.json" service))
145145- | Yojson.Json_error msg ->
146146- Error (`Msg (Printf.sprintf "Invalid JSON in %s.json: %s" service msg))
198198+ Error (`Msg (Printf.sprintf "Service file not found: %s.toml" service))
199199+ | Toml.Parser.Error (msg, _) ->
200200+ Error (`Msg (Printf.sprintf "Invalid TOML in %s.toml: %s" service msg))
147201 | Invalid_key_file msg -> Error (`Msg msg)
148202 | exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn))))
149203···155209 let services =
156210 List.filter_map
157211 (fun entry ->
158158- if String.ends_with ~suffix:".json" entry then
212212+ if String.ends_with ~suffix:".toml" entry then
159213 Some (String.sub entry 0 (String.length entry - 5))
160214 else None)
161215 entries
···204258 | Some path ->
205259 (try
206260 let content = In_channel.with_open_bin path In_channel.input_all in
207207- let json = Yojson.Basic.from_string content in
208208- match parse_service_file ~service json with
261261+ let toml = Toml.Parser.(from_string content |> unsafe) in
262262+ match parse_service_file ~service toml with
209263 | svc ->
210264 (match Service.get_profile svc profile_name with
211265 | Some prof -> prof
···238292 | Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag)
239293 | None -> Term.(const load_profile $ profile_flag $ const None)
240294295295+ let create_term ~app_name ~fs ~service ~default_data
296296+ ?profile:(default_profile = "default") () =
297297+ let open Cmdliner in
298298+299299+ (* Profile name flag *)
300300+ let profile_flag =
301301+ let doc = Printf.sprintf "Profile name to create for %s service" service in
302302+ Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc)
303303+ in
304304+305305+ (* Create flags for each key in default_data *)
306306+ let key_flags =
307307+ List.map
308308+ (fun (key, default_val) ->
309309+ let flag_name = String.map (fun c -> if c = '_' then '-' else c) key in
310310+ let doc = Printf.sprintf "Value for %s" key in
311311+ let term =
312312+ Arg.(value & opt (some string) default_val & info [ flag_name ] ~docv:(String.uppercase_ascii key) ~doc)
313313+ in
314314+ (key, term))
315315+ default_data
316316+ in
317317+318318+ (* Helper to prompt for a value if not provided *)
319319+ let prompt_for_value key =
320320+ Printf.printf "Enter %s: %!" key;
321321+ try
322322+ input_line stdin
323323+ with End_of_file ->
324324+ failwith (Printf.sprintf "Failed to read %s from stdin" key)
325325+ in
326326+327327+ (* Term that creates the keyfile *)
328328+ let create_keyfile profile_name key_values =
329329+ try
330330+ (* Build the data list, prompting for missing values *)
331331+ let data =
332332+ List.map
333333+ (fun (key, value_opt) ->
334334+ match value_opt with
335335+ | Some v -> (key, v)
336336+ | None ->
337337+ let prompted = prompt_for_value key in
338338+ (key, prompted))
339339+ (List.combine (List.map fst default_data) key_values)
340340+ in
341341+342342+ (* Create the keyfile *)
343343+ let xdg = Xdge.create fs app_name in
344344+ let keyeio = create xdg in
345345+ match create_default_keyfile keyeio ~service ~profile:profile_name ~data with
346346+ | Ok () ->
347347+ let keys_dir = match keyeio.backend with
348348+ | Filesystem { keys_dir } -> Eio.Path.native_exn keys_dir
349349+ in
350350+ Printf.printf "Created %s profile in %s/%s.toml\n" profile_name keys_dir service;
351351+ 0
352352+ | Error (`Msg msg) ->
353353+ Printf.eprintf "Failed to create key file: %s\n" msg;
354354+ 1
355355+ with exn ->
356356+ Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
357357+ 1
358358+ in
359359+360360+ (* Build the term by applying all key flags *)
361361+ let rec build_term acc_term = function
362362+ | [] -> Term.(const create_keyfile $ profile_flag $ acc_term)
363363+ | (_, flag_term) :: rest ->
364364+ build_term Term.(const (fun lst x -> lst @ [x]) $ acc_term $ flag_term) rest
365365+ in
366366+ build_term (Term.const []) key_flags
367367+241368 let env_docs ~app_name ~service () =
242369 Printf.sprintf
243370 {|ENVIRONMENT
···247374 XDG_CONFIG_HOME
248375 Base directory for configuration files. If not set, defaults to
249376 $HOME/.config. Keys for %s will be stored in:
250250- $XDG_CONFIG_HOME/%s/keys/%s.json
377377+ $XDG_CONFIG_HOME/%s/keys/%s.toml
251378252379 Example locations:
253253- ~/.config/%s/keys/%s.json (default)
254254- /custom/config/%s/keys/%s.json (if XDG_CONFIG_HOME=/custom/config)
380380+ ~/.config/%s/keys/%s.toml (default)
381381+ /custom/config/%s/keys/%s.toml (if XDG_CONFIG_HOME=/custom/config)
255382256383 File permissions should be 0600 (owner read/write only) for security.
257384|}
+114-31
stack/keyeio/lib/keyeio.mli
···991010 - Store API keys in XDG-compliant directories with proper permissions
1111 - Support multiple profiles per service (production, staging, development)
1212- - JSON-based storage format for flexibility
1212+ - TOML-based storage format for readability and flexibility
1313 - Cmdliner integration for easy command-line usage
1414 - Designed for future Secret Service API integration
15151616 {b Security Model:}
17171818- Currently, credentials are stored as JSON files in [XDG_CONFIG_HOME/appname/keys/]
1818+ Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/]
1919 with strict filesystem permissions (0o600 - owner read/write only). This follows
2020 common practice for CLI tools and provides reasonable security for single-user
2121 systems.
···26262727 {b Storage Structure:}
28282929- Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.json] where SERVICE
2929+ Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE
3030 is the name of the service (e.g., "immiche", "karakeepe"). Each service file
3131 contains one or more named profiles:
32323333 {v
3434- {
3535- "default": {
3636- "api_key": "abc123...",
3737- "base_url": "https://api.example.com"
3838- },
3939- "production": {
4040- "api_key": "xyz789...",
4141- "base_url": "https://api.prod.example.com"
4242- }
4343- }
3434+ [default]
3535+ api_key = "abc123..."
3636+ base_url = "https://api.example.com"
3737+3838+ [production]
3939+ api_key = "xyz789..."
4040+ base_url = "https://api.prod.example.com"
4441 v}
45424643 {b Example Usage:}
···8784(** Exception raised when a profile is not found in a service. *)
8885exception Profile_not_found of string
89869090-(** Exception raised when attempting to access invalid JSON structure. *)
8787+(** Exception raised when attempting to access invalid TOML structure. *)
9188exception Invalid_key_file of string
92899390(** {1 Profile} *)
···155152 ]} *)
156153 val keys : t -> string list
157154158158- (** [to_json t] converts the profile to a JSON representation.
155155+ (** [to_toml t] converts the profile to a TOML table representation.
159156160160- Returns a JSON object containing all key-value pairs in the profile.
157157+ Returns a TOML table containing all key-value pairs in the profile.
161158162159 @param t The profile to convert
163163- @return A JSON object representation *)
164164- val to_json : t -> Yojson.Basic.t
160160+ @return A TOML table representation *)
161161+ val to_toml : t -> Toml.Types.table
165162166163 (** [pp ppf t] pretty prints a profile for debugging.
167164···182179 For example, an "immiche" service might contain "default", "production",
183180 and "staging" profiles, each with their own credentials.
184181185185- Services are loaded from JSON files in the keys directory, with one file
186186- per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.json] *)
182182+ Services are loaded from TOML files in the keys directory, with one file
183183+ per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *)
187184module Service : sig
188185 (** The type of a service containing multiple profiles. *)
189186 type t
···255252 ]} *)
256253val create : Xdge.t -> t
257254255255+(** {1 Creating Credentials} *)
256256+257257+(** [create_default_keyfile t ~service ~profile ~data] creates a new credential file.
258258+259259+ Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with
260260+ the provided profile and key-value pairs. If the file already exists,
261261+ it will be loaded and the new profile will be added or updated.
262262+263263+ @param t The Keyeio context
264264+ @param service The service name (e.g., "karakeepe")
265265+ @param profile The profile name (default: "default")
266266+ @param data Key-value pairs to store in the profile
267267+ @return [Ok ()] on success, [Error (`Msg msg)] on failure
268268+269269+ {b Example:}
270270+ {[
271271+ let data = [
272272+ ("api_key", "ak1_example_key");
273273+ ("base_url", "https://api.example.com")
274274+ ] in
275275+ match Keyeio.create_default_keyfile keyeio ~service:"karakeepe"
276276+ ~profile:"default" ~data with
277277+ | Ok () -> Printf.printf "Key file created successfully\n"
278278+ | Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg
279279+ ]}
280280+281281+ {b Security:} The file is created with permissions 0o600 (owner read/write only). *)
282282+val create_default_keyfile :
283283+ t ->
284284+ service:string ->
285285+ profile:string ->
286286+ data:(string * string) list ->
287287+ (unit, [> `Msg of string ]) result
288288+258289(** {1 Loading Credentials} *)
259290260291(** [load_service t ~service] loads all profiles for a given service.
261292262262- Reads the JSON file [XDG_CONFIG_HOME/appname/keys/SERVICE.json] and
263263- parses all profiles contained within. The file must be a JSON object
264264- where each key is a profile name and each value is an object containing
265265- credential key-value pairs.
293293+ Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and
294294+ parses all profiles contained within. The file must contain TOML tables
295295+ where each section name is a profile name containing credential key-value pairs.
266296267297 @param t The Keyeio context
268298 @param service The service name to load (e.g., "immiche", "karakeepe")
···283313 {b Error Conditions:}
284314 - Service file does not exist
285315 - Service file has incorrect permissions (not 0o600)
286286- - Service file contains invalid JSON
287287- - Service file is not a JSON object *)
316316+ - Service file contains invalid TOML
317317+ - Service file does not contain proper TOML tables *)
288318val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result
289319290320(** [list_services t] returns all available service names.
291321292292- Scans the keys directory for all [*.json] files and returns their
293293- base names (without the .json extension). This allows applications
322322+ Scans the keys directory for all [*.toml] files and returns their
323323+ base names (without the .toml extension). This allows applications
294324 to discover what services have stored credentials.
295325296326 @param t The Keyeio context
···306336 Printf.eprintf "Failed to list services: %s\n" msg
307337 ]}
308338309309- {b Note:} Only files with [.json] extension are considered. Files
339339+ {b Note:} Only files with [.toml] extension are considered. Files
310340 with incorrect permissions are silently skipped. *)
311341val list_services : t -> (string list, [> `Msg of string ]) result
312342···354384355385 {b Generated Command-line Flags:}
356386 - [--profile NAME]: Select which profile to use (default: "default")
357357- - [--key-file PATH]: Override with direct JSON file path (if [key_file=true])
387387+ - [--key-file PATH]: Override with direct TOML file path (if [key_file=true])
358388359389 {b Flag Precedence:}
360390 + [--key-file PATH] - highest priority (if enabled)
···403433 The term will fail with a clear error message if:
404434 - The service file does not exist
405435 - The requested profile is not found
406406- - The JSON file is invalid
436436+ - The TOML file is invalid
407437 - File permissions are incorrect *)
408438 val term :
409439 app_name:string ->
···413443 ?key_file:bool ->
414444 unit ->
415445 Profile.t Cmdliner.Term.t
446446+447447+ (** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles.
448448+449449+ This function generates a Cmdliner term that handles interactive creation
450450+ of credential files. It prompts for required values, allows optional overrides
451451+ via command-line flags, and creates the TOML file with proper permissions.
452452+453453+ @param app_name The application name (used for XDG paths)
454454+ @param fs The Eio filesystem providing filesystem access
455455+ @param service The service name to create credentials for (e.g., "karakeepe")
456456+ @param default_data Default key-value pairs with optional prompts
457457+ @param profile Default profile name to create (default: "default")
458458+459459+ {b Generated Command-line Flags:}
460460+ - [--profile NAME]: Profile name to create (default: "default")
461461+ - One flag per key in default_data (e.g., [--api-key], [--base-url])
462462+463463+ {b Example - Basic usage with prompts:}
464464+ {[
465465+ open Cmdliner
466466+467467+ let create_cmd env =
468468+ let default_data = [
469469+ ("api_key", None); (* Will prompt if not provided *)
470470+ ("base_url", Some "https://hoard.recoil.org") (* Has default *)
471471+ ] in
472472+473473+ let create_term = Keyeio.Cmd.create_term
474474+ ~app_name:"karakeepe"
475475+ ~fs:env#fs
476476+ ~service:"karakeepe"
477477+ ~default_data
478478+ () in
479479+480480+ Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials")
481481+ create_term
482482+ ]}
483483+484484+ {b Behavior:}
485485+ - If a value is provided via CLI flag, use it
486486+ - If a value has a default in default_data, use it
487487+ - Otherwise, prompt interactively for the value
488488+ - Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml]
489489+ - Set file permissions to 0o600 for security
490490+ - Return 0 on success, 1 on failure *)
491491+ val create_term :
492492+ app_name:string ->
493493+ fs:Eio.Fs.dir_ty Eio.Path.t ->
494494+ service:string ->
495495+ default_data:(string * string option) list ->
496496+ ?profile:string ->
497497+ unit ->
498498+ int Cmdliner.Term.t
416499417500 (** [env_docs ~app_name ~service ()] generates documentation for environment variables.
418501
+35-44
stack/keyeio/test/keyeio.t
···66 $ mkdir -p $PWD/test_config/keyeio-example/keys
7788Create a test service file with multiple profiles:
99- $ cat > $PWD/test_config/keyeio-example/keys/immiche.json << 'EOF'
1010- > {
1111- > "default": {
1212- > "api_key": "test_default_key_12345",
1313- > "base_url": "https://immich.example.com"
1414- > },
1515- > "production": {
1616- > "api_key": "prod_key_67890",
1717- > "base_url": "https://immich.prod.example.com",
1818- > "extra_field": "production_value"
1919- > },
2020- > "staging": {
2121- > "api_key": "staging_key_abcde",
2222- > "base_url": "https://immich.staging.example.com"
2323- > }
2424- > }
99+ $ cat > $PWD/test_config/keyeio-example/keys/immiche.toml << 'EOF'
1010+ > [default]
1111+ > api_key = "test_default_key_12345"
1212+ > base_url = "https://immich.example.com"
1313+ >
1414+ > [production]
1515+ > api_key = "prod_key_67890"
1616+ > base_url = "https://immich.prod.example.com"
1717+ > extra_field = "production_value"
1818+ >
1919+ > [staging]
2020+ > api_key = "staging_key_abcde"
2121+ > base_url = "https://immich.staging.example.com"
2522 > EOF
26232724Test listing available services:
···3229Test listing profiles for a service:
3330 $ ../example/keyeio_example.exe profiles immiche
3431 === List Profiles Example ===
3535- Error loading service 'immiche': Service file not found: immiche.json
3232+ Error loading service 'immiche': Service file not found: immiche.toml
363337343835Test basic usage with default profile:
···4239 Profile: default
4340 API Key loaded: test_defau...
4441 Base URL: https://immich.example.com
4545- Available keys: api_key,
4646- base_url
4242+ Available keys: base_url,
4343+ api_key
47444845 Profile details:
4946 Profile immiche.default:
5050- api_key: test_def***
5147 base_url: https://immich.example.com
4848+ api_key: test_def***
524953505451Test using a specific profile:
···5855 Profile: production
5956 API Key loaded: prod_key_6...
6057 Base URL: https://immich.prod.example.com
6161- Available keys: api_key, base_url,
6262- extra_field
5858+ Available keys: extra_field, base_url,
5959+ api_key
63606461 Profile details:
6562 Profile immiche.production:
6363+ extra_field: production_value
6464+ base_url: https://immich.prod.example.com
6665 api_key: prod_key***
6767- base_url: https://immich.prod.example.com
6868- extra_field: production_value
696670677168Test API client simulation with staging profile:
···9289Test error handling - nonexistent service:
9390 $ ../example/keyeio_example.exe profiles nonexistent
9491 === List Profiles Example ===
9595- Error loading service 'nonexistent': Service file not found: nonexistent.json
9292+ Error loading service 'nonexistent': Service file not found: nonexistent.toml
96939794Test with multiple services - create another service file:
9898- $ cat > $PWD/test_config/keyeio-example/keys/karakeepe.json << 'EOF'
9999- > {
100100- > "default": {
101101- > "api_key": "hoard_default_key_xyz",
102102- > "base_url": "https://hoard.example.com"
103103- > }
104104- > }
9595+ $ cat > $PWD/test_config/keyeio-example/keys/karakeepe.toml << 'EOF'
9696+ > [default]
9797+ > api_key = "hoard_default_key_xyz"
9898+ > base_url = "https://hoard.example.com"
10599 > EOF
106100107101List services should now show both:
···110104 No services configured yet
111105112106Test with key-file override:
113113- $ cat > ./custom_keys.json << 'EOF'
114114- > {
115115- > "custom": {
116116- > "api_key": "custom_key_123",
117117- > "base_url": "https://custom.example.com"
118118- > }
119119- > }
107107+ $ cat > ./custom_keys.toml << 'EOF'
108108+ > [custom]
109109+ > api_key = "custom_key_123"
110110+ > base_url = "https://custom.example.com"
120111 > EOF
121121- $ ../example/keyeio_example.exe basic --key-file ./custom_keys.json --profile custom
112112+ $ ../example/keyeio_example.exe basic --key-file ./custom_keys.toml --profile custom
122113 === Basic Example ===
123114 Service: immiche
124115 Profile: custom
125116 API Key loaded: custom_key...
126117 Base URL: https://custom.example.com
127127- Available keys: api_key,
128128- base_url
118118+ Available keys: base_url,
119119+ api_key
129120130121 Profile details:
131122 Profile immiche.custom:
132132- api_key: custom_k***
133123 base_url: https://custom.example.com
124124+ api_key: custom_k***
134125135126136127Test file permissions (keys should have restrictive permissions):
137137- $ ls -l $PWD/test_config/keyeio-example/keys/immiche.json | awk '{print $1}' | grep -E '^-rw'
128128+ $ ls -l $PWD/test_config/keyeio-example/keys/immiche.toml | awk '{print $1}' | grep -E '^-rw'
138129 -rw-r--r--@
139130140131Test empty keys directory:
···533533(* Commands - these are created within Eio context *)
534534let user_add_cmd fs =
535535 let doc = "Add a new user" in
536536- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
536536+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
537537 let run log_level style_renderer (xdg, _cfg) username fullname email =
538538 setup_logs style_renderer log_level;
539539 let state = { xdg } in
···545545546546let user_remove_cmd fs =
547547 let doc = "Remove a user" in
548548- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
548548+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
549549 let run log_level style_renderer (xdg, _cfg) username =
550550 setup_logs style_renderer log_level;
551551 let state = { xdg } in
···556556557557let user_list_cmd fs =
558558 let doc = "List all users" in
559559- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
559559+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
560560 let run log_level style_renderer (xdg, _cfg) =
561561 setup_logs style_renderer log_level;
562562 let state = { xdg } in
···567567568568let user_show_cmd fs =
569569 let doc = "Show user details" in
570570- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
570570+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
571571 let run log_level style_renderer (xdg, _cfg) username =
572572 setup_logs style_renderer log_level;
573573 let state = { xdg } in
···578578579579let user_add_feed_cmd fs =
580580 let doc = "Add a feed to a user" in
581581- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
581581+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
582582 let run log_level style_renderer (xdg, _cfg) username name url =
583583 setup_logs style_renderer log_level;
584584 let state = { xdg } in
···589589590590let user_remove_feed_cmd fs =
591591 let doc = "Remove a feed from a user" in
592592- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
592592+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
593593 let run log_level style_renderer (xdg, _cfg) username url =
594594 setup_logs style_renderer log_level;
595595 let state = { xdg } in
···612612613613let sync_cmd fs env =
614614 let doc = "Sync feeds for users" in
615615- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
615615+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
616616 let username_opt =
617617 let doc = "Sync specific user (omit to sync all)" in
618618 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
···646646647647let list_cmd fs =
648648 let doc = "List recent posts (from all users by default, or specify a user)" in
649649- let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
649649+ let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
650650 let username_opt_arg =
651651 let doc = "Username (optional - defaults to all users)" in
652652 Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
···1919 ; data_dirs : Eio.Fs.dir_ty Eio.Path.t list
2020 }
21212222+type dir = [
2323+ | `Config
2424+ | `Cache
2525+ | `Data
2626+ | `State
2727+ | `Runtime
2828+]
2929+2230let ensure_dir ?(perm = 0o755) path = Eio.Path.mkdirs ~exists_ok:true ~perm path
23312432let validate_runtime_base_dir base_path =
···412420 }
413421414422 let term app_name fs
415415- ?(config=true) ?(data=true) ?(cache=true) ?(state=true) ?(runtime=true) () =
423423+ ?(dirs=[`Config; `Data; `Cache; `State; `Runtime]) () =
416424 let open Cmdliner in
417425 let app_upper = String.uppercase_ascii app_name in
418426 let show_paths =
419427 let doc = "Show only the resolved directory paths without formatting" in
420428 Arg.(value & flag & info [ "show-paths" ] ~doc)
421429 in
430430+ let has_dir d = List.mem d dirs in
422431 let make_dir_arg ~enabled name env_suffix xdg_var default_path =
423432 if not enabled then
424433 (* Return a term that always gives the environment-only result *)
···468477 let home_prefix = "\\$HOME" in
469478 let config_dir =
470479 make_dir_arg
471471- ~enabled:config
480480+ ~enabled:(has_dir `Config)
472481 "config"
473482 "CONFIG_DIR"
474483 "XDG_CONFIG_HOME"
···476485 in
477486 let data_dir =
478487 make_dir_arg
479479- ~enabled:data
488488+ ~enabled:(has_dir `Data)
480489 "data"
481490 "DATA_DIR"
482491 "XDG_DATA_HOME"
···484493 in
485494 let cache_dir =
486495 make_dir_arg
487487- ~enabled:cache
496496+ ~enabled:(has_dir `Cache)
488497 "cache"
489498 "CACHE_DIR"
490499 "XDG_CACHE_HOME"
···492501 in
493502 let state_dir =
494503 make_dir_arg
495495- ~enabled:state
504504+ ~enabled:(has_dir `State)
496505 "state"
497506 "STATE_DIR"
498507 "XDG_STATE_HOME"
499508 (Some (home_prefix ^ "/.local/state/" ^ app_name))
500509 in
501501- let runtime_dir = make_dir_arg ~enabled:runtime "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in
510510+ let runtime_dir = make_dir_arg ~enabled:(has_dir `Runtime) "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in
502511 Term.(
503512 const
504513 (fun
+25-19
stack/xdge/lib/xdge.mli
···3535 @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification *)
36363737(** The main XDG context type containing all directory paths for an application.
3838-3838+3939 A value of type [t] represents the complete XDG directory structure for a
4040 specific application, including both user-specific and system-wide directories.
4141 All paths are resolved at creation time and are absolute paths within the
4242 Eio filesystem. *)
4343type t
4444+4545+(** XDG directory types for specifying which directories an application needs.
4646+4747+ These polymorphic variants allow applications to declare which XDG directories
4848+ they use, enabling runtime systems to only provide the requested directories. *)
4949+type dir = [
5050+ | `Config (** User configuration files *)
5151+ | `Cache (** User-specific cached data *)
5252+ | `Data (** User-specific application data *)
5353+ | `State (** User-specific state data (logs, history, etc.) *)
5454+ | `Runtime (** User-specific runtime files (sockets, pipes, etc.) *)
5555+]
44564557(** {1 Exceptions} *)
4658···361373 as determined by command-line arguments and environment variables. *)
362374 type t
363375364364- (** [term app_name fs ?config ?data ?cache ?state ?runtime ()] creates a
365365- Cmdliner term for XDG directory configuration.
376376+ (** [term app_name fs ?dirs ()] creates a Cmdliner term for XDG directory configuration.
366377367378 This function generates a Cmdliner term that handles XDG directory
368379 configuration through both command-line flags and environment variables,
369369- and directly returns the XDG context. Individual directory flags can be
370370- disabled by passing [false] for the corresponding optional parameter.
380380+ and directly returns the XDG context. Only command-line flags for the
381381+ requested directories are generated.
371382372383 @param app_name The application name (used for environment variable prefixes)
373384 @param fs The Eio filesystem to use for path resolution
374374- @param config Include [--config-dir] flag (default: true)
375375- @param data Include [--data-dir] flag (default: true)
376376- @param cache Include [--cache-dir] flag (default: true)
377377- @param state Include [--state-dir] flag (default: true)
378378- @param runtime Include [--runtime-dir] flag (default: true)
385385+ @param dirs List of directories to include flags for (default: all directories)
379386380387 {b Generated Command-line Flags:}
381381- Only the flags for enabled directories are generated:
382382- - [--config-dir DIR]: Override configuration directory (if [config=true])
383383- - [--data-dir DIR]: Override data directory (if [data=true])
384384- - [--cache-dir DIR]: Override cache directory (if [cache=true])
385385- - [--state-dir DIR]: Override state directory (if [state=true])
386386- - [--runtime-dir DIR]: Override runtime directory (if [runtime=true])
388388+ Only the flags for requested directories are generated:
389389+ - [--config-dir DIR]: Override configuration directory (if [`Config] in dirs)
390390+ - [--data-dir DIR]: Override data directory (if [`Data] in dirs)
391391+ - [--cache-dir DIR]: Override cache directory (if [`Cache] in dirs)
392392+ - [--state-dir DIR]: Override state directory (if [`State] in dirs)
393393+ - [--runtime-dir DIR]: Override runtime directory (if [`Runtime] in dirs)
387394388395 {b Environment Variable Precedence:}
389396 For each directory type, the following precedence applies:
···403410 {b Example - Only cache directory:}
404411 {[
405412 let open Cmdliner in
406406- let xdg_term = Cmd.term "myapp" env#fs
407407- ~config:false ~data:false ~state:false ~runtime:false () in
413413+ let xdg_term = Cmd.term "myapp" env#fs ~dirs:[`Cache] () in
408414 let main_term = Term.(const main $ xdg_term $ other_args) in
409415 (* ... *)
410416 ]} *)
411417 val term : string -> Eio.Fs.dir_ty Eio.Path.t ->
412412- ?config:bool -> ?data:bool -> ?cache:bool -> ?state:bool -> ?runtime:bool ->
418418+ ?dirs:dir list ->
413419 unit -> (xdg_t * t) Cmdliner.Term.t
414420415421 (** [cache_term app_name] creates a Cmdliner term that provides just the cache
+17-8
stack/zulip/lib/zulip/lib/client.ml
···2626 let client = create ~sw env auth in
2727 f client
28282929-let request t ~method_ ~path ?params ?body () =
2929+let request t ~method_ ~path ?params ?body ?content_type () =
3030 let url = Auth.server_url t.auth ^ path in
3131 Log.debug (fun m -> m "Request: %s %s"
3232 (match method_ with
···4646 (* Prepare request body if provided *)
4747 let body_opt = match body with
4848 | Some body_str ->
4949- (* Check if this looks like form data (key=value) or JSON *)
5050- if String.contains body_str '=' && not (String.contains body_str '{') then
5151- (* Form-encoded data *)
5252- Some (Requests.Body.of_string Requests.Mime.form body_str)
5353- else
5454- (* JSON data *)
5555- Some (Requests.Body.of_string Requests.Mime.json body_str)
4949+ let mime = match content_type with
5050+ | Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
5151+ (* Custom Content-Type for multipart *)
5252+ Requests.Mime.of_string ct
5353+ | Some "application/json" ->
5454+ Requests.Mime.json
5555+ | Some "application/x-www-form-urlencoded" | None ->
5656+ (* Default for form data *)
5757+ if String.contains body_str '=' && not (String.contains body_str '{') then
5858+ Requests.Mime.form
5959+ else
6060+ Requests.Mime.json
6161+ | Some ct ->
6262+ Requests.Mime.of_string ct
6363+ in
6464+ Some (Requests.Body.of_string mime body_str)
5665 | None -> None
5766 in
5867
+3-1
stack/zulip/lib/zulip/lib/client.mli
···2323 path:string ->
2424 ?params:(string * string) list ->
2525 ?body:string ->
2626+ ?content_type:string ->
2627 unit ->
2728 (Zulip_types.json, Zulip_types.zerror) result
2828-(** Make an HTTP request to the Zulip API using the requests library *)
2929+(** Make an HTTP request to the Zulip API using the requests library.
3030+ @param content_type Optional Content-Type header (default: application/x-www-form-urlencoded for POST/PUT, none for GET/DELETE) *)
29313032val pp : Format.formatter -> t -> unit
3133(** Pretty printer for client (shows server URL only, not credentials) *)
+62-3
stack/zulip/lib/zulip/lib/messages.ml
···3737 Client.request client ~method_:`GET ~path:("/api/v1/messages/" ^ string_of_int message_id) ()
38383939let get_messages client ?anchor ?num_before ?num_after ?narrow () =
4040- let params =
4040+ let params =
4141 (match anchor with Some a -> [("anchor", a)] | None -> []) @
4242 (match num_before with Some n -> [("num_before", string_of_int n)] | None -> []) @
4343 (match num_after with Some n -> [("num_after", string_of_int n)] | None -> []) @
4444 (match narrow with Some n -> List.mapi (fun i s -> ("narrow[" ^ string_of_int i ^ "]", s)) n | None -> []) in
4545-4646- Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params ()4545+4646+ Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params ()
4747+4848+let add_reaction client ~message_id ~emoji_name =
4949+ let params = [
5050+ ("emoji_name", emoji_name);
5151+ ("reaction_type", "unicode_emoji");
5252+ ] in
5353+ match Client.request client ~method_:`POST
5454+ ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
5555+ ~params () with
5656+ | Ok _ -> Ok ()
5757+ | Error err -> Error err
5858+5959+let remove_reaction client ~message_id ~emoji_name =
6060+ let params = [
6161+ ("emoji_name", emoji_name);
6262+ ("reaction_type", "unicode_emoji");
6363+ ] in
6464+ match Client.request client ~method_:`DELETE
6565+ ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
6666+ ~params () with
6767+ | Ok _ -> Ok ()
6868+ | Error err -> Error err
6969+7070+let upload_file client ~filename =
7171+ (* Read file contents *)
7272+ let ic = open_in_bin filename in
7373+ let len = in_channel_length ic in
7474+ let content = really_input_string ic len in
7575+ close_in ic;
7676+7777+ (* Extract just the filename from the path *)
7878+ let basename = Filename.basename filename in
7979+8080+ (* Create multipart form data boundary *)
8181+ let boundary = "----OCamlZulipBoundary" ^ string_of_float (Unix.gettimeofday ()) in
8282+8383+ (* Build multipart body *)
8484+ let body = Buffer.create (len + 1024) in
8585+ Buffer.add_string body ("--" ^ boundary ^ "\r\n");
8686+ Buffer.add_string body ("Content-Disposition: form-data; name=\"file\"; filename=\"" ^ basename ^ "\"\r\n");
8787+ Buffer.add_string body "Content-Type: application/octet-stream\r\n";
8888+ Buffer.add_string body "\r\n";
8989+ Buffer.add_string body content;
9090+ Buffer.add_string body ("\r\n--" ^ boundary ^ "--\r\n");
9191+9292+ let body_str = Buffer.contents body in
9393+ let content_type = "multipart/form-data; boundary=" ^ boundary in
9494+9595+ match Client.request client ~method_:`POST ~path:"/api/v1/user_uploads"
9696+ ~body:body_str ~content_type () with
9797+ | Ok json ->
9898+ (* Parse response to extract URI *)
9999+ (match json with
100100+ | `O fields ->
101101+ (match Jsonu.get_string fields "uri" with
102102+ | Ok uri -> Ok uri
103103+ | Error e -> Error e)
104104+ | _ -> Error (Zulip_types.create_error ~code:(Zulip_types.Other "upload_error") ~msg:"Failed to parse upload response" ()))
105105+ | Error err -> Error err
+41-1
stack/zulip/lib/zulip/lib/messages.mli
···99 ?num_after:int ->
1010 ?narrow:string list ->
1111 unit ->
1212- (Zulip_types.json, Zulip_types.zerror) result1212+ (Zulip_types.json, Zulip_types.zerror) result
1313+1414+(** Add an emoji reaction to a message.
1515+1616+ @param client The Zulip client
1717+ @param message_id The message ID to react to
1818+ @param emoji_name The emoji name (e.g., "thumbs_up", "heart", "rocket")
1919+ @return Ok () on success, Error on failure
2020+2121+ {b Example:}
2222+ {[
2323+ match Messages.add_reaction client ~message_id:12345 ~emoji_name:"thumbs_up" with
2424+ | Ok () -> print_endline "Reaction added!"
2525+ | Error e -> Printf.eprintf "Failed: %s\n" (Zulip_types.error_message e)
2626+ ]} *)
2727+val add_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result
2828+2929+(** Remove an emoji reaction from a message.
3030+3131+ @param client The Zulip client
3232+ @param message_id The message ID
3333+ @param emoji_name The emoji name to remove
3434+ @return Ok () on success, Error on failure *)
3535+val remove_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result
3636+3737+(** Upload a file to Zulip.
3838+3939+ @param client The Zulip client
4040+ @param filename The path to the file to upload
4141+ @return Ok uri where uri is the Zulip URL for the uploaded file, Error on failure
4242+4343+ {b Example:}
4444+ {[
4545+ match Messages.upload_file client ~filename:"/path/to/image.png" with
4646+ | Ok uri ->
4747+ let msg = Message.create ~type_:`Channel ~to_:["general"]
4848+ ~content:("Check out this image: " ^ uri) () in
4949+ Messages.send client msg
5050+ | Error e -> Printf.eprintf "Upload failed: %s\n" (Zulip_types.error_message e)
5151+ ]} *)
5252+val upload_file : Client.t -> filename:string -> (string, Zulip_types.zerror) result