diff --git a/.changeset/wise-rams-gather.md b/.changeset/wise-rams-gather.md new file mode 100644 index 000000000..329aac1c3 --- /dev/null +++ b/.changeset/wise-rams-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3318 +--- +**`detect-custom-files` now scans `skills/`** — SDK port omitted `skills` from `GSD_MANAGED_DIRS`, so user-added skills under `/skills//` were never detected and got silently destroyed during `/gsd-update` (no entry written to `gsd-user-files-backup/`). One-line parity with `bin/gsd-tools.cjs`. (#3317) diff --git a/sdk/src/query/detect-custom-files.test.ts b/sdk/src/query/detect-custom-files.test.ts new file mode 100644 index 000000000..2b78b4851 --- /dev/null +++ b/sdk/src/query/detect-custom-files.test.ts @@ -0,0 +1,115 @@ +/** + * Regression test for #3317 — SDK detect-custom-files omits `skills/` from + * GSD_MANAGED_DIRS. Mirrors the CJS-side coverage in + * `tests/bug-2942-detect-custom-skills.test.cjs`. + * + * Without the fix, user-added skills under `/skills//` + * are not detected and get silently wiped on `/gsd-update`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { detectCustomFiles } from './detect-custom-files.js'; + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +async function writeManifest(configDir: string, files: Record): Promise { + const manifest = { + version: '1.41.1', + timestamp: new Date().toISOString(), + files: {} as Record, + }; + for (const [relPath, content] of Object.entries(files)) { + const fullPath = join(configDir, relPath); + await mkdir(join(fullPath, '..'), { recursive: true }); + await writeFile(fullPath, content); + manifest.files[relPath] = sha256(content); + } + await writeFile( + join(configDir, 'gsd-file-manifest.json'), + JSON.stringify(manifest, null, 2), + ); +} + +async function writeCustomFile(configDir: string, relPath: string, content: string): Promise { + const fullPath = join(configDir, relPath); + await mkdir(join(fullPath, '..'), { recursive: true }); + await writeFile(fullPath, content); +} + +interface DetectResult { + custom_files: string[]; + custom_count: number; + manifest_found: boolean; +} + +describe('detectCustomFiles — skills/ parity with CJS port (#3317)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-3317-skills-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('detects custom skill at skills//SKILL.md', async () => { + await writeManifest(tmpDir, { + 'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n', + }); + await writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n'); + + const { data } = await detectCustomFiles(['--config-dir', tmpDir], tmpDir); + const result = data as DetectResult; + + expect(Array.isArray(result.custom_files)).toBe(true); + expect(result.custom_files).toContain('skills/test-custom/SKILL.md'); + expect(result.custom_count).toBeGreaterThanOrEqual(1); + }); + + it('does not flag GSD-owned skill listed in manifest', async () => { + await writeManifest(tmpDir, { + 'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n', + }); + + const { data } = await detectCustomFiles(['--config-dir', tmpDir], tmpDir); + const result = data as DetectResult; + + expect(result.custom_files).not.toContain('skills/gsd-planner/SKILL.md'); + }); + + it('still detects custom files in get-shit-done/workflows/ (regression guard)', async () => { + await writeManifest(tmpDir, { + 'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n', + 'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n', + }); + await writeCustomFile(tmpDir, 'get-shit-done/workflows/custom-workflow.md', '# Custom\n'); + + const { data } = await detectCustomFiles(['--config-dir', tmpDir], tmpDir); + const result = data as DetectResult; + + expect(result.custom_files).toContain('get-shit-done/workflows/custom-workflow.md'); + }); + + it('custom_count matches custom_files.length across multiple skills', async () => { + await writeManifest(tmpDir, { + 'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n', + }); + await writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# Custom One\n'); + await writeCustomFile(tmpDir, 'skills/another-custom/SKILL.md', '# Custom Two\n'); + + const { data } = await detectCustomFiles(['--config-dir', tmpDir], tmpDir); + const result = data as DetectResult; + + expect(result.custom_count).toBe(result.custom_files.length); + const skillEntries = result.custom_files.filter(f => f.startsWith('skills/')); + expect(skillEntries).toHaveLength(2); + }); +}); diff --git a/sdk/src/query/detect-custom-files.ts b/sdk/src/query/detect-custom-files.ts index 2f496bebe..3dc46fea2 100644 --- a/sdk/src/query/detect-custom-files.ts +++ b/sdk/src/query/detect-custom-files.ts @@ -14,6 +14,7 @@ const GSD_MANAGED_DIRS = [ 'agents', join('commands', 'gsd'), 'hooks', + 'skills', ]; function walkDir(dir: string, baseDir: string): string[] {