add contacts skill with CardDAV CLI script

This commit is contained in:
Connor Rhodes 2026-05-15 13:26:03 -05:00
parent b2f1905e43
commit 6d9cd18292
3 changed files with 376 additions and 0 deletions

View file

@ -40,6 +40,7 @@ description: Master index of all skills in your robot assistant system. Your ass
| "write a field post," "se in the wild," "site visit post," "customer visit writeup," "onsite recap," "field post" | **se-in-the-wild** | | "write a field post," "se in the wild," "site visit post," "customer visit writeup," "onsite recap," "field post" | **se-in-the-wild** |
| "add this to my brag sheet," "log this kudos," "save this feedback," "add to brag sheet," "log a win" | **brag-sheet** | | "add this to my brag sheet," "log this kudos," "save this feedback," "add to brag sheet," "log a win" | **brag-sheet** |
| "support punt," "forward to support," "send to support," "punt this to support," "hand off to support" | **send-to-support** | | "support punt," "forward to support," "send to support," "punt this to support," "hand off to support" | **send-to-support** |
| "add a contact," "look up a contact," "find someone's number," "update a contact," "delete a contact," "list my contacts," "contacts" | **contacts** |
--- ---
@ -207,6 +208,12 @@ description: Master index of all skills in your robot assistant system. Your ass
**File:** `skills/send-to-support/SKILL.md` **File:** `skills/send-to-support/SKILL.md`
**Dependencies:** None **Dependencies:** None
### Contacts
**Purpose:** Manage contacts via a self-hosted Radicale CardDAV server. Create, search, update, and delete contacts using a CLI script that handles vCard formatting, duplicate detection, and addressbook discovery.
**Triggers:** "add a contact," "look up a contact," "find someone's number," "update a contact," "delete a contact," "list my contacts," "contacts"
**File:** `skills/contacts/SKILL.md`
**Dependencies:** `uv` CLI, Python 3.12+, `contacts` script at `skills/contacts/scripts/contacts`
--- ---
## Adding New Skills ## Adding New Skills

79
contacts/SKILL.md Normal file
View file

@ -0,0 +1,79 @@
---
name: contacts
description: Use this skill when the user wants to add, find, list, update, or delete contacts in their Radicale CardDAV server. Trigger on mentions of "contact", "contacts", "address book", "phone number" (in the context of contacts), or when the user asks to look up someone's info, add someone to their contacts, or update contact details.
---
# Contact Management
Manage contacts via the Radicale CardDAV server using the self-contained CLI script at `$HOME/notes/skills/contacts/scripts/contacts`.
## Commands
### Create a contact
Requires `--name` plus at least one of `--phone`, `--email`, or `--note`.
The script checks for duplicates before creating:
- If `--phone` is provided, it checks for an existing contact with that phone number
- If `--email` is provided, it checks for an existing contact with that email
- If only `--name` and `--note` are provided, it checks for an existing contact with that exact name
If a duplicate is found, the script prints the existing contact and exits with an error.
```
$HOME/notes/skills/contacts/scripts/contacts create --name "Jane Smith" --phone "+15551234567"
$HOME/notes/skills/contacts/scripts/contacts create --name "John Doe" --email "john@example.com" --note "Met at conference"
$HOME/notes/skills/contacts/scripts/contacts create --name "Alice" --phone "+15559876543" --email "alice@example.com" --note "Work colleague"
```
### Read contacts
List all contacts:
```
$HOME/notes/skills/contacts/scripts/contacts read
```
Filter by name (case-insensitive substring match):
```
$HOME/notes/skills/contacts/scripts/contacts read --name "Sonny"
```
Filter by phone number (substring match):
```
$HOME/notes/skills/contacts/scripts/contacts read --phone "+1915"
```
Filter by email (case-insensitive substring match):
```
$HOME/notes/skills/contacts/scripts/contacts read --email "example.com"
```
Multiple filters act as OR (any match is returned).
### Update a contact
Requires `--find` (search term to identify the contact) plus at least one field to update.
`--find` searches across name, phone, and email (case-insensitive substring). If multiple contacts match, the script prints them and asks the user to be more specific.
```
$HOME/notes/skills/contacts/scripts/contacts update --find "Jane" --phone "+15551112222"
$HOME/notes/skills/contacts/scripts/contacts update --find "+19155257466" --note "Childhood friend"
$HOME/notes/skills/contacts/scripts/contacts update --find "Jane Smith" --name "Jane Doe" --email "jane.doe@example.com"
```
### Delete a contact
Requires `--find` (same search behavior as update). Prompts for confirmation unless `-y` is passed.
```
$HOME/notes/skills/contacts/scripts/contacts delete --find "Jane Smith"
$HOME/notes/skills/contacts/scripts/contacts delete --find "+15551234567" -y
```
## Notes
- The script is fully self-contained. Credentials are embedded — no external config files or environment variables needed.
- The script auto-discovers the addressbook collection via CardDAV PROPFIND.
- Always use the script instead of direct HTTP requests to Radicale. It handles duplicate checking, vCard formatting, and CardDAV protocol.
- When the user asks to "look up" someone, use `read` with a filter. For "add" or "save", use `create`. To change existing info, use `update`.

290
contacts/scripts/contacts Executable file
View file

@ -0,0 +1,290 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["requests"]
# ///
import argparse
import sys
import uuid
from datetime import datetime, timezone
from xml.etree import ElementTree
import requests
BASE_URL = "https://cc.connorrhodes.com"
USERNAME = "connor"
PASSWORD = "xK9mN2pQ7vR8wL4jT6hY3sF5dG0nZ1bC"
def get_session():
s = requests.Session()
s.auth = (USERNAME, PASSWORD)
return s
def discover_addressbook(session):
principal_url = f"{BASE_URL}/{USERNAME}/"
headers = {"Depth": "1", "Content-Type": "application/xml"}
body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>'
resp = session.request("PROPFIND", principal_url, headers=headers, data=body)
tree = ElementTree.fromstring(resp.text)
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
for resp_elem in tree.findall("d:response", ns):
href = resp_elem.find("d:href", ns).text
propstat = resp_elem.find("d:propstat", ns)
if propstat is not None:
prop = propstat.find("d:prop", ns)
if prop is not None:
rt = prop.find("d:resourcetype", ns)
if rt is not None and rt.find("card:addressbook", ns) is not None:
return href
return None
def fetch_all_contacts(session, addressbook_href):
url = f"{BASE_URL}{addressbook_href}"
headers = {"Depth": "1", "Content-Type": "application/xml"}
body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>'
resp = session.request("PROPFIND", url, headers=headers, data=body)
tree = ElementTree.fromstring(resp.text)
ns = {"d": "DAV:"}
contacts = []
for resp_elem in tree.findall("d:response", ns):
href = resp_elem.find("d:href", ns).text
if href.endswith(".vcf"):
vcf_resp = session.get(f"{BASE_URL}{href}")
contact = parse_vcard(vcf_resp.text)
contact["_href"] = href
contacts.append(contact)
return contacts
def parse_vcard(text):
contact = {"fn": "", "phone": "", "email": "", "note": "", "uid": ""}
unfolded = []
for line in text.splitlines():
if line.startswith(" ") or line.startswith("\t"):
if unfolded:
unfolded[-1] += line[1:]
else:
unfolded.append(line)
for line in unfolded:
if line.startswith("FN:"):
contact["fn"] = line[3:]
elif line.startswith("TEL"):
if ":" in line:
contact["phone"] = line.split(":", 1)[1]
elif line.startswith("EMAIL"):
if ":" in line:
contact["email"] = line.split(":", 1)[1]
elif line.startswith("NOTE:"):
contact["note"] = line[5:]
elif line.startswith("UID:"):
contact["uid"] = line[4:]
return contact
def build_vcard(fn, phone="", email="", note="", uid=None):
if not uid:
uid = str(uuid.uuid4())
parts = fn.strip().rsplit(" ", 1)
last = parts[1] if len(parts) > 1 else ""
first = parts[0]
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"UID:{uid}",
f"FN:{fn}",
f"N:{last};{first};;;",
]
if phone:
lines.append(f"TEL;TYPE=cell:{phone}")
if email:
lines.append(f"EMAIL:{email}")
if note:
lines.append(f"NOTE:{note}")
lines.append(f"REV:{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def print_contact(contact):
print(f" Name: {contact['fn']}")
if contact["phone"]:
print(f" Phone: {contact['phone']}")
if contact["email"]:
print(f" Email: {contact['email']}")
if contact["note"]:
print(f" Note: {contact['note']}")
print()
def find_duplicates(contacts, name=None, phone=None, email=None):
dupes = []
for c in contacts:
if phone and c["phone"] == phone:
dupes.append(c)
continue
if email and c["email"].lower() == email.lower():
dupes.append(c)
continue
if name and not phone and not email and c["fn"].lower() == name.lower():
dupes.append(c)
return dupes
def search_contacts(contacts, name=None, phone=None, email=None):
results = []
for c in contacts:
match = False
if name and name.lower() in c["fn"].lower():
match = True
if phone and phone in c["phone"]:
match = True
if email and email.lower() in c["email"].lower():
match = True
if match:
results.append(c)
return results
def cmd_create(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
dupes = find_duplicates(contacts, name=args.name, phone=args.phone, email=args.email)
if dupes:
print("Contact already exists:", file=sys.stderr)
for d in dupes:
print_contact(d)
sys.exit(1)
uid = str(uuid.uuid4())
vcard = build_vcard(args.name, phone=args.phone or "", email=args.email or "", note=args.note or "", uid=uid)
url = f"{BASE_URL}{addressbook_href}{uid}.vcf"
resp = session.put(url, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"})
if resp.ok:
print(f"Created contact: {args.name}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def cmd_read(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
if args.name or args.phone or args.email:
contacts = search_contacts(contacts, name=args.name, phone=args.phone, email=args.email)
if not contacts:
print("No contacts found.")
return
for c in contacts:
print_contact(c)
def cmd_update(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
matches = search_contacts(contacts, name=args.find, phone=args.find, email=args.find)
if not matches:
print(f"No contact found matching '{args.find}'.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Multiple contacts match '{args.find}', be more specific:", file=sys.stderr)
for m in matches:
print_contact(m)
sys.exit(1)
contact = matches[0]
new_fn = args.name if args.name else contact["fn"]
new_phone = args.phone if args.phone else contact["phone"]
new_email = args.email if args.email else contact["email"]
new_note = args.note if args.note else contact["note"]
vcard = build_vcard(new_fn, phone=new_phone, email=new_email, note=new_note, uid=contact["uid"])
url = f"{BASE_URL}{contact['_href']}"
resp = session.put(url, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"})
if resp.ok:
print(f"Updated contact: {new_fn}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def cmd_delete(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
matches = search_contacts(contacts, name=args.find, phone=args.find, email=args.find)
if not matches:
print(f"No contact found matching '{args.find}'.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Multiple contacts match '{args.find}', be more specific:", file=sys.stderr)
for m in matches:
print_contact(m)
sys.exit(1)
contact = matches[0]
if not args.yes:
print("About to delete:")
print_contact(contact)
answer = input("Confirm? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
print("Cancelled.")
return
url = f"{BASE_URL}{contact['_href']}"
resp = session.delete(url)
if resp.ok:
print(f"Deleted contact: {contact['fn']}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Manage Radicale contacts")
sub = parser.add_subparsers(dest="command")
p_create = sub.add_parser("create", help="Create a new contact")
p_create.add_argument("--name", required=True, help="Contact name (required)")
p_create.add_argument("--phone", help="Phone number")
p_create.add_argument("--email", help="Email address")
p_create.add_argument("--note", help="Note")
p_read = sub.add_parser("read", help="Read contacts")
p_read.add_argument("--name", help="Filter by name")
p_read.add_argument("--phone", help="Filter by phone")
p_read.add_argument("--email", help="Filter by email")
p_update = sub.add_parser("update", help="Update a contact")
p_update.add_argument("--find", required=True, help="Search term to identify contact")
p_update.add_argument("--name", help="New name")
p_update.add_argument("--phone", help="New phone number")
p_update.add_argument("--email", help="New email address")
p_update.add_argument("--note", help="New note")
p_delete = sub.add_parser("delete", help="Delete a contact")
p_delete.add_argument("--find", required=True, help="Search term to identify contact")
p_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if args.command == "create" and not any([args.phone, args.email, args.note]):
print("Error: at least one of --phone, --email, or --note is required", file=sys.stderr)
sys.exit(1)
if args.command == "update" and not any([args.name, args.phone, args.email, args.note]):
print("Error: provide at least one field to update (--name, --phone, --email, --note)", file=sys.stderr)
sys.exit(1)
session = get_session()
addressbook_href = discover_addressbook(session)
if not addressbook_href:
print("Error: could not discover addressbook", file=sys.stderr)
sys.exit(1)
{"create": cmd_create, "read": cmd_read, "update": cmd_update, "delete": cmd_delete}[args.command](args, session, addressbook_href)
if __name__ == "__main__":
main()