cortexdb-connectors 0.2.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.
@@ -0,0 +1,476 @@
1
+ """CortexDB connectors CLI — `cortexdb-sync`.
2
+
3
+ Commands:
4
+
5
+ cortexdb-sync sync slack one-shot sync (uses stored cursor)
6
+ cortexdb-sync sync slack --since 2026-01-01T00:00:00Z
7
+ cortexdb-sync watch slack --interval 60 poll-and-sleep loop
8
+ cortexdb-sync status cursor table for every connector
9
+ cortexdb-sync list list available connectors + env vars
10
+ cortexdb-sync auth show resolved CortexDB credentials
11
+
12
+ Reads CortexDB creds in this order:
13
+
14
+ 1. CLI flags (--api-url, --api-key, --actor, --scope-template)
15
+ 2. Env vars (CORTEXDB_URL, CORTEXDB_API_KEY, CORTEXDB_ACTOR, CORTEXDB_SCOPE_TEMPLATE)
16
+ 3. `~/.cortexdb/state.json` — written by `cortexdb init` from the cortexdb-cli
17
+ package; same format the cortexdb-mcp 0.3.0 server uses, so you can mint
18
+ a free-tier identity once and use it from everywhere.
19
+
20
+ Per-connector secrets (Slack bot token, Jira API token, …) come from env
21
+ vars by default; override with a YAML config file via --config.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import asyncio
28
+ import importlib
29
+ import json
30
+ import logging
31
+ import os
32
+ import sys
33
+ import time
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any, Callable
37
+
38
+ from cortexdb_connectors.state import SyncStateStore
39
+
40
+ logger = logging.getLogger("cortexdb_connectors")
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Connector registry
45
+ #
46
+ # Each entry maps a CLI slug to:
47
+ # - module + class to import
48
+ # - secret env vars (required for the connector to function)
49
+ # - optional list/scalar config knobs the CLI surfaces
50
+ # ---------------------------------------------------------------------------
51
+
52
+ CONNECTORS: dict[str, dict[str, Any]] = {
53
+ "slack": {
54
+ "import": "cortexdb_connectors.slack:SlackConnector",
55
+ "secrets": [("slack_bot_token", "SLACK_BOT_TOKEN")],
56
+ "options": [("channels", "SLACK_CHANNELS", "list")],
57
+ },
58
+ "github": {
59
+ "import": "cortexdb_connectors.github:GitHubConnector",
60
+ "secrets": [("github_token", "GITHUB_TOKEN")],
61
+ "options": [
62
+ ("repos", "GITHUB_REPOS", "list"),
63
+ ("events", "GITHUB_EVENTS", "list"),
64
+ ],
65
+ },
66
+ "gitlab": {
67
+ "import": "cortexdb_connectors.gitlab:GitLabConnector",
68
+ "secrets": [("gitlab_token", "GITLAB_TOKEN")],
69
+ "options": [
70
+ ("gitlab_url", "GITLAB_URL", "str"),
71
+ ("project_ids", "GITLAB_PROJECT_IDS", "list-int"),
72
+ ("group_ids", "GITLAB_GROUP_IDS", "list-int"),
73
+ ("events", "GITLAB_EVENTS", "list"),
74
+ ],
75
+ },
76
+ "jira": {
77
+ "import": "cortexdb_connectors.jira:JiraConnector",
78
+ "secrets": [
79
+ ("jira_url", "JIRA_URL"),
80
+ ("jira_email", "JIRA_EMAIL"),
81
+ ("jira_api_token", "JIRA_API_TOKEN"),
82
+ ],
83
+ "options": [("project_keys", "JIRA_PROJECT_KEYS", "list")],
84
+ },
85
+ "linear": {
86
+ "import": "cortexdb_connectors.linear:LinearConnector",
87
+ "secrets": [("linear_api_key", "LINEAR_API_KEY")],
88
+ "options": [("team_ids", "LINEAR_TEAMS", "list")],
89
+ },
90
+ "confluence": {
91
+ "import": "cortexdb_connectors.confluence:ConfluenceConnector",
92
+ "secrets": [
93
+ ("confluence_url", "CONFLUENCE_URL"),
94
+ ("email", "CONFLUENCE_EMAIL"),
95
+ ("api_token", "CONFLUENCE_API_TOKEN"),
96
+ ],
97
+ "options": [("space_keys", "CONFLUENCE_SPACES", "list")],
98
+ },
99
+ "notion": {
100
+ "import": "cortexdb_connectors.notion:NotionConnector",
101
+ "secrets": [("notion_token", "NOTION_TOKEN")],
102
+ "options": [
103
+ ("database_ids", "NOTION_DATABASES", "list"),
104
+ ("page_ids", "NOTION_PAGES", "list"),
105
+ ],
106
+ },
107
+ "pagerduty": {
108
+ "import": "cortexdb_connectors.pagerduty:PagerDutyConnector",
109
+ "secrets": [("pagerduty_api_key", "PAGERDUTY_API_KEY")],
110
+ "options": [("service_ids", "PAGERDUTY_SERVICES", "list")],
111
+ },
112
+ "discord": {
113
+ "import": "cortexdb_connectors.discord:DiscordConnector",
114
+ "secrets": [("bot_token", "DISCORD_BOT_TOKEN")],
115
+ "options": [
116
+ ("guild_ids", "DISCORD_GUILDS", "list"),
117
+ ("channel_ids", "DISCORD_CHANNELS", "list"),
118
+ ],
119
+ },
120
+ "teams": {
121
+ "import": "cortexdb_connectors.teams:TeamsConnector",
122
+ "secrets": [
123
+ ("azure_tenant_id", "TEAMS_TENANT_ID"),
124
+ ("client_id", "TEAMS_CLIENT_ID"),
125
+ ("client_secret", "TEAMS_CLIENT_SECRET"),
126
+ ],
127
+ "options": [
128
+ ("team_ids", "TEAMS_TEAM_IDS", "list"),
129
+ ("include_chats", "TEAMS_INCLUDE_CHATS", "bool"),
130
+ ],
131
+ },
132
+ "google-workspace": {
133
+ "import": "cortexdb_connectors.google_workspace:GoogleWorkspaceConnector",
134
+ "secrets": [
135
+ ("service_account_key", "GW_SERVICE_ACCOUNT_KEY"),
136
+ ("delegated_user", "GW_DELEGATED_USER"),
137
+ ],
138
+ "options": [
139
+ ("gmail_enabled", "GW_GMAIL", "bool"),
140
+ ("drive_enabled", "GW_DRIVE", "bool"),
141
+ ("calendar_enabled", "GW_CALENDAR", "bool"),
142
+ ("shared_drives", "GW_SHARED_DRIVES", "list"),
143
+ ("calendar_ids", "GW_CALENDARS", "list"),
144
+ ],
145
+ },
146
+ "salesforce": {
147
+ "import": "cortexdb_connectors.salesforce:SalesforceConnector",
148
+ "secrets": [
149
+ ("instance_url", "SF_INSTANCE_URL"),
150
+ ("client_id", "SF_CLIENT_ID"),
151
+ ("client_secret", "SF_CLIENT_SECRET"),
152
+ ("username", "SF_USERNAME"),
153
+ ("password", "SF_PASSWORD"),
154
+ ],
155
+ "options": [("objects", "SF_OBJECTS", "list")],
156
+ },
157
+ "hubspot": {
158
+ "import": "cortexdb_connectors.hubspot:HubSpotConnector",
159
+ "secrets": [("access_token", "HUBSPOT_TOKEN")],
160
+ "options": [("objects", "HUBSPOT_OBJECTS", "list")],
161
+ },
162
+ "zendesk": {
163
+ "import": "cortexdb_connectors.zendesk:ZendeskConnector",
164
+ "secrets": [
165
+ ("subdomain", "ZENDESK_SUBDOMAIN"),
166
+ ("email", "ZENDESK_EMAIL"),
167
+ ("api_token", "ZENDESK_TOKEN"),
168
+ ],
169
+ "options": [("include_help_center", "ZENDESK_HELP_CENTER", "bool")],
170
+ },
171
+ "intercom": {
172
+ "import": "cortexdb_connectors.intercom:IntercomConnector",
173
+ "secrets": [("access_token", "INTERCOM_TOKEN")],
174
+ "options": [("include_articles", "INTERCOM_ARTICLES", "bool")],
175
+ },
176
+ "servicenow": {
177
+ "import": "cortexdb_connectors.servicenow:ServiceNowConnector",
178
+ "secrets": [
179
+ ("instance", "SNOW_INSTANCE"),
180
+ ("username", "SNOW_USERNAME"),
181
+ ("password", "SNOW_PASSWORD"),
182
+ ],
183
+ "options": [("tables", "SNOW_TABLES", "list")],
184
+ },
185
+ }
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # CortexDB credential resolution
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def _state_file_path() -> Path:
194
+ return Path.home() / ".cortexdb" / "state.json"
195
+
196
+
197
+ def _load_cortex_creds(args: argparse.Namespace) -> dict[str, str]:
198
+ """Resolve CortexDB url/api_key/actor/scope_template from flags → env → state.json."""
199
+ creds: dict[str, str] = {
200
+ "api_url": "",
201
+ "api_key": "",
202
+ "actor": "",
203
+ "scope_template": "",
204
+ }
205
+
206
+ sf = _state_file_path()
207
+ if sf.exists():
208
+ try:
209
+ data = json.loads(sf.read_text(encoding="utf-8"))
210
+ creds["api_key"] = data.get("token", "") or creds["api_key"]
211
+ creds["actor"] = data.get("user_id", "") or creds["actor"]
212
+ creds["scope_template"] = data.get("scope", "") or creds["scope_template"]
213
+ except (OSError, json.JSONDecodeError):
214
+ pass
215
+
216
+ env_map = {
217
+ "api_url": ("CORTEXDB_URL", "CORTEX_URL"),
218
+ "api_key": ("CORTEXDB_API_KEY", "CORTEX_API_KEY"),
219
+ "actor": ("CORTEXDB_ACTOR",),
220
+ "scope_template": ("CORTEXDB_SCOPE_TEMPLATE", "CORTEXDB_SCOPE"),
221
+ }
222
+ for key, vars_ in env_map.items():
223
+ for v in vars_:
224
+ if os.environ.get(v):
225
+ creds[key] = os.environ[v]
226
+ break
227
+
228
+ if getattr(args, "api_url", None):
229
+ creds["api_url"] = args.api_url
230
+ if getattr(args, "api_key", None):
231
+ creds["api_key"] = args.api_key
232
+ if getattr(args, "actor", None):
233
+ creds["actor"] = args.actor
234
+ if getattr(args, "scope_template", None):
235
+ creds["scope_template"] = args.scope_template
236
+
237
+ if not creds["api_url"]:
238
+ creds["api_url"] = "https://api-v1.cortexdb.ai"
239
+ if not creds["scope_template"] and creds["actor"]:
240
+ creds["scope_template"] = creds["actor"]
241
+ return creds
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Connector factory
246
+ # ---------------------------------------------------------------------------
247
+
248
+ _OPTION_PARSERS: dict[str, Callable[[str], Any]] = {
249
+ "str": lambda v: v,
250
+ "list": lambda v: [x.strip() for x in v.split(",") if x.strip()],
251
+ "list-int": lambda v: [int(x.strip()) for x in v.split(",") if x.strip()],
252
+ "bool": lambda v: v.lower() in ("1", "true", "yes", "on"),
253
+ }
254
+
255
+
256
+ def _resolve_kwarg(env_var: str, config_value: Any, parser: str) -> Any:
257
+ """Pull a value from config or env, parse it according to its type."""
258
+ if config_value is not None:
259
+ return config_value
260
+ raw = os.environ.get(env_var, "")
261
+ if not raw:
262
+ return None
263
+ return _OPTION_PARSERS[parser](raw)
264
+
265
+
266
+ def _build_connector(
267
+ slug: str,
268
+ creds: dict[str, str],
269
+ config: dict[str, Any],
270
+ tenant_id: str,
271
+ ) -> Any:
272
+ if slug not in CONNECTORS:
273
+ raise ValueError(f"Unknown connector: {slug!r}. Try `cortexdb-sync list`.")
274
+ spec = CONNECTORS[slug]
275
+ module_name, class_name = spec["import"].split(":")
276
+ module = importlib.import_module(module_name)
277
+ cls = getattr(module, class_name)
278
+
279
+ connector_cfg = (config or {}).get(slug, {})
280
+
281
+ kwargs: dict[str, Any] = {
282
+ "cortex_url": creds["api_url"],
283
+ "cortex_api_key": creds["api_key"],
284
+ "tenant_id": tenant_id,
285
+ "actor": creds["actor"],
286
+ "scope_template": creds["scope_template"],
287
+ }
288
+
289
+ for kw_name, env_var in spec.get("secrets", []):
290
+ value = _resolve_kwarg(env_var, connector_cfg.get(kw_name), "str")
291
+ if not value:
292
+ raise SystemExit(
293
+ f"Missing required secret for {slug}: set --{slug}-{kw_name} via "
294
+ f"config or env {env_var}."
295
+ )
296
+ kwargs[kw_name] = value
297
+
298
+ for kw_name, env_var, parser in spec.get("options", []):
299
+ value = _resolve_kwarg(env_var, connector_cfg.get(kw_name), parser)
300
+ if value is not None:
301
+ kwargs[kw_name] = value
302
+
303
+ return cls(**kwargs)
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Commands
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def _cmd_sync(args: argparse.Namespace) -> None:
312
+ creds = _load_cortex_creds(args)
313
+ config = _load_config(args.config)
314
+ state_store = SyncStateStore(path=args.state_file)
315
+
316
+ since: datetime | None = None
317
+ if args.since:
318
+ since = datetime.fromisoformat(args.since)
319
+ if since.tzinfo is None:
320
+ since = since.replace(tzinfo=timezone.utc)
321
+ else:
322
+ st = state_store.get(args.connector, args.tenant_id)
323
+ since = st.last_synced_at
324
+
325
+ connector = _build_connector(args.connector, creds, config, args.tenant_id)
326
+
327
+ logger.info(
328
+ "sync start connector=%s tenant=%s scope=%s since=%s",
329
+ args.connector,
330
+ args.tenant_id,
331
+ creds["scope_template"],
332
+ since,
333
+ )
334
+
335
+ result = asyncio.run(connector.sync(since=since))
336
+
337
+ state_store.update_last_synced(args.connector, args.tenant_id)
338
+
339
+ logger.info(
340
+ "sync done fetched=%d ingested=%d errors=%d duration=%.1fs",
341
+ result.episodes_fetched,
342
+ result.episodes_ingested,
343
+ len(result.errors),
344
+ result.duration_seconds,
345
+ )
346
+ for err in result.errors:
347
+ logger.error(" %s", err)
348
+ if result.errors:
349
+ sys.exit(1)
350
+
351
+
352
+ def _cmd_watch(args: argparse.Namespace) -> None:
353
+ """Repeat sync forever with a sleep between cycles."""
354
+ while True:
355
+ try:
356
+ _cmd_sync(args)
357
+ except SystemExit as exc:
358
+ # Don't exit the watch loop on per-cycle errors.
359
+ logger.error("cycle failed: %s", exc)
360
+ logger.info("sleeping %ds before next cycle", args.interval)
361
+ time.sleep(args.interval)
362
+
363
+
364
+ def _cmd_status(args: argparse.Namespace) -> None:
365
+ state_store = SyncStateStore(path=args.state_file)
366
+ states = state_store.list_all()
367
+ if not states:
368
+ print("No sync state recorded yet.")
369
+ return
370
+ print(f"{'Connector':<20} {'Tenant':<20} {'Last synced':<30} {'Cursor'}")
371
+ print("-" * 90)
372
+ for s in states:
373
+ last = s.last_synced_at.isoformat() if s.last_synced_at else "never"
374
+ cursor = s.cursor or "-"
375
+ print(f"{s.connector_name:<20} {s.tenant_id:<20} {last:<30} {cursor}")
376
+
377
+
378
+ def _cmd_list(_: argparse.Namespace) -> None:
379
+ print("Available connectors:\n")
380
+ for slug, spec in CONNECTORS.items():
381
+ env_vars = [v for _, v in spec.get("secrets", [])]
382
+ print(f" {slug:<20} required env: {', '.join(env_vars)}")
383
+
384
+
385
+ def _cmd_auth(args: argparse.Namespace) -> None:
386
+ creds = _load_cortex_creds(args)
387
+ redacted = {
388
+ "api_url": creds["api_url"],
389
+ "api_key": (creds["api_key"][:8] + "..." + creds["api_key"][-4:]) if creds["api_key"] else "(unset)",
390
+ "actor": creds["actor"] or "(unset)",
391
+ "scope_template": creds["scope_template"] or "(unset)",
392
+ }
393
+ print(json.dumps(redacted, indent=2))
394
+
395
+
396
+ def _load_config(path: str | None) -> dict[str, Any]:
397
+ if path is None:
398
+ candidates = [
399
+ Path.cwd() / "cortexdb-connectors.yaml",
400
+ Path.cwd() / "cortexdb-connectors.yml",
401
+ Path.home() / ".cortexdb" / "connectors.yaml",
402
+ ]
403
+ for candidate in candidates:
404
+ if candidate.exists():
405
+ path = str(candidate)
406
+ break
407
+ if path is None:
408
+ return {}
409
+ try:
410
+ import yaml # type: ignore[import-untyped]
411
+ except ImportError:
412
+ logger.warning("PyYAML is not installed; ignoring config file %s", path)
413
+ return {}
414
+ with open(path, "r", encoding="utf-8") as fh:
415
+ data = yaml.safe_load(fh)
416
+ return data if isinstance(data, dict) else {}
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # Entry point
421
+ # ---------------------------------------------------------------------------
422
+
423
+
424
+ def main(argv: list[str] | None = None) -> None:
425
+ parser = argparse.ArgumentParser(
426
+ prog="cortexdb-sync",
427
+ description="CortexDB v1 data connector CLI",
428
+ )
429
+ parser.add_argument("--config", type=str, default=None, help="YAML config file path.")
430
+ parser.add_argument("--state-file", type=str, default=None, help="Sync state JSON file path.")
431
+ parser.add_argument("--api-url", type=str, help="CortexDB v1 API base URL.")
432
+ parser.add_argument("--api-key", type=str, help="PASETO bearer token.")
433
+ parser.add_argument("--actor", type=str, help="X-Cortex-Actor value.")
434
+ parser.add_argument("--scope-template", type=str, help='Scope template, e.g. "org:acme/source:slack".')
435
+ parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging.")
436
+ sub = parser.add_subparsers(dest="command")
437
+
438
+ sync_p = sub.add_parser("sync", help="One-shot sync for a connector.")
439
+ sync_p.add_argument("connector", choices=list(CONNECTORS.keys()))
440
+ sync_p.add_argument("--since", type=str, default=None)
441
+ sync_p.add_argument("--tenant-id", type=str, default="default")
442
+
443
+ watch_p = sub.add_parser("watch", help="Sync forever with a sleep between cycles.")
444
+ watch_p.add_argument("connector", choices=list(CONNECTORS.keys()))
445
+ watch_p.add_argument("--since", type=str, default=None)
446
+ watch_p.add_argument("--tenant-id", type=str, default="default")
447
+ watch_p.add_argument("--interval", type=int, default=300, help="Seconds between cycles (default 300).")
448
+
449
+ sub.add_parser("status", help="Show cursor state for every connector.")
450
+ sub.add_parser("list", help="List available connectors and required env vars.")
451
+ sub.add_parser("auth", help="Show resolved CortexDB credentials (redacted).")
452
+
453
+ args = parser.parse_args(argv)
454
+
455
+ logging.basicConfig(
456
+ level=logging.DEBUG if args.verbose else logging.INFO,
457
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
458
+ )
459
+
460
+ if args.command == "sync":
461
+ _cmd_sync(args)
462
+ elif args.command == "watch":
463
+ _cmd_watch(args)
464
+ elif args.command == "status":
465
+ _cmd_status(args)
466
+ elif args.command == "list":
467
+ _cmd_list(args)
468
+ elif args.command == "auth":
469
+ _cmd_auth(args)
470
+ else:
471
+ parser.print_help()
472
+ sys.exit(1)
473
+
474
+
475
+ if __name__ == "__main__":
476
+ main()