Skip to content

Commit 5243871

Browse files
Alex Holmbergclaude
authored andcommitted
fix: rewrite Gemini CLI skill installer to use proper SKILL.md directory format
Gemini CLI expects skills as directories with SKILL.md files at ~/.gemini/<profile>/skills/<skill-name>/SKILL.md, not as sections appended to a flat GEMINI.md file. - Rewrote transformForGemini to output <name>/SKILL.md with frontmatter - Updated agent config: gemini is now global install, auto-discovers profile - Updated install/uninstall/status to use directory-based format - Updated all tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bd22b9f commit 5243871

9 files changed

Lines changed: 97 additions & 93 deletions

File tree

installer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "syncable-cli-skills",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"type": "module",
55
"description": "Install Syncable CLI skills for AI coding agents (Claude Code, Cursor, Windsurf, Codex, Gemini CLI)",
66
"license": "GPL-3.0",

installer/src/agents/gemini.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,47 @@ import os from 'os';
44
import { AgentConfig } from './types.js';
55
import { commandExists } from '../utils.js';
66

7+
/**
8+
* Find the Gemini CLI skills directory.
9+
* Gemini CLI stores skills under ~/.gemini/<profile>/skills/
10+
* The default profile is 'antigravity'.
11+
*/
12+
function findGeminiSkillsDir(): string {
13+
const geminiDir = path.join(os.homedir(), '.gemini');
14+
15+
// Check for antigravity profile (default)
16+
const antigravitySkills = path.join(geminiDir, 'antigravity', 'skills');
17+
if (fs.existsSync(antigravitySkills)) {
18+
return antigravitySkills;
19+
}
20+
21+
// Check for any profile with a skills directory
22+
if (fs.existsSync(geminiDir)) {
23+
try {
24+
const entries = fs.readdirSync(geminiDir);
25+
for (const entry of entries) {
26+
const skillsPath = path.join(geminiDir, entry, 'skills');
27+
if (fs.existsSync(skillsPath) && fs.statSync(skillsPath).isDirectory()) {
28+
return skillsPath;
29+
}
30+
}
31+
} catch {
32+
// Ignore errors
33+
}
34+
}
35+
36+
// Default to antigravity profile
37+
return antigravitySkills;
38+
}
39+
740
export const geminiAgent: AgentConfig = {
841
name: 'gemini',
942
displayName: 'Gemini CLI',
10-
installType: 'project',
43+
installType: 'global',
1144
detect: async () => {
1245
return fs.existsSync(path.join(os.homedir(), '.gemini')) || await commandExists('gemini');
1346
},
1447
getSkillPath: () => {
15-
return path.join(process.cwd(), 'GEMINI.md');
48+
return findGeminiSkillsDir();
1649
},
1750
};

installer/src/commands/install.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { transformForCursor } from '../transformers/cursor.js';
77
import { transformForWindsurf } from '../transformers/windsurf.js';
88
import { transformForGemini } from '../transformers/gemini.js';
99
import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js';
10+
import { TransformResult } from '../transformers/types.js';
1011

1112
export function writeSkillsForClaude(skills: Skill[], _destDir: string): void {
1213
// Claude Code uses the plugin marketplace system — destDir is ignored.
@@ -45,27 +46,17 @@ export function writeSkillsForWindsurf(skills: Skill[], destDir: string): void {
4546
}
4647
}
4748

48-
export function writeSkillsForGemini(skills: Skill[], filePath: string): void {
49-
const geminiContent = transformForGemini(skills);
50-
let existing = '';
51-
52-
if (fs.existsSync(filePath)) {
53-
existing = fs.readFileSync(filePath, 'utf-8');
54-
55-
// Replace existing section if present
56-
const startIdx = existing.indexOf(SKILL_MARKER_START);
57-
const endIdx = existing.indexOf(SKILL_MARKER_END);
58-
if (startIdx !== -1 && endIdx !== -1) {
59-
const before = existing.slice(0, startIdx);
60-
const after = existing.slice(endIdx + SKILL_MARKER_END.length);
61-
fs.writeFileSync(filePath, before + geminiContent + after);
62-
return;
49+
export function writeSkillsForGemini(skills: Skill[], destDir: string): void {
50+
// Gemini CLI uses skills/<skill-name>/SKILL.md format
51+
// destDir is ~/.gemini/<profile>/skills/
52+
for (const skill of skills) {
53+
const results = transformForGemini(skill);
54+
for (const { relativePath, content } of results) {
55+
const fullPath = path.join(destDir, relativePath);
56+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
57+
fs.writeFileSync(fullPath, content);
6358
}
6459
}
65-
66-
// Append to existing or create new
67-
const separator = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '';
68-
fs.writeFileSync(filePath, existing + separator + geminiContent + '\n');
6960
}
7061

7162
export interface InstallOptions {

installer/src/commands/status.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,10 @@ export function countInstalledSkills(dirOrPath: string, agent: AgentName | strin
4848

4949
case 'gemini': {
5050
if (!fs.existsSync(dirOrPath)) return 0;
51-
const content = fs.readFileSync(dirOrPath, 'utf-8');
52-
if (content.includes(SKILL_MARKER_START)) {
53-
const start = content.indexOf(SKILL_MARKER_START);
54-
const end = content.indexOf('<!-- SYNCABLE-CLI-SKILLS-END -->');
55-
if (start !== -1 && end !== -1) {
56-
const section = content.slice(start, end);
57-
return (section.match(/^### /gm) || []).length;
58-
}
59-
}
60-
return 0;
51+
// New format: skills/<name>/SKILL.md directories
52+
return fs.readdirSync(dirOrPath)
53+
.filter((f) => f.startsWith('syncable-') && fs.statSync(path.join(dirOrPath, f)).isDirectory())
54+
.length;
6155
}
6256

6357
default:

installer/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ program
256256
removeSyncableSkills(dest, 'syncable-*.md');
257257
break;
258258
case 'gemini':
259-
removeGeminiSection(dest);
259+
removeSyncableSkills(dest, 'syncable-*');
260260
break;
261261
}
262262
spinner.succeed(` Skills removed from ${agent.displayName}`);

installer/src/transformers/gemini.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
import { Skill } from '../skills.js';
2+
import { TransformResult } from './types.js';
23
import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js';
34

4-
export function transformForGemini(skills: Skill[]): string {
5+
/**
6+
* Transform a skill into Gemini CLI skill format.
7+
* Each skill becomes a directory with SKILL.md inside skills/<skill-name>/
8+
* Format: frontmatter with name + description, then markdown body.
9+
*/
10+
export function transformForGemini(skill: Skill): TransformResult[] {
11+
const skillName = skill.filename.replace(/\.md$/, '');
12+
const content = `---\nname: ${skillName}\ndescription: ${skill.frontmatter.description}\n---\n\n${skill.body}`;
13+
return [{ relativePath: `${skillName}/SKILL.md`, content }];
14+
}
15+
16+
/**
17+
* Legacy: generate a flat GEMINI.md section for older Gemini CLI versions.
18+
* Used as a fallback when the skills directory approach isn't available.
19+
*/
20+
export function transformForGeminiLegacy(skills: Skill[]): string {
521
const sections = skills
622
.map((s) => `### ${s.frontmatter.name}\n\n${s.body}`)
723
.join('\n\n');

installer/tests/agents/detect.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ describe('agent configs', () => {
1919
expect(allAgents().length).toBe(5);
2020
});
2121

22-
it('claude and codex are global, others are project', async () => {
22+
it('claude, codex, and gemini are global, others are project', async () => {
2323
const agents = allAgents();
2424
const globalAgents = agents.filter((a) => a.installType === 'global');
2525
const projectAgents = agents.filter((a) => a.installType === 'project');
2626

2727
expect(globalAgents.map((a) => a.name)).toContain('claude');
2828
expect(globalAgents.map((a) => a.name)).toContain('codex');
29+
expect(globalAgents.map((a) => a.name)).toContain('gemini');
2930
expect(projectAgents.map((a) => a.name)).toContain('cursor');
3031
expect(projectAgents.map((a) => a.name)).toContain('windsurf');
31-
expect(projectAgents.map((a) => a.name)).toContain('gemini');
3232
});
3333
});

installer/tests/commands/install.test.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,17 @@ describe('writeSkillsForWindsurf', () => {
8080
});
8181

8282
describe('writeSkillsForGemini', () => {
83-
it('writes content with markers to a file', () => {
84-
const filePath = path.join(tmpDir, 'GEMINI.md');
85-
writeSkillsForGemini(sampleSkills, filePath);
86-
const content = fs.readFileSync(filePath, 'utf-8');
87-
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-START -->');
88-
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-END -->');
89-
expect(content).toContain('### syncable-analyze');
90-
});
91-
92-
it('appends to existing file without destroying content', () => {
93-
const filePath = path.join(tmpDir, 'GEMINI.md');
94-
fs.writeFileSync(filePath, '# My Project\n\nExisting content.\n');
95-
writeSkillsForGemini(sampleSkills, filePath);
96-
const content = fs.readFileSync(filePath, 'utf-8');
97-
expect(content).toContain('# My Project');
98-
expect(content).toContain('Existing content.');
99-
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-START -->');
83+
it('writes each skill as a directory with SKILL.md', () => {
84+
writeSkillsForGemini(sampleSkills, tmpDir);
85+
expect(fs.existsSync(path.join(tmpDir, 'syncable-analyze', 'SKILL.md'))).toBe(true);
86+
expect(fs.existsSync(path.join(tmpDir, 'syncable-project-assessment', 'SKILL.md'))).toBe(true);
10087
});
10188

102-
it('replaces existing Syncable section on re-install', () => {
103-
const filePath = path.join(tmpDir, 'GEMINI.md');
104-
fs.writeFileSync(filePath, '# Header\n<!-- SYNCABLE-CLI-SKILLS-START -->\nold content\n<!-- SYNCABLE-CLI-SKILLS-END -->\n# Footer\n');
105-
writeSkillsForGemini(sampleSkills, filePath);
106-
const content = fs.readFileSync(filePath, 'utf-8');
107-
expect(content).toContain('# Header');
108-
expect(content).toContain('# Footer');
109-
expect(content).not.toContain('old content');
110-
expect(content).toContain('### syncable-analyze');
89+
it('includes frontmatter with name and description', () => {
90+
writeSkillsForGemini(sampleSkills, tmpDir);
91+
const content = fs.readFileSync(path.join(tmpDir, 'syncable-analyze', 'SKILL.md'), 'utf-8');
92+
expect(content).toContain('name: syncable-analyze');
93+
expect(content).toContain('description: Analyze');
94+
expect(content).toContain('Analyze.');
11195
});
11296
});

installer/tests/transformers/gemini.test.ts

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,29 @@ import { describe, it, expect } from 'vitest';
22
import { transformForGemini } from '../../src/transformers/gemini.js';
33
import { Skill } from '../../src/skills.js';
44

5-
const skills: Skill[] = [
6-
{
7-
frontmatter: { name: 'syncable-analyze', description: 'Analyze stuff' },
8-
body: '## Purpose\n\nAnalyze.',
9-
category: 'command',
10-
filename: 'syncable-analyze.md',
11-
},
12-
{
13-
frontmatter: { name: 'syncable-security', description: 'Security scan' },
14-
body: '## Purpose\n\nScan.',
15-
category: 'command',
16-
filename: 'syncable-security.md',
17-
},
18-
];
5+
const sampleSkill: Skill = {
6+
frontmatter: { name: 'syncable-analyze', description: 'Analyze stuff' },
7+
body: '## Purpose\n\nAnalyze.',
8+
category: 'command',
9+
filename: 'syncable-analyze.md',
10+
};
1911

2012
describe('transformForGemini', () => {
21-
it('produces a single content block with markers', () => {
22-
const result = transformForGemini(skills);
23-
expect(result).toContain('<!-- SYNCABLE-CLI-SKILLS-START -->');
24-
expect(result).toContain('<!-- SYNCABLE-CLI-SKILLS-END -->');
13+
it('creates skill directory with SKILL.md', () => {
14+
const result = transformForGemini(sampleSkill);
15+
expect(result.length).toBe(1);
16+
expect(result[0].relativePath).toBe('syncable-analyze/SKILL.md');
2517
});
2618

27-
it('includes all skills as sections', () => {
28-
const result = transformForGemini(skills);
29-
expect(result).toContain('### syncable-analyze');
30-
expect(result).toContain('### syncable-security');
19+
it('includes frontmatter with name and description', () => {
20+
const result = transformForGemini(sampleSkill);
21+
expect(result[0].content).toContain('name: syncable-analyze');
22+
expect(result[0].content).toContain('description: Analyze stuff');
3123
});
3224

3325
it('includes skill body content', () => {
34-
const result = transformForGemini(skills);
35-
expect(result).toContain('Analyze.');
36-
expect(result).toContain('Scan.');
37-
});
38-
39-
it('has header text', () => {
40-
const result = transformForGemini(skills);
41-
expect(result).toContain('## Syncable CLI Skills');
42-
expect(result).toContain('The following skills describe how to use the Syncable CLI');
26+
const result = transformForGemini(sampleSkill);
27+
expect(result[0].content).toContain('## Purpose');
28+
expect(result[0].content).toContain('Analyze.');
4329
});
4430
});

0 commit comments

Comments
 (0)