jsoncrm 1.0.0__py3-none-any.whl

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.
jsoncrm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """jsoncrm — A generic JSON-file CRM pipeline tool."""
2
+
3
+ __version__ = "1.0.0"
jsoncrm/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """Allow running jsoncrm as a module: ``python -m jsoncrm``."""
3
+
4
+ from jsoncrm.tool import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
jsoncrm/backup.py ADDED
@@ -0,0 +1,91 @@
1
+ """Atomic, out-of-Git snapshots of configured CRM state."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from zipfile import ZIP_DEFLATED, ZipFile
10
+
11
+ MANIFEST_NAME = "backup-manifest.json"
12
+
13
+
14
+ def _git_root(crm_dir):
15
+ for candidate in (crm_dir, *crm_dir.parents):
16
+ if (candidate / ".git").exists():
17
+ return candidate
18
+ return None
19
+
20
+
21
+ def _default_output(crm_dir, now):
22
+ name = re.sub(r"[^A-Za-z0-9_.-]+", "-", crm_dir.name).strip("-.") or "crm"
23
+ timestamp = now.strftime("%Y%m%dT%H%M%S%fZ")
24
+ return Path.home() / ".jsoncrm" / "backups" / f"{name}-{timestamp}.zip"
25
+
26
+
27
+ def create_backup(crm_dir, source_files, output=None, now=None):
28
+ crm_dir = Path(crm_dir).expanduser().resolve()
29
+ now = now or datetime.now(timezone.utc)
30
+ created_at = now.isoformat(timespec="seconds").replace("+00:00", "Z")
31
+ output = Path(output).expanduser() if output else _default_output(crm_dir, now)
32
+ output = output.resolve()
33
+
34
+ git_root = _git_root(crm_dir)
35
+ if git_root is not None:
36
+ try:
37
+ output.relative_to(git_root)
38
+ except ValueError:
39
+ pass
40
+ else:
41
+ raise ValueError(f"backup destination must be outside the Git repository: {git_root}")
42
+
43
+ entries = []
44
+ missing = []
45
+ seen = set()
46
+ for source in source_files:
47
+ path = Path(source).expanduser()
48
+ if not path.is_absolute():
49
+ path = crm_dir / path
50
+ path = path.resolve()
51
+ try:
52
+ relative = path.relative_to(crm_dir)
53
+ except ValueError as exc:
54
+ raise ValueError(f"CRM source is outside {crm_dir}: {path}") from exc
55
+ archive_name = relative.as_posix()
56
+ if archive_name in seen:
57
+ continue
58
+ seen.add(archive_name)
59
+ if not path.exists():
60
+ missing.append(archive_name)
61
+ continue
62
+ if not path.is_file():
63
+ raise ValueError(f"CRM source is not a file: {path}")
64
+ entries.append((path, archive_name))
65
+
66
+ if not entries:
67
+ raise ValueError(f"no CRM files found to back up in {crm_dir}")
68
+ if output.exists():
69
+ raise ValueError(f"backup already exists: {output}")
70
+
71
+ archived_files = [archive_name for _, archive_name in entries]
72
+ manifest = {
73
+ "created_at": created_at,
74
+ "files": archived_files,
75
+ "missing": missing,
76
+ }
77
+ output.parent.mkdir(parents=True, exist_ok=True)
78
+ temporary = output.with_name(f".{output.name}.tmp-{uuid.uuid4().hex}")
79
+ try:
80
+ with ZipFile(temporary, "x", compression=ZIP_DEFLATED) as archive:
81
+ for path, archive_name in entries:
82
+ archive.write(path, archive_name)
83
+ archive.writestr(
84
+ MANIFEST_NAME,
85
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
86
+ )
87
+ os.replace(temporary, output)
88
+ finally:
89
+ temporary.unlink(missing_ok=True)
90
+
91
+ return {"backup": str(output), "files": archived_files, "missing": missing}
jsoncrm/config.py ADDED
@@ -0,0 +1,129 @@
1
+ """Configuration loader for jsoncrm.
2
+
3
+ Supports `.crm.json` in the working directory or an explicit ``--config`` path.
4
+ Falls back to built-in marketing-CRM defaults when no config is found.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from jsoncrm.schema import JSON_DB_ENCODING
11
+
12
+ DEFAULT_MARKETING_CONFIG = {
13
+ "name": "marketing-crm",
14
+ "pipeline": {
15
+ "stages": [
16
+ {"name": "leads", "file": "leads.json"},
17
+ {"name": "prospects", "file": "prospects.json"},
18
+ {"name": "customers", "file": "customers.json"},
19
+ ],
20
+ "transitions": {
21
+ "leads": ["prospects"],
22
+ "prospects": ["customers"],
23
+ },
24
+ },
25
+ "identity": {
26
+ "primary": "linkedin_url",
27
+ "fallback": ["id"],
28
+ },
29
+ "scores": {
30
+ "order": ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"],
31
+ "disqualified": "❌",
32
+ },
33
+ "blocklist": {
34
+ "file": "competitors.json",
35
+ "match_fields": ["company", "name"],
36
+ },
37
+ }
38
+
39
+
40
+ def _deep_merge(base, override):
41
+ """Recursively merge *override* into *base*."""
42
+ result = {}
43
+ for key, value in base.items():
44
+ if key in override:
45
+ if isinstance(value, dict) and isinstance(override[key], dict):
46
+ result[key] = _deep_merge(value, override[key])
47
+ else:
48
+ result[key] = override[key]
49
+ else:
50
+ result[key] = value
51
+ # Add keys that exist only in override
52
+ for key, value in override.items():
53
+ if key not in base:
54
+ result[key] = value
55
+ return result
56
+
57
+
58
+ class Config:
59
+ """Domain-agnostic CRM configuration."""
60
+
61
+ def __init__(self, data):
62
+ self._data = data
63
+
64
+ @classmethod
65
+ def from_file(cls, path=None):
66
+ """Load config from *path* or fall back to ``.crm.json`` in cwd."""
67
+ if path is not None:
68
+ config_path = Path(path)
69
+ else:
70
+ config_path = Path(".crm.json")
71
+
72
+ if config_path.exists():
73
+ with open(config_path, encoding=JSON_DB_ENCODING) as f:
74
+ override = json.load(f)
75
+ return cls(_deep_merge(DEFAULT_MARKETING_CONFIG, override))
76
+ return cls(_deep_merge(DEFAULT_MARKETING_CONFIG, {}))
77
+
78
+ # --- pipeline ---
79
+
80
+ @property
81
+ def pipeline_stages(self):
82
+ return self._data["pipeline"]["stages"]
83
+
84
+ @property
85
+ def pipeline_transitions(self):
86
+ return self._data["pipeline"].get("transitions", {})
87
+
88
+ # --- identity ---
89
+
90
+ @property
91
+ def identity_primary(self):
92
+ return self._data["identity"]["primary"]
93
+
94
+ @property
95
+ def identity_fallback(self):
96
+ return self._data["identity"].get("fallback", [])
97
+
98
+ # --- scores ---
99
+
100
+ def _scores_block(self):
101
+ # A missing, null, or explicitly empty "scores" block all mean the
102
+ # same thing: no override was really provided, so fall back to the
103
+ # default rather than silently producing zero valid scores or
104
+ # crashing on None.
105
+ return self._data.get("scores") or DEFAULT_MARKETING_CONFIG["scores"]
106
+
107
+ @property
108
+ def score_order(self):
109
+ order = self._scores_block().get("order") or DEFAULT_MARKETING_CONFIG["scores"]["order"]
110
+ return {s: i + 1 for i, s in enumerate(order)}
111
+
112
+ @property
113
+ def score_order_descending(self):
114
+ order = self._scores_block().get("order") or DEFAULT_MARKETING_CONFIG["scores"]["order"]
115
+ return list(reversed(order))
116
+
117
+ @property
118
+ def disqualified_score(self):
119
+ return self._scores_block().get("disqualified") or DEFAULT_MARKETING_CONFIG["scores"]["disqualified"]
120
+
121
+ # --- blocklist ---
122
+
123
+ @property
124
+ def blocklist_file(self):
125
+ return self._data["blocklist"]["file"]
126
+
127
+ @property
128
+ def blocklist_match_fields(self):
129
+ return self._data["blocklist"].get("match_fields", ["company", "name"])
jsoncrm/mcp.py ADDED
@@ -0,0 +1,335 @@
1
+ """Model Context Protocol server for jsoncrm."""
2
+
3
+ import argparse
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from jsoncrm.schema import JSON_DB_ENCODING
10
+ from jsoncrm.tool import build_parser
11
+
12
+ EXCLUDED_COMMANDS = frozenset({"serve", "mcp"})
13
+
14
+
15
+ def _canonical_command_parsers():
16
+ parser = build_parser()
17
+ subparsers = next(
18
+ action for action in parser._actions
19
+ if isinstance(action, argparse._SubParsersAction)
20
+ )
21
+ commands = {}
22
+ seen = set()
23
+ for name, command_parser in subparsers.choices.items():
24
+ if id(command_parser) in seen:
25
+ continue
26
+ seen.add(id(command_parser))
27
+ commands[name] = command_parser
28
+ return commands
29
+
30
+
31
+ COMMAND_PARSERS = _canonical_command_parsers()
32
+
33
+
34
+ def _build_cli_args(command, values):
35
+ positionals = []
36
+ options = []
37
+ for action in COMMAND_PARSERS[command]._actions:
38
+ if action.dest in ("help", "json"):
39
+ continue
40
+ value = values.get(action.dest)
41
+ if action.option_strings:
42
+ flag = next(
43
+ (option for option in action.option_strings if option.startswith("--")),
44
+ action.option_strings[0],
45
+ )
46
+ if action.nargs == 0:
47
+ if value != action.default:
48
+ options.append(flag)
49
+ continue
50
+ if value is None:
51
+ continue
52
+ options.append(flag)
53
+ if isinstance(value, dict):
54
+ options.append(json.dumps(value, ensure_ascii=False))
55
+ elif isinstance(value, (list, tuple)):
56
+ options.extend(str(item) for item in value)
57
+ else:
58
+ options.append(str(value))
59
+ else:
60
+ if value is None:
61
+ continue
62
+ if isinstance(value, (list, tuple)):
63
+ positionals.extend(str(item) for item in value)
64
+ else:
65
+ positionals.append(str(value))
66
+ return [command, *positionals, *options, "--json"]
67
+
68
+
69
+ def _run_command(command, config_path=None, **values):
70
+ argv = [sys.executable, "-m", "jsoncrm"]
71
+ if config_path:
72
+ argv.extend(["--config", config_path])
73
+ argv.extend(_build_cli_args(command, values))
74
+ result = subprocess.run(
75
+ argv,
76
+ capture_output=True,
77
+ text=True,
78
+ encoding=JSON_DB_ENCODING,
79
+ check=False,
80
+ )
81
+ stdout = result.stdout.strip()
82
+ stderr = result.stderr.strip()
83
+ if result.returncode:
84
+ message = "\n".join(part for part in (stdout, stderr) if part)
85
+ raise RuntimeError(message or f"jsoncrm {command} exited with status {result.returncode}")
86
+ try:
87
+ data = json.loads(stdout)
88
+ except json.JSONDecodeError:
89
+ return {"output": stdout}
90
+ return data if isinstance(data, dict) else {"result": data}
91
+
92
+
93
+ def create_server(config_path=None):
94
+ try:
95
+ from mcp.server.fastmcp import FastMCP
96
+ except ImportError as exc:
97
+ raise SystemExit("MCP support requires: pip install 'jsoncrm[mcp]'") from exc
98
+
99
+ if config_path:
100
+ config_path = str(Path(config_path).resolve())
101
+
102
+ server = FastMCP(
103
+ "jsoncrm",
104
+ instructions="Inspect and modify the configured git-tracked JSON CRM.",
105
+ )
106
+
107
+ @server.tool()
108
+ def search(query: str, person: bool = False, company: bool = False, competitor: bool = False):
109
+ """Search the CRM pipeline by name, company, LinkedIn URL, or email."""
110
+ return _run_command(
111
+ "search", config_path, query=query, person=person, company=company, competitor=competitor,
112
+ )
113
+
114
+ @server.tool()
115
+ def find(
116
+ database_file: str,
117
+ item_file: str | None = None,
118
+ item_json: dict | str | None = None,
119
+ output_file: str | None = None,
120
+ ):
121
+ """Find exact field matches in a CRM JSON file."""
122
+ return _run_command(
123
+ "find", config_path, database_file=database_file, item_file=item_file,
124
+ item_json=item_json, output_file=output_file,
125
+ )
126
+
127
+ @server.tool()
128
+ def add(
129
+ database_file: str,
130
+ item_file: str | None = None,
131
+ item_json: dict | str | None = None,
132
+ output_file: str | None = None,
133
+ dry_run: bool = False,
134
+ apply: bool = False,
135
+ ):
136
+ """Add one record to a CRM JSON file."""
137
+ return _run_command(
138
+ "add", config_path, database_file=database_file, item_file=item_file,
139
+ item_json=item_json, output_file=output_file, dry_run=dry_run, apply=apply,
140
+ )
141
+
142
+ @server.tool()
143
+ def update(
144
+ database_file: str,
145
+ item_file: str | None = None,
146
+ item_json: dict | str | None = None,
147
+ output_file: str | None = None,
148
+ dry_run: bool = False,
149
+ apply: bool = False,
150
+ ):
151
+ """Update one record identified by id or linkedin_url."""
152
+ return _run_command(
153
+ "update", config_path, database_file=database_file, item_file=item_file,
154
+ item_json=item_json, output_file=output_file, dry_run=dry_run, apply=apply,
155
+ )
156
+
157
+ @server.tool()
158
+ def delete(
159
+ database_file: str,
160
+ item_file: str | None = None,
161
+ item_json: dict | str | None = None,
162
+ output_file: str | None = None,
163
+ dry_run: bool = False,
164
+ apply: bool = False,
165
+ ):
166
+ """Delete one record identified by id or linkedin_url."""
167
+ return _run_command(
168
+ "delete", config_path, database_file=database_file, item_file=item_file,
169
+ item_json=item_json, output_file=output_file, dry_run=dry_run, apply=apply,
170
+ )
171
+
172
+ @server.tool()
173
+ def apply_update(pending_file: str = "default"):
174
+ """Apply updates from a pending update file."""
175
+ return _run_command("apply_update", config_path, pending_file=pending_file)
176
+
177
+ @server.tool()
178
+ def intake(file: str, output: str | None = None):
179
+ """Pick the next unscored record from an intake file."""
180
+ return _run_command("intake", config_path, file=file, output=output)
181
+
182
+ @server.tool(name="parse-from-linkedin-mcp")
183
+ def parse_from_linkedin_mcp(files: list[str]):
184
+ """Convert LinkedIn MCP output files to CRM flat lists in place."""
185
+ return _run_command("parse-from-linkedin-mcp", config_path, files=files)
186
+
187
+ @server.tool()
188
+ def shuffle(file: str, seed: int | None = None, dry_run: bool = False):
189
+ """Shuffle records in a JSON array file."""
190
+ return _run_command("shuffle", config_path, file=file, seed=seed, dry_run=dry_run)
191
+
192
+ @server.tool()
193
+ def top(
194
+ num: int = 1,
195
+ min_score: str | None = None,
196
+ include_contacted: bool = False,
197
+ include_disqualified: bool = False,
198
+ output: str | None = None,
199
+ file: str | None = None,
200
+ ):
201
+ """Return top-scored actionable leads."""
202
+ return _run_command(
203
+ "top", config_path, num=num, min_score=min_score,
204
+ include_contacted=include_contacted, include_disqualified=include_disqualified,
205
+ output=output, file=file,
206
+ )
207
+
208
+ @server.tool()
209
+ def merge(file: str, dry_run: bool = False, leads_file: str | None = None):
210
+ """Merge scored records from a source file into leads."""
211
+ return _run_command("merge", config_path, file=file, dry_run=dry_run, leads_file=leads_file)
212
+
213
+ @server.tool()
214
+ def deduplicate(
215
+ file: str,
216
+ dry_run: bool = False,
217
+ leads_file: str | None = None,
218
+ prospects_file: str | None = None,
219
+ customers_file: str | None = None,
220
+ ):
221
+ """Remove records whose LinkedIn URLs already exist in the pipeline."""
222
+ return _run_command(
223
+ "deduplicate", config_path, file=file, dry_run=dry_run, leads_file=leads_file,
224
+ prospects_file=prospects_file, customers_file=customers_file,
225
+ )
226
+
227
+ @server.tool(name="filter-competitors")
228
+ def filter_competitors(file: str, dry_run: bool = False):
229
+ """Remove records matching the configured competitor watchlist."""
230
+ return _run_command("filter-competitors", config_path, file=file, dry_run=dry_run)
231
+
232
+ @server.tool()
233
+ def promote(
234
+ identity_value: str,
235
+ identity_field: str | None = None,
236
+ lead: bool = False,
237
+ prospect: bool = False,
238
+ from_file: str | None = None,
239
+ to_file: str | None = None,
240
+ dry_run: bool = False,
241
+ apply: bool = False,
242
+ ):
243
+ """Move a lead or prospect to the next configured pipeline stage."""
244
+ return _run_command(
245
+ "promote", config_path, identity_value=identity_value, identity_field=identity_field,
246
+ lead=lead, prospect=prospect, from_file=from_file, to_file=to_file,
247
+ dry_run=dry_run, apply=apply,
248
+ )
249
+
250
+ @server.tool()
251
+ def stats(
252
+ leads_file: str | None = None,
253
+ prospects_file: str | None = None,
254
+ customers_file: str | None = None,
255
+ ):
256
+ """Show pipeline statistics."""
257
+ return _run_command(
258
+ "stats", config_path, leads_file=leads_file,
259
+ prospects_file=prospects_file, customers_file=customers_file,
260
+ )
261
+
262
+ @server.tool()
263
+ def validate(
264
+ leads_file: str | None = None,
265
+ prospects_file: str | None = None,
266
+ customers_file: str | None = None,
267
+ ):
268
+ """Validate pipeline JSON files for structural integrity."""
269
+ return _run_command(
270
+ "validate", config_path, leads_file=leads_file,
271
+ prospects_file=prospects_file, customers_file=customers_file,
272
+ )
273
+
274
+ @server.tool()
275
+ def recent(
276
+ num: int = 10,
277
+ stage: str | None = None,
278
+ leads_file: str | None = None,
279
+ prospects_file: str | None = None,
280
+ customers_file: str | None = None,
281
+ ):
282
+ """Show the most recently added pipeline records."""
283
+ return _run_command(
284
+ "recent", config_path, num=num, stage=stage, leads_file=leads_file,
285
+ prospects_file=prospects_file, customers_file=customers_file,
286
+ )
287
+
288
+ @server.tool()
289
+ def demote(
290
+ identity_value: str,
291
+ identity_field: str | None = None,
292
+ customer: bool = False,
293
+ prospect: bool = False,
294
+ from_file: str | None = None,
295
+ to_file: str | None = None,
296
+ dry_run: bool = False,
297
+ apply: bool = False,
298
+ ):
299
+ """Move a prospect or customer back one pipeline stage."""
300
+ return _run_command(
301
+ "demote", config_path, identity_value=identity_value, identity_field=identity_field,
302
+ customer=customer, prospect=prospect, from_file=from_file, to_file=to_file,
303
+ dry_run=dry_run, apply=apply,
304
+ )
305
+
306
+ @server.tool(name="list")
307
+ def list_records(
308
+ stage: str,
309
+ score: str | None = None,
310
+ company: str | None = None,
311
+ query: str | None = None,
312
+ limit: int | None = None,
313
+ file: str | None = None,
314
+ ):
315
+ """List and filter records in a pipeline stage."""
316
+ return _run_command(
317
+ "list", config_path, stage=stage, score=score, company=company,
318
+ query=query, limit=limit, file=file,
319
+ )
320
+
321
+ @server.tool()
322
+ def dump_emails(out: str | None = None):
323
+ """Dump all unique pipeline email addresses, comma separated."""
324
+ return _run_command("dump_emails", config_path, out=out)
325
+
326
+ @server.tool()
327
+ def backup(output: str | None = None):
328
+ """Create an atomic ZIP backup of configured CRM files outside Git."""
329
+ return _run_command("backup", config_path, output=output)
330
+
331
+ return server
332
+
333
+
334
+ def run_mcp(config_path=None):
335
+ create_server(config_path=config_path).run(transport="stdio")
jsoncrm/parsers.py ADDED
@@ -0,0 +1,98 @@
1
+ """LinkedIn MCP output parsers for jsoncrm."""
2
+
3
+ import json
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from jsoncrm.schema import JSON_DB_ENCODING
8
+ from jsoncrm.utils import atomic_write_json
9
+
10
+
11
+ def cmd_parse_from_linkedin_mcp(args):
12
+ for filename in args.files:
13
+ path = Path(filename)
14
+ if not path.exists():
15
+ print(f"Error: {path} not found")
16
+ continue
17
+
18
+ data = json.loads(path.read_text(encoding=JSON_DB_ENCODING))
19
+
20
+ if not isinstance(data, dict):
21
+ print(f"Error: {path.name} must be a recognized MCP output format (dict)")
22
+ continue
23
+
24
+ people_data = []
25
+
26
+ # Format 1: get_post_likers
27
+ if "likers" in data:
28
+ for l in data["likers"]:
29
+ people_data.append({
30
+ "raw_name": l.get("name", ""),
31
+ "url": l.get("url", "")
32
+ })
33
+
34
+ # Format 2: get_inbox
35
+ elif "conversations" in data:
36
+ for c in data["conversations"]:
37
+ url = c.get("thread_url") or ""
38
+ if c.get("username"):
39
+ url = f"https://www.linkedin.com/in/{c['username']}/"
40
+ people_data.append({
41
+ "raw_name": c.get("name", ""),
42
+ "url": url
43
+ })
44
+
45
+ # Format 3: search_people, get_person_profile, get_company_profile, etc.
46
+ elif "references" in data:
47
+ for section, refs in data["references"].items():
48
+ for r in refs:
49
+ if isinstance(r, dict) and r.get("kind") == "person":
50
+ url = r.get("url", "")
51
+ if url.startswith("/"):
52
+ url = f"https://www.linkedin.com{url}"
53
+ people_data.append({
54
+ "raw_name": r.get("text", ""),
55
+ "url": url
56
+ })
57
+
58
+ if not people_data:
59
+ print(f"Error: No people records found in {path.name}. Supported formats: likers, conversations, references.")
60
+ continue
61
+
62
+ print(f"Converting {len(people_data)} records from {path.name} to CRM format...")
63
+ out_data = []
64
+ today_str = str(date.today())
65
+
66
+ for p in people_data:
67
+ raw_name = p["raw_name"]
68
+ parts = raw_name.split("\n")
69
+ name = parts[0].strip()
70
+ position = parts[-1].strip() if len(parts) > 1 else ""
71
+ if position.startswith("·"):
72
+ position = position[1:].strip()
73
+ company = None
74
+ if " at " in position:
75
+ p_parts = position.split(" at ")
76
+ position = p_parts[0].strip()
77
+ company = p_parts[1].strip()
78
+ elif " @ " in position:
79
+ p_parts = position.split(" @ ")
80
+ position = p_parts[0].strip()
81
+ company = p_parts[1].strip()
82
+
83
+ out_data.append({
84
+ "name": name,
85
+ "position": position,
86
+ "company": company,
87
+ "linkedin_url": p["url"],
88
+ "connected": "1st degree" in raw_name,
89
+ "email": None,
90
+ "contacted_at": None,
91
+ "source": "linkedin_mcp",
92
+ "added": today_str,
93
+ "score": None,
94
+ "notes": ""
95
+ })
96
+
97
+ atomic_write_json(path, out_data)
98
+ print(f"Successfully converted {len(out_data)} records in {path.name}.")