@trendai-crem/claude-skills 0.9.0 → 0.10.1

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.
Files changed (3) hide show
  1. package/cli.js +163 -51
  2. package/marketplace.json +19 -11
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -13,6 +13,8 @@ const EXTERNAL_SOURCES = [
13
13
  { repo: 'obra/superpowers', flags: ['--all'], label: 'superpowers' },
14
14
  ];
15
15
 
16
+ const MANIFEST_PATH = join(homedir(), '.claude', 'claude-skills-manifest.json');
17
+
16
18
  const run = (args, label) => {
17
19
  try {
18
20
  execFileSync('npx', args, { stdio: 'inherit' });
@@ -25,19 +27,36 @@ const run = (args, label) => {
25
27
 
26
28
  console.log('Installing team skills...\n');
27
29
 
28
- // 1. External skills — failures are non-fatal
29
- const externalResults = EXTERNAL_SOURCES.map(({ repo, flags, label }) =>
30
- ({ label, ok: run(['skills', 'add', repo, ...flags, '-g', '-y'], label) })
31
- );
32
-
33
30
  // 2. Team skills — required, overrides same-named externals
34
31
  const teamSkills = readdirSync(join(__dir, 'skills'), { withFileTypes: true })
35
32
  .filter(e => e.isDirectory())
36
33
  .map(e => e.name)
37
34
  .sort();
38
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
+
39
52
  const teamOk = run(['skills', 'add', __dir, '--all', '-g', '-y'], 'team skills');
40
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
+
41
60
  // 3. Marketplace plugins — failures are non-fatal
42
61
  let marketplaceResults = [];
43
62
  try {
@@ -48,6 +67,7 @@ try {
48
67
 
49
68
  // Summary
50
69
  console.log('\nResults:');
70
+ staleSkillResults.forEach(({ skill, action, ok }) => console.log(` ${ok ? '✓' : '✗'} ${skill} (${action})`));
51
71
  externalResults.forEach(({ label, ok }) => console.log(` ${ok ? '✓' : '✗'} ${label}`));
52
72
  if (teamOk) {
53
73
  teamSkills.forEach(name => console.log(` ✓ ${name}`));
@@ -90,21 +110,63 @@ function readJsonObject(filePath, fallback, label) {
90
110
  return fallback;
91
111
  }
92
112
 
93
- // Extracts the registered source URL for a marketplace from known_marketplaces.json.
94
- // Handles both string entries and object entries with a `source` field.
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
+ }
128
+
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 }));
143
+ }
144
+ }
145
+
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" } }.
95
149
  function getRegisteredMarketplaceSource(known, marketplaceName) {
96
150
  const entry = known[marketplaceName];
97
151
  if (typeof entry === 'string') return entry;
98
152
  if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
99
- return typeof entry.source === 'string' ? entry.source : null;
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
+ }
100
158
  }
101
159
  return null;
102
160
  }
103
161
 
104
- // Normalizes a marketplace source URL to a canonical host/path form for comparison.
105
- // Treats SSH and HTTPS URLs pointing to the same repo as equivalent.
106
- // Returns null if the URL cannot be parsed.
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.
107
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
+ }
108
170
  const sshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/.exec(rawSource);
109
171
  if (sshMatch) {
110
172
  return `${sshMatch[1].toLowerCase()}/${sshMatch[2].replace(/^\/+/, '').replace(/\.git$/, '')}`;
@@ -117,58 +179,42 @@ function normalizeMarketplaceSource(rawSource) {
117
179
  }
118
180
  }
119
181
 
120
- function installMarketplacePlugins() {
121
- const configPath = join(__dir, 'marketplace.json');
122
- if (!existsSync(configPath)) return [];
123
-
124
- // Parse and validate marketplace.json — all failures are non-fatal (SCD-7)
125
- const config = readJsonObject(configPath, null, 'marketplace.json');
126
- const { marketplace, plugins } = config ?? {};
127
-
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) {
128
185
  // Input validation + allowlisting (SCD-1, OWASP-A04)
129
186
  // Leading `-` is rejected to prevent argv injection: `claude plugin install -s` parses as flag.
130
187
  const SAFE_NAME = /^(?!-)[A-Za-z0-9_][A-Za-z0-9_-]*$/;
131
188
  const SAFE_SOURCE = /^(git@[\w.-]+:[\w.\-/]+\.git|https:\/\/[\w.-]+\/[\w.\-/]+\.git)$/;
189
+ const SAFE_GITHUB = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
190
+
191
+ const { name: marketplaceName, source, plugins } = entry ?? {};
132
192
 
133
- if (
134
- typeof marketplace?.name !== 'string' ||
135
- typeof marketplace?.source !== 'string' ||
136
- !Array.isArray(plugins)
137
- ) {
138
- console.error('\nInvalid marketplace.json: missing required fields (marketplace.name, marketplace.source, plugins[])');
193
+ if (typeof marketplaceName !== 'string' || typeof source !== 'string' || !Array.isArray(plugins)) {
194
+ console.error('\nInvalid marketplace entry: missing required fields (name, source, plugins[])');
139
195
  return [];
140
196
  }
141
- if (!SAFE_NAME.test(marketplace.name) || !SAFE_SOURCE.test(marketplace.source)) {
142
- console.error('\nInvalid marketplace name or source URL in marketplace.json');
197
+ if (!SAFE_NAME.test(marketplaceName) || (!SAFE_SOURCE.test(source) && !SAFE_GITHUB.test(source))) {
198
+ console.error(`\nInvalid marketplace name or source: ${marketplaceName}`);
143
199
  return [];
144
200
  }
145
- // Reject the entire config on any invalid plugin name (fail-closed, not silent filter).
201
+ // Reject the entire entry on any invalid plugin name (fail-closed, not silent filter).
146
202
  const invalidPlugins = plugins.filter(p => typeof p !== 'string' || !SAFE_NAME.test(p));
147
203
  if (invalidPlugins.length > 0) {
148
- console.error(`\nInvalid plugin names in marketplace.json: ${invalidPlugins.map(p => JSON.stringify(p)).join(', ')}`);
204
+ console.error(`\nInvalid plugin names for ${marketplaceName}: ${invalidPlugins.map(p => JSON.stringify(p)).join(', ')}`);
149
205
  return [];
150
206
  }
151
- const validPlugins = plugins;
152
207
 
153
- const { name: marketplaceName, source } = marketplace;
154
208
  console.log(`\nInstalling marketplace plugins (${marketplaceName})...\n`);
155
209
 
156
210
  // Register marketplace if not already known — verify source matches to prevent supply-chain mismatch (OWASP-A08)
157
- const knownPath = join(homedir(), '.claude', 'plugins', 'known_marketplaces.json');
158
- // Distinguish "file missing" (first-run, safe) from "file corrupted" (fail-closed).
159
- const knownExists = existsSync(knownPath);
160
- const known = readJsonObject(knownPath, knownExists ? null : {}, 'known_marketplaces.json');
161
- if (knownExists && known === null) {
162
- console.error('\nInvalid known_marketplaces.json: refusing to register marketplace automatically');
163
- return validPlugins.map(plugin => ({ plugin, action: 'skipped (invalid marketplace registry)', ok: false }));
164
- }
165
211
  const registeredSource = getRegisteredMarketplaceSource(known, marketplaceName);
166
212
  const normalizedSource = normalizeMarketplaceSource(source);
167
213
  const normalizedRegistered = registeredSource ? normalizeMarketplaceSource(registeredSource) : null;
168
214
 
169
215
  if (registeredSource && (!normalizedRegistered || normalizedRegistered !== normalizedSource)) {
170
216
  console.error(`\nMarketplace source mismatch for ${marketplaceName}: registered as ${registeredSource}, but marketplace.json specifies ${source}`);
171
- return validPlugins.map(plugin => ({ plugin, action: 'skipped (source mismatch)', ok: false }));
217
+ return plugins.map(plugin => ({ plugin, action: 'skipped (source mismatch)', ok: false }));
172
218
  }
173
219
 
174
220
  if (!registeredSource) {
@@ -179,7 +225,7 @@ function installMarketplacePlugins() {
179
225
  } catch (error) {
180
226
  const exitInfo = error.status != null ? `exit ${error.status}` : error.code ?? error.message;
181
227
  console.error(`\nFailed to register marketplace: ${marketplaceName} (${exitInfo})`);
182
- return validPlugins.map(plugin => ({ plugin, action: 'skipped (no marketplace)', ok: false }));
228
+ return plugins.map(plugin => ({ plugin, action: 'skipped (no marketplace)', ok: false }));
183
229
  }
184
230
  }
185
231
 
@@ -191,16 +237,7 @@ function installMarketplacePlugins() {
191
237
  console.warn(`\nWARN: Failed to update marketplace ${marketplaceName} (${exitInfo}) — using cached version`);
192
238
  }
193
239
 
194
- // Load installed plugin registry (SCD-7)
195
- const installedPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
196
- const installedData = readJsonObject(installedPath, {}, 'installed_plugins.json');
197
- // installed_plugins.json v2 nests under "plugins", v1 is flat
198
- const rawInstalled = installedData.plugins ?? installedData;
199
- const installed = (typeof rawInstalled === 'object' && rawInstalled !== null && !Array.isArray(rawInstalled))
200
- ? rawInstalled
201
- : {};
202
-
203
- return validPlugins.map(plugin => {
240
+ return plugins.map(plugin => {
204
241
  const key = `${plugin}@${marketplaceName}`;
205
242
  const isInstalled = Object.hasOwn(installed, key); // Object.hasOwn avoids prototype chain traversal (SCD-7)
206
243
  const action = isInstalled ? 'update' : 'install';
@@ -218,6 +255,81 @@ function installMarketplacePlugins() {
218
255
  });
219
256
  }
220
257
 
258
+ function installMarketplacePlugins() {
259
+ const configPath = join(__dir, 'marketplace.json');
260
+ if (!existsSync(configPath)) return [];
261
+
262
+ // Parse marketplace.json — all 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];
307
+ }
308
+
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 [];
317
+
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
+ }
332
+
221
333
  function setupAutoUpdate() {
222
334
  const { version: installedVersion } = JSON.parse(
223
335
  readFileSync(join(__dir, 'package.json'), 'utf8')
package/marketplace.json CHANGED
@@ -1,14 +1,22 @@
1
1
  {
2
- "marketplace": {
3
- "name": "ai-skill-marketplace",
4
- "source": "git@github.com:trend-ai-taskforce/ai-skill-marketplace.git"
5
- },
6
- "plugins": [
7
- "wiki-tools",
8
- "atlassian-tools",
9
- "google-style-guides",
10
- "l2-automation",
11
- "service-doc-generator",
12
- "claude-on-teams"
2
+ "marketplaces": [
3
+ {
4
+ "name": "ai-skill-marketplace",
5
+ "source": "git@github.com:trend-ai-taskforce/ai-skill-marketplace.git",
6
+ "plugins": [
7
+ "wiki-tools",
8
+ "atlassian-tools",
9
+ "google-style-guides",
10
+ "l2-automation",
11
+ "service-doc-generator"
12
+ ]
13
+ },
14
+ {
15
+ "name": "claude-plugins-official",
16
+ "source": "anthropics/claude-plugins-official",
17
+ "plugins": [
18
+ "ralph-loop"
19
+ ]
20
+ }
13
21
  ]
14
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendai-crem/claude-skills",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Claude Code skills installer for the trendai-crem team",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {