Skip to content

Commit 85d445b

Browse files
justinabrahmsclaude
andcommitted
UX overhaul: init wizard, edit, fuzzy matching, JSON/dry-run, docs
Major improvements to both human and agent workflows: - Add `frm init` interactive setup wizard with provider presets - Add `frm edit` command to update contact fields - Add fuzzy name matching with Levenshtein distance - Triage now accepts freeform frequency input (e.g. 2w) - Simplify --when to accept only YYYY-MM-DD dates - Structured JSON errors when --json is set - JSON output on all mutating commands - Enrich `frm check --json` with email/phone/org/group/last_note - Add --dry-run flag to all mutating operations - Improve root --help with getting started flow - Consolidate list/contacts (contacts deprecated) - Split `group list` into `group list` + `group members` - Add untriaged count and coverage % to stats - GitHub Pages docs site with guide, command ref, agent integration - Update README with docs links Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 32b2455 commit 85d445b

26 files changed

Lines changed: 3518 additions & 315 deletions

README.md

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# frm
22

3-
A CLI personal CRM backed by CardDAV. Single binary, no database — your contacts live in your existing address book, interaction history in a local JSONL file.
3+
**Maintain meaningful relationships** -- a CLI personal CRM backed by CardDAV.
44

5-
Built to be driven by AI agents. Your agent checks who you're overdue to reach out to, prepares context before meetings, and prompts you to stay in touch with the people who matter.
5+
Single binary, no database -- your contacts live in your existing address book, interaction history in a local JSONL file. Built to be driven by AI agents.
6+
7+
**[Read the docs](https://justinabrahms.github.io/frm/)** | [Getting Started](https://justinabrahms.github.io/frm/guide.html) | [Command Reference](https://justinabrahms.github.io/frm/commands.html) | [Agent Integration](https://justinabrahms.github.io/frm/agents.html)
68

79
![frm demo](demo.gif)
810

@@ -16,16 +18,14 @@ go install github.com/justinabrahms/frm@latest
1618

1719
## Quick start
1820

19-
1. Create `~/.frm/config.json` with your CardDAV credentials (see [Setup](#setup) below)
21+
1. `frm init` to connect your CardDAV server (iCloud, Fastmail, or custom)
2022
2. `frm triage` to categorize your contacts
2123
3. `frm check` to see who you're overdue to reach out to
2224
4. `frm log "Alice" --note "caught up over coffee"` after you talk to someone
2325

2426
## Agent integration
2527

26-
frm is designed for AI agents to drive your relationship maintenance. Every command supports `--json` for structured output.
27-
28-
The typical agent loop:
28+
frm is designed for AI agents to drive your relationship maintenance. Every command supports `--json` for structured output and `--dry-run` for safe previews.
2929

3030
```bash
3131
# 1. Who needs attention?
@@ -42,18 +42,17 @@ frm triage --json --limit -1
4242
# then for each: frm track "<name>" --every 2w OR frm ignore "<name>"
4343
```
4444

45-
See [SKILL.md](SKILL.md) for a complete agent skill reference.
45+
See the [Agent Integration Guide](https://justinabrahms.github.io/frm/agents.html) and [SKILL.md](SKILL.md) for a complete reference.
4646

4747
## Usage
4848

4949
```
50-
frm contacts List all contacts
5150
frm list List tracked contacts with due dates
5251
frm list --all Include untracked contacts
5352
frm check Show overdue contacts
5453
frm context "Alice" Pre-meeting prep: summary + recent emails
5554
frm log "Alice" --note "coffee" Log an interaction
56-
frm log "Alice" --when -2w Backdate an interaction
55+
frm log "Alice" --when 2026-02-15 Backdate an interaction
5756
frm triage Walk through untagged contacts interactively
5857
frm triage --json List untriaged contacts as JSON (for agents)
5958
frm track "Alice" --every 2w Track Alice every 2 weeks
@@ -64,40 +63,27 @@ frm snooze "Alice" --until 2m Temporarily suppress from check
6463
frm unsnooze "Alice" Remove a snooze
6564
frm spread Preview staggered snoozes for new imports
6665
frm spread --apply Apply the spread
66+
frm add "Alice" --email a@b.com Create a new contact
67+
frm edit "Alice" --phone "555" Update contact fields
6768
frm history "Alice" Show interaction log
6869
frm stats Dashboard
6970
frm group set "Alice" friends Assign to a group
7071
frm group unset "Alice" Remove from group
7172
frm group list List all groups
72-
frm group list friends List contacts in a group
73+
frm group members friends List contacts in a group
7374
```
7475

75-
All listing commands support `--json` for machine-readable output.
76+
All commands support `--json` for machine-readable output and `--dry-run` for previewing changes.
7677

7778
### Duration format
7879

79-
- `3d` — every 3 days
80-
- `2w` — every 2 weeks
81-
- `1m` — every month (30 days)
82-
83-
### Triage
84-
85-
`frm triage` loops through contacts that have no frequency set and aren't ignored. For each one it shows email, org, phone, and recent email context (if JMAP is configured), then prompts:
86-
87-
```
88-
Alice Smith
89-
alice@example.com
90-
Acme Corp
91-
Recent emails:
92-
Weekend plans? (2024-01-15)
93-
[m]onthly [q]uarterly [y]early [s]kip [i]gnore [Enter=skip]>
94-
```
95-
96-
`frm triage --json` skips the interactive loop and outputs untriaged contacts as a JSON array. An agent can read this, call `frm track` or `frm ignore` for each contact, then repeat until the list is empty.
80+
- `3d` -- every 3 days
81+
- `2w` -- every 2 weeks
82+
- `1m` -- every month (30 days)
9783

9884
## Setup
9985

100-
Create `~/.frm/config.json` with at least one CardDAV service.
86+
Create `~/.frm/config.json` with at least one CardDAV service, or run `frm init` for the interactive wizard.
10187

10288
### iCloud
10389

@@ -168,12 +154,12 @@ You can override the config directory with `FRM_CONFIG_DIR`.
168154

169155
Contacts and metadata live in your CardDAV server via custom vCard fields:
170156

171-
- `X-FRM-FREQUENCY` tracking interval (e.g. `2w`, `1m`)
172-
- `X-FRM-IGNORE` `"true"` to permanently hide
173-
- `X-FRM-GROUP` freeform group tag
174-
- `X-FRM-SNOOZE-UNTIL` date to suppress until
157+
- `X-FRM-FREQUENCY` -- tracking interval (e.g. `2w`, `1m`)
158+
- `X-FRM-IGNORE` -- `"true"` to permanently hide
159+
- `X-FRM-GROUP` -- freeform group tag
160+
- `X-FRM-SNOOZE-UNTIL` -- date to suppress until
175161

176-
Interaction history is stored locally in `~/.frm/log.jsonl`. This is the only local state back it up or symlink it to a synced directory.
162+
Interaction history is stored locally in `~/.frm/log.jsonl`. This is the only local state -- back it up or symlink it to a synced directory.
177163

178164
## License
179165

carddav.go

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"os"
78
"strings"
89

910
"github.com/emersion/go-vcard"
@@ -63,19 +64,45 @@ func findContactByName(ctx context.Context, client *carddav.Client, book *cardda
6364
return nil, err
6465
}
6566
nameLower := strings.ToLower(name)
67+
var allNames []string
6668
for _, obj := range objs {
67-
if strings.ToLower(contactName(obj)) == nameLower {
69+
n := contactName(obj)
70+
if strings.ToLower(n) == nameLower {
6871
return &obj, nil
6972
}
73+
allNames = append(allNames, n)
7074
}
71-
return nil, fmt.Errorf("contact %q not found", name)
75+
76+
// No exact match. Try fuzzy matching.
77+
candidates := fuzzyFind(name, allNames)
78+
if len(candidates) == 1 && candidates[0].distance <= fuzzyMaxDistance {
79+
for _, obj := range objs {
80+
if contactName(obj) == candidates[0].name {
81+
fmt.Fprintf(os.Stderr, "Using closest match: %s\n", candidates[0].name)
82+
return &obj, nil
83+
}
84+
}
85+
}
86+
87+
return nil, &fuzzyMatchError{query: name, candidates: candidates}
7288
}
7389

7490
// findContactMulti searches all accounts for a contact by name.
7591
// Returns the matching object and the client it belongs to.
92+
// If no exact match is found, it tries fuzzy matching: substring first,
93+
// then edit distance. A single close match (distance <= 2) is auto-selected
94+
// with a notice on stderr. Multiple candidates produce a suggestion error.
7695
func findContactMulti(cfg Config, name string) (*carddav.AddressObject, *carddav.Client, error) {
7796
ctx := context.Background()
7897
nameLower := strings.ToLower(name)
98+
99+
// Collect all contacts across accounts for fuzzy fallback.
100+
type objWithClient struct {
101+
obj carddav.AddressObject
102+
client *carddav.Client
103+
}
104+
var allObjs []objWithClient
105+
79106
for _, svc := range cfg.carddavServices() {
80107
client, err := newCardDAVClient(svc)
81108
if err != nil {
@@ -93,9 +120,28 @@ func findContactMulti(cfg Config, name string) (*carddav.AddressObject, *carddav
93120
if strings.ToLower(contactName(obj)) == nameLower {
94121
return &obj, client, nil
95122
}
123+
allObjs = append(allObjs, objWithClient{obj: obj, client: client})
96124
}
97125
}
98-
return nil, nil, fmt.Errorf("contact %q not found", name)
126+
127+
// No exact match. Try fuzzy matching.
128+
names := make([]string, len(allObjs))
129+
nameToIdx := make(map[string]int, len(allObjs))
130+
for i, oc := range allObjs {
131+
n := contactName(oc.obj)
132+
names[i] = n
133+
nameToIdx[n] = i
134+
}
135+
136+
candidates := fuzzyFind(name, names)
137+
if len(candidates) == 1 && candidates[0].distance <= fuzzyMaxDistance {
138+
idx := nameToIdx[candidates[0].name]
139+
obj := allObjs[idx].obj
140+
fmt.Fprintf(os.Stderr, "Using closest match: %s\n", candidates[0].name)
141+
return &obj, allObjs[idx].client, nil
142+
}
143+
144+
return nil, nil, &fuzzyMatchError{query: name, candidates: candidates}
99145
}
100146

101147
// contactMatch holds a matched contact and its client, for multi-account mutations.
@@ -106,10 +152,18 @@ type contactMatch struct {
106152

107153
// findAllContactsMulti searches all accounts for contacts matching a name.
108154
// Returns all matches across all accounts.
155+
// Uses the same fuzzy fallback logic as findContactMulti.
109156
func findAllContactsMulti(cfg Config, name string) ([]contactMatch, error) {
110157
ctx := context.Background()
111158
nameLower := strings.ToLower(name)
112159
var matches []contactMatch
160+
161+
type objWithClient struct {
162+
obj carddav.AddressObject
163+
client *carddav.Client
164+
}
165+
var allObjs []objWithClient
166+
113167
for _, svc := range cfg.carddavServices() {
114168
client, err := newCardDAVClient(svc)
115169
if err != nil {
@@ -125,15 +179,34 @@ func findAllContactsMulti(cfg Config, name string) ([]contactMatch, error) {
125179
}
126180
for _, obj := range objs {
127181
if strings.ToLower(contactName(obj)) == nameLower {
128-
o := obj // copy for pointer
182+
o := obj
129183
matches = append(matches, contactMatch{obj: &o, client: client})
130184
}
185+
allObjs = append(allObjs, objWithClient{obj: obj, client: client})
131186
}
132187
}
133-
if len(matches) == 0 {
134-
return nil, fmt.Errorf("contact %q not found", name)
188+
if len(matches) > 0 {
189+
return matches, nil
135190
}
136-
return matches, nil
191+
192+
// No exact match. Try fuzzy matching.
193+
names := make([]string, len(allObjs))
194+
nameToIdx := make(map[string]int, len(allObjs))
195+
for i, oc := range allObjs {
196+
n := contactName(oc.obj)
197+
names[i] = n
198+
nameToIdx[n] = i
199+
}
200+
201+
candidates := fuzzyFind(name, names)
202+
if len(candidates) == 1 && candidates[0].distance <= fuzzyMaxDistance {
203+
idx := nameToIdx[candidates[0].name]
204+
o := allObjs[idx].obj
205+
fmt.Fprintf(os.Stderr, "Using closest match: %s\n", candidates[0].name)
206+
return []contactMatch{{obj: &o, client: allObjs[idx].client}}, nil
207+
}
208+
209+
return nil, &fuzzyMatchError{query: name, candidates: candidates}
137210
}
138211

139212
func contactName(obj carddav.AddressObject) string {

cmd_add.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,43 @@ func init() {
8383
card[vcard.FieldURL] = []*vcard.Field{{Value: url}}
8484
}
8585

86-
path := book.Path + newUUID() + ".vcf"
87-
if _, err := client.PutAddressObject(ctx, path, card); err != nil {
88-
return fmt.Errorf("creating contact: %w", err)
86+
dryRun := isDryRun(cmd)
87+
88+
if !dryRun {
89+
path := book.Path + newUUID() + ".vcf"
90+
if _, err := client.PutAddressObject(ctx, path, card); err != nil {
91+
return fmt.Errorf("creating contact: %w", err)
92+
}
93+
}
94+
95+
if isJSONMode(cmd) {
96+
out := map[string]interface{}{
97+
"action": "add",
98+
"name": name,
99+
}
100+
if email != "" {
101+
out["email"] = email
102+
}
103+
if phone != "" {
104+
out["phone"] = phone
105+
}
106+
if org != "" {
107+
out["org"] = org
108+
}
109+
if url != "" {
110+
out["url"] = url
111+
}
112+
if dryRun {
113+
out["dry_run"] = true
114+
}
115+
return printJSON(cmd, out)
89116
}
90117

91-
fmt.Printf("Added contact %s\n", name)
118+
if dryRun {
119+
fmt.Printf("Would add contact %s (dry run)\n", name)
120+
} else {
121+
fmt.Printf("Added contact %s\n", name)
122+
}
92123
return nil
93124
},
94125
}

0 commit comments

Comments
 (0)