add contacts skill with CardDAV CLI script
This commit is contained in:
parent
b2f1905e43
commit
6d9cd18292
3 changed files with 376 additions and 0 deletions
|
|
@ -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** |
|
||||
| "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** |
|
||||
| "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`
|
||||
**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
|
||||
|
|
|
|||
79
contacts/SKILL.md
Normal file
79
contacts/SKILL.md
Normal 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
290
contacts/scripts/contacts
Executable 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue