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.
- mrstack/__init__.py +4 -0
- mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
- mrstack/_data/config/mcp-config.example.json +23 -0
- mrstack/_data/config/start-daemon.sh +53 -0
- mrstack/_data/config/start.sh +29 -0
- mrstack/_data/schedulers/manage-jobs.sh +87 -0
- mrstack/_data/schedulers/morning-briefing.sh +29 -0
- mrstack/_data/schedulers/register-jobs.py +182 -0
- mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
- mrstack/_data/schedulers/weekly-review.sh +26 -0
- mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
- mrstack/_data/templates/alert.md +56 -0
- mrstack/_data/templates/evening-summary.md +73 -0
- mrstack/_data/templates/jarvis-alert.md +64 -0
- mrstack/_data/templates/morning-briefing.md +53 -0
- mrstack/_data/templates/weekly-review.md +79 -0
- mrstack/_overlay/api/dashboard.py +223 -0
- mrstack/_overlay/api/templates/dashboard.html +328 -0
- mrstack/_overlay/bot/handlers/callback.py +1432 -0
- mrstack/_overlay/bot/handlers/command.py +1541 -0
- mrstack/_overlay/bot/utils/keyboards.py +125 -0
- mrstack/_overlay/bot/utils/ui_components.py +166 -0
- mrstack/_overlay/claude/session.py +341 -0
- mrstack/_overlay/jarvis/__init__.py +77 -0
- mrstack/_overlay/jarvis/coach.py +122 -0
- mrstack/_overlay/jarvis/context_engine.py +463 -0
- mrstack/_overlay/jarvis/pattern_learner.py +255 -0
- mrstack/_overlay/jarvis/persona.py +84 -0
- mrstack/_overlay/jarvis/platform.py +182 -0
- mrstack/_overlay/knowledge/__init__.py +6 -0
- mrstack/_overlay/knowledge/manager.py +464 -0
- mrstack/_overlay/knowledge/memory_index.py +180 -0
- mrstack/cli.py +330 -0
- mrstack/constants.py +77 -0
- mrstack/daemon.py +325 -0
- mrstack/patcher.py +169 -0
- mrstack/wizard.py +271 -0
- mrstack-1.1.0.dist-info/METADATA +640 -0
- mrstack-1.1.0.dist-info/RECORD +42 -0
- mrstack-1.1.0.dist-info/WHEEL +4 -0
- mrstack-1.1.0.dist-info/entry_points.txt +2 -0
- 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
|