mrstack 1.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.
Files changed (42) hide show
  1. mrstack/__init__.py +4 -0
  2. mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
  3. mrstack/_data/config/mcp-config.example.json +23 -0
  4. mrstack/_data/config/start-daemon.sh +53 -0
  5. mrstack/_data/config/start.sh +29 -0
  6. mrstack/_data/schedulers/manage-jobs.sh +87 -0
  7. mrstack/_data/schedulers/morning-briefing.sh +29 -0
  8. mrstack/_data/schedulers/register-jobs.py +182 -0
  9. mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
  10. mrstack/_data/schedulers/weekly-review.sh +26 -0
  11. mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
  12. mrstack/_data/templates/alert.md +56 -0
  13. mrstack/_data/templates/evening-summary.md +73 -0
  14. mrstack/_data/templates/jarvis-alert.md +64 -0
  15. mrstack/_data/templates/morning-briefing.md +53 -0
  16. mrstack/_data/templates/weekly-review.md +79 -0
  17. mrstack/_overlay/api/dashboard.py +223 -0
  18. mrstack/_overlay/api/templates/dashboard.html +328 -0
  19. mrstack/_overlay/bot/handlers/callback.py +1432 -0
  20. mrstack/_overlay/bot/handlers/command.py +1541 -0
  21. mrstack/_overlay/bot/utils/keyboards.py +125 -0
  22. mrstack/_overlay/bot/utils/ui_components.py +166 -0
  23. mrstack/_overlay/claude/session.py +341 -0
  24. mrstack/_overlay/jarvis/__init__.py +77 -0
  25. mrstack/_overlay/jarvis/coach.py +122 -0
  26. mrstack/_overlay/jarvis/context_engine.py +463 -0
  27. mrstack/_overlay/jarvis/pattern_learner.py +255 -0
  28. mrstack/_overlay/jarvis/persona.py +84 -0
  29. mrstack/_overlay/jarvis/platform.py +182 -0
  30. mrstack/_overlay/knowledge/__init__.py +6 -0
  31. mrstack/_overlay/knowledge/manager.py +464 -0
  32. mrstack/_overlay/knowledge/memory_index.py +180 -0
  33. mrstack/cli.py +330 -0
  34. mrstack/constants.py +77 -0
  35. mrstack/daemon.py +325 -0
  36. mrstack/patcher.py +169 -0
  37. mrstack/wizard.py +271 -0
  38. mrstack-1.1.0.dist-info/METADATA +640 -0
  39. mrstack-1.1.0.dist-info/RECORD +42 -0
  40. mrstack-1.1.0.dist-info/WHEEL +4 -0
  41. mrstack-1.1.0.dist-info/entry_points.txt +2 -0
  42. mrstack-1.1.0.dist-info/licenses/LICENSE +21 -0
mrstack/cli.py ADDED
@@ -0,0 +1,330 @@
1
+ """Mr.Stack CLI — command-line interface for the proactive AI butler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from . import __version__
18
+ from .constants import (
19
+ BOT_COMMAND,
20
+ DATA_DIR,
21
+ DB_FILE,
22
+ ENV_FILE,
23
+ IS_MACOS,
24
+ LOG_DIR,
25
+ MEMORY_DIR,
26
+ find_site_packages,
27
+ resolve_env_value,
28
+ )
29
+ from .daemon import (
30
+ daemon_install,
31
+ daemon_uninstall,
32
+ find_bot_pid,
33
+ is_running,
34
+ start_background,
35
+ start_foreground,
36
+ stop_bot,
37
+ )
38
+
39
+ console = Console()
40
+ app = typer.Typer(
41
+ name="mrstack",
42
+ help="Mr.Stack — Proactive AI Butler for Claude Code",
43
+ no_args_is_help=True,
44
+ pretty_exceptions_enable=False,
45
+ )
46
+
47
+
48
+ # ── version ────────────────────────────────────────────
49
+ def _version_callback(value: bool) -> None:
50
+ if value:
51
+ console.print(f"Mr.Stack [bold]v{__version__}[/]")
52
+ raise typer.Exit()
53
+
54
+
55
+ @app.callback()
56
+ def main(
57
+ version: bool = typer.Option(
58
+ False, "--version", "-v", callback=_version_callback, is_eager=True,
59
+ help="Show version and exit.",
60
+ ),
61
+ ) -> None:
62
+ """Mr.Stack — Proactive AI Butler for Claude Code."""
63
+
64
+
65
+ # ── init ───────────────────────────────────────────────
66
+ @app.command()
67
+ def init() -> None:
68
+ """Interactive setup wizard."""
69
+ from .wizard import run_wizard
70
+
71
+ run_wizard()
72
+
73
+
74
+ # ── start ──────────────────────────────────────────────
75
+ @app.command()
76
+ def start(
77
+ background: bool = typer.Option(False, "--bg", "-b", help="Run in background."),
78
+ ) -> None:
79
+ """Start the bot."""
80
+ if background:
81
+ start_background()
82
+ else:
83
+ start_foreground()
84
+
85
+
86
+ # ── stop ───────────────────────────────────────────────
87
+ @app.command()
88
+ def stop() -> None:
89
+ """Stop the bot."""
90
+ stop_bot()
91
+
92
+
93
+ # ── daemon ─────────────────────────────────────────────
94
+ @app.command()
95
+ def daemon(
96
+ uninstall: bool = typer.Option(False, "--uninstall", "-u", help="Remove daemon."),
97
+ ) -> None:
98
+ """Install/uninstall as a system daemon (launchd/systemd)."""
99
+ if uninstall:
100
+ daemon_uninstall()
101
+ else:
102
+ daemon_install()
103
+
104
+
105
+ # ── status ─────────────────────────────────────────────
106
+ @app.command()
107
+ def status() -> None:
108
+ """Show current status."""
109
+ pid = find_bot_pid()
110
+ running = pid is not None
111
+
112
+ # Collect info
113
+ version_str = f"v{__version__}"
114
+ status_str = f"[green]Running (PID {pid})[/]" if running else "[red]Stopped[/]"
115
+
116
+ uptime_str = "—"
117
+ if running and pid:
118
+ try:
119
+ import psutil
120
+
121
+ proc = psutil.Process(pid)
122
+ uptime = datetime.now() - datetime.fromtimestamp(proc.create_time())
123
+ days = uptime.days
124
+ hours, rem = divmod(uptime.seconds, 3600)
125
+ mins = rem // 60
126
+ parts = []
127
+ if days:
128
+ parts.append(f"{days}d")
129
+ if hours:
130
+ parts.append(f"{hours}h")
131
+ parts.append(f"{mins}m")
132
+ uptime_str = " ".join(parts)
133
+ except Exception:
134
+ uptime_str = "unknown"
135
+
136
+ memory_count = 0
137
+ if MEMORY_DIR.is_dir():
138
+ memory_count = sum(1 for _ in MEMORY_DIR.rglob("*.md"))
139
+
140
+ jarvis_str = "[dim]OFF[/]"
141
+ jarvis_enabled = resolve_env_value("ENABLE_JARVIS", "false").lower() == "true"
142
+ if jarvis_enabled:
143
+ if IS_MACOS:
144
+ jarvis_str = "[green]ON[/]"
145
+ else:
146
+ jarvis_str = "[yellow]ON (limited — not macOS)[/]"
147
+
148
+ last_msg = "—"
149
+ if DB_FILE.is_file():
150
+ try:
151
+ import sqlite3
152
+
153
+ with sqlite3.connect(str(DB_FILE)) as conn:
154
+ row = conn.execute(
155
+ "SELECT MAX(created_at) FROM messages"
156
+ ).fetchone()
157
+ if row and row[0]:
158
+ ts = datetime.fromisoformat(row[0])
159
+ delta = datetime.now() - ts
160
+ if delta < timedelta(minutes=1):
161
+ last_msg = "just now"
162
+ elif delta < timedelta(hours=1):
163
+ last_msg = f"{int(delta.total_seconds() // 60)}m ago"
164
+ elif delta < timedelta(days=1):
165
+ last_msg = f"{int(delta.total_seconds() // 3600)}h ago"
166
+ else:
167
+ last_msg = ts.strftime("%Y-%m-%d %H:%M")
168
+ except Exception:
169
+ pass
170
+
171
+ panel = Panel(
172
+ f" Status: {status_str}\n"
173
+ f" Uptime: {uptime_str}\n"
174
+ f" Memory: {memory_count} entries\n"
175
+ f" Last message: {last_msg}\n"
176
+ f" Jarvis: {jarvis_str}\n"
177
+ f" Data: {DATA_DIR}",
178
+ title=f"[bold]Mr.Stack {version_str}[/]",
179
+ border_style="cyan",
180
+ )
181
+ console.print(panel)
182
+
183
+
184
+ # ── logs ───────────────────────────────────────────────
185
+ @app.command()
186
+ def logs(
187
+ lines: int = typer.Option(50, "--lines", "-n", help="Number of lines."),
188
+ follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output."),
189
+ ) -> None:
190
+ """View recent logs."""
191
+ log_file = LOG_DIR / "daemon-stdout.log"
192
+ if not log_file.is_file():
193
+ log_file = LOG_DIR / "stdout.log"
194
+ if not log_file.is_file():
195
+ console.print("[yellow]No log files found.[/]")
196
+ raise typer.Exit(1)
197
+
198
+ cmd = ["tail"]
199
+ if follow:
200
+ cmd.append("-f")
201
+ cmd.extend(["-n", str(lines), str(log_file)])
202
+ try:
203
+ subprocess.run(cmd)
204
+ except KeyboardInterrupt:
205
+ pass
206
+ except FileNotFoundError:
207
+ console.print("[red]'tail' command not found.[/]")
208
+ # Fallback: read with Python
209
+ content = log_file.read_text()
210
+ for line in content.splitlines()[-lines:]:
211
+ console.print(line)
212
+
213
+
214
+ # ── config ─────────────────────────────────────────────
215
+ @app.command()
216
+ def config() -> None:
217
+ """Open configuration file in editor."""
218
+ if not ENV_FILE.is_file():
219
+ console.print("[red].env not found.[/] Run [bold]mrstack init[/] first.")
220
+ raise typer.Exit(1)
221
+
222
+ editor = os.environ.get("EDITOR", "vim")
223
+ subprocess.run([editor, str(ENV_FILE)])
224
+
225
+
226
+ # ── jarvis ─────────────────────────────────────────────
227
+ @app.command()
228
+ def jarvis(
229
+ state: str = typer.Argument(
230
+ ..., help="on / off", metavar="STATE",
231
+ ),
232
+ ) -> None:
233
+ """Toggle Jarvis mode."""
234
+ state = state.strip().lower()
235
+ if state not in ("on", "off"):
236
+ console.print("[red]Usage: mrstack jarvis on|off[/]")
237
+ raise typer.Exit(1)
238
+
239
+ if not ENV_FILE.is_file():
240
+ console.print("[red].env not found.[/]")
241
+ raise typer.Exit(1)
242
+
243
+ enable = state == "on"
244
+ if enable and not IS_MACOS:
245
+ console.print(
246
+ "[yellow]Jarvis mode has limited functionality on non-macOS platforms.[/]"
247
+ )
248
+
249
+ text = ENV_FILE.read_text()
250
+ new_val = "true" if enable else "false"
251
+
252
+ if "ENABLE_JARVIS=" in text:
253
+ import re
254
+
255
+ text = re.sub(r"ENABLE_JARVIS=\w+", f"ENABLE_JARVIS={new_val}", text)
256
+ else:
257
+ text += f"\nENABLE_JARVIS={new_val}\n"
258
+
259
+ ENV_FILE.write_text(text)
260
+ icon = "[green]ON[/]" if enable else "[red]OFF[/]"
261
+ console.print(f"Jarvis mode: {icon}")
262
+
263
+ if is_running():
264
+ console.print("[dim]Restart the bot for changes to take effect.[/]")
265
+
266
+
267
+ # ── patch ──────────────────────────────────────────────
268
+ @app.command()
269
+ def patch(
270
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files."),
271
+ ) -> None:
272
+ """Install/update Mr.Stack modules into claude-code-telegram."""
273
+ from .patcher import patch_install
274
+
275
+ patch_install(force=force)
276
+
277
+
278
+ # ── update ─────────────────────────────────────────────
279
+ @app.command()
280
+ def update() -> None:
281
+ """Update Mr.Stack to the latest version."""
282
+ console.print("Updating Mr.Stack...")
283
+ if shutil.which("uv"):
284
+ subprocess.run(["uv", "tool", "upgrade", "mrstack"], check=True)
285
+ elif shutil.which("pipx"):
286
+ subprocess.run(["pipx", "upgrade", "mrstack"], check=True)
287
+ else:
288
+ subprocess.run(["pip", "install", "--upgrade", "mrstack"], check=True)
289
+
290
+ # Re-patch after update
291
+ console.print("Re-applying patches...")
292
+ from .patcher import patch_install
293
+
294
+ patch_install(force=True)
295
+ console.print("[green]Update complete.[/]")
296
+
297
+
298
+ # ── version (explicit command) ─────────────────────────
299
+ @app.command(name="version")
300
+ def version_cmd() -> None:
301
+ """Show version information."""
302
+ table = Table(show_header=False, box=None, padding=(0, 2))
303
+ table.add_row("Mr.Stack", f"v{__version__}")
304
+
305
+ # claude-code-telegram version
306
+ try:
307
+ from importlib.metadata import version as pkg_version
308
+
309
+ cct_ver = pkg_version("claude-code-telegram")
310
+ table.add_row("claude-code-telegram", f"v{cct_ver}")
311
+ except Exception:
312
+ table.add_row("claude-code-telegram", "[dim]not installed[/]")
313
+
314
+ # Claude Code version
315
+ try:
316
+ result = subprocess.run(
317
+ ["claude", "--version"], capture_output=True, text=True, timeout=5
318
+ )
319
+ if result.returncode == 0:
320
+ table.add_row("Claude Code", result.stdout.strip())
321
+ except Exception:
322
+ table.add_row("Claude Code", "[dim]not found[/]")
323
+
324
+ # Platform
325
+ import platform
326
+
327
+ table.add_row("Platform", f"{platform.system()} {platform.machine()}")
328
+ table.add_row("Python", platform.python_version())
329
+
330
+ console.print(table)
mrstack/constants.py ADDED
@@ -0,0 +1,77 @@
1
+ """Shared constants and path helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # ── Paths ──────────────────────────────────────────────
10
+ HOME = Path.home()
11
+ DATA_DIR = HOME / "claude-telegram"
12
+ ENV_FILE = DATA_DIR / ".env"
13
+ DB_FILE = DATA_DIR / "data" / "bot.db"
14
+ LOG_DIR = DATA_DIR / "logs"
15
+ MEMORY_DIR = DATA_DIR / "memory"
16
+ TEMPLATES_DIR = DATA_DIR / "templates"
17
+
18
+ PLIST_LABEL = "com.mrstack.claude-telegram"
19
+ PLIST_DIR = HOME / "Library" / "LaunchAgents"
20
+ PLIST_FILE = PLIST_DIR / f"{PLIST_LABEL}.plist"
21
+
22
+ SYSTEMD_USER_DIR = HOME / ".config" / "systemd" / "user"
23
+ SERVICE_FILE = SYSTEMD_USER_DIR / "mrstack.service"
24
+
25
+ PID_FILE = DATA_DIR / ".mrstack.pid"
26
+
27
+ # ── Platform ───────────────────────────────────────────
28
+ IS_MACOS = sys.platform == "darwin"
29
+ IS_LINUX = sys.platform == "linux"
30
+ IS_WINDOWS = sys.platform == "win32"
31
+
32
+ # ── Overlay source (bundled with package via hatchling force-include) ──
33
+ OVERLAY_DIR = Path(__file__).parent / "_overlay"
34
+ DATA_CONFIG_DIR = Path(__file__).parent / "_data" / "config"
35
+ DATA_SCHEDULERS_DIR = Path(__file__).parent / "_data" / "schedulers"
36
+ DATA_TEMPLATES_DIR = Path(__file__).parent / "_data" / "templates"
37
+
38
+ # ── External tools ─────────────────────────────────────
39
+ CLAUDE_TELEGRAM_PKG = "claude-code-telegram"
40
+ BOT_COMMAND = "claude-telegram-bot"
41
+
42
+
43
+ def find_site_packages() -> Path | None:
44
+ """Find the claude-code-telegram site-packages directory."""
45
+ # Method 1: uv tools
46
+ uv_base = HOME / ".local" / "share" / "uv" / "tools" / CLAUDE_TELEGRAM_PKG
47
+ if uv_base.is_dir():
48
+ for d in sorted(uv_base.glob("lib/python3.*/site-packages/src"), reverse=True):
49
+ if d.is_dir():
50
+ return d.parent
51
+ # Method 2: pipx
52
+ pipx_base = HOME / ".local" / "pipx" / "venvs" / CLAUDE_TELEGRAM_PKG
53
+ if pipx_base.is_dir():
54
+ for d in sorted(pipx_base.glob("lib/python3.*/site-packages/src"), reverse=True):
55
+ if d.is_dir():
56
+ return d.parent
57
+ return None
58
+
59
+
60
+ def resolve_env_value(key: str, default: str = "") -> str:
61
+ """Read a value from .env file or environment."""
62
+ val = os.environ.get(key)
63
+ if val:
64
+ return val
65
+ if ENV_FILE.is_file():
66
+ for line in ENV_FILE.read_text().splitlines():
67
+ line = line.strip()
68
+ if line.startswith("#") or "=" not in line:
69
+ continue
70
+ k, _, v = line.partition("=")
71
+ if k.strip() == key:
72
+ v = v.strip().strip("\"'")
73
+ # Strip inline comments
74
+ if " #" in v:
75
+ v = v[: v.index(" #")].rstrip()
76
+ return v
77
+ return default