mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits on top of 58 release-side commits accumulated during the 0.8.0 cycle. Resolution summary: Take main (theirs) where main carried deliberate forward progress: - apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration: hardcoded English aria-labels/titles replaced with t() calls keyed on pluginCard.* (all 8 keys verified present in en.ts). - apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion feature: sortedRoutines (newest-first), sourceIngestionTemplates, patchSourceForm, submitSourceIngestion. activeCount/pausedCount semantics preserved (now keyed on sortedRoutines, count unchanged). - e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts imports needed by main-side test helpers. - e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal helper block added by main. Keep both sides where each added a different field to the same object literal: - apps/web/src/components/ProjectView.tsx (locale + analyticsHints spread). - apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints). Take release (ours) where release carried deliberate work that ships 0.8.0: - CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's Unreleased section was the same body of work, now finalized. - apps/landing-page/public/{apple-touch-icon,favicon}.png + apps/web/public/app-icon.svg — release-side visual refresh assets consistent with 0.8.0 stable ship. - tools/pack/src/linux.ts — packageVersion const required by line 466; taking main's empty line would build-error. - e2e/ui/project-management-flows.test.ts + e2e/ui/settings-api-protocol.test.ts + e2e/ui/settings-memory-routines.test.ts — release-side release-smoke hardening (shangxinyu1 + PerishFire) takes precedence on overlap. Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
678 lines
25 KiB
TypeScript
678 lines
25 KiB
TypeScript
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { rmSync } from 'node:fs';
|
|
|
|
import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js';
|
|
import { readFileSync } from 'node:fs';
|
|
import {
|
|
deleteUserSkill,
|
|
importUserSkill,
|
|
listSkillFiles,
|
|
listSkills,
|
|
slugifySkillName,
|
|
updateUserSkill,
|
|
} from '../src/skills.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const repoRoot = path.resolve(__dirname, '../../..');
|
|
const skillsRoot = path.join(repoRoot, 'skills');
|
|
// `live-artifact`, `dcf-valuation`, `x-research`, and `last30days` were
|
|
// reclassified as design templates under the Phase 0 split (see
|
|
// specs/current/skills-and-design-templates.md). The body/preamble
|
|
// expectations below still apply, but they now read from the design
|
|
// templates root rather than skills/.
|
|
const designTemplatesRoot = path.join(repoRoot, 'design-templates');
|
|
const liveArtifactRoot = path.join(designTemplatesRoot, 'live-artifact');
|
|
|
|
type SkillCatalogEntry = {
|
|
id: string;
|
|
name: string;
|
|
mode: string;
|
|
previewType: string;
|
|
triggers: string[];
|
|
body: string;
|
|
};
|
|
|
|
function fresh(): string {
|
|
return mkdtempSync(path.join(tmpdir(), 'od-skills-'));
|
|
}
|
|
|
|
function writeSkill(
|
|
root: string,
|
|
folder: string,
|
|
options: {
|
|
name?: string;
|
|
description?: string;
|
|
body?: string;
|
|
withAttachments?: boolean;
|
|
} = {},
|
|
) {
|
|
const dir = path.join(root, folder);
|
|
mkdirSync(dir, { recursive: true });
|
|
const fm = [
|
|
'---',
|
|
`name: ${options.name ?? folder}`,
|
|
`description: ${options.description ?? 'A test skill.'}`,
|
|
'---',
|
|
'',
|
|
options.body ?? '# Test skill body',
|
|
'',
|
|
].join('\n');
|
|
writeFileSync(path.join(dir, 'SKILL.md'), fm);
|
|
if (options.withAttachments) {
|
|
mkdirSync(path.join(dir, 'assets'), { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, 'assets', 'template.html'),
|
|
'<html><body>seed</body></html>',
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('listSkills', () => {
|
|
it('surfaces optional localized display metadata from SKILL.md frontmatter', async () => {
|
|
const root = fresh();
|
|
try {
|
|
const dir = path.join(root, 'localized');
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(dir, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
'name: localized',
|
|
'zh_name: "本地化技能"',
|
|
'en_name: "Localized Skill"',
|
|
'description: "English fallback description."',
|
|
'zh_description: "中文描述。"',
|
|
'en_description: "English localized description."',
|
|
'od:',
|
|
' example_prompt: "English fallback prompt."',
|
|
' example_prompt_i18n:',
|
|
' zh-CN: "中文 prompt。"',
|
|
'---',
|
|
'',
|
|
'# Localized skill body',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills[0]).toMatchObject({
|
|
id: 'localized',
|
|
displayName: {
|
|
en: 'Localized Skill',
|
|
'zh-CN': '本地化技能',
|
|
},
|
|
descriptionI18n: {
|
|
en: 'English localized description.',
|
|
'zh-CN': '中文描述。',
|
|
},
|
|
examplePrompt: 'English fallback prompt.',
|
|
examplePromptI18n: {
|
|
'zh-CN': '中文 prompt。',
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('includes the built-in live-artifact skill catalog entry', async () => {
|
|
const skills = await listSkills(designTemplatesRoot);
|
|
const skill = skills.find((entry: { id: string }) => entry.id === 'live-artifact');
|
|
|
|
if (!skill) throw new Error('live-artifact skill not found');
|
|
expect(skill).toMatchObject({
|
|
id: 'live-artifact',
|
|
name: 'live-artifact',
|
|
mode: 'prototype',
|
|
previewType: 'html',
|
|
});
|
|
expect(skill.triggers.length).toBeGreaterThan(0);
|
|
const liveArtifactAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(liveArtifactRoot)}`;
|
|
expect(skill.body).toContain(`> **Skill root (absolute fallback):** \`${liveArtifactRoot}\``);
|
|
expect(skill.body).toContain(`${liveArtifactAlias}/`);
|
|
expect(skill.body).toContain('references/artifact-schema.md');
|
|
expect(skill.body).toContain('references/connector-policy.md');
|
|
expect(skill.body).toContain('references/refresh-contract.md');
|
|
expect(skill.body).toContain(`${liveArtifactAlias}/references/artifact-schema.md`);
|
|
expect(skill.body).not.toContain(`${liveArtifactAlias}/assets/template.html`);
|
|
expect(skill.body).not.toContain(`${liveArtifactAlias}/references/layouts.md`);
|
|
expect(skill.body).toContain('"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json');
|
|
expect(skill.body).toContain('do not ask “where should the data come from?” before checking daemon connector tools');
|
|
expect(skill.body).toContain('notion.notion_search');
|
|
expect(skill.body).toContain('`OD_DAEMON_URL`');
|
|
expect(skill.body).toContain('`OD_TOOL_TOKEN`');
|
|
});
|
|
|
|
it('includes the agent-browser skill as an external CLI integration', async () => {
|
|
const skills = await listSkills(skillsRoot);
|
|
const skill = skills.find((entry: { id: string }) => entry.id === 'agent-browser');
|
|
|
|
if (!skill) throw new Error('agent-browser skill not found');
|
|
expect(skill).toMatchObject({
|
|
id: 'agent-browser',
|
|
name: 'agent-browser',
|
|
mode: 'prototype',
|
|
previewType: 'markdown',
|
|
designSystemRequired: false,
|
|
upstream: 'https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md',
|
|
});
|
|
expect(skill.triggers).toContain('test this web app');
|
|
expect(skill.body).toContain('agent-browser skills get core');
|
|
expect(skill.body).toContain('Never print full upstream guides into chat or tool output');
|
|
expect(skill.body).toContain('`agent-browser` must attach to an existing CDP endpoint');
|
|
expect(skill.body).toContain('curl -fsS http://127.0.0.1:9223/json/version');
|
|
expect(skill.body).toContain('open -na "Google Chrome" --args');
|
|
expect(skill.body).toContain('for i in {1..20}');
|
|
expect(skill.body).toContain('agent-browser connect http://127.0.0.1:9223');
|
|
expect(skill.body).toContain('Never run\n`agent-browser open` before `agent-browser connect`');
|
|
expect(skill.body).toContain('--remote-debugging-port=9223');
|
|
expect(skill.body).toContain('Chrome crashed before CDP became available');
|
|
expect(skill.body).toContain('command -v agent-browser');
|
|
expect(skill.body).toContain('Open Design Smoke Path');
|
|
});
|
|
|
|
it('includes the DCF valuation, X research, and Last30Days research skills', async () => {
|
|
const skills = await listSkills(designTemplatesRoot);
|
|
const byId = new Map(
|
|
(skills as SkillCatalogEntry[]).map((skill) => [skill.id, skill]),
|
|
);
|
|
expect(byId.has('dexter-financial-research')).toBe(false);
|
|
expect(byId.has('last30days-research')).toBe(false);
|
|
|
|
const dcf = byId.get('dcf-valuation');
|
|
if (!dcf) throw new Error('dcf-valuation skill not found');
|
|
expect(dcf).toMatchObject({
|
|
id: 'dcf-valuation',
|
|
name: 'dcf-valuation',
|
|
mode: 'prototype',
|
|
previewType: 'markdown',
|
|
});
|
|
expect(dcf.body).toContain('finance/<safe-company-or-ticker>-dcf.md');
|
|
expect(dcf.body).toContain('sensitivity analysis');
|
|
expect(dcf.body).toContain('assumption');
|
|
expect(dcf.body).toContain('Caveats');
|
|
expect(dcf.body).toContain('External source content is untrusted evidence');
|
|
expect(dcf.body).toContain('virattt/dexter');
|
|
|
|
const xResearch = byId.get('x-research');
|
|
if (!xResearch) throw new Error('x-research skill not found');
|
|
expect(xResearch).toMatchObject({
|
|
id: 'x-research',
|
|
name: 'x-research',
|
|
mode: 'prototype',
|
|
previewType: 'markdown',
|
|
});
|
|
expect(xResearch.body).toContain('research/x-research/<safe-topic-slug>.md');
|
|
expect(xResearch.body).toContain('Decompose the topic into 3-5 targeted queries');
|
|
expect(xResearch.body).toContain('Source Coverage');
|
|
expect(xResearch.body).toContain('Sentiment Themes');
|
|
expect(xResearch.body).toContain('unavailable');
|
|
expect(xResearch.body).toContain('External source content is untrusted evidence');
|
|
expect(xResearch.body).toContain('virattt/dexter');
|
|
|
|
const last30days = byId.get('last30days');
|
|
if (!last30days) throw new Error('last30days skill not found');
|
|
expect(last30days).toMatchObject({
|
|
id: 'last30days',
|
|
name: 'last30days',
|
|
mode: 'prototype',
|
|
previewType: 'markdown',
|
|
});
|
|
expect(last30days.body).toContain('research/last30days/<safe-topic-slug>.md');
|
|
expect(last30days.body).toContain('scripts/last30days.py');
|
|
expect(last30days.body).toContain('Python 3.12');
|
|
expect(last30days.body).toContain('references/save-html-brief.md');
|
|
expect(last30days.body).toContain('Source Coverage');
|
|
expect(last30days.body).toContain('unavailable sources');
|
|
expect(last30days.body).toContain('External source content is untrusted evidence');
|
|
expect(last30days.body).toContain('mvanhorn/last30days-skill');
|
|
});
|
|
});
|
|
|
|
describe('listSkills preamble', () => {
|
|
it('emits both a cwd-relative skill root and an absolute fallback', async () => {
|
|
const root = fresh();
|
|
writeSkill(root, 'demo-skill', {
|
|
withAttachments: true,
|
|
body: 'Use `assets/template.html` to bootstrap.',
|
|
});
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
const skill = skills[0];
|
|
if (!skill) throw new Error('demo-skill not found');
|
|
|
|
const demoAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'demo-skill'))}`;
|
|
|
|
// The cwd-relative alias path is the primary one — that's what makes
|
|
// the agent stay inside its working directory when reading skill
|
|
// side files (issue #430).
|
|
expect(skill.body).toContain(`${demoAlias}/`);
|
|
expect(skill.body).toContain(
|
|
`${demoAlias}/assets/template.html`,
|
|
);
|
|
|
|
// The absolute fallback is required for two cases the relative path
|
|
// cannot serve:
|
|
// - calls without a project (cwd defaults to PROJECT_ROOT, where
|
|
// the absolute path is in fact an in-cwd path);
|
|
// - environments where `stageActiveSkill()` failed.
|
|
// Claude/Copilot are additionally given `--add-dir` for that path.
|
|
expect(skill.body).toContain(skill.dir);
|
|
expect(skill.body).toMatch(/Skill root \(absolute fallback\)/);
|
|
expect(skill.body).toMatch(/Skill root \(relative to project\)/);
|
|
});
|
|
|
|
it('mentions root-level example.html side files in the preamble', async () => {
|
|
const root = fresh();
|
|
writeSkill(root, 'orbit-style', {
|
|
withAttachments: false,
|
|
body: 'Open and mirror the shipped `example.html` before writing output.',
|
|
});
|
|
writeFileSync(path.join(root, 'orbit-style', 'example.html'), '<main>example</main>');
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
const skill = skills[0];
|
|
if (!skill) throw new Error('orbit-style skill not found');
|
|
|
|
const orbitAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'orbit-style'))}`;
|
|
|
|
expect(skill.body).toContain(`${orbitAlias}/`);
|
|
expect(skill.body).toContain(`${orbitAlias}/example.html`);
|
|
expect(skill.body).toContain('Known side files in this skill: `example.html`.');
|
|
});
|
|
|
|
it('uses the on-disk folder name in the alias path even when `name` differs', async () => {
|
|
const root = fresh();
|
|
writeSkill(root, 'guizang-ppt', {
|
|
name: 'magazine-web-ppt',
|
|
withAttachments: true,
|
|
});
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
const skill = skills[0];
|
|
if (!skill) throw new Error('magazine-web-ppt skill not found');
|
|
|
|
const folderAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'guizang-ppt'))}`;
|
|
const frontmatterAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'magazine-web-ppt'))}`;
|
|
|
|
// `id`/`name` reflect the frontmatter value (used elsewhere as a stable
|
|
// public id), but the on-disk alias path must use the actual folder
|
|
// name — that is what the daemon-staged junction maps to.
|
|
expect(skill.id).toBe('magazine-web-ppt');
|
|
expect(skill.body).toContain(`${folderAlias}/`);
|
|
expect(skill.body).not.toContain(`${frontmatterAlias}/`);
|
|
});
|
|
|
|
it('does not emit a preamble for skills without side files', async () => {
|
|
const root = fresh();
|
|
writeSkill(root, 'lone-skill', {
|
|
withAttachments: false,
|
|
body: 'Body without external files.',
|
|
});
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
const skill = skills[0];
|
|
if (!skill) throw new Error('lone-skill not found');
|
|
|
|
expect(skill.body).not.toContain(SKILLS_CWD_ALIAS);
|
|
expect(skill.body).not.toContain('Skill root');
|
|
expect(skill.body).toContain('Body without external files.');
|
|
});
|
|
});
|
|
|
|
describe('listSkills multi-root + source tagging', () => {
|
|
it('tags entries from the first root as "user" and the second as "built-in"', async () => {
|
|
const userRoot = fresh();
|
|
const builtInRoot = fresh();
|
|
writeSkill(userRoot, 'web-search', {
|
|
description: 'User-imported web search.',
|
|
});
|
|
writeSkill(builtInRoot, 'audio-jingle', {
|
|
description: 'Built-in jingle skill.',
|
|
});
|
|
|
|
const skills = await listSkills([userRoot, builtInRoot]);
|
|
expect(skills).toHaveLength(2);
|
|
const byId = new Map<string, { id: string; source: string }>(
|
|
skills.map((s: { id: string; source: string }) => [s.id, s]),
|
|
);
|
|
expect(byId.get('web-search')?.source).toBe('user');
|
|
expect(byId.get('audio-jingle')?.source).toBe('built-in');
|
|
|
|
rmSync(userRoot, { recursive: true, force: true });
|
|
rmSync(builtInRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('lets a user skill shadow a built-in skill of the same id', async () => {
|
|
const userRoot = fresh();
|
|
const builtInRoot = fresh();
|
|
writeSkill(userRoot, 'shared-id', {
|
|
description: 'User override.',
|
|
body: '# Override body',
|
|
});
|
|
writeSkill(builtInRoot, 'shared-id', {
|
|
description: 'Original built-in.',
|
|
body: '# Built-in body',
|
|
});
|
|
|
|
const skills = await listSkills([userRoot, builtInRoot]);
|
|
expect(skills).toHaveLength(1);
|
|
const shadowed = skills[0]!;
|
|
expect(shadowed.source).toBe('user');
|
|
expect(shadowed.body).toContain('Override body');
|
|
|
|
rmSync(userRoot, { recursive: true, force: true });
|
|
rmSync(builtInRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('slugifySkillName', () => {
|
|
it('lowercases, normalises spaces, and strips reserved slugs', () => {
|
|
expect(slugifySkillName('Web Search')).toBe('web-search');
|
|
expect(slugifySkillName(' Multi Word Skill ')).toBe('multi-word-skill');
|
|
expect(slugifySkillName(' ')).toBe('');
|
|
expect(slugifySkillName('..')).toBe('');
|
|
expect(slugifySkillName('a/../b')).toBe('a-b');
|
|
});
|
|
});
|
|
|
|
describe('importUserSkill / deleteUserSkill', () => {
|
|
it('writes a SKILL.md and round-trips through listSkills', async () => {
|
|
const root = fresh();
|
|
try {
|
|
const result = await importUserSkill(root, {
|
|
name: 'Code Review',
|
|
description: 'Review the latest diff.',
|
|
body: '# Review\n\n1. Read.\n2. Comment.',
|
|
triggers: ['code review', 'review my diff'],
|
|
});
|
|
expect(result.id).toBe('Code Review');
|
|
expect(result.slug).toBe('code-review');
|
|
expect(result.dir).toBe(path.join(root, 'code-review'));
|
|
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
const imported = skills[0]!;
|
|
expect(imported.id).toBe('Code Review');
|
|
expect(imported.triggers).toEqual(['code review', 'review my diff']);
|
|
// First (and only) root is treated as the user root.
|
|
expect(imported.source).toBe('user');
|
|
|
|
// Importing the same name again surfaces a CONFLICT error.
|
|
await expect(
|
|
importUserSkill(root, {
|
|
name: 'Code Review',
|
|
body: '# Different body',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
|
|
await deleteUserSkill(root, 'Code Review');
|
|
const after = await listSkills(root);
|
|
expect(after).toHaveLength(0);
|
|
|
|
// Deleting an already-deleted skill returns NOT_FOUND.
|
|
await expect(deleteUserSkill(root, 'Code Review')).rejects.toMatchObject({
|
|
code: 'NOT_FOUND',
|
|
});
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects empty bodies and impossibly-named skills', async () => {
|
|
const root = fresh();
|
|
try {
|
|
await expect(
|
|
importUserSkill(root, { name: 'foo', body: ' ' }),
|
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
await expect(
|
|
importUserSkill(root, { name: '..', body: '# body' }),
|
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Names like '123', 'true', or 'null' are valid skill ids but YAML coerces
|
|
// unquoted scalars to non-strings, which broke the importUserSkill ->
|
|
// listSkills round-trip prior to PR #955 review feedback. The frontmatter
|
|
// emitter now always quotes `name`, so listSkills should round-trip the
|
|
// exact string id we wrote.
|
|
it('round-trips numeric- and boolean-shaped names through listSkills', async () => {
|
|
const cases = ['123', 'true', 'false', 'null', '0'];
|
|
for (const name of cases) {
|
|
const root = fresh();
|
|
try {
|
|
const result = await importUserSkill(root, {
|
|
name,
|
|
body: `# ${name} body`,
|
|
});
|
|
expect(result.id).toBe(name);
|
|
const skills = await listSkills(root);
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0]?.id).toBe(name);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('updateUserSkill', () => {
|
|
it('writes a SKILL.md and shadows a built-in entry on next listSkills', async () => {
|
|
const userRoot = fresh();
|
|
const builtInRoot = fresh();
|
|
try {
|
|
writeSkill(builtInRoot, 'shared-id', {
|
|
description: 'Original built-in.',
|
|
body: '# Original',
|
|
});
|
|
|
|
const result = await updateUserSkill(userRoot, {
|
|
name: 'shared-id',
|
|
description: 'User override.',
|
|
body: '# Override',
|
|
triggers: ['shared trigger'],
|
|
});
|
|
expect(result.slug).toBe('shared-id');
|
|
expect(result.dir).toBe(path.join(userRoot, 'shared-id'));
|
|
|
|
const skills = await listSkills([userRoot, builtInRoot]);
|
|
expect(skills).toHaveLength(1);
|
|
const shadowed = skills[0]!;
|
|
expect(shadowed.source).toBe('user');
|
|
expect(shadowed.body).toContain('Override');
|
|
expect(shadowed.triggers).toEqual(['shared trigger']);
|
|
} finally {
|
|
rmSync(userRoot, { recursive: true, force: true });
|
|
rmSync(builtInRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('rejects empty bodies and impossibly-named skills', async () => {
|
|
const root = fresh();
|
|
try {
|
|
await expect(
|
|
updateUserSkill(root, { name: 'demo', body: ' ' }),
|
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
await expect(
|
|
updateUserSkill(root, { name: '..', body: '# body' }),
|
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Regression for mrcfps' PR #955 blocker: editing a built-in skill
|
|
// wrote a shadow folder that contained only a new SKILL.md. The next
|
|
// listSkills() pass surfaced the shadow as the active dir, but
|
|
// /api/skills/:id/files, /example, /assets/* and the system-prompt
|
|
// preamble all resolve through skill.dir, so the bundled assets/,
|
|
// references/, scripts/, and examples/ silently disappeared after
|
|
// save. The fix clones the built-in side tree into the shadow on
|
|
// first edit; subsequent edits leave the user's tweaks alone.
|
|
it('clones built-in side files into the shadow on the first edit', async () => {
|
|
const userRoot = fresh();
|
|
const builtInRoot = fresh();
|
|
try {
|
|
writeSkill(builtInRoot, 'shadow-me', {
|
|
body: '# Original built-in',
|
|
withAttachments: true,
|
|
});
|
|
mkdirSync(path.join(builtInRoot, 'shadow-me', 'references'), {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
path.join(builtInRoot, 'shadow-me', 'references', 'notes.md'),
|
|
'# bundled notes',
|
|
);
|
|
mkdirSync(path.join(builtInRoot, 'shadow-me', 'scripts'), {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
path.join(builtInRoot, 'shadow-me', 'scripts', 'helper.sh'),
|
|
'#!/bin/sh\necho built-in\n',
|
|
);
|
|
|
|
const before = await listSkills([userRoot, builtInRoot]);
|
|
expect(before).toHaveLength(1);
|
|
expect(before[0]!.source).toBe('built-in');
|
|
|
|
const result = await updateUserSkill(userRoot, {
|
|
name: 'shadow-me',
|
|
body: '# User override',
|
|
sourceDir: before[0]!.dir,
|
|
});
|
|
expect(result.dir).toBe(path.join(userRoot, 'shadow-me'));
|
|
|
|
const after = await listSkills([userRoot, builtInRoot]);
|
|
expect(after).toHaveLength(1);
|
|
const shadowed = after[0]!;
|
|
expect(shadowed.source).toBe('user');
|
|
expect(shadowed.body).toContain('User override');
|
|
|
|
const files = await listSkillFiles(shadowed.dir);
|
|
const paths = files.map((entry) => entry.path).sort();
|
|
expect(paths).toContain('SKILL.md');
|
|
expect(paths).toContain('assets');
|
|
expect(paths).toContain('assets/template.html');
|
|
expect(paths).toContain('references');
|
|
expect(paths).toContain('references/notes.md');
|
|
expect(paths).toContain('scripts');
|
|
expect(paths).toContain('scripts/helper.sh');
|
|
|
|
const noteContent = readFileSync(
|
|
path.join(shadowed.dir, 'references', 'notes.md'),
|
|
'utf8',
|
|
);
|
|
expect(noteContent).toContain('bundled notes');
|
|
} finally {
|
|
rmSync(userRoot, { recursive: true, force: true });
|
|
rmSync(builtInRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves user-edited side files on subsequent edits', async () => {
|
|
const userRoot = fresh();
|
|
const builtInRoot = fresh();
|
|
try {
|
|
writeSkill(builtInRoot, 'edit-twice', {
|
|
body: '# Original',
|
|
withAttachments: true,
|
|
});
|
|
|
|
const initial = await listSkills([userRoot, builtInRoot]);
|
|
await updateUserSkill(userRoot, {
|
|
name: 'edit-twice',
|
|
body: '# First override',
|
|
sourceDir: initial[0]!.dir,
|
|
});
|
|
|
|
const tweakedAsset = path.join(
|
|
userRoot,
|
|
'edit-twice',
|
|
'assets',
|
|
'template.html',
|
|
);
|
|
writeFileSync(tweakedAsset, '<html><body>user-tweaked</body></html>');
|
|
|
|
const next = await listSkills([userRoot, builtInRoot]);
|
|
expect(next[0]!.source).toBe('user');
|
|
|
|
await updateUserSkill(userRoot, {
|
|
name: 'edit-twice',
|
|
body: '# Second override',
|
|
sourceDir: next[0]!.dir,
|
|
});
|
|
|
|
const tweaked = readFileSync(tweakedAsset, 'utf8');
|
|
expect(tweaked).toContain('user-tweaked');
|
|
const final = await listSkills([userRoot, builtInRoot]);
|
|
expect(final[0]!.body).toContain('Second override');
|
|
} finally {
|
|
rmSync(userRoot, { recursive: true, force: true });
|
|
rmSync(builtInRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('listSkillFiles', () => {
|
|
it('returns a flat sorted file/directory list with byte sizes', async () => {
|
|
const root = fresh();
|
|
try {
|
|
writeSkill(root, 'demo-files', { withAttachments: true });
|
|
mkdirSync(path.join(root, 'demo-files', 'references'), { recursive: true });
|
|
writeFileSync(
|
|
path.join(root, 'demo-files', 'references', 'notes.md'),
|
|
'# notes',
|
|
);
|
|
|
|
const entries = await listSkillFiles(path.join(root, 'demo-files'));
|
|
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
const skillMd = byPath.get('SKILL.md');
|
|
const assetsDir = byPath.get('assets');
|
|
const templateHtml = byPath.get('assets/template.html');
|
|
const referencesDir = byPath.get('references');
|
|
const notesMd = byPath.get('references/notes.md');
|
|
if (!skillMd || !assetsDir || !templateHtml || !referencesDir || !notesMd) {
|
|
throw new Error('expected file tree to include SKILL.md + assets + references');
|
|
}
|
|
expect(skillMd.kind).toBe('file');
|
|
expect(skillMd.size).toBeGreaterThan(0);
|
|
expect(assetsDir.kind).toBe('directory');
|
|
expect(assetsDir.size).toBeNull();
|
|
expect(templateHtml.kind).toBe('file');
|
|
expect(templateHtml.size).toBeGreaterThan(0);
|
|
expect(referencesDir.kind).toBe('directory');
|
|
expect(notesMd.kind).toBe('file');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips dotfiles and returns an empty list for a missing directory', async () => {
|
|
const root = fresh();
|
|
try {
|
|
writeSkill(root, 'with-dotfile');
|
|
writeFileSync(path.join(root, 'with-dotfile', '.DS_Store'), 'x');
|
|
const entries = await listSkillFiles(path.join(root, 'with-dotfile'));
|
|
expect(entries.find((entry) => entry.path === '.DS_Store')).toBeUndefined();
|
|
|
|
const missing = await listSkillFiles(path.join(root, 'no-such-skill'));
|
|
expect(missing).toEqual([]);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|