fix(3317): SDK detect-custom-files now scans skills/ (parity with CJS port) (#3318)

* 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 <config-dir>/skills/<name>/ are never returned and get destroyed
on /gsd-update. New vitest covers:

- detects custom skill at skills/<name>/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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* chore: correct changeset pr number

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 23:28:10 -04:00
committed by GitHub
parent cbc18f5e3e
commit d49e8872b5
3 changed files with 121 additions and 0 deletions

View File

@@ -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 `<config-dir>/skills/<name>/` 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)

View File

@@ -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 `<config-dir>/skills/<name>/`
* 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<string, string>): Promise<void> {
const manifest = {
version: '1.41.1',
timestamp: new Date().toISOString(),
files: {} as Record<string, string>,
};
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<void> {
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/<name>/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);
});
});

View File

@@ -14,6 +14,7 @@ const GSD_MANAGED_DIRS = [
'agents',
join('commands', 'gsd'),
'hooks',
'skills',
];
function walkDir(dir: string, baseDir: string): string[] {