@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.
- package/index.js +152 -105
- 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
|
|
137
|
-
return
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
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)`);
|