/**
* Skill tools — Claude Code SkillTool / Grok skill tool parity + SkillHub market.
*
* - `load_skill`: on-demand load of a local/builtin/bundled skill body.
* - `skillhub_search` / `skillhub_install`: discover and install marketplace skills
* into the workspace `.moss/skills` directory (SkillHub CN-first store).
*
* Passive keyword matchByText still auto-injects high-confidence skills; these tools
* cover the miss path (model knows the catalog and pulls what it needs).
*/
import fs from 'node:fs';
import type { Tool, ToolContext } from '../core/tools/tool-types.js';
import { SkillRegistry, getSkillAliases } from '../skills/registry.js';
import type { SkillMeta } from '../skills/types.js';
import { skillHubInstall, skillHubSearch } from '../skills/skillhub.js';
import { toolError } from './tool-helpers.js';
const MAX_BODY_CHARS = 24_000;
const MAX_LIST_DESC = 160;
function createRegistry(ctx: ToolContext): SkillRegistry {
return new SkillRegistry({ workspaceDir: ctx.workspaceDir });
}
function readBody(skill: SkillMeta): string | undefined {
if (!skill.sourcePath || skill.sourcePath.startsWith('builtin://')) {
return skill.body?.trim() || undefined;
}
try {
const raw = fs.readFileSync(skill.sourcePath, 'utf-8');
const body = raw.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
return body || skill.body?.trim() || undefined;
} catch {
return skill.body?.trim() || undefined;
}
}
function formatSkillEnvelope(skill: SkillMeta, body: string): string {
const truncated =
body.length > MAX_BODY_CHARS
? `${body.slice(0, MAX_BODY_CHARS)}\n\n…[skill body truncated at ${MAX_BODY_CHARS} chars]`
: body;
return [
``,
truncated,
'',
'',
'Follow the skill instructions above for the current task. They are additional guidance, not a program to execute blindly.',
].join('\n');
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/\n/g, ' ').slice(0, 300);
}
function findSkill(registry: SkillRegistry, name: string): SkillMeta | undefined {
const q = name.trim().toLowerCase();
if (!q) return undefined;
const skills = registry.list().filter((s) => s.enabled !== false);
for (const s of skills) {
const aliases = getSkillAliases(s);
if (s.name.toLowerCase() === q || aliases.some((a) => a === q)) return s;
}
const contains = skills.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
getSkillAliases(s).some((a) => a.includes(q))
);
if (contains.length === 1) return contains[0];
return undefined;
}
function listSkillsText(registry: SkillRegistry, query?: string): string {
let skills = registry.list().filter((s) => s.enabled !== false);
const q = query?.trim().toLowerCase();
if (q) {
skills = skills.filter((s) => {
const hay = `${s.name} ${s.description} ${s.tags.join(' ')} ${s.trigger.join(' ')}`.toLowerCase();
return hay.includes(q) || getSkillAliases(s).some((a) => a.includes(q));
});
}
if (skills.length === 0) {
return q
? `No local skills match "${query}". Try skillhub_search to find marketplace skills.`
: 'No local skills are registered.';
}
const lines = skills.map((s) => {
const desc =
s.description.length > MAX_LIST_DESC
? `${s.description.slice(0, MAX_LIST_DESC - 1)}…`
: s.description;
return `- ${s.name}: ${desc}`;
});
return [
q ? `Local skills matching "${query}" (${skills.length}):` : `Local skills (${skills.length}):`,
...lines,
'',
'Call load_skill with a skill name to load full instructions.',
].join('\n');
}
export const loadSkillTool: Tool = {
name: 'load_skill',
description:
'Load a skill\'s full instructions by name (Claude Code Skill / Grok skill tool parity). ' +
'Use when a Skills index entry matches the task, or when you need domain guidance (coding workflows, RDK/ROS board work). ' +
'Omit `name` or set list=true to list/search local skills. After skillhub_install, call this to activate the new skill.',
metadata: {
sideEffectClass: 'readonly',
planMode: 'allow',
},
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Skill name/slug to load (e.g. code-review, rdk-ros, efficient-coding-loop).',
},
list: {
type: 'boolean',
description: 'If true, list local skills (optionally filtered by name as a query) instead of loading a body.',
},
query: {
type: 'string',
description: 'When list=true, optional filter against name/description/tags.',
},
},
},
async execute(input, ctx) {
try {
const registry = createRegistry(ctx);
const listMode = input.list === true || (!input.name && !input.query);
if (listMode) {
return listSkillsText(
registry,
typeof input.query === 'string'
? input.query
: typeof input.name === 'string'
? input.name
: undefined
);
}
const name = typeof input.name === 'string' ? input.name : '';
if (!name.trim()) {
return listSkillsText(registry, typeof input.query === 'string' ? input.query : undefined);
}
const skill = findSkill(registry, name);
if (!skill) {
const similar = listSkillsText(registry, name);
return `Error: skill "${name}" not found.\n\n${similar}\n\nTip: use skillhub_search to find marketplace skills, then skillhub_install.`;
}
const body = readBody(skill);
if (!body) {
return `Skill "${skill.name}" has no body (description only):\n${skill.description}`;
}
return formatSkillEnvelope(skill, body);
} catch (err) {
throw toolError('Error loading skill', err);
}
},
};
export const skillhubSearchTool: Tool = {
name: 'skillhub_search',
description:
'Search the SkillHub marketplace (https://skillhub.cn) for installable skills. ' +
'CN-first skill store for coding, robotics, and office workflows. ' +
'After finding a useful slug, call skillhub_install then load_skill.',
metadata: {
// Marketplace search is read-only from the workspace POV (like web_search).
sideEffectClass: 'readonly',
planMode: 'allow',
},
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search keywords (e.g. "ros2", "code review", "pdf", "frontend").',
},
limit: {
type: 'number',
description: 'Max results (default 10, max 50).',
},
},
required: ['query'],
},
async execute(input, ctx) {
try {
const result = await skillHubSearch(String(input.query ?? ''), {
limit: typeof input.limit === 'number' ? input.limit : 10,
abortSignal: ctx.abortSignal,
});
if (!result.ok) return result.message;
if (result.hits.length === 0) {
if (result.raw) {
return `SkillHub search returned no structured hits. Raw output:\n${result.raw.slice(0, 2000)}`;
}
return `No SkillHub skills matched "${input.query}". Try different keywords.`;
}
const lines = result.hits.map((h) => {
const ver = h.version ? ` v${h.version}` : '';
const src = h.source ? ` · ${h.source}` : '';
const desc =
h.description.length > 200 ? `${h.description.slice(0, 199)}…` : h.description;
return `- ${h.slug}${ver}${src}: ${desc || h.name}`;
});
return [
`SkillHub results for "${input.query}" (${result.hits.length}):`,
...lines,
'',
'Install with skillhub_install slug="", then load_skill name="".',
].join('\n');
} catch (err) {
throw toolError('Error searching SkillHub', err);
}
},
};
export const skillhubInstallTool: Tool = {
name: 'skillhub_install',
description:
'Install a skill from SkillHub into the workspace `.moss/skills` directory so Moss can discover it. ' +
'Requires the skillhub CLI (see https://skillhub.cn/install/skillhub.md). ' +
'After install, call load_skill to load the skill body for this turn.',
metadata: {
sideEffectClass: 'local_write',
planMode: 'requires_user_confirmation',
},
inputSchema: {
type: 'object',
properties: {
slug: {
type: 'string',
description: 'Skill slug from skillhub_search (e.g. "coding", "frontend-design").',
},
force: {
type: 'boolean',
description: 'Overwrite an existing install of the same slug (default false).',
},
},
required: ['slug'],
},
async execute(input, ctx) {
try {
const result = await skillHubInstall(String(input.slug ?? ''), {
workspaceDir: ctx.workspaceDir,
force: input.force === true,
abortSignal: ctx.abortSignal,
});
if (!result.ok) return result.message;
return result.message;
} catch (err) {
throw toolError('Error installing SkillHub skill', err);
}
},
};