@impulselab/directory 1.0.8 → 1.1.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 (2) hide show
  1. package/index.js +906 -524
  2. package/package.json +7 -7
package/index.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const https = require('https');
6
6
  const http = require('http');
7
7
  const os = require('os');
8
+ const readline = require('readline');
8
9
 
9
10
  const PACKAGE = {
10
11
  scopeName: '@impulselab/directory',
@@ -18,6 +19,13 @@ const DEFAULT_BASE_URL = 'impulse.directory';
18
19
  const RAW_PATH = '/raw/';
19
20
  const STACK_API_PREFIX = '/api/stacks/';
20
21
  const STACK_API_SUFFIX = '/npx';
22
+ const DEVICE_CODE_PATH = '/api/auth/device/code';
23
+ const DEVICE_TOKEN_PATH = '/api/auth/device/token';
24
+ const SESSION_PATH = '/api/auth/get-session';
25
+ const CLI_CLIENT_ID = 'impulse-directory-cli';
26
+
27
+ const CONFIG_DIR = path.join(os.homedir(), '.impulse-directory');
28
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
21
29
 
22
30
  function normalizeBaseHost(input) {
23
31
  const raw = (input || '').trim();
@@ -40,59 +48,361 @@ function canFallbackToHttp(host) {
40
48
  return process.env.ALLOW_HTTP_FALLBACK === '1' || isLocalHost(host);
41
49
  }
42
50
 
51
+ function shouldUseHttps(baseUrl) {
52
+ if (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1')) {
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+
43
58
  const MODES = {
44
- COMMAND: 'command',
45
- STACK: 'stack',
59
+ COMMAND: 'command',
60
+ STACK: 'stack',
61
+ LOGIN: 'login',
62
+ LOGOUT: 'logout',
63
+ WHOAMI: 'whoami',
46
64
  };
47
65
 
48
66
  const INSTALLATION_TARGETS = {
49
- CLAUDE_SLASH: 'claude-slash',
50
- CLAUDE_SUB_AGENT: 'claude-sub-agent',
51
- CLAUDE_SKILL: 'claude-skill',
52
- CURSOR_COMMAND: 'cursor-command',
53
- CURSOR_RULE: 'cursor-rule',
67
+ CLAUDE_SLASH: 'claude-slash',
68
+ CLAUDE_SUB_AGENT: 'claude-sub-agent',
69
+ CURSOR_COMMAND: 'cursor-command',
70
+ CURSOR_RULE: 'cursor-rule',
54
71
  };
55
72
 
56
73
  const DEFAULT_TARGET = INSTALLATION_TARGETS.CLAUDE_SLASH;
57
74
 
58
75
  const TARGET_CONFIG = {
59
- [INSTALLATION_TARGETS.CLAUDE_SLASH]: {
60
- label: 'Claude slash command',
61
- description: '.claude/commands (falls back to ~/.claude/commands)',
62
- resolveDirectory: () => resolveClaudeDirectory('commands'),
63
- extension: '.md',
64
- },
65
- [INSTALLATION_TARGETS.CLAUDE_SUB_AGENT]: {
66
- label: 'Claude sub agent',
67
- description: '.claude/agents (falls back to ~/.claude/agents)',
68
- resolveDirectory: () => resolveClaudeDirectory('agents'),
69
- extension: '.md',
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
- },
78
- [INSTALLATION_TARGETS.CURSOR_COMMAND]: {
79
- label: 'Cursor command',
80
- description: '.cursor/commands (project only)',
81
- resolveDirectory: () => resolveCursorDirectory('commands'),
82
- extension: '.md',
83
- },
84
- [INSTALLATION_TARGETS.CURSOR_RULE]: {
85
- label: 'Cursor rule',
86
- description: '.cursor/rules (project only)',
87
- resolveDirectory: () => resolveCursorDirectory('rules'),
88
- extension: '.mdc',
89
- },
76
+ [INSTALLATION_TARGETS.CLAUDE_SLASH]: {
77
+ label: 'Claude slash command',
78
+ description: '.claude/commands (falls back to ~/.claude/commands)',
79
+ resolveDirectory: () => resolveClaudeDirectory('commands'),
80
+ extension: '.md',
81
+ },
82
+ [INSTALLATION_TARGETS.CLAUDE_SUB_AGENT]: {
83
+ label: 'Claude sub agent',
84
+ description: '.claude/agents (falls back to ~/.claude/agents)',
85
+ resolveDirectory: () => resolveClaudeDirectory('agents'),
86
+ extension: '.md',
87
+ },
88
+ [INSTALLATION_TARGETS.CURSOR_COMMAND]: {
89
+ label: 'Cursor command',
90
+ description: '.cursor/commands (project only)',
91
+ resolveDirectory: () => resolveCursorDirectory('commands'),
92
+ extension: '.md',
93
+ },
94
+ [INSTALLATION_TARGETS.CURSOR_RULE]: {
95
+ label: 'Cursor rule',
96
+ description: '.cursor/rules (project only)',
97
+ resolveDirectory: () => resolveCursorDirectory('rules'),
98
+ extension: '.mdc',
99
+ },
90
100
  };
91
101
 
92
102
  const KNOWN_TARGETS = Object.keys(TARGET_CONFIG);
93
103
 
104
+ // ============================================
105
+ // Credentials Management
106
+ // ============================================
107
+
108
+ function ensureConfigDir() {
109
+ if (!fs.existsSync(CONFIG_DIR)) {
110
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
111
+ }
112
+ }
113
+
114
+ function loadCredentials() {
115
+ try {
116
+ if (fs.existsSync(CREDENTIALS_FILE)) {
117
+ const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
118
+ return JSON.parse(data);
119
+ }
120
+ } catch {
121
+ // Ignore errors, return null
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function saveCredentials(credentials) {
127
+ ensureConfigDir();
128
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
129
+ encoding: 'utf8',
130
+ mode: 0o600,
131
+ });
132
+ }
133
+
134
+ function clearCredentials() {
135
+ try {
136
+ if (fs.existsSync(CREDENTIALS_FILE)) {
137
+ fs.unlinkSync(CREDENTIALS_FILE);
138
+ }
139
+ } catch {
140
+ // Ignore errors
141
+ }
142
+ }
143
+
144
+ function getAuthToken() {
145
+ const creds = loadCredentials();
146
+ if (!creds) return null;
147
+
148
+ // Check if token is expired
149
+ if (creds.expiresAt && new Date(creds.expiresAt) <= new Date()) {
150
+ console.log('⚠️ Session expired. Please run: npx @impulselab/directory login');
151
+ clearCredentials();
152
+ return null;
153
+ }
154
+
155
+ return creds.accessToken || null;
156
+ }
157
+
158
+ // ============================================
159
+ // HTTP Request Helpers
160
+ // ============================================
161
+
162
+ function httpRequest(options, body = null) {
163
+ return new Promise((resolve, reject) => {
164
+ const protocol = options.protocol === 'http:' ? http : https;
165
+ const req = protocol.request(options, (res) => {
166
+ let data = '';
167
+ res.on('data', (chunk) => {
168
+ data += chunk;
169
+ });
170
+ res.on('end', () => {
171
+ resolve({ statusCode: res.statusCode, headers: res.headers, body: data });
172
+ });
173
+ });
174
+ req.on('error', reject);
175
+ if (body) {
176
+ req.write(body);
177
+ }
178
+ req.end();
179
+ });
180
+ }
181
+
182
+ function sleep(ms) {
183
+ return new Promise((resolve) => setTimeout(resolve, ms));
184
+ }
185
+
186
+ // ============================================
187
+ // Device Authorization Flow
188
+ // ============================================
189
+
190
+ async function requestDeviceCode(baseUrl) {
191
+ const useHttps = shouldUseHttps(baseUrl);
192
+ const protocol = useHttps ? 'https:' : 'http:';
193
+ const [hostname, port] = baseUrl.split(':');
194
+
195
+ const body = JSON.stringify({
196
+ client_id: CLI_CLIENT_ID,
197
+ });
198
+
199
+ const options = {
200
+ protocol,
201
+ hostname,
202
+ port: port || (useHttps ? 443 : 80),
203
+ path: DEVICE_CODE_PATH,
204
+ method: 'POST',
205
+ headers: {
206
+ 'Content-Type': 'application/json',
207
+ 'Content-Length': Buffer.byteLength(body),
208
+ },
209
+ };
210
+
211
+ const res = await httpRequest(options, body);
212
+
213
+ if (res.statusCode !== 200) {
214
+ throw new Error(`Failed to request device code: HTTP ${res.statusCode}`);
215
+ }
216
+
217
+ return JSON.parse(res.body);
218
+ }
219
+
220
+ async function pollForToken(baseUrl, deviceCode, interval) {
221
+ const useHttps = shouldUseHttps(baseUrl);
222
+ const protocol = useHttps ? 'https:' : 'http:';
223
+ const [hostname, port] = baseUrl.split(':');
224
+
225
+ const body = JSON.stringify({
226
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
227
+ device_code: deviceCode,
228
+ client_id: CLI_CLIENT_ID,
229
+ });
230
+
231
+ const options = {
232
+ protocol,
233
+ hostname,
234
+ port: port || (useHttps ? 443 : 80),
235
+ path: DEVICE_TOKEN_PATH,
236
+ method: 'POST',
237
+ headers: {
238
+ 'Content-Type': 'application/json',
239
+ 'Content-Length': Buffer.byteLength(body),
240
+ },
241
+ };
242
+
243
+ while (true) {
244
+ await sleep(interval * 1000);
245
+
246
+ try {
247
+ const res = await httpRequest(options, body);
248
+ const data = JSON.parse(res.body);
249
+
250
+ if (res.statusCode === 200) {
251
+ return data;
252
+ }
253
+
254
+ if (data.error === 'authorization_pending') {
255
+ process.stdout.write('.');
256
+ continue;
257
+ }
258
+
259
+ if (data.error === 'slow_down') {
260
+ interval = Math.min(interval + 1, 10);
261
+ continue;
262
+ }
263
+
264
+ if (data.error === 'access_denied') {
265
+ throw new Error('Authorization was denied by the user');
266
+ }
267
+
268
+ if (data.error === 'expired_token') {
269
+ throw new Error('Device code expired. Please try again.');
270
+ }
271
+
272
+ throw new Error(data.error_description || data.error || 'Unknown error');
273
+ } catch (err) {
274
+ if (err.message.includes('authorization_pending')) {
275
+ continue;
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+ }
281
+
282
+ async function fetchSession(baseUrl, token) {
283
+ const useHttps = shouldUseHttps(baseUrl);
284
+ const protocol = useHttps ? 'https:' : 'http:';
285
+ const [hostname, port] = baseUrl.split(':');
286
+
287
+ const options = {
288
+ protocol,
289
+ hostname,
290
+ port: port || (useHttps ? 443 : 80),
291
+ path: SESSION_PATH,
292
+ method: 'GET',
293
+ headers: {
294
+ Authorization: `Bearer ${token}`,
295
+ },
296
+ };
297
+
298
+ const res = await httpRequest(options);
299
+
300
+ if (res.statusCode !== 200) {
301
+ return null;
302
+ }
303
+
304
+ try {
305
+ return JSON.parse(res.body);
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ async function doLogin(baseUrl) {
312
+ console.log('🔐 Authenticating with Impulse Directory...\n');
313
+
314
+ try {
315
+ const deviceCodeResponse = await requestDeviceCode(baseUrl);
316
+ const {
317
+ user_code: userCode,
318
+ verification_uri: verificationUri,
319
+ verification_uri_complete: verificationUriComplete,
320
+ device_code: deviceCode,
321
+ expires_in: expiresIn,
322
+ interval = 5,
323
+ } = deviceCodeResponse;
324
+
325
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
326
+ console.log('');
327
+ console.log(' To authenticate, visit:');
328
+ console.log(` ${verificationUriComplete || verificationUri}`);
329
+ console.log('');
330
+ if (!verificationUriComplete) {
331
+ console.log(` And enter code: ${userCode}`);
332
+ console.log('');
333
+ }
334
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
335
+ console.log('');
336
+ console.log(`⏳ Waiting for authorization (expires in ${Math.floor(expiresIn / 60)} minutes)`);
337
+
338
+ const tokenResponse = await pollForToken(baseUrl, deviceCode, interval);
339
+ console.log('\n');
340
+
341
+ // Save credentials
342
+ saveCredentials({
343
+ accessToken: tokenResponse.access_token,
344
+ tokenType: tokenResponse.token_type,
345
+ expiresIn: tokenResponse.expires_in,
346
+ expiresAt: tokenResponse.expires_in
347
+ ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
348
+ : null,
349
+ scope: tokenResponse.scope,
350
+ });
351
+
352
+ // Try to fetch user info
353
+ const session = await fetchSession(baseUrl, tokenResponse.access_token);
354
+ if (session && session.user) {
355
+ console.log(`✅ Logged in as ${session.user.name || session.user.email}`);
356
+ } else {
357
+ console.log('✅ Successfully authenticated!');
358
+ }
359
+
360
+ console.log('\nYou can now download private prompts and stacks.');
361
+ } catch (error) {
362
+ console.error(`\n❌ Login failed: ${error.message}`);
363
+ process.exit(1);
364
+ }
365
+ }
366
+
367
+ async function doLogout() {
368
+ const creds = loadCredentials();
369
+ if (!creds) {
370
+ console.log('ℹ️ Not currently logged in.');
371
+ return;
372
+ }
373
+
374
+ clearCredentials();
375
+ console.log('✅ Logged out successfully.');
376
+ }
377
+
378
+ async function doWhoami(baseUrl) {
379
+ const token = getAuthToken();
380
+ if (!token) {
381
+ console.log('ℹ️ Not logged in.');
382
+ console.log(' Run: npx @impulselab/directory login');
383
+ return;
384
+ }
385
+
386
+ try {
387
+ const session = await fetchSession(baseUrl, token);
388
+ if (session && session.user) {
389
+ console.log(`👤 Logged in as: ${session.user.name || 'Unknown'}`);
390
+ console.log(` Email: ${session.user.email}`);
391
+ } else {
392
+ console.log('⚠️ Session is invalid or expired.');
393
+ console.log(' Run: npx @impulselab/directory login');
394
+ }
395
+ } catch {
396
+ console.log('⚠️ Could not verify session.');
397
+ }
398
+ }
399
+
400
+ // ============================================
401
+ // Help
402
+ // ============================================
403
+
94
404
  function showHelp() {
95
- const primaryUsageCmd = `npx impulse-directory`;
405
+ const primaryUsageCmd = `npx @impulselab/directory`;
96
406
  console.log(`
97
407
  Impulse Directory Command Installer
98
408
  Made by ${PACKAGE.brand} (${PACKAGE.websiteUrl})
@@ -100,16 +410,24 @@ Made by ${PACKAGE.brand} (${PACKAGE.websiteUrl})
100
410
  Usage:
101
411
  ${primaryUsageCmd} <user-slug>/<command-slug> [--target <target>]
102
412
  ${primaryUsageCmd} stack <stack-id>
103
-
413
+ ${primaryUsageCmd} login
414
+ ${primaryUsageCmd} logout
415
+ ${primaryUsageCmd} whoami
104
416
 
105
417
  Description:
106
418
  Downloads a command from Impulse Directory and installs it in the right tool folder.
107
419
  When using the stack mode, installs each prompt in the stack to its inferred target.
420
+ Use 'login' to authenticate and access private prompts.
108
421
 
109
422
  Arguments:
110
423
  user-slug/command-slug The full path to the command (user/command)
111
424
  stack-id The stack ULID shown in the Impulse Directory UI
112
425
 
426
+ Commands:
427
+ login Authenticate with your Impulse Directory account
428
+ logout Clear stored credentials
429
+ whoami Show current authentication status
430
+
113
431
  Options:
114
432
  --target <target> Installation target (optional for single commands)
115
433
  -t <target> Short alias for --target
@@ -127,540 +445,604 @@ Environment Variables:
127
445
  Examples: impulse.directory | localhost:3000
128
446
 
129
447
  Examples:
448
+ ${primaryUsageCmd} login
130
449
  ${primaryUsageCmd} john/my-awesome-command
450
+ ${primaryUsageCmd} john/my-private-command (requires login)
131
451
  ${primaryUsageCmd} john/my-awesome-command --target claude-sub-agent
132
- ${primaryUsageCmd} john/my-awesome-command --target cursor-command
133
- ${primaryUsageCmd} stack 01JH0000000ABCDEF --target claude-slash
452
+ ${primaryUsageCmd} stack 01JH0000000ABCDEF
134
453
  `);
135
454
  }
136
455
 
137
- function shouldUseHttps(baseUrl) {
138
- if (baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1')) {
139
- return false;
140
- }
141
- return true;
142
- }
456
+ // ============================================
457
+ // Download Functions (with auth support)
458
+ // ============================================
143
459
 
144
460
  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();
461
+ return String(input)
462
+ .normalize('NFKD')
463
+ .replace(/[\u0300-\u036f]/g, '')
464
+ .replace(/['"]/g, '')
465
+ .replace(/[^a-zA-Z0-9]+/g, '-')
466
+ .replace(/^-+|-+$/g, '')
467
+ .toLowerCase();
152
468
  }
153
469
 
154
470
  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('-');
471
+ const normalized = toKebabCase(slug || '');
472
+ const segments = normalized.split('-');
473
+ if (segments.length <= 1) return normalized;
474
+ const last = segments[segments.length - 1];
475
+ if (/^[a-z0-9]{5,}$/i.test(last)) {
476
+ segments.pop();
477
+ }
478
+ return segments.join('-');
163
479
  }
164
480
 
165
481
  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
482
+ return (
483
+ String(identifier || '')
484
+ .normalize('NFKD')
485
+ .replace(/[\u0300-\u036f]/g, '')
486
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
487
+ .replace(/[<>:"/\\|?*]/g, '')
488
+ .replace(/\.\./g, '')
489
+ .replace(/[/\\]+/g, '-')
490
+ .replace(/^\.+/g, '')
491
+ .replace(/\.+$/g, '')
492
+ .replace(/^-+|-+$/g, '')
493
+ .replace(/-+/g, '-')
494
+ .toLowerCase() || 'unknown'
495
+ );
496
+ }
497
+
498
+ function getAuthHeaders() {
499
+ const token = getAuthToken();
500
+ if (token) {
501
+ return { Authorization: `Bearer ${token}` };
502
+ }
503
+ return {};
179
504
  }
180
505
 
181
506
  function downloadStack(baseUrl, stackId) {
182
- return new Promise((resolve, reject) => {
183
- const useHttps = shouldUseHttps(baseUrl);
184
- const protocol = useHttps ? https : http;
185
- const scheme = useHttps ? 'https' : 'http';
186
- const url = `${scheme}://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
187
-
188
- protocol
189
- .get(url, (res) => {
190
- if (res.statusCode === 200) {
191
- let data = '';
192
- res.on('data', (chunk) => {
193
- data += chunk;
194
- });
195
- res.on('end', () => {
196
- try {
197
- const parsed = JSON.parse(data);
198
- resolve(parsed);
199
- } catch (parseError) {
200
- reject(new Error('Received invalid JSON while downloading stack'));
201
- }
202
- });
203
- } else if (res.statusCode === 404) {
204
- reject(new Error(`Stack '${stackId}' not found (404)`));
205
- } else if (useHttps && (res.statusCode === 500 || res.statusCode >= 400)) {
206
- if (canFallbackToHttp(baseUrl)) {
207
- console.log(`HTTPS failed (${res.statusCode}), trying HTTP...`);
208
- downloadStackWithHttp(baseUrl, stackId)
209
- .then(resolve)
210
- .catch(reject);
211
- } else {
212
- reject(new Error(`HTTPS failed (${res.statusCode}) and HTTP fallback is disabled`));
213
- }
214
- } else {
215
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
216
- }
217
- })
218
- .on('error', (err) => {
219
- if (useHttps) {
220
- if (canFallbackToHttp(baseUrl)) {
221
- console.log(`HTTPS failed (${err.message}), trying HTTP...`);
222
- downloadStackWithHttp(baseUrl, stackId)
223
- .then(resolve)
224
- .catch(reject);
225
- } else {
226
- reject(err);
227
- }
228
- } else {
229
- reject(err);
230
- }
231
- });
232
- });
507
+ return new Promise((resolve, reject) => {
508
+ const useHttps = shouldUseHttps(baseUrl);
509
+ const protocol = useHttps ? https : http;
510
+ const scheme = useHttps ? 'https' : 'http';
511
+ const url = `${scheme}://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
512
+
513
+ const urlObj = new URL(url);
514
+ const options = {
515
+ hostname: urlObj.hostname,
516
+ port: urlObj.port || (useHttps ? 443 : 80),
517
+ path: urlObj.pathname,
518
+ method: 'GET',
519
+ headers: getAuthHeaders(),
520
+ };
521
+
522
+ protocol
523
+ .get(options, (res) => {
524
+ if (res.statusCode === 200) {
525
+ let data = '';
526
+ res.on('data', (chunk) => {
527
+ data += chunk;
528
+ });
529
+ res.on('end', () => {
530
+ try {
531
+ const parsed = JSON.parse(data);
532
+ resolve(parsed);
533
+ } catch (parseError) {
534
+ reject(new Error('Received invalid JSON while downloading stack'));
535
+ }
536
+ });
537
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
538
+ reject(
539
+ new Error(
540
+ 'This stack is private. Please run: npx @impulselab/directory login'
541
+ )
542
+ );
543
+ } else if (res.statusCode === 404) {
544
+ reject(new Error(`Stack '${stackId}' not found (404)`));
545
+ } else if (useHttps && (res.statusCode === 500 || res.statusCode >= 400)) {
546
+ if (canFallbackToHttp(baseUrl)) {
547
+ console.log(`HTTPS failed (${res.statusCode}), trying HTTP...`);
548
+ downloadStackWithHttp(baseUrl, stackId).then(resolve).catch(reject);
549
+ } else {
550
+ reject(
551
+ new Error(
552
+ `HTTPS failed (${res.statusCode}) and HTTP fallback is disabled`
553
+ )
554
+ );
555
+ }
556
+ } else {
557
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
558
+ }
559
+ })
560
+ .on('error', (err) => {
561
+ if (useHttps) {
562
+ if (canFallbackToHttp(baseUrl)) {
563
+ console.log(`HTTPS failed (${err.message}), trying HTTP...`);
564
+ downloadStackWithHttp(baseUrl, stackId).then(resolve).catch(reject);
565
+ } else {
566
+ reject(err);
567
+ }
568
+ } else {
569
+ reject(err);
570
+ }
571
+ });
572
+ });
233
573
  }
234
574
 
235
575
  function downloadStackWithHttp(baseUrl, stackId) {
236
- return new Promise((resolve, reject) => {
237
- const url = `http://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
238
-
239
- console.log(`Trying HTTP: ${url}`);
240
-
241
- http
242
- .get(url, (res) => {
243
- if (res.statusCode === 200) {
244
- let data = '';
245
- res.on('data', (chunk) => {
246
- data += chunk;
247
- });
248
- res.on('end', () => {
249
- try {
250
- const parsed = JSON.parse(data);
251
- resolve(parsed);
252
- } catch (parseError) {
253
- reject(new Error('Received invalid JSON while downloading stack'));
254
- }
255
- });
256
- } else if (res.statusCode === 404) {
257
- reject(new Error(`Stack '${stackId}' not found (404)`));
258
- } else {
259
- reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
260
- }
261
- })
262
- .on('error', reject);
263
- });
576
+ return new Promise((resolve, reject) => {
577
+ const url = `http://${baseUrl}${STACK_API_PREFIX}${stackId}${STACK_API_SUFFIX}`;
578
+ const urlObj = new URL(url);
579
+
580
+ console.log(`Trying HTTP: ${url}`);
581
+
582
+ const options = {
583
+ hostname: urlObj.hostname,
584
+ port: urlObj.port || 80,
585
+ path: urlObj.pathname,
586
+ method: 'GET',
587
+ headers: getAuthHeaders(),
588
+ };
589
+
590
+ http
591
+ .get(options, (res) => {
592
+ if (res.statusCode === 200) {
593
+ let data = '';
594
+ res.on('data', (chunk) => {
595
+ data += chunk;
596
+ });
597
+ res.on('end', () => {
598
+ try {
599
+ const parsed = JSON.parse(data);
600
+ resolve(parsed);
601
+ } catch (parseError) {
602
+ reject(new Error('Received invalid JSON while downloading stack'));
603
+ }
604
+ });
605
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
606
+ reject(
607
+ new Error(
608
+ 'This stack is private. Please run: npx @impulselab/directory login'
609
+ )
610
+ );
611
+ } else if (res.statusCode === 404) {
612
+ reject(new Error(`Stack '${stackId}' not found (404)`));
613
+ } else {
614
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
615
+ }
616
+ })
617
+ .on('error', reject);
618
+ });
264
619
  }
265
620
 
266
621
  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
- });
622
+ return new Promise((resolve, reject) => {
623
+ const useHttps = shouldUseHttps(baseUrl);
624
+ const protocol = useHttps ? https : http;
625
+ const scheme = useHttps ? 'https' : 'http';
626
+ const url = `${scheme}://${baseUrl}${RAW_PATH}${fullPath}`;
627
+
628
+ const urlObj = new URL(url);
629
+ const options = {
630
+ hostname: urlObj.hostname,
631
+ port: urlObj.port || (useHttps ? 443 : 80),
632
+ path: urlObj.pathname,
633
+ method: 'GET',
634
+ headers: getAuthHeaders(),
635
+ };
636
+
637
+ const handleOk = (res) => {
638
+ let data = '';
639
+ res.on('data', (chunk) => {
640
+ data += chunk;
641
+ });
642
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
643
+ };
644
+
645
+ const handleHttpsFailover = (reason) => {
646
+ if (useHttps && canFallbackToHttp(baseUrl)) {
647
+ console.log(`HTTPS failed (${reason}), trying HTTP...`);
648
+ downloadRawWithHeadersHttp(baseUrl, fullPath).then(resolve).catch(reject);
649
+ } else {
650
+ reject(new Error(typeof reason === 'string' ? reason : reason.message));
651
+ }
652
+ };
653
+
654
+ protocol
655
+ .get(options, (res) => {
656
+ if (res.statusCode === 200) return handleOk(res);
657
+ if (res.statusCode === 401 || res.statusCode === 403) {
658
+ return reject(
659
+ new Error(
660
+ 'This prompt is private. Please run: npx @impulselab/directory login'
661
+ )
662
+ );
663
+ }
664
+ if (res.statusCode === 404)
665
+ return reject(new Error(`Command '${fullPath}' not found (404)`));
666
+ return handleHttpsFailover(`${res.statusCode}`);
667
+ })
668
+ .on('error', handleHttpsFailover);
669
+ });
296
670
  }
297
671
 
298
672
  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
- });
673
+ return new Promise((resolve, reject) => {
674
+ const url = `http://${baseUrl}${RAW_PATH}${fullPath}`;
675
+ const urlObj = new URL(url);
676
+
677
+ console.log(`Trying HTTP: ${url}`);
678
+
679
+ const options = {
680
+ hostname: urlObj.hostname,
681
+ port: urlObj.port || 80,
682
+ path: urlObj.pathname,
683
+ method: 'GET',
684
+ headers: getAuthHeaders(),
685
+ };
686
+
687
+ http
688
+ .get(options, (res) => {
689
+ if (res.statusCode === 200) {
690
+ let data = '';
691
+ res.on('data', (chunk) => {
692
+ data += chunk;
693
+ });
694
+ res.on('end', () => resolve({ content: data, headers: res.headers }));
695
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
696
+ reject(
697
+ new Error(
698
+ 'This prompt is private. Please run: npx @impulselab/directory login'
699
+ )
700
+ );
701
+ } else if (res.statusCode === 404) {
702
+ reject(new Error(`Command '${fullPath}' not found (404)`));
703
+ } else {
704
+ reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
705
+ }
706
+ })
707
+ .on('error', reject);
708
+ });
316
709
  }
317
710
 
318
- function findClosestFolder(folderName) {
319
- let currentDir = process.cwd();
320
-
321
- while (true) {
322
- const candidate = path.join(currentDir, folderName);
323
- if (fs.existsSync(candidate)) {
324
- return candidate;
325
- }
711
+ // ============================================
712
+ // Directory Resolution
713
+ // ============================================
326
714
 
327
- const parentDir = path.dirname(currentDir);
328
- if (parentDir === currentDir) {
329
- break;
330
- }
331
- currentDir = parentDir;
332
- }
333
-
334
- return null;
715
+ function findClosestFolder(folderName) {
716
+ let currentDir = process.cwd();
717
+
718
+ while (true) {
719
+ const candidate = path.join(currentDir, folderName);
720
+ if (fs.existsSync(candidate)) {
721
+ return candidate;
722
+ }
723
+
724
+ const parentDir = path.dirname(currentDir);
725
+ if (parentDir === currentDir) {
726
+ break;
727
+ }
728
+ currentDir = parentDir;
729
+ }
730
+
731
+ return null;
335
732
  }
336
733
 
337
734
  function resolveClaudeDirectory(subFolder) {
338
- const projectClaudeDir = findClosestFolder('.claude');
339
- if (projectClaudeDir) {
340
- return path.join(projectClaudeDir, subFolder);
341
- }
342
- return path.join(os.homedir(), '.claude', subFolder);
735
+ const projectClaudeDir = findClosestFolder('.claude');
736
+ if (projectClaudeDir) {
737
+ return path.join(projectClaudeDir, subFolder);
738
+ }
739
+ return path.join(os.homedir(), '.claude', subFolder);
343
740
  }
344
741
 
345
742
  function resolveCursorDirectory(subFolder) {
346
- const projectCursorDir = findClosestFolder('.cursor');
347
- if (!projectCursorDir) {
348
- const message =
349
- 'Unable to find a .cursor directory. Run this command from inside a Cursor project.';
350
- throw new Error(message);
351
- }
352
- return path.join(projectCursorDir, subFolder);
743
+ const projectCursorDir = findClosestFolder('.cursor');
744
+ if (!projectCursorDir) {
745
+ const message =
746
+ 'Unable to find a .cursor directory. Run this command from inside a Cursor project.';
747
+ throw new Error(message);
748
+ }
749
+ return path.join(projectCursorDir, subFolder);
353
750
  }
354
751
 
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');
752
+ function ensureDirectoryExists(dirPath) {
753
+ if (!fs.existsSync(dirPath)) {
754
+ fs.mkdirSync(dirPath, { recursive: true });
755
+ console.log(`Created directory: ${dirPath}`);
756
+ }
363
757
  }
364
758
 
365
- function ensureDirectoryExists(dirPath) {
366
- if (!fs.existsSync(dirPath)) {
367
- fs.mkdirSync(dirPath, { recursive: true });
368
- console.log(`Created directory: ${dirPath}`);
369
- }
370
- }
371
-
372
- function checkFileExists(commandsDir, filename) {
373
- const filePath = path.join(commandsDir, filename);
374
- return fs.existsSync(filePath);
375
- }
376
-
377
- function saveCommand({
378
- directory,
379
- filename,
380
- content,
381
- fullPath,
382
- target,
383
- }) {
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
- }
409
-
410
- console.log(`📁 Saving ${isSkill ? 'skill' : 'command'} to ${displayPath}`);
411
-
412
- fs.writeFileSync(filePath, content, 'utf8');
413
-
414
- logSuccessMessage(target, fullPath, commandName);
759
+ function saveCommand({ directory, filename, content, fullPath, target }) {
760
+ const filePath = path.join(directory, filename);
761
+ const relativeDir = path.relative(process.cwd(), directory);
762
+ const displayPath = relativeDir ? path.join(relativeDir, filename) : filename;
763
+
764
+ console.log(`📁 Saving command to ${displayPath}`);
765
+
766
+ fs.writeFileSync(filePath, content, 'utf8');
767
+
768
+ const commandName = filename.replace(path.extname(filename), '');
769
+ logSuccessMessage(target, fullPath, commandName);
415
770
  }
416
771
 
417
772
  function logSuccessMessage(target, fullPath, commandName) {
418
- switch (target) {
419
- case INSTALLATION_TARGETS.CLAUDE_SLASH:
420
- console.log(`✅ Command '${fullPath}' installed successfully`);
421
- console.log(`\n🎉 You can now use: /${commandName}`);
422
- break;
423
- case INSTALLATION_TARGETS.CLAUDE_SUB_AGENT:
424
- console.log(`✅ Agent '${fullPath}' installed successfully`);
425
- console.log(
426
- `\n🎉 You can now select '${commandName}' from Claude Desktop's agent menu.`
427
- );
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;
435
- case INSTALLATION_TARGETS.CURSOR_COMMAND:
436
- console.log(`✅ Cursor command '${fullPath}' installed successfully`);
437
- console.log(
438
- `\n🎉 Open Cursor and run '${commandName}' from the command palette.`
439
- );
440
- break;
441
- case INSTALLATION_TARGETS.CURSOR_RULE:
442
- console.log(`✅ Cursor rule '${fullPath}' installed successfully`);
443
- console.log(
444
- `\n🎉 Cursor will apply '${commandName}' to this project.`
445
- );
446
- break;
447
- default:
448
- console.log(`✅ '${fullPath}' installed successfully`);
449
- }
773
+ switch (target) {
774
+ case INSTALLATION_TARGETS.CLAUDE_SLASH:
775
+ console.log(`✅ Command '${fullPath}' installed successfully`);
776
+ console.log(`\n🎉 You can now use: /${commandName}`);
777
+ break;
778
+ case INSTALLATION_TARGETS.CLAUDE_SUB_AGENT:
779
+ console.log(`✅ Agent '${fullPath}' installed successfully`);
780
+ console.log(
781
+ `\n🎉 You can now select '${commandName}' from Claude Desktop's agent menu.`
782
+ );
783
+ break;
784
+ case INSTALLATION_TARGETS.CURSOR_COMMAND:
785
+ console.log(`✅ Cursor command '${fullPath}' installed successfully`);
786
+ console.log(
787
+ `\n🎉 Open Cursor and run '${commandName}' from the command palette.`
788
+ );
789
+ break;
790
+ case INSTALLATION_TARGETS.CURSOR_RULE:
791
+ console.log(`✅ Cursor rule '${fullPath}' installed successfully`);
792
+ console.log(`\n🎉 Cursor will apply '${commandName}' to this project.`);
793
+ break;
794
+ default:
795
+ console.log(`✅ '${fullPath}' installed successfully`);
796
+ }
450
797
  }
451
798
 
799
+ // ============================================
800
+ // Argument Parsing
801
+ // ============================================
802
+
452
803
  function parseCommandArgs(args) {
453
- let mode = MODES.COMMAND;
454
- let identifier = null;
455
- let target = null;
456
- let showHelpFlag = false;
457
-
458
- for (let i = 0; i < args.length; i += 1) {
459
- const arg = args[i];
460
-
461
- if (arg === '--help' || arg === '-h') {
462
- showHelpFlag = true;
463
- continue;
464
- }
465
-
466
- if (arg.startsWith('--target')) {
467
- let rawValue = null;
468
- if (arg.includes('=')) {
469
- rawValue = arg.split('=')[1];
470
- } else {
471
- rawValue = args[i + 1];
472
- i += 1;
473
- }
474
-
475
- if (!rawValue) {
476
- throw new Error('Missing value for --target option');
477
- }
478
-
479
- const normalizedTarget = rawValue.toLowerCase();
480
- if (!KNOWN_TARGETS.includes(normalizedTarget)) {
481
- throw new Error(
482
- `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
483
- );
484
- }
485
-
486
- target = normalizedTarget;
487
- continue;
488
- }
489
-
490
- if (arg === '-t') {
491
- const rawValue = args[i + 1];
492
- i += 1;
493
-
494
- if (!rawValue) {
495
- throw new Error('Missing value for -t option');
496
- }
497
-
498
- const normalizedTarget = rawValue.toLowerCase();
499
- if (!KNOWN_TARGETS.includes(normalizedTarget)) {
500
- throw new Error(
501
- `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
502
- );
503
- }
504
-
505
- target = normalizedTarget;
506
- continue;
507
- }
508
-
509
- if (mode === MODES.COMMAND && arg === 'stack' && !identifier) {
510
- mode = MODES.STACK;
511
- continue;
512
- }
513
-
514
- if (!identifier) {
515
- identifier = arg;
516
- continue;
517
- }
518
-
519
- throw new Error(`Unknown argument: ${arg}`);
520
- }
521
-
522
- return { mode, identifier, target, showHelp: showHelpFlag };
804
+ let mode = MODES.COMMAND;
805
+ let identifier = null;
806
+ let target = null;
807
+ let showHelpFlag = false;
808
+
809
+ for (let i = 0; i < args.length; i += 1) {
810
+ const arg = args[i];
811
+
812
+ if (arg === '--help' || arg === '-h') {
813
+ showHelpFlag = true;
814
+ continue;
815
+ }
816
+
817
+ if (arg.startsWith('--target')) {
818
+ let rawValue = null;
819
+ if (arg.includes('=')) {
820
+ rawValue = arg.split('=')[1];
821
+ } else {
822
+ rawValue = args[i + 1];
823
+ i += 1;
824
+ }
825
+
826
+ if (!rawValue) {
827
+ throw new Error('Missing value for --target option');
828
+ }
829
+
830
+ const normalizedTarget = rawValue.toLowerCase();
831
+ if (!KNOWN_TARGETS.includes(normalizedTarget)) {
832
+ throw new Error(
833
+ `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
834
+ );
835
+ }
836
+
837
+ target = normalizedTarget;
838
+ continue;
839
+ }
840
+
841
+ if (arg === '-t') {
842
+ const rawValue = args[i + 1];
843
+ i += 1;
844
+
845
+ if (!rawValue) {
846
+ throw new Error('Missing value for -t option');
847
+ }
848
+
849
+ const normalizedTarget = rawValue.toLowerCase();
850
+ if (!KNOWN_TARGETS.includes(normalizedTarget)) {
851
+ throw new Error(
852
+ `Unknown target '${rawValue}'. Available targets: ${KNOWN_TARGETS.join(', ')}`
853
+ );
854
+ }
855
+
856
+ target = normalizedTarget;
857
+ continue;
858
+ }
859
+
860
+ // Check for auth commands
861
+ if (arg === 'login' && !identifier) {
862
+ mode = MODES.LOGIN;
863
+ continue;
864
+ }
865
+
866
+ if (arg === 'logout' && !identifier) {
867
+ mode = MODES.LOGOUT;
868
+ continue;
869
+ }
870
+
871
+ if (arg === 'whoami' && !identifier) {
872
+ mode = MODES.WHOAMI;
873
+ continue;
874
+ }
875
+
876
+ if (mode === MODES.COMMAND && arg === 'stack' && !identifier) {
877
+ mode = MODES.STACK;
878
+ continue;
879
+ }
880
+
881
+ if (!identifier) {
882
+ identifier = arg;
883
+ continue;
884
+ }
885
+
886
+ throw new Error(`Unknown argument: ${arg}`);
887
+ }
888
+
889
+ return { mode, identifier, target, showHelp: showHelpFlag };
523
890
  }
524
891
 
892
+ // ============================================
893
+ // Main
894
+ // ============================================
895
+
525
896
  async function main() {
526
- const args = process.argv.slice(2);
527
-
528
- try {
529
- const {
530
- mode,
531
- identifier,
532
- target,
533
- showHelp: helpRequested,
534
- } = parseCommandArgs(args);
535
-
536
- if (helpRequested || args.length === 0) {
537
- showHelp();
538
- return;
539
- }
540
-
541
- const baseUrl = normalizeBaseHost(process.env.IMPULSE_BASE_URL || DEFAULT_BASE_URL);
542
-
543
- if (mode === MODES.COMMAND) {
544
- if (!identifier) {
545
- console.error('❌ Error: Missing command path');
546
- showHelp();
547
- process.exit(1);
548
- }
549
-
550
- if (!identifier.includes('/')) {
551
- console.error('❌ Error: Path must include user/command format');
552
- showHelp();
553
- process.exit(1);
554
- }
555
-
556
- const resolvedTarget = target || DEFAULT_TARGET;
557
- const targetConfig = TARGET_CONFIG[resolvedTarget];
558
-
559
- if (!targetConfig) {
560
- console.error(`❌ Error: Unsupported target '${resolvedTarget}'`);
561
- process.exit(1);
562
- }
563
-
564
- const directory = targetConfig.resolveDirectory();
565
-
566
- ensureDirectoryExists(directory);
567
-
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);
578
-
579
- // Overwrite if exists; do not append author or random suffix
580
- const filename = `${finalBaseName}${targetConfig.extension}`;
581
-
582
- saveCommand({
583
- directory,
584
- filename,
585
- content,
586
- fullPath: identifier,
587
- target: resolvedTarget,
588
- });
589
- return;
590
- }
591
-
592
- if (!identifier) {
593
- console.error('❌ Error: Missing stack identifier');
594
- showHelp();
595
- process.exit(1);
596
- }
597
-
598
- const stackPayload = await downloadStack(baseUrl, identifier);
599
- const stack = stackPayload.stack || {};
600
- const prompts = Array.isArray(stackPayload.prompts)
601
- ? stackPayload.prompts
602
- : [];
603
-
604
- console.log(
605
- `📦 Installing stack '${stack.title || identifier}' (${prompts.length} prompt${
606
- prompts.length === 1 ? '' : 's'
607
- })`
608
- );
609
-
610
- let installed = 0;
611
- let skipped = 0;
612
-
613
- for (let index = 0; index < prompts.length; index += 1) {
614
- const prompt = prompts[index];
615
- const commandPath = prompt.path || prompt.slug || prompt.id || `prompt-${index + 1}`;
616
- const baseName = prompt.title
617
- ? toKebabCase(prompt.title)
618
- : stripTrailingIdSegment(prompt.slug || `prompt-${index + 1}`);
619
-
620
- const resolvedTarget = target || prompt.target || DEFAULT_TARGET;
621
- const targetConfig = TARGET_CONFIG[resolvedTarget];
622
- if (!targetConfig) {
623
- console.warn(`⚠️ Skipping prompt '${commandPath}' (unsupported target '${resolvedTarget}')`);
624
- skipped += 1;
625
- continue;
626
- }
627
-
628
- const directory = targetConfig.resolveDirectory();
629
- ensureDirectoryExists(directory);
630
-
631
- // Sanitize and overwrite if exists (no author or random suffix)
632
- const sanitizedBaseName = sanitizeIdentifierForFilename(baseName);
633
- const filename = `${sanitizedBaseName}${targetConfig.extension}`;
634
-
635
- if (!prompt.markdown) {
636
- console.warn(`⚠️ Skipping prompt '${commandPath}' (no content available)`);
637
- skipped += 1;
638
- continue;
639
- }
640
-
641
- saveCommand({
642
- directory,
643
- filename,
644
- content: prompt.markdown,
645
- fullPath: commandPath,
646
- target: resolvedTarget,
647
- });
648
- installed += 1;
649
- }
650
-
651
- console.log(
652
- `🎉 Installed ${installed} prompt${installed === 1 ? '' : 's'} from stack '${
653
- stack.title || identifier
654
- }'${skipped ? ` (skipped ${skipped})` : ''}`
655
- );
656
- } catch (error) {
657
- console.error(`❌ Error: ${error.message}`);
658
- process.exit(1);
659
- }
897
+ const args = process.argv.slice(2);
898
+
899
+ try {
900
+ const { mode, identifier, target, showHelp: helpRequested } = parseCommandArgs(args);
901
+
902
+ if (helpRequested || args.length === 0) {
903
+ showHelp();
904
+ return;
905
+ }
906
+
907
+ const baseUrl = normalizeBaseHost(
908
+ process.env.IMPULSE_BASE_URL || DEFAULT_BASE_URL
909
+ );
910
+
911
+ // Handle auth commands
912
+ if (mode === MODES.LOGIN) {
913
+ await doLogin(baseUrl);
914
+ return;
915
+ }
916
+
917
+ if (mode === MODES.LOGOUT) {
918
+ await doLogout();
919
+ return;
920
+ }
921
+
922
+ if (mode === MODES.WHOAMI) {
923
+ await doWhoami(baseUrl);
924
+ return;
925
+ }
926
+
927
+ if (mode === MODES.COMMAND) {
928
+ if (!identifier) {
929
+ console.error('❌ Error: Missing command path');
930
+ showHelp();
931
+ process.exit(1);
932
+ }
933
+
934
+ if (!identifier.includes('/')) {
935
+ console.error('❌ Error: Path must include user/command format');
936
+ showHelp();
937
+ process.exit(1);
938
+ }
939
+
940
+ const resolvedTarget = target || DEFAULT_TARGET;
941
+ const targetConfig = TARGET_CONFIG[resolvedTarget];
942
+
943
+ if (!targetConfig) {
944
+ console.error(`❌ Error: Unsupported target '${resolvedTarget}'`);
945
+ process.exit(1);
946
+ }
947
+
948
+ const directory = targetConfig.resolveDirectory();
949
+
950
+ ensureDirectoryExists(directory);
951
+
952
+ const commandNameFromPath = identifier.split('/').pop();
953
+ const { content, headers } = await downloadRawWithHeaders(baseUrl, identifier);
954
+ const titleHeader = headers['x-prompt-title'];
955
+ const title = Array.isArray(titleHeader) ? titleHeader[0] : titleHeader;
956
+
957
+ const baseNameCandidate = title
958
+ ? toKebabCase(title)
959
+ : stripTrailingIdSegment(commandNameFromPath);
960
+ const finalBaseName = sanitizeIdentifierForFilename(baseNameCandidate);
961
+
962
+ const filename = `${finalBaseName}${targetConfig.extension}`;
963
+
964
+ saveCommand({
965
+ directory,
966
+ filename,
967
+ content,
968
+ fullPath: identifier,
969
+ target: resolvedTarget,
970
+ });
971
+ return;
972
+ }
973
+
974
+ if (!identifier) {
975
+ console.error('❌ Error: Missing stack identifier');
976
+ showHelp();
977
+ process.exit(1);
978
+ }
979
+
980
+ const stackPayload = await downloadStack(baseUrl, identifier);
981
+ const stack = stackPayload.stack || {};
982
+ const prompts = Array.isArray(stackPayload.prompts) ? stackPayload.prompts : [];
983
+
984
+ console.log(
985
+ `📦 Installing stack '${stack.title || identifier}' (${prompts.length} prompt${
986
+ prompts.length === 1 ? '' : 's'
987
+ })`
988
+ );
989
+
990
+ let installed = 0;
991
+ let skipped = 0;
992
+
993
+ for (let index = 0; index < prompts.length; index += 1) {
994
+ const prompt = prompts[index];
995
+ const commandPath =
996
+ prompt.path || prompt.slug || prompt.id || `prompt-${index + 1}`;
997
+ const baseName = prompt.title
998
+ ? toKebabCase(prompt.title)
999
+ : stripTrailingIdSegment(prompt.slug || `prompt-${index + 1}`);
1000
+
1001
+ const resolvedTarget = target || prompt.target || DEFAULT_TARGET;
1002
+ const targetConfig = TARGET_CONFIG[resolvedTarget];
1003
+ if (!targetConfig) {
1004
+ console.warn(
1005
+ `⚠️ Skipping prompt '${commandPath}' (unsupported target '${resolvedTarget}')`
1006
+ );
1007
+ skipped += 1;
1008
+ continue;
1009
+ }
1010
+
1011
+ const directory = targetConfig.resolveDirectory();
1012
+ ensureDirectoryExists(directory);
1013
+
1014
+ const sanitizedBaseName = sanitizeIdentifierForFilename(baseName);
1015
+ const filename = `${sanitizedBaseName}${targetConfig.extension}`;
1016
+
1017
+ if (!prompt.markdown) {
1018
+ console.warn(`⚠️ Skipping prompt '${commandPath}' (no content available)`);
1019
+ skipped += 1;
1020
+ continue;
1021
+ }
1022
+
1023
+ saveCommand({
1024
+ directory,
1025
+ filename,
1026
+ content: prompt.markdown,
1027
+ fullPath: commandPath,
1028
+ target: resolvedTarget,
1029
+ });
1030
+ installed += 1;
1031
+ }
1032
+
1033
+ console.log(
1034
+ `🎉 Installed ${installed} prompt${installed === 1 ? '' : 's'} from stack '${
1035
+ stack.title || identifier
1036
+ }'${skipped ? ` (skipped ${skipped})` : ''}`
1037
+ );
1038
+ } catch (error) {
1039
+ console.error(`❌ Error: ${error.message}`);
1040
+ process.exit(1);
1041
+ }
660
1042
  }
661
1043
 
662
1044
  if (require.main === module) {
663
- main();
1045
+ main();
664
1046
  }
665
1047
 
666
1048
  module.exports = { main };