My agentic slop goes here. Not intended for anyone else!

sync

+31 -11
stack/bushel/bin/bushel_common.ml
···
let url_term ~default ~doc =
Arg.(value & opt string default & info ["u"; "url"] ~docv:"URL" ~doc)
-
(** TODO:claude API key file term *)
-
let api_key_file ~default =
-
let doc = "File containing API key" in
-
Arg.(value & opt string default & info ["k"; "key-file"] ~docv:"FILE" ~doc)
-
-
(** TODO:claude API key term *)
-
let api_key =
-
let doc = "API key for authentication" in
-
Arg.(value & opt (some string) None & info ["api-key"] ~docv:"KEY" ~doc)
-
(** TODO:claude Overwrite flag *)
let overwrite =
let doc = "Overwrite existing files" in
···
(** TODO:claude Common setup term combining logs setup *)
let setup_term =
-
Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())
+
Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())
+
+
(** Keyeio integration for API credential management *)
+
+
(** Create XDG term for bushel *)
+
let xdg_term fs =
+
Xdge.Cmd.term "bushel" fs ()
+
+
(** Create keyeio term for Immiche service *)
+
let immiche_key_term fs =
+
Keyeio.Cmd.term
+
~app_name:"bushel"
+
~fs
+
~service:"immiche"
+
()
+
+
(** Create keyeio term for Karakeepe service *)
+
let karakeepe_key_term fs =
+
Keyeio.Cmd.term
+
~app_name:"bushel"
+
~fs
+
~service:"karakeepe"
+
()
+
+
(** Create keyeio term for Typesense service (includes both typesense and openai keys) *)
+
let typesense_key_term fs =
+
Keyeio.Cmd.term
+
~app_name:"bushel"
+
~fs
+
~service:"typesense"
+
()
+16 -19
stack/bushel/bin/bushel_faces.ml
···
open Cmdliner
open Printf
-
(* Read API key from file *)
-
let read_api_key file =
-
let ic = open_in file in
-
let key = input_line ic in
-
close_in ic;
-
key
-
(* Get face for a single contact *)
let get_face_for_contact immiche_client ~fs output_dir contact =
let names = Bushel.Contact.names contact in
···
end
(* Process all contacts or a specific one *)
-
let process_contacts ~sw ~env base_dir output_dir specific_handle api_key base_url =
+
let process_contacts ~sw ~env base_dir output_dir specific_handle profile =
printf "Loading Bushel database from %s\n%!" base_dir;
let db = Bushel.load base_dir in
let contacts = Bushel.Entry.contacts db in
printf "Found %d contacts\n%!" (List.length contacts);
+
+
(* Get credentials from keyeio profile *)
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let base_url = Keyeio.Profile.get profile ~key:"base_url"
+
|> Option.value ~default:"https://photos.recoil.org" in
+
+
printf "Connecting to Immich at %s\n%!" base_url;
(* Create Immiche client for connection pooling *)
let immiche_client = Immiche.create ~sw ~env ~base_url ~api_key () in
···
(* Command line interface *)
-
(* Export the term for use in main bushel.ml *)
-
let term =
+
(* Create term given the Eio environment *)
+
let make_term eio_env =
+
let immiche_profile_term = Bushel_common.immiche_key_term eio_env#fs in
Term.(
-
const (fun base_dir output_dir handle api_key_file base_url ->
+
const (fun base_dir output_dir handle profile ->
try
-
let api_key = read_api_key api_key_file in
-
Eio_main.run @@ fun env ->
Eio.Switch.run @@ fun sw ->
-
process_contacts ~sw ~env base_dir output_dir handle api_key base_url
+
process_contacts ~sw ~env:eio_env base_dir output_dir handle profile
with e ->
eprintf "Error: %s\n%!" (Printexc.to_string e);
1
-
) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
-
Bushel_common.api_key_file ~default:".photos-api" $
-
Bushel_common.url_term ~default:"https://photos.recoil.org" ~doc:"Base URL of the Immich instance")
+
) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $ immiche_profile_term)
-
let cmd =
+
let make_cmd eio_env =
let info = Cmd.info "faces" ~doc:"Retrieve face thumbnails for Bushel contacts from Immich" in
-
Cmd.v info term
+
Cmd.v info (make_term eio_env)
(* Main entry point removed - accessed through bushel_main.ml *)
+158 -181
stack/bushel/bin/bushel_links.ml
···
0
(* Update links.yml from Karakeep *)
-
let update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets =
-
match api_key_opt with
-
| None ->
-
prerr_endline "Error: API key is required.";
-
prerr_endline "Please provide one with --api-key or create a ~/.karakeep-api file.";
-
1
-
| Some api_key ->
-
let assets_dir = "data/assets" in
+
let update_from_karakeep ~sw ~env base_url profile tag links_file download_assets =
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let assets_dir = "data/assets" in
-
try
-
print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
+
try
+
print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
-
(* Prepare tag filter *)
-
let filter_tags = if tag = "" then [] else [tag] in
+
(* Prepare tag filter *)
+
let filter_tags = if tag = "" then [] else [tag] in
-
(* Fetch bookmarks from Karakeep *)
-
let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
+
(* Fetch bookmarks from Karakeep *)
+
let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
-
print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
+
print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
-
(* Read existing links if file exists *)
-
let existing_links = Bushel.Link.load_links_file links_file in
+
(* Read existing links if file exists *)
+
let existing_links = Bushel.Link.load_links_file links_file in
-
(* Convert bookmarks to bushel links *)
-
let new_links = List.map (fun bookmark ->
-
Karakeepe.to_bushel_link ~base_url bookmark
-
) bookmarks in
+
(* Convert bookmarks to bushel links *)
+
let new_links = List.map (fun bookmark ->
+
Karakeepe.to_bushel_link ~base_url bookmark
+
) bookmarks in
-
(* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
-
let merged_links = Bushel.Link.merge_links existing_links new_links in
+
(* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
+
let merged_links = Bushel.Link.merge_links existing_links new_links in
-
(* Save the updated links file *)
-
Bushel.Link.save_links_file links_file merged_links;
+
(* Save the updated links file *)
+
Bushel.Link.save_links_file links_file merged_links;
-
print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
+
print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
-
(* Download assets if requested *)
-
if download_assets then begin
-
print_endline "Downloading assets for bookmarks...";
+
(* Download assets if requested *)
+
if download_assets then begin
+
print_endline "Downloading assets for bookmarks...";
-
(* Ensure the assets directory exists *)
-
(try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
+
(* Ensure the assets directory exists *)
+
(try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
-
(* Process each bookmark with assets *)
-
List.iter (fun bookmark ->
-
(* Extract asset IDs from bookmark *)
-
let assets = bookmark.Karakeepe.assets in
+
(* Process each bookmark with assets *)
+
List.iter (fun bookmark ->
+
(* Extract asset IDs from bookmark *)
+
let assets = bookmark.Karakeepe.assets in
-
(* Skip if no assets *)
-
if assets <> [] then
-
(* Process each asset *)
-
List.iter (fun (asset_id, asset_type) ->
-
let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
-
let asset_file = Fmt.str "%s/asset.bin" asset_dir in
-
let meta_file = Fmt.str "%s/metadata.json" asset_dir in
+
(* Skip if no assets *)
+
if assets <> [] then
+
(* Process each asset *)
+
List.iter (fun (asset_id, asset_type) ->
+
let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
+
let asset_file = Fmt.str "%s/asset.bin" asset_dir in
+
let meta_file = Fmt.str "%s/metadata.json" asset_dir in
-
(* Skip if the asset already exists *)
-
if not (Sys.file_exists asset_file) then begin
-
(* Create the asset directory *)
-
(try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
+
(* Skip if the asset already exists *)
+
if not (Sys.file_exists asset_file) then begin
+
(* Create the asset directory *)
+
(try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
-
(* Download the asset *)
-
print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
-
let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
+
(* Download the asset *)
+
print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
+
let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
-
(* Guess content type based on first bytes *)
-
let content_type =
-
if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
-
"image/png"
-
else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
-
"image/jpeg"
-
else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
-
"application/pdf"
-
else
-
"application/octet-stream"
-
in
+
(* Guess content type based on first bytes *)
+
let content_type =
+
if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
+
"image/png"
+
else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
+
"image/jpeg"
+
else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
+
"application/pdf"
+
else
+
"application/octet-stream"
+
in
-
(* Write the asset data *)
-
let oc = open_out_bin asset_file in
-
output_string oc data;
-
close_out oc;
+
(* Write the asset data *)
+
let oc = open_out_bin asset_file in
+
output_string oc data;
+
close_out oc;
-
(* Write metadata file *)
-
let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
-
content_type asset_type in
-
let oc = open_out meta_file in
-
output_string oc metadata;
-
close_out oc
-
end
-
) assets
-
) bookmarks;
+
(* Write metadata file *)
+
let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
+
content_type asset_type in
+
let oc = open_out meta_file in
+
output_string oc metadata;
+
close_out oc
+
end
+
) assets
+
) bookmarks;
-
print_endline "Asset download completed.";
-
0
-
end else
-
0
-
with exn ->
-
prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
-
1
+
print_endline "Asset download completed.";
+
0
+
end else
+
0
+
with exn ->
+
prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
+
1
(* Extract outgoing links from Bushel entries *)
let update_from_bushel bushel_dir links_file include_domains exclude_domains =
···
end
(* Upload links to Karakeep that don't already have karakeep data *)
-
let upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
-
match api_key_opt with
-
| None ->
-
log "Error: API key is required.\n";
-
log "Please provide one with --api-key or create a ~/.karakeep-api file.\n";
-
1
-
| Some api_key ->
-
(* Load links from file *)
-
log_verbose verbose "Loading links from %s...\n" links_file;
-
let links = Bushel.Link.load_links_file links_file in
-
log_verbose verbose "Loaded %d total links\n" (List.length links);
+
let upload_to_karakeep ~sw ~env base_url profile links_file tag max_concurrent delay_seconds limit verbose =
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
(* Load links from file *)
+
log_verbose verbose "Loading links from %s...\n" links_file;
+
let links = Bushel.Link.load_links_file links_file in
+
log_verbose verbose "Loaded %d total links\n" (List.length links);
-
(* Filter links that don't have karakeep data for this remote *)
-
log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
-
let filtered_links = filter_links_without_karakeep base_url links in
-
log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
+
(* Filter links that don't have karakeep data for this remote *)
+
log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
+
let filtered_links = filter_links_without_karakeep base_url links in
+
log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
-
(* Apply limit if specified *)
-
let links_to_upload = apply_limit_to_links limit filtered_links in
+
(* Apply limit if specified *)
+
let links_to_upload = apply_limit_to_links limit filtered_links in
-
if links_to_upload = [] then begin
-
log "No links to upload to %s (all links already have karakeep data)\n" base_url;
-
0
-
end else begin
-
log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
+
if links_to_upload = [] then begin
+
log "No links to upload to %s (all links already have karakeep data)\n" base_url;
+
0
+
end else begin
+
log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
-
(* Split links into batches for parallel processing *)
-
let batches = create_batches max_concurrent links_to_upload in
-
log_verbose verbose "Processing in %d batches of up to %d links each...\n"
-
(List.length batches) max_concurrent;
-
log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
+
(* Split links into batches for parallel processing *)
+
let batches = create_batches max_concurrent links_to_upload in
+
log_verbose verbose "Processing in %d batches of up to %d links each...\n"
+
(List.length batches) max_concurrent;
+
log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
-
(* Process batches and accumulate updated links *)
-
let updated_links = ref [] in
+
(* Process batches and accumulate updated links *)
+
let updated_links = ref [] in
-
let result = try
-
let rec process_batches total_count batch_num = function
-
| [] -> total_count
-
| batch :: rest ->
-
let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
-
batch_num (List.length batches) batch in
+
let result = try
+
let rec process_batches total_count batch_num = function
+
| [] -> total_count
+
| batch :: rest ->
+
let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
+
batch_num (List.length batches) batch in
-
(* Count successes in this batch *)
-
let batch_successes = List.fold_left (+) 0 results in
-
let new_total = total_count + batch_successes in
+
(* Count successes in this batch *)
+
let batch_successes = List.fold_left (+) 0 results in
+
let new_total = total_count + batch_successes in
-
log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
-
(batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
+
log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
+
(batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
-
(* Add a delay before processing the next batch *)
-
if rest <> [] then begin
-
log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
-
Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
-
end;
-
process_batches new_total (batch_num + 1) rest
-
in
-
process_batches 0 0 batches
-
with exn ->
-
log "Error during upload operation: %s\n" (Printexc.to_string exn);
-
0
-
in
+
(* Add a delay before processing the next batch *)
+
if rest <> [] then begin
+
log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
+
Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
+
end;
+
process_batches new_total (batch_num + 1) rest
+
in
+
process_batches 0 0 batches
+
with exn ->
+
log "Error during upload operation: %s\n" (Printexc.to_string exn);
+
0
+
in
-
(* Update the links file with the new karakeep_ids *)
-
update_links_file links_file links updated_links;
+
(* Update the links file with the new karakeep_ids *)
+
update_links_file links_file links updated_links;
-
log "Upload complete. %d/%d links uploaded successfully.\n"
-
result (List.length links_to_upload);
+
log "Upload complete. %d/%d links uploaded successfully.\n"
+
result (List.length links_to_upload);
-
0
-
end
+
0
+
end
(* Common arguments *)
let links_file_arg =
···
let default = "https://hoard.recoil.org" in
Arg.(value & opt string default & info ["url"] ~doc ~docv:"URL")
-
let api_key_arg =
-
let doc = "API key for Karakeep authentication (ak1_<key_id>_<secret>)" in
-
let get_api_key () =
-
let home = try Sys.getenv "HOME" with Not_found -> "." in
-
let key_path = Filename.concat home ".karakeep-api" in
-
try
-
let ic = open_in key_path in
-
let key = input_line ic in
-
close_in ic;
-
Some (String.trim key)
-
with _ -> None
-
in
-
Arg.(value & opt (some string) (get_api_key ()) & info ["api-key"] ~doc ~docv:"API_KEY")
-
let tag_arg =
let doc = "Tag to filter or apply to bookmarks" in
Arg.(value & opt string "" & info ["tag"; "t"] ~doc ~docv:"TAG")
···
Arg.(value & flag & info ["verbose"; "v"] ~doc)
(* Command definitions *)
-
let init_cmd =
-
let doc = "Initialize a new links.yml file" in
-
let info = Cmd.info "init" ~doc in
-
Cmd.v info Term.(const init_links_file $ links_file_arg)
+
let make_cmd eio_env =
+
let init_cmd =
+
let doc = "Initialize a new links.yml file" in
+
let info = Cmd.info "init" ~doc in
+
Cmd.v info Term.(const init_links_file $ links_file_arg)
+
in
-
let karakeep_cmd =
-
let doc = "Update links.yml with links from Karakeep" in
-
let info = Cmd.info "karakeep" ~doc in
-
Cmd.v info Term.(const (fun base_url api_key_opt tag links_file download_assets ->
-
Eio_main.run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets)
-
$ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
+
let karakeep_cmd =
+
let doc = "Update links.yml with links from Karakeep" in
+
let info = Cmd.info "karakeep" ~doc in
+
let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in
+
Cmd.v info Term.(const (fun base_url profile tag links_file download_assets ->
+
Eio.Switch.run @@ fun sw ->
+
update_from_karakeep ~sw ~env:eio_env base_url profile tag links_file download_assets)
+
$ base_url_arg $ profile_term $ tag_arg $ links_file_arg $ download_assets_arg)
+
in
-
let bushel_cmd =
-
let doc = "Update links.yml with outgoing links from Bushel entries" in
-
let info = Cmd.info "bushel" ~doc in
-
Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg)
+
let bushel_cmd =
+
let doc = "Update links.yml with outgoing links from Bushel entries" in
+
let info = Cmd.info "bushel" ~doc in
+
Cmd.v info Term.(const update_from_bushel $ base_dir_arg $ links_file_arg $ include_domains_arg $ exclude_domains_arg)
+
in
-
let upload_cmd =
-
let doc = "Upload links without karakeep data to Karakeep" in
-
let info = Cmd.info "upload" ~doc in
-
Cmd.v info Term.(const (fun base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose ->
-
Eio_main.run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose)
-
$ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
+
let upload_cmd =
+
let doc = "Upload links without karakeep data to Karakeep" in
+
let info = Cmd.info "upload" ~doc in
+
let profile_term = Bushel_common.karakeepe_key_term eio_env#fs in
+
Cmd.v info Term.(const (fun base_url profile links_file tag max_concurrent delay_seconds limit verbose ->
+
Eio.Switch.run @@ fun sw ->
+
upload_to_karakeep ~sw ~env:eio_env base_url profile links_file tag max_concurrent delay_seconds limit verbose)
+
$ base_url_arg $ profile_term $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
+
in
-
(* Export the term and cmd for use in main bushel.ml *)
-
let cmd =
+
(* Export the term and cmd for use in main bushel.ml *)
let doc = "Manage links between Bushel and Karakeep" in
let info = Cmd.info "links" ~doc in
Cmd.group info [init_cmd; karakeep_cmd; bushel_cmd; upload_cmd]
-
-
(* For standalone execution *)
-
(* Main entry point removed - accessed through bushel_main.ml *)
+63 -56
stack/bushel/bin/bushel_main.ml
···
(* Import actual command implementations from submodules *)
-
(* Faces command *)
-
let faces_cmd =
-
let doc = "Retrieve face thumbnails from Immich photo service" in
-
let info = Cmd.info "faces" ~version ~doc in
-
Cmd.v info Bushel_faces.term
+
(* Build commands - these need Eio environment *)
+
let build_commands env =
+
(* Faces command *)
+
let faces_cmd = Bushel_faces.make_cmd env in
-
(* Links command - uses group structure *)
-
let links_cmd = Bushel_links.cmd
+
(* Links command - uses group structure *)
+
let links_cmd = Bushel_links.make_cmd env in
-
(* Obsidian command *)
-
let obsidian_cmd =
-
let doc = "Convert Bushel entries to Obsidian format" in
-
let info = Cmd.info "obsidian" ~version ~doc in
-
Cmd.v info Bushel_obsidian.term
+
(* Obsidian command *)
+
let obsidian_cmd =
+
let doc = "Convert Bushel entries to Obsidian format" in
+
let info = Cmd.info "obsidian" ~version ~doc in
+
Cmd.v info Bushel_obsidian.term
+
in
-
(* Paper command *)
-
let paper_cmd =
-
let doc = "Fetch paper metadata from DOI" in
-
let info = Cmd.info "paper" ~version ~doc in
-
Cmd.v info Bushel_paper.term
+
(* Paper command *)
+
let paper_cmd =
+
let doc = "Fetch paper metadata from DOI" in
+
let info = Cmd.info "paper" ~version ~doc in
+
Cmd.v info Bushel_paper.term
+
in
-
(* Paper classify command *)
-
let paper_classify_cmd = Bushel_paper_classify.cmd
+
(* Paper classify command *)
+
let paper_classify_cmd = Bushel_paper_classify.cmd in
-
(* Paper tex command *)
-
let paper_tex_cmd = Bushel_paper_tex.cmd
+
(* Paper tex command *)
+
let paper_tex_cmd = Bushel_paper_tex.cmd in
-
(* Thumbs command *)
-
let thumbs_cmd =
-
let doc = "Generate thumbnails from paper PDFs" in
-
let info = Cmd.info "thumbs" ~version ~doc in
-
Cmd.v info Bushel_thumbs.term
+
(* Thumbs command *)
+
let thumbs_cmd =
+
let doc = "Generate thumbnails from paper PDFs" in
+
let info = Cmd.info "thumbs" ~version ~doc in
+
Cmd.v info Bushel_thumbs.term
+
in
-
(* Video command *)
-
let video_cmd =
-
let doc = "Fetch videos from PeerTube instances" in
-
let info = Cmd.info "video" ~version ~doc in
-
Cmd.v info Bushel_video.term
+
(* Video command *)
+
let video_cmd =
+
let doc = "Fetch videos from PeerTube instances" in
+
let info = Cmd.info "video" ~version ~doc in
+
Cmd.v info Bushel_video.term
+
in
-
(* Video thumbs command *)
-
let video_thumbs_cmd = Bushel_video_thumbs.cmd
+
(* Video thumbs command *)
+
let video_thumbs_cmd = Bushel_video_thumbs.cmd in
-
(* Query command *)
-
let query_cmd =
-
let doc = "Query Bushel collections using multisearch" in
-
let info = Cmd.info "query" ~version ~doc in
-
Cmd.v info Bushel_search.term
+
(* Query command *)
+
let query_cmd =
+
let doc = "Query Bushel collections using multisearch" in
+
let info = Cmd.info "query" ~version ~doc in
+
Cmd.v info (Bushel_search.make_term env)
+
in
-
(* Bibtex command *)
-
let bibtex_cmd =
-
let doc = "Export bibtex for all papers" in
-
let info = Cmd.info "bibtex" ~version ~doc in
-
Cmd.v info Bushel_bibtex.term
+
(* Bibtex command *)
+
let bibtex_cmd =
+
let doc = "Export bibtex for all papers" in
+
let info = Cmd.info "bibtex" ~version ~doc in
+
Cmd.v info Bushel_bibtex.term
+
in
-
(* Ideas command *)
-
let ideas_cmd = Bushel_ideas.cmd
+
(* Ideas command *)
+
let ideas_cmd = Bushel_ideas.cmd in
-
(* Info command *)
-
let info_cmd = Bushel_info.cmd
+
(* Info command *)
+
let info_cmd = Bushel_info.cmd in
-
(* Missing command *)
-
let missing_cmd = Bushel_missing.cmd
+
(* Missing command *)
+
let missing_cmd = Bushel_missing.cmd in
-
(* Note DOI command *)
-
let note_doi_cmd = Bushel_note_doi.cmd
+
(* Note DOI command *)
+
let note_doi_cmd = Bushel_note_doi.cmd in
-
(* DOI resolve command *)
-
let doi_cmd = Bushel_doi.cmd
+
(* DOI resolve command *)
+
let doi_cmd = Bushel_doi.cmd in
-
(* Main command *)
-
let bushel_cmd =
+
(* Main command *)
let doc = "Bushel content management toolkit" in
let sdocs = Manpage.s_common_options in
let man = [
···
video_thumbs_cmd;
]
-
let () = exit (Cmd.eval' bushel_cmd)
+
let () =
+
Eio_main.run @@ fun env ->
+
let bushel_cmd = build_commands env in
+
exit (Cmd.eval' bushel_cmd)
+25 -23
stack/bushel/bin/bushel_search.ml
···
(** TODO:claude Bushel search command for integration with main CLI *)
-
let endpoint =
-
let doc = "Typesense server endpoint URL" in
-
Arg.(value & opt string "" & info ["endpoint"; "e"] ~doc)
-
-
let api_key =
-
let doc = "Typesense API key for authentication" in
-
Arg.(value & opt string "" & info ["api-key"; "k"] ~doc)
-
+
let endpoint_override =
+
let doc = "Override Typesense server endpoint URL" in
+
Arg.(value & opt (some string) None & info ["endpoint"; "e"] ~doc)
let limit =
let doc = "Maximum number of results to return" in
···
Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" ~doc)
(** TODO:claude Search function using multisearch *)
-
let search endpoint api_key query_text limit offset =
-
let base_config = Bushel.Typesense.load_config_from_files () in
-
let config = {
-
Bushel.Typesense.endpoint = if endpoint = "" then base_config.endpoint else endpoint;
-
api_key = if api_key = "" then base_config.api_key else api_key;
-
openai_key = base_config.openai_key;
+
let search env profile endpoint_override query_text limit offset =
+
(* Get credentials from keyeio profile *)
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let default_endpoint = "http://localhost:8108" in
+
let endpoint =
+
match endpoint_override with
+
| Some e -> e
+
| None ->
+
Keyeio.Profile.get profile ~key:"endpoint"
+
|> Option.value ~default:default_endpoint
+
in
+
let openai_key = Keyeio.Profile.get_required profile ~key:"openai_key" in
+
+
let config = {
+
Bushel.Typesense.endpoint;
+
api_key;
+
openai_key;
} in
-
-
if config.api_key = "" then (
-
Printf.eprintf "Error: API key is required. Use --api-key, set TYPESENSE_API_KEY environment variable, or create .typesense-key file.\n";
-
exit 1
-
);
-
+
Printf.printf "Searching Typesense at %s\n" config.endpoint;
Printf.printf "Query: \"%s\"\n" query_text;
Printf.printf "Limit: %d, Offset: %d\n" limit offset;
Printf.printf "\n";
-
Eio_main.run @@ fun env ->
Eio.Switch.run @@ fun sw ->
(try
let result = Bushel.Typesense.multisearch ~sw ~env config query_text ~limit:50 () in
···
exit 1
)
-
(** TODO:claude Command line term *)
-
let term = Term.(const search $ endpoint $ api_key $ query_text $ limit $ offset)
+
(** TODO:claude Command line term - takes eio_env from outside *)
+
let make_term eio_env =
+
let profile_term = Bushel_common.typesense_key_term eio_env#fs in
+
Term.(const (search eio_env) $ profile_term $ endpoint_override $ query_text $ limit $ offset)
+1 -1
stack/bushel/bin/dune
···
(library
(name bushel_common)
(modules bushel_common)
-
(libraries cmdliner fmt fmt.cli fmt.tty logs logs.cli logs.fmt))
+
(libraries cmdliner fmt fmt.cli fmt.tty logs logs.cli logs.fmt xdge keyeio eio))
(executable
(name bushel_main)
+2
stack/bushel/bushel.opam
···
"karakeepe"
"typesense-cliente"
"immiche"
+
"xdge"
+
"keyeio"
"cmdliner" {>= "2.0.0"}
"odoc" {with-doc}
]
+2
stack/bushel/dune-project
···
karakeepe
typesense-cliente
immiche
+
xdge
+
keyeio
(cmdliner (>= 2.0.0))))
+1 -1
stack/cacheio/bin/dune
···
(executable
(public_name cacheio-example)
(name example)
-
(libraries cacheio eio_main logs.fmt fmt.tty))
+
(libraries cacheio eio_main fmt logs.fmt logs logs.cli fmt.tty))
(executable
(public_name cache-cli)
+136
stack/eiocmd/README.md
···
+
# Eiocmd - Batteries-Included CLI Runner for Eio
+
+
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.
+
+
## Features
+
+
- **Logging Setup**: Automatic configuration of Logs with Fmt reporter
+
- **Cmdliner Integration**: Clean command-line argument parsing
+
- **Optional XDG Support**: Integrate with [xdge](../xdge) for XDG directory management
+
- **Optional Keyeio Support**: Integrate with [keyeio](../keyeio) for API key management
+
- **Minimal Boilerplate**: Get started quickly with sensible defaults
+
+
## Installation
+
+
```bash
+
opam install eiocmd
+
```
+
+
## Quick Start
+
+
### Basic Usage
+
+
```ocaml
+
open Cmdliner
+
+
let main _env =
+
Logs.info (fun m -> m "Hello, world!");
+
0
+
+
let () =
+
let info = Cmd.info "myapp" ~version:"1.0.0" ~doc:"My application" in
+
Eiocmd.run ~info main
+
```
+
+
This automatically provides:
+
- `--log-level` flag (debug, info, warning, error, app)
+
- `--color` flag (auto, always, never)
+
- Proper Eio environment setup
+
+
### With XDG Directory Support
+
+
```ocaml
+
let main _env (xdg, _xdg_cmd) =
+
let config_dir = Xdge.config_dir xdg in
+
Logs.info (fun m -> m "Config dir: %a" Eio.Path.pp config_dir);
+
0
+
+
let () =
+
let info = Cmd.info "myapp" ~doc:"My app with XDG support" in
+
Eiocmd.run ~info ~xdge:(Some "myapp") main
+
```
+
+
### With Keyeio API Key Management
+
+
```ocaml
+
let main _env (_xdg, _xdg_cmd) profile =
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
Logs.info (fun m -> m "Using API key: %s..." (String.sub api_key 0 8));
+
(* Use api_key to create your API client *)
+
0
+
+
let () =
+
let info = Cmd.info "myapp" ~doc:"My app with keyeio" in
+
Eiocmd.run ~info
+
~xdge:(Some "myapp")
+
~keyeio:(Some "myservice")
+
main
+
```
+
+
This automatically provides:
+
- `--profile` flag to select credential profile
+
- `--key-file` flag to override credential file location
+
- Automatic loading of credentials from `~/.config/myapp/keys/myservice.toml`
+
+
## API Documentation
+
+
See [the mli file](lib/eiocmd.mli) for full API documentation.
+
+
### Log Levels
+
+
The `--log-level` flag accepts:
+
- `debug` - Debug messages and above
+
- `info` - Informational messages and above (default)
+
- `warning` - Warnings and above
+
- `error` - Errors only
+
- `app` - Application-specific messages
+
+
### Color Output
+
+
The `--color` flag accepts:
+
- `auto` - Detect TTY capability (default)
+
- `always` - Force color output
+
- `never` - Disable color output
+
+
## Advanced Usage
+
+
For more control, you can use the low-level `Setup` module:
+
+
```ocaml
+
let () =
+
Eio_main.run @@ fun env ->
+
+
(* Initialize components manually *)
+
Eiocmd.Setup.init_rng env;
+
Eiocmd.Setup.init_logs ~level:(Some Logs.Debug) ();
+
+
(* Your application logic *)
+
Logs.debug (fun m -> m "Starting application");
+
(* ... *)
+
```
+
+
Or compose your own Cmdliner terms using `Eiocmd.Terms`:
+
+
```ocaml
+
let main log_level style_renderer custom_arg =
+
Eio_main.run @@ fun env ->
+
Eiocmd.Setup.init_logs ~level:log_level ~style_renderer ();
+
(* ... *)
+
+
let () =
+
let custom_arg = Arg.(value & opt string "default" & info ["custom"]) in
+
let term = Term.(const main
+
$ Eiocmd.Terms.log_level
+
$ Eiocmd.Terms.style_renderer
+
$ custom_arg) in
+
let info = Cmd.info "myapp" in
+
exit (Cmd.eval' (Cmd.v info term))
+
```
+
+
## Examples
+
+
See the [example directory](example/) for more comprehensive examples.
+
+
## License
+
+
ISC License - see LICENSE file for details.
+39
stack/eiocmd/dune-project
···
+
(lang dune 3.0)
+
+
(name eiocmd)
+
+
(generate_opam_files true)
+
+
(source
+
(github avsm/knot))
+
+
(authors "Anil Madhavapeddy")
+
+
(maintainers "anil@recoil.org")
+
+
(license ISC)
+
+
(package
+
(name eiocmd)
+
(synopsis "Batteries-included CLI runner for Eio applications")
+
(description
+
"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).")
+
(depends
+
(ocaml
+
(>= 5.1.0))
+
(eio
+
(>= 1.0))
+
(eio_main
+
(>= 1.0))
+
(cmdliner
+
(>= 1.3.0))
+
(logs
+
(>= 0.7.0))
+
(fmt
+
(>= 0.9.0))
+
(toml
+
(>= 7.0.0))
+
(mirage-crypto-rng
+
(>= 1.0.0))
+
xdge
+
keyeio))
+39
stack/eiocmd/eiocmd.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "Batteries-included CLI runner for Eio applications"
+
description:
+
"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)."
+
maintainer: ["anil@recoil.org"]
+
authors: ["Anil Madhavapeddy"]
+
license: "ISC"
+
homepage: "https://github.com/avsm/knot"
+
bug-reports: "https://github.com/avsm/knot/issues"
+
depends: [
+
"dune" {>= "3.0"}
+
"ocaml" {>= "5.1.0"}
+
"eio" {>= "1.0"}
+
"eio_main" {>= "1.0"}
+
"cmdliner" {>= "1.3.0"}
+
"logs" {>= "0.7.0"}
+
"fmt" {>= "0.9.0"}
+
"toml" {>= "7.0.0"}
+
"mirage-crypto-rng" {>= "1.0.0"}
+
"xdge"
+
"keyeio"
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+
dev-repo: "git+https://github.com/avsm/knot.git"
+3
stack/eiocmd/example/dune
···
+
(executable
+
(name eiocmd_example)
+
(libraries eiocmd cmdliner eio_main logs.fmt))
+34
stack/eiocmd/example/eiocmd_example.ml
···
+
open Cmdliner
+
+
let main _env xdg profile =
+
Logs.app (fun m -> m "Starting example application");
+
+
(* Show XDG paths *)
+
let config_dir = Xdge.config_dir xdg in
+
Logs.info (fun m -> m "Config dir: %a" Eio.Path.pp config_dir);
+
+
let cache_dir = Xdge.cache_dir xdg in
+
Logs.info (fun m -> m "Cache dir: %a" Eio.Path.pp cache_dir);
+
+
let data_dir = Xdge.data_dir xdg in
+
Logs.info (fun m -> m "Data dir: %a" Eio.Path.pp data_dir);
+
+
(* Show profile info *)
+
Logs.info (fun m -> m "Profile keys: %a"
+
Fmt.(list ~sep:comma string)
+
(Keyeio.Profile.keys profile));
+
+
0
+
+
let () =
+
let info = Cmd.info "eiocmd-example"
+
~version:"0.1.0"
+
~doc:"Example application using eiocmd"
+
in
+
let cmd = Eiocmd.run
+
~info
+
~app_name:"eiocmd-example"
+
~service:"example"
+
Term.(const main)
+
in
+
exit (Cmd.eval' cmd)
+4
stack/eiocmd/lib/dune
···
+
(library
+
(public_name eiocmd)
+
(name eiocmd)
+
(libraries eio eio_main cmdliner logs logs.fmt logs.cli fmt xdge keyeio toml mirage-crypto-rng.unix))
+62
stack/eiocmd/lib/eiocmd.ml
···
+
(** Batteries-included CLI runner for Eio applications *)
+
+
(** {1 Running Applications} *)
+
+
(* Batteries-included run with XDG and keyeio *)
+
let run ~info ~app_name ~service main_term =
+
let open Cmdliner in
+
+
let run_main main profile_name_opt key_file_opt =
+
(* Initialize RNG with default entropy source *)
+
let () = Mirage_crypto_rng_unix.use_default () in
+
+
Eio_main.run @@ fun env ->
+
+
(* Create xdg context *)
+
let xdg = Xdge.create env#fs app_name in
+
+
(* Load keyeio profile *)
+
let keyeio_ctx = Keyeio.create xdg in
+
let profile_name = Option.value profile_name_opt ~default:"default" in
+
+
let profile = match key_file_opt with
+
| Some _path ->
+
(* TODO: Load from specified file *)
+
failwith "Direct file loading not yet supported in eiocmd (use --profile instead)"
+
| None ->
+
(* Load from XDG directory *)
+
match Keyeio.load_service keyeio_ctx ~service with
+
| Ok svc ->
+
(match Keyeio.Service.get_profile svc profile_name with
+
| Some prof -> prof
+
| None ->
+
failwith (Printf.sprintf "Profile '%s' not found in service '%s'"
+
profile_name service))
+
| Error (`Msg msg) -> failwith msg
+
in
+
+
main env xdg profile
+
in
+
+
(* Add profile and key-file flags *)
+
let profile_flag =
+
let doc = Printf.sprintf "Profile name to use for %s service" service in
+
Arg.(value & opt (some string) None & info [ "profile" ] ~docv:"NAME" ~doc)
+
in
+
let key_file_flag =
+
let doc = Printf.sprintf "Override with direct path to %s key file" service in
+
Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc)
+
in
+
+
(* Compose with main term and add logging setup *)
+
let term =
+
let open Term.Syntax in
+
let+ main = main_term
+
and+ log_level = Logs_cli.level ()
+
and+ profile_name_opt = profile_flag
+
and+ key_file_opt = key_file_flag in
+
Logs.set_reporter (Logs_fmt.reporter ());
+
Logs.set_level log_level;
+
run_main main profile_name_opt key_file_opt
+
in
+
Cmd.v info term
+24
stack/eiocmd/lib/eiocmd.mli
···
+
(** Batteries-included CLI runner for Eio applications
+
+
Eiocmd provides a convenient wrapper for building command-line applications
+
with Eio. It automatically handles common setup tasks and provides a clean
+
interface for building CLIs with minimal boilerplate. *)
+
+
(** {1 Running Applications} *)
+
+
(** [run ~info ~app_name ~service main_term] creates a batteries-included CLI command.
+
+
This is a comprehensive runner that sets up everything a typical CLI application needs:
+
- Random number generator initialization (Mirage_crypto_rng)
+
- Logging (via Logs_cli with standard flags)
+
- Cmdliner argument parsing
+
- XDG directory structure (all directories created)
+
- API key management via keyeio
+
]} *)
+
val run :
+
info:Cmdliner.Cmd.info ->
+
app_name:string ->
+
service:string ->
+
(Eio_unix.Stdenv.base -> Xdge.t -> Keyeio.Profile.t -> int) Cmdliner.Term.t ->
+
Cmdliner.Cmd.Exit.code Cmdliner.Cmd.t
+
+5 -2
stack/karakeepe/dune-project
···
(package
(name karakeepe)
(synopsis "Karakeep API client for OCaml using Eio")
-
(description "An Eio-based OCaml client library for the Karakeep bookmark management service API")
+
(description "An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface")
(depends
(ocaml (>= 4.14))
eio
···
ezjsonm
fmt
ptime
-
uri))
+
uri
+
(keyeio (>= 0.1.0))
+
(xdge (>= 0.1.0))
+
(cmdliner (>= 1.2.0))))
+4 -1
stack/karakeepe/karakeepe.opam
···
version: "0.1.0"
synopsis: "Karakeep API client for OCaml using Eio"
description:
-
"An Eio-based OCaml client library for the Karakeep bookmark management service API"
+
"An Eio-based OCaml client library for the Karakeep bookmark management service API, with a command-line interface"
depends: [
"dune" {>= "3.0"}
"ocaml" {>= "4.14"}
···
"fmt"
"ptime"
"uri"
+
"keyeio" {>= "0.1.0"}
+
"xdge" {>= "0.1.0"}
+
"cmdliner" {>= "1.2.0"}
"odoc" {with-doc}
]
build: [
+1 -1
stack/keyeio/CLAUDE.md
···
## Design Principles
- Store credentials in XDG_CONFIG_HOME/appname/keys/ with 0o600 permissions
-
- JSON format supporting multiple profiles per service
+
- TOML format supporting multiple profiles per service
- Cmdliner integration following xdge patterns
- Future-proof design for Secret Service API integration
- Security-conscious pretty printing (mask sensitive values)
+17 -20
stack/keyeio/README.md
···
### Storage Format
-
Keys are stored in `~/.config/<appname>/keys/<service>.json`:
+
Keys are stored in `~/.config/<appname>/keys/<service>.toml`:
+
+
```toml
+
[default]
+
api_key = "abc123..."
+
base_url = "https://api.example.com"
+
+
[production]
+
api_key = "xyz789..."
+
base_url = "https://api.prod.example.com"
-
```json
-
{
-
"default": {
-
"api_key": "abc123...",
-
"base_url": "https://api.example.com"
-
},
-
"production": {
-
"api_key": "xyz789...",
-
"base_url": "https://api.prod.example.com"
-
},
-
"staging": {
-
"api_key": "def456...",
-
"base_url": "https://api.staging.example.com"
-
}
-
}
+
[staging]
+
api_key = "def456..."
+
base_url = "https://api.staging.example.com"
```
## Examples
···
val load_service : t -> service:string -> (Service.t, [> `Msg of string]) result
```
-
Load all profiles for a service from `~/.config/<appname>/keys/<service>.json`.
+
Load all profiles for a service from `~/.config/<appname>/keys/<service>.toml`.
```ocaml
val list_services : t -> (string list, [> `Msg of string]) result
```
-
List all available services (JSON files in the keys directory).
+
List all available services (TOML files in the keys directory).
### Working with Profiles
···
### Current Security Model
-
- Keys stored as JSON files in `~/.config/<appname>/keys/`
+
- Keys stored as TOML files in `~/.config/<appname>/keys/`
- Files created with permissions `0o600` (owner read/write only)
- Sensitive values masked in pretty-printing output
- Follows standard Unix file permission security model
···
- `XDG_CONFIG_HOME`: Base directory for config files (default: `~/.config`)
- `<APPNAME>_CONFIG_DIR`: Application-specific override (highest priority)
-
Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.json`
+
Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.toml`
## Documentation
+1 -1
stack/keyeio/dune-project
···
(eio (>= 1.1))
eio_main
(xdge (>= 0.1.0))
-
(yojson (>= 2.0.0))
+
(toml (>= 7.0.0))
(cmdliner (>= 1.2.0))
(fmt (>= 0.11.0))
(odoc :with-doc)
+31 -8
stack/keyeio/example/keyeio_example.ml
···
Fmt.pr "Available keys: %a@." Fmt.(list ~sep:comma string) keys;
(* Pretty print the profile *)
-
Fmt.pr "@.Profile details:@.%a@." Keyeio.Profile.pp profile
+
Fmt.pr "@.Profile details:@.%a@." Keyeio.Profile.pp profile;
+
0
(** Example 2: List all services *)
let list_services_example (xdg, _xdg_cmd) =
···
else begin
Fmt.pr "Available services:@.";
List.iter (fun svc -> Fmt.pr " - %s@." svc) services
-
end
+
end;
+
0
| Error (`Msg msg) ->
-
Fmt.epr "Error listing services: %s@." msg
+
Fmt.epr "Error listing services: %s@." msg;
+
1
(** Example 3: Load service and list profiles *)
let list_profiles_example (xdg, _xdg_cmd) service_name =
···
Fmt.pr "@.";
(* Pretty print the service *)
-
Fmt.pr "@.Service details:@.%a@." Keyeio.Service.pp service
+
Fmt.pr "@.Service details:@.%a@." Keyeio.Service.pp service;
+
0
| Error (`Msg msg) ->
-
Fmt.epr "Error loading service '%s': %s@." service_name msg
+
Fmt.epr "Error loading service '%s': %s@." service_name msg;
+
1
(** Example 4: Simulated API client using loaded credentials *)
let api_client_example (_xdg, _xdg_cmd) profile =
···
Fmt.pr "@.Simulating API request...@.";
Fmt.pr "GET %s/api/status@." base_url;
Fmt.pr "Authorization: Bearer %s@." (String.sub api_key 0 (min 8 (String.length api_key)) ^ "...");
-
Fmt.pr "@.Response: 200 OK@."
+
Fmt.pr "@.Response: 200 OK@.";
+
0
(** Main command dispatcher *)
let () =
···
Cmd.v info Term.(const api_client_example $ xdg_term $ profile_term)
in
+
(* Command: init - Create a keyfile *)
+
let init_cmd =
+
let default_data = [
+
("api_key", None); (* Will prompt if not provided *)
+
("base_url", Some "https://immich.example.com") (* Has default *)
+
] in
+
let init_term = Keyeio.Cmd.create_term
+
~app_name:"keyeio-example"
+
~fs:env#fs
+
~service:"immiche"
+
~default_data
+
()
+
in
+
let info = Cmd.info "init" ~doc:"Create keyeio credentials" in
+
Cmd.v info init_term
+
in
+
(* Main command group *)
let main_cmd =
let info = Cmd.info "keyeio-example"
···
`Pre " $(b,keyeio-example client --profile staging)";
]
in
-
Cmd.group info [basic_cmd; list_cmd; profiles_cmd; client_cmd]
+
Cmd.group info [init_cmd; basic_cmd; list_cmd; profiles_cmd; client_cmd]
in
-
exit (Cmd.eval main_cmd)
+
exit (Cmd.eval' main_cmd)
+1 -1
stack/keyeio/keyeio.opam
···
"eio" {>= "1.1"}
"eio_main"
"xdge" {>= "0.1.0"}
-
"yojson" {>= "2.0.0"}
+
"toml" {>= "7.0.0"}
"cmdliner" {>= "1.2.0"}
"fmt" {>= "0.11.0"}
"odoc" {with-doc}
+1 -1
stack/keyeio/lib/dune
···
(library
(public_name keyeio)
(name keyeio)
-
(libraries eio eio_main xdge yojson cmdliner fmt))
+
(libraries eio eio_main xdge toml cmdliner fmt))
+174 -47
stack/keyeio/lib/keyeio.ml
···
let keys t = List.map fst t.data
-
let to_json t =
-
let obj = List.map (fun (k, v) -> (k, `String v)) t.data in
-
`Assoc obj
+
let to_toml t =
+
let table = Toml.Types.Table.empty in
+
List.fold_left
+
(fun tbl (k, v) -> Toml.Types.Table.add (Toml.Types.Table.Key.of_string k) (Toml.Types.TString v) tbl)
+
table
+
t.data
let pp ppf t =
let mask_sensitive key =
···
{ xdg; backend = Filesystem { keys_dir } }
-
(** {1 JSON Parsing Helpers} *)
+
(** {1 TOML Parsing Helpers} *)
-
let parse_profile ~service ~profile_name json =
-
match json with
-
| `Assoc fields ->
-
let data =
-
List.filter_map
-
(fun (k, v) ->
-
match v with
-
| `String s -> Some (k, s)
-
| _ -> None)
-
fields
-
in
-
{ Profile.service; name = profile_name; data }
-
| _ ->
-
raise
-
(Invalid_key_file
-
(Printf.sprintf "Profile '%s' in service '%s' is not a JSON object"
-
profile_name service))
+
let parse_profile ~service ~profile_name table =
+
let data =
+
Toml.Types.Table.fold
+
(fun key value acc ->
+
match value with
+
| Toml.Types.TString s ->
+
let key_str = Toml.Types.Table.Key.to_string key in
+
(key_str, s) :: acc
+
| _ -> acc)
+
table
+
[]
+
in
+
{ Profile.service; name = profile_name; data }
-
let parse_service_file ~service json =
-
match json with
-
| `Assoc profile_list ->
-
let profiles =
-
List.map
-
(fun (profile_name, profile_json) ->
-
(profile_name, parse_profile ~service ~profile_name profile_json))
-
profile_list
-
in
-
{ Service.name = service; profiles }
-
| _ ->
-
raise
-
(Invalid_key_file (Printf.sprintf "Service file '%s.json' is not a JSON object" service))
+
let parse_service_file ~service toml_table =
+
let profiles =
+
Toml.Types.Table.fold
+
(fun key value acc ->
+
match value with
+
| Toml.Types.TTable profile_table ->
+
let profile_name = Toml.Types.Table.Key.to_string key in
+
(profile_name, parse_profile ~service ~profile_name profile_table) :: acc
+
| _ -> acc)
+
toml_table
+
[]
+
in
+
if profiles = [] then
+
raise
+
(Invalid_key_file (Printf.sprintf "Service file '%s.toml' contains no valid profile tables" service))
+
else
+
{ Service.name = service; profiles }
(** {1 File Operations} *)
+
let create_default_keyfile t ~service ~profile ~data =
+
match t.backend with
+
| Filesystem { keys_dir } ->
+
let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in
+
(try
+
(* Load existing service file if it exists, otherwise start fresh *)
+
let existing_profiles =
+
try
+
let content = Eio.Path.load service_file in
+
let toml = Toml.Parser.(from_string content |> unsafe) in
+
let svc = parse_service_file ~service toml in
+
svc.Service.profiles
+
with
+
| Eio.Io (Eio.Fs.E (Not_found _), _) -> []
+
in
+
+
(* Create or update the profile *)
+
let new_profile = { Profile.service; name = profile; data } in
+
let updated_profiles =
+
(* Remove existing profile with same name if present *)
+
List.filter (fun (name, _) -> name <> profile) existing_profiles
+
@ [(profile, new_profile)]
+
in
+
+
(* Build TOML structure *)
+
let toml_table = Toml.Types.Table.empty in
+
let toml_table =
+
List.fold_left
+
(fun tbl (prof_name, prof) ->
+
let prof_table = Profile.to_toml prof in
+
Toml.Types.Table.add
+
(Toml.Types.Table.Key.of_string prof_name)
+
(Toml.Types.TTable prof_table)
+
tbl)
+
toml_table
+
updated_profiles
+
in
+
+
(* Convert to TOML string *)
+
let toml_str = Toml.Printer.string_of_table toml_table in
+
+
(* Write to file with restrictive permissions *)
+
Eio.Path.save ~create:(`Or_truncate 0o600) service_file toml_str;
+
+
Ok ()
+
with
+
| Toml.Parser.Error (msg, _) ->
+
Error (`Msg (Printf.sprintf "Invalid TOML in existing %s.toml: %s" service msg))
+
| exn ->
+
Error (`Msg (Printf.sprintf "Error creating key file: %s" (Printexc.to_string exn))))
+
let load_service t ~service =
match t.backend with
| Filesystem { keys_dir } ->
-
let service_file = Eio.Path.(keys_dir / (service ^ ".json")) in
+
let service_file = Eio.Path.(keys_dir / (service ^ ".toml")) in
(try
-
(* Read and parse the JSON file *)
+
(* Read and parse the TOML file *)
let content = Eio.Path.load service_file in
-
let json = Yojson.Basic.from_string content in
-
let service_data = parse_service_file ~service json in
+
let toml = Toml.Parser.(from_string content |> unsafe) in
+
let service_data = parse_service_file ~service toml in
Ok service_data
with
| Eio.Io (Eio.Fs.E (Not_found _), _) ->
-
Error (`Msg (Printf.sprintf "Service file not found: %s.json" service))
-
| Yojson.Json_error msg ->
-
Error (`Msg (Printf.sprintf "Invalid JSON in %s.json: %s" service msg))
+
Error (`Msg (Printf.sprintf "Service file not found: %s.toml" service))
+
| Toml.Parser.Error (msg, _) ->
+
Error (`Msg (Printf.sprintf "Invalid TOML in %s.toml: %s" service msg))
| Invalid_key_file msg -> Error (`Msg msg)
| exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn))))
···
let services =
List.filter_map
(fun entry ->
-
if String.ends_with ~suffix:".json" entry then
+
if String.ends_with ~suffix:".toml" entry then
Some (String.sub entry 0 (String.length entry - 5))
else None)
entries
···
| Some path ->
(try
let content = In_channel.with_open_bin path In_channel.input_all in
-
let json = Yojson.Basic.from_string content in
-
match parse_service_file ~service json with
+
let toml = Toml.Parser.(from_string content |> unsafe) in
+
match parse_service_file ~service toml with
| svc ->
(match Service.get_profile svc profile_name with
| Some prof -> prof
···
| Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag)
| None -> Term.(const load_profile $ profile_flag $ const None)
+
let create_term ~app_name ~fs ~service ~default_data
+
?profile:(default_profile = "default") () =
+
let open Cmdliner in
+
+
(* Profile name flag *)
+
let profile_flag =
+
let doc = Printf.sprintf "Profile name to create for %s service" service in
+
Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc)
+
in
+
+
(* Create flags for each key in default_data *)
+
let key_flags =
+
List.map
+
(fun (key, default_val) ->
+
let flag_name = String.map (fun c -> if c = '_' then '-' else c) key in
+
let doc = Printf.sprintf "Value for %s" key in
+
let term =
+
Arg.(value & opt (some string) default_val & info [ flag_name ] ~docv:(String.uppercase_ascii key) ~doc)
+
in
+
(key, term))
+
default_data
+
in
+
+
(* Helper to prompt for a value if not provided *)
+
let prompt_for_value key =
+
Printf.printf "Enter %s: %!" key;
+
try
+
input_line stdin
+
with End_of_file ->
+
failwith (Printf.sprintf "Failed to read %s from stdin" key)
+
in
+
+
(* Term that creates the keyfile *)
+
let create_keyfile profile_name key_values =
+
try
+
(* Build the data list, prompting for missing values *)
+
let data =
+
List.map
+
(fun (key, value_opt) ->
+
match value_opt with
+
| Some v -> (key, v)
+
| None ->
+
let prompted = prompt_for_value key in
+
(key, prompted))
+
(List.combine (List.map fst default_data) key_values)
+
in
+
+
(* Create the keyfile *)
+
let xdg = Xdge.create fs app_name in
+
let keyeio = create xdg in
+
match create_default_keyfile keyeio ~service ~profile:profile_name ~data with
+
| Ok () ->
+
let keys_dir = match keyeio.backend with
+
| Filesystem { keys_dir } -> Eio.Path.native_exn keys_dir
+
in
+
Printf.printf "Created %s profile in %s/%s.toml\n" profile_name keys_dir service;
+
0
+
| Error (`Msg msg) ->
+
Printf.eprintf "Failed to create key file: %s\n" msg;
+
1
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
1
+
in
+
+
(* Build the term by applying all key flags *)
+
let rec build_term acc_term = function
+
| [] -> Term.(const create_keyfile $ profile_flag $ acc_term)
+
| (_, flag_term) :: rest ->
+
build_term Term.(const (fun lst x -> lst @ [x]) $ acc_term $ flag_term) rest
+
in
+
build_term (Term.const []) key_flags
+
let env_docs ~app_name ~service () =
Printf.sprintf
{|ENVIRONMENT
···
XDG_CONFIG_HOME
Base directory for configuration files. If not set, defaults to
$HOME/.config. Keys for %s will be stored in:
-
$XDG_CONFIG_HOME/%s/keys/%s.json
+
$XDG_CONFIG_HOME/%s/keys/%s.toml
Example locations:
-
~/.config/%s/keys/%s.json (default)
-
/custom/config/%s/keys/%s.json (if XDG_CONFIG_HOME=/custom/config)
+
~/.config/%s/keys/%s.toml (default)
+
/custom/config/%s/keys/%s.toml (if XDG_CONFIG_HOME=/custom/config)
File permissions should be 0600 (owner read/write only) for security.
|}
+114 -31
stack/keyeio/lib/keyeio.mli
···
- Store API keys in XDG-compliant directories with proper permissions
- Support multiple profiles per service (production, staging, development)
-
- JSON-based storage format for flexibility
+
- TOML-based storage format for readability and flexibility
- Cmdliner integration for easy command-line usage
- Designed for future Secret Service API integration
{b Security Model:}
-
Currently, credentials are stored as JSON files in [XDG_CONFIG_HOME/appname/keys/]
+
Currently, credentials are stored as TOML files in [XDG_CONFIG_HOME/appname/keys/]
with strict filesystem permissions (0o600 - owner read/write only). This follows
common practice for CLI tools and provides reasonable security for single-user
systems.
···
{b Storage Structure:}
-
Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.json] where SERVICE
+
Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] where SERVICE
is the name of the service (e.g., "immiche", "karakeepe"). Each service file
contains one or more named profiles:
{v
-
{
-
"default": {
-
"api_key": "abc123...",
-
"base_url": "https://api.example.com"
-
},
-
"production": {
-
"api_key": "xyz789...",
-
"base_url": "https://api.prod.example.com"
-
}
-
}
+
[default]
+
api_key = "abc123..."
+
base_url = "https://api.example.com"
+
+
[production]
+
api_key = "xyz789..."
+
base_url = "https://api.prod.example.com"
v}
{b Example Usage:}
···
(** Exception raised when a profile is not found in a service. *)
exception Profile_not_found of string
-
(** Exception raised when attempting to access invalid JSON structure. *)
+
(** Exception raised when attempting to access invalid TOML structure. *)
exception Invalid_key_file of string
(** {1 Profile} *)
···
]} *)
val keys : t -> string list
-
(** [to_json t] converts the profile to a JSON representation.
+
(** [to_toml t] converts the profile to a TOML table representation.
-
Returns a JSON object containing all key-value pairs in the profile.
+
Returns a TOML table containing all key-value pairs in the profile.
@param t The profile to convert
-
@return A JSON object representation *)
-
val to_json : t -> Yojson.Basic.t
+
@return A TOML table representation *)
+
val to_toml : t -> Toml.Types.table
(** [pp ppf t] pretty prints a profile for debugging.
···
For example, an "immiche" service might contain "default", "production",
and "staging" profiles, each with their own credentials.
-
Services are loaded from JSON files in the keys directory, with one file
-
per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.json] *)
+
Services are loaded from TOML files in the keys directory, with one file
+
per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] *)
module Service : sig
(** The type of a service containing multiple profiles. *)
type t
···
]} *)
val create : Xdge.t -> t
+
(** {1 Creating Credentials} *)
+
+
(** [create_default_keyfile t ~service ~profile ~data] creates a new credential file.
+
+
Creates a TOML file at [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] with
+
the provided profile and key-value pairs. If the file already exists,
+
it will be loaded and the new profile will be added or updated.
+
+
@param t The Keyeio context
+
@param service The service name (e.g., "karakeepe")
+
@param profile The profile name (default: "default")
+
@param data Key-value pairs to store in the profile
+
@return [Ok ()] on success, [Error (`Msg msg)] on failure
+
+
{b Example:}
+
{[
+
let data = [
+
("api_key", "ak1_example_key");
+
("base_url", "https://api.example.com")
+
] in
+
match Keyeio.create_default_keyfile keyeio ~service:"karakeepe"
+
~profile:"default" ~data with
+
| Ok () -> Printf.printf "Key file created successfully\n"
+
| Error (`Msg msg) -> Printf.eprintf "Failed: %s\n" msg
+
]}
+
+
{b Security:} The file is created with permissions 0o600 (owner read/write only). *)
+
val create_default_keyfile :
+
t ->
+
service:string ->
+
profile:string ->
+
data:(string * string) list ->
+
(unit, [> `Msg of string ]) result
+
(** {1 Loading Credentials} *)
(** [load_service t ~service] loads all profiles for a given service.
-
Reads the JSON file [XDG_CONFIG_HOME/appname/keys/SERVICE.json] and
-
parses all profiles contained within. The file must be a JSON object
-
where each key is a profile name and each value is an object containing
-
credential key-value pairs.
+
Reads the TOML file [XDG_CONFIG_HOME/appname/keys/SERVICE.toml] and
+
parses all profiles contained within. The file must contain TOML tables
+
where each section name is a profile name containing credential key-value pairs.
@param t The Keyeio context
@param service The service name to load (e.g., "immiche", "karakeepe")
···
{b Error Conditions:}
- Service file does not exist
- Service file has incorrect permissions (not 0o600)
-
- Service file contains invalid JSON
-
- Service file is not a JSON object *)
+
- Service file contains invalid TOML
+
- Service file does not contain proper TOML tables *)
val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result
(** [list_services t] returns all available service names.
-
Scans the keys directory for all [*.json] files and returns their
-
base names (without the .json extension). This allows applications
+
Scans the keys directory for all [*.toml] files and returns their
+
base names (without the .toml extension). This allows applications
to discover what services have stored credentials.
@param t The Keyeio context
···
Printf.eprintf "Failed to list services: %s\n" msg
]}
-
{b Note:} Only files with [.json] extension are considered. Files
+
{b Note:} Only files with [.toml] extension are considered. Files
with incorrect permissions are silently skipped. *)
val list_services : t -> (string list, [> `Msg of string ]) result
···
{b Generated Command-line Flags:}
- [--profile NAME]: Select which profile to use (default: "default")
-
- [--key-file PATH]: Override with direct JSON file path (if [key_file=true])
+
- [--key-file PATH]: Override with direct TOML file path (if [key_file=true])
{b Flag Precedence:}
+ [--key-file PATH] - highest priority (if enabled)
···
The term will fail with a clear error message if:
- The service file does not exist
- The requested profile is not found
-
- The JSON file is invalid
+
- The TOML file is invalid
- File permissions are incorrect *)
val term :
app_name:string ->
···
?key_file:bool ->
unit ->
Profile.t Cmdliner.Term.t
+
+
(** [create_term ~app_name ~fs ~service ~default_data ()] creates a Cmdliner term for creating keyfiles.
+
+
This function generates a Cmdliner term that handles interactive creation
+
of credential files. It prompts for required values, allows optional overrides
+
via command-line flags, and creates the TOML file with proper permissions.
+
+
@param app_name The application name (used for XDG paths)
+
@param fs The Eio filesystem providing filesystem access
+
@param service The service name to create credentials for (e.g., "karakeepe")
+
@param default_data Default key-value pairs with optional prompts
+
@param profile Default profile name to create (default: "default")
+
+
{b Generated Command-line Flags:}
+
- [--profile NAME]: Profile name to create (default: "default")
+
- One flag per key in default_data (e.g., [--api-key], [--base-url])
+
+
{b Example - Basic usage with prompts:}
+
{[
+
open Cmdliner
+
+
let create_cmd env =
+
let default_data = [
+
("api_key", None); (* Will prompt if not provided *)
+
("base_url", Some "https://hoard.recoil.org") (* Has default *)
+
] in
+
+
let create_term = Keyeio.Cmd.create_term
+
~app_name:"karakeepe"
+
~fs:env#fs
+
~service:"karakeepe"
+
~default_data
+
() in
+
+
Cmd.v (Cmd.info "init" ~doc:"Create karakeepe credentials")
+
create_term
+
]}
+
+
{b Behavior:}
+
- If a value is provided via CLI flag, use it
+
- If a value has a default in default_data, use it
+
- Otherwise, prompt interactively for the value
+
- Create the keyfile at [XDG_CONFIG_HOME/appname/keys/service.toml]
+
- Set file permissions to 0o600 for security
+
- Return 0 on success, 1 on failure *)
+
val create_term :
+
app_name:string ->
+
fs:Eio.Fs.dir_ty Eio.Path.t ->
+
service:string ->
+
default_data:(string * string option) list ->
+
?profile:string ->
+
unit ->
+
int Cmdliner.Term.t
(** [env_docs ~app_name ~service ()] generates documentation for environment variables.
+35 -44
stack/keyeio/test/keyeio.t
···
$ mkdir -p $PWD/test_config/keyeio-example/keys
Create a test service file with multiple profiles:
-
$ cat > $PWD/test_config/keyeio-example/keys/immiche.json << 'EOF'
-
> {
-
> "default": {
-
> "api_key": "test_default_key_12345",
-
> "base_url": "https://immich.example.com"
-
> },
-
> "production": {
-
> "api_key": "prod_key_67890",
-
> "base_url": "https://immich.prod.example.com",
-
> "extra_field": "production_value"
-
> },
-
> "staging": {
-
> "api_key": "staging_key_abcde",
-
> "base_url": "https://immich.staging.example.com"
-
> }
-
> }
+
$ cat > $PWD/test_config/keyeio-example/keys/immiche.toml << 'EOF'
+
> [default]
+
> api_key = "test_default_key_12345"
+
> base_url = "https://immich.example.com"
+
>
+
> [production]
+
> api_key = "prod_key_67890"
+
> base_url = "https://immich.prod.example.com"
+
> extra_field = "production_value"
+
>
+
> [staging]
+
> api_key = "staging_key_abcde"
+
> base_url = "https://immich.staging.example.com"
> EOF
Test listing available services:
···
Test listing profiles for a service:
$ ../example/keyeio_example.exe profiles immiche
=== List Profiles Example ===
-
Error loading service 'immiche': Service file not found: immiche.json
+
Error loading service 'immiche': Service file not found: immiche.toml
Test basic usage with default profile:
···
Profile: default
API Key loaded: test_defau...
Base URL: https://immich.example.com
-
Available keys: api_key,
-
base_url
+
Available keys: base_url,
+
api_key
Profile details:
Profile immiche.default:
-
api_key: test_def***
base_url: https://immich.example.com
+
api_key: test_def***
Test using a specific profile:
···
Profile: production
API Key loaded: prod_key_6...
Base URL: https://immich.prod.example.com
-
Available keys: api_key, base_url,
-
extra_field
+
Available keys: extra_field, base_url,
+
api_key
Profile details:
Profile immiche.production:
+
extra_field: production_value
+
base_url: https://immich.prod.example.com
api_key: prod_key***
-
base_url: https://immich.prod.example.com
-
extra_field: production_value
Test API client simulation with staging profile:
···
Test error handling - nonexistent service:
$ ../example/keyeio_example.exe profiles nonexistent
=== List Profiles Example ===
-
Error loading service 'nonexistent': Service file not found: nonexistent.json
+
Error loading service 'nonexistent': Service file not found: nonexistent.toml
Test with multiple services - create another service file:
-
$ cat > $PWD/test_config/keyeio-example/keys/karakeepe.json << 'EOF'
-
> {
-
> "default": {
-
> "api_key": "hoard_default_key_xyz",
-
> "base_url": "https://hoard.example.com"
-
> }
-
> }
+
$ cat > $PWD/test_config/keyeio-example/keys/karakeepe.toml << 'EOF'
+
> [default]
+
> api_key = "hoard_default_key_xyz"
+
> base_url = "https://hoard.example.com"
> EOF
List services should now show both:
···
No services configured yet
Test with key-file override:
-
$ cat > ./custom_keys.json << 'EOF'
-
> {
-
> "custom": {
-
> "api_key": "custom_key_123",
-
> "base_url": "https://custom.example.com"
-
> }
-
> }
+
$ cat > ./custom_keys.toml << 'EOF'
+
> [custom]
+
> api_key = "custom_key_123"
+
> base_url = "https://custom.example.com"
> EOF
-
$ ../example/keyeio_example.exe basic --key-file ./custom_keys.json --profile custom
+
$ ../example/keyeio_example.exe basic --key-file ./custom_keys.toml --profile custom
=== Basic Example ===
Service: immiche
Profile: custom
API Key loaded: custom_key...
Base URL: https://custom.example.com
-
Available keys: api_key,
-
base_url
+
Available keys: base_url,
+
api_key
Profile details:
Profile immiche.custom:
-
api_key: custom_k***
base_url: https://custom.example.com
+
api_key: custom_k***
Test file permissions (keys should have restrictive permissions):
-
$ ls -l $PWD/test_config/keyeio-example/keys/immiche.json | awk '{print $1}' | grep -E '^-rw'
+
$ ls -l $PWD/test_config/keyeio-example/keys/immiche.toml | awk '{print $1}' | grep -E '^-rw'
-rw-r--r--@
Test empty keys directory:
+2 -2
stack/requests/lib/requests.ml
···
let config_term app_name fs =
let xdg_term = Xdge.Cmd.term app_name fs
-
~config:true ~data:true ~cache:true ~state:false ~runtime:false () in
+
~dirs:[`Config; `Data; `Cache] () in
Term.(const (fun xdg persist verify timeout retries backoff follow max_redir ua ->
{ xdg; persist_cookies = persist; verify_tls = verify;
timeout; max_retries = retries; retry_backoff = backoff;
···
let minimal_term app_name fs =
let xdg_term = Xdge.Cmd.term app_name fs
-
~config:false ~data:true ~cache:true ~state:false ~runtime:false () in
+
~dirs:[`Data; `Cache] () in
Term.(const (fun (xdg, _xdg_cmd) persist -> (xdg, persist))
$ xdg_term
$ persist_cookies_term app_name)
+8 -8
stack/river/bin/river_cli.ml
···
(* Commands - these are created within Eio context *)
let user_add_cmd fs =
let doc = "Add a new user" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) username fullname email =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let user_remove_cmd fs =
let doc = "Remove a user" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) username =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let user_list_cmd fs =
let doc = "List all users" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let user_show_cmd fs =
let doc = "Show user details" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) username =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let user_add_feed_cmd fs =
let doc = "Add a feed to a user" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) username name url =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let user_remove_feed_cmd fs =
let doc = "Remove a feed from a user" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let run log_level style_renderer (xdg, _cfg) username url =
setup_logs style_renderer log_level;
let state = { xdg } in
···
let sync_cmd fs env =
let doc = "Sync feeds for users" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let username_opt =
let doc = "Sync specific user (omit to sync all)" in
Arg.(value & pos 0 (some string) None & info [] ~docv:"USERNAME" ~doc)
···
let list_cmd fs =
let doc = "List recent posts (from all users by default, or specify a user)" in
-
let xdg_term = Xdge.Cmd.term "river" fs ~config:false ~data:false ~cache:false ~runtime:false () in
+
let xdg_term = Xdge.Cmd.term "river" fs ~dirs:[`State] () in
let username_opt_arg =
let doc = "Username (optional - defaults to all users)" in
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
···
; data_dirs : Eio.Fs.dir_ty Eio.Path.t list
}
+
type dir = [
+
| `Config
+
| `Cache
+
| `Data
+
| `State
+
| `Runtime
+
]
+
let ensure_dir ?(perm = 0o755) path = Eio.Path.mkdirs ~exists_ok:true ~perm path
let validate_runtime_base_dir base_path =
···
}
let term app_name fs
-
?(config=true) ?(data=true) ?(cache=true) ?(state=true) ?(runtime=true) () =
+
?(dirs=[`Config; `Data; `Cache; `State; `Runtime]) () =
let open Cmdliner in
let app_upper = String.uppercase_ascii app_name in
let show_paths =
let doc = "Show only the resolved directory paths without formatting" in
Arg.(value & flag & info [ "show-paths" ] ~doc)
in
+
let has_dir d = List.mem d dirs in
let make_dir_arg ~enabled name env_suffix xdg_var default_path =
if not enabled then
(* Return a term that always gives the environment-only result *)
···
let home_prefix = "\\$HOME" in
let config_dir =
make_dir_arg
-
~enabled:config
+
~enabled:(has_dir `Config)
"config"
"CONFIG_DIR"
"XDG_CONFIG_HOME"
···
in
let data_dir =
make_dir_arg
-
~enabled:data
+
~enabled:(has_dir `Data)
"data"
"DATA_DIR"
"XDG_DATA_HOME"
···
in
let cache_dir =
make_dir_arg
-
~enabled:cache
+
~enabled:(has_dir `Cache)
"cache"
"CACHE_DIR"
"XDG_CACHE_HOME"
···
in
let state_dir =
make_dir_arg
-
~enabled:state
+
~enabled:(has_dir `State)
"state"
"STATE_DIR"
"XDG_STATE_HOME"
(Some (home_prefix ^ "/.local/state/" ^ app_name))
in
-
let runtime_dir = make_dir_arg ~enabled:runtime "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in
+
let runtime_dir = make_dir_arg ~enabled:(has_dir `Runtime) "runtime" "RUNTIME_DIR" "XDG_RUNTIME_DIR" None in
Term.(
const
(fun
+25 -19
stack/xdge/lib/xdge.mli
···
@see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification *)
(** The main XDG context type containing all directory paths for an application.
-
+
A value of type [t] represents the complete XDG directory structure for a
specific application, including both user-specific and system-wide directories.
All paths are resolved at creation time and are absolute paths within the
Eio filesystem. *)
type t
+
+
(** XDG directory types for specifying which directories an application needs.
+
+
These polymorphic variants allow applications to declare which XDG directories
+
they use, enabling runtime systems to only provide the requested directories. *)
+
type dir = [
+
| `Config (** User configuration files *)
+
| `Cache (** User-specific cached data *)
+
| `Data (** User-specific application data *)
+
| `State (** User-specific state data (logs, history, etc.) *)
+
| `Runtime (** User-specific runtime files (sockets, pipes, etc.) *)
+
]
(** {1 Exceptions} *)
···
as determined by command-line arguments and environment variables. *)
type t
-
(** [term app_name fs ?config ?data ?cache ?state ?runtime ()] creates a
-
Cmdliner term for XDG directory configuration.
+
(** [term app_name fs ?dirs ()] creates a Cmdliner term for XDG directory configuration.
This function generates a Cmdliner term that handles XDG directory
configuration through both command-line flags and environment variables,
-
and directly returns the XDG context. Individual directory flags can be
-
disabled by passing [false] for the corresponding optional parameter.
+
and directly returns the XDG context. Only command-line flags for the
+
requested directories are generated.
@param app_name The application name (used for environment variable prefixes)
@param fs The Eio filesystem to use for path resolution
-
@param config Include [--config-dir] flag (default: true)
-
@param data Include [--data-dir] flag (default: true)
-
@param cache Include [--cache-dir] flag (default: true)
-
@param state Include [--state-dir] flag (default: true)
-
@param runtime Include [--runtime-dir] flag (default: true)
+
@param dirs List of directories to include flags for (default: all directories)
{b Generated Command-line Flags:}
-
Only the flags for enabled directories are generated:
-
- [--config-dir DIR]: Override configuration directory (if [config=true])
-
- [--data-dir DIR]: Override data directory (if [data=true])
-
- [--cache-dir DIR]: Override cache directory (if [cache=true])
-
- [--state-dir DIR]: Override state directory (if [state=true])
-
- [--runtime-dir DIR]: Override runtime directory (if [runtime=true])
+
Only the flags for requested directories are generated:
+
- [--config-dir DIR]: Override configuration directory (if [`Config] in dirs)
+
- [--data-dir DIR]: Override data directory (if [`Data] in dirs)
+
- [--cache-dir DIR]: Override cache directory (if [`Cache] in dirs)
+
- [--state-dir DIR]: Override state directory (if [`State] in dirs)
+
- [--runtime-dir DIR]: Override runtime directory (if [`Runtime] in dirs)
{b Environment Variable Precedence:}
For each directory type, the following precedence applies:
···
{b Example - Only cache directory:}
{[
let open Cmdliner in
-
let xdg_term = Cmd.term "myapp" env#fs
-
~config:false ~data:false ~state:false ~runtime:false () in
+
let xdg_term = Cmd.term "myapp" env#fs ~dirs:[`Cache] () in
let main_term = Term.(const main $ xdg_term $ other_args) in
(* ... *)
]} *)
val term : string -> Eio.Fs.dir_ty Eio.Path.t ->
-
?config:bool -> ?data:bool -> ?cache:bool -> ?state:bool -> ?runtime:bool ->
+
?dirs:dir list ->
unit -> (xdg_t * t) Cmdliner.Term.t
(** [cache_term app_name] creates a Cmdliner term that provides just the cache
+17 -8
stack/zulip/lib/zulip/lib/client.ml
···
let client = create ~sw env auth in
f client
-
let request t ~method_ ~path ?params ?body () =
+
let request t ~method_ ~path ?params ?body ?content_type () =
let url = Auth.server_url t.auth ^ path in
Log.debug (fun m -> m "Request: %s %s"
(match method_ with
···
(* Prepare request body if provided *)
let body_opt = match body with
| Some body_str ->
-
(* Check if this looks like form data (key=value) or JSON *)
-
if String.contains body_str '=' && not (String.contains body_str '{') then
-
(* Form-encoded data *)
-
Some (Requests.Body.of_string Requests.Mime.form body_str)
-
else
-
(* JSON data *)
-
Some (Requests.Body.of_string Requests.Mime.json body_str)
+
let mime = match content_type with
+
| Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
+
(* Custom Content-Type for multipart *)
+
Requests.Mime.of_string ct
+
| Some "application/json" ->
+
Requests.Mime.json
+
| Some "application/x-www-form-urlencoded" | None ->
+
(* Default for form data *)
+
if String.contains body_str '=' && not (String.contains body_str '{') then
+
Requests.Mime.form
+
else
+
Requests.Mime.json
+
| Some ct ->
+
Requests.Mime.of_string ct
+
in
+
Some (Requests.Body.of_string mime body_str)
| None -> None
in
+3 -1
stack/zulip/lib/zulip/lib/client.mli
···
path:string ->
?params:(string * string) list ->
?body:string ->
+
?content_type:string ->
unit ->
(Zulip_types.json, Zulip_types.zerror) result
-
(** Make an HTTP request to the Zulip API using the requests library *)
+
(** Make an HTTP request to the Zulip API using the requests library.
+
@param content_type Optional Content-Type header (default: application/x-www-form-urlencoded for POST/PUT, none for GET/DELETE) *)
val pp : Format.formatter -> t -> unit
(** Pretty printer for client (shows server URL only, not credentials) *)
+62 -3
stack/zulip/lib/zulip/lib/messages.ml
···
Client.request client ~method_:`GET ~path:("/api/v1/messages/" ^ string_of_int message_id) ()
let get_messages client ?anchor ?num_before ?num_after ?narrow () =
-
let params =
+
let params =
(match anchor with Some a -> [("anchor", a)] | None -> []) @
(match num_before with Some n -> [("num_before", string_of_int n)] | None -> []) @
(match num_after with Some n -> [("num_after", string_of_int n)] | None -> []) @
(match narrow with Some n -> List.mapi (fun i s -> ("narrow[" ^ string_of_int i ^ "]", s)) n | None -> []) in
-
-
Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params ()
+
+
Client.request client ~method_:`GET ~path:"/api/v1/messages" ~params ()
+
+
let add_reaction client ~message_id ~emoji_name =
+
let params = [
+
("emoji_name", emoji_name);
+
("reaction_type", "unicode_emoji");
+
] in
+
match Client.request client ~method_:`POST
+
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
+
~params () with
+
| Ok _ -> Ok ()
+
| Error err -> Error err
+
+
let remove_reaction client ~message_id ~emoji_name =
+
let params = [
+
("emoji_name", emoji_name);
+
("reaction_type", "unicode_emoji");
+
] in
+
match Client.request client ~method_:`DELETE
+
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
+
~params () with
+
| Ok _ -> Ok ()
+
| Error err -> Error err
+
+
let upload_file client ~filename =
+
(* Read file contents *)
+
let ic = open_in_bin filename in
+
let len = in_channel_length ic in
+
let content = really_input_string ic len in
+
close_in ic;
+
+
(* Extract just the filename from the path *)
+
let basename = Filename.basename filename in
+
+
(* Create multipart form data boundary *)
+
let boundary = "----OCamlZulipBoundary" ^ string_of_float (Unix.gettimeofday ()) in
+
+
(* Build multipart body *)
+
let body = Buffer.create (len + 1024) in
+
Buffer.add_string body ("--" ^ boundary ^ "\r\n");
+
Buffer.add_string body ("Content-Disposition: form-data; name=\"file\"; filename=\"" ^ basename ^ "\"\r\n");
+
Buffer.add_string body "Content-Type: application/octet-stream\r\n";
+
Buffer.add_string body "\r\n";
+
Buffer.add_string body content;
+
Buffer.add_string body ("\r\n--" ^ boundary ^ "--\r\n");
+
+
let body_str = Buffer.contents body in
+
let content_type = "multipart/form-data; boundary=" ^ boundary in
+
+
match Client.request client ~method_:`POST ~path:"/api/v1/user_uploads"
+
~body:body_str ~content_type () with
+
| Ok json ->
+
(* Parse response to extract URI *)
+
(match json with
+
| `O fields ->
+
(match Jsonu.get_string fields "uri" with
+
| Ok uri -> Ok uri
+
| Error e -> Error e)
+
| _ -> Error (Zulip_types.create_error ~code:(Zulip_types.Other "upload_error") ~msg:"Failed to parse upload response" ()))
+
| Error err -> Error err
+41 -1
stack/zulip/lib/zulip/lib/messages.mli
···
?num_after:int ->
?narrow:string list ->
unit ->
-
(Zulip_types.json, Zulip_types.zerror) result
+
(Zulip_types.json, Zulip_types.zerror) result
+
+
(** Add an emoji reaction to a message.
+
+
@param client The Zulip client
+
@param message_id The message ID to react to
+
@param emoji_name The emoji name (e.g., "thumbs_up", "heart", "rocket")
+
@return Ok () on success, Error on failure
+
+
{b Example:}
+
{[
+
match Messages.add_reaction client ~message_id:12345 ~emoji_name:"thumbs_up" with
+
| Ok () -> print_endline "Reaction added!"
+
| Error e -> Printf.eprintf "Failed: %s\n" (Zulip_types.error_message e)
+
]} *)
+
val add_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result
+
+
(** Remove an emoji reaction from a message.
+
+
@param client The Zulip client
+
@param message_id The message ID
+
@param emoji_name The emoji name to remove
+
@return Ok () on success, Error on failure *)
+
val remove_reaction : Client.t -> message_id:int -> emoji_name:string -> (unit, Zulip_types.zerror) result
+
+
(** Upload a file to Zulip.
+
+
@param client The Zulip client
+
@param filename The path to the file to upload
+
@return Ok uri where uri is the Zulip URL for the uploaded file, Error on failure
+
+
{b Example:}
+
{[
+
match Messages.upload_file client ~filename:"/path/to/image.png" with
+
| Ok uri ->
+
let msg = Message.create ~type_:`Channel ~to_:["general"]
+
~content:("Check out this image: " ^ uri) () in
+
Messages.send client msg
+
| Error e -> Printf.eprintf "Upload failed: %s\n" (Zulip_types.error_message e)
+
]} *)
+
val upload_file : Client.t -> filename:string -> (string, Zulip_types.zerror) result