@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 CHANGED
@@ -1,359 +1,110 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'child_process';
4
- import { readFileSync, readdirSync, writeFileSync, copyFileSync, mkdirSync, existsSync } from 'fs';
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
- const __dir = dirname(fileURLToPath(import.meta.url));
11
-
12
- const EXTERNAL_SOURCES = [
13
- { repo: 'obra/superpowers', flags: ['--all'], label: 'superpowers' },
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
- const run = (args, label) => {
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
- // Reads a JSON file and returns its parsed value if it is a non-null, non-array object.
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
- // Lists skill names available in an external repo without installing (uses --list flag).
114
- // Returns null if the query fails — caller should skip reconcile for that source.
115
- function listExternalSkillNames(repo, flags) {
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
- // Removes skills that claude-skills previously managed but are no longer in any source.
130
- // Only touches skills in our manifest — leaves user-created skills alone.
131
- function reconcileSkills(desiredSkills, previousSkills) {
132
- const staleSkills = [...previousSkills].filter(s => !desiredSkills.has(s));
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
- // Extracts the registered source for a marketplace from known_marketplaces.json.
147
- // Handles: plain string, object with string .source, and GitHub registry format
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
- // Normalizes a marketplace source to a canonical host/path form for comparison.
163
- // Accepts SSH URLs, HTTPS URLs, and GitHub shorthand (org/repo).
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
- // Installs or updates all plugins for a single marketplace entry.
183
- // Returns an array of { plugin, action, ok } results.
184
- function installFromMarketplace(entry, known, installed) {
185
- // Input validation + allowlisting (SCD-1, OWASP-A04)
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
- const { name: marketplaceName, source, plugins } = entry ?? {};
43
+ // Install / update
44
+ allResults.push(...handler.install(entry, __dir));
192
45
 
193
- if (typeof marketplaceName !== 'string' || typeof source !== 'string' || !Array.isArray(plugins)) {
194
- console.error('\nInvalid marketplace entry: missing required fields (name, source, plugins[])');
195
- return [];
46
+ // Update manifest (only when desired is known)
47
+ if (desired !== null) {
48
+ manifest.sources[label] = [...desired];
196
49
  }
197
- if (!SAFE_NAME.test(marketplaceName) || (!SAFE_SOURCE.test(source) && !SAFE_GITHUB.test(source))) {
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
- // Register marketplace if not already known verify source matches to prevent supply-chain mismatch (OWASP-A08)
211
- const registeredSource = getRegisteredMarketplaceSource(known, marketplaceName);
212
- const normalizedSource = normalizeMarketplaceSource(source);
213
- const normalizedRegistered = registeredSource ? normalizeMarketplaceSource(registeredSource) : null;
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 (registeredSource && (!normalizedRegistered || normalizedRegistered !== normalizedSource)) {
216
- console.error(`\nMarketplace source mismatch for ${marketplaceName}: registered as ${registeredSource}, but marketplace.json specifies ${source}`);
217
- return plugins.map(plugin => ({ plugin, action: 'skipped (source mismatch)', ok: false }));
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
- if (!registeredSource) {
221
- try {
222
- execFileSync('claude', ['plugin', 'marketplace', 'add', source, '--scope', 'user'], {
223
- stdio: 'inherit',
224
- });
225
- } catch (error) {
226
- const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
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
- function installMarketplacePlugins() {
259
- const configPath = join(__dir, 'marketplace.json');
260
- if (!existsSync(configPath)) return [];
261
-
262
- // Parse marketplace.jsonall failures are non-fatal (SCD-7)
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
- // Uninstalls plugins that claude-skills previously managed but are no longer in marketplace.json.
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
- console.log('\nRemoving stale marketplace plugins (removed from marketplace.json)...\n');
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 = join(homedir(), '.claude');
339
- const hooksDir = join(claudeDir, 'hooks');
340
- const cacheDir = join(homedir(), '.cache');
341
- const installScript = join(hooksDir, 'claude-skills-install-update.sh');
342
- const notifyScript = join(hooksDir, 'auto-update-claude-skills.sh');
343
- const installStamp = join(cacheDir, 'claude-skills-install-check.json');
344
- const notifyStamp = join(cacheDir, 'claude-skills-version-check.json');
345
- const settingsPath = join(claudeDir, 'settings.json');
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]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || exit 0
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]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || exit 0
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
+ }
@@ -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.10.1",
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
+ }