🔄 卡若AI 同步 2026-03-12 23:23 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""cli_anything.webpomodoro — WebPomodoro macOS app CLI interface."""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
WebPomodoro data layer — reads tasks, sessions, goals from local storage.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import (
|
||||||
|
read_localstorage,
|
||||||
|
read_tasks,
|
||||||
|
read_pomodoro_records,
|
||||||
|
count_today_pomodoros,
|
||||||
|
get_timer_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(s):
|
||||||
|
try:
|
||||||
|
return json.loads(s) if isinstance(s, str) else s
|
||||||
|
except Exception:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_b64(s: str) -> str:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(s).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_to_human(ts) -> str:
|
||||||
|
"""Convert millisecond timestamp to human-readable local time."""
|
||||||
|
try:
|
||||||
|
ts_sec = int(ts) / 1000
|
||||||
|
return datetime.fromtimestamp(ts_sec).strftime("%Y-%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
return str(ts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_task_info() -> dict:
|
||||||
|
"""
|
||||||
|
Returns info about the currently tracked task.
|
||||||
|
Combines localStorage (timingTaskId) with IndexedDB task lookup.
|
||||||
|
"""
|
||||||
|
ls = read_localstorage()
|
||||||
|
task_id = ls.get("timingTaskId", "")
|
||||||
|
subtask_id = ls.get("timingSubtaskId", "")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"timingTaskId": task_id,
|
||||||
|
"timingSubtaskId": subtask_id,
|
||||||
|
"found": False,
|
||||||
|
"taskName": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
result["message"] = "No task currently being timed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Search in IndexedDB task records
|
||||||
|
tasks = read_tasks(limit=100)
|
||||||
|
for t in tasks:
|
||||||
|
tid = t.get("id", "").strip()
|
||||||
|
if tid == task_id or task_id in tid:
|
||||||
|
result["found"] = True
|
||||||
|
result["rawData"] = t.get("data", {})
|
||||||
|
# Try to extract name from raw words
|
||||||
|
words = t.get("data", {}).get("_raw_words", [])
|
||||||
|
if words:
|
||||||
|
result["taskName"] = " ".join(words[:5])
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_info() -> dict:
|
||||||
|
"""Return logged-in user info."""
|
||||||
|
ls = read_localstorage()
|
||||||
|
return {
|
||||||
|
"name": _decode_b64(ls.get("cookie.NAME", "")),
|
||||||
|
"email": _decode_b64(ls.get("cookie.ACCT", "")),
|
||||||
|
"uid": ls.get("cookie.UID", ""),
|
||||||
|
"appVersion": ls.get("Version", ""),
|
||||||
|
"installDate": _ts_to_human(ls.get("InstallationDate", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_goals() -> list:
|
||||||
|
"""Return daily goals."""
|
||||||
|
ls = read_localstorage()
|
||||||
|
goals_raw = ls.get("Goals", "[]")
|
||||||
|
goals = _safe_json(goals_raw)
|
||||||
|
result = []
|
||||||
|
if isinstance(goals, list):
|
||||||
|
for g in goals:
|
||||||
|
if isinstance(g, dict):
|
||||||
|
goal_type = g.get("type", "")
|
||||||
|
value = g.get("value", 0)
|
||||||
|
if goal_type == "TIME":
|
||||||
|
result.append({"type": "daily_focus_minutes", "value": value,
|
||||||
|
"display": f"{value} 分钟/天"})
|
||||||
|
elif goal_type == "COUNT":
|
||||||
|
result.append({"type": "daily_pomodoro_count", "value": value,
|
||||||
|
"display": f"{value} 个番茄/天"})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_full_status() -> dict:
|
||||||
|
"""Return complete app status."""
|
||||||
|
state = get_timer_state()
|
||||||
|
user = get_user_info()
|
||||||
|
goals = get_goals()
|
||||||
|
pomodoro_count = count_today_pomodoros()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timer": {
|
||||||
|
"label": state.get("label", "unknown"),
|
||||||
|
"timingTaskId": state.get("timingTaskId", ""),
|
||||||
|
},
|
||||||
|
"user": user,
|
||||||
|
"goals": goals,
|
||||||
|
"totalPomodoros": pomodoro_count,
|
||||||
|
"lastSync": _ts_to_human(state.get("syncTimestamp", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_pomodoros(limit: int = 10) -> list:
|
||||||
|
"""Get recent Pomodoro session records."""
|
||||||
|
records = read_pomodoro_records(limit=limit)
|
||||||
|
result = []
|
||||||
|
for r in records:
|
||||||
|
item = {"id": r.get("id", ""), "data": r.get("data", {})}
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Timer control — uses AppleScript (Accessibility) to drive WebPomodoro.
|
||||||
|
State machine: initial → work ↔ pause → rest → initial
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _applescript(script: str) -> str:
|
||||||
|
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=10)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(r.stderr.strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_app_active() -> None:
|
||||||
|
_applescript('tell application "WebPomodoro" to activate')
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
|
||||||
|
def _click_in_window(x: int, y: int) -> None:
|
||||||
|
"""Click at screen position via System Events."""
|
||||||
|
script = f'''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
click at {{{x}, {y}}}
|
||||||
|
end tell
|
||||||
|
end tell'''
|
||||||
|
_applescript(script)
|
||||||
|
|
||||||
|
|
||||||
|
def _press_key(key: str) -> None:
|
||||||
|
script = f'''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
keystroke "{key}"
|
||||||
|
end tell
|
||||||
|
end tell'''
|
||||||
|
_applescript(script)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Menu-based controls ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_window_buttons() -> list:
|
||||||
|
"""Get all buttons in the main WebPomodoro window."""
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
set allButtons to buttons of win1
|
||||||
|
set btnNames to {}
|
||||||
|
repeat with b in allButtons
|
||||||
|
try
|
||||||
|
set end of btnNames to description of b & " | " & title of b
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
return btnNames
|
||||||
|
end if
|
||||||
|
return {}
|
||||||
|
end tell
|
||||||
|
end tell'''
|
||||||
|
try:
|
||||||
|
return _applescript(script)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def start_work() -> dict:
|
||||||
|
"""Start a work (focus) session."""
|
||||||
|
_ensure_app_active()
|
||||||
|
# Try AppleScript webBridge approach via JS evaluation via URL scheme
|
||||||
|
# Fallback: click the start button via Accessibility
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
-- Look for start/play button
|
||||||
|
repeat with b in buttons of win1
|
||||||
|
try
|
||||||
|
set desc to description of b
|
||||||
|
if desc contains "开始" or desc contains "start" or desc contains "Start" or desc contains "play" then
|
||||||
|
click b
|
||||||
|
return "clicked: " & desc
|
||||||
|
end if
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
return "no start button found"'''
|
||||||
|
try:
|
||||||
|
result = _applescript(script)
|
||||||
|
return {"action": "start_work", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"action": "start_work", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def pause_timer() -> dict:
|
||||||
|
"""Pause the current timer."""
|
||||||
|
_ensure_app_active()
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
repeat with b in buttons of win1
|
||||||
|
try
|
||||||
|
set desc to description of b
|
||||||
|
if desc contains "暂停" or desc contains "pause" or desc contains "Pause" then
|
||||||
|
click b
|
||||||
|
return "clicked: " & desc
|
||||||
|
end if
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
return "no pause button found"'''
|
||||||
|
try:
|
||||||
|
result = _applescript(script)
|
||||||
|
return {"action": "pause", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"action": "pause", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_timer() -> dict:
|
||||||
|
"""Stop/reset the current timer."""
|
||||||
|
_ensure_app_active()
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
repeat with b in buttons of win1
|
||||||
|
try
|
||||||
|
set desc to description of b
|
||||||
|
if desc contains "停止" or desc contains "stop" or desc contains "Stop" or desc contains "reset" then
|
||||||
|
click b
|
||||||
|
return "clicked: " & desc
|
||||||
|
end if
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
return "no stop button found"'''
|
||||||
|
try:
|
||||||
|
result = _applescript(script)
|
||||||
|
return {"action": "stop", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"action": "stop", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def start_break() -> dict:
|
||||||
|
"""Start a break session."""
|
||||||
|
_ensure_app_active()
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
repeat with b in buttons of win1
|
||||||
|
try
|
||||||
|
set desc to description of b
|
||||||
|
if desc contains "休息" or desc contains "break" or desc contains "Break" or desc contains "rest" then
|
||||||
|
click b
|
||||||
|
return "clicked: " & desc
|
||||||
|
end if
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
return "no break button found"'''
|
||||||
|
try:
|
||||||
|
result = _applescript(script)
|
||||||
|
return {"action": "start_break", "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"action": "start_break", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_label() -> str:
|
||||||
|
"""Get current timer label from menu bar (e.g. '24:30' or '专注中')."""
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
return name of menu bar item 1 of menu bar 2
|
||||||
|
end tell
|
||||||
|
end tell'''
|
||||||
|
try:
|
||||||
|
return _applescript(script)
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def list_ui_elements() -> str:
|
||||||
|
"""Debug: list all UI elements in the main window."""
|
||||||
|
script = '''
|
||||||
|
tell application "System Events"
|
||||||
|
tell process "WebPomodoro"
|
||||||
|
set result_list to {}
|
||||||
|
set wins to windows
|
||||||
|
if (count of wins) > 0 then
|
||||||
|
set win1 to item 1 of wins
|
||||||
|
-- buttons
|
||||||
|
repeat with b in buttons of win1
|
||||||
|
try
|
||||||
|
set end of result_list to "BTN: " & description of b
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
-- static texts
|
||||||
|
repeat with st in static texts of win1
|
||||||
|
try
|
||||||
|
set t to value of st
|
||||||
|
if t is not missing value and t is not "" then
|
||||||
|
set end of result_list to "TXT: " & t
|
||||||
|
end if
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
end if
|
||||||
|
return result_list
|
||||||
|
end tell
|
||||||
|
end tell'''
|
||||||
|
try:
|
||||||
|
return _applescript(script)
|
||||||
|
except Exception as e:
|
||||||
|
return f"error: {e}"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for cli-anything-webpomodoro core modules.
|
||||||
|
Tests use real LocalStorage data (read-only, no side effects).
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backend unit tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestBackend:
|
||||||
|
def test_localstorage_readable(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_localstorage
|
||||||
|
ls = read_localstorage()
|
||||||
|
assert isinstance(ls, dict)
|
||||||
|
# Should have at least Version key
|
||||||
|
assert "Version" in ls or len(ls) == 0 # empty if app never ran
|
||||||
|
|
||||||
|
def test_version_is_string(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_localstorage
|
||||||
|
ls = read_localstorage()
|
||||||
|
if "Version" in ls:
|
||||||
|
assert isinstance(ls["Version"], str)
|
||||||
|
|
||||||
|
def test_timing_task_id_format(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_localstorage
|
||||||
|
ls = read_localstorage()
|
||||||
|
tid = ls.get("timingTaskId", "")
|
||||||
|
# If set, should look like a UUID
|
||||||
|
if tid:
|
||||||
|
assert len(tid) >= 30
|
||||||
|
|
||||||
|
def test_is_running_returns_bool(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import is_running
|
||||||
|
result = is_running()
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
def test_timer_label_is_string(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import get_timer_label, is_running
|
||||||
|
if is_running():
|
||||||
|
label = get_timer_label()
|
||||||
|
assert isinstance(label, str)
|
||||||
|
assert len(label) > 0
|
||||||
|
|
||||||
|
def test_get_timer_state_keys(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import get_timer_state
|
||||||
|
state = get_timer_state()
|
||||||
|
assert "label" in state
|
||||||
|
assert "timingTaskId" in state
|
||||||
|
assert "user" in state
|
||||||
|
assert "email" in state
|
||||||
|
|
||||||
|
def test_user_email_present(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import get_timer_state
|
||||||
|
state = get_timer_state()
|
||||||
|
email = state.get("email", "")
|
||||||
|
if email:
|
||||||
|
assert "@" in email
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data layer unit tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestDataLayer:
|
||||||
|
def test_get_user_info(self):
|
||||||
|
from cli_anything.webpomodoro.core.data import get_user_info
|
||||||
|
info = get_user_info()
|
||||||
|
assert isinstance(info, dict)
|
||||||
|
assert "name" in info
|
||||||
|
assert "email" in info
|
||||||
|
assert "appVersion" in info
|
||||||
|
|
||||||
|
def test_get_goals(self):
|
||||||
|
from cli_anything.webpomodoro.core.data import get_goals
|
||||||
|
goals = get_goals()
|
||||||
|
assert isinstance(goals, list)
|
||||||
|
|
||||||
|
def test_pomodoro_count_is_int(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import count_today_pomodoros
|
||||||
|
count = count_today_pomodoros()
|
||||||
|
assert isinstance(count, int)
|
||||||
|
assert count >= 0
|
||||||
|
|
||||||
|
def test_get_full_status_structure(self):
|
||||||
|
from cli_anything.webpomodoro.core.data import get_full_status
|
||||||
|
status = get_full_status()
|
||||||
|
assert "timer" in status
|
||||||
|
assert "user" in status
|
||||||
|
assert "goals" in status
|
||||||
|
assert "totalPomodoros" in status
|
||||||
|
|
||||||
|
def test_read_tasks_returns_list(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_tasks
|
||||||
|
tasks = read_tasks(limit=5)
|
||||||
|
assert isinstance(tasks, list)
|
||||||
|
|
||||||
|
def test_read_pomodoros_returns_list(self):
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_pomodoro_records
|
||||||
|
records = read_pomodoro_records(limit=5)
|
||||||
|
assert isinstance(records, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI command tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestCLICommands:
|
||||||
|
def setup_method(self):
|
||||||
|
self.runner = CliRunner()
|
||||||
|
|
||||||
|
def test_cli_help(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "timer" in result.output
|
||||||
|
assert "task" in result.output
|
||||||
|
assert "session" in result.output
|
||||||
|
|
||||||
|
def test_timer_help(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["timer", "--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "status" in result.output
|
||||||
|
assert "start" in result.output
|
||||||
|
assert "pause" in result.output
|
||||||
|
|
||||||
|
def test_timer_status_runs(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["timer", "status"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_timer_status_json(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
import json
|
||||||
|
result = self.runner.invoke(cli, ["timer", "status", "--json"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert "label" in data or "running" in data
|
||||||
|
|
||||||
|
def test_session_today_runs(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["session", "today"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_data_settings_runs(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["data", "settings"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_data_goals_runs(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["data", "goals"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_task_list_runs(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
result = self.runner.invoke(cli, ["task", "list", "--limit", "5"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_session_history_json(self):
|
||||||
|
from cli_anything.webpomodoro.webpomodoro_cli import cli
|
||||||
|
import json
|
||||||
|
result = self.runner.invoke(cli, ["session", "history", "--json"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert isinstance(data, list)
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||||
|
|
||||||
|
Copy this file into your CLI package at:
|
||||||
|
cli_anything/<software>/utils/repl_skin.py
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||||
|
|
||||||
|
skin = ReplSkin("shotcut", version="1.0.0")
|
||||||
|
skin.print_banner()
|
||||||
|
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||||
|
skin.success("Project saved")
|
||||||
|
skin.error("File not found")
|
||||||
|
skin.warning("Unsaved changes")
|
||||||
|
skin.info("Processing 24 clips...")
|
||||||
|
skin.status("Track 1", "3 clips, 00:02:30")
|
||||||
|
skin.table(headers, rows)
|
||||||
|
skin.print_goodbye()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||||
|
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
_BOLD = "\033[1m"
|
||||||
|
_DIM = "\033[2m"
|
||||||
|
_ITALIC = "\033[3m"
|
||||||
|
_UNDERLINE = "\033[4m"
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||||
|
_CYAN_BG = "\033[48;5;80m"
|
||||||
|
_WHITE = "\033[97m"
|
||||||
|
_GRAY = "\033[38;5;245m"
|
||||||
|
_DARK_GRAY = "\033[38;5;240m"
|
||||||
|
_LIGHT_GRAY = "\033[38;5;250m"
|
||||||
|
|
||||||
|
# Software accent colors — each software gets a unique accent
|
||||||
|
_ACCENT_COLORS = {
|
||||||
|
"gimp": "\033[38;5;214m", # warm orange
|
||||||
|
"blender": "\033[38;5;208m", # deep orange
|
||||||
|
"inkscape": "\033[38;5;39m", # bright blue
|
||||||
|
"audacity": "\033[38;5;33m", # navy blue
|
||||||
|
"libreoffice": "\033[38;5;40m", # green
|
||||||
|
"obs_studio": "\033[38;5;55m", # purple
|
||||||
|
"kdenlive": "\033[38;5;69m", # slate blue
|
||||||
|
"shotcut": "\033[38;5;35m", # teal green
|
||||||
|
}
|
||||||
|
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||||
|
|
||||||
|
# Status colors
|
||||||
|
_GREEN = "\033[38;5;78m"
|
||||||
|
_YELLOW = "\033[38;5;220m"
|
||||||
|
_RED = "\033[38;5;196m"
|
||||||
|
_BLUE = "\033[38;5;75m"
|
||||||
|
_MAGENTA = "\033[38;5;176m"
|
||||||
|
|
||||||
|
# ── Brand icon ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# The cli-anything icon: a small colored diamond/chevron mark
|
||||||
|
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||||
|
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||||
|
|
||||||
|
# ── Box drawing characters ────────────────────────────────────────────
|
||||||
|
|
||||||
|
_H_LINE = "─"
|
||||||
|
_V_LINE = "│"
|
||||||
|
_TL = "╭"
|
||||||
|
_TR = "╮"
|
||||||
|
_BL = "╰"
|
||||||
|
_BR = "╯"
|
||||||
|
_T_DOWN = "┬"
|
||||||
|
_T_UP = "┴"
|
||||||
|
_T_RIGHT = "├"
|
||||||
|
_T_LEFT = "┤"
|
||||||
|
_CROSS = "┼"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ansi(text: str) -> str:
|
||||||
|
"""Remove ANSI escape codes for length calculation."""
|
||||||
|
import re
|
||||||
|
return re.sub(r"\033\[[^m]*m", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_len(text: str) -> int:
|
||||||
|
"""Get visible length of text (excluding ANSI codes)."""
|
||||||
|
return len(_strip_ansi(text))
|
||||||
|
|
||||||
|
|
||||||
|
class ReplSkin:
|
||||||
|
"""Unified REPL skin for cli-anything CLIs.
|
||||||
|
|
||||||
|
Provides consistent branding, prompts, and message formatting
|
||||||
|
across all CLI harnesses built with the cli-anything methodology.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, software: str, version: str = "1.0.0",
|
||||||
|
history_file: str | None = None):
|
||||||
|
"""Initialize the REPL skin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||||
|
version: CLI version string.
|
||||||
|
history_file: Path for persistent command history.
|
||||||
|
Defaults to ~/.cli-anything-<software>/history
|
||||||
|
"""
|
||||||
|
self.software = software.lower().replace("-", "_")
|
||||||
|
self.display_name = software.replace("_", " ").title()
|
||||||
|
self.version = version
|
||||||
|
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||||
|
|
||||||
|
# History file
|
||||||
|
if history_file is None:
|
||||||
|
from pathlib import Path
|
||||||
|
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||||
|
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.history_file = str(hist_dir / "history")
|
||||||
|
else:
|
||||||
|
self.history_file = history_file
|
||||||
|
|
||||||
|
# Detect terminal capabilities
|
||||||
|
self._color = self._detect_color_support()
|
||||||
|
|
||||||
|
def _detect_color_support(self) -> bool:
|
||||||
|
"""Check if terminal supports color."""
|
||||||
|
if os.environ.get("NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||||
|
return False
|
||||||
|
if not hasattr(sys.stdout, "isatty"):
|
||||||
|
return False
|
||||||
|
return sys.stdout.isatty()
|
||||||
|
|
||||||
|
def _c(self, code: str, text: str) -> str:
|
||||||
|
"""Apply color code if colors are supported."""
|
||||||
|
if not self._color:
|
||||||
|
return text
|
||||||
|
return f"{code}{text}{_RESET}"
|
||||||
|
|
||||||
|
# ── Banner ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_banner(self):
|
||||||
|
"""Print the startup banner with branding."""
|
||||||
|
inner = 54
|
||||||
|
|
||||||
|
def _box_line(content: str) -> str:
|
||||||
|
"""Wrap content in box drawing, padding to inner width."""
|
||||||
|
pad = inner - _visible_len(content)
|
||||||
|
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||||
|
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||||
|
|
||||||
|
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||||
|
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||||
|
|
||||||
|
# Title: ◆ cli-anything · Shotcut
|
||||||
|
icon = self._c(_CYAN + _BOLD, "◆")
|
||||||
|
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||||
|
dot = self._c(_DARK_GRAY, "·")
|
||||||
|
name = self._c(self.accent + _BOLD, self.display_name)
|
||||||
|
title = f" {icon} {brand} {dot} {name}"
|
||||||
|
|
||||||
|
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||||
|
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||||
|
empty = ""
|
||||||
|
|
||||||
|
print(top)
|
||||||
|
print(_box_line(title))
|
||||||
|
print(_box_line(ver))
|
||||||
|
print(_box_line(empty))
|
||||||
|
print(_box_line(tip))
|
||||||
|
print(bot)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def prompt(self, project_name: str = "", modified: bool = False,
|
||||||
|
context: str = "") -> str:
|
||||||
|
"""Build a styled prompt string for prompt_toolkit or input().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_name: Current project name (empty if none open).
|
||||||
|
modified: Whether the project has unsaved changes.
|
||||||
|
context: Optional extra context to show in prompt.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt string.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Icon
|
||||||
|
if self._color:
|
||||||
|
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||||
|
else:
|
||||||
|
parts.append("> ")
|
||||||
|
|
||||||
|
# Software name
|
||||||
|
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||||
|
|
||||||
|
# Project context
|
||||||
|
if project_name or context:
|
||||||
|
ctx = context or project_name
|
||||||
|
mod = "*" if modified else ""
|
||||||
|
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||||
|
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||||
|
parts.append(self._c(_DARK_GRAY, ']'))
|
||||||
|
|
||||||
|
parts.append(self._c(_GRAY, " ❯ "))
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||||
|
context: str = ""):
|
||||||
|
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||||
|
|
||||||
|
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of (style, text) tuples for prompt_toolkit.
|
||||||
|
"""
|
||||||
|
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||||
|
tokens = []
|
||||||
|
|
||||||
|
tokens.append(("class:icon", "◆ "))
|
||||||
|
tokens.append(("class:software", self.software))
|
||||||
|
|
||||||
|
if project_name or context:
|
||||||
|
ctx = context or project_name
|
||||||
|
mod = "*" if modified else ""
|
||||||
|
tokens.append(("class:bracket", " ["))
|
||||||
|
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||||
|
tokens.append(("class:bracket", "]"))
|
||||||
|
|
||||||
|
tokens.append(("class:arrow", " ❯ "))
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def get_prompt_style(self):
|
||||||
|
"""Get a prompt_toolkit Style object matching the skin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
prompt_toolkit.styles.Style
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||||
|
|
||||||
|
return Style.from_dict({
|
||||||
|
"icon": "#5fdfdf bold", # cyan brand color
|
||||||
|
"software": f"{accent_hex} bold",
|
||||||
|
"bracket": "#585858",
|
||||||
|
"context": "#bcbcbc",
|
||||||
|
"arrow": "#808080",
|
||||||
|
# Completion menu
|
||||||
|
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||||
|
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||||
|
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||||
|
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||||
|
# Auto-suggest
|
||||||
|
"auto-suggest": "#585858",
|
||||||
|
# Bottom toolbar
|
||||||
|
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||||
|
"bottom-toolbar.text": "#808080",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Messages ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def success(self, message: str):
|
||||||
|
"""Print a success message with green checkmark."""
|
||||||
|
icon = self._c(_GREEN + _BOLD, "✓")
|
||||||
|
print(f" {icon} {self._c(_GREEN, message)}")
|
||||||
|
|
||||||
|
def error(self, message: str):
|
||||||
|
"""Print an error message with red cross."""
|
||||||
|
icon = self._c(_RED + _BOLD, "✗")
|
||||||
|
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||||
|
|
||||||
|
def warning(self, message: str):
|
||||||
|
"""Print a warning message with yellow triangle."""
|
||||||
|
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||||
|
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||||
|
|
||||||
|
def info(self, message: str):
|
||||||
|
"""Print an info message with blue dot."""
|
||||||
|
icon = self._c(_BLUE, "●")
|
||||||
|
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||||
|
|
||||||
|
def hint(self, message: str):
|
||||||
|
"""Print a subtle hint message."""
|
||||||
|
print(f" {self._c(_DARK_GRAY, message)}")
|
||||||
|
|
||||||
|
def section(self, title: str):
|
||||||
|
"""Print a section header."""
|
||||||
|
print()
|
||||||
|
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||||
|
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||||
|
|
||||||
|
# ── Status display ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def status(self, label: str, value: str):
|
||||||
|
"""Print a key-value status line."""
|
||||||
|
lbl = self._c(_GRAY, f" {label}:")
|
||||||
|
val = self._c(_WHITE, f" {value}")
|
||||||
|
print(f"{lbl}{val}")
|
||||||
|
|
||||||
|
def status_block(self, items: dict[str, str], title: str = ""):
|
||||||
|
"""Print a block of status key-value pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Dict of label -> value pairs.
|
||||||
|
title: Optional title for the block.
|
||||||
|
"""
|
||||||
|
if title:
|
||||||
|
self.section(title)
|
||||||
|
|
||||||
|
max_key = max(len(k) for k in items) if items else 0
|
||||||
|
for label, value in items.items():
|
||||||
|
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||||
|
val = self._c(_WHITE, f" {value}")
|
||||||
|
print(f"{lbl}{val}")
|
||||||
|
|
||||||
|
def progress(self, current: int, total: int, label: str = ""):
|
||||||
|
"""Print a simple progress indicator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current step number.
|
||||||
|
total: Total number of steps.
|
||||||
|
label: Optional label for the progress.
|
||||||
|
"""
|
||||||
|
pct = int(current / total * 100) if total > 0 else 0
|
||||||
|
bar_width = 20
|
||||||
|
filled = int(bar_width * current / total) if total > 0 else 0
|
||||||
|
bar = "█" * filled + "░" * (bar_width - filled)
|
||||||
|
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||||
|
if label:
|
||||||
|
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
# ── Table display ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def table(self, headers: list[str], rows: list[list[str]],
|
||||||
|
max_col_width: int = 40):
|
||||||
|
"""Print a formatted table with box-drawing characters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Column header strings.
|
||||||
|
rows: List of rows, each a list of cell strings.
|
||||||
|
max_col_width: Maximum column width before truncation.
|
||||||
|
"""
|
||||||
|
if not headers:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate column widths
|
||||||
|
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||||
|
for row in rows:
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
if i < len(col_widths):
|
||||||
|
col_widths[i] = min(
|
||||||
|
max(col_widths[i], len(str(cell))), max_col_width
|
||||||
|
)
|
||||||
|
|
||||||
|
def pad(text: str, width: int) -> str:
|
||||||
|
t = str(text)[:width]
|
||||||
|
return t + " " * (width - len(t))
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header_cells = [
|
||||||
|
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||||
|
for i, h in enumerate(headers)
|
||||||
|
]
|
||||||
|
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||||
|
header_line = f" {sep.join(header_cells)}"
|
||||||
|
print(header_line)
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||||
|
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||||
|
print(sep_line)
|
||||||
|
|
||||||
|
# Rows
|
||||||
|
for row in rows:
|
||||||
|
cells = []
|
||||||
|
for i, cell in enumerate(row):
|
||||||
|
if i < len(col_widths):
|
||||||
|
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||||
|
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||||
|
print(f" {row_sep.join(cells)}")
|
||||||
|
|
||||||
|
# ── Help display ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def help(self, commands: dict[str, str]):
|
||||||
|
"""Print a formatted help listing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
commands: Dict of command -> description pairs.
|
||||||
|
"""
|
||||||
|
self.section("Commands")
|
||||||
|
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||||
|
for cmd, desc in commands.items():
|
||||||
|
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||||
|
desc_styled = self._c(_GRAY, f" {desc}")
|
||||||
|
print(f"{cmd_styled}{desc_styled}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Goodbye ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def print_goodbye(self):
|
||||||
|
"""Print a styled goodbye message."""
|
||||||
|
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||||
|
|
||||||
|
# ── Prompt toolkit session factory ────────────────────────────────
|
||||||
|
|
||||||
|
def create_prompt_session(self):
|
||||||
|
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
|
||||||
|
style = self.get_prompt_style()
|
||||||
|
|
||||||
|
session = PromptSession(
|
||||||
|
history=FileHistory(self.history_file),
|
||||||
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
|
style=style,
|
||||||
|
enable_history_search=True,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_input(self, pt_session, project_name: str = "",
|
||||||
|
modified: bool = False, context: str = "") -> str:
|
||||||
|
"""Get input from user using prompt_toolkit or fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pt_session: A prompt_toolkit PromptSession (or None).
|
||||||
|
project_name: Current project name.
|
||||||
|
modified: Whether project has unsaved changes.
|
||||||
|
context: Optional context string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User input string (stripped).
|
||||||
|
"""
|
||||||
|
if pt_session is not None:
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
tokens = self.prompt_tokens(project_name, modified, context)
|
||||||
|
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||||
|
else:
|
||||||
|
raw_prompt = self.prompt(project_name, modified, context)
|
||||||
|
return input(raw_prompt).strip()
|
||||||
|
|
||||||
|
# ── Toolbar builder ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def bottom_toolbar(self, items: dict[str, str]):
|
||||||
|
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Dict of label -> value pairs to show in toolbar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callable that returns FormattedText for the toolbar.
|
||||||
|
"""
|
||||||
|
def toolbar():
|
||||||
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
parts = []
|
||||||
|
for i, (k, v) in enumerate(items.items()):
|
||||||
|
if i > 0:
|
||||||
|
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||||
|
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||||
|
parts.append(("class:bottom-toolbar", v))
|
||||||
|
return FormattedText(parts)
|
||||||
|
return toolbar
|
||||||
|
|
||||||
|
|
||||||
|
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||||
|
|
||||||
|
_ANSI_256_TO_HEX = {
|
||||||
|
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||||
|
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||||
|
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||||
|
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||||
|
"\033[38;5;55m": "#5f00af", # obs purple
|
||||||
|
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||||
|
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||||
|
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||||
|
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||||
|
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
cli-anything-webpomodoro — WebPomodoro macOS app CLI interface.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
timer status Show current timer state
|
||||||
|
timer start Start focus session
|
||||||
|
timer pause Pause timer
|
||||||
|
timer stop Stop/reset timer
|
||||||
|
timer break Start break session
|
||||||
|
timer ui Show all UI elements (debug)
|
||||||
|
|
||||||
|
task current Show currently tracked task
|
||||||
|
task list List recent tasks from IndexedDB
|
||||||
|
|
||||||
|
session today Show today's session summary
|
||||||
|
session history List recent Pomodoro records
|
||||||
|
|
||||||
|
data settings Show app settings and user info
|
||||||
|
data goals Show daily goals
|
||||||
|
|
||||||
|
repl Interactive REPL mode (default)
|
||||||
|
"""
|
||||||
|
import click
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _out(data, json_mode: bool) -> None:
|
||||||
|
if json_mode:
|
||||||
|
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for k, v in data.items():
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
click.echo(f" {k}:")
|
||||||
|
click.echo(f" {json.dumps(v, ensure_ascii=False)}")
|
||||||
|
else:
|
||||||
|
click.echo(f" {k}: {v}")
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
click.echo(f" - {json.dumps(item, ensure_ascii=False)}")
|
||||||
|
else:
|
||||||
|
click.echo(str(data))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Root group ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, json_mode):
|
||||||
|
"""WebPomodoro CLI — control your Pomodoro timer from the command line.
|
||||||
|
|
||||||
|
Run without subcommand to enter interactive REPL mode.
|
||||||
|
"""
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
ctx.obj["json"] = json_mode
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
ctx.invoke(repl)
|
||||||
|
|
||||||
|
|
||||||
|
# ── timer group ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def timer():
|
||||||
|
"""Timer control commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("status")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def timer_status(json_mode):
|
||||||
|
"""Show current timer state (label + tracked task ID)."""
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import get_timer_state, is_running
|
||||||
|
running = is_running()
|
||||||
|
if not running:
|
||||||
|
data = {"running": False, "message": "WebPomodoro is not running"}
|
||||||
|
else:
|
||||||
|
data = get_timer_state()
|
||||||
|
data["running"] = True
|
||||||
|
_out(data, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("start")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def timer_start(json_mode):
|
||||||
|
"""Start a focus/work session."""
|
||||||
|
from cli_anything.webpomodoro.core.timer import start_work
|
||||||
|
result = start_work()
|
||||||
|
_out(result, json_mode)
|
||||||
|
if not json_mode:
|
||||||
|
label = __import__("cli_anything.webpomodoro.core.timer",
|
||||||
|
fromlist=["get_status_label"]).get_status_label()
|
||||||
|
click.echo(f" ▶ Timer: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("pause")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def timer_pause(json_mode):
|
||||||
|
"""Pause the current timer."""
|
||||||
|
from cli_anything.webpomodoro.core.timer import pause_timer
|
||||||
|
result = pause_timer()
|
||||||
|
_out(result, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("stop")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def timer_stop(json_mode):
|
||||||
|
"""Stop and reset the current timer."""
|
||||||
|
from cli_anything.webpomodoro.core.timer import stop_timer
|
||||||
|
result = stop_timer()
|
||||||
|
_out(result, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("break")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def timer_break(json_mode):
|
||||||
|
"""Start a break session."""
|
||||||
|
from cli_anything.webpomodoro.core.timer import start_break
|
||||||
|
result = start_break()
|
||||||
|
_out(result, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@timer.command("ui")
|
||||||
|
def timer_ui():
|
||||||
|
"""[Debug] List all UI elements in the main window."""
|
||||||
|
from cli_anything.webpomodoro.core.timer import list_ui_elements
|
||||||
|
click.echo(list_ui_elements())
|
||||||
|
|
||||||
|
|
||||||
|
# ── task group ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def task():
|
||||||
|
"""Task management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@task.command("current")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def task_current(json_mode):
|
||||||
|
"""Show the currently tracked task."""
|
||||||
|
from cli_anything.webpomodoro.core.data import get_current_task_info
|
||||||
|
data = get_current_task_info()
|
||||||
|
_out(data, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@task.command("list")
|
||||||
|
@click.option("--limit", default=20, help="Number of tasks to show")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def task_list(limit, json_mode):
|
||||||
|
"""List recent tasks from local database."""
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_tasks
|
||||||
|
tasks = read_tasks(limit=limit)
|
||||||
|
if json_mode:
|
||||||
|
click.echo(json.dumps(tasks, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f" Recent tasks ({len(tasks)}):")
|
||||||
|
for i, t in enumerate(tasks, 1):
|
||||||
|
task_id = t.get("id", "")[:36]
|
||||||
|
words = t.get("data", {}).get("_raw_words", [])
|
||||||
|
name_hint = " ".join(words[:4]) if words else "(binary data)"
|
||||||
|
click.echo(f" {i:2}. [{task_id}] {name_hint}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── session group ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def session():
|
||||||
|
"""Session statistics commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("today")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def session_today(json_mode):
|
||||||
|
"""Show today's focus session summary."""
|
||||||
|
from cli_anything.webpomodoro.core.data import get_full_status
|
||||||
|
data = get_full_status()
|
||||||
|
if not json_mode:
|
||||||
|
click.echo(f"\n 🍅 WebPomodoro 状态")
|
||||||
|
click.echo(f" ─────────────────────────────")
|
||||||
|
timer_info = data.get("timer", {})
|
||||||
|
click.echo(f" 计时器: {timer_info.get('label', 'unknown')}")
|
||||||
|
task_id = timer_info.get("timingTaskId", "")
|
||||||
|
click.echo(f" 当前任务: {task_id[:16]}..." if task_id else " 当前任务: 无")
|
||||||
|
user = data.get("user", {})
|
||||||
|
click.echo(f" 用户: {user.get('name', '')} <{user.get('email', '')}>")
|
||||||
|
click.echo(f" 总番茄数: {data.get('totalPomodoros', 0)}")
|
||||||
|
goals = data.get("goals", [])
|
||||||
|
for g in goals:
|
||||||
|
click.echo(f" 每日目标: {g.get('display', '')}")
|
||||||
|
click.echo(f" 最后同步: {data.get('lastSync', '')}")
|
||||||
|
click.echo()
|
||||||
|
else:
|
||||||
|
click.echo(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
@session.command("history")
|
||||||
|
@click.option("--limit", default=10, help="Number of records")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def session_history(limit, json_mode):
|
||||||
|
"""Show recent Pomodoro session records."""
|
||||||
|
from cli_anything.webpomodoro.core.data import get_recent_pomodoros
|
||||||
|
records = get_recent_pomodoros(limit=limit)
|
||||||
|
if json_mode:
|
||||||
|
click.echo(json.dumps(records, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
click.echo(f" Recent Pomodoro records ({len(records)}):")
|
||||||
|
for i, r in enumerate(records, 1):
|
||||||
|
rid = r.get("id", "")[:24]
|
||||||
|
click.echo(f" {i:2}. {rid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── data group ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def data():
|
||||||
|
"""Raw data access commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@data.command("settings")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def data_settings(json_mode):
|
||||||
|
"""Show app settings and logged-in user info."""
|
||||||
|
from cli_anything.webpomodoro.core.data import get_user_info
|
||||||
|
info = get_user_info()
|
||||||
|
_out(info, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@data.command("goals")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def data_goals(json_mode):
|
||||||
|
"""Show daily goals configuration."""
|
||||||
|
from cli_anything.webpomodoro.core.data import get_goals
|
||||||
|
goals = get_goals()
|
||||||
|
_out(goals, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
@data.command("localstorage")
|
||||||
|
@click.option("--json", "json_mode", is_flag=True)
|
||||||
|
def data_localstorage(json_mode):
|
||||||
|
"""Dump all LocalStorage key-value pairs."""
|
||||||
|
from cli_anything.webpomodoro.utils.webpomodoro_backend import read_localstorage
|
||||||
|
ls = read_localstorage()
|
||||||
|
_out(ls, json_mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ── REPL ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@cli.command("repl")
|
||||||
|
def repl():
|
||||||
|
"""Start interactive REPL session."""
|
||||||
|
try:
|
||||||
|
from cli_anything.webpomodoro.utils.repl_skin import ReplSkin
|
||||||
|
skin = ReplSkin("webpomodoro", version="1.0.0")
|
||||||
|
except Exception:
|
||||||
|
skin = None
|
||||||
|
|
||||||
|
commands = {
|
||||||
|
"timer status": "Show current timer state",
|
||||||
|
"timer start": "Start focus session",
|
||||||
|
"timer pause": "Pause timer",
|
||||||
|
"timer stop": "Stop timer",
|
||||||
|
"timer break": "Start break",
|
||||||
|
"task current": "Show current task",
|
||||||
|
"task list": "List recent tasks",
|
||||||
|
"session today": "Today's summary",
|
||||||
|
"session history": "Recent records",
|
||||||
|
"data settings": "User & app info",
|
||||||
|
"data goals": "Daily goals",
|
||||||
|
"help": "Show this help",
|
||||||
|
"exit": "Exit REPL",
|
||||||
|
}
|
||||||
|
|
||||||
|
if skin:
|
||||||
|
skin.print_banner()
|
||||||
|
skin.info("输入 'help' 查看所有命令,'exit' 退出")
|
||||||
|
else:
|
||||||
|
click.echo("🍅 WebPomodoro REPL — type 'help' or 'exit'")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if skin:
|
||||||
|
try:
|
||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
pt = PromptSession()
|
||||||
|
line = pt.prompt("webpomodoro> ").strip()
|
||||||
|
except Exception:
|
||||||
|
line = input("webpomodoro> ").strip()
|
||||||
|
else:
|
||||||
|
line = input("webpomodoro> ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
click.echo("\nBye!")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line in ("exit", "quit", "q"):
|
||||||
|
click.echo("Bye!")
|
||||||
|
break
|
||||||
|
if line == "help":
|
||||||
|
for cmd, desc in commands.items():
|
||||||
|
click.echo(f" {cmd:<20} {desc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Map REPL commands to Click subcommands
|
||||||
|
args = line.split()
|
||||||
|
try:
|
||||||
|
cli.main(args, standalone_mode=False)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
if skin:
|
||||||
|
skin.error(str(e))
|
||||||
|
else:
|
||||||
|
click.echo(f"Error: {e}")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: cli-anything-webpomodoro
|
||||||
|
Version: 1.0.0
|
||||||
|
Summary: CLI interface for WebPomodoro macOS app — Agent-native Pomodoro control
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: click>=8.0
|
||||||
|
Requires-Dist: prompt_toolkit>=3.0
|
||||||
|
Dynamic: requires-dist
|
||||||
|
Dynamic: requires-python
|
||||||
|
Dynamic: summary
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
setup.py
|
||||||
|
cli_anything/webpomodoro/__init__.py
|
||||||
|
cli_anything/webpomodoro/__main__.py
|
||||||
|
cli_anything/webpomodoro/webpomodoro_cli.py
|
||||||
|
cli_anything/webpomodoro/core/__init__.py
|
||||||
|
cli_anything/webpomodoro/core/data.py
|
||||||
|
cli_anything/webpomodoro/core/timer.py
|
||||||
|
cli_anything/webpomodoro/tests/__init__.py
|
||||||
|
cli_anything/webpomodoro/utils/__init__.py
|
||||||
|
cli_anything/webpomodoro/utils/repl_skin.py
|
||||||
|
cli_anything/webpomodoro/utils/webpomodoro_backend.py
|
||||||
|
cli_anything_webpomodoro.egg-info/PKG-INFO
|
||||||
|
cli_anything_webpomodoro.egg-info/SOURCES.txt
|
||||||
|
cli_anything_webpomodoro.egg-info/dependency_links.txt
|
||||||
|
cli_anything_webpomodoro.egg-info/entry_points.txt
|
||||||
|
cli_anything_webpomodoro.egg-info/requires.txt
|
||||||
|
cli_anything_webpomodoro.egg-info/top_level.txt
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
cli-anything-webpomodoro = cli_anything.webpomodoro.webpomodoro_cli:cli
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
click>=8.0
|
||||||
|
prompt_toolkit>=3.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
cli_anything
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from setuptools import setup, find_namespace_packages
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="cli-anything-webpomodoro",
|
||||||
|
version="1.0.0",
|
||||||
|
description="CLI interface for WebPomodoro macOS app — Agent-native Pomodoro control",
|
||||||
|
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||||
|
install_requires=[
|
||||||
|
"click>=8.0",
|
||||||
|
"prompt_toolkit>=3.0",
|
||||||
|
],
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"cli-anything-webpomodoro=cli_anything.webpomodoro.webpomodoro_cli:cli",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
python_requires=">=3.10",
|
||||||
|
)
|
||||||
@@ -315,3 +315,4 @@
|
|||||||
| 2026-03-12 22:33:45 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 22:33:45 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
| 2026-03-12 23:10:30 | 🔄 卡若AI 同步 2026-03-12 23:10 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 23:10:30 | 🔄 卡若AI 同步 2026-03-12 23:10 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
| 2026-03-12 23:12:15 | 🔄 卡若AI 同步 2026-03-12 23:12 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
| 2026-03-12 23:12:15 | 🔄 卡若AI 同步 2026-03-12 23:12 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
| 2026-03-12 23:20:58 | 🔄 卡若AI 同步 2026-03-12 23:20 | 更新:Cursor规则、水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 |
|
||||||
|
|||||||
@@ -318,3 +318,4 @@
|
|||||||
| 2026-03-12 22:33:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 22:33:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 22:33 | 更新:水桥平台对接、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
| 2026-03-12 23:10:30 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 23:10 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 23:10:30 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 23:10 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
| 2026-03-12 23:12:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 23:12 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
| 2026-03-12 23:12:15 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 23:12 | 更新:运营中枢、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
|
| 2026-03-12 23:20:58 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-12 23:20 | 更新:Cursor规则、水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |
|
||||||
|
|||||||
Reference in New Issue
Block a user