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 +3 -0
- jsoncrm/__main__.py +7 -0
- jsoncrm/backup.py +91 -0
- jsoncrm/config.py +129 -0
- jsoncrm/mcp.py +335 -0
- jsoncrm/parsers.py +98 -0
- jsoncrm/schema.py +73 -0
- jsoncrm/server.py +460 -0
- jsoncrm/tool.py +1457 -0
- jsoncrm/utils.py +176 -0
- jsoncrm/web/app.js +543 -0
- jsoncrm/web/crm.js +99 -0
- jsoncrm/web/index.html +55 -0
- jsoncrm/web/style.css +281 -0
- jsoncrm-1.0.0.dist-info/METADATA +350 -0
- jsoncrm-1.0.0.dist-info/RECORD +20 -0
- jsoncrm-1.0.0.dist-info/WHEEL +5 -0
- jsoncrm-1.0.0.dist-info/entry_points.txt +2 -0
- jsoncrm-1.0.0.dist-info/licenses/LICENSE +661 -0
- jsoncrm-1.0.0.dist-info/top_level.txt +1 -0
jsoncrm/__init__.py
ADDED
jsoncrm/__main__.py
ADDED
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}.")
|