Skill: xz-cn/clawdbot-crm-skill (version 0.1.1)

--- name: crm description: Personal CRM for managing contacts, relationships, and follow-ups using markdown files. Use when the user wants to add contacts, track relationships, set follow-up reminders, query contacts by tag/company/location, import/export contacts, or manage networking leads. Supports natural language input for adding contacts. --- # Personal CRM Skill Manage contacts, relationships, and follow-ups with markdown files. No database needed. ## Setup Initialize contacts inside memory/ for semantic search integration: ```bash mkdir -p memory/contacts/{people,companies,events,_templates,scripts} cp skills/crm/assets/templates/*.md memory/contacts/_templates/ cp skills/crm/scripts/*.py memory/contacts/scripts/ clawdbot memory index ``` ## Natural Language Input When user describes a contact naturally, extract and create: **User says:** "Met Sarah Lee at Web3 summit. She's head of partnerships at Polygon, based in Dubai. Telegram @sarahlee" **Extract:** ```yaml name: Sarah Lee company: Polygon role: Head of Partnerships location: Dubai telegram: "@sarahlee" met_at: web3-summit tags: [web3, partnership] ``` **Then run:** `memory/contacts/memory/contacts/scripts/crm-add.py person "Sarah Lee" --company "Polygon" --role "Head of Partnerships" --location "Dubai" --telegram "@sarahlee" --tags "web3,partnership"` ## Scripts All scripts use `/usr/bin/python3` and require PyYAML. ### Query Contacts ```bash memory/contacts/scripts/crm-query.py --list people # List all people memory/contacts/scripts/crm-query.py --tag investor # Filter by tag memory/contacts/scripts/crm-query.py --company "Acme" # Filter by company memory/contacts/scripts/crm-query.py --introduced-by bob # Find introductions memory/contacts/scripts/crm-query.py --location singapore # Filter by location memory/contacts/scripts/crm-query.py --search "supply chain" # Full-text search memory/contacts/scripts/crm-query.py -v # Verbose output ``` ### Add Contact ```bash memory/contacts/scripts/crm-add.py person "Name" --company "Co" --role "Title" --tags "a,b" memory/contacts/scripts/crm-add.py company "Company Name" --industry "Tech" memory/contacts/scripts/crm-add.py event "Event Name" --date 2026-03-15 --location "City" ``` ### Update Contact ```bash memory/contacts/scripts/crm-update.py alice-chen --interaction "Called about demo" memory/contacts/scripts/crm-update.py alice-chen --set-follow-up 2026-02-15 memory/contacts/scripts/crm-update.py alice-chen --add-tag vip memory/contacts/scripts/crm-update.py alice-chen --touch # Update last_contact to today ``` ### Follow-ups & Reminders ```bash memory/contacts/scripts/crm-followups.py --summary # Quick summary memory/contacts/scripts/crm-followups.py --days 7 # Due in 7 days memory/contacts/scripts/crm-followups.py --dormant 90 # Not contacted in 90 days memory/contacts/scripts/crm-remind.py contact --in 3d # Remind in 3 days memory/contacts/scripts/crm-remind.py --list # List reminders memory/contacts/scripts/crm-remind.py --check # Check due reminders ``` ### Import/Export ```bash memory/contacts/scripts/crm-import.py contacts.csv # Import CSV memory/contacts/scripts/crm-import.py contacts.vcf # Import vCard memory/contacts/scripts/crm-import.py linkedin.csv --format linkedin # LinkedIn export memory/contacts/scripts/crm-export.py --csv out.csv # Export CSV memory/contacts/scripts/crm-export.py --vcard out.vcf --type people # Export vCard ``` ### Regenerate Index ```bash memory/contacts/scripts/crm-index.py # Rebuilds contacts/_index.md ``` ## Contact Schema ```yaml --- name: Alice Chen type: person # person | company | event tags: [investor, crypto] company: Acme Corp role: Partner email: [REDACTED_SECRET] phone: +1-555-0123 telegram: "@alice" twitter: "@alice" linkedin: https://linkedin.com/in/alice location: Singapore introduced_by: bob-smith # slug of introducer met_at: token2049-2025 # slug of event first_contact: 2025-09-20 last_contact: 2026-01-27 follow_up: 2026-02-15 status: active # active | dormant | archived --- # Alice Chen ## Context Partner at Acme Corp. Met through Bob at Token2049. ## Interactions - **2026-01-27**: Called about pilot program. - **2025-09-20**: First met at conference. ## Notes - Prefers Signal over email ``` ## Folder Structure ``` memory/ └── contacts/ # Inside memory/ for semantic search ├── people/ # One file per person ├── companies/ # One file per company ├── events/ # One file per event ├── _templates/ # Templates for new contacts ├── _index.md # Auto-generated lookup table ├── .reminders.json # Reminder data └── scripts/ # CLI tools ``` ## Cross-References - **YAML fields:** `introduced_by: bob-smith`, `met_at: event-slug` - **Wiki-links in notes:** `[[bob-smith]]`, `[[acme-corp]]` - **Semantic search:** Memory search finds connections in prose ## Heartbeat Integration Add to HEARTBEAT.md: ```markdown ### CRM Follow-ups (check 1-2x daily) 1. Run: `/usr/bin/python3 contacts/memory/contacts/scripts/crm-followups.py --summary` 2. Run: `/usr/bin/python3 contacts/memory/contacts/scripts/crm-remind.py --check` If there are due items, notify the user. ``` ## Tips - Use `--dry-run` on import to preview before creating files - Run `crm-index.py` after bulk changes to update the index - Tags are lowercase, comma-separated - Slugs are auto-generated from names (lowercase, hyphenated) - For natural language input, extract fields and use crm-add.py

path: MANIFEST.md
content: # skill: crm
# date: 2026-03-02T02:03:51Z
# version: 1.0.1
# author: alfred#6765.agent
f69473dad3c6db27cd889e83fbb3aa691a4a3d93222b6e401a91083430591c8a  ./assets/templates/company.md
d5c951d0257b0ffe0b62c261dc9814491df77bedd49cb89f13897853e49b4216  ./assets/templates/event.md
5442a3942976e8e9282faab15ded0806752b94d8458ea3c36c28d20a7f06c1c4  ./assets/templates/person.md
3f865846e1ce9681d3048dd6e009da17d98c912cc5fafc5d105ec2733f0db1bf  ./README.md
fb03d3208f1d6e023b25b0328ba4230486e001bcc7aab56c56fc91d855749816  ./references/schema.md
189273279387ea5d60775f67ea80ff793fc7c73084bacd53047aadd94e16db06  ./scripts/crm-add.py
87c89d8015aea4f5ba76dbb77342d5dd5d5008b3d2a6932d2fa46cafe00c7a83  ./scripts/crm-export.py
2d04cdbb0c831638a0b4bafeaa5c6ec271fb238104f54f398d0f67f989dcf560  ./scripts/crm-followups.py
dff22a6b7e8d69402202b7c42462cd05971561541a7c9e4df80fc634373f943c  ./scripts/crm-import.py
23320e79cd4896022270ef3128a27896243ba40e98c6ae67a67b1f404ef359c6  ./scripts/crm-index.py
accc0ee479686daabc5219b9d91375a54858efd9ba78ae06b34fcd0aceb3b914  ./scripts/crm-query.py
077efc523c5e3d02b6e8b0bb6683914bba87b9fcabaa94ee4a1b001082b9b141  ./scripts/crm-remind.py
56ca8d214b2a1975cfdf40d1770cdab825a2ad10f4cd4624f6a22085498ce2dc  ./scripts/crm-update.py
091cd963b3dddfe57edceaa706d03fe278169ce06d48f28ccf3ecd310c8f59ed  ./SKILL.md


path: README.md
content: # 📇 Personal CRM Skill for Clawdbot

A lightweight, markdown-based personal CRM for managing contacts, relationships, and follow-ups. No database needed — just files.

## Features

- **Markdown-based** — Human-readable, git-friendly, portable
- **Query contacts** — Filter by tag, company, location, relationships
- **Track interactions** — Log meetings, calls, notes with timestamps
- **Follow-up reminders** — Never forget to reach out
- **Dormant detection** — Know who you haven't contacted in a while
- **Import/Export** — CSV, vCard, LinkedIn export support
- **Natural language** — Tell your AI about contacts conversationally
- **Heartbeat integration** — Periodic checks for due follow-ups

## Installation

```bash
# Clone into your Clawdbot skills folder
git clone https://github.com/xz-cn/clawdbot-crm-skill.git skills/crm

# Initialize contacts folder inside memory/ (for semantic search integration)
mkdir -p memory/contacts/{people,companies,events,_templates,scripts}
cp skills/crm/assets/templates/*.md memory/contacts/_templates/
cp skills/crm/scripts/*.py memory/contacts/scripts/

# Reindex memory to include contacts
clawdbot memory index
```

> **Why `memory/contacts/`?** Placing contacts inside `memory/` enables Clawdbot's semantic search (`memory_search`) to find contacts automatically. Ask "who knows about AI?" and it just works.

**Requirements:** Python 3.10+ with PyYAML (`apt install python3-yaml` or `pip install pyyaml`)

## Quick Start

### Add a contact
```bash
python memory/contacts/scripts/crm-add.py person "Alice Chen" \
  --company "Acme Corp" \
  --role "CEO" \
  --email "[REDACTED_SECRET]" \
  --tags "investor,crypto"
```

### Query contacts
```bash
python memory/contacts/scripts/crm-query.py --tag investor        # By tag
python memory/contacts/scripts/crm-query.py --company "Acme"      # By company
python memory/contacts/scripts/crm-query.py --location singapore  # By location
python memory/contacts/scripts/crm-query.py --search "supply chain"  # Full-text
```

### Log an interaction
```bash
python memory/contacts/scripts/crm-update.py alice-chen --interaction "Coffee meeting, discussed partnership"
```

### Check follow-ups
```bash
python memory/contacts/scripts/crm-followups.py --summary         # Quick overview
python memory/contacts/scripts/crm-followups.py --days 7          # Due this week
python memory/contacts/scripts/crm-followups.py --dormant 90      # Not contacted in 90 days
```

### Set reminders
```bash
python memory/contacts/scripts/crm-remind.py alice-chen --in 3d --message "Follow up on proposal"
python memory/contacts/scripts/crm-remind.py --list               # View pending
python memory/contacts/scripts/crm-remind.py --check              # Check due now
```

### Import/Export
```bash
python memory/contacts/scripts/crm-import.py contacts.csv         # Import CSV
python memory/contacts/scripts/crm-import.py contacts.vcf         # Import vCard
python memory/contacts/scripts/crm-export.py --csv backup.csv     # Export to CSV
python memory/contacts/scripts/crm-export.py --vcard contacts.vcf # Export to vCard
```

## Contact Schema

```yaml
---
name: Alice Chen
type: person
tags: [investor, crypto, singapore]
company: Acme Corp
role: CEO
email: [REDACTED_SECRET]
telegram: "@alice"
location: Singapore
introduced_by: bob-smith
met_at: token2049-2025
first_contact: 2025-09-20
last_contact: 2026-01-27
follow_up: 2026-02-15
status: active
---

# Alice Chen

## Context
CEO of Acme Corp. Met through Bob at Token2049.

## Interactions
- **2026-01-27**: Coffee meeting, discussed partnership.
- **2025-09-20**: First met at conference.

## Notes
- Prefers Signal over email
- Early crypto investor
```

## Folder Structure

```
memory/
└── contacts/             # Inside memory/ for semantic search
    ├── people/           # One file per person
    │   ├── alice-chen.md
    │   └── bob-smith.md
    ├── companies/        # One file per company
    │   └── acme-corp.md
    ├── events/           # One file per event
    │   └── token2049-2025.md
    ├── _templates/       # Templates for new contacts
    ├── _index.md         # Auto-generated lookup
    └── scripts/          # CLI tools
```

## Natural Language Input

When using with Clawdbot, just describe contacts naturally:

> "Met Sarah Lee at the Web3 summit. She's head of partnerships at Polygon, based in Dubai. Got her on Telegram @sarahlee"

The AI extracts structured data and creates the contact file automatically.

## Heartbeat Integration

Add to your `HEARTBEAT.md`:

```markdown
### CRM Follow-ups (check 1-2x daily)
1. Run: `python memory/contacts/scripts/crm-followups.py --summary`
2. Run: `python memory/contacts/scripts/crm-remind.py --check`
If there are due items, notify the user.
```

## Scripts Reference

| Script | Purpose |
|--------|---------|
| `crm-query.py` | Search and filter contacts |
| `crm-add.py` | Add new contacts |
| `crm-update.py` | Update contacts, log interactions |
| `crm-index.py` | Regenerate the index file |
| `crm-followups.py` | Check due follow-ups and dormant contacts |
| `crm-remind.py` | Manage reminders |
| `crm-import.py` | Import from CSV/vCard |
| `crm-export.py` | Export to CSV/vCard/Markdown |

## Verification

This skill is cryptographically signed using [ATP](https://github.com/zCloak-Network/ATP) Kind 11 (Document Signature).

| Field | Value |
|-------|-------|
| **Signer** | `alfred#6765.agent` |
| **Signature Event** | `23133cacd7db1e1270f9605b29933d012d34b7eca335b2120b7221eecfbea000` |
| **Canister** | `jayj5-xyaaa-aaaam-qfinq-cai` (ICP mainnet) |

> **Note:** The signature covers `MANIFEST.sha256` (which hashes all other files). This README references the event ID for convenience but is not itself part of the signed content — re-generate and verify the manifest to confirm integrity.

**To verify:**
```bash
# 1. Check all files match the manifest
sha256sum -c MANIFEST.sha256

# 2. Get the manifest hash and compare with the on-chain event
sha256sum MANIFEST.sha256

# 3. Look up the signature event on-chain
dfx canister call jayj5-xyaaa-aaaam-qfinq-cai fetch_events_by_counter '(97, 98)' --ic
```

## License

MIT — Use freely, contributions welcome!

---

Built with 🎩 by [Alfred](https://github.com/xz-cn) for [Clawdbot](https://clawdbot.com)


path: assets/templates/company.md
content: ---
name: 
type: company
tags: []
industry: 
website: 
location: 
size: 
status: active
---

# {{name}}

## People
- [[person-name]] — Role

## Context


## Interactions


## Notes



path: assets/templates/event.md
content: ---
name: 
type: event
tags: []
date: 
end_date: 
location: 
website: 
---

# {{name}}

## People Met
- [[person-name]] — Context

## Notes



path: assets/templates/person.md
content: ---
name: 
type: person
tags: []
company: 
role: 
email: 
phone: 
telegram: 
twitter: 
linkedin: 
location: 
introduced_by: 
met_at: 
first_contact: 
last_contact: 
follow_up: 
status: active
---

# {{name}}

## Context


## Interactions


## Notes



path: references/schema.md
content: # CRM Schema Reference

## Person Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | ✓ | Full name |
| type | string | ✓ | Always "person" |
| tags | list | | Categorization tags |
| company | string | | Current employer (slug or name) |
| role | string | | Job title |
| email | string | | Email address |
| phone | string | | Phone number |
| telegram | string | | Telegram handle (with @) |
| twitter | string | | Twitter/X handle (with @) |
| linkedin | string | | LinkedIn profile URL |
| website | string | | Personal website |
| location | string | | City/Country |
| introduced_by | string | | Slug of person who introduced |
| met_at | string | | Slug of event where met |
| first_contact | date | | First interaction date (YYYY-MM-DD) |
| last_contact | date | | Most recent interaction (YYYY-MM-DD) |
| follow_up | date | | Next follow-up date (YYYY-MM-DD) |
| status | enum | | active, dormant, archived |

## Company Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | ✓ | Company name |
| type | string | ✓ | Always "company" |
| tags | list | | Categorization tags |
| industry | string | | Industry/sector |
| website | string | | Company website |
| location | string | | HQ location |
| size | string | | Employee count range |
| status | enum | | active, dormant, archived |

## Event Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | ✓ | Event name |
| type | string | ✓ | Always "event" |
| tags | list | | Categorization tags |
| date | date | ✓ | Event start date (YYYY-MM-DD) |
| end_date | date | | Event end date |
| location | string | | Event location |
| website | string | | Event website |

## Common Tags

### People
- `investor`, `vc`, `angel`
- `founder`, `ceo`, `cto`
- `client`, `prospect`, `lead`
- `friend`, `colleague`, `mentor`
- `partner`, `advisor`
- `developer`, `designer`, `marketing`

### Industries
- `crypto`, `web3`, `defi`
- `ai`, `ml`, `saas`
- `fintech`, `healthtech`
- `enterprise`, `startup`

### Locations
- Use city names: `singapore`, `san-francisco`, `london`
- Or regions: `asia`, `europe`, `us`

## Status Definitions

| Status | Meaning |
|--------|---------|
| active | Regular contact, engaged relationship |
| dormant | No contact in 90+ days, needs re-engagement |
| archived | Relationship ended, historical record only |

## Date Formats

All dates use ISO format: `YYYY-MM-DD`

Examples:
- `2026-01-27`
- `2025-12-31`

## File Naming

Files are named with slugified names:
- "Alice Chen" → `alice-chen.md`
- "Token2049 Singapore" → `token2049-singapore.md`
- "Acme Corp Inc." → `acme-corp-inc.md`

Slugification rules:
1. Lowercase
2. Replace non-alphanumeric with hyphens
3. Remove leading/trailing hyphens
4. Collapse multiple hyphens


path: scripts/crm-add.py
content: #!/usr/bin/python3
"""
CRM Add Tool - Add new contacts from command line or interactively
Usage:
    python crm-add.py person "Alice Chen" --company "Acme Corp" --tags investor,crypto
    python crm-add.py company "Acme Corp" --industry "Technology"
    python crm-add.py event "ETHDenver 2026" --date 2026-02-28
    python crm-add.py person "Bob Smith"  # Interactive mode for missing fields
"""

import argparse
import os
import sys
from pathlib import Path
from datetime import datetime
import re

CONTACTS_DIR = Path(__file__).parent.parent
TEMPLATES_DIR = CONTACTS_DIR / "_templates"


def slugify(name: str) -> str:
    """Convert a name to a filename-safe slug."""
    slug = name.lower()
    slug = re.sub(r"[^a-z0-9]+", "-", slug)
    slug = slug.strip("-")
    return slug


def get_output_dir(contact_type: str) -> Path:
    """Get the output directory for a contact type."""
    type_dirs = {
        "person": "people",
        "company": "companies",
        "event": "events"
    }
    dir_name = type_dirs.get(contact_type, contact_type + "s")
    return CONTACTS_DIR / dir_name


def prompt_if_missing(value: str | None, prompt: str, required: bool = False) -> str:
    """Prompt for a value if not provided."""
    if value:
        return value
    
    while True:
        result = input(f"{prompt}: ").strip()
        if result or not required:
            return result
        print("This field is required.")


def create_contact(
    contact_type: str,
    name: str,
    **fields
) -> Path:
    """Create a new contact file."""
    
    slug = slugify(name)
    output_dir = get_output_dir(contact_type)
    output_dir.mkdir(parents=True, exist_ok=True)
    
    filepath = output_dir / f"{slug}.md"
    
    if filepath.exists():
        print(f"Contact already exists: {filepath}", file=sys.stderr)
        overwrite = input("Overwrite? [y/N]: ").strip().lower()
        if overwrite != "y":
            sys.exit(1)
    
    # Build frontmatter
    today = datetime.now().strftime("%Y-%m-%d")
    
    fm_lines = [
        "---",
        f"name: {name}",
        f"type: {contact_type}",
    ]
    
    # Handle tags specially (convert comma-separated to list)
    tags = fields.pop("tags", None)
    if tags:
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(",")]
        fm_lines.append(f"tags: [{', '.join(tags)}]")
    else:
        fm_lines.append("tags: []")
    
    # Add other fields
    field_order = [
        "company", "role", "industry", "email", "phone", "telegram", 
        "twitter", "linkedin", "website", "location", "size",
        "introduced_by", "met_at", "date", "end_date",
        "first_contact", "last_contact", "follow_up", "status"
    ]
    
    for field in field_order:
        value = fields.get(field)
        if value:
            fm_lines.append(f"{field}: {value}")
    
    # Set defaults
    if "first_contact" not in fields:
        fm_lines.append(f"first_contact: {today}")
    if "last_contact" not in fields:
        fm_lines.append(f"last_contact: {today}")
    if "status" not in fields:
        fm_lines.append("status: active")
    
    fm_lines.append("---")
    
    # Build body
    body_lines = [
        "",
        f"# {name}",
        "",
        "## Context",
        "",
        "",
        "## Interactions",
        "",
        "",
        "## Notes",
        "",
        ""
    ]
    
    content = "\n".join(fm_lines + body_lines)
    filepath.write_text(content)
    
    return filepath


def main():
    parser = argparse.ArgumentParser(description="Add a new contact")
    parser.add_argument("type", choices=["person", "company", "event"],
                        help="Type of contact")
    parser.add_argument("name", help="Contact name")
    
    # Person fields
    parser.add_argument("--company", "-c", help="Company name")
    parser.add_argument("--role", "-r", help="Role/title")
    parser.add_argument("--email", "-e", help="Email address")
    parser.add_argument("--phone", help="Phone number")
    parser.add_argument("--telegram", help="Telegram handle")
    parser.add_argument("--twitter", help="Twitter handle")
    parser.add_argument("--linkedin", help="LinkedIn URL")
    
    # Company fields
    parser.add_argument("--industry", help="Industry (for companies)")
    parser.add_argument("--website", help="Website URL")
    parser.add_argument("--size", help="Company size")
    
    # Event fields
    parser.add_argument("--date", help="Event date (YYYY-MM-DD)")
    parser.add_argument("--end-date", help="Event end date (YYYY-MM-DD)")
    
    # Common fields
    parser.add_argument("--tags", "-t", help="Comma-separated tags")
    parser.add_argument("--location", "-l", help="Location")
    parser.add_argument("--introduced-by", help="Who introduced this contact")
    parser.add_argument("--met-at", help="Event where you met")
    parser.add_argument("--follow-up", help="Follow-up date (YYYY-MM-DD)")
    parser.add_argument("--status", choices=["active", "dormant", "archived"],
                        default="active", help="Contact status")
    
    # Options
    parser.add_argument("--interactive", "-i", action="store_true",
                        help="Prompt for missing fields")
    
    args = parser.parse_args()
    
    # Collect fields
    fields = {}
    for field in ["company", "role", "email", "phone", "telegram", "twitter",
                  "linkedin", "industry", "website", "size", "date", "end_date",
                  "tags", "location", "introduced_by", "met_at", "follow_up", "status"]:
        # Handle hyphens in arg names
        arg_name = field.replace("_", "-")
        value = getattr(args, field.replace("-", "_"), None)
        if value:
            fields[field] = value
    
    # Interactive mode
    if args.interactive:
        if args.type == "person":
            fields["company"] = prompt_if_missing(fields.get("company"), "Company")
            fields["role"] = prompt_if_missing(fields.get("role"), "Role")
            fields["email"] = prompt_if_missing(fields.get("email"), "Email")
            fields["tags"] = prompt_if_missing(fields.get("tags"), "Tags (comma-separated)")
            fields["location"] = prompt_if_missing(fields.get("location"), "Location")
        elif args.type == "company":
            fields["industry"] = prompt_if_missing(fields.get("industry"), "Industry")
            fields["website"] = prompt_if_missing(fields.get("website"), "Website")
            fields["location"] = prompt_if_missing(fields.get("location"), "Location")
        elif args.type == "event":
            fields["date"] = prompt_if_missing(fields.get("date"), "Date (YYYY-MM-DD)", required=True)
            fields["location"] = prompt_if_missing(fields.get("location"), "Location")
    
    # Remove empty fields
    fields = {k: v for k, v in fields.items() if v}
    
    # Create the contact
    filepath = create_contact(args.type, args.name, **fields)
    print(f"Created: {filepath.relative_to(CONTACTS_DIR.parent)}")


if __name__ == "__main__":
    main()


path: scripts/crm-export.py
content: #!/usr/bin/python3
"""
CRM Export Tool - Export contacts to CSV or vCard
Usage:
    python crm-export.py --csv contacts.csv
    python crm-export.py --vcard contacts.vcf
    python crm-export.py --csv contacts.csv --tag investor
    python crm-export.py --vcard all.vcf --type people
"""

import argparse
import csv
import sys
from pathlib import Path
from datetime import datetime
import yaml

CONTACTS_DIR = Path(__file__).parent.parent


def parse_frontmatter(filepath: Path) -> dict | None:
    """Extract YAML frontmatter from a markdown file."""
    try:
        content = filepath.read_text()
        if not content.startswith("---"):
            return None
        end = content.find("---", 3)
        if end == -1:
            return None
        yaml_content = content[3:end].strip()
        return yaml.safe_load(yaml_content)
    except Exception:
        return None


def load_contacts(contact_type: str = "all", tag: str = None) -> list[tuple[Path, dict]]:
    """Load contacts with optional filtering."""
    contacts = []
    
    dirs = []
    if contact_type in ("all", "people"):
        dirs.append(CONTACTS_DIR / "people")
    if contact_type in ("all", "companies"):
        dirs.append(CONTACTS_DIR / "companies")
    if contact_type in ("all", "events"):
        dirs.append(CONTACTS_DIR / "events")
    
    for dir_path in dirs:
        if not dir_path.exists():
            continue
        for filepath in dir_path.glob("*.md"):
            fm = parse_frontmatter(filepath)
            if fm:
                # Filter by tag if specified
                if tag:
                    tags = [t.lower() for t in fm.get("tags", [])]
                    if tag.lower() not in tags:
                        continue
                contacts.append((filepath, fm))
    
    return sorted(contacts, key=lambda x: x[1].get("name", ""))


def export_csv(contacts: list[tuple[Path, dict]], output_path: Path):
    """Export contacts to CSV."""
    if not contacts:
        print("No contacts to export.")
        return
    
    # Determine fields from all contacts
    all_fields = set()
    for _, fm in contacts:
        all_fields.update(fm.keys())
    
    # Order fields nicely
    priority_fields = ["name", "type", "company", "role", "email", "phone", 
                       "telegram", "twitter", "linkedin", "location", "tags",
                       "first_contact", "last_contact", "follow_up", "status"]
    
    fields = [f for f in priority_fields if f in all_fields]
    fields += sorted([f for f in all_fields if f not in priority_fields])
    
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        writer.writeheader()
        
        for filepath, fm in contacts:
            row = {}
            for field in fields:
                value = fm.get(field, "")
                if isinstance(value, list):
                    value = ", ".join(str(v) for v in value)
                elif value is None:
                    value = ""
                row[field] = value
            writer.writerow(row)
    
    print(f"Exported {len(contacts)} contacts to {output_path}")


def export_vcard(contacts: list[tuple[Path, dict]], output_path: Path):
    """Export contacts to vCard format."""
    if not contacts:
        print("No contacts to export.")
        return
    
    lines = []
    
    for filepath, fm in contacts:
        if fm.get("type") != "person":
            continue  # vCard is for people
        
        name = fm.get("name", "")
        name_parts = name.split(" ", 1)
        first_name = name_parts[0] if name_parts else ""
        last_name = name_parts[1] if len(name_parts) > 1 else ""
        
        lines.append("BEGIN:VCARD")
        lines.append("VERSION:3.0")
        lines.append(f"FN:{name}")
        lines.append(f"N:{last_name};{first_name};;;")
        
        if fm.get("email"):
            lines.append(f"EMAIL:{fm['email']}")
        
        if fm.get("phone"):
            lines.append(f"TEL:{fm['phone']}")
        
        if fm.get("company") or fm.get("role"):
            org = fm.get("company", "")
            if org:
                lines.append(f"ORG:{org}")
            if fm.get("role"):
                lines.append(f"TITLE:{fm['role']}")
        
        if fm.get("location"):
            lines.append(f"ADR:;;{fm['location']};;;;")
        
        if fm.get("linkedin"):
            lines.append(f"URL:{fm['linkedin']}")
        
        if fm.get("twitter"):
            twitter = fm["twitter"]
            if not twitter.startswith("http"):
                twitter = f"https://twitter.com/{twitter.lstrip('@')}"
            lines.append(f"URL:{twitter}")
        
        if fm.get("telegram"):
            tg = fm["telegram"]
            if not tg.startswith("http"):
                tg = f"https://t.me/{tg.lstrip('@')}"
            lines.append(f"URL:{tg}")
        
        lines.append("END:VCARD")
        lines.append("")
    
    output_path.write_text("\n".join(lines))
    
    # Count actual vCards written
    vcard_count = sum(1 for _, fm in contacts if fm.get("type") == "person")
    print(f"Exported {vcard_count} contacts to {output_path}")


def export_markdown(contacts: list[tuple[Path, dict]], output_path: Path):
    """Export contacts to a single markdown file."""
    if not contacts:
        print("No contacts to export.")
        return
    
    lines = [
        f"# Contacts Export",
        f"",
        f"*Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}*",
        f"",
        f"---",
        f"",
    ]
    
    for filepath, fm in contacts:
        name = fm.get("name", filepath.stem)
        contact_type = fm.get("type", "unknown")
        
        lines.append(f"## {name}")
        lines.append("")
        
        # Key fields
        if fm.get("company"):
            lines.append(f"- **Company:** {fm['company']}")
        if fm.get("role"):
            lines.append(f"- **Role:** {fm['role']}")
        if fm.get("email"):
            lines.append(f"- **Email:** {fm['email']}")
        if fm.get("phone"):
            lines.append(f"- **Phone:** {fm['phone']}")
        if fm.get("location"):
            lines.append(f"- **Location:** {fm['location']}")
        if fm.get("tags"):
            tags = ", ".join(fm["tags"])
            lines.append(f"- **Tags:** {tags}")
        
        lines.append("")
        lines.append("---")
        lines.append("")
    
    output_path.write_text("\n".join(lines))
    print(f"Exported {len(contacts)} contacts to {output_path}")


def validate_output_path(path: Path) -> Path:
    """Ensure output path is safe (not escaping workspace)."""
    resolved = path.resolve()
    # Allow writing to current directory or subdirectories
    cwd = Path.cwd().resolve()
    if not str(resolved).startswith(str(cwd)) and not str(resolved).startswith("/tmp"):
        # Also allow absolute paths within home
        home = Path.home().resolve()
        if not str(resolved).startswith(str(home)):
            raise ValueError(f"Output path must be within workspace or home: {path}")
    return resolved


def main():
    parser = argparse.ArgumentParser(description="Export CRM contacts")
    
    # Output format (mutually exclusive)
    output_group = parser.add_mutually_exclusive_group(required=True)
    output_group.add_argument("--csv", metavar="FILE", help="Export to CSV")
    output_group.add_argument("--vcard", "--vcf", metavar="FILE", help="Export to vCard")
    output_group.add_argument("--markdown", "--md", metavar="FILE", help="Export to Markdown")
    
    # Filters
    parser.add_argument("--type", "-t", choices=["all", "people", "companies", "events"],
                        default="all", help="Contact type to export")
    parser.add_argument("--tag", help="Only export contacts with this tag")
    parser.add_argument("--status", choices=["active", "dormant", "archived"],
                        help="Only export contacts with this status")
    
    args = parser.parse_args()
    
    # Load contacts
    contacts = load_contacts(args.type, args.tag)
    
    # Filter by status if specified
    if args.status:
        contacts = [(p, fm) for p, fm in contacts 
                    if fm.get("status", "active") == args.status]
    
    if not contacts:
        print("No contacts match the criteria.")
        sys.exit(1)
    
    # Export
    try:
        if args.csv:
            export_csv(contacts, validate_output_path(Path(args.csv)))
        elif args.vcard:
            export_vcard(contacts, validate_output_path(Path(args.vcard)))
        elif args.markdown:
            export_markdown(contacts, validate_output_path(Path(args.markdown)))
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/crm-followups.py
content: #!/usr/bin/python3
"""
CRM Follow-ups Tool - Check due follow-ups and dormant contacts
Usage:
    python crm-followups.py                    # Show all due today
    python crm-followups.py --days 7           # Due in next 7 days
    python crm-followups.py --dormant 90       # Not contacted in 90 days
    python crm-followups.py --summary          # Quick summary for heartbeat
"""

import argparse
from datetime import datetime, timedelta
from pathlib import Path
import yaml

CONTACTS_DIR = Path(__file__).parent.parent
PEOPLE_DIR = CONTACTS_DIR / "people"


def parse_frontmatter(filepath: Path) -> dict | None:
    """Extract YAML frontmatter from a markdown file."""
    try:
        content = filepath.read_text()
        if not content.startswith("---"):
            return None
        end = content.find("---", 3)
        if end == -1:
            return None
        yaml_content = content[3:end].strip()
        return yaml.safe_load(yaml_content)
    except Exception:
        return None


def parse_date(d) -> datetime | None:
    """Parse a date from various formats."""
    if d is None:
        return None
    if isinstance(d, datetime):
        return d
    if hasattr(d, 'year'):  # date object
        return datetime.combine(d, datetime.min.time())
    if isinstance(d, str):
        try:
            return datetime.strptime(d, "%Y-%m-%d")
        except ValueError:
            return None
    return None


def load_people() -> list[tuple[Path, dict]]:
    """Load all people contacts."""
    if not PEOPLE_DIR.exists():
        return []
    
    contacts = []
    for filepath in PEOPLE_DIR.glob("*.md"):
        fm = parse_frontmatter(filepath)
        if fm and fm.get("type") == "person":
            contacts.append((filepath, fm))
    return contacts


def get_due_followups(days_ahead: int = 0) -> list[tuple[str, str, datetime]]:
    """Get contacts with follow-ups due within N days."""
    people = load_people()
    today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    cutoff = today + timedelta(days=days_ahead)
    
    due = []
    for filepath, fm in people:
        if fm.get("status") == "archived":
            continue
        
        follow_up = parse_date(fm.get("follow_up"))
        if follow_up and follow_up <= cutoff:
            name = fm.get("name", filepath.stem)
            due.append((filepath.stem, name, follow_up))
    
    return sorted(due, key=lambda x: x[2])


def get_dormant_contacts(days: int = 90) -> list[tuple[str, str, datetime, int]]:
    """Get contacts not contacted in N days."""
    people = load_people()
    cutoff = datetime.now() - timedelta(days=days)
    
    dormant = []
    for filepath, fm in people:
        if fm.get("status") in ("archived", "dormant"):
            continue
        
        last_contact = parse_date(fm.get("last_contact"))
        if last_contact and last_contact < cutoff:
            name = fm.get("name", filepath.stem)
            days_ago = (datetime.now() - last_contact).days
            dormant.append((filepath.stem, name, last_contact, days_ago))
    
    return sorted(dormant, key=lambda x: x[3], reverse=True)


def get_upcoming_birthdays(days_ahead: int = 30) -> list[tuple[str, str, str]]:
    """Get contacts with birthdays in the next N days."""
    # Future enhancement - need birthday field
    return []


def format_summary() -> str:
    """Generate a summary for heartbeat check."""
    lines = []
    
    # Due today
    due_today = get_due_followups(0)
    if due_today:
        lines.append(f"⚠️ **{len(due_today)} follow-up(s) due today:**")
        for slug, name, date in due_today[:5]:  # Limit to 5
            lines.append(f"  • {name}")
        if len(due_today) > 5:
            lines.append(f"  ...and {len(due_today) - 5} more")
        lines.append("")
    
    # Due this week
    due_week = get_due_followups(7)
    due_week = [(s, n, d) for s, n, d in due_week if d.date() > datetime.now().date()]
    if due_week:
        lines.append(f"📅 **{len(due_week)} follow-up(s) due this week**")
        lines.append("")
    
    # Dormant (90+ days)
    dormant = get_dormant_contacts(90)
    if dormant:
        lines.append(f"😴 **{len(dormant)} contact(s) going dormant** (90+ days no contact)")
        for slug, name, last, days in dormant[:3]:
            lines.append(f"  • {name} ({days} days)")
        lines.append("")
    
    if not lines:
        return "✅ No urgent follow-ups or dormant contacts."
    
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Check CRM follow-ups")
    parser.add_argument("--days", "-d", type=int, default=0,
                        help="Show follow-ups due within N days (default: today)")
    parser.add_argument("--dormant", type=int, nargs="?", const=90,
                        help="Show contacts not contacted in N days (default: 90)")
    parser.add_argument("--summary", "-s", action="store_true",
                        help="Show summary for heartbeat")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    
    args = parser.parse_args()
    
    if args.summary:
        print(format_summary())
        return
    
    # Follow-ups
    due = get_due_followups(args.days)
    
    if args.json:
        import json
        output = {
            "due_followups": [
                {"slug": s, "name": n, "date": d.strftime("%Y-%m-%d")}
                for s, n, d in due
            ]
        }
        if args.dormant:
            dormant = get_dormant_contacts(args.dormant)
            output["dormant"] = [
                {"slug": s, "name": n, "last_contact": d.strftime("%Y-%m-%d"), "days_ago": days}
                for s, n, d, days in dormant
            ]
        print(json.dumps(output, indent=2))
        return
    
    # Text output
    if due:
        period = "today" if args.days == 0 else f"in the next {args.days} days"
        print(f"📅 Follow-ups due {period}:\n")
        for slug, name, date in due:
            days_until = (date.date() - datetime.now().date()).days
            if days_until < 0:
                status = f"⚠️ OVERDUE by {-days_until} days"
            elif days_until == 0:
                status = "📍 TODAY"
            else:
                status = f"in {days_until} days"
            print(f"  • {name} — {date.strftime('%Y-%m-%d')} ({status})")
    else:
        period = "today" if args.days == 0 else f"in the next {args.days} days"
        print(f"✅ No follow-ups due {period}.")
    
    # Dormant contacts
    if args.dormant:
        print()
        dormant = get_dormant_contacts(args.dormant)
        if dormant:
            print(f"😴 Contacts not reached in {args.dormant}+ days:\n")
            for slug, name, last, days in dormant:
                print(f"  • {name} — last contact {last.strftime('%Y-%m-%d')} ({days} days ago)")
        else:
            print(f"✅ No dormant contacts (all contacted within {args.dormant} days).")


if __name__ == "__main__":
    main()


path: scripts/crm-import.py
content: #!/usr/bin/python3
"""
CRM Import Tool - Import contacts from CSV or vCard
Usage:
    python crm-import.py contacts.csv
    python crm-import.py contacts.vcf
    python crm-import.py linkedin-export.csv --format linkedin
    python crm-import.py contacts.csv --dry-run
"""

import argparse
import csv
import re
import sys
from pathlib import Path
from datetime import datetime

CONTACTS_DIR = Path(__file__).parent.parent
PEOPLE_DIR = CONTACTS_DIR / "people"


def slugify(name: str) -> str:
    """Convert a name to a filename-safe slug."""
    slug = name.lower()
    slug = re.sub(r"[^a-z0-9]+", "-", slug)
    slug = slug.strip("-")
    return slug


def parse_vcard(content: str) -> list[dict]:
    """Parse vCard format into contact dicts."""
    contacts = []
    current = {}
    
    for line in content.split("\n"):
        line = line.strip()
        
        if line == "BEGIN:VCARD":
            current = {}
        elif line == "END:VCARD":
            if current.get("name"):
                contacts.append(current)
            current = {}
        elif ":" in line:
            # Handle property;params:value format
            prop_part, value = line.split(":", 1)
            prop = prop_part.split(";")[0].upper()
            
            if prop == "FN":
                current["name"] = value
            elif prop == "N":
                # N:Last;First;Middle;Prefix;Suffix
                parts = value.split(";")
                if len(parts) >= 2:
                    first = parts[1] if len(parts) > 1 else ""
                    last = parts[0] if parts[0] else ""
                    if not current.get("name"):
                        current["name"] = f"{first} {last}".strip()
            elif prop == "EMAIL":
                current["email"] = value
            elif prop == "TEL":
                current["phone"] = value
            elif prop == "ORG":
                current["company"] = value.replace(";", " - ")
            elif prop == "TITLE":
                current["role"] = value
            elif prop == "ADR":
                # ADR:;;Street;City;State;ZIP;Country
                parts = value.split(";")
                location_parts = [p for p in parts[3:6] if p]  # City, State, Country
                if location_parts:
                    current["location"] = ", ".join(location_parts)
            elif prop == "URL":
                if "linkedin" in value.lower():
                    current["linkedin"] = value
                elif "twitter" in value.lower() or "x.com" in value.lower():
                    current["twitter"] = value
                else:
                    current["website"] = value
            elif prop == "NOTE":
                current["notes"] = value
    
    return contacts


def parse_csv(filepath: Path, format_type: str = "auto") -> list[dict]:
    """Parse CSV into contact dicts."""
    contacts = []
    
    with open(filepath, "r", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        headers = [h.lower().strip() for h in reader.fieldnames]
        
        # Detect format
        if format_type == "auto":
            if "first name" in headers or "first_name" in headers:
                format_type = "linkedin"
            elif "full name" in headers or "name" in headers:
                format_type = "generic"
            else:
                format_type = "generic"
        
        for row in reader:
            # Normalize keys to lowercase
            row = {k.lower().strip(): v.strip() for k, v in row.items() if v and v.strip()}
            
            contact = {}
            
            if format_type == "linkedin":
                # LinkedIn export format
                first = row.get("first name", row.get("first_name", ""))
                last = row.get("last name", row.get("last_name", ""))
                contact["name"] = f"{first} {last}".strip()
                contact["email"] = row.get("email address", row.get("email", ""))
                contact["company"] = row.get("company", "")
                contact["role"] = row.get("position", row.get("title", ""))
                contact["linkedin"] = row.get("profile url", row.get("linkedin", ""))
                contact["location"] = row.get("location", "")
            else:
                # Generic format
                contact["name"] = row.get("name", row.get("full name", row.get("full_name", "")))
                contact["email"] = row.get("email", row.get("email address", ""))
                contact["phone"] = row.get("phone", row.get("telephone", row.get("mobile", "")))
                contact["company"] = row.get("company", row.get("organization", ""))
                contact["role"] = row.get("role", row.get("title", row.get("position", "")))
                contact["location"] = row.get("location", row.get("city", ""))
                contact["linkedin"] = row.get("linkedin", "")
                contact["twitter"] = row.get("twitter", "")
                contact["telegram"] = row.get("telegram", "")
                contact["tags"] = row.get("tags", "")
                contact["notes"] = row.get("notes", row.get("note", ""))
            
            # Filter empty values
            contact = {k: v for k, v in contact.items() if v}
            
            if contact.get("name"):
                contacts.append(contact)
    
    return contacts


def create_contact_file(contact: dict, overwrite: bool = False) -> tuple[Path, bool]:
    """Create a contact markdown file. Returns (path, created)."""
    name = contact.get("name", "Unknown")
    slug = slugify(name)
    filepath = PEOPLE_DIR / f"{slug}.md"
    
    if filepath.exists() and not overwrite:
        return filepath, False
    
    today = datetime.now().strftime("%Y-%m-%d")
    
    # Build frontmatter
    lines = [
        "---",
        f"name: {name}",
        "type: person",
    ]
    
    # Tags
    tags = contact.get("tags", "")
    if tags:
        if isinstance(tags, str):
            tags = [t.strip() for t in tags.split(",") if t.strip()]
        lines.append(f"tags: [{', '.join(tags)}]")
    else:
        lines.append("tags: [imported]")
    
    # Other fields
    field_map = {
        "company": "company",
        "role": "role",
        "email": "email",
        "phone": "phone",
        "telegram": "telegram",
        "twitter": "twitter",
        "linkedin": "linkedin",
        "website": "website",
        "location": "location",
    }
    
    for key, yaml_key in field_map.items():
        value = contact.get(key)
        if value:
            # Escape quotes in values
            if ":" in str(value) or '"' in str(value):
                value = f'"{value}"'
            lines.append(f"{yaml_key}: {value}")
    
    lines.extend([
        f"first_contact: {today}",
        f"last_contact: {today}",
        "status: active",
        "---",
        "",
        f"# {name}",
        "",
        "## Context",
        "",
        "",
        "## Interactions",
        "",
        "",
        "## Notes",
        "",
    ])
    
    # Add notes if present
    if contact.get("notes"):
        lines.append(contact["notes"])
    
    PEOPLE_DIR.mkdir(parents=True, exist_ok=True)
    filepath.write_text("\n".join(lines) + "\n")
    
    return filepath, True


def main():
    parser = argparse.ArgumentParser(description="Import contacts to CRM")
    parser.add_argument("file", help="File to import (CSV or vCard)")
    parser.add_argument("--format", "-f", choices=["auto", "csv", "vcard", "linkedin"],
                        default="auto", help="Input format (default: auto-detect)")
    parser.add_argument("--overwrite", action="store_true",
                        help="Overwrite existing contacts")
    parser.add_argument("--dry-run", "-n", action="store_true",
                        help="Show what would be imported without creating files")
    parser.add_argument("--tags", "-t", help="Add these tags to all imports (comma-separated)")
    
    args = parser.parse_args()
    
    filepath = Path(args.file)
    if not filepath.exists():
        print(f"File not found: {filepath}", file=sys.stderr)
        sys.exit(1)
    
    # Detect and parse format
    content = filepath.read_text(encoding="utf-8-sig")
    
    if args.format == "vcard" or filepath.suffix.lower() in (".vcf", ".vcard"):
        contacts = parse_vcard(content)
        format_name = "vCard"
    else:
        format_type = "linkedin" if args.format == "linkedin" else "auto"
        contacts = parse_csv(filepath, format_type)
        format_name = "CSV"
    
    if not contacts:
        print("No contacts found in file.")
        sys.exit(1)
    
    print(f"Found {len(contacts)} contact(s) in {format_name} file.\n")
    
    # Add extra tags if specified
    if args.tags:
        extra_tags = [t.strip() for t in args.tags.split(",")]
        for contact in contacts:
            existing = contact.get("tags", "")
            if isinstance(existing, str) and existing:
                existing = [t.strip() for t in existing.split(",")]
            elif not existing:
                existing = []
            contact["tags"] = list(set(existing + extra_tags))
    
    # Import contacts
    created = 0
    skipped = 0
    
    for contact in contacts:
        name = contact.get("name", "Unknown")
        
        if args.dry_run:
            slug = slugify(name)
            exists = (PEOPLE_DIR / f"{slug}.md").exists()
            status = "EXISTS" if exists else "NEW"
            print(f"  [{status}] {name}")
            if exists:
                skipped += 1
            else:
                created += 1
        else:
            path, was_created = create_contact_file(contact, args.overwrite)
            if was_created:
                print(f"  ✓ Created: {name}")
                created += 1
            else:
                print(f"  - Skipped (exists): {name}")
                skipped += 1
    
    print(f"\nSummary: {created} created, {skipped} skipped")
    
    if not args.dry_run and created > 0:
        print("\nRun `crm-index.py` to update the index.")


if __name__ == "__main__":
    main()


path: scripts/crm-index.py
content: #!/usr/bin/python3
"""
CRM Index Tool - Regenerate the _index.md file
Usage:
    python crm-index.py
    python crm-index.py --output /path/to/index.md
"""

import argparse
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import yaml

CONTACTS_DIR = Path(__file__).parent.parent


def parse_frontmatter(filepath: Path) -> dict | None:
    """Extract YAML frontmatter from a markdown file."""
    try:
        content = filepath.read_text()
        if not content.startswith("---"):
            return None
        
        end = content.find("---", 3)
        if end == -1:
            return None
        
        yaml_content = content[3:end].strip()
        return yaml.safe_load(yaml_content)
    except Exception:
        return None


def load_contacts(subdir: str) -> list[tuple[Path, dict]]:
    """Load contacts from a subdirectory."""
    dir_path = CONTACTS_DIR / subdir
    if not dir_path.exists():
        return []
    
    contacts = []
    for filepath in sorted(dir_path.glob("*.md")):
        fm = parse_frontmatter(filepath)
        if fm:
            contacts.append((filepath, fm))
    
    return contacts


def format_date(d) -> str:
    """Format a date for display."""
    if d is None:
        return ""
    if isinstance(d, str):
        return d
    return d.strftime("%Y-%m-%d")


def generate_index() -> str:
    """Generate the index markdown content."""
    lines = [
        "# Contacts Index",
        "",
        f"*Auto-generated on {datetime.now().strftime('%Y-%m-%d %H:%M')}. Run `crm-index.py` to refresh.*",
        "",
    ]
    
    # Load all contacts
    people = load_contacts("people")
    companies = load_contacts("companies")
    events = load_contacts("events")
    
    # People table
    if people:
        lines.extend([
            "## People",
            "",
            "| Name | Company | Role | Tags | Last Contact |",
            "|------|---------|------|------|--------------|",
        ])
        
        for filepath, fm in sorted(people, key=lambda x: x[1].get("name", "")):
            name = fm.get("name", filepath.stem)
            slug = filepath.stem
            company = fm.get("company", "")
            role = fm.get("role", "")
            tags = ", ".join(fm.get("tags", []))
            last = format_date(fm.get("last_contact"))
            
            lines.append(f"| [[{slug}\\|{name}]] | {company} | {role} | {tags} | {last} |")
        
        lines.append("")
    
    # Companies table
    if companies:
        lines.extend([
            "## Companies",
            "",
            "| Name | Industry | Location | Status |",
            "|------|----------|----------|--------|",
        ])
        
        for filepath, fm in sorted(companies, key=lambda x: x[1].get("name", "")):
            name = fm.get("name", filepath.stem)
            slug = filepath.stem
            industry = fm.get("industry", "")
            location = fm.get("location", "")
            status = fm.get("status", "active")
            
            lines.append(f"| [[{slug}\\|{name}]] | {industry} | {location} | {status} |")
        
        lines.append("")
    
    # Events table
    if events:
        lines.extend([
            "## Events",
            "",
            "| Name | Date | Location |",
            "|------|------|----------|",
        ])
        
        for filepath, fm in sorted(events, key=lambda x: str(x[1].get("date", "")), reverse=True):
            name = fm.get("name", filepath.stem)
            slug = filepath.stem
            date = format_date(fm.get("date"))
            location = fm.get("location", "")
            
            lines.append(f"| [[{slug}\\|{name}]] | {date} | {location} |")
        
        lines.append("")
    
    # Tags index
    all_contacts = people + companies + events
    tags_map = defaultdict(list)
    
    for filepath, fm in all_contacts:
        for tag in fm.get("tags", []):
            tags_map[tag].append((filepath.stem, fm.get("name", filepath.stem)))
    
    if tags_map:
        lines.extend([
            "## By Tag",
            "",
        ])
        
        for tag in sorted(tags_map.keys()):
            contacts = tags_map[tag]
            contact_links = ", ".join(f"[[{slug}\\|{name}]]" for slug, name in sorted(contacts, key=lambda x: x[1]))
            lines.append(f"### #{tag}")
            lines.append(f"{contact_links}")
            lines.append("")
    
    # Follow-ups due
    today = datetime.now().date()
    due_followups = []
    
    for filepath, fm in people:
        follow_up = fm.get("follow_up")
        if follow_up:
            try:
                if isinstance(follow_up, str):
                    fu_date = datetime.strptime(follow_up, "%Y-%m-%d").date()
                else:
                    fu_date = follow_up
                if fu_date <= today:
                    due_followups.append((filepath.stem, fm.get("name", filepath.stem), follow_up))
            except (ValueError, TypeError):
                pass
    
    if due_followups:
        lines.extend([
            "## ⚠️ Due Follow-ups",
            "",
        ])
        for slug, name, date in sorted(due_followups, key=lambda x: str(x[2])):
            lines.append(f"- [[{slug}\\|{name}]] — due {date}")
        lines.append("")
    
    # Stats
    lines.extend([
        "---",
        "",
        f"**Stats:** {len(people)} people · {len(companies)} companies · {len(events)} events · {len(tags_map)} tags",
        "",
    ])
    
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Regenerate CRM index")
    parser.add_argument("--output", "-o", help="Output path (default: _index.md)")
    parser.add_argument("--dry-run", action="store_true", help="Print to stdout instead of saving")
    
    args = parser.parse_args()
    
    content = generate_index()
    
    if args.dry_run:
        print(content)
    else:
        output_path = Path(args.output) if args.output else (CONTACTS_DIR / "_index.md")
        output_path.write_text(content)
        print(f"Index updated: {output_path}")


if __name__ == "__main__":
    main()


path: scripts/crm-query.py
content: #!/usr/bin/python3
"""
CRM Query Tool - Search and filter contacts
Usage:
    python crm-query.py --tag investor
    python crm-query.py --company "Horizon Ventures"
    python crm-query.py --introduced-by bob-smith
    python crm-query.py --location singapore
    python crm-query.py --follow-up-before 2026-02-01
    python crm-query.py --status dormant
    python crm-query.py --search "supply chain"
    python crm-query.py --list all
"""

import argparse
import os
import re
import sys
from pathlib import Path
from datetime import datetime, timedelta
import yaml

CONTACTS_DIR = Path(__file__).parent.parent
PEOPLE_DIR = CONTACTS_DIR / "people"
COMPANIES_DIR = CONTACTS_DIR / "companies"
EVENTS_DIR = CONTACTS_DIR / "events"


def parse_frontmatter(filepath: Path) -> dict | None:
    """Extract YAML frontmatter from a markdown file."""
    try:
        content = filepath.read_text()
        if not content.startswith("---"):
            return None
        
        # Find the closing ---
        end = content.find("---", 3)
        if end == -1:
            return None
        
        yaml_content = content[3:end].strip()
        return yaml.safe_load(yaml_content)
    except Exception as e:
        print(f"Error parsing {filepath}: {e}", file=sys.stderr)
        return None


def get_body(filepath: Path) -> str:
    """Get markdown body after frontmatter."""
    content = filepath.read_text()
    if not content.startswith("---"):
        return content
    end = content.find("---", 3)
    if end == -1:
        return content
    return content[end + 3:].strip()


def load_all_contacts(contact_type: str = "all") -> list[tuple[Path, dict]]:
    """Load all contacts with their frontmatter."""
    contacts = []
    
    dirs = []
    if contact_type in ("all", "people"):
        dirs.append(PEOPLE_DIR)
    if contact_type in ("all", "companies"):
        dirs.append(COMPANIES_DIR)
    if contact_type in ("all", "events"):
        dirs.append(EVENTS_DIR)
    
    for dir_path in dirs:
        if not dir_path.exists():
            continue
        for filepath in dir_path.glob("*.md"):
            fm = parse_frontmatter(filepath)
            if fm:
                contacts.append((filepath, fm))
    
    return contacts


def filter_by_tag(contacts: list, tag: str) -> list:
    """Filter contacts by tag."""
    tag = tag.lower()
    return [(p, fm) for p, fm in contacts 
            if tag in [t.lower() for t in fm.get("tags", [])]]


def filter_by_company(contacts: list, company: str) -> list:
    """Filter contacts by company."""
    company = company.lower()
    return [(p, fm) for p, fm in contacts 
            if company in str(fm.get("company", "")).lower()]


def filter_by_location(contacts: list, location: str) -> list:
    """Filter contacts by location."""
    location = location.lower()
    return [(p, fm) for p, fm in contacts 
            if location in str(fm.get("location", "")).lower()]


def filter_by_introduced_by(contacts: list, introducer: str) -> list:
    """Filter contacts by who introduced them."""
    introducer = introducer.lower()
    return [(p, fm) for p, fm in contacts 
            if introducer in str(fm.get("introduced_by", "")).lower()]


def filter_by_status(contacts: list, status: str) -> list:
    """Filter contacts by status."""
    status = status.lower()
    return [(p, fm) for p, fm in contacts 
            if fm.get("status", "active").lower() == status]


def filter_by_follow_up(contacts: list, before_date: str) -> list:
    """Filter contacts with follow_up before a certain date."""
    try:
        target = datetime.strptime(before_date, "%Y-%m-%d")
    except ValueError:
        print(f"Invalid date format: {before_date}. Use YYYY-MM-DD", file=sys.stderr)
        return []
    
    result = []
    for p, fm in contacts:
        follow_up = fm.get("follow_up")
        if follow_up:
            try:
                if isinstance(follow_up, str):
                    fu_date = datetime.strptime(follow_up, "%Y-%m-%d")
                else:
                    fu_date = datetime.combine(follow_up, datetime.min.time())
                if fu_date <= target:
                    result.append((p, fm))
            except (ValueError, TypeError):
                pass
    return result


def filter_dormant(contacts: list, days: int = 90) -> list:
    """Find contacts not contacted in N days."""
    cutoff = datetime.now() - timedelta(days=days)
    result = []
    for p, fm in contacts:
        last = fm.get("last_contact")
        if last:
            try:
                if isinstance(last, str):
                    last_date = datetime.strptime(last, "%Y-%m-%d")
                else:
                    last_date = datetime.combine(last, datetime.min.time())
                if last_date < cutoff:
                    result.append((p, fm))
            except (ValueError, TypeError):
                pass
    return result


def search_content(contacts: list, query: str) -> list:
    """Search in both frontmatter and body content."""
    query = query.lower()
    result = []
    for p, fm in contacts:
        # Search in frontmatter values
        fm_text = " ".join(str(v) for v in fm.values()).lower()
        if query in fm_text:
            result.append((p, fm))
            continue
        
        # Search in body
        body = get_body(p).lower()
        if query in body:
            result.append((p, fm))
    
    return result


def format_contact(filepath: Path, fm: dict, verbose: bool = False) -> str:
    """Format a contact for display."""
    name = fm.get("name", filepath.stem)
    contact_type = fm.get("type", "unknown")
    tags = ", ".join(fm.get("tags", []))
    
    line = f"• {name}"
    
    if contact_type == "person":
        company = fm.get("company", "")
        role = fm.get("role", "")
        if role and company:
            line += f" — {role} @ {company}"
        elif role:
            line += f" — {role}"
        elif company:
            line += f" — @ {company}"
    elif contact_type == "company":
        industry = fm.get("industry", "")
        if industry:
            line += f" — {industry}"
    elif contact_type == "event":
        date = fm.get("date", "")
        if date:
            line += f" — {date}"
    
    if tags:
        line += f"  [{tags}]"
    
    if verbose:
        line += f"\n  File: {filepath.relative_to(CONTACTS_DIR)}"
        if fm.get("last_contact"):
            line += f"\n  Last contact: {fm['last_contact']}"
        if fm.get("follow_up"):
            line += f"\n  Follow-up: {fm['follow_up']}"
    
    return line


def main():
    parser = argparse.ArgumentParser(description="Query CRM contacts")
    parser.add_argument("--tag", "-t", help="Filter by tag")
    parser.add_argument("--company", "-c", help="Filter by company")
    parser.add_argument("--location", "-l", help="Filter by location")
    parser.add_argument("--introduced-by", "-i", help="Filter by introducer")
    parser.add_argument("--status", "-s", help="Filter by status (active/dormant/archived)")
    parser.add_argument("--follow-up-before", "-f", help="Show contacts with follow-up before date (YYYY-MM-DD)")
    parser.add_argument("--dormant", "-d", type=int, nargs="?", const=90, 
                        help="Show contacts not contacted in N days (default 90)")
    parser.add_argument("--search", "-q", help="Full-text search")
    parser.add_argument("--list", choices=["all", "people", "companies", "events"],
                        help="List all contacts of a type")
    parser.add_argument("--verbose", "-v", action="store_true", help="Show more details")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    
    args = parser.parse_args()
    
    # Determine contact type to load
    contact_type = "all"
    if args.list:
        contact_type = args.list
    
    contacts = load_all_contacts(contact_type)
    
    # Apply filters
    if args.tag:
        contacts = filter_by_tag(contacts, args.tag)
    if args.company:
        contacts = filter_by_company(contacts, args.company)
    if args.location:
        contacts = filter_by_location(contacts, args.location)
    if args.introduced_by:
        contacts = filter_by_introduced_by(contacts, args.introduced_by)
    if args.status:
        contacts = filter_by_status(contacts, args.status)
    if args.follow_up_before:
        contacts = filter_by_follow_up(contacts, args.follow_up_before)
    if args.dormant:
        contacts = filter_dormant(contacts, args.dormant)
    if args.search:
        contacts = search_content(contacts, args.search)
    
    # Output
    if args.json:
        import json
        output = []
        for p, fm in contacts:
            fm["_file"] = str(p.relative_to(CONTACTS_DIR))
            output.append(fm)
        print(json.dumps(output, indent=2, default=str))
    else:
        if not contacts:
            print("No contacts found.")
        else:
            print(f"Found {len(contacts)} contact(s):\n")
            for p, fm in sorted(contacts, key=lambda x: x[1].get("name", "")):
                print(format_contact(p, fm, args.verbose))


if __name__ == "__main__":
    main()


path: scripts/crm-remind.py
content: #!/usr/bin/python3
"""
CRM Remind Tool - Create cron reminders for follow-ups
Usage:
    python crm-remind.py alice-chen                    # Remind on follow_up date
    python crm-remind.py alice-chen --in 3d            # Remind in 3 days
    python crm-remind.py alice-chen --at 2026-02-15    # Remind on specific date
    python crm-remind.py --list                        # List pending reminders
"""

import argparse
import json
import re
from datetime import datetime, timedelta
from pathlib import Path
import yaml

CONTACTS_DIR = Path(__file__).parent.parent
PEOPLE_DIR = CONTACTS_DIR / "people"
REMINDERS_FILE = CONTACTS_DIR / ".reminders.json"


def parse_frontmatter(filepath: Path) -> dict | None:
    """Extract YAML frontmatter from a markdown file."""
    try:
        content = filepath.read_text()
        if not content.startswith("---"):
            return None
        end = content.find("---", 3)
        if end == -1:
            return None
        yaml_content = content[3:end].strip()
        return yaml.safe_load(yaml_content)
    except Exception:
        return None


def find_contact(slug: str) -> tuple[Path, dict] | None:
    """Find a contact by slug."""
    for subdir in ["people", "companies", "events"]:
        dir_path = CONTACTS_DIR / subdir
        if not dir_path.exists():
            continue
        
        filepath = dir_path / f"{slug}.md"
        if filepath.exists():
            fm = parse_frontmatter(filepath)
            if fm:
                return filepath, fm
        
        # Partial match
        for fp in dir_path.glob("*.md"):
            if slug in fp.stem:
                fm = parse_frontmatter(fp)
                if fm:
                    return fp, fm
    return None


def parse_duration(duration: str) -> timedelta:
    """Parse duration like '3d', '1w', '2h'."""
    match = re.match(r"(\d+)([dhwm])", duration.lower())
    if not match:
        raise ValueError(f"Invalid duration: {duration}")
    
    num = int(match.group(1))
    unit = match.group(2)
    
    if unit == "h":
        return timedelta(hours=num)
    elif unit == "d":
        return timedelta(days=num)
    elif unit == "w":
        return timedelta(weeks=num)
    elif unit == "m":
        return timedelta(days=num * 30)
    else:
        raise ValueError(f"Unknown unit: {unit}")


def load_reminders() -> list[dict]:
    """Load reminders from file."""
    if not REMINDERS_FILE.exists():
        return []
    try:
        return json.loads(REMINDERS_FILE.read_text())
    except Exception:
        return []


def save_reminders(reminders: list[dict]):
    """Save reminders to file with secure permissions."""
    import os
    REMINDERS_FILE.write_text(json.dumps(reminders, indent=2))
    os.chmod(REMINDERS_FILE, 0o600)  # Owner read/write only


def add_reminder(slug: str, name: str, remind_at: datetime, message: str = None):
    """Add a reminder."""
    reminders = load_reminders()
    
    reminder = {
        "slug": slug,
        "name": name,
        "remind_at": remind_at.isoformat(),
        "message": message or f"Follow up with {name}",
        "created_at": datetime.now().isoformat()
    }
    
    reminders.append(reminder)
    save_reminders(reminders)
    return reminder


def get_due_reminders() -> list[dict]:
    """Get reminders that are due now."""
    reminders = load_reminders()
    now = datetime.now()
    
    due = []
    remaining = []
    
    for r in reminders:
        remind_at = datetime.fromisoformat(r["remind_at"])
        if remind_at <= now:
            due.append(r)
        else:
            remaining.append(r)
    
    # Save remaining
    save_reminders(remaining)
    
    return due


def main():
    parser = argparse.ArgumentParser(description="CRM reminder management")
    parser.add_argument("contact", nargs="?", help="Contact slug")
    parser.add_argument("--in", dest="in_duration", help="Remind in duration (e.g., 3d, 1w)")
    parser.add_argument("--at", dest="at_date", help="Remind at date (YYYY-MM-DD)")
    parser.add_argument("--message", "-m", help="Custom reminder message")
    parser.add_argument("--list", "-l", action="store_true", help="List pending reminders")
    parser.add_argument("--check", action="store_true", help="Check and return due reminders")
    parser.add_argument("--clear", action="store_true", help="Clear all reminders")
    
    args = parser.parse_args()
    
    if args.clear:
        save_reminders([])
        print("All reminders cleared.")
        return
    
    if args.check:
        due = get_due_reminders()
        if due:
            print(f"🔔 {len(due)} reminder(s) due:")
            for r in due:
                print(f"  • {r['name']}: {r['message']}")
        else:
            print("No reminders due.")
        return
    
    if args.list:
        reminders = load_reminders()
        if not reminders:
            print("No pending reminders.")
            return
        
        print(f"📅 {len(reminders)} pending reminder(s):\n")
        for r in sorted(reminders, key=lambda x: x["remind_at"]):
            remind_at = datetime.fromisoformat(r["remind_at"])
            delta = remind_at - datetime.now()
            if delta.days > 0:
                when = f"in {delta.days} days"
            elif delta.seconds > 3600:
                when = f"in {delta.seconds // 3600} hours"
            else:
                when = "soon"
            print(f"  • {r['name']} — {remind_at.strftime('%Y-%m-%d %H:%M')} ({when})")
            print(f"    {r['message']}")
        return
    
    if not args.contact:
        parser.print_help()
        return
    
    # Find the contact
    result = find_contact(args.contact)
    if not result:
        print(f"Contact not found: {args.contact}")
        return
    
    filepath, fm = result
    name = fm.get("name", filepath.stem)
    slug = filepath.stem
    
    # Determine remind time
    if args.in_duration:
        remind_at = datetime.now() + parse_duration(args.in_duration)
    elif args.at_date:
        remind_at = datetime.strptime(args.at_date, "%Y-%m-%d")
        # Set to 9am
        remind_at = remind_at.replace(hour=9, minute=0)
    else:
        # Use follow_up date from contact
        follow_up = fm.get("follow_up")
        if not follow_up:
            print(f"No follow_up date set for {name}. Use --in or --at to specify.")
            return
        
        if isinstance(follow_up, str):
            remind_at = datetime.strptime(follow_up, "%Y-%m-%d")
        else:
            remind_at = datetime.combine(follow_up, datetime.min.time())
        remind_at = remind_at.replace(hour=9, minute=0)
    
    # Add the reminder
    reminder = add_reminder(slug, name, remind_at, args.message)
    print(f"✓ Reminder set: {name}")
    print(f"  When: {remind_at.strftime('%Y-%m-%d %H:%M')}")
    print(f"  Message: {reminder['message']}")


if __name__ == "__main__":
    main()


path: scripts/crm-update.py
content: #!/usr/bin/python3
"""
CRM Update Tool - Update contacts and log interactions
Usage:
    python crm-update.py alice-chen --interaction "Called about pilot program"
    python crm-update.py alice-chen --set-follow-up 2026-02-15
    python crm-update.py alice-chen --add-tag vip
    python crm-update.py alice-chen --set-status dormant
"""

import argparse
import os
import re
import sys
from pathlib import Path
from datetime import datetime
import yaml

CONTACTS_DIR = Path(__file__).parent.parent


def find_contact(slug: str) -> Path | None:
    """Find a contact file by slug."""
    for subdir in ["people", "companies", "events"]:
        dir_path = CONTACTS_DIR / subdir
        if not dir_path.exists():
            continue
        
        # Try exact match
        filepath = dir_path / f"{slug}.md"
        if filepath.exists():
            return filepath
        
        # Try partial match
        for filepath in dir_path.glob("*.md"):
            if slug in filepath.stem:
                return filepath
    
    return None


def parse_file(filepath: Path) -> tuple[dict, str]:
    """Parse a markdown file into frontmatter dict and body string."""
    content = filepath.read_text()
    
    if not content.startswith("---"):
        return {}, content
    
    end = content.find("---", 3)
    if end == -1:
        return {}, content
    
    yaml_content = content[3:end].strip()
    frontmatter = yaml.safe_load(yaml_content) or {}
    body = content[end + 3:].strip()
    
    return frontmatter, body


def write_file(filepath: Path, frontmatter: dict, body: str):
    """Write frontmatter and body back to file."""
    # Build YAML frontmatter
    yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False)
    content = f"---\n{yaml_str}---\n\n{body}\n"
    filepath.write_text(content)


def add_interaction(body: str, note: str) -> str:
    """Add an interaction to the Interactions section."""
    today = datetime.now().strftime("%Y-%m-%d")
    interaction_line = f"- **{today}**: {note}"
    
    # Find the Interactions section
    lines = body.split("\n")
    new_lines = []
    in_interactions = False
    added = False
    
    for i, line in enumerate(lines):
        new_lines.append(line)
        
        if line.strip() == "## Interactions":
            in_interactions = True
            continue
        
        if in_interactions and not added:
            # Add after the section header (skip empty lines)
            if line.strip() == "" and i + 1 < len(lines):
                # Check if next line is content or another section
                next_line = lines[i + 1].strip()
                if next_line.startswith("##") or next_line.startswith("-"):
                    new_lines.append(interaction_line)
                    added = True
                elif next_line == "":
                    new_lines.append(interaction_line)
                    added = True
            elif line.strip().startswith("-"):
                # Insert before existing interactions
                new_lines.pop()  # Remove the line we just added
                new_lines.append(interaction_line)
                new_lines.append(line)
                added = True
                in_interactions = False
        
        if in_interactions and line.strip().startswith("##") and "Interactions" not in line:
            in_interactions = False
    
    # If we didn't find a good place, append to Interactions section
    if not added:
        # Find Interactions and append
        for i, line in enumerate(new_lines):
            if line.strip() == "## Interactions":
                # Find next section or end
                j = i + 1
                while j < len(new_lines) and not new_lines[j].strip().startswith("##"):
                    j += 1
                new_lines.insert(j, interaction_line)
                new_lines.insert(j, "")
                break
    
    return "\n".join(new_lines)


def main():
    parser = argparse.ArgumentParser(description="Update a contact")
    parser.add_argument("contact", help="Contact slug or partial name")
    
    # Update actions
    parser.add_argument("--interaction", "-i", help="Log an interaction")
    parser.add_argument("--note", "-n", help="Add a note (appends to Notes section)")
    parser.add_argument("--set-follow-up", "-f", help="Set follow-up date (YYYY-MM-DD)")
    parser.add_argument("--clear-follow-up", action="store_true", help="Clear follow-up date")
    parser.add_argument("--add-tag", "-t", help="Add a tag")
    parser.add_argument("--remove-tag", help="Remove a tag")
    parser.add_argument("--set-status", "-s", choices=["active", "dormant", "archived"],
                        help="Set status")
    parser.add_argument("--touch", action="store_true", 
                        help="Update last_contact to today")
    
    # Options
    parser.add_argument("--dry-run", action="store_true", help="Show changes without saving")
    
    args = parser.parse_args()
    
    # Find the contact
    filepath = find_contact(args.contact)
    if not filepath:
        print(f"Contact not found: {args.contact}", file=sys.stderr)
        sys.exit(1)
    
    print(f"Updating: {filepath.relative_to(CONTACTS_DIR)}")
    
    # Parse the file
    frontmatter, body = parse_file(filepath)
    today = datetime.now().strftime("%Y-%m-%d")
    changes = []
    
    # Apply updates
    if args.interaction:
        body = add_interaction(body, args.interaction)
        frontmatter["last_contact"] = today
        changes.append(f"Added interaction: {args.interaction}")
        changes.append(f"Updated last_contact: {today}")
    
    if args.note:
        # Append to Notes section
        note_line = f"- {args.note}"
        if "## Notes" in body:
            body = body.replace("## Notes\n", f"## Notes\n{note_line}\n", 1)
        else:
            body += f"\n## Notes\n{note_line}\n"
        changes.append(f"Added note: {args.note}")
    
    if args.set_follow_up:
        frontmatter["follow_up"] = args.set_follow_up
        changes.append(f"Set follow_up: {args.set_follow_up}")
    
    if args.clear_follow_up:
        if "follow_up" in frontmatter:
            del frontmatter["follow_up"]
            changes.append("Cleared follow_up")
    
    if args.add_tag:
        tags = frontmatter.get("tags", [])
        if args.add_tag not in tags:
            tags.append(args.add_tag)
            frontmatter["tags"] = tags
            changes.append(f"Added tag: {args.add_tag}")
    
    if args.remove_tag:
        tags = frontmatter.get("tags", [])
        if args.remove_tag in tags:
            tags.remove(args.remove_tag)
            frontmatter["tags"] = tags
            changes.append(f"Removed tag: {args.remove_tag}")
    
    if args.set_status:
        frontmatter["status"] = args.set_status
        changes.append(f"Set status: {args.set_status}")
    
    if args.touch:
        frontmatter["last_contact"] = today
        changes.append(f"Updated last_contact: {today}")
    
    # Show changes
    if not changes:
        print("No changes specified.")
        sys.exit(0)
    
    print("\nChanges:")
    for change in changes:
        print(f"  • {change}")
    
    # Save or show dry-run
    if args.dry_run:
        print("\n[Dry run - no changes saved]")
    else:
        write_file(filepath, frontmatter, body)
        print("\n✓ Saved")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)