@impulselab/directory 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.
Files changed (3) hide show
  1. package/README.md +144 -0
  2. package/index.js +608 -0
  3. package/package.json +28 -0
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Impulse Directory Command Installer
2
+
3
+ An NPX command that downloads prompts from Impulse Directory and installs them for Claude Desktop or Cursor.
4
+
5
+ ## 🚀 Installation and Usage
6
+
7
+ ### Direct usage with npx
8
+
9
+ ```bash
10
+ npx impulse-directory <user-slug>/<command-slug> [--target <target>]
11
+ npx impulse-directory stack <stack-id> [--target <target>]
12
+ ```
13
+
14
+ `--target` selects the installation target. When omitted the command defaults to `claude-slash`.
15
+
16
+ When using the `stack` command, all prompts in the stack are installed for the chosen tool. Stacks must be constrained to a specific tool to be installed via NPX.
17
+
18
+ ### Targets
19
+
20
+ | Target | Description | Destination |
21
+ |--------|-------------|-------------|
22
+ | `claude-slash` | Claude slash command | `.claude/commands` (falls back to `~/.claude/commands`)
23
+ | `claude-sub-agent` | Claude sub agent | `.claude/agents` (falls back to `~/.claude/agents`)
24
+ | `cursor-command` | Cursor command | `.cursor/commands` (project only)
25
+ | `cursor-rule` | Cursor rule | `.cursor/rules` (project only)
26
+
27
+ > **Note**: Cursor targets require running the installer inside a Cursor project (a folder containing `.cursor/`). The command fails if no project folder is found.
28
+
29
+ ### Examples
30
+
31
+ ```bash
32
+ # Download a Claude slash command from impulse.directory
33
+ npx impulse-directory john/my-awesome-command
34
+
35
+ # Install as a Claude sub agent
36
+ npx impulse-directory john/my-awesome-command --target claude-sub-agent
37
+
38
+ # Install as a Cursor command (inside a Cursor project)
39
+ npx impulse-directory john/my-awesome-command --target cursor-command
40
+
41
+ # Install as a Cursor rule (inside a Cursor project)
42
+ npx impulse-directory john/my-awesome-command --target cursor-rule
43
+
44
+ # Install an Impulse stack (tool-specific stack ID)
45
+ npx impulse-directory stack 01JH0000000ABCDEF --target claude-slash
46
+
47
+ # Use a custom URL (local development)
48
+ IMPULSE_BASE_URL=localhost:3000 npx impulse-directory dev-user/dev-command
49
+
50
+ # Use another server
51
+ IMPULSE_BASE_URL=my-custom-domain.com npx impulse-directory author/custom-command
52
+ ```
53
+
54
+ ## 📁 Command Placement Logic
55
+
56
+ ### Claude targets (`claude-slash`, `claude-sub-agent`)
57
+
58
+ The command searches for the `.claude` folder in the following order:
59
+
60
+ 1. **Current folder**: Looks for `.claude/<target>/` in the current working directory
61
+ 2. **Parent folders**: Goes up the tree to find a `.claude` folder
62
+ 3. **Fallback home**: Uses `~/.claude/<target>/` if no project folder is found
63
+
64
+ This allows:
65
+ - Installing commands at the project level (shared with the team)
66
+ - Installing personal commands (user only)
67
+
68
+ ### Cursor targets (`cursor-command`, `cursor-rule`)
69
+
70
+ - Must be executed inside a Cursor project (a directory containing `.cursor/`)
71
+ - Always installs into the project `.cursor` folder
72
+ - Fails with an explicit message if no Cursor project is detected
73
+
74
+ ## 🔧 Configuration
75
+
76
+ ### Environment Variables
77
+
78
+ | Variable | Description | Default |
79
+ |----------|-------------|--------|
80
+ | `IMPULSE_BASE_URL` | Base URL to download commands | `impulse.directory` |
81
+
82
+ ### Automatic HTTPS/HTTP Handling
83
+
84
+ - **HTTPS by default**: All URLs use HTTPS
85
+ - **Fallback HTTP**: If HTTPS fails, the command automatically tries HTTP
86
+ - **Localhost auto-HTTP**: URLs containing `localhost` or `127.0.0.1` use HTTP directly
87
+
88
+ ## 📝 Command Format
89
+
90
+ Downloaded prompts are saved as `<command-slug>.md`. Stack installs will save each command in the stack using the prompt slug (with a stack-prefixed fallback when needed).
91
+
92
+ Example download URL:
93
+ ```
94
+ https://impulse.directory/raw/user-slug/command-slug
95
+ ```
96
+
97
+ Stack installation payloads are fetched from:
98
+
99
+ ```
100
+ https://impulse.directory/api/stacks/<stack-id>/npx
101
+ ```
102
+
103
+ ## 🛠️ Development
104
+
105
+ ### Local Test
106
+
107
+ ```bash
108
+ # From the npx/impulse-claude/ folder
109
+ node index.js --help
110
+ node index.js test-user/test-command
111
+ ```
112
+
113
+ ### File Structure
114
+
115
+ ```
116
+ npx/impulse-claude/
117
+ ├── package.json # NPM configuration
118
+ ├── index.js # Main script
119
+ └── README.md # Documentation
120
+ ```
121
+
122
+ ## 📚 Using Downloaded Prompts
123
+
124
+ - **Claude slash commands**: use `/command-name` inside Claude Desktop.
125
+ - **Claude sub agents**: open Claude Desktop and select the agent from the agents list.
126
+ - **Cursor commands**: trigger Cursor's command palette and run the command by name.
127
+ - **Cursor rules**: Cursor loads rules automatically from `.cursor/rules` for the project.
128
+
129
+ ## 🔍 Troubleshooting
130
+
131
+ ### Common Errors
132
+
133
+ 1. **Command not found (404)**: Check the command slug
134
+ 2. **Network error**: Check your connection and the base URL
135
+ 3. **Permissions**: Make sure you have write permissions in the destination folder
136
+ 4. **Cursor project missing**: Ensure you're running the command inside a folder containing `.cursor/`
137
+
138
+ ### Debug
139
+
140
+ To see download details:
141
+ ```bash
142
+ # The command automatically displays the download path and destination folder
143
+ npx impulse-directory debug-user/debug-command
144
+ ```
package/index.js ADDED
@@ -0,0 +1,608 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const http = require('http');
7
+ const os = require('os');
8
+
9
+ const DEFAULT_BASE_URL = 'impulse.directory';
10
+ const RAW_PATH = '/raw/';
11
+ const STACK_API_PREFIX = '/api/stacks/';
12
+ const STACK_API_SUFFIX = '/npx';
13
+
14
+ function normalizeBaseHost(input) {
15
+ const raw = (input || '').trim();
16
+ if (!raw) return '';
17
+ let host = raw.replace(/^\s*https?:\/\//i, '');
18
+ host = host.replace(/\/+$/, '');
19
+ return host;
20
+ }
21
+
22
+ function isLocalHost(host) {
23
+ const hostnameOnly = (host || '').split(':')[0];
24
+ return (
25
+ hostnameOnly === 'localhost' ||
26
+ hostnameOnly === '127.0.0.1' ||
27
+ hostnameOnly === '::1'
28
+ );
29
+ }
30
+
31
+ function canFallbackToHttp(host) {
32
+ return process.env.ALLOW_HTTP_FALLBACK === '1' || isLocalHost(host);
33
+ }
34
+
35
+ const MODES = {
36
+ COMMAND: 'command',
37
+ STACK: 'stack',
38
+ };
39
+
40
+ const INSTALLATION_TARGETS = {
41
+ CLAUDE_SLASH: 'claude-slash',
42
+ CLAUDE_SUB_AGENT: 'claude-sub-agent',
43
+ CURSOR_COMMAND: 'cursor-command',
44
+ CURSOR_RULE: 'cursor-rule',
45
+ };
46
+
47
+ const DEFAULT_TARGET = INSTALLATION_TARGETS.CLAUDE_SLASH;
48
+
49
+ const TARGET_CONFIG = {
50
+ [INSTALLATION_TARGETS.CLAUDE_SLASH]: {
51
+ label: 'Claude slash command',
52
+ description: '.claude/commands (falls back to ~/.claude/commands)',
53
+ resolveDirectory: () => resolveClaudeDirectory('commands'),
54
+ extension: '.md',
55
+ },
56
+ [INSTALLATION_TARGETS.CLAUDE_SUB_AGENT]: {
57
+ label: 'Claude sub agent',
58
+ description: '.claude/agents (falls back to ~/.claude/agents)',
59
+ resolveDirectory: () => resolveClaudeDirectory('agents'),
60
+ extension: '.md',
61
+ },
62
+ [INSTALLATION_TARGETS.CURSOR_COMMAND]: {
63
+ label: 'Cursor command',
64
+ description: '.cursor/commands (project only)',
65
+ resolveDirectory: () => resolveCursorDirectory('commands'),
66
+ extension: '.md',
67
+ },
68
+ [INSTALLATION_TARGETS.CURSOR_RULE]: {
69
+ label: 'Cursor rule',
70
+ description: '.cursor/rules (project only)',
71
+ resolveDirectory: () => resolveCursorDirectory('rules'),
72
+ extension: '.mdc',
73
+ },
74
+ };
75
+
76
+ const KNOWN_TARGETS = Object.keys(TARGET_CONFIG);
77
+
78
+ function showHelp() {
79
+ console.log(`
80
+ Impulse Claude Command Installer
81
+
82
+ Usage:
83
+ npx impulse-directory <user-slug>/<command-slug> [--target <target>]
84
+ npx impulse-directory stack <stack-id>
85
+
86
+ Description:
87
+ Downloads a command from Impulse Directory and installs it in the right tool folder.
88
+ When using the stack mode, installs each prompt in the stack to its inferred target.
89
+
90
+ Arguments:
91
+ user-slug/command-slug The full path to the command (user/command)
92
+ stack-id The stack ULID shown in the Impulse Directory UI
93
+
94
+ Options:
95
+ --target <target> Installation target (optional for single commands)
96
+ -t <target> Short alias for --target
97
+ --help, -h Show this help text
98
+
99
+ Targets:
100
+ ${KNOWN_TARGETS.map((key) => {
101
+ const config = TARGET_CONFIG[key];
102
+ const defaultLabel = key === DEFAULT_TARGET ? ' (default)' : '';
103
+ return ` ${key.padEnd(18)} ${config.label}${defaultLabel}\n -> ${config.description}`;
104
+ }).join('\n')}
105
+
106
+ Environment Variables:
107
+ IMPULSE_BASE_URL Base host for Impulse Directory (hostname[:port], no scheme, no trailing slash)
108
+ Examples: impulse.directory | localhost:3000
109
+
110
+ Examples:
111
+ npx impulse-directory john/my-awesome-command
112
+ npx impulse-directory john/my-awesome-command --target claude-sub-agent
113
+ npx impulse-directory john/my-awesome-command --target cursor-command
114
+ npx impulse-directory stack 01JH0000000ABCDEF --target claude-slash
115
+ `);
116
+ }
117
+
118
+ function shouldUseHttps(baseUrl) {
119
+ if (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1')) {
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ function downloadCommand(baseUrl, fullPath) {
126
+ return new Promise((resolve, reject) => {
127
+ const useHttps = shouldUseHttps(baseUrl);
128
+ const protocol = useHttps ? https : http;
129
+ const scheme = useHttps ? 'https' : 'http';
130
+
131
+ const url = `${scheme}://${baseUrl}${RAW_PATH}${fullPath}`;
132
+
133
+ protocol
134
+ .get(url, (res) => {
135
+ if (res.statusCode === 200) {
136
+ let data = '';
137
+ res.on('data', (chunk) => {
138
+ data += chunk;
139
+ });
140
+ res.on('end', () => resolve(data));
141
+ } else if (res.statusCode === 404) {
142
+ reject(new Error(`Command '${fullPath}' not found (404)`));
143
+ } else if (useHttps && (res.statusCode === 500 || res.statusCode >= 400)) {
144
+ if (canFallbackToHttp(baseUrl)) {
145
+ console.log(`HTTPS failed (${res.statusCode}), trying HTTP...`);
146
+ downloadWithHttp(baseUrl, fullPath)
147
+ .then(resolve)
148
+ .catch(reject);
149
+ } else {
150
+ reject(new Error(`HTTPS failed (${res.statusCode}) and HTTP fallback is disabled`));
151
+ }
152
+ } else {
153
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
154
+ }
155
+ })
156
+ .on('error', (err) => {
157
+ if (useHttps) {
158
+ if (canFallbackToHttp(baseUrl)) {
159
+ console.log(`HTTPS failed (${err.message}), trying HTTP...`);
160
+ downloadWithHttp(baseUrl, fullPath)
161
+ .then(resolve)
162
+ .catch(reject);
163
+ } else {
164
+ reject(err);
165
+ }
166
+ } else {
167
+ reject(err);
168
+ }
169
+ });
170
+ });
171
+ }
172
+
173
+ function downloadWithHttp(baseUrl, fullPath) {
174
+ return new Promise((resolve, reject) => {
175
+ const url = `http://${baseUrl}${RAW_PATH}${fullPath}`;
176
+
177
+ console.log(`Trying HTTP: ${url}`);
178
+
179
+ http
180
+ .get(url, (res) => {
181
+ if (res.statusCode === 200) {
182
+ let data = '';
183
+ res.on('data', (chunk) => {
184
+ data += chunk;
185
+ });
186
+ res.on('end', () => resolve(data));
187
+ } else if (res.statusCode === 404) {
188
+ reject(new Error(`Command '${fullPath}' not found (404)`));
189
+ } else {
190
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
191
+ }
192
+ })
193
+ .on('error', reject);
194
+ });
195
+ }
196
+
197
+ function downloadStack(baseUrl, stackId) {
198
+ return new Promise((resolve, reject) => {
199
+ const useHttps = shouldUseHttps(baseUrl);
200
+ const protocol = useHttps ? https : http;
201
+ const scheme = useHttps ? 'https' : 'http';
202
+ const url = `${scheme}://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
203
+
204
+ protocol
205
+ .get(url, (res) => {
206
+ if (res.statusCode === 200) {
207
+ let data = '';
208
+ res.on('data', (chunk) => {
209
+ data += chunk;
210
+ });
211
+ res.on('end', () => {
212
+ try {
213
+ const parsed = JSON.parse(data);
214
+ resolve(parsed);
215
+ } catch (parseError) {
216
+ reject(new Error('Received invalid JSON while downloading stack'));
217
+ }
218
+ });
219
+ } else if (res.statusCode === 404) {
220
+ reject(new Error(`Stack '${stackId}' not found (404)`));
221
+ } else if (useHttps && (res.statusCode === 500 || res.statusCode >= 400)) {
222
+ if (canFallbackToHttp(baseUrl)) {
223
+ console.log(`HTTPS failed (${res.statusCode}), trying HTTP...`);
224
+ downloadStackWithHttp(baseUrl, stackId)
225
+ .then(resolve)
226
+ .catch(reject);
227
+ } else {
228
+ reject(new Error(`HTTPS failed (${res.statusCode}) and HTTP fallback is disabled`));
229
+ }
230
+ } else {
231
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
232
+ }
233
+ })
234
+ .on('error', (err) => {
235
+ if (useHttps) {
236
+ if (canFallbackToHttp(baseUrl)) {
237
+ console.log(`HTTPS failed (${err.message}), trying HTTP...`);
238
+ downloadStackWithHttp(baseUrl, stackId)
239
+ .then(resolve)
240
+ .catch(reject);
241
+ } else {
242
+ reject(err);
243
+ }
244
+ } else {
245
+ reject(err);
246
+ }
247
+ });
248
+ });
249
+ }
250
+
251
+ function downloadStackWithHttp(baseUrl, stackId) {
252
+ return new Promise((resolve, reject) => {
253
+ const url = `http://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
254
+
255
+ console.log(`Trying HTTP: ${url}`);
256
+
257
+ http
258
+ .get(url, (res) => {
259
+ if (res.statusCode === 200) {
260
+ let data = '';
261
+ res.on('data', (chunk) => {
262
+ data += chunk;
263
+ });
264
+ res.on('end', () => {
265
+ try {
266
+ const parsed = JSON.parse(data);
267
+ resolve(parsed);
268
+ } catch (parseError) {
269
+ reject(new Error('Received invalid JSON while downloading stack'));
270
+ }
271
+ });
272
+ } else if (res.statusCode === 404) {
273
+ reject(new Error(`Stack '${stackId}' not found (404)`));
274
+ } else {
275
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
276
+ }
277
+ })
278
+ .on('error', reject);
279
+ });
280
+ }
281
+
282
+ function findClosestFolder(folderName) {
283
+ let currentDir = process.cwd();
284
+
285
+ while (true) {
286
+ const candidate = path.join(currentDir, folderName);
287
+ if (fs.existsSync(candidate)) {
288
+ return candidate;
289
+ }
290
+
291
+ const parentDir = path.dirname(currentDir);
292
+ if (parentDir === currentDir) {
293
+ break;
294
+ }
295
+ currentDir = parentDir;
296
+ }
297
+
298
+ return null;
299
+ }
300
+
301
+ function resolveClaudeDirectory(subFolder) {
302
+ const projectClaudeDir = findClosestFolder('.claude');
303
+ if (projectClaudeDir) {
304
+ return path.join(projectClaudeDir, subFolder);
305
+ }
306
+ return path.join(os.homedir(), '.claude', subFolder);
307
+ }
308
+
309
+ function resolveCursorDirectory(subFolder) {
310
+ const projectCursorDir = findClosestFolder('.cursor');
311
+ if (!projectCursorDir) {
312
+ const message =
313
+ 'Unable to find a .cursor directory. Run this command from inside a Cursor project.';
314
+ throw new Error(message);
315
+ }
316
+ return path.join(projectCursorDir, subFolder);
317
+ }
318
+
319
+ function ensureDirectoryExists(dirPath) {
320
+ if (!fs.existsSync(dirPath)) {
321
+ fs.mkdirSync(dirPath, { recursive: true });
322
+ console.log(`Created directory: ${dirPath}`);
323
+ }
324
+ }
325
+
326
+ function checkFileExists(commandsDir, filename) {
327
+ const filePath = path.join(commandsDir, filename);
328
+ return fs.existsSync(filePath);
329
+ }
330
+
331
+ function saveCommand({
332
+ directory,
333
+ filename,
334
+ content,
335
+ fullPath,
336
+ target,
337
+ }) {
338
+ const filePath = path.join(directory, filename);
339
+ const relativeDir = path.relative(process.cwd(), directory);
340
+ const displayPath = relativeDir
341
+ ? path.join(relativeDir, filename)
342
+ : filename;
343
+
344
+ console.log(`📁 Saving command to ${displayPath}`);
345
+
346
+ fs.writeFileSync(filePath, content, 'utf8');
347
+
348
+ const commandName = filename.replace(path.extname(filename), '');
349
+ logSuccessMessage(target, fullPath, commandName);
350
+ }
351
+
352
+ function logSuccessMessage(target, fullPath, commandName) {
353
+ switch (target) {
354
+ case INSTALLATION_TARGETS.CLAUDE_SLASH:
355
+ console.log(`✅ Command '${fullPath}' installed successfully`);
356
+ console.log(`\n🎉 You can now use: /${commandName}`);
357
+ break;
358
+ case INSTALLATION_TARGETS.CLAUDE_SUB_AGENT:
359
+ console.log(`✅ Agent '${fullPath}' installed successfully`);
360
+ console.log(
361
+ `\n🎉 You can now select '${commandName}' from Claude Desktop's agent menu.`
362
+ );
363
+ break;
364
+ case INSTALLATION_TARGETS.CURSOR_COMMAND:
365
+ console.log(`✅ Cursor command '${fullPath}' installed successfully`);
366
+ console.log(
367
+ `\n🎉 Open Cursor and run '${commandName}' from the command palette.`
368
+ );
369
+ break;
370
+ case INSTALLATION_TARGETS.CURSOR_RULE:
371
+ console.log(`✅ Cursor rule '${fullPath}' installed successfully`);
372
+ console.log(
373
+ `\n🎉 Cursor will apply '${commandName}' to this project.`
374
+ );
375
+ break;
376
+ default:
377
+ console.log(`✅ '${fullPath}' installed successfully`);
378
+ }
379
+ }
380
+
381
+ function parseCommandArgs(args) {
382
+ let mode = MODES.COMMAND;
383
+ let identifier = null;
384
+ let target = null;
385
+ let showHelpFlag = false;
386
+
387
+ for (let i = 0; i < args.length; i += 1) {
388
+ const arg = args[i];
389
+
390
+ if (arg === '--help' || arg === '-h') {
391
+ showHelpFlag = true;
392
+ continue;
393
+ }
394
+
395
+ if (arg.startsWith('--target')) {
396
+ let rawValue = null;
397
+ if (arg.includes('=')) {
398
+ rawValue = arg.split('=')[1];
399
+ } else {
400
+ rawValue = args[i + 1];
401
+ i += 1;
402
+ }
403
+
404
+ if (!rawValue) {
405
+ throw new Error('Missing value for --target option');
406
+ }
407
+
408
+ const normalizedTarget = rawValue.toLowerCase();
409
+ if (!KNOWN_TARGETS.includes(normalizedTarget)) {
410
+ throw new Error(
411
+ `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
412
+ );
413
+ }
414
+
415
+ target = normalizedTarget;
416
+ continue;
417
+ }
418
+
419
+ if (arg === '-t') {
420
+ const rawValue = args[i + 1];
421
+ i += 1;
422
+
423
+ if (!rawValue) {
424
+ throw new Error('Missing value for -t option');
425
+ }
426
+
427
+ const normalizedTarget = rawValue.toLowerCase();
428
+ if (!KNOWN_TARGETS.includes(normalizedTarget)) {
429
+ throw new Error(
430
+ `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
431
+ );
432
+ }
433
+
434
+ target = normalizedTarget;
435
+ continue;
436
+ }
437
+
438
+ if (mode === MODES.COMMAND && arg === 'stack' && !identifier) {
439
+ mode = MODES.STACK;
440
+ continue;
441
+ }
442
+
443
+ if (!identifier) {
444
+ identifier = arg;
445
+ continue;
446
+ }
447
+
448
+ throw new Error(`Unknown argument: ${arg}`);
449
+ }
450
+
451
+ return { mode, identifier, target, showHelp: showHelpFlag };
452
+ }
453
+
454
+ async function main() {
455
+ const args = process.argv.slice(2);
456
+
457
+ try {
458
+ const {
459
+ mode,
460
+ identifier,
461
+ target,
462
+ showHelp: helpRequested,
463
+ } = parseCommandArgs(args);
464
+
465
+ if (helpRequested || args.length === 0) {
466
+ showHelp();
467
+ return;
468
+ }
469
+
470
+ const baseUrl = normalizeBaseHost(process.env.IMPULSE_BASE_URL || DEFAULT_BASE_URL);
471
+
472
+ if (mode === MODES.COMMAND) {
473
+ if (!identifier) {
474
+ console.error('❌ Error: Missing command path');
475
+ showHelp();
476
+ process.exit(1);
477
+ }
478
+
479
+ if (!identifier.includes('/')) {
480
+ console.error('❌ Error: Path must include user/command format');
481
+ showHelp();
482
+ process.exit(1);
483
+ }
484
+
485
+ const resolvedTarget = target || DEFAULT_TARGET;
486
+ const targetConfig = TARGET_CONFIG[resolvedTarget];
487
+
488
+ if (!targetConfig) {
489
+ console.error(`❌ Error: Unsupported target '${resolvedTarget}'`);
490
+ process.exit(1);
491
+ }
492
+
493
+ const directory = targetConfig.resolveDirectory();
494
+
495
+ ensureDirectoryExists(directory);
496
+
497
+ const commandName = identifier.split('/').pop();
498
+ const simpleFilename = `${commandName}${targetConfig.extension}`;
499
+ const fallbackFilename = `${identifier.replace('/', '-')}${targetConfig.extension}`;
500
+ const fileExists = checkFileExists(directory, simpleFilename);
501
+ const filename = fileExists ? fallbackFilename : simpleFilename;
502
+
503
+ const content = await downloadCommand(baseUrl, identifier);
504
+
505
+ saveCommand({
506
+ directory,
507
+ filename,
508
+ content,
509
+ fullPath: identifier,
510
+ target: resolvedTarget,
511
+ });
512
+ return;
513
+ }
514
+
515
+ if (!identifier) {
516
+ console.error('❌ Error: Missing stack identifier');
517
+ showHelp();
518
+ process.exit(1);
519
+ }
520
+
521
+ const stackPayload = await downloadStack(baseUrl, identifier);
522
+ const stack = stackPayload.stack || {};
523
+ const prompts = Array.isArray(stackPayload.prompts)
524
+ ? stackPayload.prompts
525
+ : [];
526
+
527
+ console.log(
528
+ `📦 Installing stack '${stack.title || identifier}' (${prompts.length} prompt${
529
+ prompts.length === 1 ? '' : 's'
530
+ })`
531
+ );
532
+
533
+ let installed = 0;
534
+ let skipped = 0;
535
+
536
+ for (let index = 0; index < prompts.length; index += 1) {
537
+ const prompt = prompts[index];
538
+ const commandPath = prompt.path || prompt.slug || prompt.id || `prompt-${index + 1}`;
539
+ const baseName = prompt.slug || `prompt-${index + 1}`;
540
+
541
+ const resolvedTarget = target || prompt.target || DEFAULT_TARGET;
542
+ const targetConfig = TARGET_CONFIG[resolvedTarget];
543
+ if (!targetConfig) {
544
+ console.warn(`⚠️ Skipping prompt '${commandPath}' (unsupported target '${resolvedTarget}')`);
545
+ skipped += 1;
546
+ continue;
547
+ }
548
+
549
+ const directory = targetConfig.resolveDirectory();
550
+ ensureDirectoryExists(directory);
551
+
552
+ const simpleFilename = `${baseName}${targetConfig.extension}`;
553
+ const fallbackBase = stack.slug
554
+ ? `${stack.slug}-${baseName}`
555
+ : `${stack.id || identifier}-${baseName}`;
556
+ const fallbackFilename = `${fallbackBase}${targetConfig.extension}`;
557
+
558
+ const simpleExists = checkFileExists(directory, simpleFilename);
559
+ const fallbackExists = checkFileExists(directory, fallbackFilename);
560
+
561
+ let filename = simpleFilename;
562
+ if (simpleExists) {
563
+ filename = fallbackFilename;
564
+ }
565
+ if ((simpleExists && fallbackExists) || checkFileExists(directory, filename)) {
566
+ const baseForUniq = fallbackBase;
567
+ const ext = targetConfig.extension;
568
+ let counter = 1;
569
+ let candidate = `${baseForUniq}-${counter}${ext}`;
570
+ while (checkFileExists(directory, candidate)) {
571
+ counter += 1;
572
+ candidate = `${baseForUniq}-${counter}${ext}`;
573
+ }
574
+ filename = candidate;
575
+ }
576
+
577
+ if (!prompt.markdown) {
578
+ console.warn(`⚠️ Skipping prompt '${commandPath}' (no content available)`);
579
+ skipped += 1;
580
+ continue;
581
+ }
582
+
583
+ saveCommand({
584
+ directory,
585
+ filename,
586
+ content: prompt.markdown,
587
+ fullPath: commandPath,
588
+ target: resolvedTarget,
589
+ });
590
+ installed += 1;
591
+ }
592
+
593
+ console.log(
594
+ `🎉 Installed ${installed} prompt${installed === 1 ? '' : 's'} from stack '${
595
+ stack.title || identifier
596
+ }'${skipped ? ` (skipped ${skipped})` : ''}`
597
+ );
598
+ } catch (error) {
599
+ console.error(`❌ Error: ${error.message}`);
600
+ process.exit(1);
601
+ }
602
+ }
603
+
604
+ if (require.main === module) {
605
+ main();
606
+ }
607
+
608
+ module.exports = { main };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@impulselab/directory",
3
+ "version": "1.0.0",
4
+ "description": "Download and install Claude commands from Impulse Directory",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "impulse-directory": "./index.js"
8
+ },
9
+ "keywords": [
10
+ "claude",
11
+ "commands",
12
+ "impulse",
13
+ "npx"
14
+ ],
15
+ "author": "Impulse",
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "test": "node index.js --help",
25
+ "predeploy": "chmod +x index.js",
26
+ "deploy": "pnpm publish --access public --no-git-checks"
27
+ }
28
+ }