chore: 完善 CLI 逻辑 (#70)

* chore: 完善 CLI 逻辑

* chore: 新增 g6  bm25 检索词

* chore: 异常捕获优化

* chore: --json 修改为 --output
This commit is contained in:
Joel Alan
2026-04-23 11:13:37 +08:00
committed by GitHub
parent dbd880ac7b
commit 4a3b71d47a
9 changed files with 306 additions and 72 deletions
+13
View File
@@ -96,11 +96,20 @@ npm install -g @antv/chart-visualization-skills
# Retrieve skills by query
antv retrieve "bar chart" --library g2 --topk 10 --content
# Retrieve skills and output as JSON
antv retrieve "bar chart" --library g2 --output json
# List all available skills
antv list --library g2 --category core
# List skills and output as JSON
antv list --output json
# Show skill info
antv info --library g2
# Show skill info as JSON
antv info --library g2 --output json
```
**Usage for the command**:
@@ -116,9 +125,13 @@ Options:
Commands:
retrieve [options] <query> Search for skills matching a query
get [options] <id> Get a skill by its exact ID
list [options] List all available skills
info [options] Show skill info from SKILL.md
help [command] display help for command
Options shared by all commands:
--output <format> Output format: json | text (default: "text")
```
### API Usage
+52 -13
View File
@@ -1,30 +1,69 @@
import { retrieve as _retrieve, getSkillInfo } from './core/retriever';
import type { Skill, SkillInfo } from './core/types';
export type { Skill, SkillInfo };
import {
retrieve as _retrieve,
getSkillById as _getSkillById,
getSkillInfo,
availableLibraries
} from './core/retriever';
import type { Skill, SkillInfo, RetrieveOptions } from './core/types';
export type { Skill, SkillInfo, RetrieveOptions };
/**
* Retrieve skills based on a query.
*
* @param query The search query for skills.
* @param library The library to search within (default: 'g2').
* @param topk The number of top results to return (default: 7).
* @param content Whether to include markdown content body (without frontmatter) of each skill (default: false).
* @example retrieve('bar chart', 'g2', 1);
* @returns An array of skills matching the query.
* Preferred: pass an options object.
* @example retrieve('bar chart', { library: 'g2', topK: 5, content: true })
*
* Legacy positional signature still supported for backwards compatibility.
* @example retrieve('bar chart', 'g2', 5, true)
*/
export function retrieve(query: string, library = 'g2', topk = 7, content = false): Skill[] {
return _retrieve(query, { library, topK: topk, content });
export function retrieve(query: string, options?: RetrieveOptions): Skill[];
/** @deprecated Use the options-object overload instead. */
export function retrieve(
query: string,
library?: string,
topk?: number,
content?: boolean
): Skill[];
export function retrieve(
query: string,
libraryOrOpts?: string | RetrieveOptions,
topk = 7,
content = false
): Skill[] {
if (typeof libraryOrOpts === 'string' || libraryOrOpts === undefined) {
return _retrieve(query, { library: libraryOrOpts, topK: topk, content });
}
return _retrieve(query, libraryOrOpts);
}
/**
* Get a single skill by its exact ID.
*
* @param id The skill ID (e.g. 'g2-mark-bar').
* @param library Optional: restrict the search to a specific library.
* @returns The skill with full content, or undefined if not found.
* @example getSkillById('g2-mark-bar')
*/
export function getSkillById(id: string, library?: string): Skill | undefined {
return _getSkillById(id, library);
}
/**
* Get skill info embedded in the library index.
*
* @param library The library to get info for (default: 'g2').
* @example info('g2');
* @example info('g2')
* @returns The skill info, or undefined if not available.
*/
export function info(library = 'g2'): SkillInfo | undefined {
return getSkillInfo(library);
}
/**
* Return the list of libraries that have a built index on disk.
* @example libraries() // ['g2', 'g6']
*/
export function libraries(): string[] {
return availableLibraries();
}
+43
View File
@@ -0,0 +1,43 @@
import { Command } from 'commander';
import { getSkillById } from '../core/retriever';
export function registerGetCommand(program: Command): void {
program
.command('get <id>')
.description('Get a skill by its exact ID')
.option('--library <lib>', 'Restrict search to a specific library')
.option('--output <format>', 'Output format: json | text', 'text')
.action((id: string, opts: { library?: string; output: string }) => {
const skill = getSkillById(id, opts.library);
if (!skill) {
const hint = opts.library ? ` in library "${opts.library}"` : '';
console.error(`Skill not found: "${id}"${hint}`);
console.error('Tip: run `antv list` to browse available skill IDs.');
process.exit(1);
}
if (opts.output === 'json') {
console.log(JSON.stringify(skill, null, 2));
return;
}
console.log(`${'─'.repeat(50)}`);
console.log(`${skill.title} (${skill.id})`);
console.log(`Library : ${skill.library} v${skill.version}`);
console.log(
`Category : ${skill.category}${skill.subcategory ? '/' + skill.subcategory : ''}`
);
console.log(`Tags : ${skill.tags.join(', ')}`);
console.log(`Desc : ${skill.description}`);
if (skill.use_cases.length)
console.log(`Cases : ${skill.use_cases.join(' / ')}`);
if (skill.anti_patterns.length)
console.log(`Avoid : ${skill.anti_patterns.join(' / ')}`);
if (skill.related.length)
console.log(`Related : ${skill.related.join(', ')}`);
if (skill.content) {
console.log(`\n${skill.content}`);
}
});
}
+8 -2
View File
@@ -6,11 +6,17 @@ export function registerInfoCommand(program: Command): void {
.command('info')
.description('Show skill info from SKILL.md')
.option('--library <lib>', 'Library to show info for (g2 or g6)', 'g2')
.action((opts: { library: string }) => {
.option('--output <format>', 'Output format: json | text', 'text')
.action((opts: { library: string; output: string }) => {
const skill = getSkillInfo(opts.library);
if (!skill) {
console.log(`No skill info found for library: ${opts.library}`);
console.error(`No skill info found for library: ${opts.library}`);
process.exit(1);
}
if (opts.output === 'json') {
console.log(JSON.stringify(skill, null, 2));
return;
}
+40 -22
View File
@@ -9,30 +9,48 @@ export function registerListCommand(program: Command): void {
.option('--library <lib>', 'Filter by library (g2 or g6)')
.option('--category <cat>', 'Filter by category')
.option('--tags <tags>', 'Filter by tags (comma-separated)')
.option('--difficulty <level>', 'Filter by difficulty (beginner|intermediate|advanced)')
.action((opts: { library?: string; category?: string; tags?: string; difficulty?: string }) => {
const skills = listSkills({
library: opts.library,
category: opts.category || null,
tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : [],
difficulty: opts.difficulty || null,
});
.option(
'--difficulty <level>',
'Filter by difficulty (beginner|intermediate|advanced)'
)
.option('--output <format>', 'Output format: json | text', 'text')
.action(
(opts: {
library?: string;
category?: string;
tags?: string;
difficulty?: string;
output: string;
}) => {
const skills = listSkills({
library: opts.library,
category: opts.category || null,
tags: opts.tags ? opts.tags.split(',').map((t) => t.trim()) : [],
difficulty: opts.difficulty || null
});
const summary = `Total skills found: ${skills.length}`;
const groupedByLibrary: Record<string, Skill[]> = skills.reduce((acc: Record<string, Skill[]>, skill) => {
if (!acc[skill.library]) {
acc[skill.library] = [];
if (opts.output === 'json') {
console.log(JSON.stringify(skills, null, 2));
return;
}
acc[skill.library].push(skill);
return acc;
}, {});
const content = Object.entries(groupedByLibrary).map(([lib, libSkills]) => {
const skillList = libSkills.map(skill => ` - ${skill.id}: ${skill.title}`).join('\n');
return `${lib.toUpperCase()}, ${libSkills.length} documents found:\n${skillList}`;
}).join('\n\n');
const groupedByLibrary: Record<string, Skill[]> = skills.reduce(
(acc: Record<string, Skill[]>, skill) => {
if (!acc[skill.library]) acc[skill.library] = [];
acc[skill.library].push(skill);
return acc;
},
{}
);
console.log(`${summary}\n\n${content}`);
});
console.log(`Total skills found: ${skills.length}\n`);
for (const [lib, libSkills] of Object.entries(groupedByLibrary)) {
console.log(`${lib.toUpperCase()} (${libSkills.length} skills)`);
for (const skill of libSkills) {
console.log(` ${skill.id.padEnd(48)} ${skill.title}`);
}
console.log();
}
}
);
}
+40 -20
View File
@@ -8,26 +8,46 @@ export function registerRetrieveCommand(program: Command): void {
.option('--library <lib>', 'Filter by library (g2 or g6)')
.option('--topk <n>', 'Number of results to return', '7')
.option('--content', 'Include markdown content body')
.action((query: string, opts: { library?: string; topk: string; content?: true }) => {
const topK = parseInt(opts.topk, 10) || 7;
const skills = retrieve(query, { library: opts.library, topK, content: !!opts.content });
.option('--output <format>', 'Output format: json | text', 'text')
.action(
(
query: string,
opts: { library?: string; topk: string; content?: true; output: string }
) => {
const topK = parseInt(opts.topk, 10) || 7;
const skills = retrieve(query, {
library: opts.library,
topK,
content: !!opts.content
});
if (skills.length === 0) {
console.log('No skills found.');
return;
if (opts.output === 'json') {
console.log(JSON.stringify(skills, null, 2));
return;
}
if (skills.length === 0) {
console.log('No skills found.');
return;
}
console.log(`Total ${skills.length} documents found:`);
for (const [i, skill] of skills.entries()) {
console.log(`\n${'─'.repeat(50)}`);
console.log(`[${i + 1}] ${skill.title} (${skill.id})`);
console.log(
` Category : ${skill.category}${skill.subcategory ? '/' + skill.subcategory : ''}`
);
console.log(` Tags : ${skill.tags.join(', ')}`);
console.log(` Desc : ${skill.description}`);
if (skill.use_cases.length)
console.log(` Cases : ${skill.use_cases.join(' / ')}`);
if (skill.anti_patterns.length)
console.log(` Avoid : ${skill.anti_patterns.join(' / ')}`);
if (skill.content) {
console.log(`\n${skill.content}`);
}
}
}
const summary = `Total ${skills.length} documents found:`;
const content = skills.map((skill, i) => `
${i + 1}. ${skill.title}
ID: ${skill.id}
Category: ${skill.category}${skill.subcategory ? '/' + skill.subcategory : ''}
Tags: ${skill.tags.join(', ')}
Description: ${skill.description}
Content: ${skill.content ?? '' }
Use Cases: ${skill.use_cases.join(', ')}
Anti Patterns: ${skill.anti_patterns.join(', ')}`.trim());
console.log(`${summary}\n${content.join('\n')}`);
});
);
}
+32
View File
@@ -31,6 +31,7 @@ const STOP_WORDS = new Set([
]);
const PRIMARY_CHART_TOKENS = new Set([
// G2 statistical chart types
'beeswarm', 'sankey', 'chord', 'treemap', 'sunburst', 'boxplot', 'waterfall', 'funnel',
'gauge', 'gantt', 'wordcloud', 'candlestick', 'bullet', 'density', 'liquid', 'venn',
'pack', 'spiral', 'contour', 'violin', 'ridgeline', 'marimekko', 'mosaic', 'bump',
@@ -38,6 +39,10 @@ const PRIMARY_CHART_TOKENS = new Set([
'蜂群图', '漏斗图', '玫瑰图', '仪表盘', '甘特图', '词云', '箱线图', '旭日图',
'矩形树图', '桑基图', '和弦图', '密度图', '打包图', '瀑布图', 'K线图', '子弹图',
'韦恩图', '液体图', '螺旋图', '小提琴图',
// G6 graph types and layouts
'dagre', 'fishbone', 'mindmap', 'radial', 'dendrogram',
'关系图', '网络图', '拓扑图', '流程图', '思维导图', '鱼骨图', '组织架构图', '知识图谱',
'层次图', '辐射图',
]);
const SYNONYMS = new Map<string, string[]>([
@@ -74,6 +79,28 @@ const SYNONYMS = new Map<string, string[]>([
['暗色', ['theme', 'dark']], ['深色', ['theme', 'dark']],
['动画', ['animation', 'animate']], ['交互', ['interaction']],
['框选', ['brush']], ['高亮', ['highlight', 'elementhighlight']],
// G6: Chinese → English
['关系图', ['network', 'graph']], ['网络图', ['network', 'graph']],
['拓扑图', ['network', 'topology']], ['知识图谱', ['network', 'knowledge']],
['流程图', ['flow', 'dag', 'dagre']], ['有向无环图', ['dag', 'dagre']],
['思维导图', ['mindmap']], ['组织架构图', ['tree', 'dendrogram']],
['鱼骨图', ['fishbone']], ['层次图', ['dagre', 'hierarchy']],
['辐射图', ['radial']], ['树状图', ['tree', 'dendrogram']],
['力导向', ['force']], ['力导向布局', ['force']],
['层次布局', ['dagre', 'hierarchy']], ['环形布局', ['circular']],
['辐射布局', ['radial']], ['网格布局', ['grid']],
['节点', ['node']], ['连线', ['edge', 'link']], ['组合', ['combo']],
['套索', ['lasso']], ['折叠', ['collapse']], ['展开', ['expand']],
['缩略图', ['minimap']], ['时间轴', ['timebar']], ['工具栏', ['toolbar']],
['拖拽画布', ['drag-canvas']], ['缩放画布', ['zoom-canvas']],
['点击选中', ['click-select']], ['拖拽元素', ['drag-element']],
// G6: English → Chinese
['dagre', ['流程图', '层次']], ['fishbone', ['鱼骨图']],
['mindmap', ['思维导图']], ['radial', ['辐射']],
['dendrogram', ['树状图', '组织架构']],
['node', ['节点']], ['combo', ['组合']],
['minimap', ['缩略图']], ['timebar', ['时间轴']], ['toolbar', ['工具栏']],
['lasso', ['套索', '框选']], ['collapse', ['折叠']], ['expand', ['展开']],
['line', ['折线']], ['bar', ['柱状']], ['pie', ['饼图']],
['interval', ['柱状']], ['scatter', ['散点']], ['point', ['散点']],
['area', ['面积']], ['heatmap', ['热力']], ['cell', ['热力']],
@@ -88,6 +115,7 @@ const SYNONYMS = new Map<string, string[]>([
]);
const EXTRA_DICT = new Set([
// common interaction/config
'点击', '拖拽', '缩放', '悬停', '选中', '过滤',
'渲染', '更新', '刷新', '加载', '切换', '联动',
'指标', '目标', '数值', '百分比', '进度', '占比',
@@ -96,6 +124,10 @@ const EXTRA_DICT = new Set([
'布局', '容器', '宽度', '高度', '间距', '边距',
'颜色', '透明度', '圆角', '虚线', '实线',
'字体', '字号', '粗细', '旋转', '偏移',
// G6-specific
'节点', '连线', '组合', '套索', '折叠', '展开',
'关系', '拓扑', '层次', '辐射', '力导向',
'缩略图', '时间轴', '工具栏', '画布', '边框',
]);
const _DICT_TERMS = [
+59 -13
View File
@@ -10,6 +10,18 @@ const DEFAULT_LIBRARY = 'g2';
const bm25Cache = new Map<string, BM25Index>();
/**
* Return the list of libraries that have a built index on disk.
*/
export function availableLibraries(): string[] {
if (!fs.existsSync(DEFAULT_INDEX_DIR)) return [];
return fs
.readdirSync(DEFAULT_INDEX_DIR)
.filter((f) => f.endsWith('.index.json'))
.map((f) => f.replace('.index.json', ''))
.sort();
}
/**
* Load the index JSON file.
* @param library The library name.
@@ -19,7 +31,13 @@ export function loadIndex(library: string): SkillIndex {
const indexFile = path.join(DEFAULT_INDEX_DIR, `${library}.index.json`);
if (!fs.existsSync(indexFile)) {
throw new Error(`Index file not found: ${indexFile}. Run build first.`);
// Only scan the directory on the error path to build a helpful message.
const libs = availableLibraries();
throw new Error(
libs.length > 0
? `Unknown library: "${library}". Available: ${libs.join(', ')}`
: `Index file not found for "${library}". Run build first.`
);
}
return JSON.parse(fs.readFileSync(indexFile, 'utf-8'));
@@ -47,10 +65,24 @@ function getBM25Index(library: string): BM25Index {
* @param options Options to customize the retrieval.
* @returns An array of skills matching the query.
*/
export function retrieve(query: string, options: RetrieveOptions = {}): Skill[] {
const { library = DEFAULT_LIBRARY, topK = 7, content = false } = options;
const index = getBM25Index(library);
const skills = index.search(query, topK).map(({ skill }) => skill);
export function retrieve(
query: string,
options: RetrieveOptions = {}
): Skill[] {
const { library, topK = 7, content = false } = options;
let skills: Skill[];
if (library) {
skills = getBM25Index(library)
.search(query, topK)
.map((r) => r.skill);
} else {
skills = availableLibraries()
.flatMap((lib) => getBM25Index(lib).search(query, topK))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((r) => r.skill);
}
if (!content) {
return skills.map(({ content, ...skill }) => skill);
@@ -68,21 +100,35 @@ export function getSkillInfo(library = DEFAULT_LIBRARY): SkillIndex['info'] {
return loadIndex(library).info;
}
/**
* Get a single skill by its exact ID, searching across all available libraries
* unless a specific library is provided.
* @param id The skill ID.
* @param library Optional library to restrict the search.
* @returns The skill (with content), or undefined if not found.
*/
export function getSkillById(id: string, library?: string): Skill | undefined {
const libs = library ? [library] : availableLibraries();
for (const lib of libs) {
const { skills } = loadIndex(lib);
const found = skills.find((s) => s.id === id);
if (found) return found;
}
return undefined;
}
/**
* List all the skills, optionally filtered by library, category, tags, or difficulty.
* @param options Options to filter the skills.
* @returns An array of skills matching the filters.
*/
export function listSkills(options: ListOptions = {}): Skill[] {
const {
library = DEFAULT_LIBRARY,
category = null,
tags = [],
difficulty = null
} = options;
const { skills } = loadIndex(library);
const { library, category = null, tags = [], difficulty = null } = options;
return skills.filter((skill) => {
const libs = library ? [library] : availableLibraries();
const allSkills = libs.flatMap((lib) => loadIndex(lib).skills);
return allSkills.filter((skill) => {
if (category && skill.category !== category) return false;
if (difficulty && skill.difficulty !== difficulty) return false;
if (tags.length > 0 && !tags.some((t) => skill.tags.includes(t)))
+19 -2
View File
@@ -5,6 +5,7 @@ import path from 'path';
import { registerRetrieveCommand } from './commands/retrieve';
import { registerListCommand } from './commands/list';
import { registerInfoCommand } from './commands/info';
import { registerGetCommand } from './commands/get';
const pkg = require(path.resolve(__dirname, '../package.json'));
@@ -13,10 +14,26 @@ const program = new Command();
program
.name('antv')
.description('CLI tool for AntV chart visualization skills retrieval')
.version(pkg.version);
.version(pkg.version)
.option('--debug', 'Show full stack trace on error');
registerRetrieveCommand(program);
registerGetCommand(program);
registerListCommand(program);
registerInfoCommand(program);
program.parse();
// Wrap parse() so errors thrown inside synchronous action handlers are caught
// here rather than relying on the global uncaughtException hook, which would
// also swallow unexpected programming errors (TypeError, ReferenceError, etc.).
// Pass --debug to see the full stack when debugging unexpected failures.
try {
program.parse();
} catch (err) {
const debug = process.argv.includes('--debug');
if (debug) {
console.error(err);
} else {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
}
process.exit(1);
}