···11+This is a secure API key storage library for Eio applications.
22+33+The library follows OCaml best practices with abstract types (`type t`) per
44+module, comprehensive constructors/accessors, and proper pretty printers. Each
55+core concept gets its own module with a clean interface.
66+77+## Current Status
88+99+We are at STEP 1: Interface-only design. The keyeio.mli file documents the
1010+complete API without implementation. Once the interface is validated, we will
1111+proceed to STEP 2 (sample binaries) and STEP 3 (implementation).
1212+1313+## Design Principles
1414+1515+- Store credentials in XDG_CONFIG_HOME/appname/keys/ with 0o600 permissions
1616+- JSON 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)
+273
stack/keyeio/README.md
···11+# Keyeio - Secure API Key Storage for Eio Applications
22+33+Keyeio provides secure storage and retrieval of API keys and credentials for Eio-based applications. It uses the XDG Base Directory Specification via the [xdge](../xdge) library for consistent, platform-appropriate storage locations.
44+55+## Features
66+77+- 🔐 **Secure Storage**: Keys stored in XDG-compliant directories with strict file permissions (0o600)
88+- 📁 **Multiple Profiles**: Support multiple configurations per service (default, production, staging, etc.)
99+- 🎯 **Type-Safe API**: Abstract types and comprehensive accessors following OCaml best practices
1010+- 🖥️ **Cmdliner Integration**: Easy command-line argument handling matching xdge patterns
1111+- 🔮 **Future-Proof**: Designed to support Secret Service API (GNOME Keyring, KWallet) integration
1212+1313+## Installation
1414+1515+```bash
1616+opam install keyeio
1717+```
1818+1919+## Quick Start
2020+2121+### Basic Usage
2222+2323+```ocaml
2424+open Cmdliner
2525+2626+let main (xdg, _) profile =
2727+ (* Get credentials from profile *)
2828+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
2929+ let base_url = Keyeio.Profile.get profile ~key:"base_url"
3030+ |> Option.value ~default:"https://api.example.com" in
3131+3232+ (* Use credentials with your API client *)
3333+ Printf.printf "Connecting to %s\n" base_url;
3434+ (* ... *)
3535+3636+let () =
3737+ Eio_main.run @@ fun env ->
3838+3939+ (* Create Cmdliner terms *)
4040+ let xdg_term = Xdge.Cmd.term "myapp" env#fs () in
4141+ let key_term = Keyeio.Cmd.term
4242+ ~app_name:"myapp"
4343+ ~fs:env#fs
4444+ ~service:"immiche" (* Service name *)
4545+ () in
4646+4747+ let cmd = Cmd.v (Cmd.info "myapp")
4848+ Term.(const main $ xdg_term $ key_term) in
4949+ exit (Cmd.eval cmd)
5050+```
5151+5252+### Storage Format
5353+5454+Keys are stored in `~/.config/<appname>/keys/<service>.json`:
5555+5656+```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+}
7171+```
7272+7373+## Examples
7474+7575+The library includes comprehensive examples in `example/keyeio_example.ml`:
7676+7777+```bash
7878+# Build the example
7979+dune build keyeio/example/keyeio_example.exe
8080+8181+# List all configured services
8282+./keyeio_example.exe list
8383+8484+# Show profiles for a service
8585+./keyeio_example.exe profiles immiche
8686+8787+# Use default profile
8888+./keyeio_example.exe basic
8989+9090+# Use a specific profile
9191+./keyeio_example.exe basic --profile production
9292+9393+# Simulate API client
9494+./keyeio_example.exe client --profile staging
9595+```
9696+9797+### Example Output
9898+9999+```
100100+$ ./keyeio_example.exe profiles immiche
101101+=== List Profiles Example ===
102102+Service: immiche
103103+Available profiles:
104104+ - default (keys: api_key, base_url)
105105+ - production (keys: api_key, base_url, extra_field)
106106+ - staging (keys: api_key, base_url)
107107+108108+Service details:
109109+Service immiche:
110110+ default:
111111+ Profile immiche.default:
112112+ api_key: test_api***
113113+ base_url: https://immich.example.com
114114+ production:
115115+ Profile immiche.production:
116116+ api_key: prod_api***
117117+ base_url: https://immich.prod.example.com
118118+ extra_field: some_value
119119+```
120120+121121+## API Overview
122122+123123+### Creating a Keyeio Context
124124+125125+```ocaml
126126+val create : Xdge.t -> t
127127+```
128128+129129+Creates a keyeio context from an XDG context. Keys are stored in `keys/` subdirectory of the config directory.
130130+131131+### Loading Services
132132+133133+```ocaml
134134+val load_service : t -> service:string -> (Service.t, [> `Msg of string]) result
135135+```
136136+137137+Load all profiles for a service from `~/.config/<appname>/keys/<service>.json`.
138138+139139+```ocaml
140140+val list_services : t -> (string list, [> `Msg of string]) result
141141+```
142142+143143+List all available services (JSON files in the keys directory).
144144+145145+### Working with Profiles
146146+147147+```ocaml
148148+module Profile : sig
149149+ val service : t -> string
150150+ val name : t -> string
151151+ val get : t -> key:string -> string option
152152+ val get_required : t -> key:string -> string (* raises Key_not_found *)
153153+ val keys : t -> string list
154154+ val pp : Format.formatter -> t -> unit
155155+end
156156+```
157157+158158+### Working with Services
159159+160160+```ocaml
161161+module Service : sig
162162+ val name : t -> string
163163+ val profile_names : t -> string list
164164+ val get_profile : t -> string -> Profile.t option
165165+ val default_profile : t -> Profile.t option
166166+ val pp : Format.formatter -> t -> unit
167167+end
168168+```
169169+170170+### Cmdliner Integration
171171+172172+```ocaml
173173+module Cmd : sig
174174+ val term :
175175+ app_name:string ->
176176+ fs:Eio.Fs.dir_ty Eio.Path.t ->
177177+ service:string ->
178178+ ?profile:string -> (* Default: "default" *)
179179+ ?key_file:bool -> (* Add --key-file flag, default: true *)
180180+ unit ->
181181+ Profile.t Cmdliner.Term.t
182182+183183+ val env_docs : app_name:string -> service:string -> unit -> string
184184+end
185185+```
186186+187187+The `term` function generates:
188188+- `--profile NAME`: Select which profile to use
189189+- `--key-file PATH`: Override with direct file path (optional)
190190+191191+## Security
192192+193193+### Current Security Model
194194+195195+- Keys stored as JSON files in `~/.config/<appname>/keys/`
196196+- Files created with permissions `0o600` (owner read/write only)
197197+- Sensitive values masked in pretty-printing output
198198+- Follows standard Unix file permission security model
199199+200200+### Future Enhancements
201201+202202+The library is designed to support future integration with system keychains:
203203+204204+- **Linux**: freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC)
205205+- **macOS**: Keychain Services API
206206+- **Windows**: Credential Manager API
207207+208208+The abstract backend type allows adding these integrations without breaking the API.
209209+210210+## Integration Examples
211211+212212+### With Immiche (Immich API Client)
213213+214214+```ocaml
215215+let main (xdg, _) profile =
216216+ Eio_main.run @@ fun env ->
217217+218218+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
219219+ let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in
220220+221221+ let client = Immiche.create ~sw ~env ~base_url ~api_key () in
222222+ (* Use client... *)
223223+224224+let () =
225225+ Eio_main.run @@ fun env ->
226226+ let xdg_term = Xdge.Cmd.term "myapp" env#fs () in
227227+ let key_term = Keyeio.Cmd.term ~app_name:"myapp" ~fs:env#fs ~service:"immiche" () in
228228+ (* ... *)
229229+```
230230+231231+### With Karakeepe (Hoarder API Client)
232232+233233+```ocaml
234234+let key_term = Keyeio.Cmd.term
235235+ ~app_name:"myapp"
236236+ ~fs:env#fs
237237+ ~service:"karakeepe"
238238+ () in
239239+240240+let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
241241+let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in
242242+243243+let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key base_url in
244244+(* Process bookmarks... *)
245245+```
246246+247247+## Environment Variables
248248+249249+The storage location respects XDG Base Directory Specification:
250250+251251+- `XDG_CONFIG_HOME`: Base directory for config files (default: `~/.config`)
252252+- `<APPNAME>_CONFIG_DIR`: Application-specific override (highest priority)
253253+254254+Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.json`
255255+256256+## Documentation
257257+258258+Full API documentation is available:
259259+260260+```bash
261261+dune build @doc
262262+open _build/default/_doc/_html/keyeio/index.html
263263+```
264264+265265+## License
266266+267267+ISC License
268268+269269+## See Also
270270+271271+- [xdge](../xdge) - XDG Base Directory Specification for Eio
272272+- [immiche](../immiche) - Immich API client using keyeio
273273+- [karakeepe](../karakeepe) - Hoarder API client using keyeio
+33
stack/keyeio/dune-project
···11+(lang dune 3.20)
22+33+(name keyeio)
44+55+(generate_opam_files true)
66+77+(license ISC)
88+(authors "Anil Madhavapeddy")
99+(homepage "https://tangled.sh/@anil.recoil.org")
1010+(maintainers "Anil Madhavapeddy <anil@recoil.org>")
1111+(bug_reports https://tangled.sh/@anil.recoil.org/keyeio)
1212+(maintenance_intent "(latest)")
1313+1414+(package
1515+ (name keyeio)
1616+ (synopsis "Secure API key storage for Eio applications using XDG directories")
1717+ (description
1818+ "Keyeio provides secure storage and retrieval of API keys and credentials \
1919+ for Eio-based applications. It uses XDG Base Directory Specification via \
2020+ the xdge library for consistent storage locations, supports multiple profiles \
2121+ per service, and includes Cmdliner integration for easy command-line usage. \
2222+ The library is designed to allow future integration with system keychains \
2323+ like GNOME Keyring and macOS Keychain via the Secret Service API.")
2424+ (depends
2525+ (ocaml (>= 5.1.0))
2626+ (eio (>= 1.1))
2727+ eio_main
2828+ (xdge (>= 0.1.0))
2929+ (yojson (>= 2.0.0))
3030+ (cmdliner (>= 1.2.0))
3131+ (fmt (>= 0.11.0))
3232+ (odoc :with-doc)
3333+ (alcotest (and :with-test (>= 1.7.0)))))
···11+(** Secure API key storage for Eio applications using XDG directories *)
22+33+(** {1 Exceptions} *)
44+55+exception Key_not_found of string
66+exception Profile_not_found of string
77+exception Invalid_key_file of string
88+99+(** {1 Profile Implementation} *)
1010+1111+module Profile = struct
1212+ type t = {
1313+ service : string;
1414+ name : string;
1515+ data : (string * string) list;
1616+ }
1717+1818+ let service t = t.service
1919+ let name t = t.name
2020+2121+ let get t ~key = List.assoc_opt key t.data
2222+2323+ let get_required t ~key =
2424+ match get t ~key with
2525+ | Some value -> value
2626+ | None ->
2727+ raise
2828+ (Key_not_found
2929+ (Printf.sprintf "Key '%s' not found in profile '%s' of service '%s'" key
3030+ t.name t.service))
3131+3232+ let keys t = List.map fst t.data
3333+3434+ let to_json t =
3535+ let obj = List.map (fun (k, v) -> (k, `String v)) t.data in
3636+ `Assoc obj
3737+3838+ let pp ppf t =
3939+ let mask_sensitive key =
4040+ let lower = String.lowercase_ascii key in
4141+ String.contains lower 'k' && String.contains lower 'e' && String.contains lower 'y'
4242+ || String.contains lower 't' && String.contains lower 'o' && String.contains lower 'k'
4343+ || String.contains lower 'p'
4444+ && String.contains lower 'a'
4545+ && String.contains lower 's'
4646+ && String.contains lower 's'
4747+ in
4848+ Fmt.pf ppf "@[<v 2>Profile %s.%s:@," t.service t.name;
4949+ List.iter
5050+ (fun (k, v) ->
5151+ if mask_sensitive k then
5252+ Fmt.pf ppf " %s: %s@," k (String.sub v 0 (min 8 (String.length v)) ^ "***")
5353+ else Fmt.pf ppf " %s: %s@," k v)
5454+ t.data;
5555+ Fmt.pf ppf "@]"
5656+end
5757+5858+(** {1 Service Implementation} *)
5959+6060+module Service = struct
6161+ type t = {
6262+ name : string;
6363+ profiles : (string * Profile.t) list;
6464+ }
6565+6666+ let name t = t.name
6767+ let profile_names t = List.map fst t.profiles
6868+ let get_profile t name = List.assoc_opt name t.profiles
6969+ let default_profile t = get_profile t "default"
7070+7171+ let pp ppf t =
7272+ Fmt.pf ppf "@[<v 2>Service %s:@," t.name;
7373+ List.iter
7474+ (fun (pname, profile) -> Fmt.pf ppf "@[<v 2>%s:@,%a@]@," pname Profile.pp profile)
7575+ t.profiles;
7676+ Fmt.pf ppf "@]"
7777+end
7878+7979+(** {1 Main Context} *)
8080+8181+type backend = Filesystem of { keys_dir : Eio.Fs.dir_ty Eio.Path.t }
8282+8383+type t = { xdg : Xdge.t; backend : backend }
8484+8585+let create xdg =
8686+ (* Keys are stored in a "keys" subdirectory of the config directory *)
8787+ let config_dir = Xdge.config_dir xdg in
8888+ let keys_dir = Eio.Path.(config_dir / "keys") in
8989+9090+ (* Create keys directory with restrictive permissions *)
9191+ (try Eio.Path.mkdir ~perm:0o700 keys_dir with
9292+ | Eio.Io (Eio.Fs.E (Already_exists _), _) -> ());
9393+9494+ { xdg; backend = Filesystem { keys_dir } }
9595+9696+(** {1 JSON Parsing Helpers} *)
9797+9898+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))
115115+116116+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))
129129+130130+(** {1 File Operations} *)
131131+132132+let load_service t ~service =
133133+ match t.backend with
134134+ | Filesystem { keys_dir } ->
135135+ let service_file = Eio.Path.(keys_dir / (service ^ ".json")) in
136136+ (try
137137+ (* Read and parse the JSON file *)
138138+ 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
141141+ Ok service_data
142142+ with
143143+ | 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))
147147+ | Invalid_key_file msg -> Error (`Msg msg)
148148+ | exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn))))
149149+150150+let list_services t =
151151+ match t.backend with
152152+ | Filesystem { keys_dir } ->
153153+ (try
154154+ let entries = Eio.Path.read_dir keys_dir in
155155+ let services =
156156+ List.filter_map
157157+ (fun entry ->
158158+ if String.ends_with ~suffix:".json" entry then
159159+ Some (String.sub entry 0 (String.length entry - 5))
160160+ else None)
161161+ entries
162162+ in
163163+ Ok (List.sort String.compare services)
164164+ with
165165+ | Eio.Io (Eio.Fs.E (Not_found _), _) ->
166166+ (* Keys directory doesn't exist yet *)
167167+ Ok []
168168+ | exn -> Error (`Msg (Printf.sprintf "Error listing services: %s" (Printexc.to_string exn))))
169169+170170+let pp ppf t =
171171+ match t.backend with
172172+ | Filesystem { keys_dir } ->
173173+ Fmt.pf ppf "@[<v 2>Keyeio:@,";
174174+ Fmt.pf ppf "Keys directory: %s@," (Eio.Path.native_exn keys_dir);
175175+ Fmt.pf ppf "Application: %s@," (Xdge.app_name t.xdg);
176176+ Fmt.pf ppf "@]"
177177+178178+(** {1 Cmdliner Integration} *)
179179+180180+module Cmd = struct
181181+ type keyeio_t = t
182182+183183+ let term ~app_name ~fs ~service ?profile:(default_profile = "default")
184184+ ?(key_file = true) () =
185185+ let open Cmdliner in
186186+ (* Profile selection flag *)
187187+ let profile_flag =
188188+ let doc = Printf.sprintf "Profile name to use for %s service" service in
189189+ Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc)
190190+ in
191191+192192+ (* Optional key file override *)
193193+ let key_file_flag =
194194+ if key_file then
195195+ let doc = Printf.sprintf "Override with direct path to %s key file" service in
196196+ Some Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc)
197197+ else None
198198+ in
199199+200200+ (* Term that loads the profile *)
201201+ let load_profile profile_name key_file_path =
202202+ (* If key_file path is provided, load from there *)
203203+ match key_file_path with
204204+ | Some path ->
205205+ (try
206206+ 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
209209+ | svc ->
210210+ (match Service.get_profile svc profile_name with
211211+ | Some prof -> prof
212212+ | None ->
213213+ failwith
214214+ (Printf.sprintf "Profile '%s' not found in %s" profile_name path))
215215+ | exception exn ->
216216+ failwith
217217+ (Printf.sprintf "Error loading key file %s: %s" path
218218+ (Printexc.to_string exn))
219219+ with
220220+ | Sys_error msg -> failwith (Printf.sprintf "Cannot read key file: %s" msg))
221221+ | None ->
222222+ (* Load from XDG directory *)
223223+ let xdg = Xdge.create fs app_name in
224224+ let keyeio = create xdg in
225225+ (match load_service keyeio ~service with
226226+ | Ok svc ->
227227+ (match Service.get_profile svc profile_name with
228228+ | Some prof -> prof
229229+ | None ->
230230+ failwith
231231+ (Printf.sprintf "Profile '%s' not found in service '%s'" profile_name
232232+ service))
233233+ | Error (`Msg msg) -> failwith msg)
234234+ in
235235+236236+ (* Build the term *)
237237+ match key_file_flag with
238238+ | Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag)
239239+ | None -> Term.(const load_profile $ profile_flag $ const None)
240240+241241+ let env_docs ~app_name ~service () =
242242+ Printf.sprintf
243243+ {|ENVIRONMENT
244244+ Keys are stored in the XDG config directory under a 'keys' subdirectory.
245245+ The location is determined by the XDG Base Directory Specification:
246246+247247+ XDG_CONFIG_HOME
248248+ Base directory for configuration files. If not set, defaults to
249249+ $HOME/.config. Keys for %s will be stored in:
250250+ $XDG_CONFIG_HOME/%s/keys/%s.json
251251+252252+ Example locations:
253253+ ~/.config/%s/keys/%s.json (default)
254254+ /custom/config/%s/keys/%s.json (if XDG_CONFIG_HOME=/custom/config)
255255+256256+ File permissions should be 0600 (owner read/write only) for security.
257257+|}
258258+ app_name app_name service app_name service app_name service
259259+end
+437
stack/keyeio/lib/keyeio.mli
···11+(** Secure API key storage for Eio applications using XDG directories
22+33+ This library provides secure storage and retrieval of API keys and credentials
44+ for Eio-based applications. It integrates with the XDG Base Directory
55+ Specification via the xdge library to store credentials in a consistent,
66+ platform-appropriate location.
77+88+ {b Key Features:}
99+1010+ - 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
1313+ - Cmdliner integration for easy command-line usage
1414+ - Designed for future Secret Service API integration
1515+1616+ {b Security Model:}
1717+1818+ Currently, credentials are stored as JSON 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.
2222+2323+ The design supports future integration with system keychains via the
2424+ freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC)
2525+ without breaking the API.
2626+2727+ {b Storage Structure:}
2828+2929+ Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.json] where SERVICE
3030+ is the name of the service (e.g., "immiche", "karakeepe"). Each service file
3131+ contains one or more named profiles:
3232+3333+ {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+ }
4444+ v}
4545+4646+ {b Example Usage:}
4747+4848+ {[
4949+ open Cmdliner
5050+5151+ let main (xdg, _) profile =
5252+ Eio_main.run @@ fun env ->
5353+5454+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
5555+ let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in
5656+5757+ (* Use credentials to create API client *)
5858+ let client = Immiche.create ~sw ~env ~base_url ~api_key () in
5959+ (* ... *)
6060+6161+ let () =
6262+ let xdg_term = Xdge.Cmd.term "myapp" env#fs () in
6363+ let key_term = Keyeio.Cmd.term
6464+ ~app_name:"myapp"
6565+ ~service:"immiche"
6666+ env#fs () in
6767+6868+ let cmd = Cmd.v (Cmd.info "myapp")
6969+ Term.(const main $ xdg_term $ key_term) in
7070+ exit (Cmd.eval cmd)
7171+ ]}
7272+7373+ @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification
7474+ @see <https://specifications.freedesktop.org/secret-service-spec/latest/> Secret Service API *)
7575+7676+(** The main Keyeio context type containing directory paths and configuration.
7777+7878+ A value of type [t] represents the keyeio configuration for a specific
7979+ application, including the keys directory path and storage backend. *)
8080+type t
8181+8282+(** {1 Exceptions} *)
8383+8484+(** Exception raised when a required key is not found in a profile. *)
8585+exception Key_not_found of string
8686+8787+(** Exception raised when a profile is not found in a service. *)
8888+exception Profile_not_found of string
8989+9090+(** Exception raised when attempting to access invalid JSON structure. *)
9191+exception Invalid_key_file of string
9292+9393+(** {1 Profile} *)
9494+9595+(** A profile represents a set of credentials for one service configuration.
9696+9797+ Profiles allow you to store multiple sets of credentials for the same
9898+ service. For example, you might have "default", "production", and "staging"
9999+ profiles for an API service, each with different API keys and endpoints.
100100+101101+ Each profile contains arbitrary key-value pairs stored as strings. Common
102102+ keys include "api_key", "base_url", "email", etc., but applications are
103103+ free to store any configuration needed. *)
104104+module Profile : sig
105105+ (** The type of a credential profile. *)
106106+ type t
107107+108108+ (** [service t] returns the service name this profile belongs to.
109109+110110+ @return The service name (e.g., "immiche", "karakeepe") *)
111111+ val service : t -> string
112112+113113+ (** [name t] returns the profile name.
114114+115115+ @return The profile name (e.g., "default", "production", "staging") *)
116116+ val name : t -> string
117117+118118+ (** [get t ~key] retrieves a value from the profile.
119119+120120+ @param t The profile to query
121121+ @param key The key to look up
122122+ @return [Some value] if the key exists, [None] otherwise
123123+124124+ {b Example:}
125125+ {[
126126+ match Profile.get profile ~key:"api_key" with
127127+ | Some key -> Printf.printf "API key: %s\n" key
128128+ | None -> Printf.printf "No API key found\n"
129129+ ]} *)
130130+ val get : t -> key:string -> string option
131131+132132+ (** [get_required t ~key] retrieves a value that must exist.
133133+134134+ @param t The profile to query
135135+ @param key The key to look up
136136+ @return The value associated with the key
137137+ @raise Key_not_found if the key does not exist
138138+139139+ {b Example:}
140140+ {[
141141+ let api_key = Profile.get_required profile ~key:"api_key" in
142142+ (* Use api_key, knowing it exists *)
143143+ ]} *)
144144+ val get_required : t -> key:string -> string
145145+146146+ (** [keys t] returns all keys available in this profile.
147147+148148+ @param t The profile to query
149149+ @return A list of all key names in the profile
150150+151151+ {b Example:}
152152+ {[
153153+ let available = Profile.keys profile in
154154+ List.iter (fun k -> Printf.printf "Available key: %s\n" k) available
155155+ ]} *)
156156+ val keys : t -> string list
157157+158158+ (** [to_json t] converts the profile to a JSON representation.
159159+160160+ Returns a JSON object containing all key-value pairs in the profile.
161161+162162+ @param t The profile to convert
163163+ @return A JSON object representation *)
164164+ val to_json : t -> Yojson.Basic.t
165165+166166+ (** [pp ppf t] pretty prints a profile for debugging.
167167+168168+ Displays the service name, profile name, and all key-value pairs.
169169+ Sensitive values (keys containing "key", "token", "password") are
170170+ masked for security.
171171+172172+ @param ppf The formatter to print to
173173+ @param t The profile to print *)
174174+ val pp : Format.formatter -> t -> unit
175175+end
176176+177177+(** {1 Service} *)
178178+179179+(** A service represents all profiles for a given service.
180180+181181+ Services group together multiple profiles for the same API or service.
182182+ For example, an "immiche" service might contain "default", "production",
183183+ and "staging" profiles, each with their own credentials.
184184+185185+ Services are loaded from JSON files in the keys directory, with one file
186186+ per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.json] *)
187187+module Service : sig
188188+ (** The type of a service containing multiple profiles. *)
189189+ type t
190190+191191+ (** [name t] returns the service name.
192192+193193+ @return The service name (e.g., "immiche", "karakeepe") *)
194194+ val name : t -> string
195195+196196+ (** [profile_names t] returns all available profile names for this service.
197197+198198+ @param t The service to query
199199+ @return A list of profile names (e.g., ["default"; "production"; "staging"])
200200+201201+ {b Example:}
202202+ {[
203203+ let profiles = Service.profile_names service in
204204+ Printf.printf "Available profiles: %s\n" (String.concat ", " profiles)
205205+ ]} *)
206206+ val profile_names : t -> string list
207207+208208+ (** [get_profile t name] retrieves a specific profile by name.
209209+210210+ @param t The service to query
211211+ @param name The profile name to retrieve
212212+ @return [Some profile] if found, [None] otherwise
213213+214214+ {b Example:}
215215+ {[
216216+ match Service.get_profile service "production" with
217217+ | Some prof -> (* Use production profile *)
218218+ | None -> failwith "Production profile not configured"
219219+ ]} *)
220220+ val get_profile : t -> string -> Profile.t option
221221+222222+ (** [default_profile t] retrieves the "default" profile if it exists.
223223+224224+ This is a convenience function equivalent to [get_profile t "default"].
225225+226226+ @param t The service to query
227227+ @return [Some profile] if a "default" profile exists, [None] otherwise *)
228228+ val default_profile : t -> Profile.t option
229229+230230+ (** [pp ppf t] pretty prints a service and all its profiles.
231231+232232+ @param ppf The formatter to print to
233233+ @param t The service to print *)
234234+ val pp : Format.formatter -> t -> unit
235235+end
236236+237237+(** {1 Construction} *)
238238+239239+(** [create xdg] creates a Keyeio context from an Xdge context.
240240+241241+ The keys are stored in a "keys" subdirectory of the XDG config directory.
242242+ For example, if the application is "myapp" and [XDG_CONFIG_HOME] is
243243+ [~/.config], keys will be stored in [~/.config/myapp/keys/].
244244+245245+ The keys directory is created with permissions 0o700 if it doesn't exist.
246246+247247+ @param xdg The Xdge context providing XDG directory paths
248248+ @return A new Keyeio context
249249+250250+ {b Example:}
251251+ {[
252252+ let xdg = Xdge.create env#fs "myapp" in
253253+ let keyeio = Keyeio.create xdg in
254254+ (* Now you can load services and profiles *)
255255+ ]} *)
256256+val create : Xdge.t -> t
257257+258258+(** {1 Loading Credentials} *)
259259+260260+(** [load_service t ~service] loads all profiles for a given service.
261261+262262+ 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.
266266+267267+ @param t The Keyeio context
268268+ @param service The service name to load (e.g., "immiche", "karakeepe")
269269+ @return [Ok service] on success, [Error (`Msg msg)] on failure
270270+271271+ {b Example:}
272272+ {[
273273+ match Keyeio.load_service keyeio ~service:"immiche" with
274274+ | Ok svc ->
275275+ begin match Service.default_profile svc with
276276+ | Some prof -> (* Use default profile *)
277277+ | None -> failwith "No default profile"
278278+ end
279279+ | Error (`Msg msg) ->
280280+ Printf.eprintf "Failed to load service: %s\n" msg
281281+ ]}
282282+283283+ {b Error Conditions:}
284284+ - Service file does not exist
285285+ - Service file has incorrect permissions (not 0o600)
286286+ - Service file contains invalid JSON
287287+ - Service file is not a JSON object *)
288288+val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result
289289+290290+(** [list_services t] returns all available service names.
291291+292292+ Scans the keys directory for all [*.json] files and returns their
293293+ base names (without the .json extension). This allows applications
294294+ to discover what services have stored credentials.
295295+296296+ @param t The Keyeio context
297297+ @return [Ok services] with a list of service names, or [Error (`Msg msg)] on failure
298298+299299+ {b Example:}
300300+ {[
301301+ match Keyeio.list_services keyeio with
302302+ | Ok services ->
303303+ Printf.printf "Available services: %s\n"
304304+ (String.concat ", " services)
305305+ | Error (`Msg msg) ->
306306+ Printf.eprintf "Failed to list services: %s\n" msg
307307+ ]}
308308+309309+ {b Note:} Only files with [.json] extension are considered. Files
310310+ with incorrect permissions are silently skipped. *)
311311+val list_services : t -> (string list, [> `Msg of string ]) result
312312+313313+(** {1 Pretty Printing} *)
314314+315315+(** [pp ppf t] pretty prints the Keyeio configuration.
316316+317317+ Shows the keys directory path and basic configuration information.
318318+ Does not display actual credentials for security.
319319+320320+ @param ppf The formatter to print to
321321+ @param t The Keyeio context to print *)
322322+val pp : Format.formatter -> t -> unit
323323+324324+(** {1 Cmdliner Integration} *)
325325+326326+module Cmd : sig
327327+ (** The type of the outer Keyeio context *)
328328+ type keyeio_t = t
329329+330330+ (** Cmdliner integration for API key and credential management.
331331+332332+ This module provides seamless integration with the Cmdliner library,
333333+ allowing applications to easily add credential loading to their
334334+ command-line interfaces. The integration follows the same patterns
335335+ as Xdge.Cmd for consistency.
336336+337337+ {b Features:}
338338+ - Automatic command-line flag generation for profile selection
339339+ - Optional direct file path override
340340+ - Clear error messages for missing or invalid credentials
341341+ - Composable with other Cmdliner terms *)
342342+343343+ (** [term ~app_name ~fs ~service ()] creates a Cmdliner term for loading credentials.
344344+345345+ This function generates a Cmdliner term that handles credential loading
346346+ for a specific service. It automatically creates appropriate command-line
347347+ flags and handles loading the requested profile.
348348+349349+ @param app_name The application name (used for XDG paths)
350350+ @param fs The Eio filesystem providing filesystem access
351351+ @param service The service name to load credentials for (e.g., "immiche")
352352+ @param profile Default profile name to use (default: "default")
353353+ @param key_file Add [--key-file] override flag (default: [true])
354354+355355+ {b Generated Command-line Flags:}
356356+ - [--profile NAME]: Select which profile to use (default: "default")
357357+ - [--key-file PATH]: Override with direct JSON file path (if [key_file=true])
358358+359359+ {b Flag Precedence:}
360360+ + [--key-file PATH] - highest priority (if enabled)
361361+ + [--profile NAME]
362362+ + Default profile ("default")
363363+364364+ {b Example - Basic usage:}
365365+ {[
366366+ open Cmdliner
367367+368368+ let main profile =
369369+ let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
370370+ (* Use api_key *)
371371+372372+ let () =
373373+ let key_term = Keyeio.Cmd.term
374374+ ~app_name:"myapp"
375375+ ~service:"immiche"
376376+ env#fs () in
377377+378378+ let cmd = Cmd.v (Cmd.info "myapp")
379379+ Term.(const main $ key_term) in
380380+ exit (Cmd.eval cmd)
381381+ ]}
382382+383383+ {b Example - Custom profile default:}
384384+ {[
385385+ let key_term = Keyeio.Cmd.term
386386+ ~app_name:"myapp"
387387+ ~service:"immiche"
388388+ ~profile:"production" (* Use production by default *)
389389+ env#fs () in
390390+ ]}
391391+392392+ {b Example - Without key-file override:}
393393+ {[
394394+ let key_term = Keyeio.Cmd.term
395395+ ~app_name:"myapp"
396396+ ~service:"immiche"
397397+ ~key_file:false (* Only --profile flag, no --key-file *)
398398+ env#fs () in
399399+ ]}
400400+401401+ {b Error Handling:}
402402+403403+ The term will fail with a clear error message if:
404404+ - The service file does not exist
405405+ - The requested profile is not found
406406+ - The JSON file is invalid
407407+ - File permissions are incorrect *)
408408+ val term :
409409+ app_name:string ->
410410+ fs:Eio.Fs.dir_ty Eio.Path.t ->
411411+ service:string ->
412412+ ?profile:string ->
413413+ ?key_file:bool ->
414414+ unit ->
415415+ Profile.t Cmdliner.Term.t
416416+417417+ (** [env_docs ~app_name ~service ()] generates documentation for environment variables.
418418+419419+ Returns a formatted string documenting relevant environment variables
420420+ that affect key storage location. This is useful for generating man
421421+ pages or help text.
422422+423423+ @param app_name The application name
424424+ @param service The service name
425425+ @return A formatted documentation string
426426+427427+ {b Included Information:}
428428+ - How XDG_CONFIG_HOME affects key storage location
429429+ - Application-specific overrides
430430+ - File location examples
431431+432432+ {b Example:}
433433+ {[
434434+ let env_section = env_docs ~app_name:"myapp" ~service:"immiche" ()
435435+ ]} *)
436436+ val env_docs : app_name:string -> service:string -> unit -> string
437437+end