
Cn Holiday
- 1 installs
- 10 repo stars
- Updated July 30, 2026
- deusyu/rainman-skills
Queries Chinese public holidays, make-up workdays, and work schedules via the timor.tech API, with no API key needed.
About
Checks whether a date is a workday or holiday in China, including make-up workdays and yearly schedules. A developer or user uses it to look up Chinese holiday and workday information.
- No API key required; uses timor.tech API
- Commands for info, year, batch, and next workday
Cn Holiday by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,980 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/rainman-skills --skill cn-holidayAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 10 |
| Last updated | July 30, 2026 |
| Repository | deusyu/rainman-skills ↗ |
What it does
Queries Chinese public holidays, make-up workdays, and work schedules via the timor.tech API, with no API key needed.
Files
cn-holiday — Chinese Holiday & Work Schedule Skill
Query Chinese public holidays, 调休 (make-up workdays), and work schedules via the timor.tech API.
Quick Start
1. No API key needed. 2. Run bun scripts/holiday.ts --help in this skill directory. 3. Pick the matching command from references/command-map.md.
Workflow
1. Parse user intent — identify the date(s) and what they want to know. 2. Select the right command: info, year, batch, or next. 3. Run the script and return the result. 4. Interpret the result for the user in natural language.
Response Interpretation
type.type field meanings
| Value | Meaning |
|---|---|
| 0 | 工作日 (workday) |
| 1 | 周末 (weekend) |
| 2 | 节日 (holiday) |
| 3 | 调休补班 (make-up workday) |
wage field meanings (加班工资倍率)
| Value | Meaning |
|---|---|
| 1 | 正常工资 |
| 2 | 双倍工资 |
| 3 | 三倍工资 |
holiday.holiday field
true= 放假 (day off)false= 补班 (make-up workday)
Notes
- This skill is script-first and does not run an MCP server.
- Data source: timor.tech (based on State Council holiday announcements).
- No API key required.
- Covers current year and adjacent years' holiday schedules.
Command Map
| Command | Description | Required Flags | Optional Flags |
|---|---|---|---|
info | Check if a specific date is a holiday/workday/调休 | --date | - |
year | List all holidays and 调休 for a year | - | --year |
batch | Check multiple dates at once | --dates | - |
next | Find the next holiday or workday after a date | --date | --type |
Flag Details
| Flag | Format | Example |
|---|---|---|
--date | YYYY-MM-DD | 2026-02-17 |
--year | YYYY (defaults to current year) | 2026 |
--dates | Comma-separated YYYY-MM-DD | 2026-02-16,2026-02-17,2026-02-18 |
--type | Y (holiday) or N (workday) | Y |
Exit Codes
0: success2: input or config error3: network / timeout / HTTP transport error4: API business error5: unexpected internal error
API Base URL
https://timor.tech/api/holiday- No API key required.
#!/usr/bin/env bun
import { executeCommand } from "./lib/commands.ts";
import { CliError, ExitCode, toErrorMessage } from "./lib/config.ts";
import {
COMMAND_HELP_MAP,
COMMAND_ORDER,
isCommandName,
validateCommandFlags,
} from "./lib/validators.ts";
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function printGlobalHelp(): void {
const lines: string[] = [];
lines.push("Chinese Holiday CLI (timor.tech)");
lines.push("");
lines.push("Usage:");
lines.push(" bun scripts/holiday.ts <command> [flags]");
lines.push("");
lines.push("Commands:");
for (const command of COMMAND_ORDER) {
const help = COMMAND_HELP_MAP[command];
lines.push(` ${help.usage}`);
}
lines.push("");
lines.push("Tips:");
lines.push(" - No API key required");
lines.push(" - Data source: State Council holiday announcements via timor.tech");
lines.push(" - Use --help after a command for command-specific details");
process.stdout.write(`${lines.join("\n")}\n`);
}
function printCommandHelp(command: keyof typeof COMMAND_HELP_MAP): void {
const help = COMMAND_HELP_MAP[command];
process.stdout.write(`Usage: ${help.usage}\n${help.description}\n`);
}
function parseFlags(tokens: string[]): Record<string, string> {
const flags: Record<string, string> = {};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (!token.startsWith("--")) {
throw new CliError(`Invalid token: ${token}. Use --key value format.`, ExitCode.PARAM_OR_CONFIG);
}
const trimmed = token.slice(2);
if (trimmed.length === 0) {
throw new CliError("Empty flag name is not allowed.", ExitCode.PARAM_OR_CONFIG);
}
let key = trimmed;
let value: string | undefined;
const equalIndex = trimmed.indexOf("=");
if (equalIndex >= 0) {
key = trimmed.slice(0, equalIndex);
value = trimmed.slice(equalIndex + 1);
} else {
const next = tokens[index + 1];
if (!next || next.startsWith("--")) {
throw new CliError(`Flag --${key} is missing a value.`, ExitCode.PARAM_OR_CONFIG);
}
value = next;
index += 1;
}
if (key.length === 0) {
throw new CliError("Flag name cannot be empty.", ExitCode.PARAM_OR_CONFIG);
}
if (Object.hasOwn(flags, key)) {
throw new CliError(`Duplicate flag: --${key}`, ExitCode.PARAM_OR_CONFIG);
}
flags[key] = value;
}
return flags;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
printGlobalHelp();
process.exit(ExitCode.OK);
}
const commandToken = args[0];
if (!commandToken || !isCommandName(commandToken)) {
throw new CliError(`Unknown command: ${commandToken ?? ""}`, ExitCode.PARAM_OR_CONFIG);
}
const restArgs = args.slice(1);
if (restArgs.length === 1 && (restArgs[0] === "--help" || restArgs[0] === "-h")) {
printCommandHelp(commandToken);
process.exit(ExitCode.OK);
}
const rawFlags = parseFlags(restArgs);
const validatedFlags = validateCommandFlags(commandToken, rawFlags);
const response = await executeCommand(commandToken, validatedFlags as Record<string, unknown>);
printJson(response);
process.exit(ExitCode.OK);
}
void main().catch((error: unknown) => {
if (error instanceof CliError) {
if (error.rawResponse !== undefined) {
printJson(error.rawResponse);
} else {
process.stderr.write(`${error.message}\n`);
}
process.exit(error.exitCode);
}
process.stderr.write(`Unexpected internal error: ${toErrorMessage(error)}\n`);
process.exit(ExitCode.INTERNAL);
});
import { API_BASE_URL, CliError, ExitCode, isRecord } from "./config.ts";
import { getJsonWithRetry, type HttpGetRequest } from "./http.ts";
import type { CommandName } from "./validators.ts";
export interface ExecuteCommandDependencies {
requestJson?: (request: HttpGetRequest) => Promise<unknown>;
timeoutMs?: number;
retries?: number;
}
interface RuntimeContext {
requestJson: (request: HttpGetRequest) => Promise<unknown>;
timeoutMs: number | undefined;
retries: number | undefined;
}
function getRequiredString(flags: Record<string, unknown>, key: string): string {
const value = flags[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
throw new CliError(`Missing required argument: --${key}`, ExitCode.INTERNAL);
}
async function runQuery(
path: string,
params: Record<string, string | undefined>,
context: RuntimeContext,
): Promise<unknown> {
const payload = await context.requestJson({
url: `${API_BASE_URL}${path}`,
params,
timeoutMs: context.timeoutMs,
retries: context.retries,
});
if (!isRecord(payload)) {
throw new CliError(
"Unexpected API response format",
ExitCode.API_BUSINESS,
{ rawResponse: payload },
);
}
if (payload.code !== 0) {
throw new CliError(
`API error (code ${payload.code})`,
ExitCode.API_BUSINESS,
{ rawResponse: payload },
);
}
return payload;
}
export async function executeCommand(
command: CommandName,
flags: Record<string, unknown>,
dependencies: ExecuteCommandDependencies = {},
): Promise<unknown> {
const context: RuntimeContext = {
requestJson: dependencies.requestJson ?? getJsonWithRetry,
timeoutMs: dependencies.timeoutMs,
retries: dependencies.retries,
};
switch (command) {
case "info": {
const date = getRequiredString(flags, "date");
return runQuery(`/info/${date}`, {}, context);
}
case "year": {
const year = getRequiredString(flags, "year");
return runQuery(`/year/${year}/`, {}, context);
}
case "batch": {
const dates = getRequiredString(flags, "dates");
return runQuery("/batch", { d: dates }, context);
}
case "next": {
const date = getRequiredString(flags, "date");
const type = getRequiredString(flags, "type");
return runQuery(`/next/${date}`, { type }, context);
}
default: {
const unhandled: never = command;
throw new CliError(`Unsupported command: ${unhandled}`, ExitCode.INTERNAL);
}
}
}
export const API_BASE_URL = "https://timor.tech/api/holiday";
export const DEFAULT_TIMEOUT_MS = 15_000;
export const DEFAULT_RETRY_COUNT = 2;
export const DEFAULT_BACKOFF_MS = 200;
export enum ExitCode {
OK = 0,
PARAM_OR_CONFIG = 2,
NETWORK = 3,
API_BUSINESS = 4,
INTERNAL = 5,
}
export interface CliErrorOptions {
cause?: unknown;
rawResponse?: unknown;
}
export class CliError extends Error {
public readonly exitCode: ExitCode;
public readonly rawResponse?: unknown;
constructor(message: string, exitCode: ExitCode, options: CliErrorOptions = {}) {
super(message, { cause: options.cause });
this.name = "CliError";
this.exitCode = exitCode;
this.rawResponse = options.rawResponse;
}
}
export function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
import {
CliError,
DEFAULT_BACKOFF_MS,
DEFAULT_RETRY_COUNT,
DEFAULT_TIMEOUT_MS,
ExitCode,
toErrorMessage,
} from "./config.ts";
export interface HttpGetRequest {
url: string;
params: Record<string, string | undefined>;
timeoutMs?: number;
retries?: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildUrlWithParams(url: string, params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
query.set(key, value);
}
}
const queryString = query.toString();
if (queryString.length === 0) {
return url;
}
return `${url}?${queryString}`;
}
export async function getJsonWithRetry(request: HttpGetRequest): Promise<unknown> {
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = request.retries ?? DEFAULT_RETRY_COUNT;
const urlWithParams = buildUrlWithParams(request.url, request.params);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const isLastAttempt = attempt === retries;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(urlWithParams, {
method: "GET",
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "Mozilla/5.0",
},
});
clearTimeout(timer);
if (!response.ok) {
if (!isLastAttempt && (response.status >= 500 || response.status === 429)) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
throw new CliError(
`HTTP request failed with status ${response.status} for ${urlWithParams}`,
ExitCode.NETWORK,
);
}
try {
return await response.json();
} catch (jsonError) {
throw new CliError(
`Failed to parse JSON response from ${urlWithParams}: ${toErrorMessage(jsonError)}`,
ExitCode.NETWORK,
{ cause: jsonError },
);
}
} catch (error) {
lastError = error;
if (error instanceof CliError && error.exitCode !== ExitCode.NETWORK) {
throw error;
}
if (!isLastAttempt) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
}
}
if (lastError instanceof CliError) {
throw lastError;
}
throw new CliError(
`Network request failed after retries: ${toErrorMessage(lastError)}`,
ExitCode.NETWORK,
{ cause: lastError },
);
}
import { CliError, ExitCode } from "./config.ts";
export type CommandName = "info" | "year" | "batch" | "next";
interface CommandFlagMap {
info: {
date: string;
};
year: {
year: string;
};
batch: {
dates: string;
};
next: {
date: string;
type: "Y" | "N";
};
}
export type ValidatedFlags<K extends CommandName> = CommandFlagMap[K];
export interface CommandHelp {
usage: string;
description: string;
}
export const COMMAND_HELP_MAP: Record<CommandName, CommandHelp> = {
info: {
usage: "info --date <YYYY-MM-DD>",
description: "Check if a specific date is a holiday, workday, or 调休.",
},
year: {
usage: "year [--year <YYYY>]",
description: "List all holidays and 调休 for a year (default: current year).",
},
batch: {
usage: "batch --dates <YYYY-MM-DD,YYYY-MM-DD,...>",
description: "Check multiple dates at once.",
},
next: {
usage: "next --date <YYYY-MM-DD> [--type <Y|N>]",
description: "Find the next holiday (Y) or workday (N) after a date.",
},
};
export const COMMAND_ORDER: CommandName[] = ["info", "year", "batch", "next"];
const COMMAND_NAME_SET = new Set<string>(COMMAND_ORDER);
const ALLOWED_FLAG_NAMES: Record<CommandName, readonly string[]> = {
info: ["date"],
year: ["year"],
batch: ["dates"],
next: ["date", "type"],
};
function throwInvalidFlags(command: CommandName, message: string): never {
throw new CliError(`Invalid flags for ${command}: ${message}`, ExitCode.PARAM_OR_CONFIG);
}
function ensureNoUnknownFlags(command: CommandName, rawFlags: Record<string, string>): void {
const allowed = new Set(ALLOWED_FLAG_NAMES[command]);
const unknownFlags: string[] = [];
for (const key of Object.keys(rawFlags)) {
if (!allowed.has(key)) {
unknownFlags.push(`--${key}`);
}
}
if (unknownFlags.length > 0) {
throwInvalidFlags(command, `unknown flags: ${unknownFlags.join(", ")}`);
}
}
function normalizeOptionalString(command: CommandName, rawFlags: Record<string, string>, key: string): string | undefined {
const value = rawFlags[key];
if (value === undefined) {
return undefined;
}
const normalized = value.trim();
if (normalized.length === 0) {
throwInvalidFlags(command, `${key}: must be a non-empty string`);
}
return normalized;
}
function normalizeRequiredString(command: CommandName, rawFlags: Record<string, string>, key: string): string {
const normalized = normalizeOptionalString(command, rawFlags, key);
if (!normalized) {
throwInvalidFlags(command, `${key}: is required`);
}
return normalized;
}
function validateDate(command: CommandName, key: string, value: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throwInvalidFlags(command, `${key}: must be in YYYY-MM-DD format`);
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throwInvalidFlags(command, `${key}: invalid date "${value}"`);
}
return value;
}
function validateYear(command: CommandName, key: string, value: string): string {
if (!/^\d{4}$/.test(value)) {
throwInvalidFlags(command, `${key}: must be in YYYY format`);
}
const year = Number(value);
if (year < 2000 || year > 2100) {
throwInvalidFlags(command, `${key}: year out of range (2000-2100)`);
}
return value;
}
function validateDates(command: CommandName, key: string, value: string): string {
const dates = value.split(",").map((d) => d.trim());
if (dates.length === 0) {
throwInvalidFlags(command, `${key}: must provide at least one date`);
}
for (const d of dates) {
validateDate(command, key, d);
}
return dates.join(",");
}
export function isCommandName(value: string): value is CommandName {
return COMMAND_NAME_SET.has(value);
}
export function validateCommandFlags<K extends CommandName>(
command: K,
rawFlags: Record<string, string>,
): ValidatedFlags<K> {
ensureNoUnknownFlags(command, rawFlags);
switch (command) {
case "info": {
const date = validateDate(command, "date", normalizeRequiredString(command, rawFlags, "date"));
return { date } as ValidatedFlags<K>;
}
case "year": {
const yearRaw = normalizeOptionalString(command, rawFlags, "year")
?? new Date().getFullYear().toString();
const year = validateYear(command, "year", yearRaw);
return { year } as ValidatedFlags<K>;
}
case "batch": {
const dates = validateDates(command, "dates", normalizeRequiredString(command, rawFlags, "dates"));
return { dates } as ValidatedFlags<K>;
}
case "next": {
const date = validateDate(command, "date", normalizeRequiredString(command, rawFlags, "date"));
const typeRaw = normalizeOptionalString(command, rawFlags, "type") ?? "Y";
if (typeRaw !== "Y" && typeRaw !== "N") {
throwInvalidFlags(command, `type: must be Y (holiday) or N (workday)`);
}
return { date, type: typeRaw } as ValidatedFlags<K>;
}
default: {
const unhandled: never = command;
throwInvalidFlags(command, `unsupported command: ${String(unhandled)}`);
}
}
}