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:
5
.changeset/wise-rams-gather.md
Normal file
5
.changeset/wise-rams-gather.md
Normal 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)
|
||||
115
sdk/src/query/detect-custom-files.test.ts
Normal file
115
sdk/src/query/detect-custom-files.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const GSD_MANAGED_DIRS = [
|
||||
'agents',
|
||||
join('commands', 'gsd'),
|
||||
'hooks',
|
||||
'skills',
|
||||
];
|
||||
|
||||
function walkDir(dir: string, baseDir: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user