dottide 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.
dottide/__init__.py ADDED
File without changes
dottide/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Profiles: one TOML file per side, each with its own Google key and its own sites.
2
+
3
+ Keeping one file per side means a personal profile and a business profile never share a key,
4
+ and an agent can be pointed at a single file when it should only see one side.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+
15
+ from dottide import keys
16
+
17
+ DEFAULT_DIR = Path("~/.config/dottide")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Site:
22
+ name: str
23
+ profile: str
24
+ search_console: str | None = None
25
+ ga4_property: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class Profile:
30
+ name: str
31
+ keychain: str | None = None
32
+ key_file: str | None = None
33
+ sites: list[Site] = field(default_factory=list)
34
+ stores: dict = field(default_factory=dict)
35
+
36
+ @property
37
+ def key_source(self) -> str:
38
+ if self.keychain:
39
+ return f"keychain:{self.keychain}"
40
+ if self.key_file:
41
+ return f"file:{self.key_file}"
42
+ return "none"
43
+
44
+ def key_info(self) -> dict:
45
+ if self.keychain:
46
+ return keys.read(self.keychain)
47
+ if self.key_file:
48
+ return keys.read_file(self.key_file)
49
+ raise PermissionError(f"profile {self.name!r} has no [key] set")
50
+
51
+
52
+ def config_paths() -> list[Path]:
53
+ """DOTTIDE_CONFIG may name one file or a folder of .toml files."""
54
+ target = Path(os.environ.get("DOTTIDE_CONFIG", DEFAULT_DIR)).expanduser()
55
+ if target.is_file():
56
+ return [target]
57
+ if target.is_dir():
58
+ return sorted(p for p in target.glob("*.toml") if not p.name.startswith("_"))
59
+ return []
60
+
61
+
62
+ def load_profile(path: Path) -> Profile:
63
+ data = tomllib.loads(path.read_text())
64
+ key = data.get("key", {})
65
+ profile = Profile(
66
+ name=path.stem,
67
+ keychain=key.get("keychain"),
68
+ key_file=key.get("file"),
69
+ stores=data.get("stores", {}),
70
+ )
71
+ for entry in data.get("sites", []):
72
+ ga4 = entry.get("ga4_property")
73
+ profile.sites.append(
74
+ Site(
75
+ name=entry["name"],
76
+ profile=profile.name,
77
+ search_console=entry.get("search_console"),
78
+ ga4_property=str(ga4) if ga4 else None,
79
+ )
80
+ )
81
+ return profile
82
+
83
+
84
+ def load_profiles() -> list[Profile]:
85
+ return [load_profile(p) for p in config_paths()]
86
+
87
+
88
+ def find_site(profiles: list[Profile], name: str) -> tuple[Profile, Site]:
89
+ for profile in profiles:
90
+ for site in profile.sites:
91
+ if site.name == name:
92
+ return profile, site
93
+ known = ", ".join(s.name for p in profiles for s in p.sites) or "none configured"
94
+ raise ValueError(f"unknown site {name!r}. Configured sites: {known}")
95
+
96
+
97
+ def init_profile(name: str = "main") -> Path:
98
+ """Write a starter profile from the bundled template. Never overwrites a file."""
99
+ folder = DEFAULT_DIR.expanduser()
100
+ path = folder / f"{name}.toml"
101
+ if path.exists():
102
+ raise FileExistsError(f"{path} already exists; edit it, or pick another --profile name")
103
+ folder.mkdir(mode=0o700, parents=True, exist_ok=True)
104
+ path.write_text(files("dottide").joinpath("profile_template.toml").read_text())
105
+ path.chmod(0o600)
106
+ return path
dottide/discover.py ADDED
@@ -0,0 +1,122 @@
1
+ """Find every site the key can read and suggest profile entries for the ones not set up yet.
2
+
3
+ Reading is all it does on Google's side. The only write is `--write`, which appends
4
+ the suggested [[sites]] blocks to a local profile file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import urlparse
10
+
11
+ from dottide import google
12
+ from dottide.config import Profile, config_paths, load_profiles
13
+
14
+
15
+ def host(address: str) -> str:
16
+ """The bare domain behind a Search Console property or a site URL."""
17
+ if address.startswith("sc-domain:"):
18
+ address = address.removeprefix("sc-domain:")
19
+ elif "://" in address:
20
+ address = urlparse(address).hostname or ""
21
+ return address.strip("/").lower().removeprefix("www.")
22
+
23
+
24
+ def plan(gsc_properties: list[str], streams: list[dict], configured: list[dict]) -> list[dict]:
25
+ """Suggested sites, one per domain, skipping anything a profile already covers.
26
+
27
+ A domain property wins over a URL-prefix property for the same domain, because it
28
+ covers every subdomain and protocol.
29
+ """
30
+ taken = {host(s["name"]) for s in configured}
31
+ taken |= {host(s["search_console"]) for s in configured if s.get("search_console")}
32
+ taken_ga4 = {s["ga4_property"] for s in configured if s.get("ga4_property")}
33
+
34
+ by_host: dict[str, dict] = {}
35
+ for prop in gsc_properties:
36
+ entry = by_host.setdefault(host(prop), {"name": host(prop)})
37
+ current = entry.get("search_console", "")
38
+ if not current or (prop.startswith("sc-domain:") and not current.startswith("sc-domain:")):
39
+ entry["search_console"] = prop
40
+ for stream in streams:
41
+ key = host(stream["url"]) if stream["url"] else ""
42
+ if not key or stream["property"] in taken_ga4:
43
+ continue
44
+ entry = by_host.setdefault(key, {"name": key})
45
+ entry.setdefault("ga4_property", stream["property"])
46
+ entry.setdefault("measurement_id", stream["measurement_id"])
47
+ return [by_host[k] for k in sorted(by_host) if k not in taken]
48
+
49
+
50
+ def to_toml(sites: list[dict]) -> str:
51
+ blocks = []
52
+ for site in sites:
53
+ lines = ["[[sites]]", f'name = "{site["name"]}"']
54
+ if site.get("search_console"):
55
+ lines.append(f'search_console = "{site["search_console"]}"')
56
+ if site.get("ga4_property"):
57
+ note = f" # {site['measurement_id']}" if site.get("measurement_id") else ""
58
+ lines.append(f'ga4_property = "{site["ga4_property"]}"{note}')
59
+ blocks.append("\n".join(lines))
60
+ return "\n\n".join(blocks)
61
+
62
+
63
+ def discover(profile_name: str | None = None) -> dict:
64
+ """Read what the key can see and compare it with every profile."""
65
+ profiles = load_profiles()
66
+ keyed = [p for p in profiles if p.keychain or p.key_file]
67
+ if profile_name:
68
+ keyed = [p for p in keyed if p.name == profile_name]
69
+ if not keyed:
70
+ raise ValueError("no profile with a [key]; add one first (see docs/configuration.md)")
71
+
72
+ configured = [
73
+ {"name": s.name, "search_console": s.search_console, "ga4_property": s.ga4_property}
74
+ for p in profiles
75
+ for s in p.sites
76
+ ]
77
+ # Several profiles often share one key; read each key once.
78
+ seen: dict[str, Profile] = {}
79
+ for p in keyed:
80
+ seen.setdefault(p.key_source, p)
81
+
82
+ gsc, streams, errors = [], [], []
83
+ for profile in seen.values():
84
+ gsc += google.gsc_properties(profile)
85
+ try:
86
+ streams += google.ga4_web_streams(profile)
87
+ except Exception as exc: # noqa: BLE001 - GA4 is optional; Search Console still counts
88
+ errors.append(f"GA4 ({profile.key_source}): {exc}")
89
+
90
+ new = plan(sorted(set(gsc)), streams, configured)
91
+ result = {
92
+ "keys": list(seen),
93
+ "search_console_properties": sorted(set(gsc)),
94
+ "ga4_web_streams": streams,
95
+ "already_configured": [s["name"] for s in configured],
96
+ "new_sites": new,
97
+ "toml": to_toml(new),
98
+ }
99
+ if errors:
100
+ result["errors"] = errors
101
+ return result
102
+
103
+
104
+ def write(profile_name: str | None, toml: str) -> str:
105
+ """Append suggested blocks to a profile file and return its path."""
106
+ paths = config_paths()
107
+ if profile_name:
108
+ paths = [p for p in paths if p.stem == profile_name]
109
+ if len(paths) != 1:
110
+ names = ", ".join(p.stem for p in config_paths()) or "none"
111
+ raise ValueError(f"pick one profile with --profile to write to. Profiles: {names}")
112
+ path = paths[0]
113
+ text = path.read_text().rstrip()
114
+ # New sites go before [stores] when it exists, so the file keeps its usual order.
115
+ marker = "\n[stores]"
116
+ if marker in text:
117
+ head, tail = text.split(marker, 1)
118
+ text = f"{head.rstrip()}\n\n{toml}\n{marker}{tail}"
119
+ else:
120
+ text = f"{text}\n\n{toml}"
121
+ path.write_text(text + "\n")
122
+ return str(path)
dottide/google.py ADDED
@@ -0,0 +1,126 @@
1
+ """Search Console and GA4 reads through a service account. Read-only scopes only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2 import service_account
10
+
11
+ from dottide.config import Profile
12
+
13
+ SCOPES = [
14
+ "https://www.googleapis.com/auth/webmasters.readonly",
15
+ "https://www.googleapis.com/auth/analytics.readonly",
16
+ ]
17
+ GSC = "https://searchconsole.googleapis.com/webmasters/v3"
18
+ GA4_DATA = "https://analyticsdata.googleapis.com/v1beta"
19
+ GA4_ADMIN = "https://analyticsadmin.googleapis.com/v1beta"
20
+
21
+ _credentials: dict[str, service_account.Credentials] = {}
22
+
23
+
24
+ def client_email(profile: Profile) -> str:
25
+ return profile.key_info()["client_email"]
26
+
27
+
28
+ def _token(profile: Profile) -> str:
29
+ creds = _credentials.get(profile.name)
30
+ if creds is None:
31
+ creds = service_account.Credentials.from_service_account_info(
32
+ profile.key_info(), scopes=SCOPES
33
+ )
34
+ _credentials[profile.name] = creds
35
+ if not creds.valid:
36
+ creds.refresh(Request())
37
+ return creds.token
38
+
39
+
40
+ def _call(profile: Profile, method: str, url: str, **kwargs) -> dict:
41
+ headers = {"Authorization": f"Bearer {_token(profile)}"}
42
+ response = httpx.request(method, url, headers=headers, timeout=30, **kwargs)
43
+ if response.status_code >= 400:
44
+ try:
45
+ message = response.json()["error"]["message"]
46
+ except (ValueError, KeyError, TypeError):
47
+ message = response.text[:200]
48
+ raise RuntimeError(f"Google API {response.status_code}: {message}")
49
+ return response.json()
50
+
51
+
52
+ def gsc_properties(profile: Profile) -> list[str]:
53
+ entries = _call(profile, "GET", f"{GSC}/sites").get("siteEntry", [])
54
+ return [e["siteUrl"] for e in entries if e.get("permissionLevel") != "siteUnverifiedUser"]
55
+
56
+
57
+ def gsc_query(
58
+ profile: Profile, prop: str, start: str, end: str, dimension: str | None, limit: int = 25
59
+ ) -> dict[str, dict]:
60
+ """Rows keyed by query or page. With no dimension, one row keyed "total"."""
61
+ body: dict = {"startDate": start, "endDate": end, "rowLimit": limit}
62
+ if dimension:
63
+ body["dimensions"] = [dimension]
64
+ url = f"{GSC}/sites/{quote(prop, safe='')}/searchAnalytics/query"
65
+ rows = _call(profile, "POST", url, json=body).get("rows", [])
66
+ return {
67
+ (row["keys"][0] if dimension else "total"): {
68
+ "clicks": row["clicks"],
69
+ "impressions": row["impressions"],
70
+ "ctr": round(row["ctr"] * 100, 2),
71
+ "position": round(row["position"], 1),
72
+ }
73
+ for row in rows
74
+ }
75
+
76
+
77
+ def ga4_sources(profile: Profile, prop: str, start: str, end: str) -> list[dict]:
78
+ """Sessions by source and medium, biggest first."""
79
+ body = {
80
+ "dateRanges": [{"startDate": start, "endDate": end}],
81
+ "dimensions": [{"name": "sessionSource"}, {"name": "sessionMedium"}],
82
+ "metrics": [{"name": "sessions"}, {"name": "engagedSessions"}, {"name": "totalUsers"}],
83
+ "orderBys": [{"metric": {"metricName": "sessions"}, "desc": True}],
84
+ "limit": 1000,
85
+ }
86
+ data = _call(profile, "POST", f"{GA4_DATA}/properties/{prop}:runReport", json=body)
87
+ rows = []
88
+ for row in data.get("rows", []):
89
+ source, medium = (d["value"] for d in row["dimensionValues"])
90
+ sessions, engaged, users = (int(m["value"]) for m in row["metricValues"])
91
+ rows.append(
92
+ {
93
+ "source": source,
94
+ "medium": medium,
95
+ "sessions": sessions,
96
+ "engaged_sessions": engaged,
97
+ "users": users,
98
+ }
99
+ )
100
+ return rows
101
+
102
+
103
+ def ga4_web_streams(profile: Profile) -> list[dict]:
104
+ """Every GA4 web stream the key can read, with the site address it tracks."""
105
+ streams, page = [], None
106
+ while True:
107
+ params = {"pageSize": 200, **({"pageToken": page} if page else {})}
108
+ data = _call(profile, "GET", f"{GA4_ADMIN}/accountSummaries", params=params)
109
+ for account in data.get("accountSummaries", []):
110
+ for prop in account.get("propertySummaries", []):
111
+ pid = prop["property"].split("/")[1]
112
+ url = f"{GA4_ADMIN}/properties/{pid}/dataStreams"
113
+ for stream in _call(profile, "GET", url).get("dataStreams", []):
114
+ web = stream.get("webStreamData")
115
+ if web:
116
+ streams.append(
117
+ {
118
+ "property": pid,
119
+ "name": prop.get("displayName"),
120
+ "measurement_id": web.get("measurementId"),
121
+ "url": web.get("defaultUri", ""),
122
+ }
123
+ )
124
+ page = data.get("nextPageToken")
125
+ if not page:
126
+ return streams
dottide/keys.py ADDED
@@ -0,0 +1,53 @@
1
+ """Service account keys, read from the macOS keychain or a file outside any repo.
2
+
3
+ Keychain values are stored base64-encoded: `security find-generic-password -w` prints values
4
+ that contain newlines as hex, and base64 also lets us pass the key on stdin instead of argv,
5
+ so it never shows up in the process list.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ SECURITY = "/usr/bin/security"
16
+ ACCOUNT = "dottide"
17
+
18
+
19
+ def _validate(info: dict, where: str) -> dict:
20
+ if info.get("type") != "service_account" or "client_email" not in info:
21
+ raise ValueError(f"{where} is not a Google service account key")
22
+ return info
23
+
24
+
25
+ def read(service: str) -> dict:
26
+ out = subprocess.run(
27
+ [SECURITY, "find-generic-password", "-s", service, "-a", ACCOUNT, "-w"],
28
+ capture_output=True,
29
+ text=True,
30
+ )
31
+ if out.returncode != 0:
32
+ raise PermissionError(
33
+ f"no keychain item {service!r}; run: dottide import-key {service} <key.json>"
34
+ )
35
+ return _validate(json.loads(base64.b64decode(out.stdout.strip())), f"keychain item {service!r}")
36
+
37
+
38
+ def read_file(path: str) -> dict:
39
+ return _validate(json.loads(Path(path).expanduser().read_text()), path)
40
+
41
+
42
+ def store(service: str, path: str) -> str:
43
+ """Copy a key file into the keychain. Returns the service account email."""
44
+ info = read_file(path)
45
+ blob = base64.b64encode(json.dumps(info, separators=(",", ":")).encode()).decode()
46
+ label = f"'dotTide {service}'"
47
+ command = f"add-generic-password -U -s {service} -a {ACCOUNT} -l {label} -w {blob}\n"
48
+ out = subprocess.run([SECURITY, "-i"], input=command, capture_output=True, text=True)
49
+ if out.returncode != 0 or out.stderr.strip():
50
+ raise RuntimeError(f"keychain write failed: {out.stderr.strip() or out.returncode}")
51
+ if read(service)["client_email"] != info["client_email"]:
52
+ raise RuntimeError("keychain read-back did not match the key file")
53
+ return info["client_email"]
@@ -0,0 +1,25 @@
1
+ # dotTide profile, written by `dottide init`. The file name is the profile name.
2
+ # Never commit a filled-in copy. Every field is explained in docs/configuration.md.
3
+
4
+ [key]
5
+ # Where the Google service account key lives. Pick one.
6
+ keychain = "dottide-main" # set with: dottide import-key dottide-main key.json
7
+ # file = "~/.config/dottide/keys/main.json"
8
+
9
+ # One block per site. `dottide discover --write` adds them for you once the key can read your sites.
10
+ #
11
+ # search_console: the property exactly as Search Console shows it:
12
+ # "sc-domain:example.com" for a domain property, "https://example.com/" for a URL prefix.
13
+ # ga4_property: the numeric property ID (Admin > Property details), not the G- measurement ID.
14
+ # Leave it out for sites without GA4.
15
+ #
16
+ # [[sites]]
17
+ # name = "example.com"
18
+ # search_console = "sc-domain:example.com"
19
+ # ga4_property = "123456789"
20
+
21
+ [stores]
22
+ # Public numbers, no key needed.
23
+ wordpress_org = [] # plugin slugs
24
+ firefox = [] # Firefox Add-ons slugs
25
+ github_owners = [] # users or orgs; only public repos are counted
dottide/report.py ADDED
@@ -0,0 +1,197 @@
1
+ """The four reports, shared by the MCP server, the CLI, and the weekly snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+
7
+ from dottide import google, stores
8
+ from dottide.config import Profile, find_site, load_profiles
9
+
10
+ # Search Console data settles about three days after the fact.
11
+ GSC_LAG_DAYS = 3
12
+
13
+ AI_SOURCES = {
14
+ "chatgpt.com": "ChatGPT",
15
+ "chat.openai.com": "ChatGPT",
16
+ "perplexity.ai": "Perplexity",
17
+ "copilot.microsoft.com": "Copilot",
18
+ "copilot.com": "Copilot",
19
+ "gemini.google.com": "Gemini",
20
+ "bard.google.com": "Gemini",
21
+ "claude.ai": "Claude",
22
+ "chat.mistral.ai": "Mistral",
23
+ "chat.deepseek.com": "DeepSeek",
24
+ "grok.com": "Grok",
25
+ "meta.ai": "Meta AI",
26
+ "you.com": "You.com",
27
+ "phind.com": "Phind",
28
+ }
29
+
30
+
31
+ def ai_assistant(source: str) -> str | None:
32
+ host = source.lower().strip().removeprefix("www.")
33
+ for domain, name in AI_SOURCES.items():
34
+ if host == domain or host.endswith("." + domain):
35
+ return name
36
+ return None
37
+
38
+
39
+ def period(start: str | None, end: str | None, days: int) -> tuple[date, date]:
40
+ last = date.fromisoformat(end) if end else date.today() - timedelta(days=GSC_LAG_DAYS)
41
+ first = date.fromisoformat(start) if start else last - timedelta(days=days - 1)
42
+ if first > last:
43
+ raise ValueError(f"start {first} is after end {last}")
44
+ return first, last
45
+
46
+
47
+ def previous(first: date, last: date) -> tuple[date, date]:
48
+ length = (last - first).days + 1
49
+ prev_last = first - timedelta(days=1)
50
+ return prev_last - timedelta(days=length - 1), prev_last
51
+
52
+
53
+ def _delta(now: dict, before: dict | None, fields: tuple[str, ...]) -> dict:
54
+ out = dict(now)
55
+ for f in fields:
56
+ old = (before or {}).get(f)
57
+ out[f"previous_{f}"] = old
58
+ out[f"change_{f}"] = None if old is None else round(now[f] - old, 2)
59
+ return out
60
+
61
+
62
+ def sites() -> dict:
63
+ """Every configured site, the key that covers it, and whether access works when you ask."""
64
+ report = []
65
+ for profile in load_profiles():
66
+ entry: dict = {"profile": profile.name, "key": profile.key_source, "sites": []}
67
+ try:
68
+ entry["service_account"] = google.client_email(profile)
69
+ visible = google.gsc_properties(profile)
70
+ entry["search_console_visible"] = visible
71
+ except Exception as exc: # noqa: BLE001 - report every failure, never stop the list
72
+ entry["key_error"] = str(exc)
73
+ visible = None
74
+ for site in profile.sites:
75
+ status: dict = {"site": site.name}
76
+ if site.search_console:
77
+ if visible is None:
78
+ status["search_console"] = "no key access"
79
+ elif site.search_console in visible:
80
+ status["search_console"] = "ok"
81
+ else:
82
+ near = [v for v in visible if site.name in v]
83
+ status["search_console"] = "not shared with this key" + (
84
+ f"; visible instead: {', '.join(near)}" if near else ""
85
+ )
86
+ if site.ga4_property:
87
+ status["ga4"] = _ga4_check(profile, site.ga4_property)
88
+ else:
89
+ status["ga4"] = "not used"
90
+ entry["sites"].append(status)
91
+ report.append(entry)
92
+ return {"profiles": report}
93
+
94
+
95
+ def _ga4_check(profile: Profile, prop: str) -> str:
96
+ day = (date.today() - timedelta(days=1)).isoformat()
97
+ try:
98
+ google.ga4_sources(profile, prop, day, day)
99
+ return "ok"
100
+ except Exception as exc: # noqa: BLE001
101
+ return str(exc)
102
+
103
+
104
+ def search_queries(
105
+ site: str,
106
+ by: str = "query",
107
+ start: str | None = None,
108
+ end: str | None = None,
109
+ days: int = 28,
110
+ compare: bool = False,
111
+ limit: int = 25,
112
+ ) -> dict:
113
+ if by not in ("query", "page"):
114
+ raise ValueError("by must be 'query' or 'page'")
115
+ profile, s = find_site(load_profiles(), site)
116
+ if not s.search_console:
117
+ raise ValueError(f"{site} has no search_console property in its profile")
118
+ first, last = period(start, end, days)
119
+ fields = ("clicks", "impressions", "ctr", "position")
120
+ rows = google.gsc_query(profile, s.search_console, str(first), str(last), by, limit)
121
+ totals = google.gsc_query(profile, s.search_console, str(first), str(last), None).get(
122
+ "total", dict.fromkeys(fields, 0)
123
+ )
124
+ result: dict = {
125
+ "site": site,
126
+ "property": s.search_console,
127
+ "start": str(first),
128
+ "end": str(last),
129
+ "by": by,
130
+ }
131
+ if compare:
132
+ p_first, p_last = previous(first, last)
133
+ p_args = (profile, s.search_console, str(p_first), str(p_last))
134
+ before = google.gsc_query(*p_args, by, 5000)
135
+ p_totals = google.gsc_query(*p_args, None).get("total")
136
+ result["previous_start"], result["previous_end"] = str(p_first), str(p_last)
137
+ result["totals"] = _delta(totals, p_totals, fields)
138
+ result["rows"] = [{by: k, **_delta(v, before.get(k), fields)} for k, v in rows.items()]
139
+ else:
140
+ result["totals"] = totals
141
+ result["rows"] = [{by: k, **v} for k, v in rows.items()]
142
+ return result
143
+
144
+
145
+ def _traffic_summary(rows: list[dict], limit: int) -> dict:
146
+ ai: dict[str, dict] = {}
147
+ for row in rows:
148
+ if name := ai_assistant(row["source"]):
149
+ bucket = ai.setdefault(name, {"assistant": name, "sessions": 0, "engaged_sessions": 0})
150
+ bucket["sessions"] += row["sessions"]
151
+ bucket["engaged_sessions"] += row["engaged_sessions"]
152
+ return {
153
+ "sessions": sum(r["sessions"] for r in rows),
154
+ "ai_sessions": sum(a["sessions"] for a in ai.values()),
155
+ "ai_referrals": sorted(ai.values(), key=lambda a: -a["sessions"]),
156
+ "sources": rows[:limit],
157
+ }
158
+
159
+
160
+ def traffic(
161
+ site: str,
162
+ start: str | None = None,
163
+ end: str | None = None,
164
+ days: int = 28,
165
+ compare: bool = False,
166
+ limit: int = 25,
167
+ ) -> dict:
168
+ profile, s = find_site(load_profiles(), site)
169
+ if not s.ga4_property:
170
+ raise ValueError(f"{site} has no GA4 property; traffic only covers sites with GA4")
171
+ first, last = period(start, end, days)
172
+ rows = google.ga4_sources(profile, s.ga4_property, str(first), str(last))
173
+ result = {"site": site, "property": s.ga4_property, "start": str(first), "end": str(last)}
174
+ result |= _traffic_summary(rows, limit)
175
+ if compare:
176
+ p_first, p_last = previous(first, last)
177
+ p_rows = google.ga4_sources(profile, s.ga4_property, str(p_first), str(p_last))
178
+ before = _traffic_summary(p_rows, limit)
179
+ result["previous"] = {
180
+ "start": str(p_first),
181
+ "end": str(p_last),
182
+ "sessions": before["sessions"],
183
+ "ai_sessions": before["ai_sessions"],
184
+ "ai_referrals": before["ai_referrals"],
185
+ }
186
+ return result
187
+
188
+
189
+ def store_stats(profile: str | None = None) -> dict:
190
+ merged: dict[str, list] = {}
191
+ for p in load_profiles():
192
+ if profile and p.name != profile:
193
+ continue
194
+ for store, items in p.stores.items():
195
+ merged.setdefault(store, [])
196
+ merged[store] += [i for i in items if i not in merged[store]]
197
+ return stores.collect(merged)
dottide/server.py ADDED
@@ -0,0 +1,172 @@
1
+ """dotTide MCP server and command line.
2
+
3
+ dottide run the MCP server on stdio (what MCP clients call)
4
+ dottide sites print any report as JSON; also search_queries, traffic, store_stats
5
+ dottide init write a starter profile to ~/.config/dottide/main.toml
6
+ dottide discover [--write] suggest profile entries for sites the key can read
7
+ dottide snapshot [--out DIR] write the weekly snapshot, one file per profile
8
+ dottide import-key NAME FILE copy a service account key into the macOS keychain
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import sys
17
+
18
+ from mcp.server.mcpserver import MCPServer
19
+ from mcp.types import ToolAnnotations
20
+
21
+ from dottide import discover as finder
22
+ from dottide import keys, report, snapshot
23
+ from dottide.config import init_profile
24
+
25
+ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=True)
26
+
27
+ server = MCPServer(
28
+ name="dottide",
29
+ instructions=(
30
+ "Read-only numbers for every configured site and product. Call `sites` first to see "
31
+ "which sites exist and whether their access works. Search Console data lags about "
32
+ "three days, so default ranges end three days ago."
33
+ ),
34
+ )
35
+
36
+
37
+ @server.tool(annotations=READ_ONLY)
38
+ def sites() -> dict:
39
+ """Every configured site, which key covers it, and whether Search Console and GA4 access
40
+ works at the moment of the call."""
41
+ return report.sites()
42
+
43
+
44
+ @server.tool(annotations=READ_ONLY)
45
+ def search_queries(
46
+ site: str,
47
+ by: str = "query",
48
+ start: str | None = None,
49
+ end: str | None = None,
50
+ days: int = 28,
51
+ compare: bool = False,
52
+ limit: int = 25,
53
+ ) -> dict:
54
+ """Search Console clicks, impressions, CTR (percent) and average position, by "query" or
55
+ "page". Dates are YYYY-MM-DD; without them the range is the last `days` days ending three
56
+ days ago. compare=true adds the previous period of the same length and the change."""
57
+ return report.search_queries(site, by, start, end, days, compare, limit)
58
+
59
+
60
+ @server.tool(annotations=READ_ONLY)
61
+ def traffic(
62
+ site: str,
63
+ start: str | None = None,
64
+ end: str | None = None,
65
+ days: int = 28,
66
+ compare: bool = False,
67
+ limit: int = 25,
68
+ ) -> dict:
69
+ """GA4 sessions by source and medium, plus AI assistant referrals (ChatGPT, Perplexity,
70
+ Copilot, Gemini, Claude and others) totalled separately. Only for sites with GA4."""
71
+ return report.traffic(site, start, end, days, compare, limit)
72
+
73
+
74
+ @server.tool(annotations=READ_ONLY)
75
+ def store_stats(profile: str | None = None) -> dict:
76
+ """Live public store numbers that need no keys: WordPress.org plugins (installs, rating,
77
+ downloads last 7 days), Firefox Add-ons (daily users, ratings), GitHub stars."""
78
+ return report.store_stats(profile)
79
+
80
+
81
+ @server.tool(annotations=READ_ONLY)
82
+ def discover(profile: str | None = None) -> dict:
83
+ """Every Search Console property and GA4 web stream the key can read, matched by domain,
84
+ with ready-to-paste [[sites]] blocks for any site not in a profile yet. Reads only; the
85
+ user adds the blocks with `dottide discover --write`."""
86
+ return finder.discover(profile)
87
+
88
+
89
+ def main() -> None:
90
+ # httpx logs every request URL at INFO; keep stderr for real problems.
91
+ logging.getLogger("httpx").setLevel(logging.WARNING)
92
+ if len(sys.argv) == 1:
93
+ server.run("stdio")
94
+ return
95
+ parser = argparse.ArgumentParser(prog="dottide")
96
+ sub = parser.add_subparsers(dest="command", required=True)
97
+ sub.add_parser("sites")
98
+ q = sub.add_parser("search_queries")
99
+ q.add_argument("site")
100
+ q.add_argument("--by", default="query", choices=["query", "page"])
101
+ t = sub.add_parser("traffic")
102
+ t.add_argument("site")
103
+ for p in (q, t):
104
+ p.add_argument("--start")
105
+ p.add_argument("--end")
106
+ p.add_argument("--days", type=int, default=28)
107
+ p.add_argument("--compare", action="store_true")
108
+ p.add_argument("--limit", type=int, default=25)
109
+ s = sub.add_parser("store_stats")
110
+ s.add_argument("--profile")
111
+ i = sub.add_parser("init")
112
+ i.add_argument("--profile", default="main", help="profile name, which is also the file name")
113
+ d = sub.add_parser("discover")
114
+ d.add_argument("--profile", help="only use this profile's key, and write to it")
115
+ d.add_argument("--write", action="store_true", help="append the new sites to the profile")
116
+ snap = sub.add_parser("snapshot")
117
+ snap.add_argument("--out", help="folder for <profile>-<year>-W<week>.json; stdout if absent")
118
+ k = sub.add_parser("import-key")
119
+ k.add_argument("name", help="keychain item name, for example dottide-main")
120
+ k.add_argument("file", help="path to the downloaded service account JSON key")
121
+ args = parser.parse_args()
122
+ try:
123
+ _run(args)
124
+ except (OSError, ValueError, RuntimeError) as exc:
125
+ # Expected problems (missing key, unknown site, existing file) get one clear line.
126
+ sys.exit(f"dottide: {exc}")
127
+
128
+
129
+ def _run(args: argparse.Namespace) -> None:
130
+ if args.command == "import-key":
131
+ print(f"stored {keys.store(args.name, args.file)} in keychain item {args.name}")
132
+ return
133
+ if args.command == "init":
134
+ path = init_profile(args.profile)
135
+ print(f"wrote {path}")
136
+ print("next: dottide import-key dottide-main <key.json>, then dottide discover --write")
137
+ return
138
+ if args.command == "discover":
139
+ found = finder.discover(args.profile)
140
+ if args.write and found["new_sites"]:
141
+ path = finder.write(args.profile, found["toml"])
142
+ print(f"added {len(found['new_sites'])} sites to {path}")
143
+ elif found["new_sites"]:
144
+ print(found["toml"])
145
+ print(
146
+ f"\n# {len(found['new_sites'])} new sites. Add them with --write.", file=sys.stderr
147
+ )
148
+ else:
149
+ print("Every site the key can read is already in a profile.")
150
+ for error in found.get("errors", []):
151
+ print(f"note: {error}", file=sys.stderr)
152
+ return
153
+ if args.command == "snapshot":
154
+ snapshot.run(args.out)
155
+ return
156
+ if args.command == "sites":
157
+ result = report.sites()
158
+ elif args.command == "search_queries":
159
+ result = report.search_queries(
160
+ args.site, args.by, args.start, args.end, args.days, args.compare, args.limit
161
+ )
162
+ elif args.command == "traffic":
163
+ result = report.traffic(
164
+ args.site, args.start, args.end, args.days, args.compare, args.limit
165
+ )
166
+ else:
167
+ result = report.store_stats(args.profile)
168
+ print(json.dumps(result, indent=2))
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
dottide/snapshot.py ADDED
@@ -0,0 +1,65 @@
1
+ """Weekly snapshot: last full week against the week before, one JSON file per profile.
2
+
3
+ One file per profile keeps each side's numbers apart, the same way the keys are.
4
+ A failing site records its error and the snapshot carries on, so one revoked
5
+ permission never costs the whole week's numbers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import UTC, date, datetime, timedelta
12
+ from pathlib import Path
13
+
14
+ from dottide import report
15
+ from dottide.config import load_profiles
16
+
17
+ SCHEMA = 1
18
+
19
+
20
+ def _safe(fn, *args, **kwargs) -> dict:
21
+ try:
22
+ return fn(*args, **kwargs)
23
+ except Exception as exc: # noqa: BLE001 - the error is the data point
24
+ return {"error": str(exc)}
25
+
26
+
27
+ def build(profile_name: str) -> dict:
28
+ profile = next(p for p in load_profiles() if p.name == profile_name)
29
+ last = date.today() - timedelta(days=report.GSC_LAG_DAYS)
30
+ first = last - timedelta(days=6)
31
+ week = {"start": str(first), "end": str(last), "days": 7, "compare": True}
32
+ sites = []
33
+ for site in profile.sites:
34
+ entry: dict = {"site": site.name}
35
+ if site.search_console:
36
+ entry["queries"] = _safe(report.search_queries, site.name, "query", **week, limit=10)
37
+ entry["pages"] = _safe(report.search_queries, site.name, "page", **week, limit=10)
38
+ if site.ga4_property:
39
+ entry["traffic"] = _safe(report.traffic, site.name, **week, limit=10)
40
+ sites.append(entry)
41
+ return {
42
+ "schema": SCHEMA,
43
+ "profile": profile.name,
44
+ "generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
45
+ "week": {"start": str(first), "end": str(last)},
46
+ "sites": sites,
47
+ "stores": _safe(report.store_stats, profile.name),
48
+ }
49
+
50
+
51
+ def run(out: str | None) -> list[Path]:
52
+ written = []
53
+ year, week, _ = date.today().isocalendar()
54
+ for profile in load_profiles():
55
+ data = build(profile.name)
56
+ if out is None:
57
+ print(json.dumps(data, indent=2))
58
+ continue
59
+ folder = Path(out).expanduser()
60
+ folder.mkdir(parents=True, exist_ok=True)
61
+ path = folder / f"{profile.name}-{year}-W{week:02d}.json"
62
+ path.write_text(json.dumps(data, indent=2) + "\n")
63
+ written.append(path)
64
+ print(path)
65
+ return written
dottide/stores.py ADDED
@@ -0,0 +1,94 @@
1
+ """Public store numbers that need no login: WordPress.org, Firefox Add-ons, GitHub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import os
7
+ from datetime import UTC, datetime
8
+
9
+ import httpx
10
+
11
+ TIMEOUT = 20
12
+
13
+
14
+ def _get(url: str, **kwargs) -> httpx.Response:
15
+ return httpx.get(url, timeout=TIMEOUT, follow_redirects=True, **kwargs)
16
+
17
+
18
+ def wordpress_org(slug: str) -> dict:
19
+ info = _get(
20
+ "https://api.wordpress.org/plugins/info/1.2/",
21
+ params={"action": "plugin_information", "request[slug]": slug},
22
+ ).json()
23
+ if "error" in info:
24
+ return {"slug": slug, "error": info["error"]}
25
+ daily = _get(
26
+ "https://api.wordpress.org/stats/plugin/1.0/downloads.php",
27
+ params={"slug": slug, "limit": 8},
28
+ ).json()
29
+ # The endpoint returns full days only; keep the newest seven.
30
+ week = dict(sorted(daily.items())[-7:])
31
+ return {
32
+ "slug": slug,
33
+ "name": html.unescape(info.get("name") or ""),
34
+ "version": info.get("version"),
35
+ "active_installs": info.get("active_installs"),
36
+ "rating": info.get("rating"),
37
+ "num_ratings": info.get("num_ratings"),
38
+ "support_threads": info.get("support_threads"),
39
+ "support_threads_resolved": info.get("support_threads_resolved"),
40
+ "last_updated": info.get("last_updated"),
41
+ "downloads_last_7_days": sum(int(v) for v in week.values()),
42
+ "downloads_by_day": {day: int(v) for day, v in week.items()},
43
+ }
44
+
45
+
46
+ def firefox(slug: str) -> dict:
47
+ response = _get(f"https://addons.mozilla.org/api/v5/addons/addon/{slug}/")
48
+ if response.status_code != 200:
49
+ return {"slug": slug, "error": f"AMO {response.status_code}"}
50
+ data = response.json()
51
+ return {
52
+ "slug": slug,
53
+ "version": (data.get("current_version") or {}).get("version"),
54
+ "average_daily_users": data.get("average_daily_users"),
55
+ "weekly_downloads": data.get("weekly_downloads"),
56
+ "rating": data.get("ratings", {}).get("average"),
57
+ "num_ratings": data.get("ratings", {}).get("count"),
58
+ }
59
+
60
+
61
+ def github(owner: str) -> dict:
62
+ """Stars and forks for an owner's public repos. Anonymous unless DOTTIDE_GITHUB_TOKEN is set."""
63
+ headers = {"Accept": "application/vnd.github+json"}
64
+ if token := os.environ.get("DOTTIDE_GITHUB_TOKEN"):
65
+ headers["Authorization"] = f"Bearer {token}"
66
+ response = _get(
67
+ f"https://api.github.com/users/{owner}/repos",
68
+ params={"type": "owner", "per_page": 100},
69
+ headers=headers,
70
+ )
71
+ if response.status_code == 403 and response.headers.get("x-ratelimit-remaining") == "0":
72
+ reset = datetime.fromtimestamp(int(response.headers["x-ratelimit-reset"]), UTC)
73
+ return {"owner": owner, "error": f"GitHub rate limit, resets {reset:%Y-%m-%d %H:%M} UTC"}
74
+ if response.status_code != 200:
75
+ return {"owner": owner, "error": f"GitHub {response.status_code}"}
76
+ repos = [
77
+ {"repo": r["name"], "stars": r["stargazers_count"], "forks": r["forks_count"]}
78
+ for r in response.json()
79
+ if not r.get("fork") and not r.get("private")
80
+ ]
81
+ return {
82
+ "owner": owner,
83
+ "total_stars": sum(r["stars"] for r in repos),
84
+ "repos": sorted(repos, key=lambda r: -r["stars"]),
85
+ }
86
+
87
+
88
+ def collect(stores: dict) -> dict:
89
+ """stores is the merged [stores] table: lists of slugs or owners per store."""
90
+ return {
91
+ "wordpress_org": [wordpress_org(s) for s in stores.get("wordpress_org", [])],
92
+ "firefox": [firefox(s) for s in stores.get("firefox", [])],
93
+ "github": [github(o) for o in stores.get("github_owners", [])],
94
+ }
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: dottide
3
+ Version: 0.2.0
4
+ Summary: Read-only numbers for every site and product you run, across Google accounts, for people and AI agents.
5
+ Project-URL: Homepage, https://github.com/dotbrainhq/dottide
6
+ Project-URL: Documentation, https://github.com/dotbrainhq/dottide/tree/main/docs
7
+ Project-URL: Issues, https://github.com/dotbrainhq/dottide/issues
8
+ Project-URL: Changelog, https://github.com/dotbrainhq/dottide/blob/main/CHANGELOG.md
9
+ Author-email: Dotbrain LLC <hello@dotbrainhq.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,analytics,claude,cli,ga4,google-analytics,google-search-console,mcp,mcp-server,seo
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: google-auth[requests]>=2.40
22
+ Requires-Dist: httpx>=0.28
23
+ Requires-Dist: mcp>=2.3
24
+ Description-Content-Type: text/markdown
25
+
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-dark.png">
28
+ <img src="https://raw.githubusercontent.com/dotbrainhq/dottide/main/.github/assets/cover-light.png" alt="dotTide: every site's numbers, one place. Search Console, GA4 and store stats across Google accounts, for you and your AI agents. Local and read-only. Next to the text, the dotTide icon: a wave running across a green tile, with one dot under the surface.">
29
+ </picture>
30
+
31
+ # dotTide
32
+
33
+ dotTide is a read-only MCP server and command-line tool that gives Claude, Cursor, Codex and other AI agents the numbers for every site you run. It reads Google Search Console and Google Analytics 4 (GA4) across multiple Google accounts, plus WordPress.org, Firefox Add-ons and GitHub stats, and writes a weekly snapshot for scripts and agents.
34
+
35
+ Nothing gets added to your sites. dotTide only reads numbers you already have, and your keys stay on your Mac.
36
+
37
+ ## Why
38
+
39
+ If your sites sit under different Google accounts, checking them means logging in and out of each one. The weekly check slips to monthly, and any AI agent you ask works from stale numbers. Store numbers for plugins, extensions and repos live in yet more places.
40
+
41
+ dotTide reads all of it with one read-only key, and answers the same way in Claude, on the command line, and in a weekly JSON file.
42
+
43
+ ## What it does
44
+
45
+ - **`sites`:** every configured site, which key covers it, and whether Search Console and GA4 access works when you ask.
46
+ - **`search_queries`:** clicks, impressions, CTR and average position by query or page, for any date range, with an optional comparison to the previous period.
47
+ - **`traffic`:** GA4 sessions by source and medium, with AI assistant referrals (ChatGPT, Perplexity, Copilot, Gemini, Claude and others) counted separately. Only for sites that have GA4.
48
+ - **`store_stats`:** live public numbers that need no key: WordPress.org plugins, Firefox Add-ons, GitHub stars.
49
+ - **`discover`:** finds every Search Console property and GA4 property the key can read, and writes the profile entries for you.
50
+ - **Weekly snapshot:** last full week against the week before, one JSON file per profile, for scripts and agents that read files.
51
+
52
+ ## How access works
53
+
54
+ No site moves to a new Google account. You create one Google service account, and whichever Google account owns a site invites that service account's email:
55
+
56
+ - **Search Console:** as a Restricted user.
57
+ - **GA4:** as a Viewer.
58
+
59
+ The service account can only read, and you can remove it from any site in one click. Its key lives in the macOS keychain.
60
+
61
+ ## Quick start
62
+
63
+ You need macOS and [uv](https://docs.astral.sh/uv/). There is nothing to clone: `uvx` downloads dotTide the first time you run it.
64
+
65
+ 1. Write a starter profile, then add a plugin slug, an add-on slug or a GitHub user under `[stores]` in it:
66
+
67
+ ```
68
+ uvx dottide init
69
+ ```
70
+
71
+ 2. Check that it runs. Store numbers need no Google setup at all:
72
+
73
+ ```
74
+ uvx dottide store_stats
75
+ ```
76
+
77
+ 3. Set up Google access by following [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md). It takes about ten minutes, plus a minute per site.
78
+
79
+ 4. Store the key, let dotTide find your sites, and check access:
80
+
81
+ ```
82
+ uvx dottide import-key dottide-main ~/Downloads/<downloaded-key>.json
83
+ uvx dottide discover --write
84
+ uvx dottide sites
85
+ ```
86
+
87
+ 5. Connect it to Claude Code:
88
+
89
+ ```
90
+ claude mcp add --scope user dottide -- uvx dottide
91
+ ```
92
+
93
+ To update later, run `uvx dottide@latest sites` once; after that `uvx dottide` uses the new version.
94
+
95
+ ## Documentation
96
+
97
+ - [Google setup](https://github.com/dotbrainhq/dottide/blob/main/docs/google-setup.md): the service account, click by click, and sharing each site.
98
+ - [Configuration](https://github.com/dotbrainhq/dottide/blob/main/docs/configuration.md): profiles, every field, keys and environment variables.
99
+ - [Using dotTide](https://github.com/dotbrainhq/dottide/blob/main/docs/usage.md): each tool and its options, MCP clients, the weekly snapshot and its format, and fixes for common errors.
100
+ - [Asking an AI agent for your numbers](https://github.com/dotbrainhq/dottide/blob/main/docs/examples.md): example prompts for weekly checks, content to refresh, AI referrals and store numbers, plus a saved Claude Code command.
101
+ - [Connecting AI clients](https://github.com/dotbrainhq/dottide/blob/main/docs/clients.md): Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex, Gemini CLI, Grok, and agents without MCP.
102
+
103
+ ## Good to know
104
+
105
+ - Search Console data settles about three days after the fact, so default ranges end three days ago.
106
+ - Every Google scope dotTide asks for is read-only, and no tool can change anything.
107
+ - GitHub allows 60 anonymous requests an hour per IP. Set `DOTTIDE_GITHUB_TOKEN` to a read-only token if you hit the limit.
108
+
109
+ ## Contributing
110
+
111
+ See [CONTRIBUTING.md](https://github.com/dotbrainhq/dottide/blob/main/CONTRIBUTING.md). Security reports go through [SECURITY.md](https://github.com/dotbrainhq/dottide/blob/main/SECURITY.md).
112
+
113
+ ## License
114
+
115
+ The code is released under the [MIT License](https://github.com/dotbrainhq/dottide/blob/main/LICENSE). The dotTide name and icon are not covered by the license, so please use your own name and icon if you ship a copy.
@@ -0,0 +1,15 @@
1
+ dottide/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dottide/config.py,sha256=oIFWuaGRT4xY3R1rtJUM9nqEwZHmnxBtFWhJ4D9tLLE,3276
3
+ dottide/discover.py,sha256=pGFQ_4zAsYI0ra12oW8f9U1DBHEAFrFNJwuDNh_hE-U,4832
4
+ dottide/google.py,sha256=Vp_gs1KwQs37xdjpOC2mdisHIGoEZIMlPK52F0y70FQ,4872
5
+ dottide/keys.py,sha256=iqBMPMy7hOPs2x3YnOGBc3BWkTQ3E8cGyAQF7GtzqoM,2021
6
+ dottide/profile_template.toml,sha256=6mDlSKeT0Tnz0460gSYoY_3nk7WVEUb2IZJA5dLwP3w,1071
7
+ dottide/report.py,sha256=ZVGatjCopiswHKoYXVQ5zpCWrB93yeU6xPGdRJ3Q_Zo,7240
8
+ dottide/server.py,sha256=Gu0xnoWY_ulW4OY_shQ6kVbJH2fBJyZw4q2vmiTWMzU,6664
9
+ dottide/snapshot.py,sha256=z_rlheJkkSMMUNksRH4VJwAbnh8nx_caGsiL-4vyIcc,2288
10
+ dottide/stores.py,sha256=LoO_3Zu3KVjIQB0RxNauyDQoouD04FcIS6L_vlk_kjE,3622
11
+ dottide-0.2.0.dist-info/METADATA,sha256=aHKcJ956xx72Lk4_GXlcCxktcqm8li9l8jWWMaYzuXk,6409
12
+ dottide-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ dottide-0.2.0.dist-info/entry_points.txt,sha256=W7izUDENGBxbvryFkU_tm1woqJRZkfQ6Yhc9qMpQLZo,48
14
+ dottide-0.2.0.dist-info/licenses/LICENSE,sha256=9872o4dDkt3afhExxcLHMsWCbMY8ad2QlbR_AMw45UU,1069
15
+ dottide-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dottide = dottide.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dotbrain LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.