Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,29 @@ bash bin/cheer --epic
bash bin/cheer --stats
bash bin/cheer --preview
bash bin/cheer --list
bash bin/cheer --doctor
bash bin/cheer --why < tests/fixtures/taskcompleted-short.json
```

`bin/cheer` supports four flags:
`bin/cheer` supports six flags:

- `--epic` — force epic mode (plays all six animations in sequence)
- `--stats` — print total triggers, milestones reached, and last trigger time
- `--preview [name]` — play an animation without a hook trigger; `name` is optional (random if omitted)
- `--list` — list all available animations and languages
- `--doctor` — run read-only install/runtime/config diagnostics; exits non-zero when any hard failure is found
- `--why` — explain the current celebration decision in plain text without animation, voice, or state mutation

## Diagnostics

`cheer --doctor` prints grouped `PASS`, `WARN`, and `FAIL` lines for config safety, runtime mode resolution, assets, catalogs, and optional user files.

- Exit code `0`: only `PASS` / `WARN`
- Exit code non-zero: one or more `FAIL` lines

`cheer --why` explains a single celebration decision in plain text. It reads the same hook JSON payload as the normal runtime path, or uses a deterministic short-task probe when stdin is empty.

Both commands are read-only diagnostic paths: they do not append history, update stats, write cooldown state, play animation, or emit voice.

### Run animations individually

Expand Down Expand Up @@ -170,6 +185,8 @@ bash scripts/voices/cheer_ja.sh
- `CHEERER_INTENSITY=soft` keeps quick wins lighter; `high` makes celebration output more energetic, including animated `Stop` hooks in `CHEERER_MODE=auto`.
- Messages are selected from per-language catalogs and avoid immediate repeats when possible.

Built-in locale catalogs are validated in tests so every supported language keeps the same reachable celebration coverage for the current policy rules. If a locale loses a required `(tier, mood)` path, the test suite fails before release.

## Testing

```bash
Expand Down
19 changes: 18 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Claude Code が `/plugin enable cheerer` 中に設定入力を表示した場合
- `CHEERER_INTENSITY=soft` は軽い完了を控えめにし、`high` は祝福をより勢いよくします。`CHEERER_MODE=auto` では `Stop` Hook のアニメーションも有効になります。
- メッセージは言語ごとのカタログから選び、直近の重複をできるだけ避けます。

同梱のロケールカタログはテストで継続的に検証されており、すべてのサポート言語が現在のポリシーに必要な応援カバレッジを維持することが保証されます。いずれかのロケールで必須の `(tier, mood)` パスが欠けた場合、リリース前にテストスイートが失敗します。

## 🚀 直接実行

```bash
Expand All @@ -180,14 +182,29 @@ bash bin/cheer --epic
bash bin/cheer --stats
bash bin/cheer --preview
bash bin/cheer --list
bash bin/cheer --doctor
bash bin/cheer --why < tests/fixtures/taskcompleted-short.json
```

`bin/cheer` は4つのフラグをサポートしています
`bin/cheer` は6つのフラグをサポートしています

- `--epic` — Epic モードを強制(6つのアニメーションを連続再生)
- `--stats` — 総トリガー数、到達済みマイルストーン、最後のトリガー時刻を表示
- `--preview [name]` — Hook トリガーなしでアニメーションを再生。`name` は省略可能(省略時ランダム)
- `--list` — 利用可能なアニメーションと言語一覧を表示
- `--doctor` — 読み取り専用のインストール/ランタイム/設定診断を実行。重大な問題があれば非ゼロで終了
- `--why` — アニメーション・音声・状態変更を一切行わず、現在の応援決定を平文で説明

## 🩺 診断

`cheer --doctor` は設定の安全性、ランタイムモード、アセット、カタログ、オプションファイルについて `PASS`、`WARN`、`FAIL` の行をグループ単位で出力します。

- 終了コード `0`:`PASS` / `WARN` のみ
- 終了コード非ゼロ:`FAIL` が1件以上

`cheer --why` は単一の応援決定を平文で説明します。通常のランタイムパスと同じ Hook JSON ペイロードを読み込み、stdin が空の場合は内蔵の短タスクプローブを使用します。

どちらのコマンドも読み取り専用の診断パスです。履歴の追記、統計の更新、クールダウン状態の書き込み、アニメーション再生、音声出力のいずれも行いません。

## テスト

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Set in your shell profile (`~/.bashrc`, `~/.zshrc`) or `.claude/settings.json`:
- `CHEERER_INTENSITY=soft` keeps quick wins lighter; `high` makes celebration output more energetic, including animated `Stop` hooks in `CHEERER_MODE=auto`.
- Messages are selected from per-language catalogs and avoid immediate repeats when possible.

Built-in locale catalogs are validated in tests so every supported language keeps the same reachable celebration coverage for the current policy rules. If a locale loses a required `(tier, mood)` path, the test suite fails before release.

## 🚀 Direct usage

From a repo checkout:
Expand All @@ -182,14 +184,29 @@ bash bin/cheer --epic
bash bin/cheer --stats
bash bin/cheer --preview
bash bin/cheer --list
bash bin/cheer --doctor
bash bin/cheer --why < tests/fixtures/taskcompleted-short.json
```

`bin/cheer` supports four flags:
`bin/cheer` supports six flags:

- `--epic` — force epic mode (plays all six animations in sequence)
- `--stats` — print total triggers, milestones reached, and last trigger time
- `--preview [name]` — play an animation without a hook trigger; `name` is optional (random if omitted)
- `--list` — list all available animations and languages
- `--doctor` — run read-only install/runtime/config diagnostics; exits non-zero when any hard failure is found
- `--why` — explain the current celebration decision in plain text without animation, voice, or state mutation

## Diagnostics

`cheer --doctor` prints grouped `PASS`, `WARN`, and `FAIL` lines for config safety, runtime mode resolution, assets, catalogs, and optional user files.

- Exit code `0`: only `PASS` / `WARN`
- Exit code non-zero: one or more `FAIL` lines

`cheer --why` explains a single celebration decision in plain text. It reads the same hook JSON payload as the normal runtime path, or uses a deterministic short-task probe when stdin is empty.

Both commands are read-only diagnostic paths: they do not append history, update stats, write cooldown state, play animation, or emit voice.

## Testing

Expand Down
19 changes: 18 additions & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ chmod +x ~/.cheerer/bin/cheer
- `CHEERER_INTENSITY=soft` 会让轻量完成更克制,`high` 会让庆祝更有冲劲,包括在 `CHEERER_MODE=auto` 下让 `Stop` Hook 也播放动画。
- 文案按语言目录选择,并尽量避免连续重复。

内置各语言目录在测试中持续验证,确保每种语言都能覆盖当前策略所需的全部庆祝路径。如果某个语言目录缺少必要的 `(tier, mood)` 组合,测试套件会在发布前报错拦截。

## 🚀 直接使用

在仓库目录中可直接运行:
Expand All @@ -182,14 +184,29 @@ bash bin/cheer --epic
bash bin/cheer --stats
bash bin/cheer --preview
bash bin/cheer --list
bash bin/cheer --doctor
bash bin/cheer --why < tests/fixtures/taskcompleted-short.json
```

`bin/cheer` 支持四个 flag:
`bin/cheer` 支持六个 flag:

- `--epic` —— 强制 Epic 模式(依次播放六段动画)
- `--stats` —— 输出总触发次数、已达成里程碑、最后一次触发时间
- `--preview [name]` —— 不触发 Hook 直接播放动画;`name` 可选,省略则随机
- `--list` —— 列出所有可用动画和语言
- `--doctor` —— 执行只读的安装/运行时/配置诊断;存在硬错误时以非零状态退出
- `--why` —— 以纯文本说明当前庆祝决策,不播放动画、不触发语音、不修改任何状态

## 🩺 诊断

`cheer --doctor` 以分组方式输出 `PASS`、`WARN`、`FAIL` 行,涵盖配置安全性、运行时模式、资源文件、语言目录和可选用户文件。

- 退出码 `0`:仅含 `PASS` / `WARN`
- 退出码非零:存在至少一条 `FAIL`

`cheer --why` 以纯文本解释单次庆祝决策。它读取与正常运行时相同的 Hook JSON 数据,若 stdin 为空则使用内置的短任务探针。

两个命令均为只读诊断路径:不追加历史、不更新统计、不写入冷却状态、不播放动画、不触发语音。

## 测试

Expand Down
65 changes: 65 additions & 0 deletions bin/cheer
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Flags:
--preview [name] Preview an animation (random if name omitted)
--list List available animations and languages
--config Show current configuration
--doctor Run read-only diagnostics and report PASS/WARN/FAIL
--why Explain what cheerer would do for a given payload (read-only)
--disable Disable cheerer (persists across sessions)
--enable Re-enable cheerer
--version Print version
Expand Down Expand Up @@ -90,6 +92,69 @@ if [[ "${1:-}" == "--config" ]]; then
_cheerer_config
fi

_cheerer_doctor() {
. "$SCRIPT_DIR/scripts/lib/catalog.sh"
. "$SCRIPT_DIR/scripts/lib/doctor.sh"

config_load_file "$(config_file_path)"
config_apply_defaults
config_resolve_runtime_flags

CHEERER_DATA_DIR="$(config_data_dir)"
CHEERER_ROOT="$SCRIPT_DIR"
ANIM_DIR="$SCRIPT_DIR/scripts/animations"
VOICE_DIR="$SCRIPT_DIR/scripts/voices"

doctor_reset
doctor_check_config
doctor_check_runtime
doctor_check_assets
doctor_check_catalog
doctor_check_optional_files
doctor_print_report

local _ec
_ec="$(doctor_exit_code)"
exit "$_ec"
}

if [[ "${1:-}" == "--doctor" ]]; then
_cheerer_doctor
fi

_cheerer_why() {
. "$SCRIPT_DIR/scripts/lib/state.sh"
. "$SCRIPT_DIR/scripts/lib/context.sh"
. "$SCRIPT_DIR/scripts/lib/policy.sh"
. "$SCRIPT_DIR/scripts/lib/render.sh"
. "$SCRIPT_DIR/scripts/lib/catalog.sh"
. "$SCRIPT_DIR/scripts/lib/explain.sh"

config_load_file "$(config_file_path)"
config_apply_defaults
config_resolve_runtime_flags

CHEERER_DATA_DIR="$(config_data_dir)"
CHEERER_ROOT="$SCRIPT_DIR"
ANIM_DIR="$SCRIPT_DIR/scripts/animations"
VOICE_DIR="$SCRIPT_DIR/scripts/voices"
HISTORY_FILE="$CHEERER_DATA_DIR/history.log"
STATS_FILE="$CHEERER_DATA_DIR/stats.json"

# Read optional payload from stdin (non-blocking, 1s timeout)
local _why_payload=""
if read -r -t 1 _why_payload 2>/dev/null; then
:
fi

explain_run "${_why_payload:-}"
exit 0
}

if [[ "${1:-}" == "--why" ]]; then
_cheerer_why
fi

_cheerer_stats() {
config_ensure_data_dir
STATS_FILE="$CHEERER_DATA_DIR/stats.json"
Expand Down
95 changes: 95 additions & 0 deletions scripts/lib/catalog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/bin/bash

catalog_path_for_lang() {
local lang="$1"
printf '%s/scripts/messages/catalog_%s.tsv' "${CHEERER_ROOT:-$PWD}" "$lang"
}

# Print the required tier|mood pairs, one per line.
catalog_required_pairs() {
cat << 'EOF'
quick|gentle
quick|hype
quick|steady
quick|cozy
solid|steady
solid|rapid_fire
solid|cozy
solid|streak
solid|triumphant
big|triumphant
big|streak
big|hype
legendary|milestone
EOF
}

# Validate a single catalog file at the given path.
# Returns 0 on success; writes errors to stderr and returns 1 on any problem.
catalog_validate_file() {
local path="$1"
local errors=0
local row_num=0
local tier mood message_id message_text extra
local seen_ids=""

if [[ ! -f "$path" ]]; then
printf 'catalog_validate_file: file not found: %s\n' "$path" >&2
return 1
fi

while IFS='|' read -r tier mood message_id message_text extra; do
row_num=$((row_num + 1))

if [[ -z "$tier$mood$message_id$message_text${extra:-}" ]]; then
continue
fi

if [[ -n "${extra:-}" ]] || [[ -z "$tier" ]] || [[ -z "$mood" ]] || [[ -z "$message_id" ]] || [[ -z "$message_text" ]]; then
printf 'catalog_validate_file: malformed row %s in %s\n' "$row_num" "$path" >&2
errors=$((errors + 1))
continue
fi

if [[ "$seen_ids" == *"|${message_id}|"* ]]; then
printf 'catalog_validate_file: duplicate message_id %s in %s\n' "$message_id" "$path" >&2
errors=$((errors + 1))
else
seen_ids="${seen_ids}|${message_id}|"
fi
done < "$path"

local pair
while IFS= read -r pair; do
[[ -z "$pair" ]] && continue
local req_tier req_mood
req_tier="${pair%%|*}"
req_mood="${pair#*|}"
if ! grep -qF "${req_tier}|${req_mood}|" "$path"; then
printf 'catalog_validate_file: missing required pair %s in %s\n' "$pair" "$path" >&2
errors=$((errors + 1))
fi
done <<< "$(catalog_required_pairs)"

[[ "$errors" -eq 0 ]]
}

# Validate the catalog for a single language.
catalog_validate_lang() {
local lang="$1"
local path
path="$(catalog_path_for_lang "$lang")"
catalog_validate_file "$path"
}

# Validate all shipped locale catalogs.
catalog_validate_all() {
local lang
local errors=0
for lang in en es ja ko zh; do
if ! catalog_validate_lang "$lang"; then
errors=$((errors + 1))
fi
done
[[ "$errors" -eq 0 ]]
}
20 changes: 18 additions & 2 deletions scripts/lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,39 @@ config_file_path() {
# Safe value characters: letters, digits, and _-./: @ space only.
# This explicitly blocks inline command injection such as:
# CHEERER_LANG=en; rm -rf /
#
# Side effect: sets CONFIG_LOAD_STATUS to one of:
# missing — file does not exist
# ignored — file exists but failed safety checks
# accepted — file was sourced successfully
# ---------------------------------------------------------------------------
CONFIG_LOAD_STATUS="missing"

config_load_file() {
local _file="${1:-}"
CONFIG_LOAD_STATUS="missing"
[[ -n "$_file" ]] || return 0
[[ -f "$_file" ]] || return 0
if [[ ! -f "$_file" ]]; then
CONFIG_LOAD_STATUS="missing"
return 0
fi

# Must have at least one CHEERER_*= line
grep -qE '^[[:space:]]*CHEERER_[A-Z_]+=' "$_file" 2>/dev/null || return 0
if ! grep -qE '^[[:space:]]*CHEERER_[A-Z_]+=' "$_file" 2>/dev/null; then
CONFIG_LOAD_STATUS="ignored"
return 0
fi

# Must NOT contain any line that is not: blank, comment, or CHEERER_*= assignment
# Value portion is restricted to safe characters: no ;|&$`(){}<>!\
if grep -qvE '^[[:space:]]*(CHEERER_[A-Z_]+=[A-Za-z0-9_./:@ -]*|#.*|[[:space:]]*)$' "$_file" 2>/dev/null; then
CONFIG_LOAD_STATUS="ignored"
return 0
fi

# Safe to source
. "$_file"
CONFIG_LOAD_STATUS="accepted"
}

# ---------------------------------------------------------------------------
Expand Down
Loading
Loading