agentlinkops 0.6.13 → 0.6.16

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 (178) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +25 -2
  4. package/PACK-ARTIFACT.json +16 -0
  5. package/PACKS.json +591 -0
  6. package/README.md +34 -48
  7. package/cli/adapters/suppliers.js +1 -1
  8. package/cli/agent-setup.js +101 -17
  9. package/cli/campaign-bridge.js +325 -0
  10. package/cli/campaign-outcomes.js +107 -0
  11. package/cli/catalog.js +45702 -18674
  12. package/cli/check.js +40 -10
  13. package/cli/client.js +2 -1
  14. package/cli/commands.js +17 -8
  15. package/cli/connection.js +92 -18
  16. package/cli/contact-adoption.js +91 -0
  17. package/cli/context.js +13 -2
  18. package/cli/doctor.js +15 -4
  19. package/cli/import.js +7 -1
  20. package/cli/key-store.js +115 -0
  21. package/cli/ledger.js +1 -0
  22. package/cli/local-result.js +41 -6
  23. package/cli/main.js +51 -12
  24. package/cli/mcp-client.js +2 -2
  25. package/cli/migration.js +308 -0
  26. package/cli/prompt.js +25 -0
  27. package/cli/public-http.js +168 -0
  28. package/cli/state.js +14 -0
  29. package/cli/sync.js +1 -1
  30. package/package.json +7 -8
  31. package/references/citation-panels.md +2 -0
  32. package/references/cli.md +2 -2
  33. package/references/disavow.md +1 -1
  34. package/references/discovery-handoff.md +1 -1
  35. package/references/local-crm-draft-handoff.md +1 -1
  36. package/references/portable-ledger-handoff.md +11 -10
  37. package/references/recipes/catalog.json +263 -7
  38. package/references/recipes/command-notes.md +5 -3
  39. package/references/recipes/internal-link-improvement.md +25 -0
  40. package/references/recipes/placement-reconciliation.md +53 -1
  41. package/scripts/portable-handoff.py +17 -4
  42. package/shared/admission-contract.js +3 -2
  43. package/shared/admission-report-contract.js +3 -2
  44. package/shared/analytics-history-contract.js +15 -0
  45. package/shared/campaign-outcome-contract.js +12 -0
  46. package/shared/campaign-prospect-contract.js +23 -0
  47. package/shared/classification-contract.js +54 -0
  48. package/shared/contact-record-contract.js +27 -0
  49. package/shared/disavow-contract.js +37 -9
  50. package/shared/index-observation-contract.js +5 -4
  51. package/shared/internal-links-contract.js +143 -0
  52. package/shared/link-category-contract.js +35 -0
  53. package/shared/link-category.js +138 -0
  54. package/shared/link-labels.js +26 -0
  55. package/shared/link-summary-contract.js +17 -0
  56. package/shared/observation-event.js +1 -1
  57. package/shared/onboarding.js +20 -0
  58. package/shared/response-contract.js +38 -5
  59. package/shared/search-connection-contract.js +2 -1
  60. package/shared/table-page-contract.js +13 -0
  61. package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
  62. package/skills/agentlinkops-assets/SKILL.md +3 -1
  63. package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
  64. package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
  65. package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
  66. package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
  67. package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
  68. package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
  69. package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
  70. package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  71. package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
  72. package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  73. package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
  74. package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
  75. package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
  76. package/skills/agentlinkops-campaigns/SKILL.md +7 -5
  77. package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
  78. package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
  79. package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
  80. package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
  81. package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
  82. package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
  83. package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
  84. package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
  85. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
  86. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  87. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
  88. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  89. package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
  90. package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
  91. package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
  92. package/skills/agentlinkops-connect/SKILL.md +2 -0
  93. package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
  94. package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
  95. package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
  96. package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
  97. package/skills/agentlinkops-crm/SKILL.md +26 -9
  98. package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
  99. package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
  100. package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
  101. package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
  102. package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
  103. package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
  104. package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
  105. package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
  106. package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
  107. package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  108. package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
  109. package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  110. package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
  111. package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
  112. package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
  113. package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
  114. package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
  115. package/skills/agentlinkops-discovery/SKILL.md +4 -2
  116. package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
  117. package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
  118. package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
  119. package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
  120. package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
  121. package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
  122. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
  123. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  124. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
  125. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  126. package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
  127. package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
  128. package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
  129. package/src/competitors/contract.js +17 -1
  130. package/src/context/site-profile.js +34 -121
  131. package/src/corpus/acquire.js +68 -32
  132. package/src/corpus/extract.js +147 -0
  133. package/src/corpus/frontier.js +151 -0
  134. package/src/discovery/contract.js +8 -0
  135. package/src/discovery/import.js +13 -1
  136. package/src/mentions.js +193 -0
  137. package/src/page-acquisition/acquire.js +50 -0
  138. package/src/page-acquisition/bytes.js +138 -0
  139. package/src/page-acquisition/capture.js +11 -0
  140. package/src/page-acquisition/contracts.js +67 -0
  141. package/src/page-acquisition/documents.js +166 -0
  142. package/src/page-acquisition/http.js +307 -0
  143. package/src/page-acquisition/policy.js +20 -0
  144. package/src/page-acquisition/recovery.js +164 -0
  145. package/src/page-acquisition/render-failure.js +82 -0
  146. package/src/page-extraction/html.js +359 -0
  147. package/src/page-extraction/index.js +30 -0
  148. package/src/page-extraction/metadata.js +16 -0
  149. package/src/response-shaping.js +4 -3
  150. package/src/tool-views.js +22 -12
  151. package/src/verifier/fetch.js +4 -339
  152. package/src/verifier/index.js +81 -23
  153. package/src/verifier/page-quality.js +14 -0
  154. package/src/verifier/parser.js +2 -241
  155. package/src/verifier/readiness.js +64 -13
  156. package/src/verifier/render-evidence.js +25 -2
  157. package/src/verifier/target.js +201 -0
  158. package/src/verifier/transition.js +18 -3
  159. package/src/verifier/url.js +31 -0
  160. package/src/watch-crawlability.js +144 -0
  161. package/skills/content-defingerprinting/SKILL.md +0 -29
  162. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
  163. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
  164. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
  165. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
  166. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
  167. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
  168. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
  169. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
  170. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
  171. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
  172. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
  173. package/skills/internal-linking-optimizer/SKILL.md +0 -56
  174. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
  175. package/skills/pre-publish-review/SKILL.md +0 -179
  176. package/skills/source-cited-content-builder/SKILL.md +0 -56
  177. package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
  178. package/skills/source-cited-humanizer/SKILL.md +0 -20
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env python3
2
+ """AgentLinkOps portable CRM. Python 3.10+, standard library only, no telemetry."""
3
+ import sys
4
+
5
+ # The floor is checked BEFORE anything else is imported, because the failure it prevents is the
6
+ # one a user cannot act on: without this, an older interpreter fails somewhere deep inside a
7
+ # command with an AttributeError about a string method, and nothing in that message says
8
+ # "upgrade Python". The version we support is the version we test on.
9
+ MINIMUM_PYTHON = (3, 10)
10
+ if sys.version_info < MINIMUM_PYTHON: # pragma: no cover - exercised by test_python_floor
11
+ sys.stderr.write(
12
+ "agentlinkops: needs Python {}.{} or newer; this is Python {}.{}.{}.\n"
13
+ "Install a newer Python and run it explicitly, for example:\n"
14
+ " python3.12 {} --help\n".format(
15
+ MINIMUM_PYTHON[0], MINIMUM_PYTHON[1],
16
+ sys.version_info[0], sys.version_info[1], sys.version_info[2], sys.argv[0]))
17
+ raise SystemExit(2)
18
+
19
+ """Portable AgentLinkOps CRM. Python 3.10+, standard library only, no telemetry."""
20
+ import argparse
21
+ import csv
22
+ import datetime as dt
23
+ import hashlib
24
+ import io
25
+ import json
26
+ import os
27
+ from pathlib import Path
28
+ import sqlite3
29
+ import stat
30
+ import sys
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ import disavow
35
+ import report
36
+
37
+ VERSION = "0.2.0"
38
+ APPLICATION_ID = 0x4C54524C
39
+ ROOT = Path(__file__).resolve().parent
40
+ ENTITIES = {
41
+ "product": ("products", {"name", "url", "audience", "description", "target_pages", "notes"}, {"name", "url"}),
42
+ "campaign": ("campaigns", {"product_id", "name", "type", "template_version", "status", "goal", "target_url", "notes"}, {"name", "type"}),
43
+ "contact": ("contacts", {"publisher_url", "name", "email", "contact_url", "source_url", "observed_at", "confidence", "notes"}, {"publisher_url"}),
44
+ "opportunity": ("opportunities", {"campaign_id", "contact_id", "source_url", "target_url", "type", "status", "evidence", "notes"}, {"source_url", "target_url"}),
45
+ "activity": ("activities", {"opportunity_id", "contact_id", "campaign_id", "channel", "kind", "external_message_id", "occurred_at", "outcome", "notes"}, {"kind", "occurred_at"}),
46
+ "placement": ("placements", {"opportunity_id", "project_id", "watch_id", "source_url", "target_url", "status", "notes", "cost_amount", "cost_currency"}, {"source_url", "target_url"}),
47
+ }
48
+ JSON_COLUMNS = {"target_pages", "evidence", "cloud_state", "payload"}
49
+ FORBIDDEN_KEYS = {"password", "api_key", "access_token", "refresh_token", "secret", "credentials", "message_body", "email_body", "mailbox_password", "body", "html_body"}
50
+ WATCH_EVENTS = {"watch.checked", "watch.state_changed", "watch.recovered", "watch.expectations_changed", "watch.created", "watch.updated", "watch.deleted", "placement_acquired", "placement_lost", "placement_recovered", "placement_changed", "source_unavailable", "placement.deleted", "placement.state_changed"}
51
+ MAX_JSON = 4 * 1024 * 1024
52
+
53
+
54
+ class CRMError(Exception):
55
+ def __init__(self, message, code="INVALID_INPUT"):
56
+ super().__init__(message)
57
+ self.code = code
58
+
59
+
60
+ def now():
61
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
62
+
63
+
64
+ def encode(value):
65
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
66
+
67
+
68
+ def text_id(value, name="id"):
69
+ if not isinstance(value, str) or not value.strip() or len(value) > 1024 or any(ord(c) < 32 for c in value):
70
+ raise CRMError(f"{name} must be a nonempty string without control characters")
71
+ return value
72
+
73
+
74
+ def date_string(value, name):
75
+ text_id(value, name)
76
+ try:
77
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
78
+ except ValueError as exc:
79
+ raise CRMError(f"{name} must be an ISO 8601 timestamp") from exc
80
+ if parsed.tzinfo is None:
81
+ raise CRMError(f"{name} must include a timezone")
82
+ return value
83
+
84
+
85
+ def public_url(value, name, empty=False):
86
+ if empty and value == "":
87
+ return value
88
+ if not isinstance(value, str):
89
+ raise CRMError(f"{name} must be an HTTP(S) URL")
90
+ try:
91
+ parsed = urllib.parse.urlsplit(value)
92
+ if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password:
93
+ raise ValueError()
94
+ parsed.port
95
+ except ValueError as exc:
96
+ raise CRMError(f"{name} must be an HTTP(S) URL without credentials") from exc
97
+ return value
98
+
99
+
100
+ def no_sensitive_fields(value):
101
+ if isinstance(value, dict):
102
+ for key, child in value.items():
103
+ if key.lower().replace("-", "_") in FORBIDDEN_KEYS:
104
+ raise CRMError(f"Field {key} is not stored; use external message IDs and outcome notes")
105
+ no_sensitive_fields(child)
106
+ elif isinstance(value, list):
107
+ for child in value:
108
+ no_sensitive_fields(child)
109
+
110
+
111
+ def migrations():
112
+ result = []
113
+ for path in sorted((ROOT / "migrations").glob("[0-9][0-9][0-9]_*.sql")):
114
+ sql = path.read_text()
115
+ result.append((int(path.name[:3]), path.name, sql, hashlib.sha256(sql.encode()).hexdigest()))
116
+ if [row[0] for row in result] != list(range(1, len(result) + 1)):
117
+ raise CRMError("Migration files must be contiguous from 001", "SCHEMA_ERROR")
118
+ return result
119
+
120
+
121
+ def require_private_file(info):
122
+ if not stat.S_ISREG(info.st_mode):
123
+ raise CRMError("CRM storage must be a regular file", "DATABASE_ERROR")
124
+ if os.name == "posix" and stat.S_IMODE(info.st_mode) != 0o600:
125
+ raise CRMError("CRM storage requires mode 0600; secure existing files or use a filesystem that enforces private permissions", "PRIVATE_STORAGE_REQUIRED")
126
+
127
+
128
+ def open_private_file(path):
129
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
130
+ identity = None
131
+ try:
132
+ identity = os.fstat(descriptor)
133
+ require_private_file(identity)
134
+ return descriptor
135
+ except Exception:
136
+ # Check the created inode is still empty before cleanup.
137
+ try:
138
+ current = os.lstat(path)
139
+ if identity and (current.st_dev, current.st_ino) == (identity.st_dev, identity.st_ino) and current.st_size == 0:
140
+ os.unlink(path)
141
+ finally:
142
+ os.close(descriptor)
143
+ raise
144
+
145
+
146
+ def connect(path, allow_create=False):
147
+ path = Path(path).expanduser()
148
+ if path.is_symlink():
149
+ raise CRMError("Refusing a symlink database path", "DATABASE_ERROR")
150
+ if not allow_create and not path.is_file():
151
+ raise CRMError("CRM does not exist; run init with a workspace ID", "DATABASE_ERROR")
152
+ if allow_create and not path.exists():
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ os.close(open_private_file(path))
155
+ require_private_file(path.lstat())
156
+ connection = sqlite3.connect(str(path), timeout=10, isolation_level=None)
157
+ connection.row_factory = sqlite3.Row
158
+ connection.execute("PRAGMA foreign_keys = ON")
159
+ return connection
160
+
161
+
162
+ def inspect_schema(connection, allow_empty=False):
163
+ tables = {r[0] for r in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")}
164
+ app = connection.execute("PRAGMA application_id").fetchone()[0]
165
+ version = connection.execute("PRAGMA user_version").fetchone()[0]
166
+ if not tables and app == 0 and version == 0 and allow_empty:
167
+ return 0
168
+ if app != APPLICATION_ID or "schema_migrations" not in tables or "crm_meta" not in tables:
169
+ raise CRMError("File is not an AgentLinkOps CRM; it was not modified", "SCHEMA_ERROR")
170
+ known = migrations()
171
+ if version > len(known):
172
+ raise CRMError("CRM was created by a newer toolkit; refusing to downgrade", "SCHEMA_TOO_NEW")
173
+ recorded = list(connection.execute("SELECT version, name, checksum FROM schema_migrations ORDER BY version"))
174
+ if len(recorded) != version:
175
+ raise CRMError("Migration ledger and schema version disagree", "SCHEMA_ERROR")
176
+ for row, expected in zip(recorded, known):
177
+ if tuple(row) != (expected[0], expected[1], expected[3]):
178
+ raise CRMError("Applied migration checksum changed; restore original migration files", "SCHEMA_ERROR")
179
+ return version
180
+
181
+
182
+ def workspace(connection):
183
+ row = connection.execute("SELECT value FROM crm_meta WHERE key='workspace_id'").fetchone()
184
+ if row is None:
185
+ raise CRMError("CRM workspace binding is missing", "SCHEMA_ERROR")
186
+ return row[0]
187
+
188
+
189
+ def migrate(connection, path, workspace_id=None, initialize=False):
190
+ version = inspect_schema(connection, allow_empty=initialize)
191
+ if version:
192
+ actual_workspace = workspace(connection)
193
+ if workspace_id is not None and actual_workspace != workspace_id:
194
+ raise CRMError("CRM already belongs to another workspace", "WORKSPACE_MISMATCH")
195
+ elif workspace_id is None:
196
+ raise CRMError("A workspace ID is required for initialization")
197
+ pending = [m for m in migrations() if m[0] > version]
198
+ backup = None
199
+ if version and pending:
200
+ backup = str(Path(path).expanduser()) + ".pre-v" + str(version + 1) + "." + dt.datetime.now().strftime("%Y%m%d%H%M%S%f") + ".bak"
201
+ os.close(open_private_file(backup))
202
+ with sqlite3.connect(backup) as target:
203
+ connection.backup(target)
204
+ connection.execute("BEGIN IMMEDIATE")
205
+ try:
206
+ # Lock first and recheck: concurrent init/migrate must not replay DDL.
207
+ version = inspect_schema(connection, allow_empty=initialize)
208
+ if version and workspace_id is not None and workspace(connection) != workspace_id:
209
+ raise CRMError("CRM already belongs to another workspace", "WORKSPACE_MISMATCH")
210
+ connection.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)")
211
+ for number, name, sql, checksum in migrations():
212
+ if number <= version:
213
+ continue
214
+ # Migrations contain simple DDL only. executescript would commit the outer transaction.
215
+ statement = ""
216
+ for line in sql.splitlines(keepends=True):
217
+ statement += line
218
+ if sqlite3.complete_statement(statement):
219
+ connection.execute(statement)
220
+ statement = ""
221
+ if statement.strip():
222
+ raise CRMError(f"Incomplete migration: {name}", "SCHEMA_ERROR")
223
+ connection.execute("INSERT INTO schema_migrations VALUES (?, ?, ?, ?)", (number, name, checksum, now()))
224
+ connection.execute(f"PRAGMA user_version = {number}")
225
+ connection.execute(f"PRAGMA application_id = {APPLICATION_ID}")
226
+ if version == 0:
227
+ connection.execute("INSERT INTO crm_meta VALUES ('workspace_id', ?)", (text_id(workspace_id, "workspace_id"),))
228
+ connection.execute("COMMIT")
229
+ except Exception:
230
+ connection.execute("ROLLBACK")
231
+ raise
232
+ return {"schema_version": len(migrations()), "workspace_id": workspace(connection), "backup": backup}
233
+
234
+
235
+ def require_current(connection):
236
+ if inspect_schema(connection) != len(migrations()):
237
+ raise CRMError("CRM needs migration; run migrate (a backup is created first)", "MIGRATION_REQUIRED")
238
+
239
+
240
+ def decode_row(row):
241
+ result = dict(row)
242
+ for key in JSON_COLUMNS & result.keys():
243
+ result[key] = json.loads(result[key])
244
+ return result
245
+
246
+
247
+ def upsert(connection, entity, record):
248
+ if not isinstance(record, dict):
249
+ raise CRMError("Record must be a JSON object")
250
+ no_sensitive_fields(record)
251
+ table, allowed, required = ENTITIES[entity]
252
+ unknown = record.keys() - allowed - {"id"}
253
+ if unknown:
254
+ raise CRMError("Unsupported fields: " + ", ".join(sorted(unknown)))
255
+ record = dict(record)
256
+ text_id(record.get("id"))
257
+ existing = connection.execute(f"SELECT * FROM {table} WHERE id=?", (record["id"],)).fetchone()
258
+ if existing is None and required - record.keys():
259
+ raise CRMError("Missing fields: " + ", ".join(sorted(required - record.keys())))
260
+ for key in required & record.keys():
261
+ if not isinstance(record[key], str) or not record[key].strip():
262
+ raise CRMError(f"{key} must be a nonempty string")
263
+ if entity == "placement" and ({"cost_amount", "cost_currency"} & record.keys()):
264
+ try:
265
+ record["cost_amount"], record["cost_currency"] = report.cost(record.get("cost_amount", existing["cost_amount"] if existing else None), record.get("cost_currency", existing["cost_currency"] if existing else None))
266
+ except ValueError as exc:
267
+ raise CRMError(str(exc)) from exc
268
+ for key, value in record.items():
269
+ if key in JSON_COLUMNS:
270
+ expected = list if key == "target_pages" else dict
271
+ if not isinstance(value, expected):
272
+ raise CRMError(f"{key} must be a JSON {expected.__name__}")
273
+ if key == "target_pages":
274
+ for item in value:
275
+ public_url(item, "target_pages item")
276
+ record[key] = encode(value)
277
+ elif key == "template_version":
278
+ if type(value) is not int or value < 1:
279
+ raise CRMError("template_version must be a positive integer")
280
+ elif value is None and (key.endswith("_id") or key in {"cost_amount", "cost_currency"}):
281
+ continue
282
+ elif not isinstance(value, str):
283
+ raise CRMError(f"{key} must be a string")
284
+ elif key == "url" or key.endswith("_url"):
285
+ public_url(value, key, empty=key not in required)
286
+ elif key.endswith("_at") and value:
287
+ date_string(value, key)
288
+ elif key.endswith("_id") and value:
289
+ text_id(value, key)
290
+ if entity == "campaign":
291
+ chosen = record.get("type", existing["type"] if existing else None)
292
+ if chosen not in templates()["campaigns"]:
293
+ raise CRMError("Unknown campaign type; use a supported template or custom")
294
+ stamp = now()
295
+ record["updated_at"] = stamp
296
+ if existing is None:
297
+ record["created_at"] = stamp
298
+ keys = list(record)
299
+ connection.execute(f"INSERT INTO {table} ({','.join(keys)}) VALUES ({','.join('?' for _ in keys)})", [record[k] for k in keys])
300
+ else:
301
+ keys = [key for key in record if key != "id"]
302
+ connection.execute(f"UPDATE {table} SET {','.join(key + '=?' for key in keys)} WHERE id=?", [record[k] for k in keys] + [record["id"]])
303
+ return decode_row(connection.execute(f"SELECT * FROM {table} WHERE id=?", (record["id"],)).fetchone())
304
+
305
+
306
+ def list_records(connection, entity, record_id=None):
307
+ table = ENTITIES[entity][0]
308
+ rows = connection.execute(f"SELECT * FROM {table}" + (" WHERE id=?" if record_id else "") + " ORDER BY id", (record_id,) if record_id else ())
309
+ return [decode_row(row) for row in rows]
310
+
311
+
312
+ def templates():
313
+ return json.loads((ROOT.parent / "templates" / "campaigns.v1.json").read_text())
314
+
315
+
316
+ def validate_page(page, expected_workspace, requested_cursor):
317
+ if not isinstance(page, dict) or page.get("workspace_id") != expected_workspace:
318
+ raise CRMError("Cloud response workspace does not match this CRM", "WORKSPACE_MISMATCH")
319
+ events = page.get("events")
320
+ if not isinstance(events, list) or len(events) > 1000 or type(page.get("has_more")) is not bool:
321
+ raise CRMError("Malformed event page", "INVALID_EVENT_PAGE")
322
+ next_cursor = page.get("next_cursor")
323
+ text_id(next_cursor, "next_cursor")
324
+ # A project-restricted feed can advance across hidden events, including empty pages.
325
+ if page["has_more"] and (next_cursor is None or next_cursor == requested_cursor):
326
+ raise CRMError("Event pagination made no progress", "INVALID_EVENT_PAGE")
327
+ seen_ids, seen_cursors = set(), set()
328
+ for event in events:
329
+ if not isinstance(event, dict) or event.get("workspace_id") != expected_workspace:
330
+ raise CRMError("Event workspace does not match this CRM", "WORKSPACE_MISMATCH")
331
+ for name in ("id", "cursor", "type"):
332
+ text_id(event.get(name), name)
333
+ date_string(event.get("created_at"), "created_at")
334
+ for name in ("project_id", "watch_id"):
335
+ if event.get(name) is not None:
336
+ text_id(event[name], name)
337
+ if not isinstance(event.get("data"), dict):
338
+ raise CRMError("Event data must be an object", "INVALID_EVENT_PAGE")
339
+ if event["id"] in seen_ids or event["cursor"] in seen_cursors:
340
+ raise CRMError("Event page contains duplicate identifiers", "INVALID_EVENT_PAGE")
341
+ seen_ids.add(event["id"])
342
+ seen_cursors.add(event["cursor"])
343
+ no_sensitive_fields(event["data"])
344
+ return events
345
+
346
+
347
+ def apply_event(connection, event):
348
+ # Unknown event types remain in the local feed for forward compatibility.
349
+ if not event.get("watch_id") or event["type"] not in WATCH_EVENTS:
350
+ return
351
+ after = event["data"].get("after")
352
+ is_deletion = event["type"] in {"watch.deleted", "placement.deleted"}
353
+ if not isinstance(after, dict) and not (is_deletion and "after" in event["data"] and after is None):
354
+ raise CRMError("Watch after state must be an object; only deletion events allow null", "INVALID_EVENT_PAGE")
355
+ current = connection.execute("SELECT * FROM placements WHERE watch_id=?", (event["watch_id"],)).fetchone()
356
+ snapshot = dict(after or {})
357
+ for key in ("watch_id", "project_id"):
358
+ if key in snapshot and snapshot[key] != event.get(key):
359
+ raise CRMError("Watch snapshot has mismatched identifiers", "INVALID_EVENT_PAGE")
360
+ if current and current["project_id"] is not None and event.get("project_id") != current["project_id"]:
361
+ raise CRMError("Watch changed its project binding", "PROJECT_MISMATCH")
362
+ for key in ("source_url", "target_url"):
363
+ if key in snapshot:
364
+ public_url(snapshot[key], key)
365
+ if after is None:
366
+ snapshot = {"deleted": True}
367
+ stamp = now()
368
+ if current is None:
369
+ if "source_url" not in snapshot or "target_url" not in snapshot:
370
+ # Preserve the event without inventing a placement from a partial state.
371
+ return
372
+ connection.execute("INSERT INTO placements (id,project_id,watch_id,source_url,target_url,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", (
373
+ "cloud:" + event["watch_id"], event.get("project_id"), event["watch_id"], snapshot["source_url"], snapshot["target_url"], stamp, stamp))
374
+ else:
375
+ # URL bindings are cloud owned; outreach status, notes and local relations are not.
376
+ for key in ("source_url", "target_url"):
377
+ if key in snapshot:
378
+ connection.execute(f"UPDATE placements SET {key}=? WHERE watch_id=?", (snapshot[key], event["watch_id"]))
379
+ connection.execute("UPDATE placements SET cloud_state=?, cloud_observed_at=?, cloud_event_id=?, updated_at=? WHERE watch_id=?", (
380
+ encode(snapshot), event["created_at"], event["id"], stamp, event["watch_id"]))
381
+
382
+
383
+ def apply_page(connection, page, requested_cursor, endpoint):
384
+ events = validate_page(page, workspace(connection), requested_cursor)
385
+ connection.execute("BEGIN IMMEDIATE")
386
+ try:
387
+ state = connection.execute("SELECT * FROM sync_state WHERE singleton=1").fetchone()
388
+ if state["cursor"] != requested_cursor:
389
+ raise CRMError("Another sync updated the cursor; retry from current state", "SYNC_CONFLICT")
390
+ if state["endpoint"] not in (None, endpoint):
391
+ raise CRMError("CRM is bound to another service endpoint; use a separate CRM", "ENDPOINT_MISMATCH")
392
+ previous_position = connection.execute("SELECT rowid FROM cloud_events WHERE cursor=?", (requested_cursor,)).fetchone()
393
+ next_position = connection.execute("SELECT rowid FROM cloud_events WHERE cursor=?", (page["next_cursor"],)).fetchone()
394
+ if previous_position and next_position and next_position[0] < previous_position[0]:
395
+ raise CRMError("Cloud feed attempted to rewind a known cursor", "INVALID_EVENT_PAGE")
396
+ applied = 0
397
+ for event in events:
398
+ payload = encode(event)
399
+ fingerprint = hashlib.sha256(payload.encode()).hexdigest()
400
+ previous = connection.execute("SELECT payload_hash FROM cloud_events WHERE id=?", (event["id"],)).fetchone()
401
+ if previous:
402
+ if previous[0] != fingerprint:
403
+ raise CRMError("An existing cloud event changed its payload", "EVENT_CONFLICT")
404
+ continue
405
+ connection.execute("INSERT INTO cloud_events VALUES (?,?,?,?,?,?,?,?,?)", (
406
+ event["id"], event["cursor"], event["type"], event.get("project_id"), event.get("watch_id"), event["created_at"], payload, fingerprint, now()))
407
+ apply_event(connection, event)
408
+ applied += 1
409
+ connection.execute("UPDATE sync_state SET endpoint=?, cursor=?, updated_at=? WHERE singleton=1", (endpoint, page["next_cursor"], now()))
410
+ connection.execute("COMMIT")
411
+ except Exception:
412
+ connection.execute("ROLLBACK")
413
+ raise
414
+ return applied
415
+
416
+
417
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
418
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
419
+ return None
420
+
421
+
422
+ # Environment names, with the pilot compatibility window (DP-0029): AGENTLINKOPS_* is preferred,
423
+ # LINKTRAIL_* is accepted with one warning per process, and two different values under the two
424
+ # names is a refusal rather than a guess. Values are never printed.
425
+ _WARNED_ENV = set()
426
+
427
+
428
+ def read_env(suffix, environ=None):
429
+ environ = os.environ if environ is None else environ
430
+ preferred, legacy = "AGENTLINKOPS_" + suffix, "LINKTRAIL_" + suffix
431
+ fresh, old = environ.get(preferred, ""), environ.get(legacy, "")
432
+ if fresh and old and fresh != old:
433
+ raise CRMError(f"{preferred} and {legacy} disagree; select one value", "ENV_CONFLICT")
434
+ if fresh:
435
+ return fresh, preferred
436
+ if old:
437
+ if legacy not in _WARNED_ENV:
438
+ _WARNED_ENV.add(legacy)
439
+ sys.stderr.write(f"{legacy} is deprecated; set {preferred} instead. The old name keeps working during the pilot compatibility window.\n")
440
+ return old, legacy
441
+ return "", preferred
442
+
443
+
444
+ def api_endpoint(value, name="AGENTLINKOPS_API_URL"):
445
+ public_url(value, name)
446
+ parsed = urllib.parse.urlsplit(value)
447
+ if parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
448
+ raise CRMError(f"{name} must be a service origin without path/query/fragment")
449
+ if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
450
+ raise CRMError("Cloud sync requires HTTPS; HTTP is allowed only on loopback")
451
+ return value.rstrip("/")
452
+
453
+
454
+ def fetch_page(endpoint, key, cursor, limit):
455
+ params = {"limit": str(limit)}
456
+ if cursor is not None:
457
+ params["cursor"] = cursor
458
+ request = urllib.request.Request(endpoint + "/v1/events?" + urllib.parse.urlencode(params), headers={"Authorization": "Bearer " + key, "Accept": "application/json", "User-Agent": "AgentLinkOps-CRM/" + VERSION})
459
+ try:
460
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
461
+ raw = response.read(MAX_JSON + 1)
462
+ if len(raw) > MAX_JSON:
463
+ raise CRMError("Event response exceeded 4 MiB", "INVALID_EVENT_PAGE")
464
+ return json.loads(raw)
465
+ except urllib.error.HTTPError as exc:
466
+ if exc.code == 410:
467
+ raise CRMError("Cloud cursor expired. Cursor and local notes were preserved. Export this CRM and recover a current watch snapshot into a separate CRM before replaying retained events.", "CURSOR_EXPIRED") from exc
468
+ if exc.code in (401, 403):
469
+ raise CRMError("Cloud access was rejected; check the configured account and permissions", "AUTH_REQUIRED") from exc
470
+ raise CRMError(f"Cloud returned HTTP {exc.code}; this page was not applied", "HTTP_ERROR") from exc
471
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
472
+ raise CRMError("Cloud could not be reached; this page was not applied", "NETWORK_ERROR") from exc
473
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
474
+ raise CRMError("Cloud response is not valid JSON", "INVALID_EVENT_PAGE") from exc
475
+
476
+
477
+ def sync(connection, limit=100, max_pages=100):
478
+ url_value, url_name = read_env("API_URL")
479
+ endpoint = api_endpoint(url_value, url_name)
480
+ key, key_name = read_env("API_KEY")
481
+ if not key or any(ord(c) < 33 or ord(c) > 126 for c in key):
482
+ raise CRMError(f"Set {key_name} in the process environment; do not paste it into chat", "AUTH_REQUIRED")
483
+ state = connection.execute("SELECT * FROM sync_state WHERE singleton=1").fetchone()
484
+ if state["endpoint"] not in (None, endpoint):
485
+ raise CRMError("CRM is bound to another service endpoint; use a separate CRM", "ENDPOINT_MISMATCH")
486
+ applied, pages = 0, 0
487
+ cursor = state["cursor"]
488
+ has_more = True
489
+ while has_more and pages < max_pages:
490
+ page = fetch_page(endpoint, key, cursor, limit)
491
+ applied += apply_page(connection, page, cursor, endpoint)
492
+ cursor, has_more = page["next_cursor"], page["has_more"]
493
+ pages += 1
494
+ return {"applied": applied, "pages": pages, "next_cursor": cursor, "has_more": has_more}
495
+
496
+
497
+ def read_json(path):
498
+ if path == "-":
499
+ raw = sys.stdin.read(MAX_JSON + 1)
500
+ else:
501
+ with Path(path).open() as source:
502
+ raw = source.read(MAX_JSON + 1)
503
+ if len(raw) > MAX_JSON:
504
+ raise CRMError("Input exceeded 4 MiB")
505
+ return json.loads(raw)
506
+
507
+
508
+ def export_csv(records, fieldnames):
509
+ output = io.StringIO(newline="")
510
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
511
+ writer.writeheader()
512
+ for record in records:
513
+ escaped = {}
514
+ for key, value in record.items():
515
+ value = encode(value) if isinstance(value, (dict, list)) else value
516
+ # CSVs are often opened in spreadsheets; do not execute formulas from publisher text.
517
+ if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@", "\t", "\r")):
518
+ value = "'" + value
519
+ escaped[key] = value
520
+ writer.writerow(escaped)
521
+ return output.getvalue()
522
+
523
+
524
+ def write_new(path, content, suffix=None):
525
+ path = Path(path).expanduser()
526
+ if suffix and path.suffix.lower() != suffix:
527
+ raise CRMError(f"Output file must end in {suffix}")
528
+ descriptor = open_private_file(path)
529
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
530
+ output.write(content)
531
+
532
+
533
+ def main(argv=None):
534
+ parser = argparse.ArgumentParser(description=__doc__)
535
+ parser.add_argument("--version", action="version", version=VERSION)
536
+ parser.add_argument("--db", default=read_env("CRM_PATH")[0] or None, help="Local SQLite path (or AGENTLINKOPS_CRM_PATH); never stored inside the installed plugin")
537
+ commands = parser.add_subparsers(dest="command", required=True)
538
+ initialize = commands.add_parser("init")
539
+ initialize.add_argument("--workspace", required=True)
540
+ commands.add_parser("migrate")
541
+ commands.add_parser("status")
542
+ commands.add_parser("templates")
543
+ for entity in ENTITIES:
544
+ entity_parser = commands.add_parser(entity)
545
+ entity_actions = entity_parser.add_subparsers(dest="action", required=True)
546
+ put = entity_actions.add_parser("upsert")
547
+ put.add_argument("--file", default="-", help="JSON file or - for stdin; accepts a record or atomic list of records")
548
+ listing = entity_actions.add_parser("list")
549
+ listing.add_argument("--id")
550
+ exporter = commands.add_parser("export")
551
+ exporter.add_argument("--format", choices=("json", "csv"), default="json")
552
+ exporter.add_argument("--entity", choices=ENTITIES)
553
+ exporter.add_argument("--output", help="Create a new file; existing files are never overwritten")
554
+ reporter = commands.add_parser("report", help="Generate a self-contained HTML placement report from selected local data")
555
+ reporter.add_argument("--output", required=True, help="New .html file; existing files are never overwritten")
556
+ reporter.add_argument("--title", default="Link placement report")
557
+ reporter.add_argument("--brand", default="AgentLinkOps")
558
+ reporter.add_argument("--project", dest="project_id", help="Include one project's placements; defaults to all local placements")
559
+ reporter.add_argument("--generated-at", help="ISO 8601 report timestamp; set explicitly for deterministic output")
560
+ reporter.add_argument("--include-notes", action="store_true", help="Explicitly include private local placement notes")
561
+ reporter.add_argument("--include-costs", action="store_true", help="Explicitly include local cost amounts and totals by currency")
562
+ disavowing = commands.add_parser("disavow", help="User-managed, property-scoped disavow rules; no Google uploads")
563
+ disavow_actions = disavowing.add_subparsers(dest="action", required=True)
564
+ for action in ("import", "upsert", "list", "export"):
565
+ command = disavow_actions.add_parser(action)
566
+ command.add_argument("--property", required=True, dest="property_url", help="Exact Search Console URL-prefix property, not a Domain property")
567
+ if action in {"import", "upsert"}:
568
+ command.add_argument("--source", required=True, help="Provenance label chosen by the user")
569
+ command.add_argument("--file", required=action == "import", default="-", help="UTF-8 .txt import or upsert JSON file; upsert accepts stdin")
570
+ if action == "list":
571
+ command.add_argument("--active-only", action="store_true")
572
+ if action == "export":
573
+ command.add_argument("--output", help="New .txt file; defaults to stdout")
574
+ syncing = commands.add_parser("sync")
575
+ syncing.add_argument("--limit", type=int, default=100)
576
+ syncing.add_argument("--max-pages", type=int, default=100)
577
+ args = parser.parse_args(argv)
578
+ connection = None
579
+ try:
580
+ if args.command == "templates":
581
+ print(encode(templates()))
582
+ return 0
583
+ if not args.db:
584
+ raise CRMError("Supply --db or AGENTLINKOPS_CRM_PATH")
585
+ if args.command == "init":
586
+ text_id(args.workspace, "workspace_id")
587
+ connection = connect(args.db, allow_create=args.command == "init")
588
+ if args.command in {"init", "migrate"}:
589
+ result = migrate(connection, args.db, getattr(args, "workspace", None), initialize=args.command == "init")
590
+ else:
591
+ require_current(connection)
592
+ if args.command in ENTITIES:
593
+ if args.action == "upsert":
594
+ records = read_json(args.file)
595
+ multiple = isinstance(records, list)
596
+ connection.execute("BEGIN IMMEDIATE")
597
+ try:
598
+ result = [upsert(connection, args.command, record) for record in (records if multiple else [records])]
599
+ connection.execute("COMMIT")
600
+ except Exception:
601
+ connection.execute("ROLLBACK")
602
+ raise
603
+ if not multiple:
604
+ result = result[0]
605
+ else:
606
+ result = list_records(connection, args.command, args.id)
607
+ elif args.command == "sync":
608
+ if not 1 <= args.limit <= 1000 or not 1 <= args.max_pages <= 1000:
609
+ raise CRMError("limit and max-pages must be between 1 and 1000")
610
+ result = sync(connection, args.limit, args.max_pages)
611
+ elif args.command == "disavow":
612
+ if args.action == "import":
613
+ result = disavow.import_rules(connection, args.file, args.property_url, args.source)
614
+ elif args.action == "upsert":
615
+ record = read_json(args.file)
616
+ no_sensitive_fields(record)
617
+ result = disavow.upsert_rule(connection, record, args.property_url, args.source)
618
+ elif args.action == "list":
619
+ result = disavow.list_rules(connection, args.property_url, args.active_only)
620
+ else:
621
+ content, result = disavow.export_rules(connection, args.property_url)
622
+ if args.output:
623
+ write_new(args.output, content, ".txt")
624
+ result["exported"] = args.output
625
+ else:
626
+ sys.stdout.write(content)
627
+ return 0
628
+ elif args.command == "report":
629
+ generated_at = date_string(args.generated_at or now(), "generated_at")
630
+ if args.project_id:
631
+ text_id(args.project_id, "project_id")
632
+ try:
633
+ content, result = report.render(connection, args.title, args.brand, generated_at, args.project_id, args.include_notes, args.include_costs)
634
+ except ValueError as exc:
635
+ raise CRMError(str(exc)) from exc
636
+ write_new(args.output, content, ".html")
637
+ result["exported"] = args.output
638
+ elif args.command == "status":
639
+ result = {"version": VERSION, "schema_version": len(migrations()), "workspace_id": workspace(connection), "sync": dict(connection.execute("SELECT endpoint,cursor,updated_at FROM sync_state").fetchone()), "counts": {entity: connection.execute(f"SELECT count(*) FROM {values[0]}").fetchone()[0] for entity, values in ENTITIES.items()}}
640
+ elif args.command == "export":
641
+ if args.format == "csv" and not args.entity:
642
+ raise CRMError("CSV export requires --entity")
643
+ connection.execute("BEGIN")
644
+ try:
645
+ if args.entity:
646
+ data = list_records(connection, args.entity)
647
+ else:
648
+ data = {"schema_version": len(migrations()), "workspace_id": workspace(connection), "records": {entity: list_records(connection, entity) for entity in ENTITIES}, "cloud_events": [decode_row(row) for row in connection.execute("SELECT * FROM cloud_events ORDER BY rowid")], "sync": dict(connection.execute("SELECT * FROM sync_state").fetchone()), "disavow": disavow.snapshot(connection)}
649
+ fields = [row[1] for row in connection.execute(f"PRAGMA table_info({ENTITIES[args.entity][0]})")] if args.entity else []
650
+ connection.execute("COMMIT")
651
+ except Exception:
652
+ connection.execute("ROLLBACK")
653
+ raise
654
+ result_text = export_csv(data, fields) if args.format == "csv" else encode(data) + "\n"
655
+ if args.output:
656
+ write_new(args.output, result_text)
657
+ result = {"exported": args.output, "format": args.format}
658
+ else:
659
+ sys.stdout.write(result_text)
660
+ return 0
661
+ print(encode(result))
662
+ return 0
663
+ except (CRMError, disavow.DisavowError) as exc:
664
+ print(encode({"error": {"code": exc.code, "message": str(exc)}}), file=sys.stderr)
665
+ return 1
666
+ except (sqlite3.Error, ValueError, OSError) as exc:
667
+ # Do not print potentially sensitive input, SQL parameters or remote response bodies.
668
+ print(encode({"error": {"code": "LOCAL_OPERATION_FAILED", "message": "Operation failed (" + type(exc).__name__ + "); the current transaction was not committed"}}), file=sys.stderr)
669
+ return 1
670
+ finally:
671
+ if connection:
672
+ connection.close()
673
+
674
+
675
+ if __name__ == "__main__":
676
+ sys.exit(main())