@impulselab/directory 1.0.6 → 1.0.8

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 (2) hide show
  1. package/index.js +152 -105
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -48,6 +48,7 @@ const MODES = {
48
48
  const INSTALLATION_TARGETS = {
49
49
  CLAUDE_SLASH: 'claude-slash',
50
50
  CLAUDE_SUB_AGENT: 'claude-sub-agent',
51
+ CLAUDE_SKILL: 'claude-skill',
51
52
  CURSOR_COMMAND: 'cursor-command',
52
53
  CURSOR_RULE: 'cursor-rule',
53
54
  };
@@ -67,6 +68,13 @@ const TARGET_CONFIG = {
67
68
  resolveDirectory: () => resolveClaudeDirectory('agents'),
68
69
  extension: '.md',
69
70
  },
71
+ [INSTALLATION_TARGETS.CLAUDE_SKILL]: {
72
+ label: 'Claude Code skill',
73
+ description: '.claude/skills (project only)',
74
+ resolveDirectory: () => resolveClaudeSkillsDirectory(),
75
+ extension: '.md',
76
+ isSkill: true,
77
+ },
70
78
  [INSTALLATION_TARGETS.CURSOR_COMMAND]: {
71
79
  label: 'Cursor command',
72
80
  description: '.cursor/commands (project only)',
@@ -133,76 +141,41 @@ function shouldUseHttps(baseUrl) {
133
141
  return true;
134
142
  }
135
143
 
136
- function downloadCommand(baseUrl, fullPath) {
137
- return new Promise((resolve, reject) => {
138
- const useHttps = shouldUseHttps(baseUrl);
139
- const protocol = useHttps ? https : http;
140
- const scheme = useHttps ? 'https' : 'http';
141
-
142
- const url = `${scheme}://${baseUrl}${RAW_PATH}${fullPath}`;
143
-
144
- protocol
145
- .get(url, (res) => {
146
- if (res.statusCode === 200) {
147
- let data = '';
148
- res.on('data', (chunk) => {
149
- data += chunk;
150
- });
151
- res.on('end', () => resolve(data));
152
- } else if (res.statusCode === 404) {
153
- reject(new Error(`Command '${fullPath}' not found (404)`));
154
- } else if (useHttps && (res.statusCode === 500 || res.statusCode >= 400)) {
155
- if (canFallbackToHttp(baseUrl)) {
156
- console.log(`HTTPS failed (${res.statusCode}), trying HTTP...`);
157
- downloadWithHttp(baseUrl, fullPath)
158
- .then(resolve)
159
- .catch(reject);
160
- } else {
161
- reject(new Error(`HTTPS failed (${res.statusCode}) and HTTP fallback is disabled`));
162
- }
163
- } else {
164
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
165
- }
166
- })
167
- .on('error', (err) => {
168
- if (useHttps) {
169
- if (canFallbackToHttp(baseUrl)) {
170
- console.log(`HTTPS failed (${err.message}), trying HTTP...`);
171
- downloadWithHttp(baseUrl, fullPath)
172
- .then(resolve)
173
- .catch(reject);
174
- } else {
175
- reject(err);
176
- }
177
- } else {
178
- reject(err);
179
- }
180
- });
181
- });
144
+ function toKebabCase(input) {
145
+ return String(input)
146
+ .normalize('NFKD') // strip accents
147
+ .replace(/[\u0300-\u036f]/g, '')
148
+ .replace(/['"]/g, '')
149
+ .replace(/[^a-zA-Z0-9]+/g, '-') // collapse non-alnum to dashes
150
+ .replace(/^-+|-+$/g, '')
151
+ .toLowerCase();
182
152
  }
183
153
 
184
- function downloadWithHttp(baseUrl, fullPath) {
185
- return new Promise((resolve, reject) => {
186
- const url = `http://${baseUrl}${RAW_PATH}${fullPath}`;
187
-
188
- console.log(`Trying HTTP: ${url}`);
154
+ function stripTrailingIdSegment(slug) {
155
+ const normalized = toKebabCase(slug || '');
156
+ const segments = normalized.split('-');
157
+ if (segments.length <= 1) return normalized;
158
+ const last = segments[segments.length - 1];
159
+ if (/^[a-z0-9]{5,}$/i.test(last)) {
160
+ segments.pop();
161
+ }
162
+ return segments.join('-');
163
+ }
189
164
 
190
- http
191
- .get(url, (res) => {
192
- if (res.statusCode === 200) {
193
- let data = '';
194
- res.on('data', (chunk) => {
195
- data += chunk;
196
- });
197
- res.on('end', () => resolve(data));
198
- } else if (res.statusCode === 404) {
199
- reject(new Error(`Command '${fullPath}' not found (404)`));
200
- } else {
201
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
202
- }
203
- })
204
- .on('error', reject);
205
- });
165
+ function sanitizeIdentifierForFilename(identifier) {
166
+ return String(identifier || '')
167
+ .normalize('NFKD')
168
+ .replace(/[\u0300-\u036f]/g, '') // remove accents
169
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '') // remove control chars
170
+ .replace(/[<>:"/\\|?*]/g, '') // remove filesystem unsafe chars
171
+ .replace(/\.\./g, '') // remove parent directory traversal
172
+ .replace(/[/\\]+/g, '-') // replace all path separators with dashes
173
+ .replace(/^\.+/g, '') // remove leading dots
174
+ .replace(/\.+$/g, '') // remove trailing dots
175
+ .replace(/^-+|-+$/g, '') // trim edge dashes
176
+ .replace(/-+/g, '-') // collapse multiple dashes
177
+ .toLowerCase()
178
+ || 'unknown'; // fallback for empty results
206
179
  }
207
180
 
208
181
  function downloadStack(baseUrl, stackId) {
@@ -290,6 +263,58 @@ function downloadStackWithHttp(baseUrl, stackId) {
290
263
  });
291
264
  }
292
265
 
266
+ function downloadRawWithHeaders(baseUrl, fullPath) {
267
+ return new Promise((resolve, reject) => {
268
+ const useHttps = shouldUseHttps(baseUrl);
269
+ const protocol = useHttps ? https : http;
270
+ const scheme = useHttps ? 'https' : 'http';
271
+ const url = `${scheme}://${baseUrl}${RAW_PATH}${fullPath}`;
272
+
273
+ const handleOk = (res) => {
274
+ let data = '';
275
+ res.on('data', (chunk) => { data += chunk; });
276
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
277
+ };
278
+
279
+ const handleHttpsFailover = (reason) => {
280
+ if (useHttps && canFallbackToHttp(baseUrl)) {
281
+ console.log(`HTTPS failed (${reason}), trying HTTP...`);
282
+ downloadRawWithHeadersHttp(baseUrl, fullPath).then(resolve).catch(reject);
283
+ } else {
284
+ reject(new Error(typeof reason === 'string' ? reason : reason.message));
285
+ }
286
+ };
287
+
288
+ protocol
289
+ .get(url, (res) => {
290
+ if (res.statusCode === 200) return handleOk(res);
291
+ if (res.statusCode === 404) return reject(new Error(`Command '${fullPath}' not found (404)`));
292
+ return handleHttpsFailover(`${res.statusCode}`);
293
+ })
294
+ .on('error', handleHttpsFailover);
295
+ });
296
+ }
297
+
298
+ function downloadRawWithHeadersHttp(baseUrl, fullPath) {
299
+ return new Promise((resolve, reject) => {
300
+ const url = `http://${baseUrl}${RAW_PATH}${fullPath}`;
301
+ console.log(`Trying HTTP: ${url}`);
302
+ http
303
+ .get(url, (res) => {
304
+ if (res.statusCode === 200) {
305
+ let data = '';
306
+ res.on('data', (chunk) => { data += chunk; });
307
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
308
+ } else if (res.statusCode === 404) {
309
+ reject(new Error(`Command '${fullPath}' not found (404)`));
310
+ } else {
311
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
312
+ }
313
+ })
314
+ .on('error', reject);
315
+ });
316
+ }
317
+
293
318
  function findClosestFolder(folderName) {
294
319
  let currentDir = process.cwd();
295
320
 
@@ -327,6 +352,16 @@ function resolveCursorDirectory(subFolder) {
327
352
  return path.join(projectCursorDir, subFolder);
328
353
  }
329
354
 
355
+ function resolveClaudeSkillsDirectory() {
356
+ const projectClaudeDir = findClosestFolder('.claude');
357
+ if (!projectClaudeDir) {
358
+ const message =
359
+ 'Unable to find a .claude directory. Run this command from inside a project with a .claude folder.';
360
+ throw new Error(message);
361
+ }
362
+ return path.join(projectClaudeDir, 'skills');
363
+ }
364
+
330
365
  function ensureDirectoryExists(dirPath) {
331
366
  if (!fs.existsSync(dirPath)) {
332
367
  fs.mkdirSync(dirPath, { recursive: true });
@@ -346,17 +381,36 @@ function saveCommand({
346
381
  fullPath,
347
382
  target,
348
383
  }) {
349
- const filePath = path.join(directory, filename);
350
- const relativeDir = path.relative(process.cwd(), directory);
351
- const displayPath = relativeDir
352
- ? path.join(relativeDir, filename)
353
- : filename;
384
+ const targetConfig = TARGET_CONFIG[target];
385
+ const isSkill = targetConfig && targetConfig.isSkill;
386
+
387
+ let filePath;
388
+ let displayPath;
389
+ let commandName;
390
+
391
+ if (isSkill) {
392
+ const skillName = filename.replace(path.extname(filename), '');
393
+ const skillDir = path.join(directory, skillName);
394
+ ensureDirectoryExists(skillDir);
395
+ filePath = path.join(skillDir, 'SKILL.md');
396
+ const relativeDir = path.relative(process.cwd(), skillDir);
397
+ displayPath = relativeDir
398
+ ? path.join(relativeDir, 'SKILL.md')
399
+ : path.join(skillName, 'SKILL.md');
400
+ commandName = skillName;
401
+ } else {
402
+ filePath = path.join(directory, filename);
403
+ const relativeDir = path.relative(process.cwd(), directory);
404
+ displayPath = relativeDir
405
+ ? path.join(relativeDir, filename)
406
+ : filename;
407
+ commandName = filename.replace(path.extname(filename), '');
408
+ }
354
409
 
355
- console.log(`šŸ“ Saving command to ${displayPath}`);
410
+ console.log(`šŸ“ Saving ${isSkill ? 'skill' : 'command'} to ${displayPath}`);
356
411
 
357
412
  fs.writeFileSync(filePath, content, 'utf8');
358
413
 
359
- const commandName = filename.replace(path.extname(filename), '');
360
414
  logSuccessMessage(target, fullPath, commandName);
361
415
  }
362
416
 
@@ -372,6 +426,12 @@ function logSuccessMessage(target, fullPath, commandName) {
372
426
  `\nšŸŽ‰ You can now select '${commandName}' from Claude Desktop's agent menu.`
373
427
  );
374
428
  break;
429
+ case INSTALLATION_TARGETS.CLAUDE_SKILL:
430
+ console.log(`āœ… Skill '${fullPath}' installed successfully`);
431
+ console.log(
432
+ `\nšŸŽ‰ Claude Code will now automatically use the '${commandName}' skill when relevant.`
433
+ );
434
+ break;
375
435
  case INSTALLATION_TARGETS.CURSOR_COMMAND:
376
436
  console.log(`āœ… Cursor command '${fullPath}' installed successfully`);
377
437
  console.log(
@@ -505,13 +565,19 @@ async function main() {
505
565
 
506
566
  ensureDirectoryExists(directory);
507
567
 
508
- const commandName = identifier.split('/').pop();
509
- const simpleFilename = `${commandName}${targetConfig.extension}`;
510
- const fallbackFilename = `${identifier.replace('/', '-')}${targetConfig.extension}`;
511
- const fileExists = checkFileExists(directory, simpleFilename);
512
- const filename = fileExists ? fallbackFilename : simpleFilename;
568
+ const commandNameFromPath = identifier.split('/').pop();
569
+ // First download the content to capture headers (and title)
570
+ const { content, headers } = await downloadRawWithHeaders(baseUrl, identifier);
571
+ const titleHeader = headers['x-prompt-title'];
572
+ const title = Array.isArray(titleHeader) ? titleHeader[0] : titleHeader;
573
+
574
+ const baseNameCandidate = title
575
+ ? toKebabCase(title)
576
+ : stripTrailingIdSegment(commandNameFromPath);
577
+ const finalBaseName = sanitizeIdentifierForFilename(baseNameCandidate);
513
578
 
514
- const content = await downloadCommand(baseUrl, identifier);
579
+ // Overwrite if exists; do not append author or random suffix
580
+ const filename = `${finalBaseName}${targetConfig.extension}`;
515
581
 
516
582
  saveCommand({
517
583
  directory,
@@ -547,7 +613,9 @@ async function main() {
547
613
  for (let index = 0; index < prompts.length; index += 1) {
548
614
  const prompt = prompts[index];
549
615
  const commandPath = prompt.path || prompt.slug || prompt.id || `prompt-${index + 1}`;
550
- const baseName = prompt.slug || `prompt-${index + 1}`;
616
+ const baseName = prompt.title
617
+ ? toKebabCase(prompt.title)
618
+ : stripTrailingIdSegment(prompt.slug || `prompt-${index + 1}`);
551
619
 
552
620
  const resolvedTarget = target || prompt.target || DEFAULT_TARGET;
553
621
  const targetConfig = TARGET_CONFIG[resolvedTarget];
@@ -560,30 +628,9 @@ async function main() {
560
628
  const directory = targetConfig.resolveDirectory();
561
629
  ensureDirectoryExists(directory);
562
630
 
563
- const simpleFilename = `${baseName}${targetConfig.extension}`;
564
- const fallbackBase = stack.slug
565
- ? `${stack.slug}-${baseName}`
566
- : `${stack.id || identifier}-${baseName}`;
567
- const fallbackFilename = `${fallbackBase}${targetConfig.extension}`;
568
-
569
- const simpleExists = checkFileExists(directory, simpleFilename);
570
- const fallbackExists = checkFileExists(directory, fallbackFilename);
571
-
572
- let filename = simpleFilename;
573
- if (simpleExists) {
574
- filename = fallbackFilename;
575
- }
576
- if ((simpleExists && fallbackExists) || checkFileExists(directory, filename)) {
577
- const baseForUniq = fallbackBase;
578
- const ext = targetConfig.extension;
579
- let counter = 1;
580
- let candidate = `${baseForUniq}-${counter}${ext}`;
581
- while (checkFileExists(directory, candidate)) {
582
- counter += 1;
583
- candidate = `${baseForUniq}-${counter}${ext}`;
584
- }
585
- filename = candidate;
586
- }
631
+ // Sanitize and overwrite if exists (no author or random suffix)
632
+ const sanitizedBaseName = sanitizeIdentifierForFilename(baseName);
633
+ const filename = `${sanitizedBaseName}${targetConfig.extension}`;
587
634
 
588
635
  if (!prompt.markdown) {
589
636
  console.warn(`āš ļø Skipping prompt '${commandPath}' (no content available)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impulselab/directory",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Download and install Claude commands from Impulse Directory",
5
5
  "main": "index.js",
6
6
  "bin": {