@loreto-labs/skill-echarts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/actions.mjs +112 -0
- package/bin/cli.mjs +3 -0
- package/bin/mcp.mjs +5 -0
- package/command.md +9 -0
- package/loreto.skill.json +16 -0
- package/package.json +41 -0
- package/runtime/actions-core.mjs +115 -0
- package/runtime/argparse.mjs +70 -0
- package/runtime/cli-runner.mjs +112 -0
- package/runtime/mcp-runner.mjs +96 -0
- package/skill/README.md +85 -0
- package/skill/SKILL.md +164 -0
- package/skill/brands/apricot.json +30 -0
- package/skill/brands/onyx.json +31 -0
- package/skill/examples/01_bar.json +12 -0
- package/skill/examples/02_grouped_bar.json +16 -0
- package/skill/examples/03_stacked_bar.json +16 -0
- package/skill/examples/04_hbar.json +12 -0
- package/skill/examples/05_line.json +16 -0
- package/skill/examples/06_area.json +15 -0
- package/skill/examples/07_pie.json +16 -0
- package/skill/examples/08_donut.json +17 -0
- package/skill/examples/09_scatter.json +15 -0
- package/skill/examples/10_kpi.json +15 -0
- package/skill/examples/11_heatmap.json +21 -0
- package/skill/examples/12_radar.json +21 -0
- package/skill/examples/13_treemap.json +20 -0
- package/skill/examples/14_dashboard.json +28 -0
- package/skill/references/brand-style-mapping.md +58 -0
- package/skill/references/chart-type-recipes.md +66 -0
- package/skill/references/echarts-theming-model.md +62 -0
- package/skill/references/fonts-and-svg-embedding.md +60 -0
- package/skill/scripts/lib/charts.mjs +298 -0
- package/skill/scripts/lib/fonts.mjs +66 -0
- package/skill/scripts/lib/palette.mjs +119 -0
- package/skill/scripts/lib/theme.mjs +92 -0
- package/skill/scripts/package-lock.json +240 -0
- package/skill/scripts/package.json +15 -0
- package/skill/scripts/render-all.mjs +97 -0
- package/skill/scripts/render.mjs +98 -0
- package/skill/tests/test_building_branded_echarts_visualizations.py +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Building Branded Echarts Visualizations — CLI + MCP
|
|
2
|
+
|
|
3
|
+
A self-contained command-line tool **and** MCP server for the Loreto skill `building-branded-echarts-visualizations`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @loreto-labs/skill-echarts install # copies the skill into ~/.claude/skills + adds /echarts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use the CLI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
loreto-echarts --help
|
|
15
|
+
loreto-echarts info
|
|
16
|
+
loreto-echarts render --brand onyx --chart bar --out chart.svg
|
|
17
|
+
loreto-echarts test
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use as an MCP server
|
|
21
|
+
|
|
22
|
+
Add to your MCP client config:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"echarts": { "command": "loreto-echarts-mcp" }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Generated by loreto-skill-cli.
|
package/actions.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Adapter: building-branded-echarts-visualizations
|
|
2
|
+
//
|
|
3
|
+
// Skill-specific CLI sub-commands + MCP tools. These wrap the skill's own
|
|
4
|
+
// `renderChart()` export — the framework turns each entry into both a CLI
|
|
5
|
+
// command and an MCP tool with no extra code.
|
|
6
|
+
//
|
|
7
|
+
// An adapter only declares WHAT the skill can do; the framework handles
|
|
8
|
+
// argument parsing, help text, MCP schema generation, and dispatch.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
|
|
11
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const listJson = (dir) =>
|
|
16
|
+
existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
|
|
17
|
+
|
|
18
|
+
function resolveBrand(skillDir, name) {
|
|
19
|
+
if (name.endsWith('.json') || name.includes('/')) return resolve(name);
|
|
20
|
+
return join(skillDir, 'brands', `${name}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveChart(skillDir, name) {
|
|
24
|
+
if (name.endsWith('.json') || name.includes('/')) return resolve(name);
|
|
25
|
+
const dir = join(skillDir, 'examples');
|
|
26
|
+
const files = listJson(dir);
|
|
27
|
+
// Match exact (01_bar), or by suffix after the numeric prefix (bar -> 01_bar.json).
|
|
28
|
+
const hit =
|
|
29
|
+
files.find((f) => f === `${name}.json`) ||
|
|
30
|
+
files.find((f) => f.replace(/^\d+_/, '') === `${name}.json`);
|
|
31
|
+
return hit ? join(dir, hit) : join(dir, `${name}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function makeActions(ctx) {
|
|
35
|
+
const { skillDir } = ctx;
|
|
36
|
+
const scriptsDir = join(skillDir, 'scripts');
|
|
37
|
+
// Lazy: `echarts` is a large module graph, so only load it when a command
|
|
38
|
+
// that actually renders is run — `brands`/`examples`/`info`/`install` stay instant.
|
|
39
|
+
const loadRenderChart = async () =>
|
|
40
|
+
(await import(pathToFileURL(join(scriptsDir, 'render.mjs')).href)).renderChart;
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
name: 'render',
|
|
45
|
+
summary: 'Render one branded chart to SVG (+ PNG)',
|
|
46
|
+
args: [
|
|
47
|
+
{ name: 'brand', required: true, help: 'Brand theme name (onyx, apricot) or path to a brand JSON' },
|
|
48
|
+
{ name: 'chart', required: true, help: 'Example name (bar, 01_bar) or path to a chart-spec JSON' },
|
|
49
|
+
{ name: 'out', help: 'Output .svg path (default: ./<chart>.svg)' },
|
|
50
|
+
{ name: 'png', type: 'boolean', default: true, help: 'Also emit a 2x PNG (use --no-png to skip)' },
|
|
51
|
+
],
|
|
52
|
+
async run(opts) {
|
|
53
|
+
const renderChart = await loadRenderChart();
|
|
54
|
+
const brandPath = resolveBrand(skillDir, opts.brand);
|
|
55
|
+
const chartPath = resolveChart(skillDir, opts.chart);
|
|
56
|
+
if (!existsSync(brandPath)) throw new Error(`Brand not found: ${brandPath}`);
|
|
57
|
+
if (!existsSync(chartPath)) throw new Error(`Chart spec not found: ${chartPath}`);
|
|
58
|
+
|
|
59
|
+
const brand = JSON.parse(readFileSync(brandPath, 'utf8'));
|
|
60
|
+
const spec = JSON.parse(readFileSync(chartPath, 'utf8'));
|
|
61
|
+
const out = resolve(opts.out || `${basename(chartPath, '.json')}.svg`);
|
|
62
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
63
|
+
|
|
64
|
+
const { svg, png } = await renderChart({ brand, brandPath, spec, png: opts.png });
|
|
65
|
+
writeFileSync(out, svg);
|
|
66
|
+
const written = [`SVG → ${out} (${svg.length.toLocaleString()} bytes)`];
|
|
67
|
+
if (opts.png && png) {
|
|
68
|
+
const p = out.replace(/\.svg$/, '.png');
|
|
69
|
+
writeFileSync(p, png);
|
|
70
|
+
written.push(`PNG → ${p} (${png.length.toLocaleString()} bytes)`);
|
|
71
|
+
}
|
|
72
|
+
return { text: `Rendered ${brand.name || opts.brand} / ${basename(chartPath)}\n ${written.join('\n ')}` };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
name: 'render-all',
|
|
78
|
+
summary: 'Render every example chart for every bundled brand into a gallery',
|
|
79
|
+
args: [],
|
|
80
|
+
run() {
|
|
81
|
+
const r = spawnSync('node', ['render-all.mjs'], { cwd: scriptsDir, encoding: 'utf8' });
|
|
82
|
+
if (r.error) throw new Error(r.error.message);
|
|
83
|
+
if (r.status !== 0) throw new Error(r.stderr || `render-all exited ${r.status}`);
|
|
84
|
+
return { text: (r.stdout || '').trim() };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
name: 'brands',
|
|
90
|
+
summary: 'List the brand themes bundled with this skill',
|
|
91
|
+
args: [],
|
|
92
|
+
run() {
|
|
93
|
+
const files = listJson(join(skillDir, 'brands'));
|
|
94
|
+
const rows = files.map((f) => {
|
|
95
|
+
const b = JSON.parse(readFileSync(join(skillDir, 'brands', f), 'utf8'));
|
|
96
|
+
return ` ${basename(f, '.json').padEnd(10)} ${b.name || ''} (${b.mode || 'light'})`;
|
|
97
|
+
});
|
|
98
|
+
return { text: ['Brands:', ...rows].join('\n') };
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
name: 'examples',
|
|
104
|
+
summary: 'List the example chart specs bundled with this skill',
|
|
105
|
+
args: [],
|
|
106
|
+
run() {
|
|
107
|
+
const files = listJson(join(skillDir, 'examples'));
|
|
108
|
+
return { text: ['Examples:', ...files.map((f) => ` ${basename(f, '.json')}`)].join('\n') };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
package/bin/cli.mjs
ADDED
package/bin/mcp.mjs
ADDED
package/command.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Building Branded Echarts Visualizations — invoke the Loreto skill (installed in ~/.claude/skills)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Use the **building-branded-echarts-visualizations** skill (in ~/.claude/skills/building-branded-echarts-visualizations/SKILL.md) for this request.
|
|
6
|
+
|
|
7
|
+
You can also drive its bundled CLI directly, e.g. `loreto-echarts --help`.
|
|
8
|
+
|
|
9
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "building-branded-echarts-visualizations",
|
|
3
|
+
"name": "Building Branded Echarts Visualizations",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"bin": "loreto-echarts",
|
|
6
|
+
"command": "echarts",
|
|
7
|
+
"description": "Turns a company's brand styles (color codes + fonts) into polished, on-brand Apache ECharts data visualizations — bar, line, area, pie/donut, scatter, KPI cards, heatmap, radar, treemap, and full dashboards — rendered server-side to SVG and PNG. Use when you need \"branded charts\", \"on-brand data viz\", \"style ECharts to our brand\", \"company-branded dashboards\", \"charts that match our brand guidelines\", or to produce a reusable chart theme from a brand's palette and typography. You give it a brand token file (hex colors, font families, optional webfont URLs) and a chart spec (type + data); it emits a styled SVG (font-accurate) plus a PNG. Bundled Node scripts do the rendering — no browser needed. Invoke it by NAMING a theme — e.g. \"apply the Apricot style to all of our analytics charts\" or \"render this data in the Onyx theme\" — and it resolves the named style to its brand file and applies it to every chart. Bundled themes: onyx, apricot (list with `ls brands/`).",
|
|
8
|
+
"skillDir": "skill",
|
|
9
|
+
"hasActions": true,
|
|
10
|
+
"test": {
|
|
11
|
+
"cmd": [
|
|
12
|
+
"python3",
|
|
13
|
+
"tests/test_building_branded_echarts_visualizations.py"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loreto-labs/skill-echarts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + MCP for the Loreto skill: Building Branded ECharts Visualizations — render on-brand charts to SVG/PNG.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"loreto",
|
|
7
|
+
"claude",
|
|
8
|
+
"skill",
|
|
9
|
+
"echarts",
|
|
10
|
+
"charts",
|
|
11
|
+
"mcp",
|
|
12
|
+
"cli",
|
|
13
|
+
"dataviz"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Loreto <info@loreto.io>",
|
|
18
|
+
"homepage": "https://loreto.io",
|
|
19
|
+
"bin": {
|
|
20
|
+
"loreto-echarts": "bin/cli.mjs",
|
|
21
|
+
"loreto-echarts-mcp": "bin/mcp.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"runtime",
|
|
26
|
+
"skill",
|
|
27
|
+
"actions.mjs",
|
|
28
|
+
"loreto.skill.json",
|
|
29
|
+
"command.md"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"echarts": "^5.5.1",
|
|
33
|
+
"@resvg/resvg-js": "^2.6.2"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// actions-core.mjs — universal actions every Loreto skill bundle gets for free,
|
|
2
|
+
// regardless of whether the skill ships executable code. These wrap the skill
|
|
3
|
+
// package (SKILL.md, references/, tests/) so the same CLI works for a pure
|
|
4
|
+
// knowledge skill and for a code-bearing one like echarts.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, cpSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join, basename } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
function refsDir(ctx) {
|
|
12
|
+
return join(ctx.skillDir, 'references');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function makeCoreActions(ctx) {
|
|
16
|
+
const { manifest, skillDir } = ctx;
|
|
17
|
+
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
name: 'info',
|
|
21
|
+
summary: 'Print this skill\'s name, version, and description',
|
|
22
|
+
args: [],
|
|
23
|
+
run() {
|
|
24
|
+
const lines = [
|
|
25
|
+
`${manifest.name} (v${manifest.version || '0.0.0'})`,
|
|
26
|
+
`slug: ${manifest.slug}`,
|
|
27
|
+
`command: /${manifest.command}`,
|
|
28
|
+
'',
|
|
29
|
+
(manifest.description || '').trim(),
|
|
30
|
+
];
|
|
31
|
+
return { text: lines.join('\n') };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
name: 'docs',
|
|
37
|
+
summary: 'Print the skill\'s SKILL.md (the agent-facing instructions)',
|
|
38
|
+
args: [],
|
|
39
|
+
run() {
|
|
40
|
+
const p = join(skillDir, 'SKILL.md');
|
|
41
|
+
if (!existsSync(p)) return { text: 'No SKILL.md found in this bundle.' };
|
|
42
|
+
return { text: readFileSync(p, 'utf8') };
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
name: 'refs',
|
|
48
|
+
summary: 'List reference docs, or print one with --name <file>',
|
|
49
|
+
args: [{ name: 'name', help: 'Reference filename to print (e.g. chart-type-recipes.md)' }],
|
|
50
|
+
run(opts) {
|
|
51
|
+
const dir = refsDir(ctx);
|
|
52
|
+
if (!existsSync(dir)) return { text: 'This skill has no references/ folder.' };
|
|
53
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
|
54
|
+
if (!opts.name) {
|
|
55
|
+
return { text: ['References:', ...files.map((f) => ` - ${f}`)].join('\n') };
|
|
56
|
+
}
|
|
57
|
+
const match = files.find((f) => f === opts.name || f === `${opts.name}.md`);
|
|
58
|
+
if (!match) return { text: `No reference named "${opts.name}". Available: ${files.join(', ')}` };
|
|
59
|
+
return { text: readFileSync(join(dir, match), 'utf8') };
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
name: 'install',
|
|
65
|
+
summary: 'Install the skill into ~/.claude/skills and register the /slash command',
|
|
66
|
+
args: [
|
|
67
|
+
{ name: 'dir', help: 'Target Claude config dir', default: join(homedir(), '.claude') },
|
|
68
|
+
{ name: 'dry-run', type: 'boolean', help: 'Show what would happen without writing' },
|
|
69
|
+
{ name: 'force', type: 'boolean', help: 'Overwrite an existing install' },
|
|
70
|
+
],
|
|
71
|
+
run(opts) {
|
|
72
|
+
const claudeDir = opts['dir'];
|
|
73
|
+
const skillsTarget = join(claudeDir, 'skills', manifest.slug);
|
|
74
|
+
const cmdTarget = join(claudeDir, 'commands', `${manifest.command}.md`);
|
|
75
|
+
const cmdSource = join(ctx.bundleDir, 'command.md');
|
|
76
|
+
const plan = [
|
|
77
|
+
`skill → ${skillsTarget}`,
|
|
78
|
+
`command → ${cmdTarget} (invoke with /${manifest.command})`,
|
|
79
|
+
];
|
|
80
|
+
if (opts['dry-run']) {
|
|
81
|
+
return { text: ['[dry-run] would install:', ...plan.map((l) => ' ' + l)].join('\n') };
|
|
82
|
+
}
|
|
83
|
+
if (existsSync(skillsTarget) && !opts.force) {
|
|
84
|
+
return { text: `Already installed at ${skillsTarget}. Re-run with --force to overwrite.` };
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(join(claudeDir, 'skills'), { recursive: true });
|
|
87
|
+
mkdirSync(join(claudeDir, 'commands'), { recursive: true });
|
|
88
|
+
cpSync(skillDir, skillsTarget, { recursive: true });
|
|
89
|
+
if (existsSync(cmdSource)) {
|
|
90
|
+
writeFileSync(cmdTarget, readFileSync(cmdSource, 'utf8'));
|
|
91
|
+
}
|
|
92
|
+
return { text: ['Installed:', ...plan.map((l) => ' ' + l)].join('\n') };
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
name: 'test',
|
|
98
|
+
summary: 'Run the skill\'s bundled test script',
|
|
99
|
+
args: [],
|
|
100
|
+
run() {
|
|
101
|
+
const t = manifest.test;
|
|
102
|
+
if (!t || !t.cmd || !t.cmd.length) return { text: 'This skill bundles no test command.' };
|
|
103
|
+
const [bin, ...rest] = t.cmd;
|
|
104
|
+
const r = spawnSync(bin, rest, { cwd: ctx.skillDir, stdio: 'inherit' });
|
|
105
|
+
if (r.error) return { text: `Could not run test (${bin}): ${r.error.message}` };
|
|
106
|
+
if (r.status !== 0) {
|
|
107
|
+
const e = new Error(`test exited with code ${r.status}`);
|
|
108
|
+
e.exitCode = r.status || 1;
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
return { text: `\nTest passed (${basename(bin)} ${rest.join(' ')}).` };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// argparse.mjs — tiny, zero-dependency flag + positional parser.
|
|
2
|
+
//
|
|
3
|
+
// Supports: --key value --flag --no-flag and bare positionals.
|
|
4
|
+
// `spec` maps a flag name to { boolean, alias }.
|
|
5
|
+
|
|
6
|
+
export function parseArgv(argv, spec = {}) {
|
|
7
|
+
const aliases = {};
|
|
8
|
+
for (const [name, s] of Object.entries(spec)) {
|
|
9
|
+
if (s.alias) aliases[s.alias] = name;
|
|
10
|
+
}
|
|
11
|
+
const out = { _: [] };
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const tok = argv[i];
|
|
14
|
+
if (tok === '--') {
|
|
15
|
+
out._.push(...argv.slice(i + 1));
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
if (tok.startsWith('--') || tok.startsWith('-')) {
|
|
19
|
+
let key = tok.replace(/^-+/, '');
|
|
20
|
+
let value = undefined;
|
|
21
|
+
const eq = key.indexOf('=');
|
|
22
|
+
if (eq !== -1) {
|
|
23
|
+
value = key.slice(eq + 1);
|
|
24
|
+
key = key.slice(0, eq);
|
|
25
|
+
}
|
|
26
|
+
let negated = false;
|
|
27
|
+
if (key.startsWith('no-')) {
|
|
28
|
+
negated = true;
|
|
29
|
+
key = key.slice(3);
|
|
30
|
+
}
|
|
31
|
+
if (aliases[key]) key = aliases[key];
|
|
32
|
+
const s = spec[key] || {};
|
|
33
|
+
if (s.boolean) {
|
|
34
|
+
out[key] = !negated;
|
|
35
|
+
} else if (value !== undefined) {
|
|
36
|
+
out[key] = value;
|
|
37
|
+
} else {
|
|
38
|
+
out[key] = argv[++i];
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
out._.push(tok);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build a flag spec object from an action's `args` schema.
|
|
48
|
+
export function specFromArgs(args = []) {
|
|
49
|
+
const spec = {};
|
|
50
|
+
for (const a of args) spec[a.name] = { boolean: a.type === 'boolean', alias: a.alias };
|
|
51
|
+
return spec;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Coerce parsed flags + positionals into a clean options object, applying
|
|
55
|
+
// defaults and mapping leading positionals onto required args by order.
|
|
56
|
+
export function coerce(parsed, args = []) {
|
|
57
|
+
const opts = {};
|
|
58
|
+
const positional = [...parsed._];
|
|
59
|
+
for (const a of args) {
|
|
60
|
+
if (a.name in parsed) {
|
|
61
|
+
opts[a.name] = parsed[a.name];
|
|
62
|
+
} else if (a.required && positional.length) {
|
|
63
|
+
opts[a.name] = positional.shift();
|
|
64
|
+
} else if (a.default !== undefined) {
|
|
65
|
+
opts[a.name] = a.default;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
opts._ = positional;
|
|
69
|
+
return opts;
|
|
70
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// cli-runner.mjs — the generic CLI dispatcher every bundle's bin/cli.mjs delegates to.
|
|
2
|
+
//
|
|
3
|
+
// It discovers the bundle from the bin file's location, loads the manifest,
|
|
4
|
+
// merges universal actions with any skill-specific actions (actions.mjs), and
|
|
5
|
+
// dispatches the first positional as the sub-command. `mcp` hands off to the
|
|
6
|
+
// stdio MCP server so the *same* action set is exposed to MCP clients.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { dirname, resolve, join } from 'node:path';
|
|
11
|
+
import { parseArgv, specFromArgs, coerce } from './argparse.mjs';
|
|
12
|
+
import { makeCoreActions } from './actions-core.mjs';
|
|
13
|
+
|
|
14
|
+
export async function loadBundle(binUrl) {
|
|
15
|
+
const bundleDir = resolve(dirname(fileURLToPath(binUrl)), '..');
|
|
16
|
+
const manifest = JSON.parse(readFileSync(join(bundleDir, 'loreto.skill.json'), 'utf8'));
|
|
17
|
+
const skillDir = join(bundleDir, manifest.skillDir || 'skill');
|
|
18
|
+
const ctx = { bundleDir, skillDir, manifest };
|
|
19
|
+
|
|
20
|
+
let skillActions = [];
|
|
21
|
+
const actionsPath = join(bundleDir, 'actions.mjs');
|
|
22
|
+
if (existsSync(actionsPath)) {
|
|
23
|
+
const mod = await import(pathToFileURL(actionsPath).href);
|
|
24
|
+
skillActions = await mod.makeActions(ctx);
|
|
25
|
+
}
|
|
26
|
+
// Skill-specific actions first so they take precedence in help ordering.
|
|
27
|
+
const actions = [...skillActions, ...makeCoreActions(ctx)];
|
|
28
|
+
return { ctx, actions };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function printHelp(manifest, actions) {
|
|
32
|
+
const lines = [
|
|
33
|
+
`${manifest.name}`,
|
|
34
|
+
(manifest.description || '').trim().split('\n')[0],
|
|
35
|
+
'',
|
|
36
|
+
`Usage: ${manifest.bin} <command> [options]`,
|
|
37
|
+
'',
|
|
38
|
+
'Commands:',
|
|
39
|
+
];
|
|
40
|
+
for (const a of actions) {
|
|
41
|
+
lines.push(` ${a.name.padEnd(12)} ${a.summary}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push(` ${'mcp'.padEnd(12)} Start the MCP server (stdio) exposing these as tools`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
lines.push(`Run "${manifest.bin} <command> --help" for command options.`);
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printActionHelp(manifest, action) {
|
|
50
|
+
const lines = [`${manifest.bin} ${action.name} — ${action.summary}`, ''];
|
|
51
|
+
if (action.args.length) {
|
|
52
|
+
lines.push('Options:');
|
|
53
|
+
for (const a of action.args) {
|
|
54
|
+
const flag = a.type === 'boolean' ? `--${a.name}` : `--${a.name} <value>`;
|
|
55
|
+
const req = a.required ? ' (required)' : '';
|
|
56
|
+
lines.push(` ${flag.padEnd(22)} ${a.help || ''}${req}`);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
lines.push('(no options)');
|
|
60
|
+
}
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runCli(binUrl) {
|
|
65
|
+
const { ctx, actions } = await loadBundle(binUrl);
|
|
66
|
+
const { manifest } = ctx;
|
|
67
|
+
const argv = process.argv.slice(2);
|
|
68
|
+
const cmd = argv[0];
|
|
69
|
+
|
|
70
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
71
|
+
console.log(printHelp(manifest, actions));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (cmd === 'mcp') {
|
|
76
|
+
const { runMcp } = await import('./mcp-runner.mjs');
|
|
77
|
+
await runMcp(ctx, actions);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const action = actions.find((a) => a.name === cmd);
|
|
82
|
+
if (!action) {
|
|
83
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
84
|
+
console.error(printHelp(manifest, actions));
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rest = argv.slice(1);
|
|
89
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
90
|
+
console.log(printActionHelp(manifest, action));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parsed = parseArgv(rest, specFromArgs(action.args));
|
|
95
|
+
const opts = coerce(parsed, action.args);
|
|
96
|
+
|
|
97
|
+
for (const a of action.args) {
|
|
98
|
+
if (a.required && (opts[a.name] === undefined || opts[a.name] === '')) {
|
|
99
|
+
console.error(`Missing required option: --${a.name}\n`);
|
|
100
|
+
console.error(printActionHelp(manifest, action));
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const res = await action.run(opts, ctx);
|
|
107
|
+
if (res && res.text) console.log(res.text);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(`Error: ${e.message}`);
|
|
110
|
+
process.exit(e.exitCode || 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// mcp-runner.mjs — a minimal, dependency-free MCP server over stdio.
|
|
2
|
+
//
|
|
3
|
+
// Implements just enough of the Model Context Protocol (JSON-RPC 2.0,
|
|
4
|
+
// newline-delimited messages) to expose each skill action as an MCP tool:
|
|
5
|
+
// initialize · tools/list · tools/call · ping · notifications/*
|
|
6
|
+
//
|
|
7
|
+
// Production note: for the shipped product this can be swapped for the official
|
|
8
|
+
// @modelcontextprotocol/sdk; the action→tool mapping below is the stable part.
|
|
9
|
+
|
|
10
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
11
|
+
|
|
12
|
+
function inputSchema(action) {
|
|
13
|
+
const properties = {};
|
|
14
|
+
const required = [];
|
|
15
|
+
for (const a of action.args || []) {
|
|
16
|
+
properties[a.name] = {
|
|
17
|
+
type: a.type === 'boolean' ? 'boolean' : 'string',
|
|
18
|
+
description: a.help || '',
|
|
19
|
+
};
|
|
20
|
+
if (a.required) required.push(a.name);
|
|
21
|
+
}
|
|
22
|
+
return { type: 'object', properties, required };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toolName(ctx, action) {
|
|
26
|
+
// Namespaced so multiple skill MCP servers can coexist in one client.
|
|
27
|
+
return `${ctx.manifest.command}.${action.name}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runMcp(ctx, actions) {
|
|
31
|
+
// `mcp`, `install`, and `test` are operational CLI concerns; expose the rest
|
|
32
|
+
// (skill capabilities + read-only introspection) as tools.
|
|
33
|
+
const exposed = actions.filter((a) => !['install'].includes(a.name));
|
|
34
|
+
|
|
35
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
36
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
37
|
+
const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
38
|
+
|
|
39
|
+
async function handle(req) {
|
|
40
|
+
const { id, method, params } = req;
|
|
41
|
+
if (method === 'initialize') {
|
|
42
|
+
return reply(id, {
|
|
43
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
44
|
+
capabilities: { tools: {} },
|
|
45
|
+
serverInfo: { name: `loreto-skill:${ctx.manifest.slug}`, version: ctx.manifest.version || '0.0.0' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (method === 'ping') return reply(id, {});
|
|
49
|
+
if (method && method.startsWith('notifications/')) return; // no response for notifications
|
|
50
|
+
if (method === 'tools/list') {
|
|
51
|
+
return reply(id, {
|
|
52
|
+
tools: exposed.map((a) => ({
|
|
53
|
+
name: toolName(ctx, a),
|
|
54
|
+
description: a.summary,
|
|
55
|
+
inputSchema: inputSchema(a),
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (method === 'tools/call') {
|
|
60
|
+
const name = params?.name;
|
|
61
|
+
const action = exposed.find((a) => toolName(ctx, a) === name || a.name === name);
|
|
62
|
+
if (!action) return fail(id, -32602, `Unknown tool: ${name}`);
|
|
63
|
+
try {
|
|
64
|
+
const res = await action.run(params?.arguments || {}, ctx);
|
|
65
|
+
const text = (res && res.text) || '(no output)';
|
|
66
|
+
return reply(id, { content: [{ type: 'text', text }], isError: false });
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return reply(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (id !== undefined) return fail(id, -32601, `Method not found: ${method}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let buffer = '';
|
|
75
|
+
process.stdin.setEncoding('utf8');
|
|
76
|
+
process.stdin.on('data', (chunk) => {
|
|
77
|
+
buffer += chunk;
|
|
78
|
+
let nl;
|
|
79
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
80
|
+
const line = buffer.slice(0, nl).trim();
|
|
81
|
+
buffer = buffer.slice(nl + 1);
|
|
82
|
+
if (!line) continue;
|
|
83
|
+
let req;
|
|
84
|
+
try {
|
|
85
|
+
req = JSON.parse(line);
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
Promise.resolve(handle(req)).catch((e) => {
|
|
90
|
+
if (req && req.id !== undefined) fail(req.id, -32603, e.message);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// Keep the process alive until stdin closes.
|
|
95
|
+
await new Promise((resolve) => process.stdin.on('end', resolve));
|
|
96
|
+
}
|