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

more

Changed files
+658
stack
+275
stack/karakeepe/README.md
···
+
# Karakeepe - Karakeep/Hoarder CLI Client
+
+
A command-line client for managing bookmarks in [Karakeep (Hoarder)](https://github.com/hoarder-app/hoarder).
+
+
## Features
+
+
- ๐Ÿ”– List, search, and manage bookmarks
+
- ๐Ÿ” Secure credential storage using [keyeio](../keyeio)
+
- ๐Ÿ“ Multiple profile support (default, production, staging, etc.)
+
- ๐ŸŽฏ Tag-based filtering and search
+
- โญ Support for favourites and archived bookmarks
+
- ๐Ÿ“ Add notes and titles to bookmarks
+
+
## Installation
+
+
```bash
+
opam install karakeepe
+
```
+
+
## Configuration
+
+
Create a configuration file at `~/.config/karakeepe/keys/karakeepe.toml`:
+
+
```toml
+
[default]
+
api_key = "ak1_<key_id>_<secret>"
+
base_url = "https://hoard.example.com"
+
+
[production]
+
api_key = "ak1_<prod_key_id>_<prod_secret>"
+
base_url = "https://hoard.prod.example.com"
+
```
+
+
### Getting an API Key
+
+
1. Log in to your Karakeep instance
+
2. Navigate to Settings โ†’ API Keys
+
3. Create a new API key
+
4. Copy the key (format: `ak1_<key_id>_<secret>`)
+
+
## Usage
+
+
### List Bookmarks
+
+
```bash
+
# List all bookmarks
+
karakeepe list
+
+
# List with limit
+
karakeepe list --limit 10
+
+
# List only archived bookmarks
+
karakeepe list --archived true
+
+
# List only favourited bookmarks
+
karakeepe list --favourited true
+
+
# Filter by tags
+
karakeepe list --tags "work,important"
+
+
# Use production profile
+
karakeepe list --profile production
+
```
+
+
### Get a Single Bookmark
+
+
```bash
+
# Get bookmark by ID
+
karakeepe get <bookmark-id>
+
+
# Example
+
karakeepe get "clx7y8z9a0001..."
+
```
+
+
### Create a Bookmark
+
+
```bash
+
# Create a simple bookmark
+
karakeepe create "https://example.com"
+
+
# Create with title
+
karakeepe create "https://example.com" --title "Example Site"
+
+
# Create with title and tags
+
karakeepe create "https://example.com" \
+
--title "Example Site" \
+
--tags "web,example,reference"
+
+
# Create with note
+
karakeepe create "https://example.com" \
+
--title "Example" \
+
--note "Useful reference for web development"
+
+
# Create as favourited
+
karakeepe create "https://example.com" --favourited
+
+
# Create as archived
+
karakeepe create "https://example.com" --archived
+
```
+
+
### Search Bookmarks
+
+
```bash
+
# Search by single tag
+
karakeepe search --tags work
+
+
# Search by multiple tags
+
karakeepe search --tags "work,important"
+
+
# Limit search results
+
karakeepe search --tags work --limit 5
+
```
+
+
## Command Reference
+
+
### Global Options
+
+
- `--profile NAME` - Select profile to use (default: "default")
+
- `--key-file FILE` - Override with direct TOML file path
+
- `--url URL` - Override base URL from profile
+
- `--help` - Show help for command
+
+
### Commands
+
+
#### `karakeepe list [OPTIONS]`
+
+
List bookmarks with optional filtering.
+
+
**Options:**
+
- `-l, --limit NUM` - Maximum number of bookmarks to fetch (default: 50)
+
- `-a, --archived BOOL` - Filter by archived status (true/false)
+
- `-f, --favourited BOOL` - Filter by favourited status (true/false)
+
- `-t, --tags TAGS` - Filter by tags (comma-separated)
+
+
#### `karakeepe get ID`
+
+
Get detailed information for a single bookmark.
+
+
**Arguments:**
+
- `ID` - Bookmark ID (required)
+
+
#### `karakeepe create URL [OPTIONS]`
+
+
Create a new bookmark.
+
+
**Arguments:**
+
- `URL` - URL to bookmark (required)
+
+
**Options:**
+
- `--title TITLE` - Bookmark title
+
- `-n, --note NOTE` - Bookmark note/description
+
- `-t, --tags TAGS` - Tags to add (comma-separated)
+
- `-a, --archived` - Mark as archived
+
- `-f, --favourited` - Mark as favourited
+
+
#### `karakeepe search --tags TAGS [OPTIONS]`
+
+
Search bookmarks by tags.
+
+
**Options:**
+
- `-t, --tags TAGS` - Tags to search for (comma-separated, required)
+
- `-l, --limit NUM` - Maximum number of results (default: 50)
+
+
## Examples
+
+
### Daily Workflow
+
+
```bash
+
# Add a bookmark you found
+
karakeepe create "https://blog.example.com/article" \
+
--title "Great Article on OCaml" \
+
--tags "ocaml,programming,blog"
+
+
# List your work bookmarks
+
karakeepe list --tags work
+
+
# Search for OCaml resources
+
karakeepe search --tags ocaml
+
+
# Get details of a specific bookmark
+
karakeepe get clx7y8z9a0001...
+
+
# Mark something as favourited
+
karakeepe create "https://important.example.com" --favourited
+
```
+
+
### Multiple Profiles
+
+
```bash
+
# Use default profile (personal instance)
+
karakeepe list
+
+
# Use work profile
+
karakeepe list --profile work
+
+
# Use custom file
+
karakeepe list --key-file ~/my-keys.toml --profile custom
+
```
+
+
## Profile Setup Examples
+
+
### Personal Setup
+
+
`~/.config/karakeepe/keys/karakeepe.toml`:
+
```toml
+
[default]
+
api_key = "ak1_abc123..."
+
base_url = "https://hoard.example.com"
+
```
+
+
### Work Setup
+
+
`~/.config/karakeepe/keys/karakeepe.toml`:
+
```toml
+
[default]
+
api_key = "ak1_personal_key..."
+
base_url = "https://hoard.personal.com"
+
+
[work]
+
api_key = "ak1_work_key..."
+
base_url = "https://hoard.company.com"
+
```
+
+
## Library Usage
+
+
The `karakeepe` library can also be used programmatically in OCaml applications:
+
+
```ocaml
+
let () =
+
Eio_main.run @@ fun env ->
+
Eio.Switch.run @@ fun sw ->
+
+
let api_key = "ak1_..." in
+
let base_url = "https://hoard.example.com" in
+
+
(* Fetch all bookmarks *)
+
let bookmarks = Karakeepe.fetch_all_bookmarks
+
~sw ~env ~api_key base_url in
+
+
(* Create a bookmark *)
+
let bookmark = Karakeepe.create_bookmark
+
~sw ~env ~api_key
+
~url:"https://example.com"
+
~title:"Example"
+
~tags:["web"; "example"]
+
base_url in
+
+
Printf.printf "Created bookmark: %s\n" bookmark.id
+
```
+
+
See the [library documentation](karakeepe.mli) for full API reference.
+
+
## Troubleshooting
+
+
### "Service file not found: karakeepe.toml"
+
+
Create the configuration file at `~/.config/karakeepe/keys/karakeepe.toml` with your API key.
+
+
### "HTTP error: 401"
+
+
Your API key is invalid or expired. Generate a new one from your Karakeep instance.
+
+
### "HTTP error: 404"
+
+
The bookmark ID doesn't exist or the base URL is incorrect.
+
+
## See Also
+
+
- [Hoarder](https://github.com/hoarder-app/hoarder) - The Karakeep/Hoarder bookmark manager
+
- [keyeio](../keyeio) - Secure API key storage
+
- [xdge](../xdge) - XDG Base Directory Specification for Eio
+
+
## License
+
+
ISC License
+4
stack/karakeepe/bin/dune
···
+
(executable
+
(name karakeepe_cli)
+
(public_name karakeepe)
+
(libraries karakeepe keyeio xdge eiocmd eio eio_main cmdliner ptime))
+379
stack/karakeepe/bin/karakeepe_cli.ml
···
+
open Cmdliner
+
+
(** List bookmarks command *)
+
let list_bookmarks env _xdg profile base_url limit archived favourited tags =
+
Eio.Switch.run @@ fun sw ->
+
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let base_url =
+
match base_url with
+
| Some url -> url
+
| None ->
+
Keyeio.Profile.get profile ~key:"base_url"
+
|> Option.value ~default:"https://hoard.recoil.org"
+
in
+
+
Printf.printf "Fetching bookmarks from %s...\n" base_url;
+
+
try
+
let bookmarks = Karakeepe.fetch_all_bookmarks
+
~sw ~env ~api_key
+
~page_size:limit
+
~include_content:true
+
?filter_tags:(if tags = [] then None else Some tags)
+
base_url
+
in
+
+
(* Filter by archived/favourited if requested *)
+
let bookmarks =
+
List.filter (fun (b : Karakeepe.bookmark) ->
+
(match archived with
+
| Some true -> b.archived
+
| Some false -> not b.archived
+
| None -> true) &&
+
(match favourited with
+
| Some true -> b.favourited
+
| Some false -> not b.favourited
+
| None -> true)
+
) bookmarks
+
in
+
+
Printf.printf "Found %d bookmarks\n\n" (List.length bookmarks);
+
+
List.iteri (fun i (b : Karakeepe.bookmark) ->
+
Printf.printf "%d. %s\n" (i + 1) b.url;
+
(match b.title with
+
| Some title -> Printf.printf " Title: %s\n" title
+
| None -> ());
+
Printf.printf " ID: %s\n" b.id;
+
Printf.printf " Created: %s\n" (Ptime.to_rfc3339 b.created_at);
+
if b.tags <> [] then
+
Printf.printf " Tags: %s\n" (String.concat ", " b.tags);
+
if b.archived then Printf.printf " [ARCHIVED]\n";
+
if b.favourited then Printf.printf " [FAVOURITED]\n";
+
(match b.summary with
+
| Some s when s <> "" ->
+
let summary = if String.length s > 100 then String.sub s 0 100 ^ "..." else s in
+
Printf.printf " Summary: %s\n" summary
+
| _ -> ());
+
Printf.printf "\n"
+
) bookmarks;
+
0
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
1
+
+
(** Get a single bookmark by ID *)
+
let get_bookmark env _xdg profile base_url bookmark_id =
+
Eio.Switch.run @@ fun sw ->
+
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let base_url =
+
match base_url with
+
| Some url -> url
+
| None ->
+
Keyeio.Profile.get profile ~key:"base_url"
+
|> Option.value ~default:"https://hoard.recoil.org"
+
in
+
+
try
+
let bookmark = Karakeepe.fetch_bookmark_details ~sw ~env ~api_key base_url bookmark_id in
+
+
Printf.printf "Bookmark: %s\n" bookmark.url;
+
Printf.printf "ID: %s\n" bookmark.id;
+
(match bookmark.title with
+
| Some title -> Printf.printf "Title: %s\n" title
+
| None -> ());
+
(match bookmark.note with
+
| Some note -> Printf.printf "Note: %s\n" note
+
| None -> ());
+
Printf.printf "Created: %s\n" (Ptime.to_rfc3339 bookmark.created_at);
+
(match bookmark.updated_at with
+
| Some t -> Printf.printf "Updated: %s\n" (Ptime.to_rfc3339 t)
+
| None -> ());
+
+
if bookmark.tags <> [] then
+
Printf.printf "Tags: %s\n" (String.concat ", " bookmark.tags);
+
+
if bookmark.archived then Printf.printf "Status: ARCHIVED\n";
+
if bookmark.favourited then Printf.printf "Status: FAVOURITED\n";
+
+
(match bookmark.summary with
+
| Some s when s <> "" -> Printf.printf "\nSummary:\n%s\n" s
+
| _ -> ());
+
+
if bookmark.content <> [] then begin
+
Printf.printf "\nContent metadata:\n";
+
List.iter (fun (k, v) ->
+
if v <> "null" && v <> "" then
+
Printf.printf " %s: %s\n" k v
+
) bookmark.content
+
end;
+
+
if bookmark.assets <> [] then begin
+
Printf.printf "\nAssets:\n";
+
List.iter (fun (id, asset_type) ->
+
Printf.printf " %s (%s)\n" id asset_type;
+
Printf.printf " URL: %s\n" (Karakeepe.get_asset_url base_url id)
+
) bookmark.assets
+
end;
+
+
0
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
1
+
+
(** Create a new bookmark *)
+
let create_bookmark env _xdg profile base_url url title note tags archived favourited =
+
Eio.Switch.run @@ fun sw ->
+
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let base_url =
+
match base_url with
+
| Some url -> url
+
| None ->
+
Keyeio.Profile.get profile ~key:"base_url"
+
|> Option.value ~default:"https://hoard.recoil.org"
+
in
+
+
try
+
Printf.printf "Creating bookmark: %s\n" url;
+
+
let tags_opt = if tags = [] then None else Some tags in
+
+
let bookmark = Karakeepe.create_bookmark
+
~sw ~env ~api_key
+
~url
+
?title
+
?note
+
?tags:tags_opt
+
~archived
+
~favourited
+
base_url
+
in
+
+
Printf.printf "โœ“ Bookmark created successfully!\n";
+
Printf.printf "ID: %s\n" bookmark.id;
+
Printf.printf "URL: %s\n" bookmark.url;
+
(match bookmark.title with
+
| Some t -> Printf.printf "Title: %s\n" t
+
| None -> ());
+
if bookmark.tags <> [] then
+
Printf.printf "Tags: %s\n" (String.concat ", " bookmark.tags);
+
0
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
1
+
+
(** Search bookmarks by tag *)
+
let search_bookmarks env __xdg profile base_url tags limit =
+
Eio.Switch.run @@ fun sw ->
+
+
let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in
+
let base_url =
+
match base_url with
+
| Some url -> url
+
| None ->
+
Keyeio.Profile.get profile ~key:"base_url"
+
|> Option.value ~default:"https://hoard.recoil.org"
+
in
+
+
if tags = [] then begin
+
Printf.eprintf "Error: At least one tag is required for search\n";
+
1
+
end else begin
+
try
+
Printf.printf "Searching for bookmarks with tags: %s\n" (String.concat ", " tags);
+
+
let bookmarks = Karakeepe.fetch_all_bookmarks
+
~sw ~env ~api_key
+
~page_size:limit
+
~filter_tags:tags
+
~include_content:true
+
base_url
+
in
+
+
Printf.printf "Found %d bookmarks\n\n" (List.length bookmarks);
+
+
List.iteri (fun i (b : Karakeepe.bookmark) ->
+
Printf.printf "%d. %s\n" (i + 1) b.url;
+
(match b.title with
+
| Some title -> Printf.printf " Title: %s\n" title
+
| None -> ());
+
Printf.printf " ID: %s\n" b.id;
+
Printf.printf " Tags: %s\n" (String.concat ", " b.tags);
+
Printf.printf "\n"
+
) bookmarks;
+
0
+
with exn ->
+
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
+
1
+
end
+
+
(** Command-line arguments *)
+
+
let base_url_arg =
+
let doc = "Override Karakeep instance base URL" in
+
Arg.(value & opt (some string) None & info ["url"; "u"] ~docv:"URL" ~doc)
+
+
let limit_arg =
+
let doc = "Maximum number of bookmarks to fetch" in
+
Arg.(value & opt int 50 & info ["limit"; "l"] ~docv:"NUM" ~doc)
+
+
let archived_arg =
+
let doc = "Filter by archived status (true/false)" in
+
Arg.(value & opt (some bool) None & info ["archived"; "a"] ~docv:"BOOL" ~doc)
+
+
let favourited_arg =
+
let doc = "Filter by favourited status (true/false)" in
+
Arg.(value & opt (some bool) None & info ["favourited"; "f"] ~docv:"BOOL" ~doc)
+
+
let tags_arg =
+
let doc = "Filter by tags (comma-separated)" in
+
Arg.(value & opt (list ~sep:',' string) [] & info ["tags"; "t"] ~docv:"TAGS" ~doc)
+
+
let bookmark_id_arg =
+
let doc = "Bookmark ID" in
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"ID" ~doc)
+
+
let url_arg =
+
let doc = "URL to bookmark" in
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"URL" ~doc)
+
+
let title_arg =
+
let doc = "Bookmark title" in
+
Arg.(value & opt (some string) None & info ["title"] ~docv:"TITLE" ~doc)
+
+
let note_arg =
+
let doc = "Bookmark note/description" in
+
Arg.(value & opt (some string) None & info ["note"; "n"] ~docv:"NOTE" ~doc)
+
+
let create_tags_arg =
+
let doc = "Tags to add (comma-separated)" in
+
Arg.(value & opt (list ~sep:',' string) [] & info ["tags"; "t"] ~docv:"TAGS" ~doc)
+
+
let create_archived_flag =
+
let doc = "Mark bookmark as archived" in
+
Arg.(value & flag & info ["archived"; "a"] ~doc)
+
+
let create_favourited_flag =
+
let doc = "Mark bookmark as favourited" in
+
Arg.(value & flag & info ["favourited"; "f"] ~doc)
+
+
(** Main commands *)
+
+
(* Init command - doesn't use eiocmd since it's creating the profile *)
+
let init_cmd =
+
let doc = "Initialize karakeepe credentials" in
+
let man = [
+
`S Manpage.s_description;
+
`P "Create a new credentials file for karakeepe in the XDG config directory.";
+
`P "This will create ~/.config/karakeepe/keys/karakeepe.toml with the specified profile.";
+
`S Manpage.s_examples;
+
`P "Initialize with prompts:";
+
`Pre " karakeepe init";
+
`P "Initialize with command-line arguments:";
+
`Pre " karakeepe init --api-key \"ak1_xxx\" --base-url \"https://hoard.example.com\"";
+
`P "Initialize a production profile:";
+
`Pre " karakeepe init --profile production --api-key \"ak1_xxx\"";
+
] in
+
let default_data = [
+
("api_key", None); (* Will prompt if not provided *)
+
("base_url", Some "https://hoard.recoil.org") (* Has default *)
+
] in
+
let init_term =
+
Eio_main.run @@ fun env ->
+
Keyeio.Cmd.create_term
+
~app_name:"karakeepe"
+
~fs:env#fs
+
~service:"karakeepe"
+
~default_data
+
()
+
in
+
let info = Cmd.info "init" ~doc ~man in
+
Cmd.v info init_term
+
+
(* List command *)
+
let list_cmd =
+
let doc = "List bookmarks" in
+
Eiocmd.run
+
~info:(Cmd.info "list" ~doc)
+
~app_name:"karakeepe"
+
~service:"karakeepe"
+
Term.(const (fun base_url limit archived favourited tags env xdg profile ->
+
list_bookmarks env xdg profile base_url limit archived favourited tags)
+
$ base_url_arg $ limit_arg $ archived_arg $ favourited_arg $ tags_arg)
+
+
(* Get command *)
+
let get_cmd =
+
let doc = "Get a single bookmark by ID" in
+
Eiocmd.run
+
~info:(Cmd.info "get" ~doc)
+
~app_name:"karakeepe"
+
~service:"karakeepe"
+
Term.(const (fun base_url bookmark_id env xdg profile ->
+
get_bookmark env xdg profile base_url bookmark_id)
+
$ base_url_arg $ bookmark_id_arg)
+
+
(* Create command *)
+
let create_cmd =
+
let doc = "Create a new bookmark" in
+
let man = [
+
`S Manpage.s_description;
+
`P "Create a new bookmark in Karakeep.";
+
`S Manpage.s_examples;
+
`P "Create a simple bookmark:";
+
`Pre " karakeepe create \"https://example.com\"";
+
`P "Create with title and tags:";
+
`Pre " karakeepe create \"https://example.com\" --title \"Example Site\" --tags \"web,example\"";
+
`P "Create as favourited:";
+
`Pre " karakeepe create \"https://example.com\" --favourited";
+
] in
+
Eiocmd.run
+
~info:(Cmd.info "create" ~doc ~man)
+
~app_name:"karakeepe"
+
~service:"karakeepe"
+
Term.(const (fun base_url url title note tags archived favourited env xdg profile ->
+
create_bookmark env xdg profile base_url url title note tags archived favourited)
+
$ base_url_arg $ url_arg $ title_arg $ note_arg $ create_tags_arg $ create_archived_flag $ create_favourited_flag)
+
+
(* Search command *)
+
let search_cmd =
+
let doc = "Search bookmarks by tags" in
+
let man = [
+
`S Manpage.s_description;
+
`P "Search for bookmarks by tags.";
+
`S Manpage.s_examples;
+
`P "Search for bookmarks with tag 'work':";
+
`Pre " karakeepe search --tags work";
+
`P "Search for bookmarks with multiple tags:";
+
`Pre " karakeepe search --tags \"work,important\"";
+
] in
+
Eiocmd.run
+
~info:(Cmd.info "search" ~doc ~man)
+
~app_name:"karakeepe"
+
~service:"karakeepe"
+
Term.(const (fun base_url tags limit env xdg profile ->
+
search_bookmarks env xdg profile base_url tags limit)
+
$ base_url_arg $ tags_arg $ limit_arg)
+
+
(* Main command *)
+
let () =
+
let doc = "Karakeep command-line client" in
+
let man = [
+
`S Manpage.s_description;
+
`P "A command-line client for managing bookmarks in Karakeep (Hoarder).";
+
`P "Credentials are stored securely using keyeio in XDG config directories.";
+
`S Manpage.s_commands;
+
`S "CONFIGURATION";
+
`P "Initialize credentials using:";
+
`Pre " karakeepe init";
+
`P "Or manually create ~/.config/karakeepe/keys/karakeepe.toml:";
+
`Pre "[default]\napi_key = \"ak1_<key_id>_<secret>\"\nbase_url = \"https://hoard.example.com\"";
+
`S Manpage.s_bugs;
+
`P "Report bugs at https://github.com/avsm/knot/issues";
+
] in
+
let info = Cmd.info "karakeepe" ~version:"0.1.0" ~doc ~man in
+
let main_cmd = Cmd.group info [init_cmd; list_cmd; get_cmd; create_cmd; search_cmd] in
+
+
exit (Cmd.eval' main_cmd)