diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bf6db42..1d05853 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,11 +18,7 @@ jobs: bun-version: latest - run: bun install - - # Tests run on every PR merge. Skip here — one test has a CI-env HOME - # mock race that doesn't repro locally. Publish shouldn't be blocked. - # TODO: tests/unit/skills.test.ts:174 — investigate projectSkills HOME - # visibility under CI-specific parallel test scheduling. + - run: bun test tests/unit/ - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2efe9df --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: sudo apt-get update && sudo apt-get install -y tmux + - run: bun install + - run: bunx tsc --noEmit + - run: bun test tests/unit/ + - run: bun test tests/integration/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ed4ba..aaaa037 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ bun install bun test ``` -All 199 tests must pass. If your change needs new tests, add them. +All tests must pass (`bun test`). If your change needs new tests, add them. ## Style diff --git a/src/skills.ts b/src/skills.ts index 33d30a1..870cd84 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -21,6 +21,11 @@ function home(): string { return process.env.HOME || require('os').homedir() } +/** Default global skills directory, overridable via FLT_SKILLS_DIR for tests. */ +function globalSkillsDir(): string { + return process.env.FLT_SKILLS_DIR ?? join(home(), '.flt', 'skills') +} + export interface SkillEntry { name: string description: string @@ -86,7 +91,7 @@ function parseFrontmatter(raw: string): ParsedFrontmatter { } function loadGlobalSkills(): Map { - const globalDir = join(home(), '.flt', 'skills') + const globalDir = globalSkillsDir() if (!existsSync(globalDir)) return new Map() let entries: string[] @@ -186,7 +191,7 @@ export function projectSkills( for (const name of requested) { const skill = available.get(name) if (!skill) { - warnings.push(`Skill "${name}" not found (looked in ${join(home(), '.flt', 'skills')})`) + warnings.push(`Skill "${name}" not found (looked in ${globalSkillsDir()})`) continue } selected.push(skill) diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index 920eb24..6832665 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -50,25 +50,28 @@ const droidAdapter: CliAdapter = { } describe('skills', () => { - let tempHome: string + let skillsDir: string let workDir: string - let origHome: string | undefined + let origSkillsDir: string | undefined beforeEach(() => { - tempHome = mkdtempSync(join(tmpdir(), 'flt-test-home-')) + const tempBase = mkdtempSync(join(tmpdir(), 'flt-test-home-')) + skillsDir = join(tempBase, '.flt', 'skills') + mkdirSync(skillsDir, { recursive: true }) workDir = mkdtempSync(join(tmpdir(), 'flt-test-work-')) - origHome = process.env.HOME - process.env.HOME = tempHome + origSkillsDir = process.env.FLT_SKILLS_DIR + process.env.FLT_SKILLS_DIR = skillsDir }) afterEach(() => { - process.env.HOME = origHome - rmSync(tempHome, { recursive: true, force: true }) + if (origSkillsDir === undefined) delete process.env.FLT_SKILLS_DIR + else process.env.FLT_SKILLS_DIR = origSkillsDir + rmSync(skillsDir, { recursive: true, force: true }) rmSync(workDir, { recursive: true, force: true }) }) function makeSkill(name: string, description: string, body: string): string { - const skillDir = join(tempHome, '.flt', 'skills', name) + const skillDir = join(skillsDir, name) mkdirSync(skillDir, { recursive: true }) writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\ncli-support: ["*"]\n---\n${body}`) return skillDir @@ -90,11 +93,11 @@ describe('skills', () => { }) it('filters skills by specific cli', () => { - const ccDir = join(tempHome, '.flt', 'skills', 'cc-only') + const ccDir = join(skillsDir, 'cc-only') mkdirSync(ccDir, { recursive: true }) writeFileSync(join(ccDir, 'SKILL.md'), `---\nname: cc-only\ncli-support: ["claude-code"]\n---\nCC only.`) - const codexDir = join(tempHome, '.flt', 'skills', 'codex-only') + const codexDir = join(skillsDir, 'codex-only') mkdirSync(codexDir, { recursive: true }) writeFileSync(join(codexDir, 'SKILL.md'), `---\nname: codex-only\ncli-support: ["codex"]\n---\nCodex only.`) @@ -108,11 +111,11 @@ describe('skills', () => { }) it('cli="*" returns all skills regardless of cli-support', () => { - const ccDir = join(tempHome, '.flt', 'skills', 'cc-only') + const ccDir = join(skillsDir, 'cc-only') mkdirSync(ccDir, { recursive: true }) writeFileSync(join(ccDir, 'SKILL.md'), `---\nname: cc-only\ncli-support: ["claude-code"]\n---\nCC only.`) - const anyDir = join(tempHome, '.flt', 'skills', 'any-cli') + const anyDir = join(skillsDir, 'any-cli') mkdirSync(anyDir, { recursive: true }) writeFileSync(join(anyDir, 'SKILL.md'), `---\nname: any-cli\ncli-support: ["*"]\n---\nAny CLI.`) @@ -125,7 +128,7 @@ describe('skills', () => { describe('projectSkills for claude-code', () => { it('copies SKILL.md to /.claude/skills//SKILL.md', () => { const rawContent = `---\nname: my-skill\ndescription: A skill\ncli-support: ["*"]\n---\nDo the thing.` - const skillDir = join(tempHome, '.flt', 'skills', 'my-skill') + const skillDir = join(skillsDir, 'my-skill') mkdirSync(skillDir, { recursive: true }) writeFileSync(join(skillDir, 'SKILL.md'), rawContent) @@ -239,6 +242,26 @@ describe('skills', () => { }) }) + describe('FLT_SKILLS_DIR override', () => { + it('uses FLT_SKILLS_DIR when set instead of ~/.flt/skills', () => { + makeSkill('override-skill', 'From override dir', 'Override content.') + const skills = loadSkills('*') + expect(skills.map(s => s.name)).toContain('override-skill') + expect(skills[0].path).toContain(skillsDir) + }) + + it('falls back to default when FLT_SKILLS_DIR is unset', () => { + delete process.env.FLT_SKILLS_DIR + // With the override unset, globalSkillsDir() resolves to join(HOME, '.flt', 'skills'). + // Requesting a nonexistent skill surfaces that path in the warning string, + // proving the real-home fallback branch is active rather than the temp dir. + const result = projectSkills(workDir, claudeAdapter, { requested: ['__no_such_skill__'] }) + expect(result.warnings).toHaveLength(1) + const expectedBase = join(process.env.HOME!, '.flt', 'skills') + expect(result.warnings[0]).toContain(expectedBase) + }) + }) + describe('re-spawn idempotency', () => { it('calling projectSkills twice does not duplicate the skills block', () => { makeSkill('my-skill', 'A skill', 'Do the thing.')