@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.
- package/README.md +144 -0
- package/index.js +608 -0
- 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
|
+
}
|