Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
960 changes: 0 additions & 960 deletions .cursor/rules/project-rules.mdc

This file was deleted.

56 changes: 0 additions & 56 deletions .cursor/rules/rust-rules.mdc

This file was deleted.

6 changes: 3 additions & 3 deletions installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ One command installs **11 skills** (7 command + 4 workflow) that give AI coding

| Agent | Install Type | Format |
|-------|-------------|--------|
| **Claude Code** | Global (`~/.claude/skills/`) | Native markdown |
| **Codex** | Global (`~/.codex/skills/`) | `SKILL.md` directories |
| **Claude Code** | Plugin (`~/.claude/plugins/cache/syncable/`) | Plugin marketplace with `SKILL.md` directories |
| **Codex** | Global (`~/.agents/skills/`) | `SKILL.md` directories |
| **Cursor** | Per-project (`.cursor/rules/`) | `.mdc` with `alwaysApply` |
| **Windsurf** | Per-project (`.windsurf/rules/`) | `.md` with `trigger: always` |
| **Gemini CLI** | Per-project (`GEMINI.md`) | Concatenated markdown block |
| **Gemini CLI** | Global (`~/.gemini/<profile>/skills/`) | `SKILL.md` directories |

## Quick Start

Expand Down
2 changes: 1 addition & 1 deletion installer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "syncable-cli-skills",
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"description": "Install Syncable CLI skills for AI coding agents (Claude Code, Cursor, Windsurf, Codex, Gemini CLI)",
"license": "GPL-3.0",
Expand Down
3 changes: 2 additions & 1 deletion installer/src/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const codexAgent: AgentConfig = {
return fs.existsSync(path.join(os.homedir(), '.codex')) || await commandExists('codex');
},
getSkillPath: () => {
return path.join(os.homedir(), '.codex', 'skills');
// Codex user-level skills path per docs: $HOME/.agents/skills
return path.join(os.homedir(), '.agents', 'skills');
},
};
37 changes: 35 additions & 2 deletions installer/src/agents/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,47 @@ import os from 'os';
import { AgentConfig } from './types.js';
import { commandExists } from '../utils.js';

/**
* Find the Gemini CLI skills directory.
* Gemini CLI stores skills under ~/.gemini/<profile>/skills/
* The default profile is 'antigravity'.
*/
function findGeminiSkillsDir(): string {
const geminiDir = path.join(os.homedir(), '.gemini');

// Check for antigravity profile (default)
const antigravitySkills = path.join(geminiDir, 'antigravity', 'skills');
if (fs.existsSync(antigravitySkills)) {
return antigravitySkills;
}

// Check for any profile with a skills directory
if (fs.existsSync(geminiDir)) {
try {
const entries = fs.readdirSync(geminiDir);
for (const entry of entries) {
const skillsPath = path.join(geminiDir, entry, 'skills');
if (fs.existsSync(skillsPath) && fs.statSync(skillsPath).isDirectory()) {
return skillsPath;
}
}
} catch {
// Ignore errors
}
}

// Default to antigravity profile
return antigravitySkills;
}

export const geminiAgent: AgentConfig = {
name: 'gemini',
displayName: 'Gemini CLI',
installType: 'project',
installType: 'global',
detect: async () => {
return fs.existsSync(path.join(os.homedir(), '.gemini')) || await commandExists('gemini');
},
getSkillPath: () => {
return path.join(process.cwd(), 'GEMINI.md');
return findGeminiSkillsDir();
},
};
29 changes: 10 additions & 19 deletions installer/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { transformForCursor } from '../transformers/cursor.js';
import { transformForWindsurf } from '../transformers/windsurf.js';
import { transformForGemini } from '../transformers/gemini.js';
import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js';
import { TransformResult } from '../transformers/types.js';

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

export function writeSkillsForGemini(skills: Skill[], filePath: string): void {
const geminiContent = transformForGemini(skills);
let existing = '';

if (fs.existsSync(filePath)) {
existing = fs.readFileSync(filePath, 'utf-8');

// Replace existing section if present
const startIdx = existing.indexOf(SKILL_MARKER_START);
const endIdx = existing.indexOf(SKILL_MARKER_END);
if (startIdx !== -1 && endIdx !== -1) {
const before = existing.slice(0, startIdx);
const after = existing.slice(endIdx + SKILL_MARKER_END.length);
fs.writeFileSync(filePath, before + geminiContent + after);
return;
export function writeSkillsForGemini(skills: Skill[], destDir: string): void {
// Gemini CLI uses skills/<skill-name>/SKILL.md format
// destDir is ~/.gemini/<profile>/skills/
for (const skill of skills) {
const results = transformForGemini(skill);
for (const { relativePath, content } of results) {
const fullPath = path.join(destDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
}

// Append to existing or create new
const separator = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '';
fs.writeFileSync(filePath, existing + separator + geminiContent + '\n');
}

export interface InstallOptions {
Expand Down
14 changes: 4 additions & 10 deletions installer/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,10 @@ export function countInstalledSkills(dirOrPath: string, agent: AgentName | strin

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

default:
Expand Down
6 changes: 5 additions & 1 deletion installer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { Command } from 'commander';
import inquirer from 'inquirer';
import ora from 'ora';
import path from 'path';
import os from 'os';
import chalk from 'chalk';
import { createRequire } from 'module';
import { checkNodeVersion, checkCargo, checkSyncCtl } from './prerequisites/check.js';
Expand Down Expand Up @@ -248,6 +250,8 @@ program
break;
case 'codex':
removeSyncableSkills(dest, 'syncable-*');
// Also clean old location (~/.codex/skills/)
removeSyncableSkills(path.join(os.homedir(), '.codex', 'skills'), 'syncable-*');
break;
case 'cursor':
removeSyncableSkills(dest, 'syncable-*.mdc');
Expand All @@ -256,7 +260,7 @@ program
removeSyncableSkills(dest, 'syncable-*.md');
break;
case 'gemini':
removeGeminiSection(dest);
removeSyncableSkills(dest, 'syncable-*');
break;
}
spinner.succeed(` Skills removed from ${agent.displayName}`);
Expand Down
18 changes: 17 additions & 1 deletion installer/src/transformers/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { Skill } from '../skills.js';
import { TransformResult } from './types.js';
import { SKILL_MARKER_START, SKILL_MARKER_END } from '../constants.js';

export function transformForGemini(skills: Skill[]): string {
/**
* Transform a skill into Gemini CLI skill format.
* Each skill becomes a directory with SKILL.md inside skills/<skill-name>/
* Format: frontmatter with name + description, then markdown body.
*/
export function transformForGemini(skill: Skill): TransformResult[] {
const skillName = skill.filename.replace(/\.md$/, '');
const content = `---\nname: ${skillName}\ndescription: ${skill.frontmatter.description}\n---\n\n${skill.body}`;
return [{ relativePath: `${skillName}/SKILL.md`, content }];
}

/**
* Legacy: generate a flat GEMINI.md section for older Gemini CLI versions.
* Used as a fallback when the skills directory approach isn't available.
*/
export function transformForGeminiLegacy(skills: Skill[]): string {
const sections = skills
.map((s) => `### ${s.frontmatter.name}\n\n${s.body}`)
.join('\n\n');
Expand Down
4 changes: 2 additions & 2 deletions installer/tests/agents/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ describe('agent configs', () => {
expect(allAgents().length).toBe(5);
});

it('claude and codex are global, others are project', async () => {
it('claude, codex, and gemini are global, others are project', async () => {
const agents = allAgents();
const globalAgents = agents.filter((a) => a.installType === 'global');
const projectAgents = agents.filter((a) => a.installType === 'project');

expect(globalAgents.map((a) => a.name)).toContain('claude');
expect(globalAgents.map((a) => a.name)).toContain('codex');
expect(globalAgents.map((a) => a.name)).toContain('gemini');
expect(projectAgents.map((a) => a.name)).toContain('cursor');
expect(projectAgents.map((a) => a.name)).toContain('windsurf');
expect(projectAgents.map((a) => a.name)).toContain('gemini');
});
});
36 changes: 10 additions & 26 deletions installer/tests/commands/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,33 +80,17 @@ describe('writeSkillsForWindsurf', () => {
});

describe('writeSkillsForGemini', () => {
it('writes content with markers to a file', () => {
const filePath = path.join(tmpDir, 'GEMINI.md');
writeSkillsForGemini(sampleSkills, filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-START -->');
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-END -->');
expect(content).toContain('### syncable-analyze');
});

it('appends to existing file without destroying content', () => {
const filePath = path.join(tmpDir, 'GEMINI.md');
fs.writeFileSync(filePath, '# My Project\n\nExisting content.\n');
writeSkillsForGemini(sampleSkills, filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('# My Project');
expect(content).toContain('Existing content.');
expect(content).toContain('<!-- SYNCABLE-CLI-SKILLS-START -->');
it('writes each skill as a directory with SKILL.md', () => {
writeSkillsForGemini(sampleSkills, tmpDir);
expect(fs.existsSync(path.join(tmpDir, 'syncable-analyze', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'syncable-project-assessment', 'SKILL.md'))).toBe(true);
});

it('replaces existing Syncable section on re-install', () => {
const filePath = path.join(tmpDir, 'GEMINI.md');
fs.writeFileSync(filePath, '# Header\n<!-- SYNCABLE-CLI-SKILLS-START -->\nold content\n<!-- SYNCABLE-CLI-SKILLS-END -->\n# Footer\n');
writeSkillsForGemini(sampleSkills, filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('# Header');
expect(content).toContain('# Footer');
expect(content).not.toContain('old content');
expect(content).toContain('### syncable-analyze');
it('includes frontmatter with name and description', () => {
writeSkillsForGemini(sampleSkills, tmpDir);
const content = fs.readFileSync(path.join(tmpDir, 'syncable-analyze', 'SKILL.md'), 'utf-8');
expect(content).toContain('name: syncable-analyze');
expect(content).toContain('description: Analyze');
expect(content).toContain('Analyze.');
});
});
48 changes: 17 additions & 31 deletions installer/tests/transformers/gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,29 @@ import { describe, it, expect } from 'vitest';
import { transformForGemini } from '../../src/transformers/gemini.js';
import { Skill } from '../../src/skills.js';

const skills: Skill[] = [
{
frontmatter: { name: 'syncable-analyze', description: 'Analyze stuff' },
body: '## Purpose\n\nAnalyze.',
category: 'command',
filename: 'syncable-analyze.md',
},
{
frontmatter: { name: 'syncable-security', description: 'Security scan' },
body: '## Purpose\n\nScan.',
category: 'command',
filename: 'syncable-security.md',
},
];
const sampleSkill: Skill = {
frontmatter: { name: 'syncable-analyze', description: 'Analyze stuff' },
body: '## Purpose\n\nAnalyze.',
category: 'command',
filename: 'syncable-analyze.md',
};

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

it('includes all skills as sections', () => {
const result = transformForGemini(skills);
expect(result).toContain('### syncable-analyze');
expect(result).toContain('### syncable-security');
it('includes frontmatter with name and description', () => {
const result = transformForGemini(sampleSkill);
expect(result[0].content).toContain('name: syncable-analyze');
expect(result[0].content).toContain('description: Analyze stuff');
});

it('includes skill body content', () => {
const result = transformForGemini(skills);
expect(result).toContain('Analyze.');
expect(result).toContain('Scan.');
});

it('has header text', () => {
const result = transformForGemini(skills);
expect(result).toContain('## Syncable CLI Skills');
expect(result).toContain('The following skills describe how to use the Syncable CLI');
const result = transformForGemini(sampleSkill);
expect(result[0].content).toContain('## Purpose');
expect(result[0].content).toContain('Analyze.');
});
});
Loading