Skip to content

Commit 04e417b

Browse files
thestack_aiclaude
andcommitted
feat: add branded CLI UX with ASCII logo, status icons, and dividers
New banner.py with Mr.Stack ASCII art (cyan+magenta), compact logo, diamond status icons, and rich dividers. All CLI commands now show consistent branded output. Version bump to v1.1.2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d785403 commit 04e417b

File tree

6 files changed

+186
-80
lines changed

6 files changed

+186
-80
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ node_modules/
1616
scrapers/threads/output/
1717
session/
1818
logs/
19+
uv.lock

mrstack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""Mr.Stack — Proactive AI Butler for Claude Code."""
22

3-
__version__ = "1.1.1"
3+
__version__ = "1.1.2"
44
__app_name__ = "Mr.Stack"

mrstack/banner.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Mr.Stack ASCII art banner and branding elements."""
2+
3+
from rich.console import Console
4+
from rich.text import Text
5+
6+
from . import __version__
7+
8+
# ── Brand colors (rich markup) ─────────────────────────
9+
# Cyan = primary, Magenta = accent, Green = success
10+
BRAND_CYAN = "cyan"
11+
BRAND_MAGENTA = "magenta"
12+
BRAND_GREEN = "green"
13+
BRAND_DIM = "dim"
14+
15+
# ── ASCII Logo ─────────────────────────────────────────
16+
LOGO = r"""
17+
[cyan]███╗ ███╗██████╗ [magenta]███████╗████████╗ █████╗ ██████╗██╗ ██╗[/]
18+
[cyan]████╗ ████║██╔══██╗ [magenta]██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝[/]
19+
[cyan]██╔████╔██║██████╔╝ [magenta]███████╗ ██║ ███████║██║ █████╔╝[/]
20+
[cyan]██║╚██╔╝██║██╔══██╗ [magenta]╚════██║ ██║ ██╔══██║██║ ██╔═██╗[/]
21+
[cyan]██║ ╚═╝ ██║██║ ██║██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗[/]
22+
[cyan]╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝[/]
23+
"""
24+
25+
TAGLINE = " [dim]Your AI butler, fully stacked.[/]"
26+
27+
# ── Compact banner (for status, start, etc.) ───────────
28+
COMPACT_LOGO = (
29+
" [cyan bold]◆ ◆ ◆[/] [bold]Mr.Stack[/] [cyan bold]◆ ◆ ◆[/]"
30+
)
31+
32+
33+
def print_banner(console: Console, compact: bool = False) -> None:
34+
"""Print the Mr.Stack banner."""
35+
if compact:
36+
console.print()
37+
console.print(COMPACT_LOGO)
38+
console.print(f" [dim]v{__version__}[/]")
39+
else:
40+
console.print(LOGO)
41+
console.print(TAGLINE)
42+
console.print(f" [dim]v{__version__}[/]")
43+
console.print()
44+
45+
46+
def print_divider(console: Console) -> None:
47+
"""Print a branded divider line."""
48+
console.print(" [dim]─────────────────────────────────────────[/]")
49+
50+
51+
def status_icon(ok: bool) -> str:
52+
"""Return a branded status icon."""
53+
return "[cyan]◆[/]" if ok else "[dim]◇[/]"

mrstack/cli.py

Lines changed: 112 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
import shutil
77
import subprocess
8-
import time
8+
import sys
99
from datetime import datetime, timedelta
1010
from pathlib import Path
1111

@@ -15,12 +15,24 @@
1515
from rich.table import Table
1616

1717
from . import __version__
18+
from .banner import (
19+
BRAND_CYAN,
20+
BRAND_DIM,
21+
BRAND_GREEN,
22+
BRAND_MAGENTA,
23+
COMPACT_LOGO,
24+
print_banner,
25+
print_divider,
26+
status_icon,
27+
)
1828
from .constants import (
1929
BOT_COMMAND,
2030
DATA_DIR,
2131
DB_FILE,
2232
ENV_FILE,
33+
IS_LINUX,
2334
IS_MACOS,
35+
IS_WINDOWS,
2436
LOG_DIR,
2537
MEMORY_DIR,
2638
find_site_packages,
@@ -48,7 +60,7 @@
4860
# ── version ────────────────────────────────────────────
4961
def _version_callback(value: bool) -> None:
5062
if value:
51-
console.print(f"Mr.Stack [bold]v{__version__}[/]")
63+
print_banner(console, compact=True)
5264
raise typer.Exit()
5365

5466

@@ -77,8 +89,10 @@ def start(
7789
background: bool = typer.Option(False, "--bg", "-b", help="Run in background."),
7890
) -> None:
7991
"""Start the bot."""
92+
print_banner(console, compact=True)
8093
if background:
8194
start_background()
95+
_print_quick_status()
8296
else:
8397
start_foreground()
8498

@@ -99,21 +113,28 @@ def daemon(
99113
if uninstall:
100114
daemon_uninstall()
101115
else:
116+
print_banner(console, compact=True)
102117
daemon_install()
103118

104119

105120
# ── status ─────────────────────────────────────────────
106121
@app.command()
107122
def status() -> None:
108123
"""Show current status."""
124+
print_banner(console, compact=True)
125+
print_divider(console)
126+
109127
pid = find_bot_pid()
110128
running = pid is not None
111129

112-
# Collect info
113-
version_str = f"v{__version__}"
114-
status_str = f"[green]Running (PID {pid})[/]" if running else "[red]Stopped[/]"
130+
# Status
131+
if running:
132+
console.print(f" {status_icon(True)} Status [green bold]Running[/] [dim](PID {pid})[/]")
133+
else:
134+
console.print(f" {status_icon(False)} Status [red bold]Stopped[/]")
115135

116-
uptime_str = "—"
136+
# Uptime
137+
uptime_str = ""
117138
if running and pid:
118139
try:
119140
import psutil
@@ -131,54 +152,41 @@ def status() -> None:
131152
parts.append(f"{mins}m")
132153
uptime_str = " ".join(parts)
133154
except Exception:
134-
uptime_str = "unknown"
155+
uptime_str = "?"
156+
console.print(f" {status_icon(True)} Uptime [bold]{uptime_str}[/]")
135157

158+
# Memory
136159
memory_count = 0
137160
if MEMORY_DIR.is_dir():
138161
memory_count = sum(1 for _ in MEMORY_DIR.rglob("*.md"))
162+
console.print(f" {status_icon(memory_count > 0)} Memory [bold]{memory_count}[/] entries")
163+
164+
# Last message
165+
last_msg = _get_last_message_time()
166+
console.print(f" {status_icon(last_msg != '—')} Last msg {last_msg}")
139167

140-
jarvis_str = "[dim]OFF[/]"
168+
# Jarvis
141169
jarvis_enabled = resolve_env_value("ENABLE_JARVIS", "false").lower() == "true"
142170
if jarvis_enabled:
143171
if IS_MACOS:
144-
jarvis_str = "[green]ON[/]"
172+
console.print(f" {status_icon(True)} Jarvis [green bold]ON[/]")
145173
else:
146-
jarvis_str = "[yellow]ON (limited — not macOS)[/]"
174+
console.print(f" {status_icon(True)} Jarvis [yellow bold]ON[/] [dim](limited)[/]")
175+
else:
176+
console.print(f" {status_icon(False)} Jarvis [dim]OFF[/]")
147177

148-
last_msg = "—"
149-
if DB_FILE.is_file():
150-
try:
151-
import sqlite3
178+
# Platform
179+
platform_label = "macOS" if IS_MACOS else ("Linux" if IS_LINUX else "Windows")
180+
console.print(f" {status_icon(True)} Platform {platform_label}")
152181

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
182+
print_divider(console)
170183

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)
184+
# Quick hints
185+
if not running:
186+
console.print(f" [dim]Start: [/][bold]mrstack start[/]")
187+
else:
188+
console.print(f" [dim]Logs: [/][bold]mrstack logs -f[/] [dim]| Stop: [/][bold]mrstack stop[/]")
189+
console.print()
182190

183191

184192
# ── logs ───────────────────────────────────────────────
@@ -205,7 +213,6 @@ def logs(
205213
pass
206214
except FileNotFoundError:
207215
console.print("[red]'tail' command not found.[/]")
208-
# Fallback: read with Python
209216
content = log_file.read_text()
210217
for line in content.splitlines()[-lines:]:
211218
console.print(line)
@@ -243,7 +250,8 @@ def jarvis(
243250
enable = state == "on"
244251
if enable and not IS_MACOS:
245252
console.print(
246-
"[yellow]Jarvis mode has limited functionality on non-macOS platforms.[/]"
253+
"[yellow]Jarvis has limited functionality on this platform.[/]\n"
254+
"[dim] Active app detection and Chrome tab reading are macOS-only.[/]"
247255
)
248256

249257
text = ENV_FILE.read_text()
@@ -257,11 +265,14 @@ def jarvis(
257265
text += f"\nENABLE_JARVIS={new_val}\n"
258266

259267
ENV_FILE.write_text(text)
260-
icon = "[green]ON[/]" if enable else "[red]OFF[/]"
261-
console.print(f"Jarvis mode: {icon}")
268+
269+
if enable:
270+
console.print(f" {status_icon(True)} Jarvis [green bold]ON[/]")
271+
else:
272+
console.print(f" {status_icon(False)} Jarvis [dim]OFF[/]")
262273

263274
if is_running():
264-
console.print("[dim]Restart the bot for changes to take effect.[/]")
275+
console.print(" [dim]Restart for changes to take effect.[/]")
265276

266277

267278
# ── patch ──────────────────────────────────────────────
@@ -279,52 +290,93 @@ def patch(
279290
@app.command()
280291
def update() -> None:
281292
"""Update Mr.Stack to the latest version."""
282-
console.print("Updating Mr.Stack...")
293+
console.print(f" {status_icon(True)} Updating Mr.Stack...")
283294
if shutil.which("uv"):
284295
subprocess.run(["uv", "tool", "upgrade", "mrstack"], check=True)
285296
elif shutil.which("pipx"):
286297
subprocess.run(["pipx", "upgrade", "mrstack"], check=True)
287298
else:
288299
subprocess.run(["pip", "install", "--upgrade", "mrstack"], check=True)
289300

290-
# Re-patch after update
291-
console.print("Re-applying patches...")
301+
console.print(f" {status_icon(True)} Re-applying patches...")
292302
from .patcher import patch_install
293303

294304
patch_install(force=True)
295-
console.print("[green]Update complete.[/]")
305+
console.print(f" [green bold]Update complete.[/]")
296306

297307

298308
# ── version (explicit command) ─────────────────────────
299309
@app.command(name="version")
300310
def version_cmd() -> None:
301311
"""Show version information."""
302-
table = Table(show_header=False, box=None, padding=(0, 2))
303-
table.add_row("Mr.Stack", f"v{__version__}")
312+
from .banner import LOGO
313+
314+
console.print(LOGO)
315+
316+
print_divider(console)
317+
318+
console.print(f" {status_icon(True)} Mr.Stack [bold]v{__version__}[/]")
304319

305320
# claude-code-telegram version
306321
try:
307322
from importlib.metadata import version as pkg_version
308323

309324
cct_ver = pkg_version("claude-code-telegram")
310-
table.add_row("claude-code-telegram", f"v{cct_ver}")
325+
console.print(f" {status_icon(True)} claude-code-telegram [bold]v{cct_ver}[/]")
311326
except Exception:
312-
table.add_row("claude-code-telegram", "[dim]not installed[/]")
327+
console.print(f" {status_icon(False)} claude-code-telegram [dim]not installed[/]")
313328

314329
# Claude Code version
315330
try:
316331
result = subprocess.run(
317332
["claude", "--version"], capture_output=True, text=True, timeout=5
318333
)
319334
if result.returncode == 0:
320-
table.add_row("Claude Code", result.stdout.strip())
335+
console.print(f" {status_icon(True)} Claude Code [bold]{result.stdout.strip()}[/]")
321336
except Exception:
322-
table.add_row("Claude Code", "[dim]not found[/]")
337+
console.print(f" {status_icon(False)} Claude Code [dim]not found[/]")
323338

324339
# Platform
325340
import platform
326341

327-
table.add_row("Platform", f"{platform.system()} {platform.machine()}")
328-
table.add_row("Python", platform.python_version())
342+
console.print(f" {status_icon(True)} Platform {platform.system()} {platform.machine()}")
343+
console.print(f" {status_icon(True)} Python {platform.python_version()}")
344+
345+
print_divider(console)
346+
console.print()
329347

330-
console.print(table)
348+
349+
# ── Helpers ────────────────────────────────────────────
350+
def _get_last_message_time() -> str:
351+
if DB_FILE.is_file():
352+
try:
353+
import sqlite3
354+
355+
with sqlite3.connect(str(DB_FILE)) as conn:
356+
row = conn.execute(
357+
"SELECT MAX(created_at) FROM messages"
358+
).fetchone()
359+
if row and row[0]:
360+
ts = datetime.fromisoformat(row[0])
361+
delta = datetime.now() - ts
362+
if delta < timedelta(minutes=1):
363+
return "just now"
364+
elif delta < timedelta(hours=1):
365+
return f"{int(delta.total_seconds() // 60)}m ago"
366+
elif delta < timedelta(days=1):
367+
return f"{int(delta.total_seconds() // 3600)}h ago"
368+
else:
369+
return ts.strftime("%Y-%m-%d %H:%M")
370+
except Exception:
371+
pass
372+
return "[dim]—[/]"
373+
374+
375+
def _print_quick_status() -> None:
376+
"""Print a quick 2-line status after start."""
377+
pid = find_bot_pid()
378+
if pid:
379+
jarvis = resolve_env_value("ENABLE_JARVIS", "false").lower() == "true"
380+
j_str = "[green]ON[/]" if jarvis else "[dim]OFF[/]"
381+
console.print(f" {status_icon(True)} Jarvis: {j_str} {status_icon(True)} Logs: [bold]mrstack logs -f[/]")
382+
console.print()

0 commit comments

Comments
 (0)