Skill: chloepark85/kr-holiday-cli (version 0.1.0)

--- name: kr-holiday-cli description: Korean public holidays, business-day calculator, and lunar↔solar converter (CLI). Check if a date is a Korean public holiday (including 대체공휴일), list holidays in a year/month, compute next/previous business days, count business days in a range, add signed business-day offsets, convert between Korean lunar and solar dates (including 윤달), render a monthly calendar. Use when a user asks about Korean holidays 공휴일, 설날, 추석, 대체공휴일, 평일 계산, 영업일, 음력 양력 변환, or scheduling that must skip Korean holidays. Zero API key. license: MIT --- # Korean Holiday & Business-Day CLI Zero-config CLI for Korean public holidays, business-day math, and Korean lunar↔solar conversion. No API key. No network required at runtime. - Public holidays powered by the [`holidays`](https://pypi.org/project/holidays/) package (Korean names, substitute holidays / 대체공휴일 included). - Lunar↔solar powered by [`korean-lunar-calendar`](https://pypi.org/project/korean-lunar-calendar/) (KASI astronomical tables, supports 윤달). ## When to use Trigger this skill for any of: - Is a given date a Korean public holiday? ("2026-05-05 공휴일이야?") - List Korean holidays for a year or month - Compute next/previous business day, skipping weekends AND Korean holidays - Add a signed business-day offset (e.g., invoice due in +10 business days from order date) - Count business days between two dates (payroll, SLA, delivery ETA) - Convert solar ↔ Korean lunar dates (음력 생일 lookup, 설날·추석 date lookup) - Render a monthly calendar view with holidays marked Do **not** use for: - Regional / religious holidays that aren't recognized as national public holidays (예: 성탄절 제외 종교 행사) - Company-specific working calendars (add your own per-company overrides outside this skill) - Realtime election-day announcements (bundled rules reflect statutory holidays; ad-hoc presidential decree holidays may lag until the `holidays` package updates) ## Install ```bash pip install -r scripts/requirements.txt ``` Dependencies: `holidays`, `korean-lunar-calendar`. ## Usage All commands are sub-commands of `scripts/kr_holiday.py`. Output is JSON (UTF-8, Korean preserved). Dates accept `YYYY-MM-DD`, `YYYYMMDD`, `YYYY/MM/DD`, or `YYYY.MM.DD`. ### Check a date ```bash python scripts/kr_holiday.py is-holiday 2026-05-05 # → {"date":"2026-05-05","weekday":"Tue","is_weekend":false,"is_holiday":true,"is_business_day":false,"names":["어린이날"]} ``` ### List holidays in a year (optionally filter by month) ```bash python scripts/kr_holiday.py list --year 2026 python scripts/kr_holiday.py list --year 2026 --month 5 ``` ### Next / previous business day ```bash python scripts/kr_holiday.py next-business-day 2026-05-02 # skip Sat → 2026-05-04 (Mon) python scripts/kr_holiday.py next-business-day 2026-05-02 --offset 3 # 3rd next business day python scripts/kr_holiday.py prev-business-day 2026-05-06 --offset 2 ``` ### Add a signed business-day offset ```bash python scripts/kr_holiday.py add-business-days 2026-04-20 --days 10 # 2026-05-04 python scripts/kr_holiday.py add-business-days 2026-05-05 --days -3 ``` ### Count business days in a range (inclusive) ```bash python scripts/kr_holiday.py business-days --start 2026-05-01 --end 2026-05-31 # → {"start":"2026-05-01","end":"2026-05-31","calendar_days":31,"business_days":19} ``` ### Solar ↔ Korean lunar ```bash python scripts/kr_holiday.py solar-to-lunar 2026-02-17 # → {"solar":"2026-02-17","lunar":"2026-01-01","is_leap_month":false,"gapja":"병오년 경인월 임술일"} python scripts/kr_holiday.py lunar-to-solar 2026-08-15 python scripts/kr_holiday.py lunar-to-solar 2025-06-01 --leap # pass --leap for 윤달 ``` ### Monthly calendar view ```bash python scripts/kr_holiday.py month 2026 5 # human-readable, Sunday-first python scripts/kr_holiday.py month 2026 5 --format json # weeks grid in JSON ``` ## Output conventions - JSON by default (no pretty-print; pipe through `python -m json.tool` if desired). - `is_business_day = true` ⇔ Mon–Fri AND not a Korean public holiday. - `names` is a list — a single calendar day can carry multiple labels (e.g., when holidays overlap). - Substitute holidays (대체공휴일) appear as their own entries in `list` output. ## Error handling - Unparseable date → exit `2`, stderr JSON `{"error":"bad_date","input":"…"}`. - Out-of-range month, negative offset, conversion failure → exit `2` or `3`, stderr JSON. - Missing dependency → exit `4`, stderr JSON with install hint. ## Notes on holiday accuracy - 대체공휴일 rules: reflects the revised 2021/2023 rules (substitute applies when a national holiday falls on Sat/Sun, with the documented exceptions). Results match rulings published by 공공데이터포털 한국천문연구원 특일 정보 through 2030. - Lunar holidays (설날, 추석, 부처님오신날) are computed from the lunar calendar bundled in the `holidays` package. - Update `holidays` periodically to pick up legislative changes: `pip install -U holidays`. ## See also - `krx-stock-cli` — KRX stock market data (KOSPI/KOSDAQ) - `toss-payments-cli` — Toss Payments API - `kakao-local-cli` — Kakao Local place & geocoding API ## License MIT.

path: README.md
content: # kr-holiday-cli

Korean public holidays, business-day calculator, and lunar↔solar converter — CLI.

Zero API key. Zero network. Pure compute over bundled rules.

## What it does

- `is-holiday` — is a given date a Korean public holiday (including 대체공휴일)?
- `list` — all Korean public holidays in a year (optionally filtered by month).
- `next-business-day` / `prev-business-day` — skip weekends + Korean holidays.
- `add-business-days` — signed business-day offset from a given date.
- `business-days` — count business days between two inclusive dates.
- `solar-to-lunar` / `lunar-to-solar` — Korean lunar calendar conversion (with 윤달 support).
- `month` — rendered monthly calendar with holidays highlighted.

## Install

```bash
pip install -r scripts/requirements.txt
```

Dependencies:

- [`holidays`](https://pypi.org/project/holidays/) — Korean public holidays (with substitute holidays).
- [`korean-lunar-calendar`](https://pypi.org/project/korean-lunar-calendar/) — lunar↔solar conversion based on KASI tables.

## Quick start

```bash
python scripts/kr_holiday.py is-holiday 2026-05-05
python scripts/kr_holiday.py list --year 2026
python scripts/kr_holiday.py add-business-days 2026-04-20 --days 10
python scripts/kr_holiday.py solar-to-lunar 2026-02-17
python scripts/kr_holiday.py month 2026 5
```

See `SKILL.md` for complete usage and I/O conventions.

## Why

Every agent that touches Korean scheduling (payroll, delivery SLAs, billing cycles, appointment reminders, marketing sends) needs to answer "is this a public holiday?" or "when is the next business day?" For Korea that question is non-trivial: lunar holidays (설날, 추석, 부처님오신날) shift every year and 대체공휴일 rules changed in 2021 and 2023.

## License

MIT.


path: scripts/kr_holiday.py
content: #!/usr/bin/env python3
"""kr-holiday-cli — Korean public holidays, business-day calculator, lunar↔solar converter.

Zero API key. Uses the `holidays` package for KR public holidays (including
substitute holidays / 대체공휴일) and `korean-lunar-calendar` for lunar↔solar
conversion.

Subcommands:
    is-holiday <date>
    list --year <y> [--month <m>]
    next-business-day <date> [--offset N]
    prev-business-day <date> [--offset N]
    add-business-days <date> --days <n>
    business-days --start <d> --end <d>
    solar-to-lunar <date>
    lunar-to-solar <date> [--leap]
    month <year> <month> [--format text|json]

Dates accept YYYYMMDD or YYYY-MM-DD.
"""
from __future__ import annotations

import argparse
import calendar
import datetime as dt
import json
import sys
from typing import Iterable


def _eprint(payload: dict, code: int = 1) -> int:
    print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
    return code


def _import_holidays():
    try:
        import holidays  # type: ignore
    except ImportError:
        _eprint({
            "error": "missing_dependency",
            "package": "holidays",
            "hint": "pip install -r scripts/requirements.txt",
        })
        sys.exit(4)
    return holidays


def _import_lunar():
    try:
        from korean_lunar_calendar import KoreanLunarCalendar  # type: ignore
    except ImportError:
        _eprint({
            "error": "missing_dependency",
            "package": "korean-lunar-calendar",
            "hint": "pip install -r scripts/requirements.txt",
        })
        sys.exit(4)
    return KoreanLunarCalendar


def parse_date(s: str) -> dt.date:
    s = s.strip()
    for fmt in ("%Y-%m-%d", "%Y%m%d", "%Y/%m/%d", "%Y.%m.%d"):
        try:
            return dt.datetime.strptime(s, fmt).date()
        except ValueError:
            continue
    raise SystemExit(_eprint({"error": "bad_date", "input": s}, 2))


def kr_holidays(years: Iterable[int]):
    holidays = _import_holidays()
    return holidays.country_holidays("KR", years=list(years), language="ko")


def is_business_day(d: dt.date, kr) -> bool:
    return d.weekday() < 5 and d not in kr


def cmd_is_holiday(args) -> int:
    d = parse_date(args.date)
    kr = kr_holidays([d.year])
    names = []
    if d in kr:
        raw = kr.get(d)
        if raw:
            names = [n.strip() for n in str(raw).split(",") if n.strip()]
    payload = {
        "date": d.isoformat(),
        "weekday": d.strftime("%a"),
        "is_weekend": d.weekday() >= 5,
        "is_holiday": d in kr,
        "is_business_day": is_business_day(d, kr),
        "names": names,
    }
    print(json.dumps(payload, ensure_ascii=False))
    return 0


def cmd_list(args) -> int:
    kr = kr_holidays([args.year])
    rows = []
    for d in sorted(kr):
        if args.month and d.month != args.month:
            continue
        raw = kr.get(d)
        names = [n.strip() for n in str(raw).split(",") if n.strip()] if raw else []
        rows.append({
            "date": d.isoformat(),
            "weekday": d.strftime("%a"),
            "names": names,
        })
    print(json.dumps(rows, ensure_ascii=False))
    return 0


def _shift_business(start: dt.date, step: int, count: int) -> dt.date:
    if count < 0:
        raise SystemExit(_eprint({"error": "bad_offset", "offset": count}, 2))
    years = {start.year, start.year + 1, start.year - 1}
    kr = kr_holidays(years)
    d = start
    moved = 0
    guard = 0
    while moved < count:
        d = d + dt.timedelta(days=step)
        guard += 1
        if guard > 3650:  # ~10 years of slack
            raise SystemExit(_eprint({"error": "shift_overflow"}, 3))
        if d.year not in years:
            years.add(d.year)
            kr = kr_holidays(years)
        if is_business_day(d, kr):
            moved += 1
    return d


def cmd_next_business(args) -> int:
    d = parse_date(args.date)
    n = max(1, args.offset)
    result = _shift_business(d, +1, n)
    print(json.dumps({"input": d.isoformat(), "offset": n, "result": result.isoformat()}, ensure_ascii=False))
    return 0


def cmd_prev_business(args) -> int:
    d = parse_date(args.date)
    n = max(1, args.offset)
    result = _shift_business(d, -1, n)
    print(json.dumps({"input": d.isoformat(), "offset": -n, "result": result.isoformat()}, ensure_ascii=False))
    return 0


def cmd_add_business(args) -> int:
    d = parse_date(args.date)
    days = args.days
    if days == 0:
        years = {d.year}
        kr = kr_holidays(years)
        result = d
        payload_days = 0
        if not is_business_day(d, kr):
            # snap forward to the next business day
            result = _shift_business(d, +1, 1)
            payload_days = 0
        print(json.dumps({"input": d.isoformat(), "days": payload_days, "result": result.isoformat()}, ensure_ascii=False))
        return 0
    step = 1 if days > 0 else -1
    result = _shift_business(d, step, abs(days))
    print(json.dumps({"input": d.isoformat(), "days": days, "result": result.isoformat()}, ensure_ascii=False))
    return 0


def cmd_business_days(args) -> int:
    start = parse_date(args.start)
    end = parse_date(args.end)
    if end < start:
        start, end = end, start
    years = set(range(start.year, end.year + 1))
    kr = kr_holidays(years)
    total = 0
    d = start
    while d <= end:
        if is_business_day(d, kr):
            total += 1
        d += dt.timedelta(days=1)
    print(json.dumps({
        "start": start.isoformat(),
        "end": end.isoformat(),
        "calendar_days": (end - start).days + 1,
        "business_days": total,
    }, ensure_ascii=False))
    return 0


def cmd_solar_to_lunar(args) -> int:
    KoreanLunarCalendar = _import_lunar()
    d = parse_date(args.date)
    c = KoreanLunarCalendar()
    ok = c.setSolarDate(d.year, d.month, d.day)
    if not ok:
        return _eprint({"error": "conversion_failed", "input": d.isoformat()}, 3)
    print(json.dumps({
        "solar": d.isoformat(),
        "lunar": f"{c.lunarYear:04d}-{c.lunarMonth:02d}-{c.lunarDay:02d}",
        "lunar_year": c.lunarYear,
        "lunar_month": c.lunarMonth,
        "lunar_day": c.lunarDay,
        "is_leap_month": c.isIntercalation,
        "gapja": c.getGapJaString(),
    }, ensure_ascii=False))
    return 0


def cmd_lunar_to_solar(args) -> int:
    KoreanLunarCalendar = _import_lunar()
    d = parse_date(args.date)
    c = KoreanLunarCalendar()
    ok = c.setLunarDate(d.year, d.month, d.day, bool(args.leap))
    if not ok:
        return _eprint({"error": "conversion_failed", "input": d.isoformat(), "leap": bool(args.leap)}, 3)
    print(json.dumps({
        "lunar": f"{d.year:04d}-{d.month:02d}-{d.day:02d}",
        "is_leap_month": bool(args.leap),
        "solar": f"{c.solarYear:04d}-{c.solarMonth:02d}-{c.solarDay:02d}",
        "gapja": c.getGapJaString(),
    }, ensure_ascii=False))
    return 0


def cmd_month(args) -> int:
    year, month = args.year, args.month
    if not (1 <= month <= 12):
        return _eprint({"error": "bad_month", "month": month}, 2)
    kr = kr_holidays([year])
    cal = calendar.Calendar(firstweekday=6)  # Sunday-first (Korean convention)
    weeks = cal.monthdatescalendar(year, month)

    if args.format == "json":
        grid = []
        for week in weeks:
            row = []
            for d in week:
                in_month = d.month == month
                names = []
                if in_month and d in kr:
                    raw = kr.get(d)
                    if raw:
                        names = [n.strip() for n in str(raw).split(",") if n.strip()]
                row.append({
                    "date": d.isoformat(),
                    "day": d.day,
                    "in_month": in_month,
                    "is_weekend": d.weekday() >= 5,
                    "is_holiday": in_month and d in kr,
                    "is_business_day": in_month and is_business_day(d, kr),
                    "names": names,
                })
            grid.append(row)
        print(json.dumps({
            "year": year,
            "month": month,
            "first_weekday": "Sunday",
            "weeks": grid,
        }, ensure_ascii=False))
        return 0

    # text format
    header = f"{year}-{month:02d}  Korean calendar (S M T W T F S)"
    print(header)
    print("-" * len(header))
    print(" 일  월  화  수  목  금  토")
    holiday_names = []
    for week in weeks:
        cells = []
        for d in week:
            if d.month != month:
                cells.append("   ")
                continue
            token = f"{d.day:2d}"
            is_hol = d in kr
            is_wknd = d.weekday() >= 5
            if is_hol:
                cells.append(f"*{token}")
                raw = kr.get(d)
                if raw:
                    holiday_names.append(f"  {d.isoformat()}  {raw}")
            elif is_wknd:
                cells.append(f" {token}")
            else:
                cells.append(f" {token}")
        print(" ".join(cells))
    if holiday_names:
        print()
        print("Holidays (* = public holiday):")
        for line in holiday_names:
            print(line)
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="kr-holiday", description="Korean public holidays & business-day calculator (CLI)")
    sub = p.add_subparsers(dest="cmd", required=True)

    s = sub.add_parser("is-holiday", help="Check if a date is a Korean public holiday")
    s.add_argument("date")
    s.set_defaults(func=cmd_is_holiday)

    s = sub.add_parser("list", help="List Korean public holidays in a year")
    s.add_argument("--year", type=int, required=True)
    s.add_argument("--month", type=int)
    s.set_defaults(func=cmd_list)

    s = sub.add_parser("next-business-day", help="N-th next business day (weekend/holiday aware)")
    s.add_argument("date")
    s.add_argument("--offset", type=int, default=1)
    s.set_defaults(func=cmd_next_business)

    s = sub.add_parser("prev-business-day", help="N-th previous business day")
    s.add_argument("date")
    s.add_argument("--offset", type=int, default=1)
    s.set_defaults(func=cmd_prev_business)

    s = sub.add_parser("add-business-days", help="Add signed business-day offset to a date")
    s.add_argument("date")
    s.add_argument("--days", type=int, required=True)
    s.set_defaults(func=cmd_add_business)

    s = sub.add_parser("business-days", help="Count business days between two inclusive dates")
    s.add_argument("--start", required=True)
    s.add_argument("--end", required=True)
    s.set_defaults(func=cmd_business_days)

    s = sub.add_parser("solar-to-lunar", help="Convert solar date to Korean lunar date")
    s.add_argument("date")
    s.set_defaults(func=cmd_solar_to_lunar)

    s = sub.add_parser("lunar-to-solar", help="Convert Korean lunar date to solar date")
    s.add_argument("date")
    s.add_argument("--leap", action="store_true", help="Treat input month as leap (윤달)")
    s.set_defaults(func=cmd_lunar_to_solar)

    s = sub.add_parser("month", help="Render a monthly calendar with Korean holidays")
    s.add_argument("year", type=int)
    s.add_argument("month", type=int)
    s.add_argument("--format", choices=["text", "json"], default="text")
    s.set_defaults(func=cmd_month)

    return p


def main(argv=None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        return args.func(args)
    except SystemExit:
        raise
    except Exception as exc:  # last-resort guard
        return _eprint({"error": "internal_error", "message": str(exc)}, 3)


if __name__ == "__main__":
    sys.exit(main())


path: scripts/requirements.txt
content: holidays>=0.52
korean-lunar-calendar>=0.3.1


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)