🔄 卡若AI 同步 2026-03-12 23:23 | 更新:卡木、运营中枢工作台 | 排除 >20MB: 11 个

This commit is contained in:
2026-03-12 23:23:53 +08:00
parent aaea6b252c
commit a2e84d2717
19 changed files with 1407 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""cli_anything.webpomodoro — WebPomodoro macOS app CLI interface."""

View File

@@ -0,0 +1,4 @@
from cli_anything.webpomodoro.webpomodoro_cli import cli
if __name__ == "__main__":
cli()

View File

@@ -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

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
[console_scripts]
cli-anything-webpomodoro = cli_anything.webpomodoro.webpomodoro_cli:cli

View File

@@ -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",
)

View File

@@ -315,3 +315,4 @@
| 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: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 个 |

View File

@@ -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 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: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) |