scripts/call-rules.json
{
"schema_version": 18,
"description": "Runtime normalization and validation rules for Wind MCP tool calls.",
"kline_period_map": {
"1min": "1",
"5min": "3",
"10min": "4",
"15min": "5",
"30min": "6",
"60min": "7",
"120min": "8",
"240min": "9",
"1d": "10",
"1w": "11",
"1mo": "12",
"1y": "13",
"1q": "14",
"6mo": "15"
},
"tool_by_domain": {
"price": {
"stock_data": "get_stock_price_indicators",
"fund_data": "get_fund_price_indicators",
"index_data": "get_index_price_indicators"
},
"kline": {
"stock_data": "get_stock_kline",
"fund_data": "get_fund_kline",
"index_data": "get_index_kline"
},
"quote": {
"stock_data": "get_stock_quote",
"fund_data": "get_fund_quote",
"index_data": "get_index_quote"
}
},
"basic": {
"string_keys": [
"question",
"query",
"windcode",
"indexes",
"observation",
"begin_date",
"end_date",
"begin",
"end",
"beginDate",
"endDate",
"date",
"tradeDate",
"afdate"
]
},
"tool_rules": [
{
"name": "question_required",
"label": "自然语言",
"tools": [
"search_stocks",
"get_stock_basicinfo",
"get_stock_fundamentals",
"get_stock_equity_holders",
"get_stock_events",
"get_stock_technicals",
"get_risk_metrics",
"search_funds",
"get_fund_info",
"get_fund_financials",
"get_fund_holdings",
"get_fund_performance",
"get_fund_holders",
"get_fund_company_info",
"get_index_basicinfo",
"get_index_fundamentals",
"get_index_technicals",
"get_bond_basicinfo",
"get_bond_issuer_info",
"get_bond_market_data",
"get_bond_financial_data",
"get_financial_data"
],
"required": ["question"]
},
{
"name": "financial_docs_query_required",
"label": "金融文档",
"tools": ["get_company_announcements", "get_financial_news"],
"required": ["query"]
},
{
"name": "kline",
"label": "K 线",
"tools": ["get_stock_kline", "get_fund_kline", "get_index_kline"],
"required": ["windcode", "begin_date", "end_date"],
"ordered_dates": [["begin_date", "end_date"]],
"enum_fields": {
"period": {
"values_from": "kline_period_map",
"message": "字段 'period' 只能是 ${values},日 K 请传 '1d'"
},
"aftype": {
"values": ["0", "1", "2"],
"message": "字段 'aftype' 只能是 '0'(前复权)、'1'(后复权)或 '2'(不复权)"
},
"issusp": {
"values": ["0", "1"],
"message": "字段 'issusp' 只能是 '0' 或 '1'"
}
},
"patterns": {
"count": {
"pattern": "^-?\\d+$",
"message": "字段 'count' 只能是整数:正数从开始日期往后取 N 条,负数从结束日期往前取 N 条,0 取区间全部"
}
}
},
{
"name": "quote",
"label": "分钟行情",
"tools": ["get_stock_quote", "get_fund_quote", "get_index_quote"],
"required": ["windcode"],
"ordered_dates": [["begin", "end"]],
"patterns": {
"count": {
"pattern": "^-?\\d+$",
"message": "字段 'count' 只能是整数:正数从 begin 往后取 N 条,负数从 end 往前取 N 条,0 取区间全部"
}
}
},
{
"name": "economic_search",
"label": "宏观 EDB 搜索指标",
"tools": ["search_economic_indicator"],
"allowed": ["question"],
"required": ["question"]
},
{
"name": "economic_query",
"label": "宏观 EDB 取数",
"tools": ["query_economic_indicator_data"],
"allowed": ["question", "beginDate", "endDate", "observation"],
"required": ["question"],
"paired": [["beginDate", "endDate"]],
"mutually_exclusive": [["observation", "beginDate"], ["observation", "endDate"]],
"ordered_dates": [["beginDate", "endDate"]],
"patterns": {
"observation": {
"pattern": "^\\d+$",
"message": "字段 'observation' 只能是数字字符串(近 N 期)"
}
},
"required_one_of": [
{
"one_of": [["observation"], ["beginDate", "endDate"]],
"message": "query_economic_indicator_data 必须显式提供 observation 或 beginDate/endDate(不能只给 question)"
}
]
}
]
}
scripts/cli.mjs
#!/usr/bin/env node
// wind-mcp-skill CLI: thin JSON-envelope wrapper around Wind MCP servers
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, basename, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';
// #region 静态:版本、7 个 MCP 地址、路径、HTTP 状态码映射。只含常量,不发网络。
const SKILL_VERSION = '2.0.4';
// 本地 registry: 工具选择可在任何网络调用前失败
const SERVERS = {
stock_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_stock_data/mcp/',
label: 'Wind 股票(选股筛选 + 档案/财务/股本/事件/技术/风险 + 行情/K线/分钟)',
},
fund_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_fund_data/mcp/',
label: 'Wind 基金(基金筛选 + 档案/财务/持仓/业绩/持有人/公司 + 行情/K线/分钟)',
},
index_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_index_data/mcp/',
label: 'Wind 指数/板块(档案/基本面/技术 + 行情/K线/分钟)',
},
bond_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_bond_data/mcp/',
label: 'Wind 债券(基本档案/发债主体/行情估值/主体财务)',
},
financial_docs: {
endpoint: 'https://mcp.wind.com.cn/vserver_financial_docs/mcp/',
label: 'Wind 金融文档 RAG(公告 / 新闻)',
},
economic_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_economic_data/mcp/',
label: 'Wind EDB 宏观/行业经济指标',
},
analytics_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_analytics_data/mcp/',
label: 'Wind 通用分析数据(NL → Wind 数据)',
},
};
const PORTAL_URL = 'https://aifinmarket.wind.com.cn/#/user/overview';
const SKILL_DIR = dirname(dirname(fileURLToPath(
import.meta.url)));
const UPDATE_CHECK_PATH = join(SKILL_DIR, 'scripts', 'update-check.mjs');
const TOOL_MANIFEST_PATH = join(SKILL_DIR, 'scripts', 'tool-manifest.json');
const CALL_RULES_PATH = join(SKILL_DIR, 'scripts', 'call-rules.json');
const SKILL_NAME = basename(SKILL_DIR);
const CALL_EXAMPLES = [
`cli.mjs call stock_data search_stocks '{"question":"筛选沪深市场市值超500亿且连续5日上涨的股票"}'`,
`cli.mjs call stock_data search_stocks '{"question":"筛选港股中市值超1000亿港元的科技股"}'`,
`cli.mjs call fund_data search_funds '{"question":"筛选股票型基金中近一年收益率超20%的产品"}'`,
`cli.mjs call stock_data get_stock_basicinfo '{"question":"600519.SH公司基本档案"}'`,
`cli.mjs call stock_data get_stock_price_indicators '{"windcode":"600519.SH","indexes":"中文简称,最新成交价,涨跌幅"}'`,
`cli.mjs call fund_data get_fund_kline '{"windcode":"588200.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
`cli.mjs call stock_data get_stock_quote '{"windcode":"AAPL.O","begin":"2026-08-05","end":"2026-08-05","count":-30}'`,
`cli.mjs call index_data get_index_kline '{"windcode":"000300.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
`cli.mjs call financial_docs get_financial_news '{"query":"美联储利率政策","top_k":3}'`,
`cli.mjs call economic_data search_economic_indicator '{"question":"中国GDP相关指标有哪些"}'`,
`cli.mjs call economic_data query_economic_indicator_data '{"question":"中国GDP","observation":"10"}'`,
`cli.mjs call analytics_data get_financial_data '{"question":"查询中国A股市场过去一年的平均成交量"}'`,
];
const PRICE_INDICATOR_TOOLS = new Set(['get_stock_price_indicators', 'get_fund_price_indicators', 'get_index_price_indicators']);
const QUOTE_TOOLS = new Set(['get_stock_quote', 'get_fund_quote', 'get_index_quote']);
const HTTP_ERROR_MAP = {
401: 'AUTH_ERROR',
429: 'RATE_LIMIT_ERROR',
500: 'NETWORK_ERROR',
502: 'NETWORK_ERROR',
503: 'NETWORK_ERROR',
504: 'NETWORK_ERROR',
};
// #endregion 静态
// #region 自动更新:仅 call 成功后触发;今天已成功则跳过;detached 跑 update-check.mjs,不阻塞取数。
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function normalizePath(value) {
const normalized = resolve(value).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function updateScope() {
const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
const skillDir = normalizePath(SKILL_DIR);
return skillDir.startsWith(globalRoot + '/') ? 'global' : 'project';
}
function updateStateFile() {
return join(SKILL_DIR, 'scripts', 'update-state.json');
}
function readUpdateState() {
try {
const stateFile = updateStateFile();
if (!existsSync(stateFile)) return null;
return JSON.parse(readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
function writeUpdateStatePatch(patch) {
const stateFile = updateStateFile();
mkdirSync(dirname(stateFile), { recursive: true });
const state = { ...(readUpdateState() || {}), ...patch };
writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n');
}
function alreadyUpdatedToday() {
try {
const state = readUpdateState();
return state && state.date === todayKey() && state.status === 'success';
} catch {
return false;
}
}
function markSkillUsed() {
writeUpdateStatePatch({
lastUsedAt: new Date().toISOString(),
lastUsedPid: process.pid,
});
}
function triggerUpdateCheck() {
try {
if (!existsSync(UPDATE_CHECK_PATH)) return;
if (alreadyUpdatedToday()) return;
markSkillUsed();
const tmpDir = join(homedir(), '.cache', 'wind-aifinmarket');
mkdirSync(tmpDir, { recursive: true });
const runnerPath = join(tmpDir, `update-check-${SKILL_NAME}-${process.pid}.mjs`);
copyFileSync(UPDATE_CHECK_PATH, runnerPath);
const child = spawn('node', [runnerPath, SKILL_DIR], { detached: true, stdio: 'ignore', windowsHide: true });
child.on('error', () => { /* ignore spawn failures; update must not block CLI */ });
child.unref();
} catch { }
}
// #endregion 自动更新
// #region 信封:成功写 MCP result + cli_meta;失败统一写 {ok:false,code,message}。Agent 只读 stdout。
function normalizeSuccessPayload(value, path = '$', state = { warnings: [], tables: [], invalidPaths: [] }, dataCell = false) {
if (dataCell && value === 'INVALID') {
state.invalidPaths.push(path);
return null;
}
if (Array.isArray(value)) {
return value.map((item, index) => normalizeSuccessPayload(item, `${path}[${index}]`, state, dataCell));
}
if (!value || typeof value !== 'object') return value;
const normalized = {};
for (const [key, item] of Object.entries(value)) {
const isStructuredDataArray = Array.isArray(item) && (key === 'rows' || key === 'value');
normalized[key] = normalizeSuccessPayload(item, `${path}.${key}`, state, dataCell || isStructuredDataArray);
}
if (Array.isArray(value.rows)) {
state.tables.push({ path, actual_row_count: value.rows.length });
}
if (Object.hasOwn(value, 'excelTotalCount')) {
state.warnings.push({
code: 'UNRELIABLE_DECLARED_COUNT',
path: `${path}.excelTotalCount`,
message: 'excelTotalCount 仅保留为后端原始字段,不得据此判断结果总数或完整性。',
});
}
return normalized;
}
// 保留 MCP result 外层兼容性;只清洗可解析的 JSON 文本并附加机器可读安全元数据。
function normalizeCallSuccess(result, context = {}) {
const output = result && typeof result === 'object' ? structuredClone(result) : result;
const state = { warnings: [], tables: [], invalidPaths: [] };
if (output && Array.isArray(output.content)) {
for (const item of output.content) {
if (item?.type !== 'text' || typeof item.text !== 'string') continue;
try {
const parsed = JSON.parse(item.text);
item.text = JSON.stringify(normalizeSuccessPayload(parsed, '$', state));
} catch {
// 非 JSON 文本按后端原文透传。
}
}
}
if (state.invalidPaths.length) {
state.warnings.push({
code: 'BACKEND_INVALID_AS_NULL',
count: state.invalidPaths.length,
paths: state.invalidPaths.slice(0, 100),
truncated: state.invalidPaths.length > 100,
message: '结构化数据区中的后端字符串 INVALID 已转换为 null;表示缺失或不适用,禁止按 0 参与计算。',
});
}
if (output && typeof output === 'object') {
output.cli_meta = {
schema_version: '1.0',
server_type: context.server_type || null,
tool_name: context.tool_name || null,
completeness: state.warnings.some(warning => warning.code === 'UNRELIABLE_DECLARED_COUNT') ? 'unknown' : 'not_asserted',
tables: state.tables,
warnings: state.warnings,
};
}
return output;
}
function writeRawCallSuccess(result, context = {}) {
process.stdout.write(JSON.stringify(normalizeCallSuccess(result, context), null, 2) + '\n');
}
function writePlainSuccess(data) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}
const DEFAULT_ERROR_MESSAGES = Object.freeze({
AUTH_ERROR: '认证失败,请检查 API Key',
PARAM_TYPE_ERROR: '参数类型错误,请检查字段类型',
USAGE_ERROR: '命令用法错误,请检查输入参数',
PARAMS_FILE_ERROR: '参数文件读取失败,请检查文件路径和内容',
INVALID_PARAMS_JSON: '参数格式错误,params 必须是 JSON 对象',
ROUTE_ERROR: '工具路由失败,请检查 server_type 和 tool_name',
PARAM_VALIDATION_ERROR: '参数校验失败,请检查字段名和取值',
PARAM_CONFLICT_ERROR: '参数存在冲突,请检查输入组合',
RATE_LIMIT_ERROR: '请求过于频繁,请稍后重试',
NETWORK_ERROR: '服务暂时不可用,请稍后重试',
TOOL_RUNTIME_ERROR: '响应解析失败,请稍后重试',
SETUP_ERROR: '本地配置缺失或无效,请检查技能配置',
UNKNOWN: '调用失败,请稍后重试',
});
const MAPPED_ERROR_MESSAGE_CODES = new Set(
Object.keys(DEFAULT_ERROR_MESSAGES).filter((code) => code !== 'UNKNOWN'),
);
function normalizeErrorMessage(code, detail, metadata = {}) {
if (!MAPPED_ERROR_MESSAGE_CODES.has(code)) {
if (typeof metadata.error_message === 'string' && metadata.error_message.trim()) {
return metadata.error_message.trim();
}
if (typeof detail === 'string' && detail.trim()) {
return detail.trim().slice(0, 2000);
}
return DEFAULT_ERROR_MESSAGES.UNKNOWN;
}
if (typeof detail === 'string' && detail.trim()) {
return detail.trim().slice(0, 500);
}
return DEFAULT_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGES.UNKNOWN;
}
function writeErrorEnvelope(code, detail, metadata = {}) {
const envelope = {
ok: false,
code,
message: normalizeErrorMessage(code, detail, metadata),
};
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
}
function die(code, detail = null, exitCode = 1, metadata = {}) {
writeErrorEnvelope(code, detail, metadata);
process.exit(exitCode);
}
function exitWithUsage(usage, exitCode = 0) {
die('USAGE_ERROR', `USAGE:\n${usage}`, exitCode);
}
// #endregion 信封
// #region 认证:Key 顺序为 ~/.wind-aifinmarket/config > skill config.json > 环境变量 WIND_API_KEY。
function maskKey(key) {
if (!key || key.length < 8) return '***';
return key.slice(0, 4) + '***' + key.slice(-4);
}
// dotenv 解析: 兼容注释 / 引号 / export 前缀
function parseDotenv(content) {
const env = {};
for (const rawLine of content.split('\n')) {
let line = rawLine.replace(/^\uFEFF/, '').trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('export ')) line = line.slice(7).trim();
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
} else {
const hashIdx = val.indexOf(' #');
if (hashIdx >= 0) val = val.slice(0, hashIdx).trim();
}
env[key] = val;
}
return env;
}
function getApiKey() {
const globalConfig = join(homedir(), '.wind-aifinmarket', 'config');
if (existsSync(globalConfig)) {
try {
const env = parseDotenv(readFileSync(globalConfig, 'utf8'));
const key = env.WIND_API_KEY?.trim();
if (key) return key;
} catch { }
}
const localConfig = join(SKILL_DIR, 'config.json');
if (existsSync(localConfig)) {
try {
const cfg = JSON.parse(readFileSync(localConfig, 'utf8'));
const key = typeof cfg.wind_api_key === 'string' ? cfg.wind_api_key.trim() : '';
if (key) return key;
} catch { }
}
const envKey = process.env.WIND_API_KEY?.trim();
if (envKey) return envKey;
die('AUTH_ERROR', 'WIND_API_KEY 未配置(CLI 已完整检查:用户全局配置 > Skill 本地配置 > 环境变量)');
}
// #endregion 认证
// #region 路由:校验 server_type、tool_name 是否在 SERVERS 与 tool-manifest.json。非法则 ROUTE_ERROR。
function getServer(server_type) {
const server = SERVERS[server_type];
if (!server) {
die('ROUTE_ERROR', `未知 server_type: ${server_type}. 可用: ${Object.keys(SERVERS).join(' / ')}`);
}
return server;
}
function loadToolManifest() {
try {
// tool-manifest.json is the authority for legal server_type + tool_name combinations.
const manifest = JSON.parse(readFileSync(TOOL_MANIFEST_PATH, 'utf8'));
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('manifest 顶层必须是对象');
}
for (const [serverType, tools] of Object.entries(manifest)) {
if (!SERVERS[serverType]) {
throw new Error(`manifest 包含未知 server_type: ${serverType}`);
}
if (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !tool)) {
throw new Error(`manifest 中 ${serverType} 的工具清单必须是非空字符串数组`);
}
}
for (const serverType of Object.keys(SERVERS)) {
if (!Array.isArray(manifest[serverType])) {
throw new Error(`manifest 缺少 server_type: ${serverType}`);
}
}
return manifest;
} catch (err) {
die('UNKNOWN', `工具清单读取失败: ${err.message}`);
}
}
function validateToolSelection(server_type, toolName) {
getServer(server_type);
const manifest = loadToolManifest();
const tools = manifest[server_type];
if (!tools.includes(toolName)) {
die('ROUTE_ERROR', `工具名 "${toolName}" 不属于 server_type "${server_type}"。`);
}
}
// #endregion 路由
// #region 规则加载:读 call-rules.json,得到 K 线周期映射、按域改写工具名、参数校验规则。
function readCallRules() {
try {
return JSON.parse(readFileSync(CALL_RULES_PATH, 'utf8'));
} catch (err) {
die('UNKNOWN', `调用规则读取失败: ${err.message}`);
}
}
function prepareNormalizationRules(rules) {
return {
klinePeriodMap: new Map(Object.entries(rules.kline_period_map || {})),
toolByDomain: rules.tool_by_domain || {},
};
}
const CALL_RULES = readCallRules();
const NORMALIZATION_RULES = prepareNormalizationRules(CALL_RULES);
const KLINE_PERIOD_MAP = NORMALIZATION_RULES.klinePeriodMap;
const PUBLIC_KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.keys());
const KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.values());
const TOOL_BY_DOMAIN = NORMALIZATION_RULES.toolByDomain;
const TOOL_VALIDATION_RULES = {
basic: CALL_RULES.basic || {},
toolRules: Array.isArray(CALL_RULES.tool_rules) ? CALL_RULES.tool_rules : [],
};
const KLINE_TOOLS = new Set(TOOL_VALIDATION_RULES.toolRules.find(rule => rule.name === 'kline')?.tools || []);
// #endregion 规则加载
// #region 规范化:整理 windcode/indexes/period。不给中文名称猜交易所后缀。
function normalizeIndexes(indexes) {
if (typeof indexes !== 'string') return indexes;
return indexes.split(',').map((item) => item.trim()).filter(Boolean).join(',');
}
function normalizeWindcode(windcode) {
if (typeof windcode !== 'string') return windcode;
const raw = windcode.trim();
const upper = raw.toUpperCase();
// Keep natural-language names untouched. Wind's backend NER is responsible
// for resolving names/aliases; the CLI must not guess exchange suffixes.
if (/[\u4e00-\u9fff]/.test(raw)) return raw;
if (/^0\d{4}\.HK$/.test(upper)) return upper.slice(1);
if (/^\d{4}\.HK$/.test(upper)) return upper;
if (/^\d{6}\.(SH|SZ|BJ|OF)$/.test(upper)) return upper;
if (/^[A-Z]{1,5}\.(O|N|A|HK|SH|SZ|BJ)$/.test(upper)) return upper;
return raw;
}
function toolFamily(toolName) {
if (PRICE_INDICATOR_TOOLS.has(toolName)) return 'price';
if (KLINE_TOOLS.has(toolName)) return 'kline';
if (QUOTE_TOOLS.has(toolName)) return 'quote';
return null;
}
function normalizeCall(server_type, toolName, args) {
const family = toolFamily(toolName);
if (family) toolName = TOOL_BY_DOMAIN[family]?.[server_type] || toolName;
const normalizedArgs = { ...args };
const normalizationErrors = [];
if (typeof normalizedArgs.indexes === 'string') normalizedArgs.indexes = normalizeIndexes(normalizedArgs.indexes);
if (typeof normalizedArgs.windcode === 'string') normalizedArgs.windcode = normalizeWindcode(normalizedArgs.windcode);
// count 是整型字段:把整数字符串收敛成 number,非整数原样留给 patterns 校验拦截。
if (typeof normalizedArgs.count === 'string' && /^-?\d+$/.test(normalizedArgs.count.trim())) {
normalizedArgs.count = Number(normalizedArgs.count.trim());
}
if (KLINE_TOOLS.has(toolName) && normalizedArgs.period === undefined) normalizedArgs.period = '1d';
if (typeof normalizedArgs.period === 'string') {
const key = normalizedArgs.period.trim();
const backendPeriod = KLINE_PERIOD_MAP.get(key);
normalizedArgs.period = backendPeriod || key;
if (!backendPeriod && KLINE_PERIODS.has(key)) {
normalizationErrors.push({
message: `字段 'period' 只能是 ${Array.from(PUBLIC_KLINE_PERIODS).join('/')},日 K 请传 '1d'`,
field: 'period',
issue: 'invalid_enum',
actual: key,
allowed_values: Array.from(PUBLIC_KLINE_PERIODS),
});
}
}
return { server_type, toolName, args: normalizedArgs, normalizationErrors };
}
// #endregion 规范化
// #region 校验:按 call-rules 查必填、枚举、成对/互斥字段、日期顺序。发网络前拦住非法参数。
function validateBasicParams(params) {
const errors = [];
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return [{
code: 'PARAM_TYPE_ERROR',
message: 'params 必须是 JSON object',
field: 'params',
issue: 'invalid_type',
expected_type: 'object',
actual_type: Array.isArray(params) ? 'array' : typeof params,
}];
}
const basic = TOOL_VALIDATION_RULES.basic;
for (const key of basic.string_keys || []) {
if (!(key in params)) continue;
if (typeof params[key] !== 'string') {
errors.push({ message: `字段 '${key}' 必须是字符串`, field: key, issue: 'invalid_type', expected_type: 'string', actual_type: Array.isArray(params[key]) ? 'array' : typeof params[key] });
} else if (params[key].trim().length === 0) {
errors.push({ message: `字段 '${key}' 不能为空或全空白`, field: key, issue: 'empty_value', expected: 'non-empty string' });
}
}
return errors;
}
function hasParamValue(params, key) {
return params[key] !== undefined && params[key] !== null && params[key] !== '';
}
function resolveValidationValues(fieldRule) {
if (Array.isArray(fieldRule.values)) return fieldRule.values.map(String);
if (fieldRule.values_from === 'kline_period_map') return Array.from(KLINE_PERIODS).map(String);
return [];
}
function resolveValidationDisplayValues(fieldRule) {
if (fieldRule.values_from === 'kline_period_map') return Array.from(PUBLIC_KLINE_PERIODS).map(String);
return resolveValidationValues(fieldRule);
}
function renderValidationMessage(template, values) {
return String(template || '').replace('${values}', values.join('/'));
}
function validationErrorMessage(error) {
return typeof error === 'string' ? error : error.message;
}
function validationErrorCode(error) {
return typeof error === 'object' && error?.code ? error.code : null;
}
function validateToolParams(toolName, params) {
const errors = [];
const rules = TOOL_VALIDATION_RULES.toolRules.filter(rule => Array.isArray(rule.tools) && rule.tools.includes(toolName));
for (const rule of rules) {
const ruleLabel = rule.label || rule.name || toolName;
if (Array.isArray(rule.allowed)) {
const allowedKeys = new Set(rule.allowed);
for (const key of Object.keys(params)) {
if (!allowedKeys.has(key)) errors.push({ message: `${ruleLabel} 工具不支持字段 '${key}'`, field: key, issue: 'unknown_field', allowed_fields: [...allowedKeys] });
}
}
for (const key of rule.required || []) {
if (!hasParamValue(params, key)) errors.push({ message: `${ruleLabel} 工具缺少必填字段 '${key}'`, field: key, issue: 'missing_required', required_fields: rule.required || [] });
}
for (const [field, fieldRule] of Object.entries(rule.enum_fields || {})) {
if (!(field in params)) continue;
const values = resolveValidationValues(fieldRule);
if (!values.includes(String(params[field]))) {
const displayValues = resolveValidationDisplayValues(fieldRule);
errors.push({ message: renderValidationMessage(fieldRule.message, displayValues), field, issue: 'invalid_enum', actual: params[field], allowed_values: displayValues });
}
}
for (const fields of rule.paired || []) {
const present = fields.filter(key => hasParamValue(params, key));
if (present.length > 0 && present.length < fields.length) {
errors.push({ message: `字段 '${fields.join("' 和 '")}' 应成对填写`, fields, issue: 'incomplete_pair', expected_fields: fields });
}
}
for (const fields of rule.mutually_exclusive || []) {
const present = fields.filter(key => hasParamValue(params, key));
if (present.length > 1) {
errors.push({ message: `字段 '${fields.join('/')}' 互斥,不应同时填写`, fields, issue: 'mutually_exclusive' });
}
}
for (const [startKey, endKey] of rule.ordered_dates || []) {
if (params[startKey] && params[endKey] && params[startKey] > params[endKey]) {
errors.push({ message: `字段 '${startKey}' 不能晚于 '${endKey}'`, fields: [startKey, endKey], issue: 'invalid_order', expected: `${startKey} <= ${endKey}` });
}
}
for (const [field, patternRule] of Object.entries(rule.patterns || {})) {
if (!(field in params)) continue;
const pattern = new RegExp(patternRule.pattern);
if (!pattern.test(String(params[field]))) {
errors.push({ message: patternRule.message || `字段 '${field}' 格式不合法`, field, issue: 'invalid_format', actual: params[field], expected_pattern: patternRule.pattern });
}
}
for (const conditional of rule.required_one_of_when || []) {
if (!conditional.values?.map(String).includes(String(params[conditional.field]))) continue;
const satisfied = conditional.one_of?.some(group => group.every(key => hasParamValue(params, key)));
if (!satisfied) errors.push({ message: conditional.message || `字段 '${conditional.field}' 当前取值缺少配套参数`, field: conditional.field, issue: 'missing_conditional_fields', one_of: conditional.one_of });
}
for (const requirement of rule.required_one_of || []) {
const satisfied = requirement.one_of?.some(group => group.every(key => hasParamValue(params, key)));
if (!satisfied) errors.push({ message: requirement.message || `${ruleLabel} 工具缺少一组必填字段`, issue: 'missing_one_of', one_of: requirement.one_of });
}
}
return errors;
}
// #endregion 校验
// #region MCP:裸 HTTP JSON-RPC + SSE。先 initialize 再 tools/call。本地/网络错误由 CLI 收口,接口错误统一 backend_error。
function parseSSE(text) {
const trimmed = text.trim();
// 后端正常 SSE, 部分错误场景纯 JSON
if (trimmed.startsWith('{')) {
try {
return JSON.parse(trimmed);
} catch { }
}
const lines = text.split(/\r?\n/);
let last = null;
for (const line of lines) {
if (line.startsWith('data: ')) last = line.slice(6);
}
if (last) {
try {
return JSON.parse(last);
} catch (e) {
throw new Error(`SSE data 行 JSON 解析失败:${e.message}。原文前 200 字符:${text.slice(0, 200)}`);
}
}
throw new Error(`响应格式无法识别(既非 SSE 也非纯 JSON)。原文前 200 字符:${text.slice(0, 200)}`);
}
async function fetchWithRetry(fetchFn, url, optionsOrFactory, {
attempts = 3,
delaysMs = [300, 1000],
onAttemptError = null,
} = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const options = typeof optionsOrFactory === 'function'
? optionsOrFactory(attempt)
: optionsOrFactory;
return await fetchFn(url, options);
} catch (err) {
lastError = err;
onAttemptError?.(err, attempt, attempts);
const delayMs = delaysMs[Math.min(attempt - 1, delaysMs.length - 1)] || 0;
if (attempt < attempts && delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
async function mcpRequest(server_type, method, params, {
timeoutMs = 60_000,
} = {}) {
const server = getServer(server_type);
const apiKey = getApiKey();
const headers = {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
};
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params
});
const dieInterfaceError = (message) => {
die('backend_error', null, 1, {
error_message: String(message ?? '').slice(0, 2000),
});
};
let resp;
try {
resp = await fetchWithRetry(
fetch,
server.endpoint,
() => ({
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
}),
{
attempts: 3,
delaysMs: [300, 1000],
onAttemptError: process.env.WIND_DEBUG === '1'
? (err, attempt, total) => {
const causeCode = err?.cause?.code || err?.code || 'UNKNOWN_CAUSE';
process.stderr.write(`[wind-mcp fetch retry ${attempt}/${total}] ${causeCode}: ${err?.message || err}\n`);
}
: null,
},
);
} catch {
die('NETWORK_ERROR');
}
if (!resp.ok) {
await resp.text().catch(() => '');
die(HTTP_ERROR_MAP[resp.status] || 'NETWORK_ERROR');
}
const text = await resp.text();
let payload;
try {
payload = parseSSE(text);
} catch (err) {
die('TOOL_RUNTIME_ERROR', `${err.message} (server=${server_type})`);
}
if (payload.error) {
const msg = typeof payload.error === 'string'
? payload.error
: (payload.error.message || JSON.stringify(payload.error));
dieInterfaceError(msg);
}
if (payload.result?.isError) {
const msg = payload.result.content?.[0]?.text || JSON.stringify(payload.result);
dieInterfaceError(msg);
}
// 部分工具把业务错误包在 content[0].text 的 JSON 字符串里, 必须二次解析
const innerText = payload.result?.content?.[0]?.text;
if (typeof innerText === 'string') {
let inner;
try {
inner = JSON.parse(innerText);
} catch {
inner = null;
}
if (inner) {
if (typeof inner.mcp_tool_error_code === 'number' && inner.mcp_tool_error_code !== 0) {
const msg = inner.mcp_tool_error_msg || JSON.stringify(inner);
dieInterfaceError(msg);
}
if (inner.error && (inner.error.code || inner.error.message)) {
const errorMessage = inner.error.message || JSON.stringify(inner.error);
dieInterfaceError(errorMessage);
}
if (inner?.data && typeof inner.data === 'object') {
const numericCode = typeof inner.data.code === 'number'
? inner.data.code
: (typeof inner.data.code === 'string' && /^\d+$/.test(inner.data.code.trim()) ? Number(inner.data.code) : null);
const isSuccessCode = numericCode === 0
|| (numericCode !== null && numericCode >= 200 && numericCode < 300);
if (numericCode !== null && !isSuccessCode) {
dieInterfaceError(typeof inner.data.message === 'string' ? inner.data.message : JSON.stringify(inner.data));
}
}
}
}
return payload.result;
}
async function mcpInitializeAndCall(server_type, method, params) {
await mcpRequest(server_type, 'initialize', {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: {
name: SKILL_NAME,
version: SKILL_VERSION
},
}, {
timeoutMs: 30_000
});
return mcpRequest(server_type, method, params, {
timeoutMs: 600_000,
});
}
// #endregion MCP
// #region 命令:call 取数;list-tools 拉 schema;setup-key / open-portal 配 Key;diagnose 看更新状态。
function loadParamsInput(paramsInput) {
if (!paramsInput.startsWith('@')) {
return { jsonText: paramsInput, source: 'inline' };
}
const fileArg = paramsInput.slice(1);
if (!fileArg) {
const error = new Error('@file 缺少文件路径');
error.code = 'PARAMS_FILE_ERROR';
error.file = fileArg;
throw error;
}
const filePath = resolve(process.cwd(), fileArg);
try {
const jsonText = readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return { jsonText, source: 'file', filePath };
} catch (cause) {
const error = new Error(`无法读取 params 文件:${filePath} (${cause.code || cause.message})`);
error.code = 'PARAMS_FILE_ERROR';
error.file = filePath;
error.cause = cause;
throw error;
}
}
async function cmdCall(server_type, toolName, paramsInput) {
if (!server_type || !toolName || !paramsInput) {
exitWithUsage(
`用法:call <server_type> <tool_name> '<params_json>|@params_file'\n` +
`可用 server_type: ${Object.keys(SERVERS).join(' / ')}\n` +
`典型:\n ${CALL_EXAMPLES.join('\n ')}`,
1,
);
}
let paramsSource;
try {
paramsSource = loadParamsInput(paramsInput);
} catch (e) {
die('PARAMS_FILE_ERROR', e.message);
}
let args;
try {
args = JSON.parse(paramsSource.jsonText);
} catch (e) {
const sourceDetail = paramsSource.source === 'file'
? `文件:${paramsSource.filePath}`
: `原文:${paramsSource.jsonText.slice(0, 200)}`;
die('INVALID_PARAMS_JSON', `params JSON 解析失败:${e.message} | ${sourceDetail}`);
}
if (!args || typeof args !== 'object' || Array.isArray(args)) {
die('PARAM_TYPE_ERROR', 'params 必须是 JSON object');
}
let normalizationErrors;
({ server_type, toolName, args, normalizationErrors } = normalizeCall(server_type, toolName, args));
validateToolSelection(server_type, toolName);
const validationErrors = [...normalizationErrors, ...validateBasicParams(args)];
const paramsShapeInvalid = validationErrors.some(error => validationErrorCode(error) === 'PARAM_TYPE_ERROR' && error.field === 'params');
if (!paramsShapeInvalid) validationErrors.push(...validateToolParams(toolName, args));
if (validationErrors.length > 0) {
const explicitCode = validationErrors.map(validationErrorCode).find(Boolean);
const messages = validationErrors.map(validationErrorMessage);
const hasTypeError = validationErrors.some(error => typeof error === 'object' && error?.issue === 'invalid_type');
die(explicitCode || (hasTypeError ? 'PARAM_TYPE_ERROR' : 'PARAM_VALIDATION_ERROR'), messages.join(';'));
}
const result = await mcpInitializeAndCall(server_type, 'tools/call', {
name: toolName,
arguments: args,
_meta: { clientVersion: SKILL_VERSION },
});
return {
server_type,
tool: toolName,
result,
};
}
async function cmdListTools(server_type) {
if (!server_type) {
exitWithUsage(
`用法:list-tools <server_type>\n` +
`可用 server_type: ${Object.keys(SERVERS).join(' / ')}`,
1,
);
}
getServer(server_type);
const result = await mcpInitializeAndCall(server_type, 'tools/list', {});
return { server_type, ...result };
}
async function cmdSetupKey(...rawArgs) {
const key = rawArgs[0];
if (!key || key.startsWith('--')) {
exitWithUsage(
`用法:cli.mjs setup-key <KEY> --scope <global|skill>\n\n` +
`scope: global=全局共享;skill=仅当前 skill。调用前先让用户选择。`,
1,
);
}
let scope = null;
for (let i = 1; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === '--scope' && rawArgs[i + 1]) {
scope = rawArgs[i + 1];
break;
}
if (a.startsWith('--scope=')) {
scope = a.slice(8);
break;
}
}
if (!scope) {
exitWithUsage(
`setup-key 缺 --scope 参数。\n\n` +
`先让用户选择 global 或 skill,再重试:cli.mjs setup-key ${maskKey(key)} --scope <global|skill>`,
1,
);
}
if (!['global', 'skill'].includes(scope)) {
die('SETUP_ERROR', `setup-key 未知 scope: ${scope} (可选: global / skill)`);
}
let file;
try {
if (scope === 'global') {
const dir = join(homedir(), '.wind-aifinmarket');
if (!existsSync(dir)) mkdirSync(dir, {
recursive: true
});
file = join(dir, 'config');
let lines = [];
if (existsSync(file)) {
lines = readFileSync(file, 'utf8').split('\n')
.filter(l => l.length > 0 && !/^\s*(export\s+)?WIND_API_KEY\s*=/.test(l));
}
lines.push(`WIND_API_KEY=${key}`);
writeFileSync(file, lines.join('\n') + '\n', {
mode: 0o600
});
} else {
file = join(SKILL_DIR, 'config.json');
writeFileSync(file, JSON.stringify({ wind_api_key: key }, null, 2) + '\n', { mode: 0o600 });
}
} catch (err) {
die('SETUP_ERROR', `配置写入失败 (scope=${scope}, path=${file || 'n/a'}): ${err.message}`);
}
return {
scope,
path: file,
key_masked: maskKey(key),
next: '现在可以重试原 Wind 调用',
};
}
async function cmdOpenPortal() {
const platform = process.platform;
let bin, args;
if (platform === 'darwin') {
bin = 'open';
args = [PORTAL_URL];
} else if (platform === 'win32') {
bin = 'cmd';
args = ['/c', 'start', '', PORTAL_URL];
} else {
bin = 'xdg-open';
args = [PORTAL_URL];
}
let spawnError = null;
try {
const child = spawn(bin, args, {
stdio: 'ignore',
detached: true,
windowsHide: true
});
child.unref();
spawnError = await new Promise((resolve) => {
child.once('error', resolve);
setTimeout(() => resolve(null), 300);
});
} catch (err) {
spawnError = err;
}
const data = {
url: PORTAL_URL,
platform,
spawn_command: `${bin} ${args.join(' ')}`,
flow_note: '未登录时会自动跳转到登录页(/#/login);登录完成后回到 overview 页面即可获取 API Key。',
fallback_message: `如果浏览器没有自动弹出,请手动访问:${PORTAL_URL}`,
};
if (spawnError) {
die('SETUP_ERROR', `本地无法启动浏览器: ${spawnError.message} | 用户应手动打开 ${data.url}`);
}
return data;
}
// 诊断: 输出自动更新状态
async function cmdDiagnose() {
let updateState = null;
try {
const stateFile = updateStateFile();
if (existsSync(stateFile)) {
updateState = JSON.parse(readFileSync(stateFile, 'utf8'));
}
} catch {
updateState = { status: 'unreadable' };
}
return {
platform: process.platform,
node_pid: process.pid,
update_scope: updateScope(),
update_state_file: updateStateFile(),
update_state: updateState,
next_update_needed: !alreadyUpdatedToday(),
};
}
// #endregion 命令
// #region 主入口:IS_MAIN 避免测试 import 时跑副作用。无参打 USAGE;仅 call 成功才触发更新检查。
const IS_MAIN = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (IS_MAIN) runMain();
function runMain() {
const [cmd, ...args] = process.argv.slice(2);
const USAGE =
`wind-mcp-skill\n` +
`访问万得 Wind 金融数据(按数据域分类调用)\n\n` +
`用法:\n` +
` cli.mjs call <server_type> <tool_name> '<params_json>|@params_file'\n` +
` cli.mjs list-tools <server_type> # 获取后端官方工具描述和 inputSchema\n` +
` cli.mjs open-portal # 打开万得开发者中心拿 API Key\n` +
` cli.mjs setup-key <KEY> --scope <global|skill> # 配置 API Key(先问用户存放位置)\n\n` +
`可用 server_type:\n` +
Object.entries(SERVERS).map(([k, v]) => ` ${k.padEnd(20)}${v.label}`).join('\n') + '\n\n' +
`典型:\n` +
` ${CALL_EXAMPLES.join('\n ')}`;
const commands = {
call: () => cmdCall(args[0], args[1], args[2]),
'list-tools': () => cmdListTools(args[0]),
'open-portal': () => cmdOpenPortal(),
'setup-key': () => cmdSetupKey(...args),
diagnose: () => cmdDiagnose(),
};
if (!cmd) {
// help: 直接输出 USAGE 纯文本
process.stdout.write(USAGE + '\n');
process.exit(0);
}
if (!commands[cmd]) {
die('USAGE_ERROR', `未知命令: ${cmd}\nUSAGE:\n${USAGE}`);
}
commands[cmd]()
.then((data) => {
if (cmd === 'call') {
// call: 透传 result 内容 (parse JSON if applicable, else raw text)
writeRawCallSuccess(data?.result, { server_type: data?.server_type, tool_name: data?.tool });
setTimeout(triggerUpdateCheck, 0);
} else {
// open-portal / setup-key: 直接输出结构化数据 (无 envelope 包裹)
writePlainSuccess(data);
}
})
.catch((err) => {
die('UNKNOWN', `执行失败: ${err.message || err}${err.stack ? ' | stack: ' + err.stack.slice(0, 300) : ''}`);
});
}
// #endregion 主入口
scripts/update-check.mjs
#!/usr/bin/env node
// Daily background updater for wind-mcp-skill.
// The CLI starts this script detached; failures are recorded but never block data calls.
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import {
createHash
} from 'node:crypto';
import {
spawnSync
} from 'node:child_process';
import {
homedir
} from 'node:os';
import {
basename,
dirname,
join,
resolve
} from 'node:path';
import {
fileURLToPath
} from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(
import.meta.url));
const SKILL_DIR = process.argv[2] ? resolve(process.argv[2]) : dirname(SCRIPT_DIR);
const SKILL_SCRIPTS_DIR = join(SKILL_DIR, 'scripts');
const LOCK_FILE = join(SKILL_SCRIPTS_DIR, 'update.lock');
const SKILL_NAME = basename(SKILL_DIR);
const DEFAULT_SOURCES = [
'Wind-Information-Co-Ltd/wind-skills',
'git@gitee.com:wind_info/wind-skills.git',
];
const LOCK_STALE_MS = 30 * 60 * 1000;
const QUIET_MS = 10 * 1000;
const MAX_WAIT_MS = 10 * 60 * 1000;
function normalizePath(value) {
const normalized = resolve(value).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function updateScope() {
const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
const skillDir = normalizePath(SKILL_DIR);
return skillDir.startsWith(`${globalRoot}/`) ? 'global' : 'project';
}
function projectRoot() {
return resolve(SKILL_DIR, '..', '..', '..');
}
function uniquePaths(paths) {
const seen = new Set();
const result = [];
for (const path of paths.filter(Boolean).map((value) => resolve(value))) {
const key = normalizePath(path);
if (seen.has(key)) continue;
seen.add(key);
result.push(path);
}
return result;
}
function updateCommand() {
const command = ['npx', 'skills', 'update', SKILL_NAME, '-y'];
if (updateScope() === 'global') command.push('-g');
return command;
}
function projectLockCandidates() {
const roots = [projectRoot(), process.cwd(), process.env.INIT_CWD];
let current = resolve(SKILL_DIR);
while (true) {
roots.push(current);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return uniquePaths(
roots.filter(Boolean).map((root) => join(root, 'skills-lock.json')),
);
}
function globalLockCandidates() {
const xdg = process.env.XDG_STATE_HOME;
return uniquePaths([
xdg ? join(xdg, 'skills', '.skill-lock.json') : null,
join(homedir(), '.agents', '.skill-lock.json'),
]);
}
function lockFileCandidates() {
const globalCandidates = globalLockCandidates();
const projectCandidates = projectLockCandidates();
return updateScope() === 'global' ?
uniquePaths([...globalCandidates, ...projectCandidates]) :
uniquePaths([...projectCandidates, ...globalCandidates]);
}
function readLockInfo() {
const candidates = lockFileCandidates();
let firstExistingFile = null;
for (const file of candidates) {
try {
if (!existsSync(file)) continue;
firstExistingFile ||= file;
const data = JSON.parse(readFileSync(file, 'utf8'));
const entry = data?.skills?.[SKILL_NAME] || null;
if (entry) return {
file,
entry,
candidates
};
} catch {}
}
return {
file: firstExistingFile || candidates[0] || null,
entry: null,
candidates,
};
}
function readLockEntry() {
return readLockInfo().entry;
}
function isGiteeSource(entry) {
const values = [entry?.sourceType, entry?.source, entry?.sourceUrl]
.filter(Boolean)
.map((value) => String(value).toLowerCase());
return values.some((value) => value.includes('gitee'));
}
function sourceUrl(entry) {
if (!entry) return null;
if (entry.sourceUrl) return entry.sourceUrl;
if (entry.sourceType === 'github' && /^[^/\s]+\/[^/\s]+$/.test(entry.source || '')) {
return `https://github.com/${entry.source}.git`;
}
if (
(entry.sourceType === 'gitee' || entry.sourceType === 'git') &&
/^[^/\s]+\/[^/\s]+$/.test(entry.source || '')
) {
return `https://gitee.com/${entry.source}.git`;
}
return entry.source || null;
}
function updateEnv() {
return {
...process.env
};
}
function remoteHead(entry) {
const source = sourceUrl(entry);
if (!source) return null;
try {
const result = spawnSync('git', ['ls-remote', source, 'HEAD'], {
encoding: 'utf8',
env: updateEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60 * 1000,
windowsHide: true,
});
if (result.status !== 0) return null;
const head = (result.stdout || '').trim().split(/\s+/)[0];
return /^[0-9a-f]{40}$/i.test(head) ? head : null;
} catch {
return null;
}
}
function addCommandForSource(source) {
if (!source) return null;
const command = ['npx', 'skills', 'add', source, '--skill', SKILL_NAME, '-y'];
if (updateScope() === 'global') command.push('-g');
return command;
}
function addCommand(entry) {
return addCommandForSource(sourceUrl(entry));
}
function fallbackAddCommands(entry) {
const sources = [sourceUrl(entry), ...DEFAULT_SOURCES];
const seen = new Set();
return sources
.filter(Boolean)
.filter((source) => {
const key = String(source).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(addCommandForSource)
.filter(Boolean);
}
function commandForUpdate() {
const entry = readLockEntry();
if (isGiteeSource(entry)) {
const command = addCommand(entry);
if (command) {
return {
command,
method: 'add',
sourceType: entry?.sourceType || null,
};
}
}
return {
command: updateCommand(),
method: 'update',
sourceType: entry?.sourceType || null,
};
}
function updateStateFile() {
return join(SKILL_SCRIPTS_DIR, 'update-state.json');
}
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function readState() {
try {
const stateFile = updateStateFile();
if (!existsSync(stateFile)) return null;
return JSON.parse(readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
function alreadyUpdatedToday() {
const state = readState();
if (!state || state.date !== todayKey() || state.status !== 'success') return false;
const entry = readLockEntry();
if (!entry || isGiteeSource(entry)) return true;
const head = remoteHead(entry);
return !head || head === state.lastAppliedRemoteHead;
}
function lastUsedAt() {
try {
const state = readState();
const timestamp = new Date(state?.lastUsedAt).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
} catch {
return 0;
}
}
function quietLongEnough() {
const last = lastUsedAt();
return last === 0 || Date.now() - last >= QUIET_MS;
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
async function waitForQuietWindow() {
const startedAt = Date.now();
while (!quietLongEnough()) {
if (Date.now() - startedAt >= MAX_WAIT_MS) return false;
await sleep(QUIET_MS);
}
return true;
}
function acquireLock() {
try {
if (!existsSync(SKILL_SCRIPTS_DIR)) mkdirSync(SKILL_SCRIPTS_DIR, {
recursive: true
});
try {
const st = statSync(LOCK_FILE);
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) unlinkSync(LOCK_FILE);
} catch {}
return openSync(LOCK_FILE, 'wx');
} catch {
return null;
}
}
function releaseLock(fd) {
try {
if (fd !== null) closeSync(fd);
} catch {}
try {
unlinkSync(LOCK_FILE);
} catch {}
}
function writeState(patch) {
const {
command,
method,
sourceType
} = commandForUpdate();
const lock = readLockInfo();
const stateFile = updateStateFile();
const state = {
date: todayKey(),
scope: updateScope(),
lockFile: lock.file,
lockFound: Boolean(lock.entry),
command: command.join(' '),
method,
sourceType,
updatedAt: new Date().toISOString(),
...patch,
};
mkdirSync(dirname(stateFile), {
recursive: true
});
writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`);
}
function hashSkillDir() {
const hash = createHash('sha256');
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir, {
withFileTypes: true
})) {
const full = join(dir, entry.name);
const rel = full.slice(SKILL_DIR.length + 1).replace(/\\/g, '/');
if (rel === 'config.json' || rel === 'scripts/update-state.json') continue;
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile()) {
files.push({
full,
rel
});
}
}
}
walk(SKILL_DIR);
files.sort((a, b) => a.rel.localeCompare(b.rel));
for (const file of files) {
hash.update(file.rel);
hash.update('\0');
hash.update(readFileSync(file.full));
hash.update('\0');
}
return hash.digest('hex');
}
function runSkillCommand(command, method) {
const cwd = updateScope() === 'global' ? homedir() : projectRoot();
const isWin = process.platform === 'win32';
const bin = isWin ? 'cmd.exe' : 'npx';
const args = isWin ? ['/d', '/s', '/c', command.join(' ')] : command.slice(1);
const result = spawnSync(bin, args, {
cwd,
encoding: 'utf8',
env: updateEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10 * 60 * 1000,
windowsHide: true,
});
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
const failedByOutput =
/failed to (update|add|install)|No installed skills found matching/i.test(output);
return {
command,
method,
result,
output,
ok: result.status === 0 && !failedByOutput,
error: result.error ?
String(result.error.message || result.error) :
failedByOutput ?
`npx skills ${method} reported failure` :
null,
};
}
function runUpdate() {
const entry = readLockEntry();
const {
command,
method,
sourceType
} = commandForUpdate();
const state = readState();
const beforeRemoteHead = remoteHead(entry);
const remoteChanged = Boolean(beforeRemoteHead && beforeRemoteHead !== state?.lastAppliedRemoteHead);
const beforeHash = hashSkillDir();
let attempt = runSkillCommand(command, method);
let usedFallback = false;
let fallbackReason = null;
if ((!attempt.ok || (attempt.ok && remoteChanged && beforeHash === hashSkillDir())) && method !== 'add') {
const fallbackCommands = fallbackAddCommands(entry);
if (fallbackCommands.length > 0) {
fallbackReason = entry ?
attempt.ok ?
'remote changed but update did not change local files' :
'update failed' :
'lock entry missing or update did not find installed skill';
const outputs = [attempt.output].filter(Boolean);
usedFallback = true;
for (const fallbackCommand of fallbackCommands) {
const fallback = runSkillCommand(fallbackCommand, 'add');
outputs.push(fallback.output);
attempt = {
...fallback,
output: outputs.filter(Boolean).join('\n\n--- fallback: npx skills add ---\n\n'),
error: fallback.ok ? null : fallback.error || attempt.error,
};
if (fallback.ok) break;
}
}
}
const afterHash = hashSkillDir();
writeState({
status: attempt.ok ? 'success' : 'failed',
finishedAt: new Date().toISOString(),
exitCode: attempt.result.status,
method: attempt.method,
usedFallback,
fallbackReason,
sourceType,
command: attempt.command.join(' '),
error: attempt.error,
remoteHead: beforeRemoteHead,
remoteChanged,
lastAppliedRemoteHead: attempt.ok ?
beforeRemoteHead || state?.lastAppliedRemoteHead || null :
state?.lastAppliedRemoteHead || null,
changed: beforeHash !== afterHash,
beforeHash,
afterHash,
output: attempt.output.slice(-2000),
});
}
async function main() {
if (alreadyUpdatedToday()) return;
const fd = acquireLock();
if (fd === null) return;
try {
if (alreadyUpdatedToday()) return;
if (!(await waitForQuietWindow())) {
writeState({
status: 'deferred',
finishedAt: new Date().toISOString(),
exitCode: null,
error: 'skill kept being used; update deferred after max wait',
changed: false,
});
return;
}
writeState({
status: 'updating',
startedAt: new Date().toISOString(),
exitCode: null,
changed: false,
});
runUpdate();
} finally {
releaseLock(fd);
}
}
main().catch((err) => {
try {
writeState({
status: 'failed',
finishedAt: new Date().toISOString(),
exitCode: null,
error: String(err?.message || err),
changed: false,
});
} catch {}
});