-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathclaude.ts
More file actions
337 lines (297 loc) · 12.5 KB
/
Copy pathclaude.ts
File metadata and controls
337 lines (297 loc) · 12.5 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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
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 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 }];
}
/**
* Root directory for all cached versions of this plugin.
*/
function getPluginCacheRoot(): string {
return path.join(os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME, PLUGIN_NAME);
}
/**
* Find the cache directory that Claude Code's CLI created (from `claude plugin install`).
* This version is guaranteed to match the GitHub marketplace, so we piggyback on it
* to avoid creating a version that Claude Code would later orphan.
*/
function findCliInstalledCacheDir(): string | null {
const root = getPluginCacheRoot();
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root)) {
const dir = path.join(root, entry);
if (!fs.statSync(dir).isDirectory()) continue;
// Return any version dir (there should be at most one after CLI install)
return dir;
}
return null;
}
/**
* Fallback cache directory when the CLI install didn't create one.
* Uses "0.0.0" as a sentinel — the real version comes from the CLI.
*/
export function getClaudePluginCacheDir(): string {
return path.join(getPluginCacheRoot(), '0.0.0');
}
// ────────────────────────────────────────────────────────────────────────────
// 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 });
// Derive version from the cache directory name (e.g. ".../0.1.0" → "0.1.0")
const version = path.basename(cacheDir);
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,
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 }> {
// Step 1: Run the official CLI — this fetches marketplace.json from GitHub,
// determines the correct version, caches the plugin, and registers it in
// settings.json. The version it creates MATCHES what GitHub advertises,
// so Claude Code will never orphan it.
await tryClaudeCliInstall();
// Step 2: Find the directory the CLI created. We write our skills INTO it
// rather than creating a new version directory, because any version we
// invent might not match the GitHub marketplace and get orphaned.
let cacheDir = findCliInstalledCacheDir();
if (cacheDir) {
// Remove .orphaned_at if present (stale from a previous bad install)
const orphanedFile = path.join(cacheDir, '.orphaned_at');
if (fs.existsSync(orphanedFile)) fs.unlinkSync(orphanedFile);
} else {
// CLI install failed or claude isn't installed — use fallback path
cacheDir = getClaudePluginCacheDir();
}
// Step 3: Remove ALL other version directories. Previous installs may have
// created directories at different versions that are now stale/orphaned.
const pluginRoot = getPluginCacheRoot();
if (fs.existsSync(pluginRoot)) {
const activeDirName = path.basename(cacheDir);
for (const entry of fs.readdirSync(pluginRoot)) {
if (entry !== activeDirName && entry !== '.DS_Store') {
fs.rmSync(path.join(pluginRoot, entry), { recursive: true, force: true });
}
}
}
// Step 4: Clear and rewrite skills so the cache is always fresh.
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();
// Step 4: Also write skills to ~/.claude/skills/ for SDK-based integrations
// (Zed ACP, etc.) that don't read the plugin cache.
writeUserLevelSkills(skills);
return { cacheDir, skillCount: skills.length };
}
/**
* Write skills to ~/.claude/skills/ for SDK-based integrations.
* transformForClaude returns paths like "skills/name/SKILL.md", but
* user-level skills live at ~/.claude/skills/name/SKILL.md, so we
* strip the leading "skills/" prefix.
*/
function writeUserLevelSkills(skills: Skill[]): void {
const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
for (const skill of skills) {
const skillName = skill.filename.replace(/\.md$/, '');
const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
const content = `---\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
const outDir = path.join(userSkillsDir, skillName);
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
}
}
/**
* 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 user-level skills (both old flat files and new directory format)
const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
if (fs.existsSync(userSkillsDir)) {
for (const entry of fs.readdirSync(userSkillsDir)) {
if (entry.startsWith('syncable-')) {
const entryPath = path.join(userSkillsDir, entry);
const stat = fs.statSync(entryPath);
if (stat.isDirectory()) {
fs.rmSync(entryPath, { recursive: true });
} else if (entry.endsWith('.md')) {
fs.unlinkSync(entryPath);
}
}
}
}
}