agentlinkops 0.5.0

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