@trendai-crem/claude-skills 0.10.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +68 -325
- package/lib/config.js +49 -0
- package/lib/handlers/index.js +9 -0
- package/lib/handlers/marketplace.js +160 -0
- package/lib/handlers/skills-dir.js +55 -0
- package/lib/handlers/skills-repo.js +84 -0
- package/lib/manifest.js +118 -0
- package/lib/utils.js +60 -0
- package/package.json +3 -1
- package/sources.json +39 -0
package/cli.js
CHANGED
|
@@ -1,359 +1,110 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'child_process';
|
|
4
|
-
import { readFileSync,
|
|
4
|
+
import { readFileSync, writeFileSync, copyFileSync, mkdirSync, existsSync } from 'fs';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import { dirname, join } from 'path';
|
|
7
7
|
import { homedir, tmpdir } from 'os';
|
|
8
8
|
import { renameSync } from 'fs';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
];
|
|
10
|
+
import { HANDLERS } from './lib/handlers/index.js';
|
|
11
|
+
import { loadSources } from './lib/config.js';
|
|
12
|
+
import { loadManifest, writeManifest } from './lib/manifest.js';
|
|
13
|
+
import { printSummary } from './lib/utils.js';
|
|
15
14
|
|
|
15
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const MANIFEST_PATH = join(homedir(), '.claude', 'claude-skills-manifest.json');
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
execFileSync('npx', args, { stdio: 'inherit' });
|
|
21
|
-
return true;
|
|
22
|
-
} catch (error) {
|
|
23
|
-
console.error(`\nFailed: ${label} (exit ${error.status})`);
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
console.log('Installing team skills...\n');
|
|
29
|
-
|
|
30
|
-
// 2. Team skills — required, overrides same-named externals
|
|
31
|
-
const teamSkills = readdirSync(join(__dir, 'skills'), { withFileTypes: true })
|
|
32
|
-
.filter(e => e.isDirectory())
|
|
33
|
-
.map(e => e.name)
|
|
34
|
-
.sort();
|
|
35
|
-
|
|
36
|
-
// Compute desired skills for reconcile (query external sources before installing)
|
|
37
|
-
const externalSkillNames = EXTERNAL_SOURCES.flatMap(({ repo, flags }) =>
|
|
38
|
-
listExternalSkillNames(repo, flags) ?? []
|
|
39
|
-
);
|
|
40
|
-
const desiredSkills = new Set([...teamSkills, ...externalSkillNames]);
|
|
41
|
-
|
|
42
|
-
// Reconcile stale skills via manifest (before installing, so removals show first)
|
|
43
|
-
const skillManifest = readJsonObject(MANIFEST_PATH, {}, 'claude-skills-manifest.json') ?? {};
|
|
44
|
-
const previousSkills = new Set(Array.isArray(skillManifest.skills) ? skillManifest.skills : []);
|
|
45
|
-
const staleSkillResults = reconcileSkills(desiredSkills, previousSkills);
|
|
46
|
-
|
|
47
|
-
// 1. External skills — failures are non-fatal
|
|
48
|
-
const externalResults = EXTERNAL_SOURCES.map(({ repo, flags, label }) =>
|
|
49
|
-
({ label, ok: run(['skills', 'add', repo, ...flags, '-g', '-y'], label) })
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const teamOk = run(['skills', 'add', __dir, '--all', '-g', '-y'], 'team skills');
|
|
53
|
-
|
|
54
|
-
// Write skills portion of manifest (merge to preserve plugins field written later)
|
|
55
|
-
const tmpSkillManifest = join(tmpdir(), `claude-skills-manifest-${process.pid}-skills.json`);
|
|
56
|
-
const mergedSkillManifest = { ...skillManifest, skills: [...desiredSkills] };
|
|
57
|
-
writeFileSync(tmpSkillManifest, JSON.stringify(mergedSkillManifest, null, 2) + '\n');
|
|
58
|
-
renameSync(tmpSkillManifest, MANIFEST_PATH);
|
|
59
|
-
|
|
60
|
-
// 3. Marketplace plugins — failures are non-fatal
|
|
61
|
-
let marketplaceResults = [];
|
|
62
|
-
try {
|
|
63
|
-
marketplaceResults = installMarketplacePlugins();
|
|
64
|
-
} catch (err) {
|
|
65
|
-
console.error(`\nmarketplace plugin installation failed unexpectedly: ${err.message}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Summary
|
|
69
|
-
console.log('\nResults:');
|
|
70
|
-
staleSkillResults.forEach(({ skill, action, ok }) => console.log(` ${ok ? '✓' : '✗'} ${skill} (${action})`));
|
|
71
|
-
externalResults.forEach(({ label, ok }) => console.log(` ${ok ? '✓' : '✗'} ${label}`));
|
|
72
|
-
if (teamOk) {
|
|
73
|
-
teamSkills.forEach(name => console.log(` ✓ ${name}`));
|
|
74
|
-
} else {
|
|
75
|
-
console.log(` ✗ team skills`);
|
|
76
|
-
}
|
|
77
|
-
marketplaceResults.forEach(({ plugin, action, ok }) =>
|
|
78
|
-
console.log(` ${ok ? '✓' : '✗'} ${plugin} (${action})`)
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
if (!teamOk) {
|
|
82
|
-
console.error('\nFATAL: Team skills installation failed.');
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const externalFailed = externalResults.filter(r => !r.ok);
|
|
87
|
-
if (externalFailed.length > 0) {
|
|
88
|
-
console.warn(`\nWARN: ${externalFailed.length} external source(s) failed — team skills installed successfully.`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const marketplaceFailed = marketplaceResults.filter(r => !r.ok);
|
|
92
|
-
if (marketplaceFailed.length > 0) {
|
|
93
|
-
console.warn(`\nWARN: ${marketplaceFailed.length} marketplace plugin(s) failed — team skills installed successfully.`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Configure update-check hooks
|
|
97
|
-
setupAutoUpdate();
|
|
18
|
+
// ── Orchestrator ──────────────────────────────────────────────────────────────
|
|
98
19
|
|
|
99
|
-
|
|
100
|
-
// Returns `fallback` on any error (missing file, parse failure, wrong type).
|
|
101
|
-
function readJsonObject(filePath, fallback, label) {
|
|
102
|
-
if (!existsSync(filePath)) return fallback;
|
|
103
|
-
try {
|
|
104
|
-
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
105
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
106
|
-
console.warn(`\nWARN: Ignoring invalid ${label}: expected JSON object`);
|
|
107
|
-
} catch (err) {
|
|
108
|
-
console.warn(`\nWARN: Ignoring invalid ${label}: ${err.message}`);
|
|
109
|
-
}
|
|
110
|
-
return fallback;
|
|
111
|
-
}
|
|
20
|
+
console.log('Installing claude-skills...\n');
|
|
112
21
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const output = execFileSync('npx', ['skills', 'add', repo, ...flags, '-l'], {
|
|
118
|
-
encoding: 'utf8',
|
|
119
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
-
});
|
|
121
|
-
return output.split('\n')
|
|
122
|
-
.filter(l => /^│ [a-z]/.test(l))
|
|
123
|
-
.map(l => l.replace(/^│\s+/, '').trim());
|
|
124
|
-
} catch {
|
|
125
|
-
return null; // can't determine list — reconcile skipped for this source
|
|
126
|
-
}
|
|
127
|
-
}
|
|
22
|
+
const sources = loadSources(join(__dir, 'sources.json'));
|
|
23
|
+
const manifest = loadManifest(MANIFEST_PATH, join(__dir, 'skills'));
|
|
24
|
+
const allResults = [];
|
|
128
25
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (staleSkills.length === 0) return [];
|
|
134
|
-
|
|
135
|
-
console.log('\nRemoving stale skills (removed from config)...\n');
|
|
136
|
-
try {
|
|
137
|
-
execFileSync('npx', ['skills', 'remove', ...staleSkills, '-g', '-y'], { stdio: 'inherit' });
|
|
138
|
-
return staleSkills.map(skill => ({ skill, action: 'uninstall', ok: true }));
|
|
139
|
-
} catch (error) {
|
|
140
|
-
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
141
|
-
console.error(`\nFailed to remove stale skills (${exitInfo})`);
|
|
142
|
-
return staleSkills.map(skill => ({ skill, action: 'uninstall', ok: false }));
|
|
26
|
+
for (const entry of sources) {
|
|
27
|
+
if (!Object.hasOwn(HANDLERS, entry.type)) {
|
|
28
|
+
console.warn(`\nWARN: Unknown source type "${entry.type}" (label: ${entry.label}), skipping`);
|
|
29
|
+
continue;
|
|
143
30
|
}
|
|
144
|
-
}
|
|
145
31
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// { source: { source: "github", repo: "org/repo" } }.
|
|
149
|
-
function getRegisteredMarketplaceSource(known, marketplaceName) {
|
|
150
|
-
const entry = known[marketplaceName];
|
|
151
|
-
if (typeof entry === 'string') return entry;
|
|
152
|
-
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
153
|
-
if (typeof entry.source === 'string') return entry.source;
|
|
154
|
-
// GitHub registry format: { source: { source: "github", repo: "org/repo" }, ... }
|
|
155
|
-
if (entry.source?.source === 'github' && typeof entry.source?.repo === 'string') {
|
|
156
|
-
return entry.source.repo;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
32
|
+
const handler = HANDLERS[entry.type];
|
|
33
|
+
const { label } = entry;
|
|
161
34
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Returns null if the source cannot be parsed.
|
|
165
|
-
function normalizeMarketplaceSource(rawSource) {
|
|
166
|
-
// GitHub shorthand: org/repo (no slashes in org, no protocol)
|
|
167
|
-
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(rawSource)) {
|
|
168
|
-
return rawSource.toLowerCase();
|
|
169
|
-
}
|
|
170
|
-
const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(rawSource);
|
|
171
|
-
if (sshMatch) {
|
|
172
|
-
return `${sshMatch[1].toLowerCase()}/${sshMatch[2].replace(/^\/+/, '').replace(/\.git$/, '')}`;
|
|
173
|
-
}
|
|
174
|
-
try {
|
|
175
|
-
const url = new URL(rawSource);
|
|
176
|
-
return `${url.hostname.toLowerCase()}/${url.pathname.replace(/^\/+/, '').replace(/\.git$/, '')}`;
|
|
177
|
-
} catch {
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
35
|
+
const desired = handler.getDesired(entry, __dir);
|
|
36
|
+
const previous = new Set(manifest.sources?.[label] ?? []);
|
|
181
37
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Leading `-` is rejected to prevent argv injection: `claude plugin install -s` parses as flag.
|
|
187
|
-
const SAFE_NAME = /^(?!-)[A-Za-z0-9_][A-Za-z0-9_-]*$/;
|
|
188
|
-
const SAFE_SOURCE = /^(git@[\w.-]+:[\w.\-/]+\.git|https:\/\/[\w.-]+\/[\w.\-/]+\.git)$/;
|
|
189
|
-
const SAFE_GITHUB = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
38
|
+
// Reconcile: uninstall items no longer in desired set.
|
|
39
|
+
// desired=null means the set couldn't be determined — skip removals, still install.
|
|
40
|
+
const stale = desired ? [...previous].filter(i => !desired.has(i)) : [];
|
|
41
|
+
if (stale.length) allResults.push(...handler.uninstall(stale, entry));
|
|
190
42
|
|
|
191
|
-
|
|
43
|
+
// Install / update
|
|
44
|
+
allResults.push(...handler.install(entry, __dir));
|
|
192
45
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
46
|
+
// Update manifest (only when desired is known)
|
|
47
|
+
if (desired !== null) {
|
|
48
|
+
manifest.sources[label] = [...desired];
|
|
196
49
|
}
|
|
197
|
-
|
|
198
|
-
console.error(`\nInvalid marketplace name or source: ${marketplaceName}`);
|
|
199
|
-
return [];
|
|
200
|
-
}
|
|
201
|
-
// Reject the entire entry on any invalid plugin name (fail-closed, not silent filter).
|
|
202
|
-
const invalidPlugins = plugins.filter(p => typeof p !== 'string' || !SAFE_NAME.test(p));
|
|
203
|
-
if (invalidPlugins.length > 0) {
|
|
204
|
-
console.error(`\nInvalid plugin names for ${marketplaceName}: ${invalidPlugins.map(p => JSON.stringify(p)).join(', ')}`);
|
|
205
|
-
return [];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
console.log(`\nInstalling marketplace plugins (${marketplaceName})...\n`);
|
|
50
|
+
}
|
|
209
51
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
52
|
+
// Post-loop cleanup: handle manifest labels no longer present in sources.json.
|
|
53
|
+
const activeLabels = new Set(sources.map(e => e.label));
|
|
54
|
+
for (const [orphanLabel, items] of Object.entries(manifest.sources ?? {})) {
|
|
55
|
+
if (activeLabels.has(orphanLabel)) continue;
|
|
214
56
|
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
57
|
+
if (orphanLabel.startsWith('__v1_')) {
|
|
58
|
+
// Synthetic migration labels: items are now tracked under real source labels.
|
|
59
|
+
// Just remove the synthetic entry — do NOT uninstall (items still managed).
|
|
60
|
+
delete manifest.sources[orphanLabel];
|
|
61
|
+
continue;
|
|
218
62
|
}
|
|
219
63
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
console.error(`\nFailed to register marketplace: ${marketplaceName} (${exitInfo})`);
|
|
228
|
-
return plugins.map(plugin => ({ plugin, action: 'skipped (no marketplace)', ok: false }));
|
|
64
|
+
// Real orphaned label: source was removed from sources.json.
|
|
65
|
+
// Uninstall its previously managed items, then remove from manifest.
|
|
66
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
67
|
+
const hasAtSign = items.some(i => typeof i === 'string' && i.includes('@'));
|
|
68
|
+
const handlerType = hasAtSign ? 'marketplace' : 'skills-repo';
|
|
69
|
+
if (Object.hasOwn(HANDLERS, handlerType)) {
|
|
70
|
+
allResults.push(...HANDLERS[handlerType].uninstall(items, { label: orphanLabel }));
|
|
229
71
|
}
|
|
230
72
|
}
|
|
231
|
-
|
|
232
|
-
// Always update marketplace to pull latest plugin versions before install/update
|
|
233
|
-
try {
|
|
234
|
-
execFileSync('claude', ['plugin', 'marketplace', 'update', marketplaceName], { stdio: 'inherit' });
|
|
235
|
-
} catch (error) {
|
|
236
|
-
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
237
|
-
console.warn(`\nWARN: Failed to update marketplace ${marketplaceName} (${exitInfo}) — using cached version`);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return plugins.map(plugin => {
|
|
241
|
-
const key = `${plugin}@${marketplaceName}`;
|
|
242
|
-
const isInstalled = Object.hasOwn(installed, key); // Object.hasOwn avoids prototype chain traversal (SCD-7)
|
|
243
|
-
const action = isInstalled ? 'update' : 'install';
|
|
244
|
-
const args = isInstalled
|
|
245
|
-
? ['plugin', 'update', key]
|
|
246
|
-
: ['plugin', 'install', key, '--scope', 'user'];
|
|
247
|
-
try {
|
|
248
|
-
execFileSync('claude', args, { stdio: 'inherit' });
|
|
249
|
-
return { plugin, action, ok: true };
|
|
250
|
-
} catch (error) {
|
|
251
|
-
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
252
|
-
console.error(`\nFailed: ${key} ${action} (${exitInfo})`);
|
|
253
|
-
return { plugin, action, ok: false };
|
|
254
|
-
}
|
|
255
|
-
});
|
|
73
|
+
delete manifest.sources[orphanLabel];
|
|
256
74
|
}
|
|
257
75
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const config = readJsonObject(configPath, null, 'marketplace.json');
|
|
264
|
-
const marketplaces = config?.marketplaces;
|
|
265
|
-
if (!Array.isArray(marketplaces) || marketplaces.length === 0) {
|
|
266
|
-
console.warn('\nWARN: marketplace.json has no valid "marketplaces" array, skipping');
|
|
267
|
-
return [];
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Load shared state once: known registry (for source verification) and installed plugins
|
|
271
|
-
const knownPath = join(homedir(), '.claude', 'plugins', 'known_marketplaces.json');
|
|
272
|
-
const knownExists = existsSync(knownPath);
|
|
273
|
-
const known = readJsonObject(knownPath, knownExists ? null : {}, 'known_marketplaces.json');
|
|
274
|
-
if (knownExists && known === null) {
|
|
275
|
-
console.error('\nInvalid known_marketplaces.json: refusing to register any marketplace automatically');
|
|
276
|
-
return marketplaces.flatMap(e =>
|
|
277
|
-
(e?.plugins ?? []).map(plugin => ({ plugin, action: 'skipped (invalid marketplace registry)', ok: false }))
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const installedPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
282
|
-
const installedData = readJsonObject(installedPath, {}, 'installed_plugins.json');
|
|
283
|
-
// installed_plugins.json v2 nests under "plugins", v1 is flat
|
|
284
|
-
const rawInstalled = installedData.plugins ?? installedData;
|
|
285
|
-
const installed = (typeof rawInstalled === 'object' && rawInstalled !== null && !Array.isArray(rawInstalled))
|
|
286
|
-
? rawInstalled
|
|
287
|
-
: {};
|
|
288
|
-
|
|
289
|
-
const desiredKeys = new Set(
|
|
290
|
-
marketplaces.flatMap(e => (e?.plugins ?? []).map(p => `${p}@${e.name}`))
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
// Reconcile: uninstall plugins WE previously managed that are no longer in marketplace.json.
|
|
294
|
-
// Uses a manifest so we only remove what claude-skills installed — not user-installed plugins.
|
|
295
|
-
const manifest = readJsonObject(MANIFEST_PATH, {}, 'claude-skills-manifest.json');
|
|
296
|
-
const previousKeys = new Set(Array.isArray(manifest.plugins) ? manifest.plugins : []);
|
|
297
|
-
const uninstallResults = reconcileMarketplacePlugins(desiredKeys, previousKeys, installed);
|
|
298
|
-
|
|
299
|
-
// Update manifest — merge to preserve other fields (e.g. skills written by reconcileSkills)
|
|
300
|
-
const tmpManifest = join(tmpdir(), `claude-skills-manifest-${process.pid}.json`);
|
|
301
|
-
const updatedManifest = { ...(readJsonObject(MANIFEST_PATH, {}) ?? {}), plugins: [...desiredKeys] };
|
|
302
|
-
writeFileSync(tmpManifest, JSON.stringify(updatedManifest, null, 2) + '\n');
|
|
303
|
-
renameSync(tmpManifest, MANIFEST_PATH);
|
|
304
|
-
|
|
305
|
-
const installResults = marketplaces.flatMap(entry => installFromMarketplace(entry, known, installed));
|
|
306
|
-
return [...uninstallResults, ...installResults];
|
|
76
|
+
// Skip manifest write if v1 migration was aborted (backup failed) — preserves original file.
|
|
77
|
+
if (!manifest._writeSkipped) {
|
|
78
|
+
writeManifest(MANIFEST_PATH, manifest);
|
|
79
|
+
} else {
|
|
80
|
+
console.warn('\nWARN: Manifest not updated — v1 backup failed. Run again to retry migration.');
|
|
307
81
|
}
|
|
82
|
+
printSummary(allResults);
|
|
308
83
|
|
|
309
|
-
//
|
|
310
|
-
// Only touches plugins in our manifest — leaves user-installed plugins alone.
|
|
311
|
-
function reconcileMarketplacePlugins(desiredKeys, previousKeys, installed) {
|
|
312
|
-
const staleKeys = [...previousKeys].filter(key =>
|
|
313
|
-
!desiredKeys.has(key) && Object.hasOwn(installed, key)
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
if (staleKeys.length === 0) return [];
|
|
84
|
+
// ── Auto-update hooks ─────────────────────────────────────────────────────────
|
|
317
85
|
|
|
318
|
-
|
|
319
|
-
return staleKeys.map(key => {
|
|
320
|
-
const atIdx = key.lastIndexOf('@');
|
|
321
|
-
const plugin = atIdx !== -1 ? key.slice(0, atIdx) : key;
|
|
322
|
-
try {
|
|
323
|
-
execFileSync('claude', ['plugin', 'uninstall', key], { stdio: 'inherit' });
|
|
324
|
-
return { plugin, action: 'uninstall', ok: true };
|
|
325
|
-
} catch (error) {
|
|
326
|
-
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
327
|
-
console.error(`\nFailed to uninstall ${key} (${exitInfo})`);
|
|
328
|
-
return { plugin, action: 'uninstall', ok: false };
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
}
|
|
86
|
+
setupAutoUpdate();
|
|
332
87
|
|
|
333
88
|
function setupAutoUpdate() {
|
|
334
89
|
const { version: installedVersion } = JSON.parse(
|
|
335
90
|
readFileSync(join(__dir, 'package.json'), 'utf8')
|
|
336
91
|
);
|
|
337
92
|
|
|
338
|
-
const claudeDir
|
|
339
|
-
const hooksDir
|
|
340
|
-
const cacheDir
|
|
341
|
-
const installScript
|
|
342
|
-
const notifyScript
|
|
343
|
-
const installStamp
|
|
344
|
-
const notifyStamp
|
|
345
|
-
const settingsPath
|
|
93
|
+
const claudeDir = join(homedir(), '.claude');
|
|
94
|
+
const hooksDir = join(claudeDir, 'hooks');
|
|
95
|
+
const cacheDir = join(homedir(), '.cache');
|
|
96
|
+
const installScript = join(hooksDir, 'claude-skills-install-update.sh');
|
|
97
|
+
const notifyScript = join(hooksDir, 'auto-update-claude-skills.sh');
|
|
98
|
+
const installStamp = join(cacheDir, 'claude-skills-install-check.json');
|
|
99
|
+
const notifyStamp = join(cacheDir, 'claude-skills-version-check.json');
|
|
100
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
346
101
|
|
|
347
102
|
mkdirSync(hooksDir, { recursive: true });
|
|
348
103
|
mkdirSync(cacheDir, { recursive: true });
|
|
349
104
|
|
|
350
|
-
// SessionStart: auto-install if update available (24h throttle, synchronous)
|
|
351
105
|
writeFileSync(installScript, buildInstallScript(installStamp, installedVersion), { mode: 0o755 });
|
|
106
|
+
writeFileSync(notifyScript, buildNotifyScript(notifyStamp, installedVersion), { mode: 0o755 });
|
|
352
107
|
|
|
353
|
-
// UserPromptSubmit: notify only if update available (2h throttle)
|
|
354
|
-
writeFileSync(notifyScript, buildNotifyScript(notifyStamp, installedVersion), { mode: 0o755 });
|
|
355
|
-
|
|
356
|
-
// Merge hooks into ~/.claude/settings.json (idempotent)
|
|
357
108
|
let settings = {};
|
|
358
109
|
if (existsSync(settingsPath)) {
|
|
359
110
|
try {
|
|
@@ -367,25 +118,18 @@ function setupAutoUpdate() {
|
|
|
367
118
|
|
|
368
119
|
settings.hooks ??= {};
|
|
369
120
|
|
|
370
|
-
// SessionStart — auto-install update (24h throttle)
|
|
371
121
|
settings.hooks.SessionStart ??= [];
|
|
372
122
|
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
373
123
|
e => !e.hooks?.some(h => typeof h.command === 'string' && h.command === installScript)
|
|
374
124
|
);
|
|
375
|
-
settings.hooks.SessionStart.push({
|
|
376
|
-
hooks: [{ type: 'command', command: installScript }]
|
|
377
|
-
});
|
|
125
|
+
settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: installScript }] });
|
|
378
126
|
|
|
379
|
-
// UserPromptSubmit — notify only (2h throttle)
|
|
380
127
|
settings.hooks.UserPromptSubmit ??= [];
|
|
381
128
|
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
382
129
|
e => !e.hooks?.some(h => typeof h.command === 'string' && h.command === notifyScript)
|
|
383
130
|
);
|
|
384
|
-
settings.hooks.UserPromptSubmit.push({
|
|
385
|
-
hooks: [{ type: 'command', command: notifyScript }]
|
|
386
|
-
});
|
|
131
|
+
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: notifyScript }] });
|
|
387
132
|
|
|
388
|
-
// Atomic write
|
|
389
133
|
const tmpPath = join(tmpdir(), `claude-settings-${process.pid}.json`);
|
|
390
134
|
writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n');
|
|
391
135
|
renameSync(tmpPath, settingsPath);
|
|
@@ -422,11 +166,10 @@ python3 -c "import json,sys; json.dump({'ts': int(sys.argv[1])}, open(sys.argv[2
|
|
|
422
166
|
LATEST=$(npm view "$PACKAGE" version 2>/dev/null || echo "")
|
|
423
167
|
[ -z "$LATEST" ] && exit 0
|
|
424
168
|
[ "$LATEST" = "$INSTALLED" ] && exit 0
|
|
425
|
-
[[ "$LATEST" =~ ^[0-9]
|
|
169
|
+
[[ "$LATEST" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || exit 0
|
|
426
170
|
|
|
427
|
-
# Install update synchronously
|
|
428
171
|
INSTALL_DIR="$(mktemp -d)"
|
|
429
|
-
if npm install --prefix "$INSTALL_DIR" "$PACKAGE@$LATEST" --silent 2>/dev/null
|
|
172
|
+
if npm install --prefix "$INSTALL_DIR" "$PACKAGE@$LATEST" --silent 2>/dev/null \\
|
|
430
173
|
&& node "$INSTALL_DIR/node_modules/$PACKAGE/cli.js" 2>/dev/null; then
|
|
431
174
|
rm -rf "$INSTALL_DIR"
|
|
432
175
|
python3 -c "import json,sys; print(json.dumps({'systemMessage': 'claude-skills updated: ' + sys.argv[1] + ' \u2192 ' + sys.argv[2]}))" "$INSTALLED" "$LATEST"
|
|
@@ -465,7 +208,7 @@ python3 -c "import json,sys; json.dump({'ts': int(sys.argv[1])}, open(sys.argv[2
|
|
|
465
208
|
LATEST=$(npm view "$PACKAGE" version 2>/dev/null || echo "")
|
|
466
209
|
[ -z "$LATEST" ] && exit 0
|
|
467
210
|
[ "$LATEST" = "$INSTALLED" ] && exit 0
|
|
468
|
-
[[ "$LATEST" =~ ^[0-9]
|
|
211
|
+
[[ "$LATEST" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || exit 0
|
|
469
212
|
|
|
470
213
|
python3 -c "import json,sys; print(json.dumps({'systemMessage': 'claude-skills update available: ' + sys.argv[1] + ' \u2192 ' + sys.argv[2] + chr(10) + 'Run: npx @trendai-crem/claude-skills@latest'}))" "$INSTALLED" "$LATEST"
|
|
471
214
|
`;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readJsonObject } from './utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads and normalizes sources.json.
|
|
5
|
+
*
|
|
6
|
+
* Each entry is normalized so:
|
|
7
|
+
* - entry.label is always set (= entry.label ?? entry.name ?? entry.type)
|
|
8
|
+
* - Entries missing type or a resolvable label are warned and skipped.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} filePath - Absolute path to sources.json.
|
|
11
|
+
* @returns {Array} Normalized source entries (empty array on any failure).
|
|
12
|
+
*/
|
|
13
|
+
export function loadSources(filePath) {
|
|
14
|
+
const config = readJsonObject(filePath, null, 'sources.json');
|
|
15
|
+
if (!config) return [];
|
|
16
|
+
|
|
17
|
+
const raw = config.sources;
|
|
18
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
19
|
+
console.warn('\nWARN: sources.json has no valid "sources" array, skipping all sources');
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const normalized = [];
|
|
25
|
+
|
|
26
|
+
for (const entry of raw) {
|
|
27
|
+
if (typeof entry?.type !== 'string') {
|
|
28
|
+
console.warn(`\nWARN: Skipping source entry with missing or non-string "type": ${JSON.stringify(entry)}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve canonical label — used as manifest key
|
|
33
|
+
const label = entry.label ?? entry.name;
|
|
34
|
+
if (typeof label !== 'string' || label.trim() === '') {
|
|
35
|
+
console.warn(`\nWARN: Skipping source entry of type "${entry.type}" with no resolvable label`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (seen.has(label)) {
|
|
40
|
+
console.warn(`\nWARN: Duplicate source label "${label}" — skipping second entry`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seen.add(label);
|
|
44
|
+
|
|
45
|
+
normalized.push({ ...entry, label });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { skillsDirHandler } from './skills-dir.js';
|
|
2
|
+
import { skillsRepoHandler } from './skills-repo.js';
|
|
3
|
+
import { marketplaceHandler } from './marketplace.js';
|
|
4
|
+
|
|
5
|
+
// Use Object.create(null) to prevent prototype chain traversal on adversarial type values.
|
|
6
|
+
export const HANDLERS = Object.create(null);
|
|
7
|
+
HANDLERS['skills-dir'] = skillsDirHandler;
|
|
8
|
+
HANDLERS['skills-repo'] = skillsRepoHandler;
|
|
9
|
+
HANDLERS['marketplace'] = marketplaceHandler;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { readJsonObject, SAFE_NAME, SAFE_SOURCE, SAFE_GITHUB } from '../utils.js';
|
|
6
|
+
|
|
7
|
+
// Normalizes a marketplace source to a canonical host/path form for comparison.
|
|
8
|
+
// Accepts SSH URLs, HTTPS URLs, and GitHub shorthand (org/repo).
|
|
9
|
+
function normalizeMarketplaceSource(rawSource) {
|
|
10
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(rawSource)) return rawSource.toLowerCase();
|
|
11
|
+
const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(rawSource);
|
|
12
|
+
if (sshMatch) {
|
|
13
|
+
return `${sshMatch[1].toLowerCase()}/${sshMatch[2].replace(/^\/+/, '').replace(/\.git$/, '')}`;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(rawSource);
|
|
17
|
+
return `${url.hostname.toLowerCase()}/${url.pathname.replace(/^\/+/, '').replace(/\.git$/, '')}`;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Extracts the registered source for a marketplace from known_marketplaces.json.
|
|
24
|
+
function getRegisteredMarketplaceSource(known, marketplaceName) {
|
|
25
|
+
const entry = known[marketplaceName];
|
|
26
|
+
if (typeof entry === 'string') return entry;
|
|
27
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
28
|
+
if (typeof entry.source === 'string') return entry.source;
|
|
29
|
+
if (entry.source?.source === 'github' && typeof entry.source?.repo === 'string') {
|
|
30
|
+
return entry.source.repo;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handler for marketplace plugin sources (type: "marketplace").
|
|
38
|
+
* Manages Claude Code plugins from a declared marketplace.
|
|
39
|
+
*/
|
|
40
|
+
export const marketplaceHandler = {
|
|
41
|
+
/**
|
|
42
|
+
* Returns the set of plugin@marketplace keys this entry should manage.
|
|
43
|
+
* Validates all fields against allowlist regexes (SCD-1, OWASP-A04).
|
|
44
|
+
* @param {object} entry - Source entry with `name`, `source`, `plugins` fields.
|
|
45
|
+
* @returns {Set<string>|null}
|
|
46
|
+
*/
|
|
47
|
+
getDesired(entry) {
|
|
48
|
+
const { name: marketplaceName, source, plugins } = entry ?? {};
|
|
49
|
+
if (typeof marketplaceName !== 'string' || typeof source !== 'string' || !Array.isArray(plugins)) return null;
|
|
50
|
+
if (!SAFE_NAME.test(marketplaceName) || (!SAFE_SOURCE.test(source) && !SAFE_GITHUB.test(source))) return null;
|
|
51
|
+
const invalid = plugins.filter(p => typeof p !== 'string' || !SAFE_NAME.test(p));
|
|
52
|
+
if (invalid.length > 0) return null;
|
|
53
|
+
return new Set(plugins.map(p => `${p}@${marketplaceName}`));
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Installs or updates plugins for this marketplace entry.
|
|
58
|
+
* All security controls from the original implementation are preserved.
|
|
59
|
+
* @returns {Array<{label, action, ok}>}
|
|
60
|
+
*/
|
|
61
|
+
install(entry) {
|
|
62
|
+
const { name: marketplaceName, source, plugins } = entry ?? {};
|
|
63
|
+
|
|
64
|
+
// Full validation (SCD-1, OWASP-A04)
|
|
65
|
+
if (typeof marketplaceName !== 'string' || typeof source !== 'string' || !Array.isArray(plugins)) {
|
|
66
|
+
console.error('\nInvalid marketplace entry: missing required fields (name, source, plugins[])');
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
if (!SAFE_NAME.test(marketplaceName) || (!SAFE_SOURCE.test(source) && !SAFE_GITHUB.test(source))) {
|
|
70
|
+
console.error(`\nInvalid marketplace name or source: ${marketplaceName}`);
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const invalidPlugins = plugins.filter(p => typeof p !== 'string' || !SAFE_NAME.test(p));
|
|
74
|
+
if (invalidPlugins.length > 0) {
|
|
75
|
+
console.error(`\nInvalid plugin names for ${marketplaceName}: ${invalidPlugins.map(p => JSON.stringify(p)).join(', ')}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(`\nInstalling marketplace plugins (${marketplaceName})...\n`);
|
|
80
|
+
|
|
81
|
+
// Load known registry — fail-closed if corrupted (OWASP-A08, SCD-7)
|
|
82
|
+
const knownPath = join(homedir(), '.claude', 'plugins', 'known_marketplaces.json');
|
|
83
|
+
const knownExists = existsSync(knownPath);
|
|
84
|
+
const known = readJsonObject(knownPath, knownExists ? null : {}, 'known_marketplaces.json');
|
|
85
|
+
if (knownExists && known === null) {
|
|
86
|
+
console.error(`\nInvalid known_marketplaces.json: refusing to register marketplace ${marketplaceName}`);
|
|
87
|
+
return plugins.map(p => ({ label: `${p}@${marketplaceName}`, action: 'skipped (invalid registry)', ok: false }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Source mismatch check — prevent supply-chain redirection (OWASP-A08)
|
|
91
|
+
const registeredSource = getRegisteredMarketplaceSource(known, marketplaceName);
|
|
92
|
+
const normalizedSource = normalizeMarketplaceSource(source);
|
|
93
|
+
const normalizedRegistered = registeredSource ? normalizeMarketplaceSource(registeredSource) : null;
|
|
94
|
+
if (registeredSource && (!normalizedRegistered || normalizedRegistered !== normalizedSource)) {
|
|
95
|
+
console.error(`\nMarketplace source mismatch for ${marketplaceName}: registered as ${registeredSource}, but sources.json specifies ${source}`);
|
|
96
|
+
return plugins.map(p => ({ label: `${p}@${marketplaceName}`, action: 'skipped (source mismatch)', ok: false }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Register if not already known
|
|
100
|
+
if (!registeredSource) {
|
|
101
|
+
try {
|
|
102
|
+
execFileSync('claude', ['plugin', 'marketplace', 'add', source, '--scope', 'user'], { stdio: 'inherit' });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
105
|
+
console.error(`\nFailed to register marketplace: ${marketplaceName} (${exitInfo})`);
|
|
106
|
+
return plugins.map(p => ({ label: `${p}@${marketplaceName}`, action: 'skipped (no marketplace)', ok: false }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Always update marketplace to pull latest plugin versions
|
|
111
|
+
try {
|
|
112
|
+
execFileSync('claude', ['plugin', 'marketplace', 'update', marketplaceName], { stdio: 'inherit' });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
115
|
+
console.warn(`\nWARN: Failed to update marketplace ${marketplaceName} (${exitInfo}) — using cached version`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Load installed registry for install vs update decision (SCD-7)
|
|
119
|
+
const installedPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
120
|
+
const installedData = readJsonObject(installedPath, {}, 'installed_plugins.json');
|
|
121
|
+
const rawInstalled = installedData.plugins ?? installedData;
|
|
122
|
+
const installed = (typeof rawInstalled === 'object' && rawInstalled !== null && !Array.isArray(rawInstalled))
|
|
123
|
+
? rawInstalled : {};
|
|
124
|
+
|
|
125
|
+
return plugins.map(plugin => {
|
|
126
|
+
const key = `${plugin}@${marketplaceName}`;
|
|
127
|
+
const isInstalled = Object.hasOwn(installed, key); // prototype-safe (SCD-7)
|
|
128
|
+
const action = isInstalled ? 'update' : 'install';
|
|
129
|
+
const args = isInstalled
|
|
130
|
+
? ['plugin', 'update', key]
|
|
131
|
+
: ['plugin', 'install', key, '--scope', 'user'];
|
|
132
|
+
try {
|
|
133
|
+
execFileSync('claude', args, { stdio: 'inherit' });
|
|
134
|
+
return { label: key, action, ok: true };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
137
|
+
console.error(`\nFailed: ${key} ${action} (${exitInfo})`);
|
|
138
|
+
return { label: key, action, ok: false };
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Uninstalls stale plugin@marketplace keys.
|
|
145
|
+
* @param {string[]} staleItems - Keys like "plugin@marketplace".
|
|
146
|
+
* @returns {Array<{label, action, ok}>}
|
|
147
|
+
*/
|
|
148
|
+
uninstall(staleItems) {
|
|
149
|
+
return staleItems.map(key => {
|
|
150
|
+
try {
|
|
151
|
+
execFileSync('claude', ['plugin', 'uninstall', key], { stdio: 'inherit' });
|
|
152
|
+
return { label: key, action: 'uninstall', ok: true };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
155
|
+
console.error(`\nFailed to uninstall ${key} (${exitInfo})`);
|
|
156
|
+
return { label: key, action: 'uninstall', ok: false };
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { readdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { uninstallSkills } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handler for local skills directory sources (type: "skills-dir").
|
|
8
|
+
* Installs all skill subdirectories from a local path.
|
|
9
|
+
*/
|
|
10
|
+
export const skillsDirHandler = {
|
|
11
|
+
/**
|
|
12
|
+
* Returns the set of skill names in the directory.
|
|
13
|
+
* @param {object} entry - Source entry with `path` field.
|
|
14
|
+
* @param {string} baseDir - Repository base directory.
|
|
15
|
+
* @returns {Set<string>|null}
|
|
16
|
+
*/
|
|
17
|
+
getDesired(entry, baseDir) {
|
|
18
|
+
try {
|
|
19
|
+
const absPath = join(baseDir, entry.path);
|
|
20
|
+
const names = readdirSync(absPath, { withFileTypes: true })
|
|
21
|
+
.filter(e => e.isDirectory())
|
|
22
|
+
.map(e => e.name)
|
|
23
|
+
.sort();
|
|
24
|
+
return new Set(names);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.warn(`\nWARN: Cannot read skills directory "${entry.path}": ${err.message}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Installs all skills from the directory via `npx skills add`.
|
|
33
|
+
* @returns {Array<{label, action, ok}>}
|
|
34
|
+
*/
|
|
35
|
+
install(entry, baseDir) {
|
|
36
|
+
const absPath = join(baseDir, entry.path);
|
|
37
|
+
try {
|
|
38
|
+
execFileSync('npx', ['skills', 'add', absPath, '--all', '-g', '-y'], { stdio: 'inherit' });
|
|
39
|
+
return [{ label: entry.label, action: 'install-group', ok: true }];
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
42
|
+
console.error(`\nFailed: ${entry.label} (${exitInfo})`);
|
|
43
|
+
return [{ label: entry.label, action: 'install-group', ok: false }];
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Removes stale skills. Delegates to shared uninstallSkills utility.
|
|
49
|
+
* @param {string[]} staleItems - Skill names to remove.
|
|
50
|
+
* @returns {Array<{label, action, ok}>}
|
|
51
|
+
*/
|
|
52
|
+
uninstall(staleItems) {
|
|
53
|
+
return uninstallSkills(staleItems);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { SAFE_GITHUB, SAFE_SOURCE, uninstallSkills } from '../utils.js';
|
|
3
|
+
|
|
4
|
+
// Allowed flags for skills-repo sources (allowlist to prevent flag injection).
|
|
5
|
+
const ALLOWED_REPO_FLAGS = new Set(['--all', '--copy', '--full-depth']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handler for remote skill repository sources (type: "skills-repo").
|
|
9
|
+
* Installs all skills from a GitHub/git repository.
|
|
10
|
+
*/
|
|
11
|
+
export const skillsRepoHandler = {
|
|
12
|
+
/**
|
|
13
|
+
* Lists available skills in the repo via --list flag (no install).
|
|
14
|
+
* Returns null if the query fails — reconcile is skipped but install still runs.
|
|
15
|
+
* Returns an empty Set if the repo has no skills (reconcile removes all stale).
|
|
16
|
+
* @param {object} entry - Source entry with `repo` and `flags` fields.
|
|
17
|
+
* @returns {Set<string>|null}
|
|
18
|
+
*/
|
|
19
|
+
getDesired(entry) {
|
|
20
|
+
if (!_validateEntry(entry)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const output = execFileSync(
|
|
23
|
+
'npx', ['skills', 'add', entry.repo, ...(entry.flags ?? []), '-l'],
|
|
24
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
25
|
+
);
|
|
26
|
+
const names = output.split('\n')
|
|
27
|
+
.filter(l => /^│ [a-z]/.test(l))
|
|
28
|
+
.map(l => l.replace(/^│\s+/, '').trim());
|
|
29
|
+
if (names.length === 0) {
|
|
30
|
+
console.warn(`\nWARN: Could not parse skill list from ${entry.repo} — reconcile skipped`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return new Set(names);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(`\nWARN: Cannot list skills from ${entry.repo}: ${error.message}`);
|
|
36
|
+
return null; // network failure — skip reconcile, still install
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Installs all skills from the repo via `npx skills add`.
|
|
42
|
+
* @returns {Array<{label, action, ok}>}
|
|
43
|
+
*/
|
|
44
|
+
install(entry) {
|
|
45
|
+
if (!_validateEntry(entry)) {
|
|
46
|
+
return [{ label: entry?.label ?? 'skills-repo', action: 'install-group', ok: false }];
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(
|
|
50
|
+
'npx', ['skills', 'add', entry.repo, ...(entry.flags ?? []), '-g', '-y'],
|
|
51
|
+
{ stdio: 'inherit' }
|
|
52
|
+
);
|
|
53
|
+
return [{ label: entry.label, action: 'install-group', ok: true }];
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
56
|
+
console.error(`\nFailed: ${entry.label} (${exitInfo})`);
|
|
57
|
+
return [{ label: entry.label, action: 'install-group', ok: false }];
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Removes stale skills via `npx skills remove`. Delegates to shared utility.
|
|
63
|
+
* @param {string[]} staleItems - Skill names to remove.
|
|
64
|
+
* @returns {Array<{label, action, ok}>}
|
|
65
|
+
*/
|
|
66
|
+
uninstall(staleItems) {
|
|
67
|
+
return uninstallSkills(staleItems);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Validates repo and flags fields before passing to execFileSync (SCD-1, OWASP-A04).
|
|
72
|
+
function _validateEntry(entry) {
|
|
73
|
+
if (typeof entry?.repo !== 'string' ||
|
|
74
|
+
(!SAFE_GITHUB.test(entry.repo) && !SAFE_SOURCE.test(entry.repo))) {
|
|
75
|
+
console.error(`\nInvalid skills-repo entry: invalid repo "${entry?.repo}"`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const flags = entry.flags ?? [];
|
|
79
|
+
if (!Array.isArray(flags) || flags.some(f => typeof f !== 'string' || !ALLOWED_REPO_FLAGS.has(f))) {
|
|
80
|
+
console.error(`\nInvalid skills-repo entry: unsupported flags ${JSON.stringify(flags)}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, readdirSync, renameSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { readJsonObject } from './utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loads the manifest from disk, automatically migrating v1 format to v2.
|
|
8
|
+
* On migration, backs up the original as manifest.json.v1.bak.
|
|
9
|
+
*
|
|
10
|
+
* v1 format: { plugins: [...], skills: [...] }
|
|
11
|
+
* v2 format: { version: 2, sources: { "label": [...items] } }
|
|
12
|
+
*
|
|
13
|
+
* @param {string} manifestPath - Absolute path to the manifest file.
|
|
14
|
+
* @param {string} skillsDirPath - Absolute path to the skills/ directory (for v1 migration attribution).
|
|
15
|
+
* @returns {{ version: number, sources: object, _writeSkipped?: boolean }}
|
|
16
|
+
* If `_writeSkipped` is true, the caller MUST NOT call writeManifest() — the
|
|
17
|
+
* original manifest file must be preserved for the next run to retry migration.
|
|
18
|
+
*/
|
|
19
|
+
export function loadManifest(manifestPath, skillsDirPath) {
|
|
20
|
+
const raw = readJsonObject(manifestPath, null, 'claude-skills-manifest.json');
|
|
21
|
+
|
|
22
|
+
// No manifest yet — start fresh
|
|
23
|
+
if (!raw) return { version: 2, sources: {} };
|
|
24
|
+
|
|
25
|
+
// Already v2
|
|
26
|
+
if (raw.version === 2 && raw.sources && typeof raw.sources === 'object') {
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Migrate v1 → v2
|
|
31
|
+
console.log('\nMigrating claude-skills manifest from v1 to v2...');
|
|
32
|
+
|
|
33
|
+
// Backup before overwriting
|
|
34
|
+
const backupPath = `${manifestPath}.v1.bak`;
|
|
35
|
+
try {
|
|
36
|
+
copyFileSync(manifestPath, backupPath);
|
|
37
|
+
console.log(` Backed up v1 manifest to ${backupPath}`);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(` ERROR: Cannot back up v1 manifest (${err.message}). Skipping migration to preserve data.`);
|
|
40
|
+
// Signal caller to skip writeManifest so the original v1 file is preserved.
|
|
41
|
+
// Next run retries migration once the backup succeeds.
|
|
42
|
+
return { version: 2, sources: {}, _writeSkipped: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return migrateV1toV2(raw, skillsDirPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Migrates a v1 manifest to v2 format.
|
|
50
|
+
*
|
|
51
|
+
* Algorithm:
|
|
52
|
+
* - skills that exist in skills/ dir → sources["team-skills"]
|
|
53
|
+
* - remaining skills → sources["__v1_migrated"] (reconcile-skipped on first run)
|
|
54
|
+
* - plugins with @marketplace suffix → sources[marketplace-name]
|
|
55
|
+
* - plugins with unknown marketplace → sources["__v1_orphaned"]
|
|
56
|
+
*/
|
|
57
|
+
function migrateV1toV2(v1, skillsDirPath) {
|
|
58
|
+
const sources = {};
|
|
59
|
+
|
|
60
|
+
// Migrate skills
|
|
61
|
+
if (Array.isArray(v1.skills) && v1.skills.length > 0) {
|
|
62
|
+
let teamSkillNames = new Set();
|
|
63
|
+
try {
|
|
64
|
+
teamSkillNames = new Set(
|
|
65
|
+
readdirSync(skillsDirPath, { withFileTypes: true })
|
|
66
|
+
.filter(e => e.isDirectory())
|
|
67
|
+
.map(e => e.name)
|
|
68
|
+
);
|
|
69
|
+
} catch { /* skills dir unreadable — all go to __v1_migrated */ }
|
|
70
|
+
|
|
71
|
+
const teamSkills = v1.skills.filter(s => teamSkillNames.has(s));
|
|
72
|
+
const migratedSkills = v1.skills.filter(s => !teamSkillNames.has(s));
|
|
73
|
+
|
|
74
|
+
if (teamSkills.length > 0) sources['team-skills'] = teamSkills;
|
|
75
|
+
if (migratedSkills.length > 0) {
|
|
76
|
+
sources['__v1_migrated'] = migratedSkills;
|
|
77
|
+
console.log(` ${migratedSkills.length} skills moved to __v1_migrated (will resolve on next full run)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Migrate plugins — distribute by @marketplace-name suffix
|
|
82
|
+
if (Array.isArray(v1.plugins) && v1.plugins.length > 0) {
|
|
83
|
+
const byMarketplace = {};
|
|
84
|
+
const orphaned = [];
|
|
85
|
+
|
|
86
|
+
for (const key of v1.plugins) {
|
|
87
|
+
const atIdx = key.lastIndexOf('@');
|
|
88
|
+
if (atIdx !== -1) {
|
|
89
|
+
const mName = key.slice(atIdx + 1);
|
|
90
|
+
(byMarketplace[mName] ??= []).push(key);
|
|
91
|
+
} else {
|
|
92
|
+
orphaned.push(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const [mName, keys] of Object.entries(byMarketplace)) {
|
|
97
|
+
sources[mName] = keys;
|
|
98
|
+
}
|
|
99
|
+
if (orphaned.length > 0) {
|
|
100
|
+
sources['__v1_orphaned'] = orphaned;
|
|
101
|
+
console.log(` ${orphaned.length} plugins moved to __v1_orphaned (unknown marketplace)`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { version: 2, sources };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Writes the manifest atomically (tmp file + rename).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} manifestPath - Absolute path to the manifest file.
|
|
112
|
+
* @param {{ version: number, sources: object }} manifest
|
|
113
|
+
*/
|
|
114
|
+
export function writeManifest(manifestPath, manifest) {
|
|
115
|
+
const tmp = join(tmpdir(), `claude-skills-manifest-${process.pid}.json`);
|
|
116
|
+
writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n');
|
|
117
|
+
renameSync(tmp, manifestPath);
|
|
118
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
// Shared allowlist constants — all handlers MUST validate entry fields against these
|
|
5
|
+
// before passing to execFileSync (SCD-1, OWASP-A04).
|
|
6
|
+
export const SAFE_NAME = /^(?!-)[A-Za-z0-9_][A-Za-z0-9_-]*$/;
|
|
7
|
+
export const SAFE_SOURCE = /^(git@[\w.-]+:[\w.\-/]+\.git|https:\/\/[\w.-]+\/[\w.\-/]+\.git)$/;
|
|
8
|
+
export const SAFE_GITHUB = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads a JSON file and returns its parsed value if it is a non-null, non-array object.
|
|
12
|
+
* Returns `fallback` on any error (missing file, parse failure, wrong type).
|
|
13
|
+
*
|
|
14
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
15
|
+
* @param {*} fallback - Value returned when the file is missing or unreadable.
|
|
16
|
+
* @param {string} label - Human-readable label used in warning messages.
|
|
17
|
+
* @returns {object|*} The parsed object, or fallback.
|
|
18
|
+
*/
|
|
19
|
+
export function readJsonObject(filePath, fallback, label) {
|
|
20
|
+
if (!existsSync(filePath)) return fallback;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
23
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
24
|
+
console.warn(`\nWARN: Ignoring invalid ${label}: expected JSON object`);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.warn(`\nWARN: Ignoring invalid ${label}: ${err.message}`);
|
|
27
|
+
}
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Removes skills by name via `npx skills remove`. Used by skills-dir and skills-repo handlers.
|
|
33
|
+
*
|
|
34
|
+
* @param {string[]} staleItems - Skill names to remove.
|
|
35
|
+
* @returns {Array<{label, action, ok}>}
|
|
36
|
+
*/
|
|
37
|
+
export function uninstallSkills(staleItems) {
|
|
38
|
+
if (staleItems.length === 0) return [];
|
|
39
|
+
try {
|
|
40
|
+
execFileSync('npx', ['skills', 'remove', ...staleItems, '-g', '-y'], { stdio: 'inherit' });
|
|
41
|
+
return staleItems.map(s => ({ label: s, action: 'uninstall', ok: true }));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
|
|
44
|
+
console.error(`\nFailed to remove stale skills (${exitInfo})`);
|
|
45
|
+
return staleItems.map(s => ({ label: s, action: 'uninstall', ok: false }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prints the consolidated install/uninstall results summary.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array<{label: string, action: string, ok: boolean}>} results
|
|
53
|
+
*/
|
|
54
|
+
export function printSummary(results) {
|
|
55
|
+
console.log('\nResults:');
|
|
56
|
+
results.forEach(({ label, action, ok }) => {
|
|
57
|
+
const suffix = action !== 'install-group' ? ` (${action})` : '';
|
|
58
|
+
console.log(` ${ok ? '✓' : '✗'} ${label}${suffix}`);
|
|
59
|
+
});
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trendai-crem/claude-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Claude Code skills installer for the trendai-crem team",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"cli.js",
|
|
15
|
+
"sources.json",
|
|
15
16
|
"marketplace.json",
|
|
17
|
+
"lib/",
|
|
16
18
|
"skills/"
|
|
17
19
|
],
|
|
18
20
|
"type": "module",
|
package/sources.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"sources": [
|
|
4
|
+
{
|
|
5
|
+
"type": "skills-dir",
|
|
6
|
+
"path": "./skills",
|
|
7
|
+
"label": "team-skills"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "skills-repo",
|
|
11
|
+
"repo": "obra/superpowers",
|
|
12
|
+
"flags": [
|
|
13
|
+
"--all"
|
|
14
|
+
],
|
|
15
|
+
"label": "superpowers"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "marketplace",
|
|
19
|
+
"name": "ai-skill-marketplace",
|
|
20
|
+
"source": "git@github.com:trend-ai-taskforce/ai-skill-marketplace.git",
|
|
21
|
+
"plugins": [
|
|
22
|
+
"wiki-tools",
|
|
23
|
+
"atlassian-tools",
|
|
24
|
+
"google-style-guides",
|
|
25
|
+
"l2-automation",
|
|
26
|
+
"service-doc-generator",
|
|
27
|
+
"claude-on-teams"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "marketplace",
|
|
32
|
+
"name": "claude-plugins-official",
|
|
33
|
+
"source": "anthropics/claude-plugins-official",
|
|
34
|
+
"plugins": [
|
|
35
|
+
"ralph-loop"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|