Files
shiro-neko/test/skills.test.ts
T
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

198 lines
8.7 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createSkillTool, loadSkills, parseSkill, renderSkills, type Skill } from '../src/skills';
import { BUILTIN_SKILLS } from '../src/skills-builtin';
const SKILLS_MD_DIR = join(import.meta.dir, '..', 'src', 'skills-md');
let home: string;
let work: string;
let savedHome: string | undefined;
beforeEach(() => {
savedHome = process.env['SHIRO_HOME'];
home = mkdtempSync(join(tmpdir(), 'shiro-skill-home-'));
work = mkdtempSync(join(tmpdir(), 'shiro-skill-work-'));
process.env['SHIRO_HOME'] = home;
});
afterEach(() => {
if (savedHome === undefined) delete process.env['SHIRO_HOME'];
else process.env['SHIRO_HOME'] = savedHome;
rmSync(home, { recursive: true, force: true });
rmSync(work, { recursive: true, force: true });
});
const load = (tool: ReturnType<typeof createSkillTool>, name: string) =>
Promise.resolve(tool.execute!({ name } as never, { toolCallId: 'x', messages: [] } as never)) as Promise<string>;
test('frontmatter is parsed into name, description, and body', () => {
const skill = parseSkill('---\nname: demo\ndescription: A demo skill\n---\n\n# Body\n\nDo the thing.\n', 'builtin');
expect(skill?.name).toBe('demo');
expect(skill?.description).toBe('A demo skill');
expect(skill?.body).toContain('Do the thing.');
expect(skill?.body).not.toContain('description:');
});
test('quotes around values are stripped', () => {
const skill = parseSkill('---\nname: "quoted"\ndescription: \'also quoted\'\n---\nbody\n', 'builtin');
expect(skill?.name).toBe('quoted');
expect(skill?.description).toBe('also quoted');
});
test('a file without frontmatter is rejected', () => {
expect(parseSkill('# Just markdown\n', 'builtin')).toBeUndefined();
});
test('frontmatter missing name or description is rejected', () => {
expect(parseSkill('---\nname: only\n---\nbody\n', 'builtin')).toBeUndefined();
expect(parseSkill('---\ndescription: only\n---\nbody\n', 'builtin')).toBeUndefined();
});
test('every bundled skill parses and has a usable description', () => {
for (const { name, source } of BUILTIN_SKILLS) {
const skill = parseSkill(source, 'builtin');
expect(skill).toBeDefined();
expect(skill!.name).toBe(name);
expect(skill!.description.length).toBeGreaterThan(20);
expect(skill!.body.length).toBeGreaterThan(100);
}
});
test('every builtin skill is a Markdown file on disk with valid frontmatter and a real body', async () => {
for (const { name, source } of BUILTIN_SKILLS) {
// The .md file is the source of truth and must exist beside the others.
const file = Bun.file(join(SKILLS_MD_DIR, `${name}.md`));
expect(await file.exists(), `${name}.md missing`).toBe(true);
// Proper skill-Markdown: a --- frontmatter fence with name + description, then a body.
expect(source.trimStart().startsWith('---'), `${name} has no frontmatter fence`).toBe(true);
const parsed = parseSkill(source, 'builtin');
expect(parsed, `${name} failed to parse`).toBeDefined();
expect(parsed!.name, name).toBe(name);
expect(parsed!.description.length, `${name} description too short`).toBeGreaterThan(20);
expect(parsed!.body.length, `${name} body too short`).toBeGreaterThan(200);
// The body is real Markdown, not an escaped blob: it has a heading and structure.
expect(parsed!.body, `${name} has no Markdown heading`).toMatch(/^#\s/m);
}
});
test('the builtin skills load with no files on disk', async () => {
const skills = await loadSkills(work);
// The catalogue is the builtin set and nothing else: every entry parses, is a
// builtin, and the list is sorted and de-duplicated. Counted rather than named so
// adding a skill does not mean editing this test.
expect(skills.length).toBe(BUILTIN_SKILLS.length);
expect(skills.every((s) => s.origin === 'builtin')).toBe(true);
expect(skills.every((s) => s.name.length > 0 && s.description.length > 10 && s.body.length > 100)).toBe(true);
const names = skills.map((s) => s.name);
expect(new Set(names).size).toBe(names.length);
expect([...names].sort((a, b) => a.localeCompare(b))).toEqual(names);
// The long-standing skills must still be present even as the set grows.
for (const name of ['commit', 'debug', 'migrate', 'perf', 'plan', 'refactor', 'review', 'security', 'test', 'verify', 'docs']) {
expect(names).toContain(name);
}
});
test('a project skill is discovered and reported as project origin', async () => {
mkdirSync(join(work, '.shiro', 'skills'), { recursive: true });
await Bun.write(join(work, '.shiro', 'skills', 'deploy.md'), '---\nname: deploy\ndescription: Ship it safely\n---\nsteps\n');
const skills = await loadSkills(work);
const deploy = skills.find((s) => s.name === 'deploy');
expect(deploy?.origin).toBe('project');
expect(deploy?.path).toContain('deploy.md');
});
test('a user skill is discovered from SHIRO_HOME', async () => {
mkdirSync(join(home, '.shiro-neko', 'skills'), { recursive: true });
await Bun.write(join(home, '.shiro-neko', 'skills', 'mine.md'), '---\nname: mine\ndescription: My own workflow\n---\nbody\n');
const skills = await loadSkills(work);
expect(skills.find((s) => s.name === 'mine')?.origin).toBe('user');
});
test('a project skill overrides a builtin with the same name', async () => {
mkdirSync(join(work, '.shiro', 'skills'), { recursive: true });
await Bun.write(join(work, '.shiro', 'skills', 'debug.md'), '---\nname: debug\ndescription: Project debugging rules\n---\nPROJECT-BODY\n');
const skills = await loadSkills(work);
const debug = skills.filter((s) => s.name === 'debug');
expect(debug).toHaveLength(1);
expect(debug[0]?.origin).toBe('project');
expect(debug[0]?.body).toContain('PROJECT-BODY');
});
test('a project skill overrides a user skill of the same name', async () => {
mkdirSync(join(home, '.shiro-neko', 'skills'), { recursive: true });
mkdirSync(join(work, '.shiro', 'skills'), { recursive: true });
await Bun.write(join(home, '.shiro-neko', 'skills', 'x.md'), '---\nname: x\ndescription: user version\n---\nUSER\n');
await Bun.write(join(work, '.shiro', 'skills', 'x.md'), '---\nname: x\ndescription: project version\n---\nPROJECT\n');
const skills = await loadSkills(work);
expect(skills.find((s) => s.name === 'x')?.body).toBe('PROJECT');
});
test('a malformed skill file is skipped, not fatal', async () => {
mkdirSync(join(work, '.shiro', 'skills'), { recursive: true });
await Bun.write(join(work, '.shiro', 'skills', 'broken.md'), 'no frontmatter here\n');
await Bun.write(join(work, '.shiro', 'skills', 'good.md'), '---\nname: good\ndescription: This one is fine\n---\nbody\n');
const skills = await loadSkills(work);
expect(skills.some((s) => s.name === 'good')).toBe(true);
expect(skills).toHaveLength(BUILTIN_SKILLS.length + 1);
});
test('the catalogue carries descriptions but not bodies', async () => {
const skills = await loadSkills(work);
const catalog = renderSkills(skills);
for (const s of skills) {
expect(catalog).toContain(s.name);
expect(catalog).toContain(s.description);
}
// Bodies are the expensive part and must stay out until asked for.
expect(catalog).not.toContain('Three hypotheses');
expect(catalog.length).toBeLessThan(skills.reduce((n, s) => n + s.body.length, 0));
});
test('an empty skill list renders nothing', () => {
expect(renderSkills([])).toBe('');
});
test('the skill tool returns the body on demand', async () => {
const skills = await loadSkills(work);
const out = await load(createSkillTool(() => skills), 'debug');
expect(out).toContain('Three hypotheses');
expect(out).toContain('builtin');
});
test('the skill tool rejects an unknown name and lists what exists', async () => {
const skills = await loadSkills(work);
const tool = createSkillTool(() => skills);
expect(load(tool, 'nonexistent')).rejects.toThrow(/No skill named "nonexistent"/);
expect(load(tool, 'nonexistent')).rejects.toThrow(/debug/);
});
test('the skill tool tolerates surrounding whitespace and case', async () => {
const skills = await loadSkills(work);
const out = await load(createSkillTool(() => skills), ' REVIEW ');
expect(out).toContain('Severity order');
});
test('the skill tool reads the list live, so a mid-session install needs no restart', async () => {
// Mutating the array between calls, the way a hot-reload swaps the session's list,
// must be visible to the next call on the *same* tool instance.
const skills: Skill[] = [];
const tool = createSkillTool(() => skills);
skills.push({ name: 'hot', description: 'installed mid-session', origin: 'registry', body: 'fresh body' });
const out = await load(tool, 'hot');
expect(out).toContain('fresh body');
expect(out).toContain('registry');
});