@clawhub-enawareness-7d10a2fd12
フリーランス・個人事業主のための確定申告サポート。所得分類、経費整理、控除確認、消費税・インボイス制度の知識アシスタント。API接続なし、マイナンバー取扱なし。
---
name: kakutei-shinkoku
description: フリーランス・個人事業主のための確定申告サポート。所得分類、経費整理、控除確認、消費税・インボイス制度の知識アシスタント。API接続なし、マイナンバー取扱なし。
homepage: https://github.com/ENAwareness/kakutei-shinkoku
metadata:
openclaw:
emoji: 🇯🇵
tags:
- japan
- tax
- freelancer
- kakutei-shinkoku
- 確定申告
- accounting
- invoice
---
# 確定申告アシスタント 🇯🇵
フリーランス・個人事業主のための確定申告サポートスキル。所得の分類、経費の整理、控除の確認、消費税・インボイス制度の理解をサポートします。
**知識アシスタント**であり、申告ツールではありません。税理士に渡す資料の準備や、e-Taxでの自己申告を自信を持って行えるようサポートします。
e-Tax接続なし。マイナンバー取扱なし。安全に使えます。
## ⚠️ 免責事項
本スキルは日本の税法に基づく一般的な税務知識を提供するものであり、税理士資格に基づく税務相談ではありません。計算結果は概算です。申告前に必ず税理士または国税庁ウェブサイトでご確認ください。税法は毎年改正されます。
## 対象ユーザー
- 初めて確定申告をするフリーランスの方
- 副業で雑所得の申告が必要な方
- 税理士に相談する前に書類を整理したい方
- インボイス制度の対応に悩んでいる方
## 機能
### 所得区分
| 区分 | 対象 |
|------|------|
| 事業所得 | 開業届を出したフリーランス |
| 雑所得 | 副業、単発の業務委託 |
| 給与所得 | 会社員の給料 |
| 譲渡所得 | 株式・暗号資産・不動産の売却益 |
| 配当所得 | 株式の配当金 |
| 不動産所得 | 賃貸収入 |
### 経費管理
| 勘定科目 | 具体例 |
|----------|--------|
| 消耗品費 | 文房具、10万円未満の備品 |
| 通信費 | 携帯電話、インターネット(事業分) |
| 旅費交通費 | 電車、タクシー、出張の航空券 |
| 減価償却費 | PC、カメラ、10万円以上の設備 |
| 地代家賃 | オフィス賃料、自宅オフィス分 |
| 水道光熱費 | 電気・ガス・水道(事業分) |
| 外注費 | 外部委託の支払い |
| 接待交際費 | 取引先との食事、贈答品(記録必須) |
| 保険料 | 事業用保険 |
| 新聞図書費 | 専門書、研修、サブスクリプション |
| 広告宣伝費 | ウェブサイト、広告、販促物 |
### 家事按分
自宅兼オフィスの経費を事業分と生活分に按分する方法:
- **面積按分**: (作業スペース㎡ ÷ 総面積㎡)× 経費
- **時間按分**: (事業使用時間 ÷ 総時間)× 経費
- 按分方法の根拠を記録しておくこと(税務調査対策)
### 控除一覧
| 控除 | 上限額 |
|------|--------|
| 基礎控除 | 48万円(所得2,400万円以下) |
| 青色申告特別控除 | 65万円(e-Tax+複式簿記) |
| 社会保険料控除 | 全額 |
| 国民年金 | 全額 |
| 国民健康保険 | 全額 |
| 小規模企業共済 | 年間84万円まで |
| iDeCo(個人型確定拠出年金) | 年間81.6万円まで(第1号) |
| 配偶者控除 | 38万円まで |
| 扶養控除 | 38万〜63万円(扶養親族1人あたり) |
| 医療費控除 | 10万円超過分(または所得の5%超過分) |
| 生命保険料控除 | 12万円まで |
| 地震保険料控除 | 5万円まで |
| ふるさと納税(寄附金控除) | 寄附額 − 2,000円 |
### 消費税・インボイス制度
**消費税の申告義務:**
- 基準期間の課税売上高 > 1,000万円 → 課税事業者(義務)
- 適格請求書発行事業者に登録済み → 課税事業者(義務)
- 任意登録 → 選択可能
**インボイス制度のポイント:**
- 登録番号の形式: T + 13桁
- 適格請求書を発行しないと取引先が仕入税額控除を受けられない
- **2割特例**: 納税額を売上税額の20%に軽減(2029年まで、新規登録者対象)
- **簡易課税**: 課税売上高5,000万円以下で選択可能
### 税務カレンダー
| 日付 | イベント | アクション |
|------|---------|-----------|
| 1月1日 | 課税年度開始 | — |
| 1〜2月 | 書類準備 | 領収書整理、収入記録の確認 |
| 2月16日 | **確定申告受付開始** | e-Tax・郵送・窓口で提出可能 |
| 3月15日 | **所得税の申告・納付期限** | 所得税の申告・納税(日曜の場合は翌月曜) |
| 3月31日 | **消費税の申告・納付期限** | 個人事業主の消費税申告・納税 |
| 4月中旬〜下旬 | 振替納税(所得税) | 口座引き落とし(例:4月23日頃) |
| 4月末 | 振替納税(消費税) | 口座引き落とし(例:4月30日頃) |
| 6月 | 住民税通知 | 確定申告に基づく税額通知 |
**青色申告承認申請書の提出期限:**
- 既存事業者が青色に切り替える場合 → **その年の3月15日まで**
- 1月1日〜1月15日に新規開業 → **その年の3月15日まで**
- 1月16日以降に新規開業 → **開業日から2ヶ月以内**
※ 振替納税で残高不足により引き落としできなかった場合、延滞税は**元の申告期限の翌日**から発生します(振替日からではありません)。
### 申告方法の比較
| 方法 | メリット | デメリット |
|------|---------|-----------|
| **e-Tax(Web版)** | 青色65万円控除、24時間、即時送信 | マイナンバーカード+リーダー必要 |
| **e-Tax(ID/PW方式)** | カードリーダー不要 | 税務署で事前登録が必要 |
| **郵送** | 技術不要 | 青色控除が55万円に制限 |
| **税務署窓口** | 職員のサポートあり | 2〜3月は混雑 |
### 青色申告 vs 白色申告
| 項目 | 青色申告 | 白色申告 |
|------|---------|---------|
| 特別控除 | 10万〜65万円 | なし |
| 損失繰越 | 3年間 | 不可 |
| 少額減価償却 | 30万円まで一括経費 | 不可 |
| 専従者給与 | 可(届出必要) | 制限あり |
| 記帳義務 | 複式簿記(65万円の場合) | 簡易帳簿 |
| 届出 | 開業届 + 青色申告承認申請書 | 開業届のみ |
## 使い方
```
フリーランスの確定申告を手伝ってください
```
```
自宅オフィスの家事按分はどう計算しますか?
```
```
インボイス制度に登録すべきですか?
```
```
事業所得800万円、経費200万円での所得税を概算してください
```
```
開業したばかりです。どの届出が必要ですか?
```
## 所得税率(2026年)
| 課税所得 | 税率 | 控除額 |
|---------|------|--------|
| 〜194.9万円 | 5% | 0円 |
| 195万〜329.9万円 | 10% | 97,500円 |
| 330万〜694.9万円 | 20% | 427,500円 |
| 695万〜899.9万円 | 23% | 636,000円 |
| 900万〜1,799.9万円 | 33% | 1,536,000円 |
| 1,800万〜3,999.9万円 | 40% | 2,796,000円 |
| 4,000万円〜 | 45% | 4,796,000円 |
**加算:** 復興特別所得税 = 所得税額 × 2.1%
**加算:** 住民税 = 課税所得 × 約10%
## 制限事項
- 表示されている税率・ルールは2026年税法に基づいています。毎年ご確認ください
- 本スキルはe-Taxや政府システムに接続しません
- 本スキルはマイナンバーを取り扱い・保存しません
- 複雑なケース(海外所得、相続、不動産)は税理士にご相談ください
- 消費税の計算は簡略化されています
FILE:MEMORY.md
# 確定申告メモリー
## ユーザー税務プロフィール
| 項目 | 内容 |
|------|------|
| 申告種類 | 青色 / 白色 |
| 所得区分 | 事業所得 / 雑所得 / 混合 |
| インボイス登録 | あり / なし |
| 対象年度 | |
| 税理士利用 | あり / なし |
## 経費ログ
| 日付 | 勘定科目 | 金額 | 内容 | 領収書 |
|------|----------|------|------|--------|
| | | | | |
## 年次サマリー
| 項目 | 金額 |
|------|------|
| 売上合計 | |
| 経費合計 | |
| 所得(売上−経費) | |
| 控除合計 | |
| 課税所得 | |
| 所得税概算 | |
FILE:README.md
# 確定申告アシスタント 🇯🇵
フリーランス・個人事業主のための確定申告サポートスキル。所得の分類、経費の整理、控除の確認、消費税・インボイス制度の理解を手助けします。
## 特徴
- **知識アシスタント** — e-Tax接続なし、マイナンバー取扱なし、安全に使えます
- **日本税制特化** — 所得税率、青色申告、インボイス制度など2026年税制に対応
- **バイリンガル対応** — 日本語メイン、英語でも質問可能(在日外国人フリーランサー向け)
- **依存関係ゼロ** — プロンプトのみ、スクリプトなし、APIキー不要
## ⚠️ 免責事項
本スキルは一般的な税務知識を提供するものであり、税理士資格に基づく税務相談ではありません。計算結果は概算です。申告前に必ず税理士または国税庁のウェブサイトでご確認ください。
## インストール
```
clawhub install enawareness/kakutei-shinkoku
```
## 使い方
```
フリーランスの確定申告を手伝ってください
```
```
自宅オフィスの家事按分はどうやって計算しますか?
```
```
インボイス制度に登録すべきですか?
```
```
事業所得800万円、経費200万円での所得税を概算してください
```
## 対応する内容
| カテゴリ | 内容 |
|---------|------|
| **所得区分** | 事業所得、雑所得、給与所得、譲渡所得、配当所得、不動産所得 |
| **経費管理** | 消耗品費、通信費、旅費交通費、減価償却費、地代家賃、外注費など |
| **控除** | 基礎控除、青色申告特別控除、社会保険料、iDeCo、ふるさと納税など |
| **消費税** | インボイス制度、2割特例、簡易課税、免税事業者の判定 |
| **青色 vs 白色** | 特別控除額、損失繰越、家族給与、記帳要件の比較 |
| **税務カレンダー** | 所得税(3/15)・消費税(3/31)申告期限、振替納税日程、青色届出期限 |
## 対象ユーザー
- 初めて確定申告をするフリーランスの方
- 副業で雑所得の申告が必要な方
- 税理士に相談する前に書類を整理したい方
## ライセンス
MIT
Structured security and quality audit framework for AI agent skills. Teaches you what to check before installing any skill.
---
name: skill-audit-framework
description: Structured security and quality audit framework for AI agent skills. Teaches you what to check before installing any skill.
homepage: https://github.com/ENAwareness/skill-auditor
metadata:
openclaw:
emoji: 🔍
tags:
- security
- audit
- skills
- safety
- review
- trust
---
# Skill Auditor 🔍
A structured framework that teaches your agent how to audit ClawHub and MCP skills before you install them. Not a scanner — a systematic review methodology.
Unlike automated scanners that give false confidence, Skill Auditor walks through what matters: permissions, behavior, credentials, and persistence — so you understand exactly what a skill will do on your system.
## Why this exists
- 13.4% of ClawHub skills have critical security issues (Snyk ToxicSkills study)
- 341 malicious skills were found in a single campaign (ClawHavoc incident, Feb 2026)
- Automated scanners can miss context-dependent threats and provide false security
- Understanding what you're installing is better than trusting a green checkmark
## How to use
Ask your agent to audit any skill before installing:
```
Audit this skill before I install it: [skill-name or URL]
```
```
Review the security of @author/skill-name on ClawHub
```
```
I want to install [skill]. Is it safe?
```
## Audit Framework
The agent follows a 6-domain checklist. Each domain produces a PASS / WARN / FAIL verdict.
### 1. Identity & Provenance
- [ ] Author has a GitHub profile with other projects
- [ ] Skill has a public source repository (not ClawHub-only)
- [ ] Repository has commit history (not a single-commit dump)
- [ ] Author identity is consistent across platforms
- **FAIL if**: No source repo, no author history, single-commit repo
### 2. Permission & Scope Analysis
- [ ] `requires.env` only lists credentials the skill actually uses
- [ ] No credentials unrelated to the skill's purpose
- [ ] File access limited to workspace directory
- [ ] No requests for system-wide permissions
- **FAIL if**: Requests credentials beyond stated purpose, accesses files outside workspace
### 3. Behavior vs Description Match
- [ ] Every file in the skill serves the stated purpose
- [ ] No network calls to undeclared endpoints
- [ ] No data exfiltration patterns (sending user data to external URLs)
- [ ] Script behavior matches what SKILL.md describes
- **FAIL if**: Hidden functionality, undeclared network calls, description mismatch
### 4. Credential & Secret Handling
- [ ] API keys stored in env vars, not hardcoded
- [ ] No credentials logged or written to non-protected files
- [ ] OAuth tokens have minimal required scopes
- [ ] Cached tokens stored in workspace, not system-wide
- **FAIL if**: Hardcoded secrets, credentials in logs, excessive OAuth scopes
### 5. Persistence & Side Effects
- [ ] Files written only within workspace boundaries
- [ ] No system-level modifications (crontab, /etc/, systemd)
- [ ] No auto-start or background processes installed
- [ ] Uninstall is clean (no orphaned files or processes)
- **FAIL if**: System modifications, persistent background processes, dirty uninstall
### 6. Dependency & Supply Chain
- [ ] Dependencies are well-known packages (not obscure single-author libs)
- [ ] No `curl | bash` or `curl | python` install patterns
- [ ] No post-install scripts that download additional code
- [ ] Package versions are pinned (not `latest`)
- **FAIL if**: Unknown dependencies, pipe-to-shell installs, unpinned versions
## Output Format
The agent produces a structured report:
```
## Skill Audit Report: [skill-name]
Author: [name] | Source: [repo URL or "ClawHub only"]
Version: [X.Y.Z] | Files: [count] | Scripts: [count]
### Verdicts
| Domain | Verdict | Notes |
|---------------------------|---------|----------------------|
| Identity & Provenance | PASS | |
| Permission & Scope | WARN | Requests broad perms |
| Behavior vs Description | PASS | |
| Credential Handling | PASS | |
| Persistence & Side Effects| FAIL | Writes to /etc/ |
| Dependency & Supply Chain | PASS | |
### Overall: ⚠️ WARN — Review flagged items before installing
### Flagged Items
1. [Domain]: [Specific issue and recommendation]
### What to Ask the Author
1. Why does the skill need [permission X]?
2. Can [flagged behavior] be made opt-in?
```
## Limitations
- This is a review framework, not a deterministic scanner
- The agent reads and reasons about skill files — it cannot execute or sandbox them
- Always read the source code yourself for high-privilege skills
- A PASS verdict means no issues were found, not that the skill is guaranteed safe
## Trust Hierarchy
When evaluating skill trust, consider this hierarchy:
1. **Highest trust**: Open-source on GitHub + active maintainer + ClawHub Benign scan + you read the code
2. **Moderate trust**: GitHub repo exists + ClawHub Benign scan + reasonable permissions
3. **Low trust**: ClawHub-only (no source repo) + Suspicious scan + broad permissions
4. **No trust**: No source, no author history, requests unrelated credentials
FILE:MEMORY.md
# Skill Auditor Memory
## Audit History
Track audited skills here for reference:
| Date | Skill | Author | Verdict | Key Findings |
|------|-------|--------|---------|--------------|
| | | | | |
## Known Malicious Patterns
Common patterns found in malicious ClawHub skills (from ClawHavoc + ToxicSkills research):
- **Atomic Stealer distribution**: Skills that download macOS malware via encoded URLs
- **Credential harvesting**: Skills requesting unrelated API keys and exfiltrating them
- **Typosquatting**: Skills mimicking popular skill names with slight misspellings
- **Obfuscated payloads**: Base64-encoded scripts that decode and execute at runtime
- **Shadow dependencies**: package.json pointing to attacker-controlled npm packages
FILE:README.md
# Skill Audit Framework 🔍
Structured security and quality audit framework for ClawHub/MCP skills. Teaches your agent how to review skills before you install them.
## Why
- 13.4% of ClawHub skills have critical security issues ([Snyk ToxicSkills](https://snyk.io/blog/toxicskills-malicious-ai-agent-skills-clawhub/))
- 341 malicious skills found in a single campaign ([ClawHavoc](https://www.koi.ai/blog/clawhavoc-341-malicious-clawedbot-skills-found-by-the-bot-they-were-targeting))
- A green checkmark is not enough
## What it does
Not a scanner — a **review methodology**. Your agent walks through 6 audit domains and produces a structured PASS/WARN/FAIL report:
1. **Identity & Provenance** — Who made this? Is there source code?
2. **Permission & Scope** — What does it ask for? Is it proportionate?
3. **Behavior vs Description** — Does it do what it says?
4. **Credential Handling** — How are secrets stored and used?
5. **Persistence & Side Effects** — Does it modify your system?
6. **Dependency & Supply Chain** — Are dependencies trustworthy?
## Install
```
clawhub install enawareness/skill-audit-framework
```
## Usage
```
Audit this skill before I install it: @author/skill-name
```
```
Review the security of [skill URL]
```
## No dependencies
Pure prompt skill. No scripts, no system packages, no API keys required.
## License
MIT
Science-based running coach with HD visual training plans and Garmin sync. For all runners — from 5K fitness to marathon.
---
name: run-coach
description: Science-based running coach with HD visual training plans and Garmin sync. For all runners — from 5K fitness to marathon.
metadata:
openclaw:
requires:
env:
- TELEGRAM_BOT_TOKEN
- TELEGRAM_CHAT_ID
anyBins:
- node
primaryEnv: TELEGRAM_BOT_TOKEN
emoji: 🏃
tags:
- running
- fitness
- coaching
- garmin
- telegram
- training
---
# Run Coach 🏃
A science-based running coach that works through Telegram. Logs your training, sends visual training plans as HD photo albums, syncs Garmin data, and coaches you with data-driven feedback.
Works for any runner — whether you're jogging 3x a week for fitness or training for your first marathon.
## What it does
- **Training log** — Record every run: distance, pace, heart rate, feel score
- **Visual plans** — Training plans rendered as HD images sent to your Telegram Photos tab
- **Trend tracking** — Pace, heart rate, mileage trends over weeks and months
- **Garmin sync** — Pull data automatically from Garmin Connect (optional)
- **Injury monitoring** — Tracks knee, plantar fascia, IT band signals
- **4-week reviews** — Automatic progress analysis every 4 weeks
- **Race prep** — Structured build-up for 5K, 10K, half marathon, or full marathon
## Setup
### 1. Required environment variables
Set these in your OpenClaw config or `.env`:
```
TELEGRAM_BOT_TOKEN=your_bot_token_from_botfather
TELEGRAM_CHAT_ID=your_telegram_user_id
```
To get your `TELEGRAM_CHAT_ID`: send `/start` to your bot — it will show `Your Telegram user id: XXXXXXXXXX`.
### 2. Fill in your profile
Edit `MEMORY.md` with your personal data: age, running history, injuries, goal race.
### 3. Optional: Garmin integration
> ⚠️ **Known limitation:** Garmin periodically changes their login flow. The `garminconnect` library may stop working after a Garmin-side update until the library maintainers patch it. Check [garminconnect releases](https://github.com/cyberjunky/python-garminconnect/releases) if sync suddenly fails. The rest of the bot works fine without Garmin — you can always log runs manually.
>
> ⚖️ **Legal note:** `garminconnect` uses Garmin's unofficial API (no official API exists). This may technically conflict with Garmin's Terms of Service. It accesses only your own data and Garmin has not acted against individual users, but use at your own discretion.
Set these additional env vars for automatic Garmin Connect sync:
```
GARMIN_EMAIL=your_garmin_email
GARMIN_PASSWORD=your_garmin_password
```
Then install the Python library:
```bash
pip install garminconnect
```
### 4. Optional: Visual training plans (self-hosted)
The image pipeline (visual training plans sent as Telegram photos) requires additional system dependencies. Install these in your container/environment:
```bash
# CJK fonts + emoji
apt-get install -y fonts-noto-cjk fonts-noto-color-emoji
fc-cache -f
# Playwright Chromium (chrome-headless-shell)
npx playwright install chromium
```
Without these, the coaching features still work — plans will be sent as text instead of images.
## How to use
### Log a run (text)
```
I ran 8km today, pace 5:30/km, avg HR 135, felt good
```
### Log a run (screenshot — no Garmin needed)
Take a screenshot of your watch, running app (Strava, Nike Run Club, Apple Watch, etc.), or any device that shows your run data, and send it directly to the bot. The bot's built-in vision capability (LLM multimodal input) extracts the numbers — no OCR code is included in this skill.
```
[send screenshot of your watch/app summary]
Please log this run and give me feedback
```
Works with any device — the LLM reads the image natively, no integration required.
### Request a visual training plan
```
Send me this week's training plan as an image
```
The bot calls `training/text-to-image.sh` and sends the plan as a Telegram photo album — appears in your Photos tab, full quality.
### Get a weekly summary
```
Summarize my training this week
```
### Sync Garmin data (optional)
```
Sync my Garmin data and give me feedback on today's run
```
## Image pipeline
Training plans are rendered using Playwright + `chrome-headless-shell` and sent via Telegram `sendMediaGroup` (photo album). This means:
- ✅ Appears in Telegram Photos tab
- ✅ No compression on text
- ✅ CJK (Chinese/Japanese/Korean) and emoji supported
- ✅ Weekly plans sent as 2-photo album: run days + cross-training days
> **Note:** The image pipeline requires Telegram. If you use a different channel (Discord, WhatsApp), the coaching features still work — only the visual plan sending is Telegram-specific.
## Training methodology
Based on three evidence-based frameworks:
| Framework | Application |
|-----------|-------------|
| **Daniels VDOT** | Pace zones derived from test results, not guesses |
| **MAF heart rate** | Easy runs at truly easy effort — conversational pace |
| **FIRST structure** | Quality sessions: Interval + Tempo + Long run |
| **80/20 polarized** | 80% easy volume, 20% quality — prevents overtraining |
Safety rule: pain >4/10 means stop. Always.
## Files included
```
run-coach/
├── SKILL.md # This file — manifest + instructions
├── MEMORY.md # User profile template (fill in your data)
├── training/
│ ├── send-plan.sh # HTML → screenshot → Telegram album
│ ├── text-to-image.sh # Text → HTML → screenshot → Telegram album
│ ├── screenshot.mjs # Playwright screenshot engine
│ └── send-album.mjs # Telegram sendPhoto (single) or sendMediaGroup (multi)
└── garmin/ # Optional Garmin integration
├── garmin-sync.py
└── garmin-query.py
```
## Agent instructions
When the user asks to send content as an image, use exec to run:
```bash
# Option A: convert text to image
bash training/text-to-image.sh "Title" "Content with \n line breaks"
# Option B: weekly plan as 2-photo album (run days + cross-training days)
bash training/send-plan.sh "Week X Plan" training/week-XX-run.html training/week-XX-cross.html
# Option C: single HTML (today's plan, summaries — not for weekly plans)
bash training/send-plan.sh "Title" training/week-XX.html
```
**Do NOT** use canvas, browser, or Playwright directly. Only these two scripts.
If a script errors, report the exact error to the user — do not silently switch to text.
For all numeric calculations (pace conversions, HR zones, VDOT), use exec:
```bash
node -e "console.log(42.195 / (3*60+55))"
```
Never calculate in your head.
FILE:MEMORY.md
# MEMORY.md — Runner Profile
Fill in your personal data. This file is auto-loaded every session.
## Profile
- Name: (your name)
- Age: (e.g. 30)
- Location: (city, country)
- Running experience: (e.g. beginner / 2 years casual / completed a half marathon)
- Injury history: (e.g. none / left knee pain in 2023 / plantar fasciitis)
- MAF heart rate ceiling: (180 minus your age — e.g. 150 for age 30)
- Training start date: (when you started with this bot)
## Goal
- Target race: (e.g. 5K park run / 10K in June / first half marathon / full marathon)
- Target finish time: (e.g. sub-30 for 5K / just finish)
- Timeline: (e.g. 12 weeks / 6 months)
## Training Preferences
- Training days per week: (e.g. 3 — Mon / Wed / Fri)
- Preferred run time: (e.g. morning / evening)
- Cross-training available: (e.g. none / home exercises / gym)
- Data source: (e.g. Garmin Forerunner 235 / Apple Watch / manual)
- Language: (e.g. English / Chinese)
## Personal Bests
| Distance | Time | Date | Notes |
|----------|------|------|-------|
| (fill in) | | | |
## Race History
| Date | Race | Finish Time | Notes |
|------|------|-------------|-------|
| (fill in) | | | |
## Training Log Summary
(Bot will update this automatically after each run)
## Key Decisions
(Bot logs important plan changes here)
## Image Sending Note
- Always use exec + training/text-to-image.sh or training/send-plan.sh for images
- Never use canvas / browser / Playwright directly
FILE:garmin/garmin-query.py
#!/usr/bin/env python3
"""Garmin data query for run-coach bot.
Pure stdlib — no third-party deps.
Reads JSON files written by garmin-sync.py.
"""
import json
import sys
from datetime import date, timedelta
from pathlib import Path
# Workspace is the parent of this script's directory
GARMIN_DIR = Path(__file__).parent
SUMMARY_FILE = GARMIN_DIR / "summary.json"
ACTIVITIES_DIR = GARMIN_DIR / "activities"
def load_summary():
if not SUMMARY_FILE.exists():
return None
with open(SUMMARY_FILE, encoding="utf-8") as f:
return json.load(f)
def cmd_recent(n=5):
"""Show recent activities."""
summary = load_summary()
if not summary:
print("No Garmin data yet. Run garmin-sync on host first.")
return
activities = summary.get("recent_activities", [])[:n]
if not activities:
print("No recent running activities found.")
return
print(f"=== Recent Runs (last {len(activities)}) ===\n")
for a in activities:
print(f"📅 {a['date']} {a.get('name', '')}")
print(f" Distance: {a['distance_km']}km | Time: {a['duration_min']}min | Pace: {a.get('avg_pace', '?')}/km")
print(f" HR: avg {a.get('avg_hr', '?')} / max {a.get('max_hr', '?')} | Cadence: {a.get('avg_cadence', '?')}spm")
print(f" Calories: {a.get('calories', '?')}")
print()
def cmd_weekly():
"""Show weekly training summary."""
summary = load_summary()
if not summary:
print("No Garmin data yet.")
return
activities = summary.get("recent_activities", [])
# Filter to last 7 days
week_ago = (date.today() - timedelta(days=7)).isoformat()
week_runs = [a for a in activities if a.get("date", "") >= week_ago]
if not week_runs:
print("No runs in the past 7 days.")
return
total_km = sum(a.get("distance_km", 0) for a in week_runs)
total_min = sum(a.get("duration_min", 0) for a in week_runs)
avg_hr = [a["avg_hr"] for a in week_runs if a.get("avg_hr")]
avg_hr_val = round(sum(avg_hr) / len(avg_hr)) if avg_hr else "?"
print(f"=== Weekly Summary ({week_ago} ~ {date.today().isoformat()}) ===\n")
print(f"Runs: {len(week_runs)}")
print(f"Total distance: {total_km:.1f}km")
print(f"Total time: {total_min:.0f}min ({total_min/60:.1f}h)")
print(f"Avg HR: {avg_hr_val}")
print()
for a in week_runs:
print(f" {a['date']}: {a['distance_km']}km @ {a.get('avg_pace', '?')}/km (HR {a.get('avg_hr', '?')})")
def cmd_stats():
"""Show daily stats and training metrics."""
summary = load_summary()
if not summary:
print("No Garmin data yet.")
return
print(f"=== Garmin Stats (synced: {summary.get('last_sync', '?')}) ===\n")
daily = summary.get("daily_stats")
if daily:
print(f"📊 Daily ({daily.get('date', '?')}):")
print(f" Steps: {daily.get('steps', '?')}")
print(f" Resting HR: {daily.get('resting_hr', '?')}")
print(f" HR range: {daily.get('min_hr', '?')} - {daily.get('max_hr', '?')}")
print(f" Stress avg: {daily.get('stress_avg', '?')}")
print(f" Body Battery: {daily.get('body_battery_low', '?')} - {daily.get('body_battery_high', '?')}")
print()
metrics = summary.get("training_metrics", {})
if metrics:
print("🏋️ Training Metrics:")
if metrics.get("vo2max"):
print(f" VO2max: {metrics['vo2max']}")
if metrics.get("fitness_age"):
print(f" Fitness Age: {metrics['fitness_age']}")
if metrics.get("training_status"):
print(f" Status: {metrics['training_status']}")
if metrics.get("training_load"):
print(f" Load: {metrics['training_load']}")
preds = metrics.get("race_predictions", {})
if preds:
print("\n🏁 Race Predictions:")
for dist, time_str in preds.items():
print(f" {dist}: {time_str}")
def cmd_activity(activity_date):
"""Show detailed activity by date."""
if not ACTIVITIES_DIR.exists():
print("No activity files found.")
return
matches = sorted(ACTIVITIES_DIR.glob(f"{activity_date}*.json"))
if not matches:
print(f"No activity found for {activity_date}")
return
for filepath in matches:
with open(filepath, encoding="utf-8") as f:
data = json.load(f)
s = data.get("summary", {})
print(f"=== {s.get('activityName', 'Activity')} ({s.get('startTimeLocal', '')}) ===\n")
print(f"Distance: {s.get('distance', 0)/1000:.2f}km")
print(f"Duration: {s.get('duration', 0)/60:.1f}min")
print(f"Avg Pace: {format_pace(s.get('distance', 0), s.get('duration', 0))}/km")
print(f"HR: avg {s.get('averageHR', '?')} / max {s.get('maxHR', '?')}")
print(f"Cadence: {s.get('averageRunningCadenceInStepsPerMinute', '?')}spm")
print(f"Elevation: +{s.get('elevationGain', 0):.0f}m / -{s.get('elevationLoss', 0):.0f}m")
# Splits
splits = data.get("splits", {})
laps = splits.get("lapDTOs", [])
if laps:
print(f"\n--- Splits ({len(laps)} laps) ---")
for i, lap in enumerate(laps, 1):
dist = lap.get("distance", 0) / 1000
dur = lap.get("duration", 0)
pace = format_pace(lap.get("distance", 0), dur)
hr = lap.get("averageHR", "?")
print(f" Lap {i}: {dist:.2f}km | {pace}/km | HR {hr}")
# HR zones
zones = data.get("hr_zones", [])
if zones:
print("\n--- HR Zones ---")
for z in zones:
zn = z.get("zoneNumber", "?")
secs = z.get("secsInZone", 0)
if secs > 0:
print(f" Zone {zn}: {secs/60:.1f}min")
print()
def cmd_json():
"""Output raw summary as JSON (for bot parsing)."""
summary = load_summary()
if summary:
print(json.dumps(summary, ensure_ascii=False, indent=2))
else:
print("{}")
def format_pace(distance_m, duration_s):
if not distance_m or distance_m == 0:
return "?"
pace_s = duration_s / (distance_m / 1000)
minutes = int(pace_s // 60)
seconds = int(pace_s % 60)
return f"{minutes}:{seconds:02d}"
def main():
if len(sys.argv) < 2:
print("Usage: garmin-query.py <command> [args]")
print()
print("Commands:")
print(" recent [n] - Show recent N activities (default 5)")
print(" weekly - Weekly training summary")
print(" stats - Daily stats + training metrics")
print(" activity DATE - Detailed activity (e.g. 2026-03-18)")
print(" json - Raw summary JSON")
return
cmd = sys.argv[1]
if cmd == "recent":
n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
cmd_recent(n)
elif cmd == "weekly":
cmd_weekly()
elif cmd == "stats":
cmd_stats()
elif cmd == "activity":
if len(sys.argv) < 3:
print("Usage: garmin-query.py activity YYYY-MM-DD")
return
cmd_activity(sys.argv[2])
elif cmd == "json":
cmd_json()
else:
print(f"Unknown command: {cmd}")
if __name__ == "__main__":
main()
FILE:garmin/garmin-sync.py
#!/usr/bin/env python3
"""Garmin Connect data sync for run-coach bot.
Reads credentials from environment variables GARMIN_EMAIL and GARMIN_PASSWORD.
Writes JSON activity data to the garmin/ directory for the bot to query.
"""
import json
import os
import sys
from datetime import date, timedelta
from pathlib import Path
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
# Workspace is always the directory containing this script's parent
_WORKSPACE = Path(__file__).parent.parent
GARTH_HOME = str(_WORKSPACE / "garmin" / ".garth")
GARMIN_DIR = _WORKSPACE / "garmin"
ACTIVITIES_DIR = GARMIN_DIR / "activities"
SUMMARY_FILE = GARMIN_DIR / "summary.json"
def load_credentials():
"""Load Garmin credentials from environment variables."""
email = os.environ.get("GARMIN_EMAIL", "").strip()
password = os.environ.get("GARMIN_PASSWORD", "").strip()
if not email or not password:
print("Error: GARMIN_EMAIL and GARMIN_PASSWORD environment variables must be set.")
print("Add them to your OpenClaw config or .env file:")
print(" [email protected]")
print(" GARMIN_PASSWORD=your_password")
sys.exit(1)
return email, password
def get_client():
"""Authenticate with Garmin Connect, using cached tokens when possible."""
email, password = load_credentials()
client = Garmin(email=email, password=password)
# Try token-based login first
if Path(GARTH_HOME).exists():
try:
client.login(GARTH_HOME)
print("Logged in with cached token.")
return client
except Exception:
print("Cached token expired, re-authenticating...")
# Fresh login
try:
client.login()
client.garth.dump(GARTH_HOME)
print("Fresh login successful, token cached.")
return client
except GarminConnectAuthenticationError as e:
print(f"Auth failed: {e}")
sys.exit(1)
except GarminConnectConnectionError as e:
print(f"Connection error: {e}")
sys.exit(1)
except GarminConnectTooManyRequestsError as e:
print(f"Rate limited: {e}")
sys.exit(1)
def sync_activities(client, days=7):
"""Fetch recent running activities and save as JSON."""
end_date = date.today().isoformat()
start_date = (date.today() - timedelta(days=days)).isoformat()
print(f"Fetching activities from {start_date} to {end_date}...")
activities = client.get_activities_by_date(
startdate=start_date,
enddate=end_date,
activitytype="running",
)
print(f"Found {len(activities)} running activities.")
saved = []
for activity in activities:
activity_id = activity.get("activityId")
activity_date = activity.get("startTimeLocal", "")[:10]
# Fetch detailed data
detail = {}
try:
detail["summary"] = activity
detail["splits"] = client.get_activity_splits(activity_id)
detail["hr_zones"] = client.get_activity_hr_in_timezones(activity_id)
except Exception as e:
print(f" Warning: couldn't fetch details for {activity_id}: {e}")
detail["summary"] = activity
# Save individual activity file
filename = f"{activity_date}_{activity_id}.json"
filepath = ACTIVITIES_DIR / filename
with open(filepath, "w", encoding="utf-8") as f:
json.dump(detail, f, ensure_ascii=False, indent=2, default=str)
saved.append({
"date": activity_date,
"id": activity_id,
"name": activity.get("activityName", ""),
"distance_km": round(activity.get("distance", 0) / 1000, 2),
"duration_min": round(activity.get("duration", 0) / 60, 1),
"avg_hr": activity.get("averageHR"),
"max_hr": activity.get("maxHR"),
"avg_pace": format_pace(activity.get("distance", 0), activity.get("duration", 0)),
"calories": activity.get("calories"),
"avg_cadence": activity.get("averageRunningCadenceInStepsPerMinute"),
"file": filename,
})
print(f" Saved: {filename}")
return saved
def sync_daily_stats(client):
"""Fetch today's daily stats."""
today = date.today().isoformat()
try:
stats = client.get_stats_and_body(today)
return {
"date": today,
"steps": stats.get("totalSteps"),
"distance_km": round((stats.get("totalDistanceMeters") or 0) / 1000, 2),
"calories_total": stats.get("totalKilocalories"),
"calories_active": stats.get("activeKilocalories"),
"resting_hr": stats.get("restingHeartRate"),
"min_hr": stats.get("minHeartRate"),
"max_hr": stats.get("maxHeartRate"),
"stress_avg": stats.get("averageStressLevel"),
"body_battery_high": stats.get("bodyBatteryChargedValue"),
"body_battery_low": stats.get("bodyBatteryDrainedValue"),
}
except Exception as e:
print(f"Warning: couldn't fetch daily stats: {e}")
return None
def sync_training_metrics(client):
"""Fetch training status, VO2max, race predictions."""
today = date.today().isoformat()
metrics = {}
try:
max_metrics = client.get_max_metrics(today)
# Returns a list, take first element
if isinstance(max_metrics, list) and max_metrics:
max_metrics = max_metrics[0]
generic = max_metrics.get("generic", {}) if max_metrics else {}
metrics["vo2max"] = generic.get("vo2MaxPreciseValue")
metrics["fitness_age"] = generic.get("fitnessAge")
except Exception as e:
print(f"Warning: VO2max not available (FR235 may not support): {e}")
try:
training = client.get_training_status(today)
if training:
metrics["training_status"] = training.get("trainingStatusKey")
metrics["training_load"] = training.get("trainingLoad")
except Exception as e:
print(f"Warning: training status not available: {e}")
try:
preds = client.get_race_predictions()
if preds:
race_predictions = {}
for race in preds:
dist_km = race.get("raceDistanceInMeters", 0) / 1000
time_s = race.get("raceTimeinSeconds", 0)
if dist_km > 0 and time_s > 0:
h = int(time_s // 3600)
m = int((time_s % 3600) // 60)
s = int(time_s % 60)
label = f"{dist_km:.0f}km"
race_predictions[label] = f"{h}:{m:02d}:{s:02d}"
metrics["race_predictions"] = race_predictions
except Exception as e:
print(f"Warning: race predictions not available: {e}")
return metrics
def format_pace(distance_m, duration_s):
"""Calculate pace in min/km format."""
if not distance_m or distance_m == 0:
return None
pace_s = duration_s / (distance_m / 1000)
minutes = int(pace_s // 60)
seconds = int(pace_s % 60)
return f"{minutes}:{seconds:02d}"
def build_summary(recent_activities, daily_stats, training_metrics):
"""Build summary JSON combining all data."""
summary = {
"last_sync": date.today().isoformat(),
"recent_activities": recent_activities,
"daily_stats": daily_stats,
"training_metrics": training_metrics,
}
return summary
def main():
days = 14
if len(sys.argv) > 1:
try:
days = int(sys.argv[1])
except ValueError:
pass
# Ensure directories exist
ACTIVITIES_DIR.mkdir(parents=True, exist_ok=True)
print(f"=== Garmin Sync ({date.today().isoformat()}) ===")
client = get_client()
# Save token after successful operations
recent = sync_activities(client, days=days)
daily = sync_daily_stats(client)
training = sync_training_metrics(client)
# Persist token
client.garth.dump(GARTH_HOME)
# Write summary
summary = build_summary(recent, daily, training)
with open(SUMMARY_FILE, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, default=str)
print(f"\nSummary written to {SUMMARY_FILE}")
print(f"Activities: {len(recent)}")
if daily:
print(f"Resting HR: {daily.get('resting_hr')}")
if training.get("vo2max"):
print(f"VO2max: {training['vo2max']}")
print("=== Done ===")
if __name__ == "__main__":
main()
FILE:training/screenshot.mjs
// screenshot.mjs — Render an HTML file to a full-page HD PNG using Playwright
//
// Uses chrome-headless-shell (not full Chrome) to avoid SIGTRAP crash
// when running as a non-root user inside Docker containers.
//
// Usage: node screenshot.mjs <input.html> <output.png>
import { chromium } from '/app/node_modules/playwright-core/index.mjs';
import { readdirSync, existsSync } from 'fs';
import { join } from 'path';
const [,, htmlFile, outFile] = process.argv;
if (!htmlFile || !outFile) {
console.log('Usage: node screenshot.mjs <input.html> <output.png>');
process.exit(1);
}
// Auto-detect chrome-headless-shell binary path using native fs only
function findHeadlessShell() {
const base = '/home/node/.cache/ms-playwright';
if (!existsSync(base)) return null;
for (const dir of readdirSync(base)) {
const candidate = join(base, dir, 'chrome-headless-shell-linux64', 'chrome-headless-shell');
if (existsSync(candidate)) return candidate;
}
return null;
}
const executablePath = findHeadlessShell();
if (!executablePath) {
console.error('chrome-headless-shell not found. Run setup.sh first.');
process.exit(1);
}
const browser = await chromium.launch({
headless: true,
executablePath,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const context = await browser.newContext({
viewport: { width: 1200, height: 800 },
deviceScaleFactor: 2 // 2400px output width — sharp text, within Telegram's 10000px total limit
});
const page = await context.newPage();
await page.goto('file://' + htmlFile, { waitUntil: 'networkidle' });
await page.screenshot({ path: outFile, type: 'png', fullPage: true });
await browser.close();
console.log('done: ' + outFile);
FILE:training/send-album.mjs
// send-album.mjs — Send PNG(s) as Telegram photo(s)
//
// Single image: sendPhoto (appears in Telegram Photos tab).
// Falls back to sendDocument if image exceeds Telegram's dimension limit.
// Multiple images: sendMediaGroup (album, up to 10 photos).
//
// Usage: node send-album.mjs <caption> <chatId> <botToken> <img1.png> [img2.png ...]
import { readFileSync } from 'fs';
const [,, caption, chatId, botToken, ...pngFiles] = process.argv;
if (!pngFiles.length) {
console.log('Usage: node send-album.mjs <caption> <chatId> <botToken> <img1.png> [img2.png ...]');
process.exit(1);
}
const API = `https://api.telegram.org/botbotToken`;
if (pngFiles.length === 1) {
// Single image: try sendPhoto, fallback to sendDocument
const form = new FormData();
form.append('chat_id', chatId);
form.append('caption', caption);
form.append('photo', new Blob([readFileSync(pngFiles[0])], { type: 'image/png' }), 'photo.png');
const res = await fetch(`API/sendPhoto`, { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) {
if (data.description?.includes('PHOTO_INVALID_DIMENSIONS') || data.description?.includes('photo is too big')) {
console.log('Photo too large, sending as document...');
const fallbackForm = new FormData();
fallbackForm.append('chat_id', chatId);
fallbackForm.append('caption', caption);
fallbackForm.append('document', new Blob([readFileSync(pngFiles[0])], { type: 'image/png' }), 'plan.png');
const fallbackRes = await fetch(`API/sendDocument`, { method: 'POST', body: fallbackForm });
const fallbackData = await fallbackRes.json();
if (!fallbackData.ok) throw new Error(fallbackData.description);
console.log('Sent as document (full quality)');
} else {
throw new Error(data.description);
}
} else {
console.log('Photo sent');
}
} else {
// Multiple images: sendMediaGroup
const form = new FormData();
form.append('chat_id', chatId);
const mediaJson = pngFiles.map((f, i) => ({
type: 'photo',
media: `attach://photoi`,
...(i === 0 ? { caption } : {})
}));
form.append('media', JSON.stringify(mediaJson));
pngFiles.forEach((f, i) => {
form.append(`photoi`, new Blob([readFileSync(f)], { type: 'image/png' }), `photoi.png`);
});
const res = await fetch(`API/sendMediaGroup`, { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(data.description);
console.log(`Album sent (pngFiles.length photos)`);
}
FILE:training/send-plan.sh
#!/bin/bash
# send-plan.sh — Screenshot HTML(s) and send as Telegram photo(s)
#
# Usage: send-plan.sh <caption> <html1.html> [html2.html ...]
# Multiple HTMLs are screenshotted individually and sent as a photo album.
#
# Credentials from environment (set in OpenClaw config or .env):
# TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
#
# Example (split weekly plan):
# send-plan.sh "Week 5 Plan" week-05-run.html week-05-cross.html
# Example (single plan):
# send-plan.sh "Today's Session" today.html
set -e
CAPTION="$1"
shift
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
BOT_TOKEN="TELEGRAM_BOT_TOKEN"
CHAT_ID="TELEGRAM_CHAT_ID"
if [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ]; then
echo "Error: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set as environment variables"
exit 1
fi
if [ -z "$CAPTION" ] || [ $# -eq 0 ]; then
echo "Usage: send-plan.sh <caption> <html1.html> [html2.html ...]"
exit 1
fi
PNG_FILES=()
for HTML_FILE in "$@"; do
BASENAME=$(basename "$HTML_FILE" .html)
DIR=$(dirname "$HTML_FILE")
PNG_FILE="DIR/BASENAME-hd.png"
echo "Generating screenshot: $(basename $HTML_FILE) → $(basename $PNG_FILE)"
node "SCRIPT_DIR/screenshot.mjs" "$HTML_FILE" "$PNG_FILE"
PNG_FILES+=("$PNG_FILE")
done
echo "Sending #PNG_FILES[@] image(s)..."
node "SCRIPT_DIR/send-album.mjs" "$CAPTION" "$CHAT_ID" "$BOT_TOKEN" "PNG_FILES[@]"
FILE:training/text-to-image.sh
#!/bin/bash
# text-to-image.sh — Convert plain text/markdown to a styled dark-mode image and send via Telegram
# Usage: text-to-image.sh "Title" "Content (supports \\n line breaks and markdown)"
set -e
TITLE="$1"
CONTENT="$2"
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
# Credentials from environment (set in OpenClaw config or .env)
BOT_TOKEN="TELEGRAM_BOT_TOKEN"
CHAT_ID="TELEGRAM_CHAT_ID"
if [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ]; then
echo "Error: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set as environment variables"
exit 1
fi
if [ -z "$TITLE" ] || [ -z "$CONTENT" ]; then
echo "Usage: text-to-image.sh \"Title\" \"Content\""
exit 1
fi
TEMP_HTML="SCRIPT_DIR/today.html"
node -e '
const title = process.argv[1];
let content = process.argv[2];
content = content.replace(/\\n/g, "\n");
const lines = content.split("\n").map(l => {
l = l.trim();
if (!l) return "<br>";
if (l.startsWith("##")) return `<h3 style="color:#64b5f6;margin:18px 0 8px">l.replace(/^#+\s*/, "")</h3>`;
if (l.startsWith("#")) return `<h2 style="color:#fff;margin:20px 0 10px">l.replace(/^#+\s*/, "")</h2>`;
if (l.startsWith("- ") || l.startsWith("• ")) return `<div style="margin:4px 0 4px 16px">• l.replace(/^[-•]\s*/, "")</div>`;
if (/^\d+\./.test(l)) return `<div style="margin:4px 0 4px 16px">l</div>`;
if (/^[⚠⛔△]/.test(l)) return `<div style="background:#2a2215;border-radius:8px;padding:10px 14px;margin:8px 0;color:#ffd54f">l</div>`;
if (/^[💡📌✅🔥]/.test(l)) return `<div style="background:#1a2230;border-radius:8px;padding:10px 14px;margin:8px 0;color:#90caf9">l</div>`;
return `<div style="margin:4px 0">l</div>`;
}).join("\n");
const html = `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:"Noto Sans CJK SC","Noto Color Emoji","Segoe UI",sans-serif; background:#0f0f0f; color:#e0e0e0; padding:40px; width:800px; font-size:15px; line-height:1.8; }
.header { background:linear-gradient(135deg,#1a4a6e,#2d7ab4); border-radius:16px; padding:24px 30px; margin-bottom:24px; }
.header h1 { font-size:24px; color:#fff; }
.content { background:#1a1a1a; border-radius:14px; padding:24px; border-left:4px solid #2d7ab4; }
</style></head>
<body>
<div class="header"><h1>title</h1></div>
<div class="content">lines</div>
</body></html>`;
require("fs").writeFileSync(process.argv[3], html);
' "$TITLE" "$CONTENT" "$TEMP_HTML"
echo "HTML generated, taking screenshot..."
bash "SCRIPT_DIR/send-plan.sh" "$TITLE" "$TEMP_HTML"