Skip to content

Commit a2937e5

Browse files
Alex HolmbergAlex Holmberg
authored andcommitted
feat: claude skills feature
1 parent f6909d8 commit a2937e5

35 files changed

Lines changed: 1939 additions & 301 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "syncable",
3+
"owner": {
4+
"name": "Syncable",
5+
"email": "support@syncable.dev"
6+
},
7+
"metadata": {
8+
"description": "Syncable CLI skills for AI coding agents — project analysis, security, vulnerabilities, dependencies, IaC validation, and cloud deployment.",
9+
"version": "0.1.0"
10+
},
11+
"plugins": [
12+
{
13+
"name": "syncable-cli-skills",
14+
"source": "./plugins/syncable-cli-skills",
15+
"description": "Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, and cloud deployment.",
16+
"version": "0.1.0"
17+
}
18+
]
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "syncable-cli-skills",
3+
"description": "Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, Kubernetes optimization, and cloud deployment.",
4+
"version": "0.1.0",
5+
"author": {
6+
"name": "Syncable",
7+
"email": "support@syncable.dev"
8+
},
9+
"homepage": "https://syncable.dev",
10+
"license": "MIT",
11+
"keywords": ["syncable", "devops", "security", "deployment", "kubernetes", "docker", "iac"]
12+
}

installer/src/commands/install.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import fs from 'fs';
22
import path from 'path';
33
import { Skill, loadSkills, getBundledSkillsDir } from '../skills.js';
4-
import { transformForClaude } from '../transformers/claude.js';
4+
import { installClaudePlugin } from '../transformers/claude.js';
55
import { transformForCodex } from '../transformers/codex.js';
66
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';
1010

11-
export function writeSkillsForClaude(skills: Skill[], destDir: string): void {
12-
for (const skill of skills) {
13-
const results = transformForClaude(skill);
14-
for (const { relativePath, content } of results) {
15-
const fullPath = path.join(destDir, relativePath);
16-
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
17-
fs.writeFileSync(fullPath, content);
18-
}
19-
}
11+
export function writeSkillsForClaude(skills: Skill[], _destDir: string): void {
12+
// Claude Code uses the plugin marketplace system — destDir is ignored.
13+
// Skills are installed as a plugin at ~/.claude/plugins/cache/syncable/...
14+
installClaudePlugin(skills);
2015
}
2116

2217
export function writeSkillsForCodex(skills: Skill[], destDir: string): void {

installer/src/commands/status.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@ import fs from 'fs';
22
import path from 'path';
33
import { AgentName } from '../agents/types.js';
44
import { SKILL_MARKER_START } from '../constants.js';
5+
import { getClaudePluginCacheDir } from '../transformers/claude.js';
56

67
export function countInstalledSkills(dirOrPath: string, agent: AgentName | string): number {
78
if (!fs.existsSync(dirOrPath)) return 0;
89

910
switch (agent) {
1011
case 'claude': {
12+
// Check plugin cache location
13+
const cacheDir = getClaudePluginCacheDir();
14+
const skillsDir = path.join(cacheDir, 'skills');
15+
if (fs.existsSync(skillsDir)) {
16+
return fs.readdirSync(skillsDir)
17+
.filter((f) => fs.statSync(path.join(skillsDir, f)).isDirectory())
18+
.length;
19+
}
20+
// Fallback: check old location
1121
let count = 0;
1222
for (const sub of ['commands', 'workflows']) {
1323
const dir = path.join(dirOrPath, sub);

installer/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
InstallOptions,
2121
} from './commands/install.js';
2222
import { removeSyncableSkills, removeGeminiSection } from './commands/uninstall.js';
23+
import { uninstallClaudePlugin } from './transformers/claude.js';
2324
import { countInstalledSkills } from './commands/status.js';
2425

2526
const require = createRequire(import.meta.url);
@@ -243,7 +244,7 @@ program
243244
const dest = agent.getSkillPath();
244245
switch (agent.name) {
245246
case 'claude':
246-
removeSyncableSkills(dest);
247+
uninstallClaudePlugin();
247248
break;
248249
case 'codex':
249250
removeSyncableSkills(dest, 'syncable-*');
Lines changed: 208 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,213 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import os from 'os';
14
import { Skill } from '../skills.js';
25
import { TransformResult } from './types.js';
36

7+
const PLUGIN_NAME = 'syncable-cli-skills';
8+
const PLUGIN_VERSION = '0.1.0';
9+
const MARKETPLACE_NAME = 'syncable';
10+
11+
/**
12+
* Transform a skill into Claude Code plugin format.
13+
* Each skill becomes a directory with SKILL.md inside skills/<skill-name>/
14+
*/
415
export function transformForClaude(skill: Skill): TransformResult[] {
5-
const dir = skill.category === 'command' ? 'commands' : 'workflows';
6-
const content = `---\nname: ${skill.frontmatter.name}\ndescription: ${skill.frontmatter.description}\n---\n\n${skill.body}`;
7-
return [{ relativePath: `${dir}/${skill.filename}`, content }];
16+
// Skill name from filename (strip .md extension)
17+
const skillName = skill.filename.replace(/\.md$/, '');
18+
19+
// Build YAML-safe description (double-quoted, no inner unescaped quotes)
20+
const safeDesc = skill.frontmatter.description
21+
.replace(/"/g, '\\"')
22+
.replace(/: /g, ' - ') // Remove colons that break YAML
23+
.replace(/Trigger on:.*$/, '') // Strip trigger phrases
24+
.trim();
25+
26+
// Only description in frontmatter — directory name is the skill name
27+
const content = `---\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
28+
29+
return [{ relativePath: `skills/${skillName}/SKILL.md`, content }];
30+
}
31+
32+
/**
33+
* Get the plugin cache directory for Claude Code.
34+
*/
35+
export function getClaudePluginCacheDir(): string {
36+
return path.join(
37+
os.homedir(),
38+
'.claude',
39+
'plugins',
40+
'cache',
41+
MARKETPLACE_NAME,
42+
PLUGIN_NAME,
43+
PLUGIN_VERSION
44+
);
45+
}
46+
47+
/**
48+
* Write the plugin.json manifest.
49+
*/
50+
function writePluginManifest(cacheDir: string): void {
51+
const manifestDir = path.join(cacheDir, '.claude-plugin');
52+
fs.mkdirSync(manifestDir, { recursive: true });
53+
54+
const manifest = {
55+
name: PLUGIN_NAME,
56+
description:
57+
'Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, Kubernetes optimization, and cloud deployment.',
58+
version: PLUGIN_VERSION,
59+
author: {
60+
name: 'Syncable',
61+
email: 'support@syncable.dev',
62+
},
63+
homepage: 'https://syncable.dev',
64+
license: 'MIT',
65+
keywords: ['syncable', 'devops', 'security', 'deployment', 'kubernetes', 'docker', 'iac'],
66+
};
67+
68+
fs.writeFileSync(path.join(manifestDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
69+
}
70+
71+
/**
72+
* Register the plugin in installed_plugins.json.
73+
*/
74+
function registerPlugin(cacheDir: string): void {
75+
const pluginsFile = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
76+
77+
let data: { version: number; plugins: Record<string, unknown[]> } = { version: 2, plugins: {} };
78+
79+
if (fs.existsSync(pluginsFile)) {
80+
try {
81+
data = JSON.parse(fs.readFileSync(pluginsFile, 'utf-8'));
82+
} catch {
83+
// Corrupted file — start fresh
84+
data = { version: 2, plugins: {} };
85+
}
86+
}
87+
88+
const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
89+
const now = new Date().toISOString();
90+
91+
data.plugins[key] = [
92+
{
93+
scope: 'user',
94+
installPath: cacheDir,
95+
version: PLUGIN_VERSION,
96+
installedAt: now,
97+
lastUpdated: now,
98+
},
99+
];
100+
101+
fs.mkdirSync(path.dirname(pluginsFile), { recursive: true });
102+
fs.writeFileSync(pluginsFile, JSON.stringify(data, null, 2));
103+
}
104+
105+
/**
106+
* Register the marketplace in known_marketplaces.json so Claude Code knows about it.
107+
*/
108+
function registerMarketplace(): void {
109+
const marketFile = path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json');
110+
111+
let data: Record<string, unknown> = {};
112+
113+
if (fs.existsSync(marketFile)) {
114+
try {
115+
data = JSON.parse(fs.readFileSync(marketFile, 'utf-8'));
116+
} catch {
117+
data = {};
118+
}
119+
}
120+
121+
// Only add if not already present
122+
if (!data[MARKETPLACE_NAME]) {
123+
data[MARKETPLACE_NAME] = {
124+
source: {
125+
source: 'github',
126+
repo: 'syncable-dev/syncable-cli',
127+
},
128+
installLocation: path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_NAME),
129+
lastUpdated: new Date().toISOString(),
130+
};
131+
132+
fs.writeFileSync(marketFile, JSON.stringify(data, null, 2));
133+
}
134+
}
135+
136+
/**
137+
* Full Claude Code plugin installation:
138+
* 1. Write SKILL.md files into plugin cache
139+
* 2. Write plugin.json manifest
140+
* 3. Register in installed_plugins.json
141+
* 4. Register marketplace in known_marketplaces.json
142+
*/
143+
export function installClaudePlugin(skills: Skill[]): { cacheDir: string; skillCount: number } {
144+
const cacheDir = getClaudePluginCacheDir();
145+
146+
// Clear old skills
147+
const skillsDir = path.join(cacheDir, 'skills');
148+
if (fs.existsSync(skillsDir)) {
149+
fs.rmSync(skillsDir, { recursive: true });
150+
}
151+
152+
// Write each skill as skills/<name>/SKILL.md
153+
for (const skill of skills) {
154+
const results = transformForClaude(skill);
155+
for (const { relativePath, content } of results) {
156+
const fullPath = path.join(cacheDir, relativePath);
157+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
158+
fs.writeFileSync(fullPath, content);
159+
}
160+
}
161+
162+
// Write plugin manifest
163+
writePluginManifest(cacheDir);
164+
165+
// Register plugin
166+
registerPlugin(cacheDir);
167+
168+
// Register marketplace
169+
registerMarketplace();
170+
171+
return { cacheDir, skillCount: skills.length };
172+
}
173+
174+
/**
175+
* Remove the Claude Code plugin.
176+
*/
177+
export function uninstallClaudePlugin(): void {
178+
const cacheDir = getClaudePluginCacheDir();
179+
180+
// Remove cache directory
181+
if (fs.existsSync(cacheDir)) {
182+
fs.rmSync(cacheDir, { recursive: true });
183+
}
184+
185+
// Remove from installed_plugins.json
186+
const pluginsFile = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
187+
if (fs.existsSync(pluginsFile)) {
188+
try {
189+
const data = JSON.parse(fs.readFileSync(pluginsFile, 'utf-8'));
190+
const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
191+
delete data.plugins[key];
192+
fs.writeFileSync(pluginsFile, JSON.stringify(data, null, 2));
193+
} catch {
194+
// Ignore errors
195+
}
196+
}
197+
198+
// Also clean up old-style flat skills if they exist
199+
const oldSkillsDir = path.join(os.homedir(), '.claude', 'skills', 'syncable');
200+
if (fs.existsSync(oldSkillsDir)) {
201+
fs.rmSync(oldSkillsDir, { recursive: true });
202+
}
203+
204+
// Clean up flat files from failed earlier installs
205+
const flatSkillsDir = path.join(os.homedir(), '.claude', 'skills');
206+
if (fs.existsSync(flatSkillsDir)) {
207+
for (const file of fs.readdirSync(flatSkillsDir)) {
208+
if (file.startsWith('syncable-') && file.endsWith('.md')) {
209+
fs.unlinkSync(path.join(flatSkillsDir, file));
210+
}
211+
}
212+
}
8213
}

skills/commands/syncable-dependencies.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,25 @@ sync-ctl dependencies <PATH> --licenses --prod-only --agent
4949

5050
## Reading Results
5151

52-
When you use `--agent`, the output is a compressed summary. License distribution and dependency counts are always included. Individual package details are available via retrieve for large dependency trees.
52+
When you use `--agent`, the output is a **compressed summary** with counts, license distribution, and source breakdown. Individual package details are NOT in the compressed output — use `sync-ctl retrieve` to get them.
5353

54-
The output JSON includes:
55-
- `summary` — total counts, license distribution, prod/dev split
56-
- `license_concerns` — packages with copyleft or unknown licenses
57-
- `full_data_ref` — reference ID for retrieving full data
58-
- `retrieval_hint` — exact command for drill-down
54+
**What's in the compressed output:**
55+
- `total` — total dependency count
56+
- `production` / `development` — prod vs dev split
57+
- `by_source` — counts per ecosystem (npm, crates.io, pypi, etc.)
58+
- `by_license` — license distribution
59+
- `full_data_ref` — reference ID for the full data
5960

60-
To drill into specifics:
61+
**To get individual package details, use retrieve:**
6162
```bash
62-
# Get high-severity license findings
63-
sync-ctl retrieve <ref_id> --query "severity:high"
63+
# Get the full dependency list
64+
sync-ctl retrieve <ref_id>
6465

65-
# Get findings for a specific file
66+
# Search for a specific package
6667
sync-ctl retrieve <ref_id> --query "file:package.json"
6768
```
6869

69-
**Available query filters:** `severity:<level>`, `file:<path>`
70+
Results are paginated (default 20). Use `--limit N --offset M` for more.
7071

7172
## Error Handling
7273

0 commit comments

Comments
 (0)