@impulselab/directory 1.0.6 → 1.0.7

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 +124 -31
  2. package/package.json +7 -7
package/index.js CHANGED
@@ -133,6 +133,60 @@ function shouldUseHttps(baseUrl) {
133
133
  return true;
134
134
  }
135
135
 
136
+ function toKebabCase(input) {
137
+ return String(input)
138
+ .normalize('NFKD') // strip accents
139
+ .replace(/[\u0300-\u036f]/g, '')
140
+ .replace(/['"]/g, '')
141
+ .replace(/[^a-zA-Z0-9]+/g, '-') // collapse non-alnum to dashes
142
+ .replace(/^-+|-+$/g, '')
143
+ .toLowerCase();
144
+ }
145
+
146
+ function stripTrailingIdSegment(slug) {
147
+ const normalized = toKebabCase(slug || '');
148
+ const segments = normalized.split('-');
149
+ if (segments.length <= 1) return normalized;
150
+ const last = segments[segments.length - 1];
151
+ if (/^[a-z0-9]{5,}$/i.test(last)) {
152
+ segments.pop();
153
+ }
154
+ return segments.join('-');
155
+ }
156
+
157
+ function sanitizeIdentifierForFilename(identifier) {
158
+ return String(identifier || '')
159
+ .normalize('NFKD')
160
+ .replace(/[\u0300-\u036f]/g, '') // remove accents
161
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '') // remove control chars
162
+ .replace(/[<>:"/\\|?*]/g, '') // remove filesystem unsafe chars
163
+ .replace(/\.\./g, '') // remove parent directory traversal
164
+ .replace(/[/\\]+/g, '-') // replace all path separators with dashes
165
+ .replace(/^\.+/g, '') // remove leading dots
166
+ .replace(/\.+$/g, '') // remove trailing dots
167
+ .replace(/^-+|-+$/g, '') // trim edge dashes
168
+ .replace(/-+/g, '-') // collapse multiple dashes
169
+ .toLowerCase()
170
+ || 'unknown'; // fallback for empty results
171
+ }
172
+
173
+ function generateUniqueFilename(directory, baseName, extension) {
174
+ const baseFilename = `${baseName}${extension}`;
175
+
176
+ if (!checkFileExists(directory, baseFilename)) {
177
+ return baseFilename;
178
+ }
179
+
180
+ let counter = 1;
181
+ let candidate = `${baseName}-${counter}${extension}`;
182
+ while (checkFileExists(directory, candidate)) {
183
+ counter += 1;
184
+ candidate = `${baseName}-${counter}${extension}`;
185
+ }
186
+
187
+ return candidate;
188
+ }
189
+
136
190
  function downloadCommand(baseUrl, fullPath) {
137
191
  return new Promise((resolve, reject) => {
138
192
  const useHttps = shouldUseHttps(baseUrl);
@@ -290,6 +344,58 @@ function downloadStackWithHttp(baseUrl, stackId) {
290
344
  });
291
345
  }
292
346
 
347
+ function downloadRawWithHeaders(baseUrl, fullPath) {
348
+ return new Promise((resolve, reject) => {
349
+ const useHttps = shouldUseHttps(baseUrl);
350
+ const protocol = useHttps ? https : http;
351
+ const scheme = useHttps ? 'https' : 'http';
352
+ const url = `${scheme}://${baseUrl}${RAW_PATH}${fullPath}`;
353
+
354
+ const handleOk = (res) => {
355
+ let data = '';
356
+ res.on('data', (chunk) => { data += chunk; });
357
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
358
+ };
359
+
360
+ const handleHttpsFailover = (reason) => {
361
+ if (useHttps && canFallbackToHttp(baseUrl)) {
362
+ console.log(`HTTPS failed (${reason}), trying HTTP...`);
363
+ downloadRawWithHeadersHttp(baseUrl, fullPath).then(resolve).catch(reject);
364
+ } else {
365
+ reject(new Error(typeof reason === 'string' ? reason : reason.message));
366
+ }
367
+ };
368
+
369
+ protocol
370
+ .get(url, (res) => {
371
+ if (res.statusCode === 200) return handleOk(res);
372
+ if (res.statusCode === 404) return reject(new Error(`Command '${fullPath}' not found (404)`));
373
+ return handleHttpsFailover(`${res.statusCode}`);
374
+ })
375
+ .on('error', handleHttpsFailover);
376
+ });
377
+ }
378
+
379
+ function downloadRawWithHeadersHttp(baseUrl, fullPath) {
380
+ return new Promise((resolve, reject) => {
381
+ const url = `http://${baseUrl}${RAW_PATH}${fullPath}`;
382
+ console.log(`Trying HTTP: ${url}`);
383
+ http
384
+ .get(url, (res) => {
385
+ if (res.statusCode === 200) {
386
+ let data = '';
387
+ res.on('data', (chunk) => { data += chunk; });
388
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
389
+ } else if (res.statusCode === 404) {
390
+ reject(new Error(`Command '${fullPath}' not found (404)`));
391
+ } else {
392
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
393
+ }
394
+ })
395
+ .on('error', reject);
396
+ });
397
+ }
398
+
293
399
  function findClosestFolder(folderName) {
294
400
  let currentDir = process.cwd();
295
401
 
@@ -505,13 +611,19 @@ async function main() {
505
611
 
506
612
  ensureDirectoryExists(directory);
507
613
 
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;
614
+ const commandNameFromPath = identifier.split('/').pop();
615
+ // First download the content to capture headers (and title)
616
+ const { content, headers } = await downloadRawWithHeaders(baseUrl, identifier);
617
+ const titleHeader = headers['x-prompt-title'];
618
+ const title = Array.isArray(titleHeader) ? titleHeader[0] : titleHeader;
513
619
 
514
- const content = await downloadCommand(baseUrl, identifier);
620
+ const baseNameCandidate = title
621
+ ? toKebabCase(title)
622
+ : stripTrailingIdSegment(commandNameFromPath);
623
+ const finalBaseName = sanitizeIdentifierForFilename(baseNameCandidate);
624
+
625
+ // Overwrite if exists; do not append author or random suffix
626
+ const filename = `${finalBaseName}${targetConfig.extension}`;
515
627
 
516
628
  saveCommand({
517
629
  directory,
@@ -547,7 +659,9 @@ async function main() {
547
659
  for (let index = 0; index < prompts.length; index += 1) {
548
660
  const prompt = prompts[index];
549
661
  const commandPath = prompt.path || prompt.slug || prompt.id || `prompt-${index + 1}`;
550
- const baseName = prompt.slug || `prompt-${index + 1}`;
662
+ const baseName = prompt.title
663
+ ? toKebabCase(prompt.title)
664
+ : stripTrailingIdSegment(prompt.slug || `prompt-${index + 1}`);
551
665
 
552
666
  const resolvedTarget = target || prompt.target || DEFAULT_TARGET;
553
667
  const targetConfig = TARGET_CONFIG[resolvedTarget];
@@ -560,30 +674,9 @@ async function main() {
560
674
  const directory = targetConfig.resolveDirectory();
561
675
  ensureDirectoryExists(directory);
562
676
 
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
- }
677
+ // Sanitize and overwrite if exists (no author or random suffix)
678
+ const sanitizedBaseName = sanitizeIdentifierForFilename(baseName);
679
+ const filename = `${sanitizedBaseName}${targetConfig.extension}`;
587
680
 
588
681
  if (!prompt.markdown) {
589
682
  console.warn(`⚠️ Skipping prompt '${commandPath}' (no content available)`);
package/package.json CHANGED
@@ -1,16 +1,11 @@
1
1
  {
2
2
  "name": "@impulselab/directory",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Download and install Claude commands from Impulse Directory",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "directory": "./index.js"
8
8
  },
9
- "scripts": {
10
- "test": "node index.js --help",
11
- "predeploy": "chmod +x index.js",
12
- "deploy": "pnpm publish --access public --no-git-checks"
13
- },
14
9
  "keywords": [
15
10
  "claude",
16
11
  "commands",
@@ -25,5 +20,10 @@
25
20
  },
26
21
  "publishConfig": {
27
22
  "access": "public"
23
+ },
24
+ "scripts": {
25
+ "test": "node index.js --help",
26
+ "predeploy": "chmod +x index.js",
27
+ "deploy": "pnpm publish --access public --no-git-checks"
28
28
  }
29
- }
29
+ }