dotmd 0.1.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.
dotmd/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """dotmd package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
dotmd/api.py ADDED
@@ -0,0 +1,467 @@
1
+ """Supabase API helpers for dotmd CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from dataclasses import dataclass
7
+ import json
8
+ import os
9
+ import re
10
+ from typing import Any, Dict, List, Optional, Sequence
11
+ from urllib.parse import urljoin
12
+
13
+ import requests
14
+
15
+ DEFAULT_BASE_URL = "https://xxdzzzloqgdexwlkljbi.supabase.co/rest/v1"
16
+ DEFAULT_REGISTRY_SITE_URL = "https://mydotmd.io"
17
+ SUPABASE_REST_RE = re.compile(r"https://[a-z0-9-]+\.supabase\.co/rest/v1", re.IGNORECASE)
18
+ SCRIPT_SRC_RE = re.compile(r"""<script[^>]+src=["']([^"']+)["']""", re.IGNORECASE)
19
+ JWT_RE = re.compile(
20
+ r"eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+
25
+ class DotmdAPIError(RuntimeError):
26
+ """Raised when the dotmd backend request fails."""
27
+
28
+
29
+ @dataclass
30
+ class RuleRecord:
31
+ content: str
32
+ format_type: str
33
+
34
+
35
+ class DotmdAPI:
36
+ """Thin API client for the mydotmd Supabase backend."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ base_url: Optional[str] = None,
42
+ anon_key: Optional[str] = None,
43
+ timeout: int = 20,
44
+ ):
45
+ configured_base_url = (
46
+ base_url
47
+ or os.getenv("DOTMD_SUPABASE_BASE_URL")
48
+ or os.getenv("DOTMD_BASE_URL")
49
+ or DEFAULT_BASE_URL
50
+ )
51
+ configured_anon_key = (
52
+ anon_key
53
+ or os.getenv("DOTMD_SUPABASE_ANON_KEY")
54
+ or os.getenv("DOTMD_API_KEY")
55
+ )
56
+
57
+ self.base_url = configured_base_url.rstrip("/")
58
+ self.timeout = timeout
59
+ self.registry_site_url = (
60
+ os.getenv("DOTMD_REGISTRY_SITE_URL") or DEFAULT_REGISTRY_SITE_URL
61
+ ).rstrip("/")
62
+ self._base_url_is_explicit = bool(
63
+ base_url or os.getenv("DOTMD_SUPABASE_BASE_URL") or os.getenv("DOTMD_BASE_URL")
64
+ )
65
+ self._anon_key_is_explicit = bool(
66
+ anon_key or os.getenv("DOTMD_SUPABASE_ANON_KEY") or os.getenv("DOTMD_API_KEY")
67
+ )
68
+ self._discovery_attempted = False
69
+ self._key_discovery_attempted = False
70
+ self.headers: Dict[str, str] = {}
71
+ if configured_anon_key:
72
+ self._set_auth_headers(configured_anon_key)
73
+
74
+ def _set_auth_headers(self, anon_key: str) -> None:
75
+ self.headers = {
76
+ "apikey": anon_key,
77
+ "Authorization": f"Bearer {anon_key}",
78
+ }
79
+
80
+ @staticmethod
81
+ def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
82
+ parts = token.split(".")
83
+ if len(parts) != 3:
84
+ return None
85
+
86
+ payload_part = parts[1]
87
+ padding = "=" * (-len(payload_part) % 4)
88
+ try:
89
+ decoded = base64.urlsafe_b64decode(payload_part + padding)
90
+ payload = json.loads(decoded.decode("utf-8"))
91
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
92
+ return None
93
+
94
+ if isinstance(payload, dict):
95
+ return payload
96
+ return None
97
+
98
+ @staticmethod
99
+ def _extract_supabase_rest_url(text: str) -> Optional[str]:
100
+ if not text:
101
+ return None
102
+
103
+ direct = SUPABASE_REST_RE.search(text)
104
+ if direct:
105
+ return direct.group(0)
106
+
107
+ unescaped = text.replace("\\/", "/")
108
+ escaped = SUPABASE_REST_RE.search(unescaped)
109
+ if escaped:
110
+ return escaped.group(0)
111
+
112
+ return None
113
+
114
+ @staticmethod
115
+ def _extract_supabase_anon_key(text: str) -> Optional[str]:
116
+ if not text:
117
+ return None
118
+
119
+ candidates = JWT_RE.findall(text)
120
+ for candidate in candidates:
121
+ payload = DotmdAPI._decode_jwt_payload(candidate)
122
+ if not payload:
123
+ continue
124
+ if str(payload.get("iss", "")).lower() == "supabase" and str(
125
+ payload.get("role", "")
126
+ ).lower() == "anon":
127
+ return candidate
128
+
129
+ return None
130
+
131
+ def _registry_payloads(self) -> List[str]:
132
+ try:
133
+ homepage = requests.get(self.registry_site_url, timeout=min(self.timeout, 10))
134
+ except requests.RequestException:
135
+ return []
136
+
137
+ candidates: List[str] = [homepage.text or ""]
138
+ for script_path in SCRIPT_SRC_RE.findall(homepage.text or "")[:8]:
139
+ script_url = urljoin(f"{self.registry_site_url}/", script_path)
140
+ try:
141
+ script_response = requests.get(script_url, timeout=min(self.timeout, 10))
142
+ except requests.RequestException:
143
+ continue
144
+ candidates.append(script_response.text or "")
145
+
146
+ return candidates
147
+
148
+ def _discover_base_url_from_registry(self) -> Optional[str]:
149
+ for payload in self._registry_payloads():
150
+ discovered = self._extract_supabase_rest_url(payload)
151
+ if discovered:
152
+ return discovered.rstrip("/")
153
+
154
+ return None
155
+
156
+ def _discover_anon_key_from_registry(self) -> Optional[str]:
157
+ for payload in self._registry_payloads():
158
+ discovered = self._extract_supabase_anon_key(payload)
159
+ if discovered:
160
+ return discovered
161
+
162
+ return None
163
+
164
+ def _try_discover_and_swap_base_url(self) -> bool:
165
+ if self._base_url_is_explicit or self._discovery_attempted:
166
+ return False
167
+
168
+ self._discovery_attempted = True
169
+ discovered = self._discover_base_url_from_registry()
170
+ if not discovered or discovered == self.base_url:
171
+ return False
172
+
173
+ self.base_url = discovered
174
+ return True
175
+
176
+ def _try_discover_and_set_anon_key(self) -> bool:
177
+ if self._anon_key_is_explicit or self._key_discovery_attempted:
178
+ return False
179
+
180
+ self._key_discovery_attempted = True
181
+ discovered = self._discover_anon_key_from_registry()
182
+ if not discovered:
183
+ return False
184
+
185
+ self._set_auth_headers(discovered)
186
+ return True
187
+
188
+ @staticmethod
189
+ def _looks_like_api_key_error(status_code: int, body: str) -> bool:
190
+ if status_code != 401:
191
+ return False
192
+
193
+ normalized = (body or "").lower()
194
+ return "api key" in normalized or "apikey" in normalized
195
+
196
+ def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
197
+ path = path.lstrip("/")
198
+ url = f"{self.base_url}/{path}"
199
+ try:
200
+ response = requests.get(url, headers=self.headers, params=params, timeout=self.timeout)
201
+ except requests.RequestException as exc:
202
+ if self._try_discover_and_swap_base_url():
203
+ retry_url = f"{self.base_url}/{path}"
204
+ try:
205
+ response = requests.get(
206
+ retry_url, headers=self.headers, params=params, timeout=self.timeout
207
+ )
208
+ except requests.RequestException as retry_exc:
209
+ raise DotmdAPIError(
210
+ f"Network error while calling {retry_url}: {retry_exc}"
211
+ ) from retry_exc
212
+ url = retry_url
213
+ else:
214
+ raise DotmdAPIError(f"Network error while calling {url}: {exc}") from exc
215
+
216
+ if response.status_code >= 400:
217
+ if self._looks_like_api_key_error(response.status_code, response.text):
218
+ if self._try_discover_and_set_anon_key():
219
+ retry_url = f"{self.base_url}/{path}"
220
+ try:
221
+ response = requests.get(
222
+ retry_url, headers=self.headers, params=params, timeout=self.timeout
223
+ )
224
+ except requests.RequestException as retry_exc:
225
+ raise DotmdAPIError(
226
+ f"Network error while calling {retry_url}: {retry_exc}"
227
+ ) from retry_exc
228
+ url = retry_url
229
+
230
+ if self._try_discover_and_swap_base_url():
231
+ retry_url = f"{self.base_url}/{path}"
232
+ try:
233
+ retry_response = requests.get(
234
+ retry_url, headers=self.headers, params=params, timeout=self.timeout
235
+ )
236
+ except requests.RequestException as retry_exc:
237
+ raise DotmdAPIError(
238
+ f"Network error while calling {retry_url}: {retry_exc}"
239
+ ) from retry_exc
240
+
241
+ response = retry_response
242
+ url = retry_url
243
+
244
+ if response.status_code >= 400 and self._looks_like_api_key_error(
245
+ response.status_code, response.text
246
+ ):
247
+ if self._try_discover_and_set_anon_key():
248
+ retry_url = f"{self.base_url}/{path}"
249
+ try:
250
+ response = requests.get(
251
+ retry_url, headers=self.headers, params=params, timeout=self.timeout
252
+ )
253
+ except requests.RequestException as retry_exc:
254
+ raise DotmdAPIError(
255
+ f"Network error while calling {retry_url}: {retry_exc}"
256
+ ) from retry_exc
257
+ url = retry_url
258
+
259
+ if response.status_code >= 400:
260
+ raise DotmdAPIError(
261
+ f"API request failed ({response.status_code}) for {path}: {response.text.strip()}"
262
+ )
263
+
264
+ try:
265
+ payload = response.json()
266
+ except ValueError as exc:
267
+ raise DotmdAPIError(f"Invalid JSON response for {path}") from exc
268
+
269
+ if not isinstance(payload, list):
270
+ raise DotmdAPIError(f"Unexpected API response for {path}: {payload!r}")
271
+
272
+ return payload
273
+
274
+ @staticmethod
275
+ def _normalize_keywords(keywords: Sequence[str] | str) -> List[str]:
276
+ if isinstance(keywords, str):
277
+ parts = keywords.split()
278
+ else:
279
+ parts = list(keywords)
280
+
281
+ return [part.strip() for part in parts if part and part.strip()]
282
+
283
+ def resolve_username(self, username: str) -> str:
284
+ rows = self._get(
285
+ "profiles",
286
+ params={"select": "user_id", "username": f"eq.{username}"},
287
+ )
288
+ if not rows:
289
+ raise DotmdAPIError(f"Username not found: {username}")
290
+
291
+ user_id = rows[0].get("user_id")
292
+ if not user_id:
293
+ raise DotmdAPIError(f"No user_id returned for username: {username}")
294
+
295
+ return str(user_id)
296
+
297
+ @staticmethod
298
+ def _title_candidates(title: str) -> List[str]:
299
+ raw = title.strip()
300
+ if not raw:
301
+ return []
302
+
303
+ candidates: List[str] = [raw]
304
+ lower = raw.lower()
305
+ if lower.endswith(".md") or lower.endswith(".txt"):
306
+ stem = raw.rsplit(".", 1)[0]
307
+ if stem:
308
+ candidates.append(stem)
309
+ else:
310
+ candidates.append(f"{raw}.md")
311
+ candidates.append(f"{raw}.txt")
312
+
313
+ deduped: List[str] = []
314
+ seen: set[str] = set()
315
+ for item in candidates:
316
+ key = item.lower()
317
+ if key in seen:
318
+ continue
319
+ seen.add(key)
320
+ deduped.append(item)
321
+ return deduped
322
+
323
+ def get_rule(self, user_id: str, title: str) -> RuleRecord:
324
+ title_candidates = self._title_candidates(title)
325
+ rows: List[Dict[str, Any]] = []
326
+ for candidate in title_candidates:
327
+ rows = self._get(
328
+ "rules",
329
+ params={
330
+ "select": "content,format_type,title",
331
+ "user_id": f"eq.{user_id}",
332
+ "title": f"ilike.{candidate}",
333
+ "limit": 1,
334
+ },
335
+ )
336
+ if rows:
337
+ break
338
+
339
+ if not rows:
340
+ search_rows = self._get(
341
+ "rules",
342
+ params={
343
+ "select": "title",
344
+ "user_id": f"eq.{user_id}",
345
+ "title": f"ilike.%{title}%",
346
+ "limit": 5,
347
+ "order": "title.asc",
348
+ },
349
+ )
350
+ if search_rows:
351
+ examples = ", ".join(str(row.get("title", "")).strip() for row in search_rows[:5])
352
+ raise DotmdAPIError(
353
+ f"Rule not found for title: {title}. Close matches: {examples}"
354
+ )
355
+ raise DotmdAPIError(f"Rule not found for title: {title}")
356
+
357
+ row = rows[0]
358
+ content = row.get("content")
359
+ if not isinstance(content, str) or not content.strip():
360
+ raise DotmdAPIError("Rule content was empty")
361
+
362
+ format_type = row.get("format_type") or "agents.md"
363
+ return RuleRecord(content=content, format_type=str(format_type))
364
+
365
+ def search_rules(self, keywords: Sequence[str] | str, limit: int = 20) -> List[Dict[str, Any]]:
366
+ keyword_parts = self._normalize_keywords(keywords)
367
+ if not keyword_parts:
368
+ raise DotmdAPIError("At least one keyword is required for search")
369
+
370
+ pattern = f"%{'%'.join(keyword_parts)}%"
371
+ rows = self._get(
372
+ "rules",
373
+ params={
374
+ "select": "title,format_type,user_id",
375
+ "title": f"ilike.{pattern}",
376
+ "limit": max(1, min(limit, 100)),
377
+ "order": "title.asc",
378
+ },
379
+ )
380
+
381
+ user_ids = sorted(
382
+ {
383
+ str(row.get("user_id"))
384
+ for row in rows
385
+ if isinstance(row.get("user_id"), str) and row.get("user_id")
386
+ }
387
+ )
388
+ if not user_ids:
389
+ return rows
390
+
391
+ profiles = self._get(
392
+ "profiles",
393
+ params={
394
+ "select": "user_id,username",
395
+ "user_id": f"in.({','.join(user_ids)})",
396
+ },
397
+ )
398
+ user_map = {
399
+ str(row.get("user_id")): str(row.get("username"))
400
+ for row in profiles
401
+ if isinstance(row.get("user_id"), str) and isinstance(row.get("username"), str)
402
+ }
403
+
404
+ hydrated_rows: List[Dict[str, Any]] = []
405
+ for row in rows:
406
+ hydrated = dict(row)
407
+ username = user_map.get(str(row.get("user_id")))
408
+ if username:
409
+ hydrated["username"] = username
410
+ hydrated_rows.append(hydrated)
411
+
412
+ return hydrated_rows
413
+
414
+ def list_rules(self, username: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
415
+ if username:
416
+ user_id = self.resolve_username(username)
417
+ return self._get(
418
+ "rules",
419
+ params={
420
+ "select": "title,format_type",
421
+ "user_id": f"eq.{user_id}",
422
+ "limit": max(1, min(limit, 200)),
423
+ "order": "title.asc",
424
+ },
425
+ )
426
+
427
+ rows = self._get(
428
+ "rules",
429
+ params={
430
+ "select": "title,format_type,user_id",
431
+ "limit": max(1, min(limit, 200)),
432
+ "order": "title.asc",
433
+ },
434
+ )
435
+
436
+ user_ids = sorted(
437
+ {
438
+ str(row.get("user_id"))
439
+ for row in rows
440
+ if isinstance(row.get("user_id"), str) and row.get("user_id")
441
+ }
442
+ )
443
+ if not user_ids:
444
+ return rows
445
+
446
+ profiles = self._get(
447
+ "profiles",
448
+ params={
449
+ "select": "user_id,username",
450
+ "user_id": f"in.({','.join(user_ids)})",
451
+ },
452
+ )
453
+ user_map = {
454
+ str(row.get("user_id")): str(row.get("username"))
455
+ for row in profiles
456
+ if isinstance(row.get("user_id"), str) and isinstance(row.get("username"), str)
457
+ }
458
+
459
+ hydrated_rows: List[Dict[str, Any]] = []
460
+ for row in rows:
461
+ hydrated = dict(row)
462
+ username_value = user_map.get(str(row.get("user_id")))
463
+ if username_value:
464
+ hydrated["username"] = username_value
465
+ hydrated_rows.append(hydrated)
466
+
467
+ return hydrated_rows
dotmd/art.py ADDED
@@ -0,0 +1,147 @@
1
+ """ASCII art assets for dotmd CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from typing import List
7
+
8
+ import pyfiglet
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Hand-crafted mascot — the dotmd blob doctor
16
+ # Wider, more detailed, better proportioned blob character.
17
+ # ---------------------------------------------------------------------------
18
+ _MASCOT: List[str] = [
19
+ " .~~~~~~~~~~~~~~~~~~~~~~.",
20
+ " .' '.",
21
+ " / ( O ) ( O ) \\",
22
+ " | |",
23
+ " | _______________ |",
24
+ " | / \\ |",
25
+ " | | ~ ~ ~ ~ ~ ~ ~ | |",
26
+ " | \\_______________/ |",
27
+ " \\ /",
28
+ " '. .'",
29
+ " .----'------------------------'----.",
30
+ " / o \\",
31
+ "| (o) .----------------------. |",
32
+ "| | | ## claude.md | |",
33
+ "| | ## .cursorrules | |",
34
+ "| | ## windsurf.md | |",
35
+ "| '----------------------' |",
36
+ " \\ /",
37
+ " '-----------. .----------'",
38
+ " | |",
39
+ " _|_ _|_",
40
+ " / \\ / \\",
41
+ ]
42
+
43
+
44
+ def _mascot_text() -> Text:
45
+ """Build a rich Text object from the mascot lines with a colour gradient."""
46
+ t = Text(no_wrap=True)
47
+ total = len(_MASCOT)
48
+ colours = [
49
+ "bold bright_cyan", # top ~30%
50
+ "bold cyan", # mid ~40%
51
+ "bold bright_blue", # lower ~20%
52
+ "bold blue", # feet ~10%
53
+ ]
54
+ for i, line in enumerate(_MASCOT):
55
+ ratio = i / max(total - 1, 1)
56
+ if ratio < 0.30:
57
+ style = colours[0]
58
+ elif ratio < 0.65:
59
+ style = colours[1]
60
+ elif ratio < 0.85:
61
+ style = colours[2]
62
+ else:
63
+ style = colours[3]
64
+ suffix = "\n" if i < total - 1 else ""
65
+ t.append(line + suffix, style=style)
66
+ return t
67
+
68
+
69
+ def build_banner() -> str:
70
+ """Return a rich-rendered dotmd welcome banner for CLI splash screens."""
71
+ buf = io.StringIO()
72
+ c = Console(file=buf, highlight=False, force_terminal=True, width=110)
73
+
74
+ # ── Giant wordmark via pyfiglet ───────────────────────────────────────────
75
+ raw = pyfiglet.figlet_format("dotmd", font="speed")
76
+ wm_lines = raw.splitlines()
77
+ while wm_lines and not wm_lines[-1].strip():
78
+ wm_lines.pop()
79
+
80
+ wordmark = Text(justify="left")
81
+ wm_colours = ["bold bright_cyan", "bold cyan", "bold bright_blue", "bold blue", "bold bright_cyan"]
82
+ for i, line in enumerate(wm_lines):
83
+ wordmark.append(line + "\n", style=wm_colours[i % len(wm_colours)])
84
+
85
+ # ── Description text ──────────────────────────────────────────────────────
86
+ desc = Text()
87
+ desc.append("Like Docker Hub, but for ", style="white")
88
+ desc.append(".md", style="bold bright_cyan")
89
+ desc.append(" files.\n\n", style="white")
90
+
91
+ desc.append("Fetch and install the instruction files\n", style="dim white")
92
+ desc.append("that power your AI coding assistants.\n\n", style="dim white")
93
+
94
+ tools = ["Cursor", "Windsurf", "Claude", "Copilot", "Cline", "Aider"]
95
+ desc.append("Works with ", style="dim white")
96
+ for j, tool in enumerate(tools):
97
+ desc.append(tool, style="bold bright_blue")
98
+ if j < len(tools) - 1:
99
+ desc.append(" ", style="dim white")
100
+ desc.append("\nand more.\n\n", style="dim white")
101
+
102
+ desc.append("Quick start\n", style="bold white")
103
+ desc.append("─" * 28 + "\n", style="dim blue")
104
+ for cmd, note in [
105
+ ("dotmd list", "browse the registry"),
106
+ ("dotmd search <query>", "find rules"),
107
+ ("dotmd get <user>/<rule>", "install a rule"),
108
+ ("dotmd info <user>/<rule>", "inspect a rule"),
109
+ ]:
110
+ desc.append(" $ ", style="dim white")
111
+ desc.append(f"{cmd:<24}", style="bold bright_cyan")
112
+ desc.append(f" {note}\n", style="dim white")
113
+
114
+ desc.append("\n")
115
+ desc.append(" ● ", style="bold bright_blue")
116
+ desc.append("mydotmd.io", style="bold white")
117
+ desc.append(" · ", style="dim white")
118
+ desc.append("github.com/dotmd-cli/dotmd-cli", style="dim white")
119
+
120
+ # ── Tagline under wordmark ────────────────────────────────────────────────
121
+ tagline = Text()
122
+ tagline.append(" The open registry for AI coding assistant rules ", style="dim white")
123
+
124
+ # ── Right column: wordmark + tagline + description ────────────────────────
125
+ right = Text()
126
+ right.append_text(wordmark)
127
+ right.append_text(tagline)
128
+ right.append("\n\n")
129
+ right.append_text(desc)
130
+
131
+ # ── Two-column grid: mascot left, content right ───────────────────────────
132
+ grid = Table.grid(expand=True, padding=(0, 1))
133
+ grid.add_column(width=42) # mascot — fixed width
134
+ grid.add_column() # content — fills remaining space
135
+ grid.add_row(_mascot_text(), right)
136
+
137
+ c.print(
138
+ Panel(
139
+ grid,
140
+ border_style="bright_blue",
141
+ padding=(1, 2),
142
+ )
143
+ )
144
+
145
+ return buf.getvalue()
146
+
147
+ # Made with Bob