
Exchange Rate
- 1 installs
- 10 repo stars
- Updated July 30, 2026
- deusyu/rainman-skills
Queries real-time and historical currency exchange rates via the Frankfurter API (ECB data) with convert, latest, and history commands.
About
Converts currencies and looks up real-time or historical exchange rates using ECB data. A developer or user uses it to convert amounts or check rate trends between currencies.
- Frankfurter API (ECB source), no API key needed
- Commands: convert, latest, history, currencies
Exchange Rate by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 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 exchange-rateAdd 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 real-time and historical currency exchange rates via the Frankfurter API (ECB data) with convert, latest, and history commands.
Files
exchange-rate — Currency Exchange Rate Skill
Query real-time and historical exchange rates via the Frankfurter API (ECB data source).
Quick Start
1. No API key needed. 2. Run bun scripts/exchange.ts --help in this skill directory. 3. Pick the matching command from references/command-map.md.
Workflow
1. Parse user intent — identify source currency, target currency, amount, and date (if any). 2. Select the right command: convert, latest, history, or currencies. 3. Run the script and return the result. 4. When the user provides a natural language query like "100 美元换人民币", use the convert command with --amount 100 --from USD --to CNY.
Commands
- Full command mapping:
references/command-map.md
Common Currency Aliases
When the user uses Chinese names, map them:
- 美元/美金 → USD
- 人民币/元 → CNY
- 欧元 → EUR
- 英镑 → GBP
- 日元/日币 → JPY
- 韩元/韩币 → KRW
- 港币/港元 → HKD
- 新加坡元/新币 → SGD
- 澳元/澳币 → AUD
- 加元/加币 → CAD
- 泰铢 → THB
- 瑞士法郎/瑞郎 → CHF
Notes
- This skill is script-first and does not run an MCP server.
- Data source is ECB (European Central Bank), updated once per working day.
- Supports 30 major currencies.
- No API key required.
Command Map
| Command | Description | Required Flags | Optional Flags |
|---|---|---|---|
convert | Convert an amount between currencies | --from, --to | --amount, --date |
latest | Show latest rates for a base currency | --from | --to |
history | Show rate on a specific date | --from, --date | --to |
series | Show rate trend over a date range | --from, --start, --end | --to |
currencies | List all supported currencies | - | - |
Flag Details
| Flag | Format | Example |
|---|---|---|
--from | ISO 4217 currency code | USD, CNY, EUR |
--to | One or more codes, comma-separated | CNY, CNY,JPY,EUR |
--amount | Positive number | 100, 1500.50 |
--date | YYYY-MM-DD | 2025-06-15 |
--start | YYYY-MM-DD | 2025-01-01 |
--end | YYYY-MM-DD | 2025-01-31 |
Exit Codes
0: success2: input or config error (invalid currency code, bad date format, etc.)3: network / timeout / HTTP transport error4: API business error5: unexpected internal error
API Base URL
https://api.frankfurter.dev/v1- 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("Exchange Rate CLI (Frankfurter / ECB)");
lines.push("");
lines.push("Usage:");
lines.push(" bun scripts/exchange.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: European Central Bank (ECB)");
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);
}
function getOptionalString(flags: Record<string, unknown>, key: string): string | undefined {
const value = flags[key];
if (typeof value === "string") {
return value;
}
return undefined;
}
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 (typeof payload.message === "string") {
throw new CliError(
`API error: ${payload.message}`,
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 "convert": {
const from = getRequiredString(flags, "from");
const to = getRequiredString(flags, "to");
const amount = getOptionalString(flags, "amount") ?? "1";
const date = getOptionalString(flags, "date");
const path = date ? `/${date}` : "/latest";
return runQuery(path, { from, to, amount }, context);
}
case "latest": {
const from = getRequiredString(flags, "from");
const to = getOptionalString(flags, "to");
return runQuery("/latest", { from, to }, context);
}
case "history": {
const from = getRequiredString(flags, "from");
const date = getRequiredString(flags, "date");
const to = getOptionalString(flags, "to");
return runQuery(`/${date}`, { from, to }, context);
}
case "series": {
const from = getRequiredString(flags, "from");
const start = getRequiredString(flags, "start");
const end = getRequiredString(flags, "end");
const to = getOptionalString(flags, "to");
return runQuery(`/${start}..${end}`, { from, to }, context);
}
case "currencies": {
return runQuery("/currencies", {}, context);
}
default: {
const unhandled: never = command;
throw new CliError(`Unsupported command: ${unhandled}`, ExitCode.INTERNAL);
}
}
}
export const API_BASE_URL = "https://api.frankfurter.dev/v1";
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" },
});
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 = "convert" | "latest" | "history" | "series" | "currencies";
interface CommandFlagMap {
convert: {
from: string;
to: string;
amount?: string;
date?: string;
};
latest: {
from: string;
to?: string;
};
history: {
from: string;
date: string;
to?: string;
};
series: {
from: string;
start: string;
end: string;
to?: string;
};
currencies: Record<string, never>;
}
export type ValidatedFlags<K extends CommandName> = CommandFlagMap[K];
export interface CommandHelp {
usage: string;
description: string;
}
export const COMMAND_HELP_MAP: Record<CommandName, CommandHelp> = {
convert: {
usage: "convert --from <CODE> --to <CODE> [--amount <number>] [--date <YYYY-MM-DD>]",
description: "Convert an amount between two currencies.",
},
latest: {
usage: "latest --from <CODE> [--to <CODE,CODE,...>]",
description: "Show latest exchange rates for a base currency.",
},
history: {
usage: "history --from <CODE> --date <YYYY-MM-DD> [--to <CODE,CODE,...>]",
description: "Show exchange rate on a specific date.",
},
series: {
usage: "series --from <CODE> --start <YYYY-MM-DD> --end <YYYY-MM-DD> [--to <CODE,CODE,...>]",
description: "Show exchange rate trend over a date range.",
},
currencies: {
usage: "currencies",
description: "List all supported currency codes.",
},
};
export const COMMAND_ORDER: CommandName[] = ["convert", "latest", "history", "series", "currencies"];
const COMMAND_NAME_SET = new Set<string>(COMMAND_ORDER);
const VALID_CURRENCIES = new Set([
"AUD", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD",
"HUF", "IDR", "ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK",
"NZD", "PHP", "PLN", "RON", "SEK", "SGD", "THB", "TRY", "USD", "ZAR",
]);
const ALLOWED_FLAG_NAMES: Record<CommandName, readonly string[]> = {
convert: ["from", "to", "amount", "date"],
latest: ["from", "to"],
history: ["from", "date", "to"],
series: ["from", "start", "end", "to"],
currencies: [],
};
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 validateCurrencyCode(command: CommandName, key: string, value: string): string {
const upper = value.toUpperCase();
if (!VALID_CURRENCIES.has(upper)) {
throwInvalidFlags(command, `${key}: unknown currency code "${value}". Run 'currencies' to see all supported codes.`);
}
return upper;
}
function validateCurrencyCodes(command: CommandName, key: string, value: string): string {
const codes = value.split(",").map((c) => c.trim());
for (const code of codes) {
validateCurrencyCode(command, key, code);
}
return codes.map((c) => c.toUpperCase()).join(",");
}
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 validateAmount(command: CommandName, key: string, value: string): string {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
throwInvalidFlags(command, `${key}: must be a positive number`);
}
return value;
}
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 "convert": {
const from = validateCurrencyCode(command, "from", normalizeRequiredString(command, rawFlags, "from"));
const to = validateCurrencyCode(command, "to", normalizeRequiredString(command, rawFlags, "to"));
const amountRaw = normalizeOptionalString(command, rawFlags, "amount");
const amount = amountRaw ? validateAmount(command, "amount", amountRaw) : undefined;
const dateRaw = normalizeOptionalString(command, rawFlags, "date");
const date = dateRaw ? validateDate(command, "date", dateRaw) : undefined;
return { from, to, amount, date } as ValidatedFlags<K>;
}
case "latest": {
const from = validateCurrencyCode(command, "from", normalizeRequiredString(command, rawFlags, "from"));
const toRaw = normalizeOptionalString(command, rawFlags, "to");
const to = toRaw ? validateCurrencyCodes(command, "to", toRaw) : undefined;
return { from, to } as ValidatedFlags<K>;
}
case "history": {
const from = validateCurrencyCode(command, "from", normalizeRequiredString(command, rawFlags, "from"));
const date = validateDate(command, "date", normalizeRequiredString(command, rawFlags, "date"));
const toRaw = normalizeOptionalString(command, rawFlags, "to");
const to = toRaw ? validateCurrencyCodes(command, "to", toRaw) : undefined;
return { from, date, to } as ValidatedFlags<K>;
}
case "series": {
const from = validateCurrencyCode(command, "from", normalizeRequiredString(command, rawFlags, "from"));
const start = validateDate(command, "start", normalizeRequiredString(command, rawFlags, "start"));
const end = validateDate(command, "end", normalizeRequiredString(command, rawFlags, "end"));
const toRaw = normalizeOptionalString(command, rawFlags, "to");
const to = toRaw ? validateCurrencyCodes(command, "to", toRaw) : undefined;
if (start > end) {
throwInvalidFlags(command, `start date (${start}) must be before end date (${end})`);
}
return { from, start, end, to } as ValidatedFlags<K>;
}
case "currencies": {
return {} as ValidatedFlags<K>;
}
default: {
const unhandled: never = command;
throwInvalidFlags(command, `unsupported command: ${String(unhandled)}`);
}
}
}