devdash-mac 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.
devdash/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """DevDash - Open-source macOS menubar developer utilities."""
2
+
3
+ __version__ = "0.1.0"
4
+ __app_name__ = "DevDash"
devdash/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m devdash`."""
2
+
3
+ from devdash.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
devdash/app.py ADDED
@@ -0,0 +1,145 @@
1
+ """Main DevDash application - macOS menubar app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import rumps
6
+
7
+ from devdash import __app_name__, __version__, clipboard
8
+ from devdash.config import load_config
9
+ from devdash.plugin_loader import discover_tools
10
+ from devdash.tools.base import DevTool
11
+ from devdash.ui.notifications import notify
12
+ from devdash.ui.windows import show_tool_dialog
13
+
14
+ # Content type display names for notifications
15
+ _TYPE_NAMES: dict[clipboard.ContentType, str] = {
16
+ clipboard.ContentType.JSON: "JSON",
17
+ clipboard.ContentType.JWT: "JWT token",
18
+ clipboard.ContentType.UUID: "UUID",
19
+ clipboard.ContentType.BASE64: "Base64",
20
+ clipboard.ContentType.UNIX_TIMESTAMP: "Unix timestamp",
21
+ clipboard.ContentType.URL: "URL",
22
+ clipboard.ContentType.URL_ENCODED: "URL-encoded text",
23
+ clipboard.ContentType.HEX_COLOR: "HEX color",
24
+ clipboard.ContentType.CRON: "Cron expression",
25
+ }
26
+
27
+
28
+ class DevDashApp(rumps.App):
29
+ """macOS menubar developer utilities app."""
30
+
31
+ def __init__(self) -> None:
32
+ super().__init__(name=__app_name__, title="\U0001f527", quit_button=None)
33
+ self._tools: list[DevTool] = discover_tools()
34
+ self._last_clipboard: str = ""
35
+ self._build_menu()
36
+ # Start clipboard watcher if enabled in config
37
+ config = load_config()
38
+ if config.get("clipboard_watcher", False):
39
+ self._start_clipboard_watcher()
40
+
41
+ def _build_menu(self) -> None:
42
+ """Build the menubar dropdown from discovered tools."""
43
+ menu_items: list[rumps.MenuItem | None] = []
44
+
45
+ # Auto-detect clipboard item
46
+ auto_detect = rumps.MenuItem("Clipboard: Auto-detect", callback=self._on_auto_detect)
47
+ menu_items.append(auto_detect)
48
+ menu_items.append(None) # separator
49
+
50
+ # Group tools by category
51
+ current_category = ""
52
+ for tool in self._tools:
53
+ if tool.category != current_category:
54
+ if current_category:
55
+ menu_items.append(None) # separator between categories
56
+ current_category = tool.category
57
+ item = rumps.MenuItem(tool.name, callback=self._make_tool_callback(tool))
58
+ menu_items.append(item)
59
+
60
+ menu_items.append(None) # separator
61
+
62
+ # About and Quit
63
+ about = rumps.MenuItem(f"About {__app_name__}", callback=self._on_about)
64
+ quit_item = rumps.MenuItem("Quit", callback=self._on_quit)
65
+ menu_items.append(about)
66
+ menu_items.append(quit_item)
67
+
68
+ self.menu = menu_items
69
+
70
+ def _make_tool_callback(self, tool: DevTool): # type: ignore[no-untyped-def]
71
+ """Create a callback closure for a specific tool."""
72
+
73
+ def callback(_: rumps.MenuItem) -> None:
74
+ show_tool_dialog(tool)
75
+
76
+ return callback
77
+
78
+ def _on_auto_detect(self, _: rumps.MenuItem) -> None:
79
+ """Read clipboard, detect content type, open matching tool."""
80
+ content = clipboard.read()
81
+ if not content.strip():
82
+ notify("DevDash", "Clipboard is empty")
83
+ return
84
+
85
+ detected = clipboard.detect_type(content)
86
+ # Find matching tool by keyword
87
+ keyword_map = {
88
+ clipboard.ContentType.JSON: "json",
89
+ clipboard.ContentType.JWT: "jwt",
90
+ clipboard.ContentType.UUID: "uuid",
91
+ clipboard.ContentType.BASE64: "base64",
92
+ clipboard.ContentType.UNIX_TIMESTAMP: "timestamp",
93
+ clipboard.ContentType.URL: "url",
94
+ clipboard.ContentType.URL_ENCODED: "url",
95
+ clipboard.ContentType.HEX_COLOR: "color",
96
+ clipboard.ContentType.CRON: "cron",
97
+ }
98
+ keyword = keyword_map.get(detected)
99
+ if keyword:
100
+ for tool in self._tools:
101
+ if tool.keyword == keyword:
102
+ show_tool_dialog(tool, input_text=content)
103
+ return
104
+ notify("DevDash", f"Detected: {detected.name}. No matching tool found.")
105
+
106
+ def _on_about(self, _: rumps.MenuItem) -> None:
107
+ """Show about dialog."""
108
+ rumps.alert(
109
+ title=f"About {__app_name__}",
110
+ message=f"{__app_name__} v{__version__}\n\n"
111
+ "Open-source macOS menubar developer utilities.\n"
112
+ "https://github.com/devdash/devdash",
113
+ )
114
+
115
+ def _on_quit(self, _: rumps.MenuItem) -> None:
116
+ """Quit the app."""
117
+ rumps.quit_application()
118
+
119
+ def _start_clipboard_watcher(self) -> None:
120
+ """Start background clipboard polling (opt-in, privacy conscious)."""
121
+
122
+ @rumps.timer(2)
123
+ def _watch_clipboard(timer: rumps.Timer) -> None:
124
+ try:
125
+ content = clipboard.read()
126
+ if not content or content == self._last_clipboard:
127
+ return
128
+ self._last_clipboard = content
129
+ detected = clipboard.detect_type(content)
130
+ if detected != clipboard.ContentType.PLAIN_TEXT:
131
+ type_name = _TYPE_NAMES.get(detected, detected.name)
132
+ notify(
133
+ "DevDash",
134
+ f"Detected {type_name} in your clipboard. Click to process.",
135
+ )
136
+ except Exception:
137
+ pass # Never crash the watcher
138
+
139
+ _watch_clipboard.start() # type: ignore[attr-defined]
140
+
141
+
142
+ def main() -> None:
143
+ """Entry point for the application."""
144
+ app = DevDashApp()
145
+ app.run()
devdash/clipboard.py ADDED
@@ -0,0 +1,98 @@
1
+ """Clipboard utilities with content type auto-detection."""
2
+
3
+ import json
4
+ import re
5
+ from enum import Enum, auto
6
+
7
+ import pyperclip
8
+
9
+
10
+ class ContentType(Enum):
11
+ """Detected content types for clipboard auto-detection."""
12
+
13
+ JWT = auto()
14
+ JSON = auto()
15
+ UUID = auto()
16
+ CRON = auto()
17
+ URL_ENCODED = auto()
18
+ BASE64 = auto()
19
+ UNIX_TIMESTAMP = auto()
20
+ HEX_COLOR = auto()
21
+ URL = auto()
22
+ PLAIN_TEXT = auto()
23
+
24
+
25
+ _UUID_RE = re.compile(
26
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
27
+ )
28
+ _CRON_FIELD = r"[\d*,/\-LW#?]+"
29
+ _CRON_RE = re.compile(rf"^{_CRON_FIELD}(\s+{_CRON_FIELD}){{4,5}}$")
30
+ _URL_ENCODED_RE = re.compile(r"%[0-9A-Fa-f]{2}")
31
+ _BASE64_RE = re.compile(r"^[A-Za-z0-9+/\n\r]+=*$")
32
+ _HEX_COLOR_RE = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")
33
+
34
+
35
+ def read() -> str:
36
+ """Get current clipboard content as string."""
37
+ return pyperclip.paste()
38
+
39
+
40
+ def write(text: str) -> None:
41
+ """Write string to clipboard."""
42
+ pyperclip.copy(text)
43
+
44
+
45
+ def detect_type(text: str) -> ContentType:
46
+ """Auto-detect content type with priority-based detection."""
47
+ if not text or not text.strip():
48
+ return ContentType.PLAIN_TEXT
49
+
50
+ stripped = text.strip()
51
+
52
+ # 1. JWT: starts with "eyJ" and has exactly 2 dots
53
+ if stripped.startswith("eyJ") and stripped.count(".") == 2:
54
+ return ContentType.JWT
55
+
56
+ # 2. JSON: starts with { or [ and is valid JSON
57
+ if stripped.startswith(("{", "[")):
58
+ try:
59
+ json.loads(stripped)
60
+ return ContentType.JSON
61
+ except (json.JSONDecodeError, ValueError):
62
+ pass
63
+
64
+ # 3. UUID: matches 8-4-4-4-12 hex pattern
65
+ if _UUID_RE.match(stripped):
66
+ return ContentType.UUID
67
+
68
+ # 4. Cron: 5-6 space-separated fields
69
+ if _CRON_RE.match(stripped):
70
+ return ContentType.CRON
71
+
72
+ # 5. URL-encoded: contains %XX patterns
73
+ if _URL_ENCODED_RE.search(stripped) and " " not in stripped:
74
+ return ContentType.URL_ENCODED
75
+
76
+ # 6. Base64: matches charset and length multiple of 4
77
+ if len(stripped) >= 4 and len(stripped) % 4 == 0 and _BASE64_RE.match(stripped):
78
+ return ContentType.BASE64
79
+
80
+ # 7. Unix timestamp: pure digits, 10 or 13 chars, reasonable range
81
+ if stripped.isdigit() and len(stripped) in (10, 13):
82
+ ts = int(stripped)
83
+ if len(stripped) == 13:
84
+ ts = ts // 1000
85
+ # Reasonable range: 1970 to 2100
86
+ if 0 <= ts <= 4102444800:
87
+ return ContentType.UNIX_TIMESTAMP
88
+
89
+ # 8. HEX color: starts with # + 3 or 6 hex chars
90
+ if _HEX_COLOR_RE.match(stripped):
91
+ return ContentType.HEX_COLOR
92
+
93
+ # 9. URL: starts with http:// or https://
94
+ if stripped.startswith(("http://", "https://")):
95
+ return ContentType.URL
96
+
97
+ # 10. Fallback
98
+ return ContentType.PLAIN_TEXT
devdash/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """User preferences management with YAML config."""
2
+
3
+ import os
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ CONFIG_DIR = Path.home() / ".config" / "devdash"
11
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
12
+
13
+ _lock = threading.Lock()
14
+
15
+ DEFAULT_CONFIG: dict[str, Any] = {
16
+ "default_hash_algorithm": "sha256",
17
+ "timestamp_format": "%Y-%m-%d %H:%M:%S",
18
+ "password_length": 16,
19
+ "uuid_version": "v4",
20
+ "auto_clipboard_detection": True,
21
+ "clipboard_watcher": False,
22
+ }
23
+
24
+
25
+ def _ensure_config_dir() -> None:
26
+ """Create config directory if it doesn't exist."""
27
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+
30
+ def load_config() -> dict[str, Any]:
31
+ """Load config from file, creating defaults on first run."""
32
+ with _lock:
33
+ if not CONFIG_FILE.exists():
34
+ save_config(DEFAULT_CONFIG)
35
+ return dict(DEFAULT_CONFIG)
36
+ with open(CONFIG_FILE) as f:
37
+ data = yaml.safe_load(f)
38
+ if not isinstance(data, dict):
39
+ return dict(DEFAULT_CONFIG)
40
+ # Merge with defaults for any missing keys
41
+ merged = dict(DEFAULT_CONFIG)
42
+ merged.update(data)
43
+ return merged
44
+
45
+
46
+ def save_config(config: dict[str, Any]) -> None:
47
+ """Save config to file with restricted permissions."""
48
+ _ensure_config_dir()
49
+ with open(CONFIG_FILE, "w") as f:
50
+ yaml.dump(config, f, default_flow_style=False)
51
+ os.chmod(CONFIG_FILE, 0o600)
@@ -0,0 +1,43 @@
1
+ """Dynamic tool discovery and registration."""
2
+
3
+ import importlib
4
+ import logging
5
+ import pkgutil
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from devdash.tools.base import DevTool
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def discover_tools() -> list["DevTool"]:
15
+ """Scan the tools/ directory and return registered tool instances."""
16
+ tools: list[DevTool] = []
17
+ package_name = "devdash.tools"
18
+
19
+ try:
20
+ package = importlib.import_module(package_name)
21
+ except ImportError:
22
+ logger.error("Could not import tools package")
23
+ return tools
24
+
25
+ package_path = getattr(package, "__path__", None)
26
+ if package_path is None:
27
+ return tools
28
+
29
+ for _importer, module_name, _ispkg in pkgutil.iter_modules(package_path):
30
+ if module_name.startswith("_") or module_name == "base":
31
+ continue
32
+ try:
33
+ module = importlib.import_module(f"{package_name}.{module_name}")
34
+ register_fn = getattr(module, "register", None)
35
+ if register_fn and callable(register_fn):
36
+ tool = register_fn()
37
+ tools.append(tool)
38
+ except Exception:
39
+ logger.exception("Failed to load tool module: %s", module_name)
40
+
41
+ # Sort by category then name
42
+ tools.sort(key=lambda t: (t.category, t.name))
43
+ return tools
devdash/storage.py ADDED
@@ -0,0 +1,244 @@
1
+ """Optional SQLite storage for tool history and favorites.
2
+
3
+ The database is created lazily on first access and stored at
4
+ ~/.config/devdash/history.db with restricted (0o600) permissions.
5
+ All public methods are thread-safe — each call opens its own
6
+ connection so there is no shared state between threads.
7
+ """
8
+
9
+ import os
10
+ import sqlite3
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ from devdash.config import CONFIG_DIR
15
+
16
+ DB_PATH: Path = CONFIG_DIR / "history.db"
17
+ _PREVIEW_MAX = 200
18
+ _HISTORY_KEEP = 100
19
+
20
+ _init_lock = threading.Lock()
21
+ _initialized = False
22
+
23
+
24
+ def _truncate(text: str | None, limit: int = _PREVIEW_MAX) -> str | None:
25
+ """Truncate *text* to *limit* characters, appending an ellipsis if cut."""
26
+ if text is None:
27
+ return None
28
+ if len(text) <= limit:
29
+ return text
30
+ return text[: limit - 1] + "\u2026"
31
+
32
+
33
+ def _ensure_db() -> None:
34
+ """Create the database file and tables if they do not yet exist.
35
+
36
+ Uses a module-level lock so the schema is applied at most once per
37
+ process, regardless of how many threads call in concurrently.
38
+ """
39
+ global _initialized
40
+ if _initialized:
41
+ return
42
+
43
+ with _init_lock:
44
+ # Double-check after acquiring the lock.
45
+ if _initialized:
46
+ return
47
+
48
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
49
+ con = sqlite3.connect(str(DB_PATH))
50
+ try:
51
+ con.executescript(
52
+ """\
53
+ CREATE TABLE IF NOT EXISTS tool_history (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ tool_name TEXT NOT NULL,
56
+ input_preview TEXT,
57
+ output_preview TEXT,
58
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP
59
+ );
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_tool_history_tool
62
+ ON tool_history (tool_name, timestamp DESC);
63
+
64
+ CREATE TABLE IF NOT EXISTS favorites (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ tool_name TEXT NOT NULL,
67
+ label TEXT NOT NULL,
68
+ content TEXT NOT NULL
69
+ );
70
+
71
+ CREATE INDEX IF NOT EXISTS idx_favorites_tool
72
+ ON favorites (tool_name);
73
+ """
74
+ )
75
+ finally:
76
+ con.close()
77
+
78
+ os.chmod(DB_PATH, 0o600)
79
+ _initialized = True
80
+
81
+
82
+ def _connect() -> sqlite3.Connection:
83
+ """Return a new connection to the history database.
84
+
85
+ Ensures the schema exists before handing back the connection.
86
+ """
87
+ _ensure_db()
88
+ con = sqlite3.connect(str(DB_PATH))
89
+ con.row_factory = sqlite3.Row
90
+ return con
91
+
92
+
93
+ class HistoryStore:
94
+ """High-level interface to the DevDash history / favorites database.
95
+
96
+ Supports use as a context manager::
97
+
98
+ with HistoryStore() as store:
99
+ store.add_history("base64", "hello", "aGVsbG8=")
100
+
101
+ When used without a context manager, call :meth:`close` explicitly
102
+ or simply let the garbage collector handle it — each public method
103
+ opens (and closes) its own connection, so the instance itself holds
104
+ no long-lived resources.
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ self._closed = False
109
+
110
+ # -- context manager ----------------------------------------------------
111
+
112
+ def __enter__(self) -> "HistoryStore":
113
+ return self
114
+
115
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ANN001
116
+ self.close()
117
+ return None
118
+
119
+ def close(self) -> None:
120
+ """Mark the store as closed. Subsequent calls will raise."""
121
+ self._closed = True
122
+
123
+ def _check_open(self) -> None:
124
+ if self._closed:
125
+ raise RuntimeError("HistoryStore is closed")
126
+
127
+ # -- tool_history -------------------------------------------------------
128
+
129
+ def add_history(
130
+ self,
131
+ tool_name: str,
132
+ input_text: str | None = None,
133
+ output_text: str | None = None,
134
+ ) -> None:
135
+ """Record a tool invocation, then auto-cleanup old rows."""
136
+ self._check_open()
137
+ in_prev = _truncate(input_text)
138
+ out_prev = _truncate(output_text)
139
+ con = _connect()
140
+ try:
141
+ con.execute(
142
+ "INSERT INTO tool_history (tool_name, input_preview, output_preview) "
143
+ "VALUES (?, ?, ?)",
144
+ (tool_name, in_prev, out_prev),
145
+ )
146
+ con.commit()
147
+ finally:
148
+ con.close()
149
+ self.cleanup(tool_name)
150
+
151
+ def get_history(self, tool_name: str, limit: int = 20) -> list[dict]:
152
+ """Return the most recent *limit* history rows for *tool_name*."""
153
+ self._check_open()
154
+ con = _connect()
155
+ try:
156
+ rows = con.execute(
157
+ "SELECT id, tool_name, input_preview, output_preview, timestamp "
158
+ "FROM tool_history "
159
+ "WHERE tool_name = ? "
160
+ "ORDER BY timestamp DESC "
161
+ "LIMIT ?",
162
+ (tool_name, limit),
163
+ ).fetchall()
164
+ return [dict(r) for r in rows]
165
+ finally:
166
+ con.close()
167
+
168
+ # -- favorites ----------------------------------------------------------
169
+
170
+ def add_favorite(self, tool_name: str, label: str, content: str) -> int:
171
+ """Save a favorite and return its row id."""
172
+ self._check_open()
173
+ con = _connect()
174
+ try:
175
+ cur = con.execute(
176
+ "INSERT INTO favorites (tool_name, label, content) VALUES (?, ?, ?)",
177
+ (tool_name, label, content),
178
+ )
179
+ con.commit()
180
+ return cur.lastrowid # type: ignore[return-value]
181
+ finally:
182
+ con.close()
183
+
184
+ def get_favorites(self, tool_name: str) -> list[dict]:
185
+ """Return all favorites for *tool_name*, ordered by id."""
186
+ self._check_open()
187
+ con = _connect()
188
+ try:
189
+ rows = con.execute(
190
+ "SELECT id, tool_name, label, content "
191
+ "FROM favorites "
192
+ "WHERE tool_name = ? "
193
+ "ORDER BY id",
194
+ (tool_name,),
195
+ ).fetchall()
196
+ return [dict(r) for r in rows]
197
+ finally:
198
+ con.close()
199
+
200
+ def remove_favorite(self, favorite_id: int) -> bool:
201
+ """Delete a favorite by id. Returns True if a row was deleted."""
202
+ self._check_open()
203
+ con = _connect()
204
+ try:
205
+ cur = con.execute("DELETE FROM favorites WHERE id = ?", (favorite_id,))
206
+ con.commit()
207
+ return cur.rowcount > 0
208
+ finally:
209
+ con.close()
210
+
211
+ # -- maintenance --------------------------------------------------------
212
+
213
+ def cleanup(self, tool_name: str | None = None) -> None:
214
+ """Trim tool_history to the newest *_HISTORY_KEEP* rows per tool.
215
+
216
+ If *tool_name* is given, only that tool is cleaned. Otherwise
217
+ every tool present in the table is processed.
218
+ """
219
+ self._check_open()
220
+ con = _connect()
221
+ try:
222
+ if tool_name is not None:
223
+ tools = [tool_name]
224
+ else:
225
+ tools = [
226
+ row["tool_name"]
227
+ for row in con.execute("SELECT DISTINCT tool_name FROM tool_history").fetchall()
228
+ ]
229
+
230
+ for name in tools:
231
+ con.execute(
232
+ "DELETE FROM tool_history "
233
+ "WHERE tool_name = ? "
234
+ "AND id NOT IN ("
235
+ " SELECT id FROM tool_history "
236
+ " WHERE tool_name = ? "
237
+ " ORDER BY timestamp DESC "
238
+ " LIMIT ?"
239
+ ")",
240
+ (name, name, _HISTORY_KEEP),
241
+ )
242
+ con.commit()
243
+ finally:
244
+ con.close()
@@ -0,0 +1 @@
1
+ """DevDash tools registry."""
devdash/tools/base.py ADDED
@@ -0,0 +1,38 @@
1
+ """Abstract base class for all DevDash tools."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class DevTool(ABC):
7
+ """Base class for all DevDash tools."""
8
+
9
+ @property
10
+ @abstractmethod
11
+ def name(self) -> str:
12
+ """Display name in the menu."""
13
+ ...
14
+
15
+ @property
16
+ @abstractmethod
17
+ def keyword(self) -> str:
18
+ """Short identifier for CLI and auto-detection."""
19
+ ...
20
+
21
+ @property
22
+ def category(self) -> str:
23
+ """Menu category grouping."""
24
+ return "General"
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ """One-line description shown in tooltip."""
29
+ return ""
30
+
31
+ @abstractmethod
32
+ def process(self, input_text: str, **kwargs: object) -> str:
33
+ """Process input and return output string."""
34
+ ...
35
+
36
+ def validate(self, input_text: str) -> str | None:
37
+ """Validate input. Return error message or None if valid."""
38
+ return None