-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathclaude.ts
More file actions
282 lines (250 loc) · 10.1 KB
/
Copy pathclaude.ts
File metadata and controls
282 lines (250 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import fs from 'fs';
import path from 'path';
import os from 'os';
import { Skill } from '../skills.js';
import { TransformResult } from './types.js';
import { execCommand, commandExists } from '../utils.js';
const PLUGIN_NAME = 'syncable-cli-skills';
const PLUGIN_VERSION = '0.1.8';
const MARKETPLACE_NAME = 'syncable';
const MARKETPLACE_REPO = 'syncable-dev/syncable-cli';
/**
* Transform a skill into Claude Code plugin format.
* Each skill becomes a directory with SKILL.md inside skills/<skill-name>/
*/
export function transformForClaude(skill: Skill): TransformResult[] {
const skillName = skill.filename.replace(/\.md$/, '');
const safeDesc = skill.frontmatter.description
.replace(/"/g, '\\"')
.trim();
const content = `---\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
return [{ relativePath: `skills/${skillName}/SKILL.md`, content }];
}
/**
* Get the plugin cache directory for Claude Code.
*/
export function getClaudePluginCacheDir(): string {
return path.join(
os.homedir(),
'.claude',
'plugins',
'cache',
MARKETPLACE_NAME,
PLUGIN_NAME,
PLUGIN_VERSION
);
}
// ────────────────────────────────────────────────────────────────────────────
// Installation strategy (in priority order):
//
// 1. `claude plugin marketplace add` + `claude plugin install`
// The official documented flow. This registers the marketplace, clones the
// plugin from the GitHub repo, caches it, AND auto-enables it in settings.
// 100 % guaranteed to work if the `claude` CLI is on PATH.
//
// 2. Manual write: cache files + enabledPlugins in settings.json
// If the CLI is unavailable (user hasn't installed Claude Code yet, or
// they're on a CI machine), we write the plugin files directly to the
// cache directory AND register it in ~/.claude/settings.json so that
// next time Claude Code starts, the plugin loads automatically.
// ────────────────────────────────────────────────────────────────────────────
/**
* Try to install the plugin via the Claude Code CLI.
* Returns true if it fully succeeded.
*/
async function tryClaudeCliInstall(): Promise<boolean> {
const hasClaude = await commandExists('claude');
if (!hasClaude) return false;
try {
// Step 1: Register the marketplace (idempotent — safe to re-add)
await execCommand(`claude plugin marketplace add ${MARKETPLACE_REPO}`);
} catch {
// Marketplace may already exist — continue
}
try {
// Step 2: Install the plugin (auto-enables in user scope)
await execCommand(`claude plugin install ${PLUGIN_NAME}@${MARKETPLACE_NAME} --scope user`);
return true;
} catch {
// install can fail if plugin already exists at same version — check settings
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
if (settings.enabledPlugins?.[key] === true) {
// Already installed and enabled — that's fine
return true;
}
}
} catch {
// Couldn't verify — fall through to manual path
}
return false;
}
}
/**
* Write the plugin.json manifest inside the cache directory.
*/
function writePluginManifest(cacheDir: string): void {
const manifestDir = path.join(cacheDir, '.claude-plugin');
fs.mkdirSync(manifestDir, { recursive: true });
const manifest = {
name: PLUGIN_NAME,
description:
'Syncable CLI skills for project analysis, security scanning, vulnerability detection, dependency auditing, IaC validation, Kubernetes optimization, and cloud deployment.',
version: PLUGIN_VERSION,
author: {
name: 'Syncable',
email: 'support@syncable.dev',
},
homepage: 'https://syncable.dev',
repository: `https://github.com/${MARKETPLACE_REPO}`,
license: 'MIT',
keywords: ['syncable', 'devops', 'security', 'deployment', 'kubernetes', 'docker', 'iac'],
};
fs.writeFileSync(path.join(manifestDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
}
/**
* Enable the plugin in ~/.claude/settings.json.
*
* Per Claude Code docs, plugins are activated via the `enabledPlugins` key.
* We also register the marketplace in `extraKnownMarketplaces` so that
* Claude Code can discover future updates automatically.
*/
function enablePluginInSettings(): void {
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
let settings: Record<string, unknown> = {};
if (fs.existsSync(settingsFile)) {
try {
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
} catch {
try { fs.copyFileSync(settingsFile, settingsFile + '.bak'); } catch { /* */ }
settings = {};
}
}
// Enable the plugin
if (!settings.enabledPlugins || typeof settings.enabledPlugins !== 'object') {
settings.enabledPlugins = {};
}
const pluginKey = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
(settings.enabledPlugins as Record<string, boolean>)[pluginKey] = true;
// Register the marketplace so Claude Code can auto-update
if (!settings.extraKnownMarketplaces || typeof settings.extraKnownMarketplaces !== 'object') {
settings.extraKnownMarketplaces = {};
}
const marketplaces = settings.extraKnownMarketplaces as Record<string, unknown>;
// Always overwrite the marketplace entry to ensure it is canonical and free
// of non-standard fields (e.g. a stale "path" override added by Claude Code
// dev-mode that causes the plugin to be loaded from the local filesystem).
marketplaces[MARKETPLACE_NAME] = {
source: {
source: 'github',
repo: MARKETPLACE_REPO,
},
};
fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
/**
* Full Claude Code plugin installation.
*
* 1. Try `claude plugin marketplace add` + `claude plugin install`
* 2. Fall back to manual: write cache files + update settings.json
*/
export async function installClaudePlugin(skills: Skill[]): Promise<{ cacheDir: string; skillCount: number }> {
// Try the official CLI first — this handles enabledPlugins registration.
// We don't return early on success because the CLI may have cached an old
// version of the plugin that is missing the skills directory (e.g. from a
// previous install before skills were added, or from a stale npx cache).
// We always write skills directly to the cache so they're guaranteed to exist.
await tryClaudeCliInstall();
const cacheDir = getClaudePluginCacheDir();
// Remove stale older-version cache entries so Claude Code doesn't load an
// empty/outdated version instead of the current one.
const pluginRootDir = path.dirname(cacheDir);
if (fs.existsSync(pluginRootDir)) {
for (const entry of fs.readdirSync(pluginRootDir)) {
if (entry !== PLUGIN_VERSION) {
fs.rmSync(path.join(pluginRootDir, entry), { recursive: true, force: true });
}
}
}
// Clear old skills and rewrite them so the cache is always up to date.
const skillsDir = path.join(cacheDir, 'skills');
if (fs.existsSync(skillsDir)) {
fs.rmSync(skillsDir, { recursive: true });
}
for (const skill of skills) {
const results = transformForClaude(skill);
for (const { relativePath, content } of results) {
const fullPath = path.join(cacheDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
}
writePluginManifest(cacheDir);
enablePluginInSettings();
return { cacheDir, skillCount: skills.length };
}
/**
* Remove the Claude Code plugin.
*/
export async function uninstallClaudePlugin(): Promise<void> {
// Try CLI first
const hasClaude = await commandExists('claude');
if (hasClaude) {
try {
await execCommand(`claude plugin uninstall ${PLUGIN_NAME}@${MARKETPLACE_NAME} --scope user`);
return;
} catch { /* fall through */ }
}
// Manual cleanup
const cacheDir = getClaudePluginCacheDir();
if (fs.existsSync(cacheDir)) {
fs.rmSync(cacheDir, { recursive: true });
}
// Remove from enabledPlugins in settings.json
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
if (fs.existsSync(settingsFile)) {
try {
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
const pluginKey = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') {
delete settings.enabledPlugins[pluginKey];
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
} catch { /* */ }
}
// Clean up legacy files from previous installer versions
const legacyFiles = [
path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'),
path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'),
];
for (const legacyFile of legacyFiles) {
if (fs.existsSync(legacyFile)) {
try {
const data = JSON.parse(fs.readFileSync(legacyFile, 'utf-8'));
const key = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
if (data.plugins) delete data.plugins[key];
if (data[MARKETPLACE_NAME]) delete data[MARKETPLACE_NAME];
fs.writeFileSync(legacyFile, JSON.stringify(data, null, 2));
} catch { /* */ }
}
}
// Clean up old flat-file skills
const oldDirs = [
path.join(os.homedir(), '.claude', 'skills', 'syncable'),
];
for (const dir of oldDirs) {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
}
const flatSkillsDir = path.join(os.homedir(), '.claude', 'skills');
if (fs.existsSync(flatSkillsDir)) {
for (const file of fs.readdirSync(flatSkillsDir)) {
if (file.startsWith('syncable-') && file.endsWith('.md')) {
fs.unlinkSync(path.join(flatSkillsDir, file));
}
}
}
}