diff --git a/PICOCLAW_MANAGER_API.md b/PICOCLAW_MANAGER_API.md new file mode 100644 index 000000000..24796fa71 --- /dev/null +++ b/PICOCLAW_MANAGER_API.md @@ -0,0 +1,35 @@ +# PicoClaw Manager API + +HTTP API server untuk mengontrol lifecycle PicoClaw gateway process. + +Base URL: `http://{host}:{port}` (default: `http://localhost:8321`) + +## Endpoints + +### `GET /api/health` +Health check server. Tidak perlu auth. +- Response: `{"status": "ok", "service": "picoclaw-manager", "timestamp": "..."}` + +### `GET /api/picoclaw/status` +Cek status PicoClaw gateway: apakah running, PID, uptime, dan 20 baris log terakhir. +- Response: `{"running": true, "pid": 1234, "started_at": "...", "uptime_seconds": 3600, "recent_logs": [...]}` + +### `POST /api/picoclaw/start` +Jalankan PicoClaw gateway. Gagal jika sudah berjalan. +- Response sukses: `{"success": true, "message": "...", "pid": 1234}` +- Response gagal: `{"success": false, "message": "PicoClaw gateway sudah berjalan"}` + +### `POST /api/picoclaw/stop` +Hentikan PicoClaw gateway (SIGTERM → SIGKILL fallback). +- Response: `{"success": true, "message": "PicoClaw gateway berhasil dihentikan (PID: 1234)"}` + +### `POST /api/picoclaw/restart` +Stop lalu start ulang PicoClaw gateway. Bisa dipanggil meskipun gateway sedang tidak berjalan. +- Response: `{"success": true, "message": "...", "pid": 5678}` + +## Contoh Request +```bash +curl http://localhost:8321/api/picoclaw/status +curl -X POST http://localhost:8321/api/picoclaw/start +curl -X POST http://localhost:8321/api/picoclaw/restart +``` diff --git a/picoclaw_manager.py b/picoclaw_manager.py new file mode 100644 index 000000000..a1c6149eb --- /dev/null +++ b/picoclaw_manager.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +PicoClaw Manager Server +────────────────────────── +Lightweight HTTP API to manage PicoClaw process lifecycle. + +Endpoints: + POST /api/picoclaw/restart → Kill & restart PicoClaw gateway + POST /api/picoclaw/start → Start PicoClaw gateway + POST /api/picoclaw/stop → Stop PicoClaw gateway + GET /api/picoclaw/status → Check if PicoClaw gateway is running + GET /api/health → Health check + +Usage: + python3 picoclaw_manager.py # default port 8321 + python3 picoclaw_manager.py --port 9000 # custom port + python3 picoclaw_manager.py --token mysecretkey # with auth token + python3 picoclaw_manager.py --picoclaw-bin /path/bin # custom binary path +""" + +import argparse +import json +import logging +import os +import signal +import subprocess +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from threading import Thread, Lock +from datetime import datetime + +# ── Config ──────────────────────────────────────── +DEFAULT_PORT = 8321 +DEFAULT_PICOCLAW_BIN = os.path.expanduser("~/.local/bin/picoclaw") +DEFAULT_CONFIG_PATH = os.path.expanduser("~/.picoclaw/config.json") + +# ── Logging ─────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s │ %(levelname)-7s │ %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("picoclaw-manager") + + +class PicoClawManager: + """Manages the PicoClaw gateway process lifecycle.""" + + def __init__(self, picoclaw_bin: str, config_path: str): + self.picoclaw_bin = picoclaw_bin + self.config_path = config_path + self._process = None + self._lock = Lock() + self._started_at = None + self._log_tail = [] + self._max_log_lines = 100 + + @property + def is_running(self) -> bool: + with self._lock: + if self._process is None: + return False + return self._process.poll() is None + + def status(self) -> dict: + running = self.is_running + info = { + "running": running, + "pid": self._process.pid if self._process and running else None, + "started_at": self._started_at, + "uptime_seconds": None, + "binary": self.picoclaw_bin, + "recent_logs": self._log_tail[-20:], + } + if running and self._started_at: + delta = datetime.now() - datetime.fromisoformat(self._started_at) + info["uptime_seconds"] = int(delta.total_seconds()) + return info + + def start(self) -> dict: + with self._lock: + if self._process and self._process.poll() is None: + return { + "success": False, + "message": "PicoClaw gateway sudah berjalan", + "pid": self._process.pid, + } + + return self._start_process() + + def stop(self) -> dict: + with self._lock: + if self._process is None or self._process.poll() is not None: + return { + "success": True, + "message": "PicoClaw gateway tidak sedang berjalan", + } + + return self._stop_process() + + def restart(self) -> dict: + with self._lock: + # Stop if running + if self._process and self._process.poll() is None: + self._stop_process() + time.sleep(1) # brief cooldown + + return self._start_process() + + def _start_process(self) -> dict: + """Internal: start the picoclaw gateway (must hold lock).""" + if not os.path.isfile(self.picoclaw_bin): + return { + "success": False, + "message": f"Binary tidak ditemukan: {self.picoclaw_bin}", + } + + cmd = [self.picoclaw_bin, "gateway"] + + env = os.environ.copy() + # Load .env file if exists alongside the binary or in cwd + for env_path in [".env", os.path.join(os.path.dirname(self.picoclaw_bin), ".env")]: + if os.path.isfile(env_path): + self._load_env_file(env_path, env) + log.info("Loaded env from: %s", env_path) + break + + try: + self._log_tail.clear() + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + preexec_fn=os.setsid, # new process group for clean kill + ) + self._started_at = datetime.now().isoformat() + + # Background thread to capture logs + log_thread = Thread( + target=self._read_output, + args=(self._process,), + daemon=True, + ) + log_thread.start() + + log.info( + "✓ PicoClaw gateway started (PID: %d)", self._process.pid + ) + return { + "success": True, + "message": "PicoClaw gateway berhasil dijalankan", + "pid": self._process.pid, + } + except Exception as e: + log.error("✗ Gagal menjalankan PicoClaw: %s", e) + return { + "success": False, + "message": f"Gagal menjalankan PicoClaw: {str(e)}", + } + + def _stop_process(self) -> dict: + """Internal: stop the running process (must hold lock).""" + pid = self._process.pid + try: + # Send SIGTERM to the entire process group + os.killpg(os.getpgid(pid), signal.SIGTERM) + # Wait up to 5 seconds for graceful shutdown + for _ in range(50): + if self._process.poll() is not None: + break + time.sleep(0.1) + else: + # Force kill if still alive + os.killpg(os.getpgid(pid), signal.SIGKILL) + self._process.wait(timeout=3) + + log.info("✓ PicoClaw gateway stopped (PID: %d)", pid) + self._process = None + self._started_at = None + return { + "success": True, + "message": f"PicoClaw gateway berhasil dihentikan (PID: {pid})", + } + except Exception as e: + log.error("✗ Gagal menghentikan PicoClaw: %s", e) + return { + "success": False, + "message": f"Gagal menghentikan process: {str(e)}", + } + + def _read_output(self, process: subprocess.Popen): + """Capture process stdout in background.""" + try: + for line in iter(process.stdout.readline, b""): + decoded = line.decode("utf-8", errors="replace").rstrip() + self._log_tail.append(decoded) + if len(self._log_tail) > self._max_log_lines: + self._log_tail.pop(0) + log.info("[picoclaw] %s", decoded) + except Exception: + pass + + @staticmethod + def _load_env_file(path: str, env: dict): + """Parse a simple .env file into env dict.""" + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + env[key.strip()] = value.strip() + + +# ── HTTP Handler ────────────────────────────────── + +class PicoClawHandler(BaseHTTPRequestHandler): + """REST API handler for PicoClaw management.""" + + manager: PicoClawManager = None + auth_token: str = None + + def do_GET(self): + if self.path == "/api/health": + self._json_response(200, { + "status": "ok", + "service": "picoclaw-manager", + "timestamp": datetime.now().isoformat(), + }) + elif self.path == "/api/picoclaw/status": + if not self._check_auth(): + return + self._json_response(200, self.manager.status()) + else: + self._json_response(404, {"error": "Not found"}) + + def do_POST(self): + if not self._check_auth(): + return + + routes = { + "/api/picoclaw/start": self.manager.start, + "/api/picoclaw/stop": self.manager.stop, + "/api/picoclaw/restart": self.manager.restart, + } + + handler = routes.get(self.path) + if handler: + result = handler() + code = 200 if result.get("success", True) else 500 + self._json_response(code, result) + else: + self._json_response(404, {"error": "Not found"}) + + def _check_auth(self) -> bool: + """Validate Bearer token if auth is configured.""" + if not self.auth_token: + return True + + auth_header = self.headers.get("Authorization", "") + if auth_header == f"Bearer {self.auth_token}": + return True + + self._json_response(401, {"error": "Unauthorized"}) + return False + + def _json_response(self, code: int, data: dict): + body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): + """Handle CORS preflight.""" + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + + def log_message(self, format, *args): + """Route HTTP logs through our logger.""" + log.debug("%s %s", self.client_address[0], format % args) + + +# ── Main ────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="PicoClaw Manager — Process Lifecycle Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Contoh penggunaan: + python3 picoclaw_manager.py + python3 picoclaw_manager.py --port 9000 --token rahasia123 + +Contoh request (curl): + curl http://localhost:8321/api/picoclaw/status + curl -X POST http://localhost:8321/api/picoclaw/start + curl -X POST http://localhost:8321/api/picoclaw/stop + curl -X POST http://localhost:8321/api/picoclaw/restart + +Dengan auth token: + curl -H "Authorization: Bearer rahasia123" http://localhost:8321/api/picoclaw/status + """, + ) + parser.add_argument( + "--port", type=int, default=DEFAULT_PORT, + help=f"Port untuk API server (default: {DEFAULT_PORT})", + ) + parser.add_argument( + "--host", default="0.0.0.0", + help="Bind address (default: 0.0.0.0)", + ) + parser.add_argument( + "--token", + default=os.environ.get("PICOCLAW_MANAGER_TOKEN"), + help="Bearer token untuk autentikasi (opsional, bisa via env PICOCLAW_API_TOKEN)", + ) + parser.add_argument( + "--picoclaw-bin", default=DEFAULT_PICOCLAW_BIN, + help=f"Path ke binary picoclaw (default: {DEFAULT_PICOCLAW_BIN})", + ) + parser.add_argument( + "--config", default=DEFAULT_CONFIG_PATH, + help=f"Path ke config.json (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument( + "--auto-start", action="store_true", + help="Otomatis start PicoClaw gateway saat server dimulai", + ) + + args = parser.parse_args() + + # Wire up the manager + manager = PicoClawManager(args.picoclaw_bin, args.config) + PicoClawHandler.manager = manager + PicoClawHandler.auth_token = args.token + + server = HTTPServer((args.host, args.port), PicoClawHandler) + + # Banner + print() + print(" ┌─────────────────────────────────────────┐") + print(" │ 🦀 PicoClaw Manager Server │") + print(" └─────────────────────────────────────────┘") + print() + print(f" Listening → http://{args.host}:{args.port}") + print(f" Binary → {args.picoclaw_bin}") + print(f" Auth → {'✓ enabled' if args.token else '✗ disabled'}") + print() + print(" Endpoints:") + print(" GET /api/health → Health check") + print(" GET /api/picoclaw/status → Status PicoClaw") + print(" POST /api/picoclaw/start → Start gateway") + print(" POST /api/picoclaw/stop → Stop gateway") + print(" POST /api/picoclaw/restart → Restart gateway") + print() + + if args.auto_start: + log.info("Auto-starting PicoClaw gateway...") + result = manager.start() + log.info("Auto-start: %s", result["message"]) + + # Graceful shutdown + def shutdown_handler(signum, frame): + log.info("Shutting down...") + # Run cleanup in a separate thread to avoid deadlock + # (signal handler runs in main thread, same as serve_forever) + def _cleanup(): + if manager.is_running: + manager.stop() + server.shutdown() + Thread(target=_cleanup, daemon=True).start() + + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + log.info("Server stopped.") + + +if __name__ == "__main__": + main() diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index ecd451ec9..0a2068005 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -7,6 +7,26 @@ import ( "time" ) +// humanDuration formats a time.Duration into a concise human-readable string. +// Examples: "45s", "4m32s", "1h5m", "2h0m". +func humanDuration(d time.Duration) string { + d = d.Round(time.Second) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + if s == 0 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%dm%ds", m, s) + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", h, m) +} + // FallbackChain orchestrates model fallback across multiple candidates. type FallbackChain struct { cooldown *CooldownTracker @@ -111,9 +131,8 @@ func (fc *FallbackChain) Execute( Skipped: true, Reason: FailoverRateLimit, Error: fmt.Errorf( - "provider %s in cooldown (%s remaining)", - candidate.Provider, - remaining.Round(time.Second), + "skipped (cooldown %s remaining)", + humanDuration(remaining), ), }) continue @@ -277,7 +296,11 @@ func (e *FallbackExhaustedError) Error() string { sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts))) for i, a := range e.Attempts { if a.Skipped { - sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model)) + if a.Error != nil { + sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v", i+1, a.Provider, a.Model, a.Error)) + } else { + sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model)) + } } else { sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)", i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond))) diff --git a/setup_picoclaw_manager.sh b/setup_picoclaw_manager.sh new file mode 100644 index 000000000..cde024f7f --- /dev/null +++ b/setup_picoclaw_manager.sh @@ -0,0 +1,237 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════ +# PicoClaw Manager — Installer & Service Manager +# Untuk Armbian / Debian / Ubuntu +# ═══════════════════════════════════════════════════ +set -e + +SERVICE_NAME="picoclaw-manager" +INSTALL_DIR="/opt/picoclaw" +SCRIPT_NAME="picoclaw_manager.py" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +PICOCLAW_BIN="$HOME/.local/bin/picoclaw" +RUN_USER="$(whoami)" + +# ── Warna ───────────────────────────────────────── +R='\033[0;31m' G='\033[0;32m' B='\033[0;34m' +Y='\033[1;33m' C='\033[0;36m' W='\033[1;37m' X='\033[0m' + +banner() { + echo "" + echo -e " ${C}┌──────────────────────────────────────┐${X}" + echo -e " ${C}│${W} 🦀 PicoClaw Service Manager ${C}│${X}" + echo -e " ${C}└──────────────────────────────────────┘${X}" + echo "" +} + +info() { echo -e " ${B}▸${X} $1"; } +success() { echo -e " ${G}✓${X} $1"; } +warn() { echo -e " ${Y}!${X} $1"; } +err() { echo -e " ${R}✗${X} $1"; } + +# ── Install ─────────────────────────────────────── +cmd_install() { + banner + info "Installing ${SERVICE_NAME}..." + echo "" + + # Cari picoclaw_api.py di folder yang sama dengan script ini + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + SOURCE="${SCRIPT_DIR}/${SCRIPT_NAME}" + + if [ ! -f "$SOURCE" ]; then + err "${SCRIPT_NAME} tidak ditemukan di ${SCRIPT_DIR}" + exit 1 + fi + + # Copy script + info "Copying ${SCRIPT_NAME} → ${INSTALL_DIR}/" + sudo mkdir -p "$INSTALL_DIR" + sudo cp "$SOURCE" "${INSTALL_DIR}/${SCRIPT_NAME}" + sudo chmod +x "${INSTALL_DIR}/${SCRIPT_NAME}" + success "Script copied" + + # Copy .env jika ada + if [ -f "${SCRIPT_DIR}/.env" ]; then + sudo cp "${SCRIPT_DIR}/.env" "${INSTALL_DIR}/.env" + success ".env copied" + fi + + # Buat systemd service + info "Creating systemd service..." + sudo tee "$SERVICE_FILE" > /dev/null << UNIT +[Unit] +Description=PicoClaw Manager — Process Lifecycle Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=${RUN_USER} +WorkingDirectory=${INSTALL_DIR} +ExecStart=/usr/bin/python3 ${INSTALL_DIR}/${SCRIPT_NAME} --auto-start --picoclaw-bin ${PICOCLAW_BIN} +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +UNIT + success "Service file created: ${SERVICE_FILE}" + + # Reload & enable + sudo systemctl daemon-reload + sudo systemctl enable "$SERVICE_NAME" + success "Service enabled (auto-start on boot)" + + # Start + sudo systemctl start "$SERVICE_NAME" + success "Service started" + + echo "" + info "Cek status: ${W}$0 status${X}" + info "Lihat log: ${W}$0 logs${X}" + echo "" +} + +# ── Uninstall ───────────────────────────────────── +cmd_uninstall() { + banner + warn "Uninstalling ${SERVICE_NAME}..." + echo "" + + if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + sudo systemctl stop "$SERVICE_NAME" + success "Service stopped" + fi + + if [ -f "$SERVICE_FILE" ]; then + sudo systemctl disable "$SERVICE_NAME" 2>/dev/null || true + sudo rm -f "$SERVICE_FILE" + sudo systemctl daemon-reload + success "Service file removed" + fi + + if [ -d "$INSTALL_DIR" ]; then + read -p " Hapus ${INSTALL_DIR}? [y/N] " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo rm -rf "$INSTALL_DIR" + success "Install directory removed" + fi + fi + + success "Uninstall selesai" + echo "" +} + +# ── Update ──────────────────────────────────────── +cmd_update() { + banner + info "Updating ${SCRIPT_NAME}..." + + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + SOURCE="${SCRIPT_DIR}/${SCRIPT_NAME}" + + if [ ! -f "$SOURCE" ]; then + err "${SCRIPT_NAME} tidak ditemukan di ${SCRIPT_DIR}" + exit 1 + fi + + sudo cp "$SOURCE" "${INSTALL_DIR}/${SCRIPT_NAME}" + sudo chmod +x "${INSTALL_DIR}/${SCRIPT_NAME}" + success "Script updated" + + sudo systemctl restart "$SERVICE_NAME" + success "Service restarted" + echo "" +} + +# ── Service Commands ────────────────────────────── +cmd_start() { sudo systemctl start "$SERVICE_NAME" && success "Started"; } +cmd_stop() { sudo systemctl stop "$SERVICE_NAME" && success "Stopped"; } +cmd_restart() { sudo systemctl restart "$SERVICE_NAME" && success "Restarted"; } + +cmd_status() { + banner + echo -e " ${W}Systemd Status:${X}" + echo "" + sudo systemctl status "$SERVICE_NAME" --no-pager -l 2>/dev/null || warn "Service not found" + echo "" + + # Cek API health + if command -v curl &> /dev/null; then + echo -e " ${W}API Health Check:${X}" + echo "" + RESPONSE=$(curl -s http://localhost:8321/api/health 2>/dev/null || echo "unreachable") + if echo "$RESPONSE" | grep -q '"ok"'; then + success "API is responding: ${RESPONSE}" + else + warn "API not responding" + fi + echo "" + + echo -e " ${W}PicoClaw Gateway:${X}" + echo "" + GW_STATUS=$(curl -s http://localhost:8321/api/picoclaw/status 2>/dev/null || echo "{}") + RUNNING=$(echo "$GW_STATUS" | grep -o '"running": *[a-z]*' | head -1 | awk '{print $2}') + PID=$(echo "$GW_STATUS" | grep -o '"pid": *[0-9]*' | head -1 | awk '{print $2}') + if [ "$RUNNING" = "true" ]; then + success "Gateway running (PID: ${PID})" + else + warn "Gateway not running" + fi + echo "" + fi +} + +cmd_logs() { + journalctl -u "$SERVICE_NAME" -f --no-pager +} + +cmd_logs_history() { + journalctl -u "$SERVICE_NAME" --no-pager -n "${1:-50}" +} + +# ── Usage ───────────────────────────────────────── +cmd_help() { + banner + echo -e " ${W}Usage:${X} $0 " + echo "" + echo -e " ${C}Setup${X}" + echo " install Install & enable service" + echo " uninstall Remove service & files" + echo " update Update script & restart" + echo "" + echo -e " ${C}Service${X}" + echo " start Start the API server" + echo " stop Stop the API server" + echo " restart Restart the API server" + echo " status Show status & health check" + echo "" + echo -e " ${C}Logs${X}" + echo " logs Follow live logs" + echo " logs-history Show last 50 log lines" + echo "" +} + +# ── Dispatch ────────────────────────────────────── +case "${1:-help}" in + install) cmd_install ;; + uninstall) cmd_uninstall ;; + update) cmd_update ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs ;; + logs-history) cmd_logs_history "$2" ;; + help|--help|-h) cmd_help ;; + *) + err "Unknown command: $1" + cmd_help + exit 1 + ;; +esac diff --git a/workspace/skills/prayer-times/SKILL.md b/workspace/skills/prayer-times/SKILL.md new file mode 100644 index 000000000..ea83ba9a9 --- /dev/null +++ b/workspace/skills/prayer-times/SKILL.md @@ -0,0 +1,145 @@ +--- +name: prayer-times +description: Jadwal sholat otomatis — reminder waktu sholat, sahur, dan buka puasa. Data dari jadwalsholatorg. +metadata: {"nanobot":{"emoji":"🕌"}} +--- + +# Prayer Times / Jadwal Sholat + +Reminder otomatis waktu sholat. Data dari [jadwalsholatorg](https://github.com/lakuapik/jadwalsholatorg). +Notifikasi dikirim ke **Telegram + ntfy**. + +## Script + +Lokasi: `skills/prayer-times/scripts/prayer_notify.sh` + +```bash +chmod +x skills/prayer-times/scripts/prayer_notify.sh +``` + +| Command | Fungsi | +|---------|--------| +| `setup ` | Set kota + fetch data awal **(wajib pertama kali)** | +| `fetch` | Download JSON bulan ini | +| `today` | Tampilkan jadwal hari ini | +| `schedule [prayers...]` | Output `nama\|HH:MM\|detik` untuk sholat yang belum lewat | +| `notify