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 +4 -0
- dotmd/api.py +467 -0
- dotmd/art.py +147 -0
- dotmd/cli.py +545 -0
- dotmd/formats.py +39 -0
- dotmd/mascot.png +0 -0
- dotmd-0.1.0.dist-info/METADATA +336 -0
- dotmd-0.1.0.dist-info/RECORD +12 -0
- dotmd-0.1.0.dist-info/WHEEL +5 -0
- dotmd-0.1.0.dist-info/entry_points.txt +2 -0
- dotmd-0.1.0.dist-info/licenses/LICENSE +21 -0
- dotmd-0.1.0.dist-info/top_level.txt +1 -0
dotmd/__init__.py
ADDED
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
|