@dzhng/crm.cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1312 -0
  2. package/dist/cli.js +4291 -0
  3. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,1312 @@
1
+ # crm.cli
2
+
3
+ ![crm.cli — Your CRM is a filesystem](assets/cover.png)
4
+
5
+ **A headless, CLI-first CRM for developers who do sales.** Contacts, deals, and pipeline in a single SQLite file — queryable from your terminal, composable with Unix tools, and mountable as a virtual filesystem so any tool that reads files (Claude Code, Codex, grep, jq, vim) has full CRM access without any integration.
6
+
7
+ No server. No Docker. No accounts. No GUI. Just `bun install -g @dzhng/crm.cli` and go.
8
+
9
+ > **Sponsored by [Duet](https://duet.so)** — a cloud agent workspace with persistent AI. Set up crm.cli in your own private cloud computer and run it with Claude Code or Codex — no local setup required. [Try Duet →](https://duet.so)
10
+
11
+ ## Why crm.cli
12
+
13
+ Existing CRMs are GUI-first tools built for sales teams. If you're a technical founder, indie hacker, or engineer running BD, you're probably managing contacts in a spreadsheet you grep through. crm.cli is built for you.
14
+
15
+ **Your CRM is a filesystem.** Mount it with `crm mount ~/crm` and every tool that reads files — AI agents, shell scripts, editors — gets full CRM access for free. No MCP servers, no API keys, no integration code. The filesystem is the universal API.
16
+
17
+ ```bash
18
+ crm mount ~/crm
19
+ ls ~/crm/contacts/
20
+ cat ~/crm/contacts/jane-doe.json | jq .name
21
+ # Point Claude Code at ~/crm and ask it to research your pipeline
22
+ ```
23
+
24
+ **Deep data normalization.** A CSV has zero setup cost. crm.cli justifies its existence with structured intelligence: E.164 phone normalization (look up by any format), website normalization, social handle extraction (paste a LinkedIn URL, it stores the handle), entity merge with reference relinking, and fuzzy duplicate detection. This is what a spreadsheet can never provide.
25
+
26
+ ```bash
27
+ crm contact add --name "Jane Doe" \
28
+ --email jane@acme.com \
29
+ --phone "+1-212-555-1234" \
30
+ --linkedin linkedin.com/in/janedoe \
31
+ --company Acme
32
+ # Phone stored as E.164, LinkedIn URL → handle, company auto-linked
33
+ ```
34
+
35
+ **Pipe-friendly by default.** Every command outputs structured data. Pipe to `jq`, `grep`, `awk`, or feed into scripts. `--format json` on everything.
36
+
37
+ ```bash
38
+ crm deal list --stage qualified --format json | jq '.[] | .value'
39
+ crm find "that fintech CTO from London"
40
+ crm report pipeline
41
+ crm dupes --threshold 0.5
42
+ ```
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ # Install globally via bun (recommended)
48
+ bun install -g @dzhng/crm.cli
49
+
50
+ # Or npx without installing
51
+ bunx @dzhng/crm.cli contact list
52
+
53
+ # Or install the compiled binary
54
+ curl -fsSL https://raw.githubusercontent.com/dzhng/crm.cli/main/install.sh | sh
55
+ ```
56
+
57
+ ## Storage
58
+
59
+ Everything lives in a single SQLite file. Default: `~/.crm/crm.db`.
60
+
61
+ ```bash
62
+ crm --db ./my-project.db contact list # use a specific database
63
+ export CRM_DB=./team.db # or set via env var
64
+ ```
65
+
66
+ No server. No Docker. No accounts. Back it up by copying the file.
67
+
68
+ ## Configuration
69
+
70
+ Config is loaded from `crm.toml`. Resolution order (first match wins):
71
+
72
+ 1. `--config <path>` flag (explicit)
73
+ 2. `CRM_CONFIG` env var
74
+ 3. Walk up from CWD: `./crm.toml` → `../crm.toml` → `../../crm.toml` → ... → `/crm.toml`
75
+ 4. `~/.crm/config.toml` (global fallback)
76
+
77
+ This means you can drop a `crm.toml` in your project root and it applies to everyone working in that directory — just like `.gitignore` or `biome.jsonc`.
78
+
79
+ ```bash
80
+ # Project-scoped config
81
+ echo '[pipeline]
82
+ stages = ["discovery", "demo", "trial", "closed-won", "closed-lost"]' > ./crm.toml
83
+
84
+ # Global config (applies everywhere unless overridden)
85
+ mkdir -p ~/.crm
86
+ cat > ~/.crm/config.toml << 'EOF'
87
+ [database]
88
+ path = "~/.crm/crm.db"
89
+
90
+ [pipeline]
91
+ stages = ["lead", "qualified", "proposal", "negotiation", "closed-won", "closed-lost"]
92
+ won_stage = "closed-won"
93
+ lost_stage = "closed-lost"
94
+
95
+ [defaults]
96
+ format = "table" # table | json | csv | tsv | ids
97
+
98
+ [phone]
99
+ default_country = "US" # ISO 3166-1 alpha-2; for numbers without country code
100
+ display = "international" # international | national | e164
101
+
102
+ [hooks]
103
+ # pre-contact-add = "echo 'adding contact'"
104
+ # post-contact-add = "echo 'contact added'"
105
+ # pre-deal-stage-change = "echo 'stage changing'"
106
+
107
+ [mount]
108
+ default_path = "~/crm" # where `crm mount` mounts by default
109
+ readonly = false # set true to prevent writes via FUSE
110
+ max_recent_activity = 10 # activities shown per entity in FUSE
111
+ search_limit = 20 # max results from search/find
112
+
113
+ EOF
114
+ ```
115
+
116
+ Settings in a closer `crm.toml` override the global config. The `--config` flag overrides everything.
117
+
118
+ ---
119
+
120
+ ## CLI Reference
121
+
122
+ ### Global Flags
123
+
124
+ | Flag | Env Var | Description |
125
+ | ----------------- | ------------ | --------------------------------------------------- |
126
+ | `--db <path>` | `CRM_DB` | Path to SQLite database |
127
+ | `--format <fmt>` | `CRM_FORMAT` | Output format: `table`, `json`, `csv`, `tsv`, `ids` |
128
+ | `--no-color` | `NO_COLOR` | Disable colored output |
129
+ | `--config <path>` | `CRM_CONFIG` | Path to config file |
130
+ | `--version` | — | Print version |
131
+
132
+ ---
133
+
134
+ ### Contacts
135
+
136
+ People you interact with.
137
+
138
+ #### `crm contact add`
139
+
140
+ ```bash
141
+ crm contact add --name "Jane Doe" --email jane@acme.com
142
+ crm contact add --name "Jane Doe" --email jane@acme.com --email jane.doe@gmail.com --phone "+1-212-555-1234" --phone "+44-20-7946-0958" --company Acme --company "Acme Ventures" --tag hot-lead --tag enterprise
143
+ crm contact add --name "Jane Doe" --email jane@acme.com --linkedin janedoe --x janedoe --set title=CTO --set source=conference --set notes="Met at SaaStr"
144
+ crm contact add --name "Jane Doe" --linkedin https://linkedin.com/in/janedoe # URL input also works — handle is extracted
145
+ ```
146
+
147
+ | Flag | Required | Description |
148
+ | ------------ | -------- | -------------------------------------------------------------------- |
149
+ | `--name` | yes | Full name |
150
+ | `--email` | no | Email address (repeatable — multiple allowed) |
151
+ | `--phone` | no | Phone number (repeatable — multiple allowed) |
152
+ | `--company` | no | Company name (repeatable — links to existing or creates stub) |
153
+ | `--tag` | no | Tag (repeatable — multiple allowed) |
154
+ | `--linkedin` | no | LinkedIn handle or URL (stored as handle, e.g. `janedoe`) |
155
+ | `--x` | no | X / Twitter handle or URL (stored as handle, e.g. `janedoe`) |
156
+ | `--bluesky` | no | Bluesky handle or URL (stored as handle, e.g. `janedoe.bsky.social`) |
157
+ | `--telegram` | no | Telegram handle or URL (stored as handle, e.g. `janedoe`) |
158
+ | `--set` | no | Custom field as `key=value` (repeatable — multiple allowed) |
159
+
160
+ Prints the created contact ID to stdout.
161
+
162
+ Social handles enforce uniqueness — no two contacts can share the same handle on a given platform. All of these input formats are accepted and normalized to the raw handle:
163
+
164
+ | Input | Stored as |
165
+ | ----------------------------------- | ------------------ |
166
+ | `janedoe` | `janedoe` |
167
+ | `@janedoe` | `janedoe` |
168
+ | `https://linkedin.com/in/janedoe` | `janedoe` |
169
+ | `linkedin.com/in/janedoe` | `janedoe` |
170
+ | `www.linkedin.com/in/janedoe` | `janedoe` |
171
+ | `x.com/janedoe` | `janedoe` |
172
+ | `twitter.com/janedoe` | `janedoe` |
173
+ | `bsky.app/profile/user.bsky.social` | `user.bsky.social` |
174
+ | `t.me/janedoe` | `janedoe` |
175
+
176
+ The same normalization applies to lookups, edits, and duplicate detection — `crm contact show x.com/janedoe` matches a contact stored with handle `janedoe`.
177
+
178
+ #### `crm contact list`
179
+
180
+ ```bash
181
+ crm contact list
182
+ crm contact list --tag hot-lead
183
+ crm contact list --company Acme --format json
184
+ crm contact list --filter "title~=CTO AND source=conference"
185
+ crm contact list --limit 10 --offset 20
186
+ ```
187
+
188
+ | Flag | Description |
189
+ | ----------- | ------------------------------------------------------------------------ |
190
+ | `--tag` | Filter by tag (multiple = AND) |
191
+ | `--company` | Filter by company name (matches any linked company) |
192
+ | `--filter` | Filter expression (see Filtering) — works on both core and custom fields |
193
+ | `--sort` | Sort field: `name`, `created`, `updated` |
194
+ | `--reverse` | Reverse sort order |
195
+ | `--limit` | Max results (default: no limit) |
196
+ | `--offset` | Skip N results |
197
+
198
+ #### `crm contact show <id-or-email-or-phone-or-handle>`
199
+
200
+ ```bash
201
+ crm contact show ct_01J8Z...
202
+ crm contact show jane@acme.com
203
+ crm contact show "+1-212-555-1234"
204
+ crm contact show janedoe # matches any social handle
205
+ crm contact show linkedin.com/in/janedoe # URL also works — extracts handle
206
+ ```
207
+
208
+ Accepts ID, any email, any phone number, or any social handle (LinkedIn, X, Bluesky, Telegram). URLs are also accepted — the handle is extracted before lookup. Shows full contact details including linked companies, deals, activity history, tags, and custom fields.
209
+
210
+ #### `crm contact edit <id-or-email-or-phone-or-handle>`
211
+
212
+ ```bash
213
+ crm contact edit jane@acme.com --name "Jane Smith"
214
+ crm contact edit ct_01J8Z... --add-email jane.personal@gmail.com --rm-email old@acme.com
215
+ crm contact edit ct_01J8Z... --add-phone "+44-20-7946-0958" --rm-phone "+1-310-555-9876"
216
+ crm contact edit ct_01J8Z... --add-company "Acme Ventures" --rm-company "Old Corp"
217
+ crm contact edit ct_01J8Z... --set title=CEO --set source=referral
218
+ crm contact edit jane@acme.com --add-tag vip --rm-tag cold
219
+ ```
220
+
221
+ | Flag | Description |
222
+ | --------------- | ------------------------------------------------------ |
223
+ | `--name` | Update name |
224
+ | `--add-email` | Add an email address |
225
+ | `--rm-email` | Remove an email address |
226
+ | `--add-phone` | Add a phone number |
227
+ | `--rm-phone` | Remove a phone number |
228
+ | `--add-company` | Link to a company (creates stub if needed) |
229
+ | `--rm-company` | Unlink from a company |
230
+ | `--linkedin` | Set LinkedIn handle (accepts URL — extracts handle) |
231
+ | `--x` | Set X / Twitter handle (accepts URL — extracts handle) |
232
+ | `--bluesky` | Set Bluesky handle (accepts URL — extracts handle) |
233
+ | `--telegram` | Set Telegram handle (accepts URL — extracts handle) |
234
+ | `--set` | Set custom field `key=value` |
235
+ | `--unset` | Remove custom field |
236
+ | `--add-tag` | Add tag |
237
+ | `--rm-tag` | Remove tag |
238
+
239
+ #### `crm contact rm <id-or-email-or-phone-or-handle>`
240
+
241
+ ```bash
242
+ crm contact rm jane@acme.com
243
+ crm contact rm "+1-212-555-1234" --force # skip confirmation
244
+ ```
245
+
246
+ Prompts for confirmation unless `--force` is passed. Removes the contact and unlinks from deals/companies (does not delete linked entities).
247
+
248
+ #### `crm contact merge <id> <id>`
249
+
250
+ ```bash
251
+ crm contact merge ct_01J8Z... ct_02K9A...
252
+ ```
253
+
254
+ Merges two contacts. Keeps the first, absorbs data from the second. Emails, phones, companies, tags, custom fields, and activity history are combined. Deals linked to the second contact are relinked to the first. The first contact's name and social handles take priority. Prints the surviving contact ID.
255
+
256
+ ---
257
+
258
+ ### Companies
259
+
260
+ Organizations that contacts belong to.
261
+
262
+ #### `crm company add`
263
+
264
+ ```bash
265
+ crm company add --name "Acme Corp" --website acme.com
266
+ crm company add --name "Acme Corp" --website acme.com/ventures --website acme.co.uk --phone "+1-212-555-1234" --phone "+44-20-7946-0958" --tag enterprise --set industry=SaaS --set size=50-200
267
+ ```
268
+
269
+ | Flag | Required | Description |
270
+ | ----------- | -------- | -------------------------------------------------------- |
271
+ | `--name` | yes | Company name |
272
+ | `--website` | no | Website URL (repeatable — multiple allowed) |
273
+ | `--phone` | no | Phone number (repeatable — multiple allowed) |
274
+ | `--tag` | no | Tag (repeatable — multiple allowed) |
275
+ | `--set` | no | Custom field `key=value` (repeatable — multiple allowed) |
276
+
277
+ #### `crm company list`
278
+
279
+ ```bash
280
+ crm company list
281
+ crm company list --filter "industry=SaaS" --format json
282
+ crm company list --tag enterprise --sort name
283
+ ```
284
+
285
+ | Flag | Description |
286
+ | ----------- | ---------------------------------------- |
287
+ | `--tag` | Filter by tag |
288
+ | `--filter` | Filter expression (see Filtering) |
289
+ | `--sort` | Sort field: `name`, `created`, `updated` |
290
+ | `--reverse` | Reverse sort order |
291
+ | `--limit` | Max results |
292
+ | `--offset` | Skip N results |
293
+
294
+ #### `crm company show <id-or-website-or-phone>`
295
+
296
+ ```bash
297
+ crm company show acme.com
298
+ crm company show co_01J8Z...
299
+ crm company show "+1-212-555-1234"
300
+ ```
301
+
302
+ Accepts ID, any stored website, or any phone number. Shows company details plus all linked contacts and deals.
303
+
304
+ #### `crm company edit <id-or-website-or-phone>`
305
+
306
+ ```bash
307
+ crm company edit acme.com --name "Acme Inc" --set industry=Fintech
308
+ crm company edit co_01J8Z... --add-website acme.co.uk --add-phone "+44-20-7946-0958"
309
+ crm company edit acme.com --rm-website old-acme.com --rm-phone "+1-415-555-0000"
310
+ ```
311
+
312
+ | Flag | Description |
313
+ | --------------- | ---------------------------- |
314
+ | `--name` | Update name |
315
+ | `--add-website` | Add a website URL |
316
+ | `--rm-website` | Remove a website URL |
317
+ | `--add-phone` | Add a phone number |
318
+ | `--rm-phone` | Remove a phone number |
319
+ | `--set` | Set custom field `key=value` |
320
+ | `--unset` | Remove custom field |
321
+ | `--add-tag` | Add tag |
322
+ | `--rm-tag` | Remove tag |
323
+
324
+ #### `crm company rm <id-or-website-or-phone>`
325
+
326
+ Prompts for confirmation unless `--force` is passed. Unlinks contacts and deals but does not delete them.
327
+
328
+ #### `crm company merge <id> <id>`
329
+
330
+ ```bash
331
+ crm company merge co_01J8Z... co_02K9A...
332
+ ```
333
+
334
+ Merges two companies. Keeps the first, absorbs data from the second. Websites, phones, tags, and custom fields are combined. All contacts and deals linked to the second company are relinked to the first. The first company's name takes priority. Prints the surviving company ID.
335
+
336
+ ---
337
+
338
+ ### Deals
339
+
340
+ Pipeline tracking for opportunities.
341
+
342
+ #### `crm deal add`
343
+
344
+ ```bash
345
+ crm deal add --title "Acme Enterprise" --value 50000
346
+ crm deal add --title "Acme Enterprise" --value 50000 --stage qualified --contact jane@acme.com --company acme.com --expected-close 2026-06-01 --probability 60 --tag q2
347
+ ```
348
+
349
+ | Flag | Required | Description |
350
+ | ------------------ | -------- | -------------------------------------------------------------------- |
351
+ | `--title` | yes | Deal name |
352
+ | `--value` | no | Deal value in dollars (integer) |
353
+ | `--stage` | no | Pipeline stage (default: first configured stage) |
354
+ | `--contact` | no | Link contact by ID or email (repeatable — auto-creates if not found) |
355
+ | `--company` | no | Link company by ID or website (auto-creates if not found) |
356
+ | `--expected-close` | no | Expected close date (`YYYY-MM-DD`) |
357
+ | `--probability` | no | Win probability 0-100 |
358
+ | `--tag` | no | Tag (multiple allowed) |
359
+ | `--set` | no | Custom field `key=value` |
360
+
361
+ #### `crm deal list`
362
+
363
+ ```bash
364
+ crm deal list
365
+ crm deal list --stage qualified
366
+ crm deal list --stage qualified --sort value --reverse
367
+ crm deal list --contact jane@acme.com
368
+ crm deal list --company acme.com
369
+ crm deal list --min-value 10000 --max-value 100000
370
+ ```
371
+
372
+ | Flag | Description |
373
+ | ------------- | ------------------------------------------------------------------------ |
374
+ | `--stage` | Filter by stage |
375
+ | `--contact` | Filter by linked contact |
376
+ | `--company` | Filter by linked company |
377
+ | `--min-value` | Minimum deal value |
378
+ | `--max-value` | Maximum deal value |
379
+ | `--tag` | Filter by tag |
380
+ | `--filter` | Filter expression (see Filtering) — works on both core and custom fields |
381
+ | `--sort` | Sort: `title`, `value`, `stage`, `created`, `updated`, `expected-close` |
382
+ | `--reverse` | Reverse sort order |
383
+ | `--limit` | Max results |
384
+ | `--offset` | Skip first N results |
385
+
386
+ #### `crm deal show <id>`
387
+
388
+ Shows full deal details including stage history (timestamps of every stage transition).
389
+
390
+ #### `crm deal edit <id>`
391
+
392
+ Same pattern as other entities. `--stage` is NOT used here — use `crm deal move` for stage changes (so transitions are tracked properly).
393
+
394
+ | Flag | Description |
395
+ | ------------------ | ------------------------------------------------------ |
396
+ | `--title` | Update title |
397
+ | `--value` | Update value |
398
+ | `--add-contact` | Link an additional contact (auto-creates if not found) |
399
+ | `--rm-contact` | Unlink a contact |
400
+ | `--company` | Change linked company |
401
+ | `--expected-close` | Update expected close date |
402
+ | `--probability` | Update win probability |
403
+ | `--set` | Set custom field `key=value` |
404
+ | `--unset` | Remove custom field |
405
+ | `--add-tag` | Add tag |
406
+ | `--rm-tag` | Remove tag |
407
+
408
+ #### `crm deal move <id> --stage <stage>`
409
+
410
+ ```bash
411
+ crm deal move dl_01J8Z... --stage negotiation
412
+ crm deal move dl_01J8Z... --stage closed-won --note "Signed annual contract"
413
+ crm deal move dl_01J8Z... --stage closed-lost --note "Budget cut"
414
+ ```
415
+
416
+ Records the stage transition with a timestamp by creating a `stage-change` activity entry. This activity includes the old stage, new stage, and timestamp. Stage history is reconstructed from these activity entries — `crm deal show` includes a `stage_history` array with `{stage, at}` pairs. `--note` attaches a note to the transition (shown in `crm report won` and `crm report lost`).
417
+
418
+ Moving a deal to its current stage is rejected with an error.
419
+
420
+ #### `crm deal rm <id>`
421
+
422
+ Prompts for confirmation unless `--force` is passed.
423
+
424
+ #### `crm pipeline`
425
+
426
+ ```bash
427
+ crm pipeline
428
+ crm pipeline --format json
429
+ ```
430
+
431
+ Visual pipeline summary showing count, total value, and weighted value per stage.
432
+
433
+ ```
434
+ Pipeline
435
+ ────────────────────────────────────────────────────────
436
+ lead ████████████ 12 $45,000 wt: $4,500
437
+ qualified ████████ 8 $120,000 wt: $48,000
438
+ proposal ███ 3 $85,000 wt: $42,500
439
+ negotiation ██ 2 $60,000 wt: $48,000
440
+ ────────────────────────────────────────────────────────
441
+ Total: 25 deals | $310,000 pipeline | $143,000 weighted
442
+ ```
443
+
444
+ ---
445
+
446
+ ### Auto-Create Behavior
447
+
448
+ When linking entities via `--contact` or `--company` flags, crm.cli auto-creates stubs if the referenced entity doesn't exist. This applies consistently across all commands:
449
+
450
+ | Command | `--contact` | `--company` |
451
+ | ----------------------------- | ------------ | ------------ |
452
+ | `crm contact add` | — | auto-creates |
453
+ | `crm contact edit` | — | auto-creates |
454
+ | `crm deal add` | auto-creates | auto-creates |
455
+ | `crm deal edit --add-contact` | auto-creates | auto-creates |
456
+ | `crm log` | auto-creates | auto-creates |
457
+
458
+ Auto-created contacts use the email local part as the name (e.g. `jane@acme.com` → name `jane`). Auto-created companies use the provided name directly. All stubs can be enriched later with `crm contact edit` or `crm company edit`.
459
+
460
+ `--deal` flags always require an existing deal — deals are not auto-created.
461
+
462
+ ---
463
+
464
+ ### Activities
465
+
466
+ Interaction log attached to contacts, companies, or deals.
467
+
468
+ #### `crm log <type> <body> [flags]`
469
+
470
+ ```bash
471
+ crm log note "Had a great intro call" --contact jane@acme.com
472
+ crm log call "Demo scheduled for Friday" --contact jane@acme.com --set duration=15m
473
+ crm log meeting "Went through pricing" --contact jane@acme.com --company Acme
474
+ crm log email "Sent proposal PDF" --contact jane@acme.com --deal dl_abc123
475
+ crm log note "General observation" # standalone, no entity link
476
+ ```
477
+
478
+ | Argument | Description |
479
+ | -------- | ------------------------------------------------------------------------------------------------ |
480
+ | `type` | One of: `note`, `call`, `meeting`, `email` (plus `stage-change` auto-created by `crm deal move`) |
481
+ | `body` | Free-text description |
482
+
483
+ | Flag | Description |
484
+ | ----------- | -------------------------------------------------------- |
485
+ | `--contact` | Link to contact (repeatable — auto-creates if not found) |
486
+ | `--company` | Link to company (auto-creates if not found) |
487
+ | `--deal` | Link to deal |
488
+ | `--at` | Override timestamp (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`) |
489
+ | `--set` | Custom field `key=value` (e.g. `--set duration=15m`) |
490
+
491
+ #### `crm activity list`
492
+
493
+ ```bash
494
+ crm activity list
495
+ crm activity list --contact jane@acme.com
496
+ crm activity list --company acme.com
497
+ crm activity list --deal dl_01J8Z...
498
+ crm activity list --type call --since 2026-01-01
499
+ crm activity list --limit 20
500
+ ```
501
+
502
+ | Flag | Description |
503
+ | ----------- | -------------------------------- |
504
+ | `--contact` | Filter by contact |
505
+ | `--company` | Filter by company |
506
+ | `--deal` | Filter by deal |
507
+ | `--type` | Filter by activity type |
508
+ | `--since` | Activities after date |
509
+ | `--sort` | Sort field: `type`, `created_at` |
510
+ | `--reverse` | Reverse sort order |
511
+ | `--limit` | Max results |
512
+ | `--offset` | Skip first N results |
513
+
514
+ ---
515
+
516
+ ### Tags
517
+
518
+ Flat labels across all entity types.
519
+
520
+ #### `crm tag <entity-ref> <tags...>`
521
+
522
+ ```bash
523
+ crm tag jane@acme.com hot-lead enterprise
524
+ crm tag acme.com target-account
525
+ crm tag dl_01J8Z... q2 high-priority
526
+ ```
527
+
528
+ #### `crm untag <entity-ref> <tags...>`
529
+
530
+ ```bash
531
+ crm untag jane@acme.com cold
532
+ ```
533
+
534
+ #### `crm tag list`
535
+
536
+ ```bash
537
+ crm tag list # all tags with counts
538
+ crm tag list --type contact # tags used on contacts only
539
+ ```
540
+
541
+ ---
542
+
543
+ ### Search
544
+
545
+ #### `crm search <query>`
546
+
547
+ Exact keyword search using SQLite FTS5. Searches across all entity types and activity notes.
548
+
549
+ ```bash
550
+ crm search "acme"
551
+ crm search "acme" --type contact
552
+ crm search "acme" --type contact,company
553
+ crm search "enterprise plan" --format json
554
+ ```
555
+
556
+ | Flag | Description |
557
+ | -------- | ------------------------------------------------------------------ |
558
+ | `--type` | Restrict to entity types: `contact`, `company`, `deal`, `activity` |
559
+
560
+ #### `crm dupes`
561
+
562
+ Find likely duplicate entities using fuzzy matching on string fields. This is a review command for agents and operators — it suggests suspicious pairs, but does not merge anything.
563
+
564
+ ```bash
565
+ crm dupes
566
+ crm dupes --type contact
567
+ crm dupes --type company --format json
568
+ crm dupes --limit 20
569
+ ```
570
+
571
+ Typical signals:
572
+
573
+ - similar contact names with different emails
574
+ - similar company names with different websites
575
+ - same contact name + same company name
576
+ - similar social handles across contacts
577
+
578
+ | Flag | Description |
579
+ | ------------- | ---------------------------------- |
580
+ | `--type` | Restrict to `contact` or `company` |
581
+ | `--limit` | Max results (default: 50) |
582
+ | `--threshold` | Minimum similarity score 0.0-1.0 |
583
+
584
+ Output includes both candidate entities plus the reasons they were flagged. Exact duplicates already prevented by uniqueness constraints (email, phone, social handles) should not appear here.
585
+
586
+ #### `crm find <query>`
587
+
588
+ Full-text keyword search across all entities using SQLite FTS5.
589
+
590
+ ```bash
591
+ crm find "fintech"
592
+ crm find "enterprise SaaS"
593
+ crm find "jane"
594
+ ```
595
+
596
+ Uses SQLite's built-in FTS5 full-text search — no API keys, no network calls, no external dependencies. The search index is automatically maintained as you add/edit entities.
597
+
598
+ | Flag | Description |
599
+ | ------------- | -------------------------------------------------- |
600
+ | `--type` | Restrict to entity types |
601
+ | `--limit` | Max results (default: 10) |
602
+ | `--threshold` | Minimum keyword match score 0.0-1.0 (default: 0.3) |
603
+
604
+ #### `crm index`
605
+
606
+ ```bash
607
+ crm index rebuild # rebuild the full search index
608
+ crm index status # show index stats (row count, last updated, model info)
609
+ ```
610
+
611
+ The search index updates automatically on every write operation. Manual rebuild is only needed after config changes or corruption.
612
+
613
+ ---
614
+
615
+ ### Reports
616
+
617
+ #### `crm report pipeline`
618
+
619
+ Pipeline summary with deal counts, values, and weighted values per stage.
620
+
621
+ ```bash
622
+ crm report pipeline
623
+ crm report pipeline --format json
624
+ crm report pipeline --format markdown > pipeline.md
625
+ ```
626
+
627
+ #### `crm report activity`
628
+
629
+ Activity volume over a time period.
630
+
631
+ ```bash
632
+ crm report activity # last 30 days
633
+ crm report activity --period 7d # last 7 days
634
+ crm report activity --period 90d --by type # grouped by activity type
635
+ crm report activity --by contact # grouped by contact
636
+ ```
637
+
638
+ | Flag | Description |
639
+ | ---------- | ---------------------------------------------- |
640
+ | `--period` | Time window: `7d`, `30d`, `90d`, `1y` |
641
+ | `--by` | Group by: `type`, `contact`, `company`, `deal` |
642
+
643
+ #### `crm report stale`
644
+
645
+ Contacts and deals with no recent activity.
646
+
647
+ ```bash
648
+ crm report stale # no activity in 30 days (default)
649
+ crm report stale --days 7 # no activity in 7 days
650
+ crm report stale --type deal # only stale deals
651
+ crm report stale --type contact # only stale contacts
652
+ ```
653
+
654
+ #### `crm report conversion`
655
+
656
+ Stage-to-stage conversion rates.
657
+
658
+ ```bash
659
+ crm report conversion
660
+ crm report conversion --since 2026-01-01
661
+ ```
662
+
663
+ Output:
664
+
665
+ ```
666
+ Stage Conversion Rates
667
+ ──────────────────────────────────────
668
+ lead → qualified 42% (50/120)
669
+ qualified → proposal 65% (33/50)
670
+ proposal → negotiation 72% (24/33)
671
+ negotiation → won 58% (14/24)
672
+ ──────────────────────────────────────
673
+ Overall: 12% lead-to-close
674
+ ```
675
+
676
+ #### `crm report velocity`
677
+
678
+ Average time deals spend in each stage.
679
+
680
+ ```bash
681
+ crm report velocity
682
+ crm report velocity --won-only # only count deals that were won
683
+ ```
684
+
685
+ #### `crm report forecast`
686
+
687
+ Weighted pipeline forecast by expected close date.
688
+
689
+ ```bash
690
+ crm report forecast
691
+ crm report forecast --period Q2-2026
692
+ crm report forecast --period 2026-06 # single month
693
+ ```
694
+
695
+ #### `crm report won` / `crm report lost`
696
+
697
+ Summary of closed deals.
698
+
699
+ ```bash
700
+ crm report won --period 30d
701
+ crm report lost --period 30d
702
+ ```
703
+
704
+ Both reports include a `notes` field with any notes attached via `crm deal move --note`.
705
+
706
+ ---
707
+
708
+ ### Import / Export
709
+
710
+ #### `crm import <entity-type> <file>`
711
+
712
+ ```bash
713
+ crm import contacts leads.csv
714
+ crm import contacts leads.json
715
+ crm import companies companies.csv
716
+ crm import deals deals.csv
717
+ ```
718
+
719
+ CSV files expect headers matching core field names (`name`, `email`, `phone`, `company`, `tags`). Any unrecognized column headers are imported as custom fields. Tags are comma-separated within the field.
720
+
721
+ JSON files expect an array of objects with the same field names.
722
+
723
+ | Flag | Description |
724
+ | --------------- | ------------------------------------------------------------------------------- |
725
+ | `--dry-run` | Preview what would be imported without writing |
726
+ | `--skip-errors` | Continue on invalid rows instead of aborting |
727
+ | `--update` | Update existing records (match by email/website) instead of skipping duplicates |
728
+
729
+ #### `crm export <entity-type>`
730
+
731
+ ```bash
732
+ crm export contacts --format csv > contacts.csv
733
+ crm export deals --format json > deals.json
734
+ crm export all --format json > full-backup.json
735
+ ```
736
+
737
+ `crm export all` exports the entire database (contacts, companies, deals, activities, tags) as a single JSON structure.
738
+
739
+ ---
740
+
741
+ ### Bulk Operations
742
+
743
+ All list commands output IDs when using `--format ids`, enabling pipe-based bulk operations:
744
+
745
+ ```bash
746
+ # Tag all contacts from Acme as enterprise
747
+ crm contact list --company Acme --format ids | xargs -I{} crm tag {} enterprise
748
+
749
+ # Move all qualified deals over $50k to proposal
750
+ crm deal list --stage qualified --min-value 50000 --format ids | xargs -I{} crm deal move {} --stage proposal
751
+
752
+ # Import from stdin
753
+ cat leads.json | crm import contacts -
754
+
755
+ # Chain with other tools
756
+ crm contact list --format json | jq '.[] | select(.tags | contains(["hot-lead"]))' | ...
757
+ ```
758
+
759
+ ---
760
+
761
+ ### Hooks
762
+
763
+ Shell commands triggered on mutations. Configured in `~/.crm/config.toml`:
764
+
765
+ ```toml
766
+ [hooks]
767
+ post-contact-add = "~/.crm/hooks/notify-slack.sh"
768
+ post-deal-stage-change = "~/.crm/hooks/deal-moved.sh"
769
+ pre-contact-rm = "~/.crm/hooks/confirm-delete.sh"
770
+ ```
771
+
772
+ Hooks receive the entity data as JSON via stdin. Pre-hooks can abort the operation by exiting non-zero.
773
+
774
+ Available hooks:
775
+
776
+ - `pre-*` / `post-*` for: `contact-add`, `contact-edit`, `contact-rm`, `company-add`, `company-edit`, `company-rm`, `deal-add`, `deal-edit`, `deal-rm`, `deal-stage-change`, `activity-add`
777
+
778
+ ---
779
+
780
+ ## Custom Fields
781
+
782
+ All entities (contacts, companies, deals, activities) support arbitrary custom fields via `--set key=value`. Custom fields store any JSON value — strings, numbers, booleans, arrays, objects.
783
+
784
+ ```bash
785
+ # String value (default when using --set)
786
+ crm contact add --name "Jane" --set title=CTO --set source=conference
787
+
788
+ # JSON values (prefix with json: for non-string types)
789
+ crm contact edit jane@acme.com --set "json:score=85" --set "json:verified=true"
790
+ crm company edit acme.com --set "json:offices=[\"NYC\",\"London\"]"
791
+
792
+ # Remove a custom field
793
+ crm contact edit jane@acme.com --unset title
794
+
795
+ # Filter on custom fields (same syntax as core fields)
796
+ crm contact list --filter "title~=CTO"
797
+ crm company list --filter "industry=SaaS"
798
+ ```
799
+
800
+ Only a small set of fields are hard-coded per entity — everything else lives in `custom_fields`:
801
+
802
+ | Entity | Hard-coded fields | Everything else → `custom_fields` |
803
+ | -------- | ----------------------------------------------------------------------------------------------------- | --------------------------------- |
804
+ | Contact | `name`, `emails[]`, `phones[]`, `companies[]`, `linkedin`, `x`, `bluesky`, `telegram`, `tags[]` | title, source, notes, ... |
805
+ | Company | `name`, `websites[]`, `phones[]`, `tags[]` | industry, size, founded, ... |
806
+ | Deal | `title`, `value`, `stage`, `contacts[]` (multi), `company`, `expected_close`, `probability`, `tags[]` | source, channel, priority, ... |
807
+ | Activity | `type`, `body`, `contacts[]` (multi), `company`, `deal`, `created_at` | duration, outcome, attendees, ... |
808
+
809
+ Hard-coded fields drive business logic (entity resolution, pipeline math, relationships, reports). Custom fields are metadata — flexible, filterable, but no special behavior.
810
+
811
+ ---
812
+
813
+ ## Phone Normalization
814
+
815
+ All phone numbers are normalized to [E.164](https://en.wikipedia.org/wiki/E.164) format on input using [`libphonenumber-js`](https://gitlab.com/nicolo-ribaudo/libphonenumber-js). This ensures consistent storage, deduplication, and lookup regardless of how numbers are entered.
816
+
817
+ **Storage:** Always E.164 (`+12125551234`).
818
+
819
+ **Display:** Configurable via `phone.display` in `crm.toml`:
820
+
821
+ | Format | Example | Description |
822
+ | ------------------------- | ----------------- | ----------------------------------------- |
823
+ | `international` (default) | `+1 212 555 1234` | Human-readable with spaces |
824
+ | `national` | `(212) 555-1234` | Local format (requires `default_country`) |
825
+ | `e164` | `+12125551234` | Raw stored format |
826
+
827
+ **Input:** Any reasonable format is accepted and normalized:
828
+
829
+ ```bash
830
+ crm contact add --name "Jane" --phone "+1-212-555-1234" # international with dashes
831
+ crm contact add --name "Jane" --phone "(212) 555-1234" # national (uses default_country)
832
+ crm contact add --name "Jane" --phone "2125551234" # digits only (uses default_country)
833
+ crm contact add --name "Jane" --phone "+12125551234" # E.164
834
+ # All four store the same E.164 value: +12125551234
835
+ ```
836
+
837
+ **Lookup:** Any format resolves to the same entity:
838
+
839
+ ```bash
840
+ crm contact show "+12125551234" # E.164
841
+ crm contact show "(212) 555-1234" # national
842
+ crm contact show "212-555-1234" # partial
843
+ # All three find the same contact
844
+ ```
845
+
846
+ **Dedup:** All formats normalize to E.164, so duplicate detection works across formats:
847
+
848
+ ```bash
849
+ crm contact add --name "Jane" --phone "+1-212-555-1234"
850
+ crm contact add --name "Bob" --phone "(212) 555-1234" # fails: duplicate phone
851
+ ```
852
+
853
+ **Validation:** Invalid numbers are rejected:
854
+
855
+ ```bash
856
+ crm contact add --name "Jane" --phone "not-a-number" # error: invalid phone number
857
+ crm contact add --name "Jane" --phone "123" # error: too short
858
+ ```
859
+
860
+ **Country code:** Numbers without a country code use `phone.default_country` from config (ISO 3166-1 alpha-2). If unset, a country code is required.
861
+
862
+ ```toml
863
+ # crm.toml
864
+ [phone]
865
+ default_country = "US"
866
+ display = "international"
867
+ ```
868
+
869
+ **FUSE:** `_by-phone/` symlinks use E.164 filenames (`+12125551234.json`), so agents can look up phones in any format by normalizing first, or browse the directory for the canonical E.164 listing.
870
+
871
+ ---
872
+
873
+ ## Website Normalization
874
+
875
+ Company websites are stored as full URLs.
876
+
877
+ Input is normalized for consistent storage and lookup:
878
+
879
+ - strip protocol if provided
880
+ - strip `www.` prefix
881
+ - lowercase the host
882
+ - preserve the path
883
+ - drop the trailing slash only when there is no path
884
+
885
+ ```bash
886
+ crm company add --name "Acme" --website "https://www.Acme.com/labs"
887
+ # Stored as: acme.com/labs
888
+ ```
889
+
890
+ This means path-based companies can remain distinct:
891
+
892
+ ```bash
893
+ crm company add --name "Globex Research" --website "globex.com/research"
894
+ crm company add --name "Globex Consulting" --website "globex.com/consulting"
895
+ ```
896
+
897
+ **Dedup:** exact match after normalization is a duplicate. The same normalized website cannot belong to two different companies:
898
+
899
+ ```bash
900
+ crm company add --name "Acme Corp" --website "acme.com/labs"
901
+ crm company add --name "Acme Inc" --website "www.acme.com/labs" # fails: duplicate website
902
+ ```
903
+
904
+ **Different paths are distinct:**
905
+
906
+ ```bash
907
+ crm company add --name "Globex Research" --website "globex.com/research"
908
+ crm company add --name "Globex Consulting" --website "globex.com/consulting" # allowed — different path
909
+ ```
910
+
911
+ **Subdomains are distinct:**
912
+
913
+ ```bash
914
+ crm company add --name "Acme US" --website "us.acme.com"
915
+ crm company add --name "Acme EU" --website "eu.acme.com" # allowed — different subdomain
916
+ ```
917
+
918
+ **Lookup:** equivalent input formats resolve to the same normalized website:
919
+
920
+ ```bash
921
+ crm company show "https://www.acme.com/labs"
922
+ crm company show "ACME.COM/labs"
923
+ ```
924
+
925
+ **FUSE:** `_by-website/` symlinks use the normalized website value, encoded as a filename-safe path.
926
+
927
+ ---
928
+
929
+ ## Filtering
930
+
931
+ The `--filter` flag accepts expressions:
932
+
933
+ ```
934
+ field=value # exact match
935
+ field!=value # not equal
936
+ field~=value # contains (case-insensitive)
937
+ field>value / field<value # comparison (numeric/date fields)
938
+ expr AND expr # both conditions
939
+ expr OR expr # either condition
940
+ ```
941
+
942
+ Custom fields are addressed by name — no prefix needed:
943
+
944
+ ```bash
945
+ crm contact list --filter "title~=CTO AND company=Acme"
946
+ crm deal list --filter "value>10000 AND stage!=closed-lost"
947
+ crm contact list --filter "source=inbound OR source=referral"
948
+ crm company list --filter "industry=SaaS AND size~=50"
949
+ ```
950
+
951
+ ---
952
+
953
+ ## Output Formats
954
+
955
+ Every command that produces output supports `--format`:
956
+
957
+ | Format | Description |
958
+ | ------- | ------------------------------------ |
959
+ | `table` | Human-readable ASCII table (default) |
960
+ | `json` | JSON array of objects |
961
+ | `csv` | Comma-separated values with header |
962
+ | `tsv` | Tab-separated values with header |
963
+ | `ids` | One ID per line (for piping) |
964
+
965
+ Set a default via `CRM_FORMAT` env var or `defaults.format` in config.
966
+
967
+ ---
968
+
969
+ ## IDs
970
+
971
+ All entities use prefixed ULID-based IDs:
972
+
973
+ | Entity | Prefix | Example |
974
+ | -------- | ------ | ------------------ |
975
+ | Contact | `ct_` | `ct_01J8ZVXB3K...` |
976
+ | Company | `co_` | `co_01J8ZVXB3K...` |
977
+ | Deal | `dl_` | `dl_01J8ZVXB3K...` |
978
+ | Activity | `ac_` | `ac_01J8ZVXB3K...` |
979
+
980
+ Commands that accept an entity reference also accept email, phone, or social handle (contacts), or website URL/host or phone (companies) as shortcuts.
981
+
982
+ ---
983
+
984
+ ## Virtual Filesystem (FUSE)
985
+
986
+ Mount the CRM as a read/write filesystem. AI agents (or humans) can explore CRM data using standard file operations — `ls`, `cat`, `find`, `grep`.
987
+
988
+ ### Mount
989
+
990
+ ```bash
991
+ crm mount ~/crm # mount with default DB
992
+ crm mount ~/crm --db ./team.db # mount a specific database
993
+ crm mount ~/crm --readonly # read-only mode (no writes)
994
+ crm unmount ~/crm # unmount
995
+ ```
996
+
997
+ The mount point stays live — changes made via the CLI or filesystem are reflected immediately in both directions.
998
+
999
+ ### Filesystem Layout
1000
+
1001
+ ```
1002
+ ~/crm/
1003
+ ├── llm.txt # agent instructions (read this first)
1004
+ ├── contacts/
1005
+ │ ├── ct_01J8Z...jane-doe.json # full contact as JSON
1006
+ │ ├── ct_02K9A...john-smith.json
1007
+ │ ├── _by-email/ # symlinks for email lookup
1008
+ │ │ ├── jane@acme.com.json → ../ct_01J8Z...jane-doe.json
1009
+ │ │ └── john@globex.com.json → ../ct_02K9A...john-smith.json
1010
+ │ ├── _by-phone/ # symlinks for phone lookup (E.164 filenames)
1011
+ │ │ └── +12125551234.json → ../ct_01J8Z...jane-doe.json
1012
+ │ ├── _by-linkedin/ # symlinks for LinkedIn handle lookup
1013
+ │ │ └── janedoe.json → ../ct_01J8Z...jane-doe.json
1014
+ │ ├── _by-x/ # symlinks for X handle lookup
1015
+ │ │ └── janedoe.json → ../ct_01J8Z...jane-doe.json
1016
+ │ ├── _by-bluesky/ # symlinks for Bluesky handle lookup
1017
+ │ ├── _by-telegram/ # symlinks for Telegram handle lookup
1018
+ │ ├── _by-company/ # symlinks grouped by company (contact appears under each)
1019
+ │ │ └── acme-corp/
1020
+ │ │ └── ct_01J8Z...jane-doe.json → ../../ct_01J8Z...jane-doe.json
1021
+ │ └── _by-tag/ # symlinks grouped by tag
1022
+ │ ├── hot-lead/
1023
+ │ │ └── ct_01J8Z...jane-doe.json → ../../ct_01J8Z...jane-doe.json
1024
+ │ └── enterprise/
1025
+ │ └── ct_01J8Z...jane-doe.json → ../../ct_01J8Z...jane-doe.json
1026
+ ├── companies/
1027
+ │ ├── co_01J8Z...acme-corp.json
1028
+ │ ├── _by-website/
1029
+ │ │ ├── acme.com.json → ../co_01J8Z...acme-corp.json
1030
+ │ │ └── acme.co.uk.json → ../co_01J8Z...acme-corp.json
1031
+ │ ├── _by-phone/ # E.164 filenames
1032
+ │ │ └── +12125551234.json → ../co_01J8Z...acme-corp.json
1033
+ │ └── _by-tag/
1034
+ │ └── enterprise/
1035
+ │ └── co_01J8Z...acme-corp.json → ../../co_01J8Z...acme-corp.json
1036
+ ├── deals/
1037
+ │ ├── dl_01J8Z...acme-enterprise.json
1038
+ │ ├── _by-stage/
1039
+ │ │ ├── lead/
1040
+ │ │ ├── qualified/
1041
+ │ │ ├── proposal/
1042
+ │ │ ├── negotiation/
1043
+ │ │ ├── closed-won/
1044
+ │ │ └── closed-lost/
1045
+ │ ├── _by-company/
1046
+ │ │ └── acme-corp/
1047
+ │ │ └── dl_01J8Z...acme-enterprise.json → ../../dl_01J8Z...acme-enterprise.json
1048
+ │ └── _by-tag/
1049
+ │ └── q2/
1050
+ ├── activities/
1051
+ │ ├── ac_01J8Z...note-2026-04-01.json
1052
+ │ ├── _by-contact/
1053
+ │ │ └── ct_01J8Z...jane-doe/
1054
+ │ │ └── ac_01J8Z...note-2026-04-01.json → ../../ac_01J8Z...note-2026-04-01.json
1055
+ │ ├── _by-company/
1056
+ │ ├── _by-deal/
1057
+ │ └── _by-type/
1058
+ │ ├── note/
1059
+ │ ├── call/
1060
+ │ ├── meeting/
1061
+ │ └── email/
1062
+ ├── pipeline.json # pipeline summary (read-only)
1063
+ ├── reports/
1064
+ │ ├── pipeline.json # same as above
1065
+ │ ├── stale.json # stale contacts/deals
1066
+ │ ├── forecast.json # weighted forecast
1067
+ │ ├── conversion.json # stage conversion rates
1068
+ │ ├── velocity.json # time per stage
1069
+ │ ├── won.json # closed-won summary
1070
+ │ └── lost.json # closed-lost summary
1071
+ ├── tags.json # all tags with counts
1072
+ └── search/ # query by reading a "file"
1073
+ └── <query>.json # cat ~/crm/search/"acme CTO".json
1074
+ ```
1075
+
1076
+ ### File Format
1077
+
1078
+ Each entity file is a self-contained JSON document with linked data inlined:
1079
+
1080
+ ```json
1081
+ // ~/crm/contacts/ct_01J8Z...jane-doe.json
1082
+ {
1083
+ "id": "ct_01J8ZVXB3K...",
1084
+ "name": "Jane Doe",
1085
+ "emails": ["jane@acme.com", "jane.doe@gmail.com"],
1086
+ "phones": ["+12125551234"],
1087
+ "companies": [{ "id": "co_01J8Z...", "name": "Acme Corp" }],
1088
+ "linkedin": "janedoe",
1089
+ "x": "janedoe",
1090
+ "bluesky": null,
1091
+ "telegram": null,
1092
+ "tags": ["hot-lead", "enterprise"],
1093
+ "custom_fields": {
1094
+ "title": "CTO",
1095
+ "source": "conference"
1096
+ },
1097
+ "deals": [
1098
+ { "id": "dl_01J8Z...", "title": "Acme Enterprise", "stage": "qualified", "value": 50000 }
1099
+ ],
1100
+ "recent_activity": [
1101
+ {
1102
+ "id": "ac_01J8Z...",
1103
+ "type": "note",
1104
+ "note": "Had a great intro call",
1105
+ "created_at": "2026-04-01T10:30:00Z"
1106
+ }
1107
+ ],
1108
+ "created_at": "2026-03-15T09:00:00Z",
1109
+ "updated_at": "2026-04-01T10:30:00Z"
1110
+ }
1111
+ ```
1112
+
1113
+ ### Read Operations
1114
+
1115
+ ```bash
1116
+ # Browse contacts
1117
+ ls ~/crm/contacts/
1118
+ cat ~/crm/contacts/ct_01J8Z...jane-doe.json
1119
+
1120
+ # Look up by email or phone
1121
+ cat ~/crm/contacts/_by-email/jane@acme.com.json
1122
+ cat ~/crm/contacts/_by-phone/+12125551234.json
1123
+
1124
+ # List all deals in a pipeline stage
1125
+ ls ~/crm/deals/_by-stage/qualified/
1126
+
1127
+ # Get all contacts at a company
1128
+ ls ~/crm/contacts/_by-company/acme-corp/
1129
+
1130
+ # Get all activities for a contact
1131
+ ls ~/crm/activities/_by-contact/ct_01J8Z...jane-doe/
1132
+
1133
+ # Read pipeline report
1134
+ cat ~/crm/pipeline.json
1135
+
1136
+ # Read stale report
1137
+ cat ~/crm/reports/stale.json
1138
+
1139
+ # Search (read a virtual file whose name is the query)
1140
+ cat ~/crm/search/"fintech CTO".json
1141
+
1142
+ # Use standard tools
1143
+ grep -r "enterprise" ~/crm/contacts/
1144
+ find ~/crm/deals/_by-stage/lead/ -name "*.json" | xargs jq '.value'
1145
+ ```
1146
+
1147
+ ### Write Operations
1148
+
1149
+ Writes are **full-document replacements** — the JSON you write becomes the complete state of the entity. This matches how files work: read → modify → write back.
1150
+
1151
+ **Validation is strict.** Every write is validated before being applied:
1152
+
1153
+ | Error | errno | Feedback |
1154
+ | ---------------------------- | -------- | ----------------------------------------------------------------- |
1155
+ | Malformed JSON (parse error) | `EINVAL` | `Invalid JSON: Unexpected token...` |
1156
+ | Unknown key | `EINVAL` | `Unknown field: "bogus". Valid fields: name, emails, phones, ...` |
1157
+ | Missing required field | `EINVAL` | `Missing required field: "name"` |
1158
+ | Type mismatch | `EINVAL` | `Invalid type for "emails": expected array, got string` |
1159
+ | Write in readonly mode | `EROFS` | `Filesystem mounted read-only` |
1160
+
1161
+ Errors are returned as the write syscall error, so agents get immediate feedback and can self-correct.
1162
+
1163
+ ```bash
1164
+ # Create a new contact (write any filename — ID is auto-assigned)
1165
+ echo '{"name": "Bob Smith", "emails": ["bob@globex.com"]}' > ~/crm/contacts/new.json
1166
+
1167
+ # Update an existing contact — FULL document write (read, modify, write back)
1168
+ jq '.custom_fields.title = "VP Engineering"' ~/crm/contacts/ct_01J8Z...jane-doe.json | \
1169
+ sponge ~/crm/contacts/ct_01J8Z...jane-doe.json
1170
+
1171
+ # Delete a contact
1172
+ rm ~/crm/contacts/ct_01J8Z...jane-doe.json
1173
+
1174
+ # Move a deal stage (update the stage field in the full document)
1175
+ jq '.stage = "closed-won"' ~/crm/deals/dl_01J8Z...acme-enterprise.json | \
1176
+ sponge ~/crm/deals/dl_01J8Z...acme-enterprise.json
1177
+
1178
+ # Log an activity (write to activities/)
1179
+ echo '{"type": "note", "entity_ref": "jane@acme.com", "note": "Follow up on proposal"}' \
1180
+ > ~/crm/activities/new.json
1181
+
1182
+ # Error cases — all rejected with EINVAL
1183
+ echo 'not json' > ~/crm/contacts/new.json # parse error
1184
+ echo '{"bogus": 1}' > ~/crm/contacts/new.json # unknown field
1185
+ echo '{"emails": ["a@b.com"]}' > ~/crm/contacts/new.json # missing "name"
1186
+ echo '{"name": "X", "emails": "wrong"}' > ~/crm/contacts/new.json # type mismatch
1187
+ ```
1188
+
1189
+ ### AI Agent Usage
1190
+
1191
+ The filesystem is designed for agents that navigate via `ls`, `cat`, and `find`. A `llm.txt` file at the root describes the filesystem structure and usage — agents should read this first. An agent exploring CRM data might:
1192
+
1193
+ ```
1194
+ Agent: Let me understand this filesystem.
1195
+ > cat ~/crm/llm.txt
1196
+
1197
+ Agent: Let me check the pipeline.
1198
+ > cat ~/crm/pipeline.json
1199
+
1200
+ Agent: Who are the contacts at Acme?
1201
+ > ls ~/crm/contacts/_by-company/acme-corp/
1202
+ > cat ~/crm/contacts/_by-company/acme-corp/ct_01J8Z...jane-doe.json
1203
+
1204
+ Agent: What deals are stale?
1205
+ > cat ~/crm/reports/stale.json
1206
+
1207
+ Agent: Let me search for that fintech CTO.
1208
+ > cat ~/crm/search/"fintech CTO london".json
1209
+
1210
+ Agent: I'll update her title.
1211
+ > jq '.custom_fields.title = "CEO"' ~/crm/contacts/ct_01J8Z...jane-doe.json > /tmp/update.json
1212
+ > mv /tmp/update.json ~/crm/contacts/ct_01J8Z...jane-doe.json
1213
+ ```
1214
+
1215
+ ### Configuration
1216
+
1217
+ FUSE settings in `crm.toml`:
1218
+
1219
+ ```toml
1220
+ [mount]
1221
+ default_path = "~/crm" # default mount point for `crm mount`
1222
+ readonly = false # default to read-write
1223
+ max_recent_activity = 10 # how many activities to inline in entity files
1224
+ search_limit = 20 # max results for search/ virtual files
1225
+ ```
1226
+
1227
+ ### Platform Notes
1228
+
1229
+ - **Linux:** Works out of the box (FUSE is in the kernel).
1230
+ - **macOS:** Requires [FUSE-T](https://www.fuse-t.org/) (`brew install fuse-t`, recommended) or [macFUSE](https://osxfuse.github.io/) (`brew install --cask macfuse`).
1231
+ - **Windows:** Not supported (use WSL).
1232
+ - **Containers/sandboxes:** FUSE requires `--privileged` or `--device /dev/fuse`. If unavailable, use the CLI with `--format json` instead.
1233
+
1234
+ ---
1235
+
1236
+ ## Stack
1237
+
1238
+ - **Runtime:** [Bun](https://bun.sh)
1239
+ - **Language:** TypeScript (strict mode)
1240
+ - **Database:** SQLite via [libSQL](https://github.com/tursodatabase/libsql) + [Drizzle ORM](https://orm.drizzle.team)
1241
+ - **Phone normalization:** [libphonenumber-js](https://gitlab.com/nicolo-ribaudo/libphonenumber-js) (E.164)
1242
+ - **Website normalization:** [normalize-url](https://github.com/sindresorhus/normalize-url)
1243
+ - **Linting:** [Biome](https://biomejs.dev) via [Ultracite](https://github.com/haydenbleasel/ultracite)
1244
+ - **Testing:** `bun test` (functional tests at the CLI level)
1245
+
1246
+ ---
1247
+
1248
+ ## Distribution
1249
+
1250
+ ### Package Manager
1251
+
1252
+ ```bash
1253
+ # Install globally via bun (recommended)
1254
+ bun install -g @dzhng/crm.cli
1255
+
1256
+ # Or via npm
1257
+ npm install -g @dzhng/crm.cli
1258
+
1259
+ # Or use without installing
1260
+ bunx @dzhng/crm.cli contact list
1261
+ npx @dzhng/crm.cli contact list
1262
+ ```
1263
+
1264
+ ### Compiled Binary
1265
+
1266
+ Pre-built binaries are available on [GitHub Releases](https://github.com/dzhng/crm.cli/releases), compiled via `bun build --compile` for:
1267
+
1268
+ - Linux x64
1269
+ - Linux arm64
1270
+ - macOS x64 (Intel)
1271
+ - macOS arm64 (Apple Silicon)
1272
+
1273
+ ```bash
1274
+ # Download and install the latest release
1275
+ curl -fsSL https://raw.githubusercontent.com/dzhng/crm.cli/main/install.sh | sh
1276
+ ```
1277
+
1278
+ ### Install Script
1279
+
1280
+ The install script (`install.sh`) handles:
1281
+
1282
+ 1. Detecting your platform (Linux/macOS, x64/arm64)
1283
+ 2. Downloading the correct binary from GitHub Releases
1284
+ 3. Installing it to `~/.local/bin` (or `/usr/local/bin` with sudo)
1285
+ 4. Installing FUSE dependencies:
1286
+ - Linux: `libfuse3-dev` via apt/yum/pacman
1287
+ - macOS: `macfuse` via Homebrew
1288
+
1289
+ ```bash
1290
+ curl -fsSL https://raw.githubusercontent.com/dzhng/crm.cli/main/install.sh | sh
1291
+ ```
1292
+
1293
+ ### CI/CD
1294
+
1295
+ GitHub Actions pipeline:
1296
+
1297
+ - **On push to main:** Run all tests via `bun test`
1298
+ - **On tag (`v*`):** Build binaries for all platforms, publish to npm, create GitHub Release with assets
1299
+
1300
+ ---
1301
+
1302
+ ## Sponsor
1303
+
1304
+ crm.cli is sponsored by **[Duet](https://duet.so)** — a cloud agent workspace where every user gets a private cloud computer with a persistent, always-on AI agent. Set up crm.cli in your own Duet workspace and run it with Claude Code or Codex in the cloud — no local setup required.
1305
+
1306
+ [Try Duet &rarr;](https://duet.so)
1307
+
1308
+ ---
1309
+
1310
+ ## License
1311
+
1312
+ MIT