From d49e8872b5ee267f4d228347ae774b610475c9cb Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 9 May 2026 23:28:10 -0400 Subject: [PATCH] fix(3317): SDK detect-custom-files now scans skills/ (parity with CJS port) (#3318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(3317): red — SDK detect-custom-files must scan skills/ Mirrors tests/bug-2942-detect-custom-skills.test.cjs on the SDK side. The SDK's GSD_MANAGED_DIRS array omits 'skills', so user-added skills under /skills// are never returned and get destroyed on /gsd-update. New vitest covers: - detects custom skill at skills//SKILL.md - does not flag manifest-tracked skill as custom - still detects custom files under get-shit-done/workflows/ (regression) - custom_count matches custom_files.length across multiple skills Fails 2/4 against current SDK source. Will pass after fix. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(3317): SDK detect-custom-files now scans skills/ (parity with CJS) The SDK port of detect-custom-files declared GSD_MANAGED_DIRS without 'skills', while the canonical bin/gsd-tools.cjs port (which the SDK docstring explicitly cites as its source) had the entry. Because update.md prefers gsd-sdk over the CJS shim, real-world users with the SDK installed never had their custom skills detected — the installer's 'Installed N skills to skills/' step then wiped any non-manifest skill without backing it up to gsd-user-files-backup/. Real-world incident: skills/gsd-roadmap/SKILL.md (fully user-owned) destroyed during the 1.40.0 → 1.41.0 update, recoverable only via Time Machine. One-line fix adds 'skills' to the SDK's GSD_MANAGED_DIRS, matching the CJS source the port was supposed to mirror. The 4 vitest cases added in the prior commit now all pass. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: correct changeset pr number --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/wise-rams-gather.md | 5 + sdk/src/query/detect-custom-files.test.ts | 115 ++++++++++++++++++++++ sdk/src/query/detect-custom-files.ts | 1 + 3 files changed, 121 insertions(+) create mode 100644 .changeset/wise-rams-gather.md create mode 100644 sdk/src/query/detect-custom-files.test.ts 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[] {