mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
feat(skills): normalize skill discovery contract across runtimes (#2261)
This commit is contained in:
@@ -117,7 +117,9 @@ Verify with:
|
|||||||
- Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists
|
- Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Claude Code 2.1.88+, Qwen Code, and Codex install as skills (`skills/gsd-*/SKILL.md`). Older Claude Code versions use `commands/gsd/`. Cline uses `.clinerules` for configuration. The installer handles all formats automatically.
|
> Claude Code 2.1.88+, Qwen Code, and Codex install as skills (`.claude/skills/`, `./.codex/skills/`, or the matching global `~/.claude/skills/` / `~/.codex/skills/` roots). Older Claude Code versions use `commands/gsd/`. `~/.claude/get-shit-done/skills/` is import-only for legacy migration. The installer handles all formats automatically.
|
||||||
|
|
||||||
|
The canonical discovery contract is documented in [docs/skills/discovery-contract.md](docs/skills/discovery-contract.md).
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> For source-based installs or environments where npm is unavailable, see **[docs/manual-update.md](docs/manual-update.md)**.
|
> For source-based installs or environments where npm is unavailable, see **[docs/manual-update.md](docs/manual-update.md)**.
|
||||||
@@ -818,8 +820,9 @@ This prevents Claude from reading these files entirely, regardless of what comma
|
|||||||
|
|
||||||
**Commands not found after install?**
|
**Commands not found after install?**
|
||||||
- Restart your runtime to reload commands/skills
|
- Restart your runtime to reload commands/skills
|
||||||
- Verify files exist in `~/.claude/skills/gsd-*/SKILL.md` (Claude Code 2.1.88+) or `~/.claude/commands/gsd/` (legacy)
|
- Verify files exist in `~/.claude/skills/gsd-*/SKILL.md` or `~/.codex/skills/gsd-*/SKILL.md` for managed global installs
|
||||||
- For Codex, verify skills exist in `~/.codex/skills/gsd-*/SKILL.md` (global) or `./.codex/skills/gsd-*/SKILL.md` (local)
|
- For local installs, verify `.claude/skills/gsd-*/SKILL.md` or `./.codex/skills/gsd-*/SKILL.md`
|
||||||
|
- Legacy Claude Code installs still use `~/.claude/commands/gsd/`
|
||||||
|
|
||||||
**Commands not working as expected?**
|
**Commands not working as expected?**
|
||||||
- Run `/gsd-help` to verify installation
|
- Run `/gsd-help` to verify installation
|
||||||
|
|||||||
92
docs/skills/discovery-contract.md
Normal file
92
docs/skills/discovery-contract.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Skill Discovery Contract
|
||||||
|
|
||||||
|
> Canonical rules for scanning, inventorying, and rendering GSD skills.
|
||||||
|
|
||||||
|
## Root Categories
|
||||||
|
|
||||||
|
### Project Roots
|
||||||
|
|
||||||
|
Scan these roots relative to the project root:
|
||||||
|
|
||||||
|
- `.claude/skills/`
|
||||||
|
- `.agents/skills/`
|
||||||
|
- `.cursor/skills/`
|
||||||
|
- `.github/skills/`
|
||||||
|
- `./.codex/skills/`
|
||||||
|
|
||||||
|
These roots are used for project-specific skills and for the project `CLAUDE.md` skills section.
|
||||||
|
|
||||||
|
### Managed Global Roots
|
||||||
|
|
||||||
|
Scan these roots relative to the user home directory:
|
||||||
|
|
||||||
|
- `~/.claude/skills/`
|
||||||
|
- `~/.codex/skills/`
|
||||||
|
|
||||||
|
These roots are used for managed runtime installs and inventory reporting.
|
||||||
|
|
||||||
|
### Deprecated Import-Only Root
|
||||||
|
|
||||||
|
- `~/.claude/get-shit-done/skills/`
|
||||||
|
|
||||||
|
This root is kept for legacy migration only. Inventory code may report it, but new installs should not write here.
|
||||||
|
|
||||||
|
### Legacy Claude Commands
|
||||||
|
|
||||||
|
- `~/.claude/commands/gsd/`
|
||||||
|
|
||||||
|
This is not a skills root. Discovery code only checks whether it exists so inventory can report legacy Claude installs.
|
||||||
|
|
||||||
|
## Normalization Rules
|
||||||
|
|
||||||
|
- Scan only subdirectories that contain `SKILL.md`.
|
||||||
|
- Read `name` and `description` from YAML frontmatter.
|
||||||
|
- Use the directory name when `name` is missing.
|
||||||
|
- Extract trigger hints from body lines that match `TRIGGER when: ...`.
|
||||||
|
- Treat `gsd-*` directories as installed framework skills.
|
||||||
|
- Treat `~/.claude/get-shit-done/skills/` entries as deprecated/import-only.
|
||||||
|
- Treat `~/.claude/commands/gsd/` as legacy command installation metadata, not skills.
|
||||||
|
|
||||||
|
## Scanner Behavior
|
||||||
|
|
||||||
|
### `sdk/src/query/skills.ts`
|
||||||
|
|
||||||
|
- Returns a de-duplicated list of discovered skill names.
|
||||||
|
- Scans project roots plus managed global roots.
|
||||||
|
- Does not scan the deprecated import-only root.
|
||||||
|
|
||||||
|
### `get-shit-done/bin/lib/profile-output.cjs`
|
||||||
|
|
||||||
|
- Builds the project `CLAUDE.md` skills section.
|
||||||
|
- Scans project roots only.
|
||||||
|
- Skips `gsd-*` directories so the project section stays focused on user/project skills.
|
||||||
|
- Adds `.codex/skills/` to the project discovery set.
|
||||||
|
|
||||||
|
### `get-shit-done/bin/lib/init.cjs`
|
||||||
|
|
||||||
|
- Generates the skill inventory object for `skill-manifest`.
|
||||||
|
- Reports `skills`, `roots`, `installation`, and `counts`.
|
||||||
|
- Marks `gsd_skills_installed` when any discovered skill name starts with `gsd-`.
|
||||||
|
- Marks `legacy_claude_commands_installed` when `~/.claude/commands/gsd/` contains `.md` command files.
|
||||||
|
|
||||||
|
## Inventory Shape
|
||||||
|
|
||||||
|
`skill-manifest` returns a JSON object with:
|
||||||
|
|
||||||
|
- `skills`: normalized skill entries
|
||||||
|
- `roots`: the canonical roots that were checked
|
||||||
|
- `installation`: summary booleans for installed GSD skills and legacy Claude commands
|
||||||
|
- `counts`: small inventory counts for downstream consumers
|
||||||
|
|
||||||
|
Each skill entry includes:
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `triggers`
|
||||||
|
- `path`
|
||||||
|
- `file_path`
|
||||||
|
- `root`
|
||||||
|
- `scope`
|
||||||
|
- `installed`
|
||||||
|
- `deprecated`
|
||||||
|
|
||||||
@@ -1590,75 +1590,207 @@ function cmdAgentSkills(cwd, agentType, raw) {
|
|||||||
/**
|
/**
|
||||||
* Generate a skill manifest from a skills directory.
|
* Generate a skill manifest from a skills directory.
|
||||||
*
|
*
|
||||||
* Scans the given skills directory for subdirectories containing SKILL.md,
|
* Scans the canonical skill discovery roots and returns a normalized
|
||||||
* extracts frontmatter (name, description) and trigger conditions from the
|
* inventory object with discovered skills, root metadata, and installation
|
||||||
* body text, and returns an array of skill descriptors.
|
* summary flags. A legacy `skillsDir` override is still accepted for focused
|
||||||
|
* scans, but the default mode is multi-root discovery.
|
||||||
*
|
*
|
||||||
* @param {string} skillsDir - Absolute path to the skills directory
|
* @param {string} cwd - Project root directory
|
||||||
* @returns {Array<{name: string, description: string, triggers: string[], path: string}>}
|
* @param {string|null} [skillsDir] - Optional absolute path to a specific skills directory
|
||||||
|
* @returns {{
|
||||||
|
* skills: Array<{name: string, description: string, triggers: string[], path: string, file_path: string, root: string, scope: string, installed: boolean, deprecated: boolean}>,
|
||||||
|
* roots: Array<{root: string, path: string, scope: string, present: boolean, skill_count?: number, command_count?: number, deprecated?: boolean}>,
|
||||||
|
* installation: { gsd_skills_installed: boolean, legacy_claude_commands_installed: boolean },
|
||||||
|
* counts: { skills: number, roots: number }
|
||||||
|
* }}
|
||||||
*/
|
*/
|
||||||
function buildSkillManifest(skillsDir) {
|
function buildSkillManifest(cwd, skillsDir = null) {
|
||||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
if (!fs.existsSync(skillsDir)) return [];
|
const canonicalRoots = skillsDir ? [{
|
||||||
|
root: path.resolve(skillsDir),
|
||||||
|
path: path.resolve(skillsDir),
|
||||||
|
scope: 'custom',
|
||||||
|
present: fs.existsSync(skillsDir),
|
||||||
|
kind: 'skills',
|
||||||
|
}] : [
|
||||||
|
{
|
||||||
|
root: '.claude/skills',
|
||||||
|
path: path.join(cwd, '.claude', 'skills'),
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '.agents/skills',
|
||||||
|
path: path.join(cwd, '.agents', 'skills'),
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '.cursor/skills',
|
||||||
|
path: path.join(cwd, '.cursor', 'skills'),
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '.github/skills',
|
||||||
|
path: path.join(cwd, '.github', 'skills'),
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '.codex/skills',
|
||||||
|
path: path.join(cwd, '.codex', 'skills'),
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '~/.claude/skills',
|
||||||
|
path: path.join(os.homedir(), '.claude', 'skills'),
|
||||||
|
scope: 'global',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '~/.codex/skills',
|
||||||
|
path: path.join(os.homedir(), '.codex', 'skills'),
|
||||||
|
scope: 'global',
|
||||||
|
kind: 'skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '~/.claude/get-shit-done/skills',
|
||||||
|
path: path.join(os.homedir(), '.claude', 'get-shit-done', 'skills'),
|
||||||
|
scope: 'import-only',
|
||||||
|
kind: 'skills',
|
||||||
|
deprecated: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '~/.claude/commands/gsd',
|
||||||
|
path: path.join(os.homedir(), '.claude', 'commands', 'gsd'),
|
||||||
|
scope: 'legacy-commands',
|
||||||
|
kind: 'commands',
|
||||||
|
deprecated: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let entries;
|
const skills = [];
|
||||||
try {
|
const roots = [];
|
||||||
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
let legacyClaudeCommandsInstalled = false;
|
||||||
} catch {
|
for (const rootInfo of canonicalRoots) {
|
||||||
return [];
|
const rootPath = rootInfo.path;
|
||||||
}
|
const rootSummary = {
|
||||||
|
root: rootInfo.root,
|
||||||
|
path: rootPath,
|
||||||
|
scope: rootInfo.scope,
|
||||||
|
present: fs.existsSync(rootPath),
|
||||||
|
deprecated: !!rootInfo.deprecated,
|
||||||
|
};
|
||||||
|
|
||||||
const manifest = [];
|
if (!rootSummary.present) {
|
||||||
for (const entry of entries) {
|
roots.push(rootSummary);
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
|
|
||||||
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
||||||
if (!fs.existsSync(skillMdPath)) continue;
|
|
||||||
|
|
||||||
let content;
|
|
||||||
try {
|
|
||||||
content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontmatter = extractFrontmatter(content);
|
if (rootInfo.kind === 'commands') {
|
||||||
const name = frontmatter.name || entry.name;
|
let entries = [];
|
||||||
const description = frontmatter.description || '';
|
try {
|
||||||
|
entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
||||||
// Extract trigger lines from body text (after frontmatter)
|
} catch {
|
||||||
const triggers = [];
|
roots.push(rootSummary);
|
||||||
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
|
continue;
|
||||||
if (bodyMatch) {
|
|
||||||
const body = bodyMatch[1];
|
|
||||||
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
|
|
||||||
if (triggerLines) {
|
|
||||||
for (const line of triggerLines) {
|
|
||||||
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
|
|
||||||
if (m) triggers.push(m[1].trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commandFiles = entries.filter(entry => entry.isFile() && entry.name.endsWith('.md'));
|
||||||
|
rootSummary.command_count = commandFiles.length;
|
||||||
|
if (rootSummary.command_count > 0) legacyClaudeCommandsInstalled = true;
|
||||||
|
roots.push(rootSummary);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest.push({
|
let entries;
|
||||||
name,
|
try {
|
||||||
description,
|
entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
||||||
triggers,
|
} catch {
|
||||||
path: entry.name,
|
roots.push(rootSummary);
|
||||||
});
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skillCount = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
|
const skillMdPath = path.join(rootPath, entry.name, 'SKILL.md');
|
||||||
|
if (!fs.existsSync(skillMdPath)) continue;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(skillMdPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = extractFrontmatter(content);
|
||||||
|
const name = frontmatter.name || entry.name;
|
||||||
|
const description = frontmatter.description || '';
|
||||||
|
|
||||||
|
// Extract trigger lines from body text (after frontmatter)
|
||||||
|
const triggers = [];
|
||||||
|
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
|
||||||
|
if (bodyMatch) {
|
||||||
|
const body = bodyMatch[1];
|
||||||
|
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gmi);
|
||||||
|
if (triggerLines) {
|
||||||
|
for (const line of triggerLines) {
|
||||||
|
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
|
||||||
|
if (m) triggers.push(m[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skills.push({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
triggers,
|
||||||
|
path: entry.name,
|
||||||
|
file_path: `${entry.name}/SKILL.md`,
|
||||||
|
root: rootInfo.root,
|
||||||
|
scope: rootInfo.scope,
|
||||||
|
installed: rootInfo.scope !== 'import-only',
|
||||||
|
deprecated: !!rootInfo.deprecated,
|
||||||
|
});
|
||||||
|
skillCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
rootSummary.skill_count = skillCount;
|
||||||
|
roots.push(rootSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by name for deterministic output
|
skills.sort((a, b) => {
|
||||||
manifest.sort((a, b) => a.name.localeCompare(b.name));
|
const rootCmp = a.root.localeCompare(b.root);
|
||||||
return manifest;
|
return rootCmp !== 0 ? rootCmp : a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const gsdSkillsInstalled = skills.some(skill => skill.name.startsWith('gsd-'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
skills,
|
||||||
|
roots,
|
||||||
|
installation: {
|
||||||
|
gsd_skills_installed: gsdSkillsInstalled,
|
||||||
|
legacy_claude_commands_installed: legacyClaudeCommandsInstalled,
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
skills: skills.length,
|
||||||
|
roots: roots.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: generate skill manifest JSON.
|
* Command: generate skill manifest JSON.
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* --skills-dir <path> Path to skills directory (required)
|
* --skills-dir <path> Optional absolute path to a single skills directory
|
||||||
* --write Also write to .planning/skill-manifest.json
|
* --write Also write to .planning/skill-manifest.json
|
||||||
*/
|
*/
|
||||||
function cmdSkillManifest(cwd, args, raw) {
|
function cmdSkillManifest(cwd, args, raw) {
|
||||||
@@ -1667,12 +1799,7 @@ function cmdSkillManifest(cwd, args, raw) {
|
|||||||
? args[skillsDirIdx + 1]
|
? args[skillsDirIdx + 1]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!skillsDir) {
|
const manifest = buildSkillManifest(cwd, skillsDir);
|
||||||
output([], raw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = buildSkillManifest(skillsDir);
|
|
||||||
|
|
||||||
// Optionally write to .planning/skill-manifest.json
|
// Optionally write to .planning/skill-manifest.json
|
||||||
if (args.includes('--write')) {
|
if (args.includes('--write')) {
|
||||||
|
|||||||
@@ -177,11 +177,11 @@ const CLAUDE_MD_FALLBACKS = {
|
|||||||
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
|
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
|
||||||
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
|
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
|
||||||
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
|
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
|
||||||
skills: 'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.',
|
skills: 'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Directories where project skills may live (checked in order)
|
// Directories where project skills may live (checked in order)
|
||||||
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills'];
|
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
|
||||||
|
|
||||||
const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
|
const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
|
||||||
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
|
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
|
||||||
|
|||||||
@@ -2,29 +2,72 @@
|
|||||||
* Tests for agent skills query handler.
|
* Tests for agent skills query handler.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
import { agentSkills } from './skills.js';
|
import { agentSkills } from './skills.js';
|
||||||
|
|
||||||
|
function writeSkill(rootDir: string, name: string, description = 'Skill under test') {
|
||||||
|
const skillDir = join(rootDir, name);
|
||||||
|
return mkdir(skillDir, { recursive: true }).then(() => writeFile(join(skillDir, 'SKILL.md'), [
|
||||||
|
'---',
|
||||||
|
`name: ${name}`,
|
||||||
|
`description: ${description}`,
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
`# ${name}`,
|
||||||
|
].join('\n')));
|
||||||
|
}
|
||||||
|
|
||||||
describe('agentSkills', () => {
|
describe('agentSkills', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
let homeDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-'));
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-'));
|
||||||
await mkdir(join(tmpDir, '.cursor', 'skills', 'my-skill'), { recursive: true });
|
homeDir = await mkdtemp(join(tmpdir(), 'gsd-skills-home-'));
|
||||||
|
await writeSkill(join(tmpDir, '.cursor', 'skills'), 'my-skill');
|
||||||
|
await writeSkill(join(tmpDir, '.codex', 'skills'), 'project-codex');
|
||||||
|
await mkdir(join(tmpDir, '.claude', 'skills', 'orphaned-dir'), { recursive: true });
|
||||||
|
await writeSkill(join(homeDir, '.claude', 'skills'), 'global-claude');
|
||||||
|
await writeSkill(join(homeDir, '.codex', 'skills'), 'global-codex');
|
||||||
|
await writeSkill(join(homeDir, '.claude', 'get-shit-done', 'skills'), 'legacy-import');
|
||||||
|
vi.stubEnv('HOME', homeDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns deduped skill names from project skill dirs', async () => {
|
it('returns deduped skill names from project and managed global skill dirs', async () => {
|
||||||
const r = await agentSkills(['gsd-executor'], tmpDir);
|
const r = await agentSkills(['gsd-executor'], tmpDir);
|
||||||
const data = r.data as Record<string, unknown>;
|
const data = r.data as Record<string, unknown>;
|
||||||
expect(data.skill_count).toBeGreaterThan(0);
|
const skills = data.skills as string[];
|
||||||
expect((data.skills as string[]).length).toBeGreaterThan(0);
|
|
||||||
|
expect(skills).toEqual(expect.arrayContaining([
|
||||||
|
'my-skill',
|
||||||
|
'project-codex',
|
||||||
|
'global-claude',
|
||||||
|
'global-codex',
|
||||||
|
]));
|
||||||
|
expect(skills).not.toContain('orphaned-dir');
|
||||||
|
expect(skills).not.toContain('legacy-import');
|
||||||
|
expect(data.skill_count).toBe(skills.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts deduped skill names when the same skill exists in multiple roots', async () => {
|
||||||
|
await writeSkill(join(tmpDir, '.claude', 'skills'), 'shared-skill');
|
||||||
|
await writeSkill(join(tmpDir, '.agents', 'skills'), 'shared-skill');
|
||||||
|
|
||||||
|
const r = await agentSkills(['gsd-executor'], tmpDir);
|
||||||
|
const data = r.data as Record<string, unknown>;
|
||||||
|
const skills = data.skills as string[];
|
||||||
|
|
||||||
|
expect(skills.filter((skill) => skill === 'shared-skill')).toHaveLength(1);
|
||||||
|
expect(data.skill_count).toBe(skills.length);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Agent skills query handler — scan installed skill directories.
|
* Agent skills query handler — scan installed skill directories.
|
||||||
*
|
*
|
||||||
* Reads from .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/,
|
* Reads from project `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`,
|
||||||
* and the global ~/.claude/get-shit-done/skills/ directory.
|
* `.github/skills/`, `.codex/skills/`, plus managed global `~/.claude/skills/`
|
||||||
|
* and `~/.codex/skills/` roots.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
@@ -26,7 +27,9 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
|
|||||||
join(projectDir, '.agents', 'skills'),
|
join(projectDir, '.agents', 'skills'),
|
||||||
join(projectDir, '.cursor', 'skills'),
|
join(projectDir, '.cursor', 'skills'),
|
||||||
join(projectDir, '.github', 'skills'),
|
join(projectDir, '.github', 'skills'),
|
||||||
join(homedir(), '.claude', 'get-shit-done', 'skills'),
|
join(projectDir, '.codex', 'skills'),
|
||||||
|
join(homedir(), '.claude', 'skills'),
|
||||||
|
join(homedir(), '.codex', 'skills'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const skills: string[] = [];
|
const skills: string[] = [];
|
||||||
@@ -35,16 +38,19 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
|
|||||||
try {
|
try {
|
||||||
const entries = readdirSync(dir, { withFileTypes: true });
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) skills.push(entry.name);
|
if (!entry.isDirectory()) continue;
|
||||||
|
if (!existsSync(join(dir, entry.name, 'SKILL.md'))) continue;
|
||||||
|
skills.push(entry.name);
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dedupedSkills = [...new Set(skills)];
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
agent_type: agentType,
|
agent_type: agentType,
|
||||||
skills: [...new Set(skills)],
|
skills: dedupedSkills,
|
||||||
skill_count: skills.length,
|
skill_count: dedupedSkills.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -148,6 +148,38 @@ describe('generate-claude-md skills section', () => {
|
|||||||
assert.ok(content.includes('ERP synchronization flows'));
|
assert.ok(content.includes('ERP synchronization flows'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('discovers skills from .codex/skills/ directory and ignores deprecated import-only roots', () => {
|
||||||
|
const codexSkillDir = path.join(tmpDir, '.codex', 'skills', 'automation');
|
||||||
|
fs.mkdirSync(codexSkillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(codexSkillDir, 'SKILL.md'),
|
||||||
|
'---\nname: automation\ndescription: Project Codex skill.\n---\n\n# Automation\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-claude-skills-home-'));
|
||||||
|
fs.mkdirSync(path.join(homeDir, '.claude', 'get-shit-done', 'skills', 'import-only'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(homeDir, '.claude', 'get-shit-done', 'skills', 'import-only', 'SKILL.md'),
|
||||||
|
'---\nname: import-only\ndescription: Deprecated import-only skill.\n---\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
process.env.HOME = homeDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||||
|
assert.ok(content.includes('automation'));
|
||||||
|
assert.ok(content.includes('Project Codex skill'));
|
||||||
|
assert.ok(!content.includes('import-only'));
|
||||||
|
} finally {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
cleanup(homeDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('skips gsd- prefixed skill directories', () => {
|
test('skips gsd- prefixed skill directories', () => {
|
||||||
const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase');
|
const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase');
|
||||||
const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature');
|
const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature');
|
||||||
|
|||||||
@@ -157,6 +157,19 @@ describe('generate-claude-md command', () => {
|
|||||||
const content = fs.readFileSync(outputPath, 'utf-8');
|
const content = fs.readFileSync(outputPath, 'utf-8');
|
||||||
assert.ok(content.length > 0, 'should still have content');
|
assert.ok(content.length > 0, 'should still have content');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('skills fallback mentions the normalized project roots', () => {
|
||||||
|
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||||
|
assert.ok(result.success, `Failed: ${result.error}`);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||||
|
assert.ok(content.includes('.claude/skills/'));
|
||||||
|
assert.ok(content.includes('.agents/skills/'));
|
||||||
|
assert.ok(content.includes('.cursor/skills/'));
|
||||||
|
assert.ok(content.includes('.github/skills/'));
|
||||||
|
assert.ok(content.includes('.codex/skills/'));
|
||||||
|
assert.ok(!content.includes('get-shit-done/skills'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── generate-dev-preferences ─────────────────────────────────────────────────
|
// ─── generate-dev-preferences ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for skill-manifest command
|
* Tests for skill-manifest command
|
||||||
* TDD: RED phase — tests written before implementation
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
@@ -9,211 +8,123 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function writeSkill(rootDir, name, description, body = '') {
|
||||||
|
const skillDir = path.join(rootDir, name);
|
||||||
|
fs.mkdirSync(skillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
||||||
|
'---',
|
||||||
|
`name: ${name}`,
|
||||||
|
`description: ${description}`,
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
body || `# ${name}`,
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
describe('skill-manifest', () => {
|
describe('skill-manifest', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
let homeDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = createTempProject();
|
tmpDir = createTempProject();
|
||||||
|
homeDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-skill-manifest-home-'));
|
||||||
|
|
||||||
|
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'project-claude', 'Project Claude skill');
|
||||||
|
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'gsd-help', 'Installed GSD skill');
|
||||||
|
writeSkill(path.join(tmpDir, '.agents', 'skills'), 'project-agents', 'Project agent skill');
|
||||||
|
writeSkill(path.join(tmpDir, '.codex', 'skills'), 'project-codex', 'Project Codex skill');
|
||||||
|
|
||||||
|
writeSkill(path.join(homeDir, '.claude', 'skills'), 'global-claude', 'Global Claude skill');
|
||||||
|
writeSkill(path.join(homeDir, '.codex', 'skills'), 'global-codex', 'Global Codex skill');
|
||||||
|
writeSkill(
|
||||||
|
path.join(homeDir, '.claude', 'get-shit-done', 'skills'),
|
||||||
|
'legacy-import',
|
||||||
|
'Deprecated import-only skill'
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(homeDir, '.claude', 'commands', 'gsd'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(homeDir, '.claude', 'commands', 'gsd', 'help.md'), '# legacy');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup(tmpDir);
|
cleanup(tmpDir);
|
||||||
|
cleanup(homeDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skill-manifest command exists and returns JSON', () => {
|
test('returns normalized inventory across canonical roots', () => {
|
||||||
// Create a skills directory with one skill
|
const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir });
|
||||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'test-skill');
|
|
||||||
fs.mkdirSync(skillDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
'name: test-skill',
|
|
||||||
'description: A test skill',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Test Skill',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
const manifest = JSON.parse(result.output);
|
||||||
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
|
assert.ok(Array.isArray(manifest.skills), 'skills should be an array');
|
||||||
});
|
assert.ok(Array.isArray(manifest.roots), 'roots should be an array');
|
||||||
|
assert.ok(manifest.installation && typeof manifest.installation === 'object', 'installation summary present');
|
||||||
|
assert.ok(manifest.counts && typeof manifest.counts === 'object', 'counts summary present');
|
||||||
|
|
||||||
test('generates manifest with correct structure from SKILL.md frontmatter', () => {
|
const skillNames = manifest.skills.map((skill) => skill.name).sort();
|
||||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'my-skill');
|
assert.deepStrictEqual(skillNames, [
|
||||||
fs.mkdirSync(skillDir, { recursive: true });
|
'global-claude',
|
||||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
'global-codex',
|
||||||
'---',
|
'gsd-help',
|
||||||
'name: my-skill',
|
'legacy-import',
|
||||||
'description: Does something useful',
|
'project-agents',
|
||||||
'---',
|
'project-claude',
|
||||||
'',
|
'project-codex',
|
||||||
'# My Skill',
|
]);
|
||||||
'',
|
|
||||||
'TRIGGER when: user asks about widgets',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
|
const codexSkill = manifest.skills.find((skill) => skill.name === 'project-codex');
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
assert.deepStrictEqual(
|
||||||
|
{
|
||||||
|
root: codexSkill.root,
|
||||||
|
scope: codexSkill.scope,
|
||||||
|
installed: codexSkill.installed,
|
||||||
|
deprecated: codexSkill.deprecated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '.codex/skills',
|
||||||
|
scope: 'project',
|
||||||
|
installed: true,
|
||||||
|
deprecated: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
const importedSkill = manifest.skills.find((skill) => skill.name === 'legacy-import');
|
||||||
assert.strictEqual(manifest.length, 1);
|
assert.deepStrictEqual(
|
||||||
assert.strictEqual(manifest[0].name, 'my-skill');
|
{
|
||||||
assert.strictEqual(manifest[0].description, 'Does something useful');
|
root: importedSkill.root,
|
||||||
assert.strictEqual(manifest[0].path, 'my-skill');
|
scope: importedSkill.scope,
|
||||||
});
|
installed: importedSkill.installed,
|
||||||
|
deprecated: importedSkill.deprecated,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: '~/.claude/get-shit-done/skills',
|
||||||
|
scope: 'import-only',
|
||||||
|
installed: false,
|
||||||
|
deprecated: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
test('empty skills directory produces empty manifest', () => {
|
const gsdSkill = manifest.skills.find((skill) => skill.name === 'gsd-help');
|
||||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
assert.strictEqual(gsdSkill.installed, true);
|
||||||
fs.mkdirSync(skillsDir, { recursive: true });
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
|
const legacyRoot = manifest.roots.find((root) => root.scope === 'legacy-commands');
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
assert.ok(legacyRoot, 'legacy commands root should be reported');
|
||||||
|
assert.strictEqual(legacyRoot.present, true);
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
assert.strictEqual(manifest.installation.gsd_skills_installed, true);
|
||||||
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
|
assert.strictEqual(manifest.installation.legacy_claude_commands_installed, true);
|
||||||
assert.strictEqual(manifest.length, 0);
|
assert.strictEqual(manifest.counts.skills, 7);
|
||||||
});
|
|
||||||
|
|
||||||
test('skills without SKILL.md are skipped', () => {
|
|
||||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
|
||||||
// Skill with SKILL.md
|
|
||||||
const goodDir = path.join(skillsDir, 'good-skill');
|
|
||||||
fs.mkdirSync(goodDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(goodDir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
'name: good-skill',
|
|
||||||
'description: Has a SKILL.md',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Good Skill',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
// Skill without SKILL.md (just a directory)
|
|
||||||
const badDir = path.join(skillsDir, 'bad-skill');
|
|
||||||
fs.mkdirSync(badDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(badDir, 'README.md'), '# No SKILL.md here');
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(manifest.length, 1);
|
|
||||||
assert.strictEqual(manifest[0].name, 'good-skill');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manifest includes frontmatter fields from SKILL.md', () => {
|
|
||||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'rich-skill');
|
|
||||||
fs.mkdirSync(skillDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
'name: rich-skill',
|
|
||||||
'description: A richly documented skill',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Rich Skill',
|
|
||||||
'',
|
|
||||||
'TRIGGER when: user mentions databases',
|
|
||||||
'DO NOT TRIGGER when: user asks about frontend',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills')], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(manifest.length, 1);
|
|
||||||
|
|
||||||
const skill = manifest[0];
|
|
||||||
assert.strictEqual(skill.name, 'rich-skill');
|
|
||||||
assert.strictEqual(skill.description, 'A richly documented skill');
|
|
||||||
assert.strictEqual(skill.path, 'rich-skill');
|
|
||||||
// triggers extracted from body text
|
|
||||||
assert.ok(Array.isArray(skill.triggers), 'triggers should be an array');
|
|
||||||
assert.ok(skill.triggers.length > 0, 'triggers should have at least one entry');
|
|
||||||
assert.ok(skill.triggers.some(t => t.includes('databases')), 'triggers should mention databases');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple skills are all included in manifest', () => {
|
|
||||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
|
||||||
|
|
||||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
|
||||||
const dir = path.join(skillsDir, name);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(dir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
`name: ${name}`,
|
|
||||||
`description: The ${name} skill`,
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
`# ${name}`,
|
|
||||||
].join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(manifest.length, 3);
|
|
||||||
const names = manifest.map(s => s.name).sort();
|
|
||||||
assert.deepStrictEqual(names, ['alpha', 'beta', 'gamma']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
|
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
|
||||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'write-test');
|
const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir });
|
||||||
fs.mkdirSync(skillDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
'name: write-test',
|
|
||||||
'description: Tests write mode',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Write Test',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, '.claude', 'skills'), '--write'], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
||||||
|
|
||||||
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
|
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
|
||||||
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
|
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
|
||||||
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
assert.strictEqual(manifest.length, 1);
|
assert.ok(Array.isArray(manifest.skills));
|
||||||
assert.strictEqual(manifest[0].name, 'write-test');
|
assert.ok(manifest.installation);
|
||||||
});
|
|
||||||
|
|
||||||
test('nonexistent skills directory returns empty manifest', () => {
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', path.join(tmpDir, 'nonexistent')], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
|
||||||
assert.ok(Array.isArray(manifest), 'Manifest should be an array');
|
|
||||||
assert.strictEqual(manifest.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('files in skills directory are ignored (only subdirectories scanned)', () => {
|
|
||||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
|
||||||
fs.mkdirSync(skillsDir, { recursive: true });
|
|
||||||
// A file, not a directory
|
|
||||||
fs.writeFileSync(path.join(skillsDir, 'not-a-skill.md'), '# Not a skill');
|
|
||||||
|
|
||||||
// A valid skill directory
|
|
||||||
const skillDir = path.join(skillsDir, 'real-skill');
|
|
||||||
fs.mkdirSync(skillDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
||||||
'---',
|
|
||||||
'name: real-skill',
|
|
||||||
'description: A real skill',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Real Skill',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const result = runGsdTools(['skill-manifest', '--skills-dir', skillsDir], tmpDir);
|
|
||||||
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(manifest.length, 1);
|
|
||||||
assert.strictEqual(manifest[0].name, 'real-skill');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user