crush-commands 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 +129 -0
- package/bin/crush-commands.js +193 -0
- package/lib/github.js +138 -0
- package/lib/installer.js +195 -0
- package/lib/prompts.js +44 -0
- package/lib/utils.js +64 -0
- package/package.json +36 -0
- package/tasks/prd-github-installer.md +243 -0
- package/test/github.test.js +56 -0
- package/test/utils.test.js +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# crush-commands
|
|
2
|
+
|
|
3
|
+
Install Crush commands from GitHub repositories.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
No installation required! Just use npx:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx crush-commands <owner>/<repo> --command <name>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Install All Commands
|
|
16
|
+
|
|
17
|
+
Install all commands from a repository:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx crush-commands add <owner>/<repo>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx crush-commands add myuser/my-crush-commands
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Install a Specific Command
|
|
30
|
+
|
|
31
|
+
Install a specific command by name:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx crush-commands <owner>/<repo> --command <name>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx crush-commands myuser/my-crush-commands --command mycommand
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Options
|
|
44
|
+
|
|
45
|
+
- `--command, -c <name>` - Install a specific command (without .md extension)
|
|
46
|
+
- `--ref <branch|tag>` - Use specific branch or tag (default: default branch)
|
|
47
|
+
- `--yes, -y` - Skip confirmation prompts (useful for CI/automation)
|
|
48
|
+
- `--quiet, -q` - Suppress non-essential output
|
|
49
|
+
- `--help, -h` - Show help message
|
|
50
|
+
- `--version, -v` - Show version number
|
|
51
|
+
|
|
52
|
+
### Examples
|
|
53
|
+
|
|
54
|
+
Install all commands from the `main` branch:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx crush-commands add myuser/my-crush-commands --ref main
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Install a specific command without confirmation:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx crush-commands myuser/my-crush-commands --command mycommand --yes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install commands quietly (for scripts):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx crush-commands add myuser/my-crush-commands --quiet
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
1. The tool fetches the `crush/commands/` directory from the target GitHub repository
|
|
75
|
+
2. It lists all `.md` files in that directory
|
|
76
|
+
3. For each command file, it checks if it already exists in `~/.crush/commands`
|
|
77
|
+
4. If a file exists, it asks for confirmation before overwriting (unless `--yes` is used)
|
|
78
|
+
5. It writes the command files to `~/.crush/commands/<name>.md`
|
|
79
|
+
|
|
80
|
+
## Repository Structure
|
|
81
|
+
|
|
82
|
+
The tool expects repositories to have the following structure:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
my-repo/
|
|
86
|
+
└── crush/
|
|
87
|
+
└── commands/
|
|
88
|
+
├── command1.md
|
|
89
|
+
├── command2.md
|
|
90
|
+
└── command3.md
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Each `.md` file should contain a Crush command definition.
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
- Node.js 18 or higher
|
|
98
|
+
- Internet connection to access GitHub's public API
|
|
99
|
+
- The target repository must be public and contain a `crush/commands/` directory
|
|
100
|
+
|
|
101
|
+
## Limitations
|
|
102
|
+
|
|
103
|
+
- Only works with public GitHub repositories
|
|
104
|
+
- Only installs `.md` files from `crush/commands/` directory
|
|
105
|
+
- Requires write permissions to `~/.crush/commands`
|
|
106
|
+
- GitHub API rate limits apply (60 requests/hour for unauthenticated requests)
|
|
107
|
+
|
|
108
|
+
## After Installation
|
|
109
|
+
|
|
110
|
+
After installing commands, run:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
crush reload
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This refreshes Crush's command cache so the new commands are available.
|
|
117
|
+
|
|
118
|
+
## Error Handling
|
|
119
|
+
|
|
120
|
+
The tool provides helpful error messages for common issues:
|
|
121
|
+
|
|
122
|
+
- **Repository not found**: Verify the repository exists and is public
|
|
123
|
+
- **No commands found**: Check that the repository has a `crush/commands/` directory with `.md` files
|
|
124
|
+
- **Network errors**: Check your internet connection
|
|
125
|
+
- **Rate limit exceeded**: Wait before trying again
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'util';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { installAllCommands, installSpecificCommand } from '../lib/installer.js';
|
|
6
|
+
import { validateRepoFormat, stripMdExtension } from '../lib/utils.js';
|
|
7
|
+
|
|
8
|
+
const PACKAGE_VERSION = '1.0.0';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse command line arguments
|
|
12
|
+
*/
|
|
13
|
+
function parseCliArgs() {
|
|
14
|
+
const { values, positionals } = parseArgs({
|
|
15
|
+
args: process.argv.slice(2),
|
|
16
|
+
options: {
|
|
17
|
+
command: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
},
|
|
20
|
+
ref: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
},
|
|
23
|
+
yes: {
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
short: 'y',
|
|
26
|
+
},
|
|
27
|
+
quiet: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
short: 'q',
|
|
30
|
+
},
|
|
31
|
+
help: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
short: 'h',
|
|
34
|
+
},
|
|
35
|
+
version: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
short: 'v',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
allowPositionals: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return { values, positionals };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Show help message
|
|
48
|
+
*/
|
|
49
|
+
function showHelp() {
|
|
50
|
+
console.log(`
|
|
51
|
+
${chalk.bold('crush-commands')} - Install Crush commands from GitHub repositories
|
|
52
|
+
|
|
53
|
+
${chalk.bold('Usage:')}
|
|
54
|
+
${chalk.cyan('npx crush-commands add')} ${chalk.gray('<owner>/<repo>')}${chalk.dim(' ')}Install all commands
|
|
55
|
+
${chalk.cyan('npx crush-commands')} ${chalk.gray('<owner>/<repo> --command <name>')}${chalk.dim(' ')}Install specific command
|
|
56
|
+
|
|
57
|
+
${chalk.bold('Options:')}
|
|
58
|
+
${chalk.cyan('--command, -c <name>')} ${chalk.dim('Install a specific command (without .md extension)')}
|
|
59
|
+
${chalk.cyan('--ref <branch|tag>')} ${chalk.dim('Use specific branch or tag (default: default branch)')}
|
|
60
|
+
${chalk.cyan('--yes, -y')} ${chalk.dim('Skip confirmation prompts')}
|
|
61
|
+
${chalk.cyan('--quiet, -q')} ${chalk.dim('Suppress non-essential output')}
|
|
62
|
+
${chalk.cyan('--help, -h')} ${chalk.dim('Show this help message')}
|
|
63
|
+
${chalk.cyan('--version, -v')} ${chalk.dim('Show version number')}
|
|
64
|
+
|
|
65
|
+
${chalk.bold('Examples:')}
|
|
66
|
+
${chalk.cyan('npx crush-commands add')} ${chalk.gray('myusername/my-crush-commands')}
|
|
67
|
+
${chalk.cyan('npx crush-commands')} ${chalk.gray('myusername/my-crush-commands --command mycommand')}
|
|
68
|
+
${chalk.cyan('npx crush-commands')} ${chalk.gray('myusername/my-crush-commands --command mycommand --ref main')}
|
|
69
|
+
|
|
70
|
+
${chalk.bold('Notes:')}
|
|
71
|
+
- Commands are installed from the ${chalk.cyan('crush/commands/')} directory in the target repository
|
|
72
|
+
- Files are installed to ${chalk.cyan('~/.crush/commands')}
|
|
73
|
+
- Only ${chalk.cyan('.md')} files are installed
|
|
74
|
+
- Run ${chalk.cyan('crush reload')} after installing to refresh commands
|
|
75
|
+
`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Show version
|
|
80
|
+
*/
|
|
81
|
+
function showVersion() {
|
|
82
|
+
console.log(`crush-commands v${PACKAGE_VERSION}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Main entry point
|
|
87
|
+
*/
|
|
88
|
+
async function main() {
|
|
89
|
+
const { values, positionals } = parseCliArgs();
|
|
90
|
+
|
|
91
|
+
// Handle --help
|
|
92
|
+
if (values.help) {
|
|
93
|
+
showHelp();
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle --version
|
|
98
|
+
if (values.version) {
|
|
99
|
+
showVersion();
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check if we have the required positional argument
|
|
104
|
+
if (positionals.length === 0) {
|
|
105
|
+
console.error(chalk.red('Error: Missing repository argument'));
|
|
106
|
+
console.error('');
|
|
107
|
+
console.error('Usage: npx crush-commands <owner>/<repo> [options]');
|
|
108
|
+
console.error(' or: npx crush-commands add <owner>/<repo> [options]');
|
|
109
|
+
console.error('');
|
|
110
|
+
console.error('Run --help for more information');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if it's the 'add' command or direct repo specification
|
|
115
|
+
let repo;
|
|
116
|
+
let command = values.command;
|
|
117
|
+
|
|
118
|
+
if (positionals[0] === 'add') {
|
|
119
|
+
if (positionals.length < 2) {
|
|
120
|
+
console.error(
|
|
121
|
+
chalk.red('Error: Missing repository after "add" command')
|
|
122
|
+
);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
repo = positionals[1];
|
|
126
|
+
} else {
|
|
127
|
+
repo = positionals[0];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate repo format
|
|
131
|
+
let owner, repoName;
|
|
132
|
+
try {
|
|
133
|
+
({ owner, repo: repoName } = validateRepoFormat(repo));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Strip .md extension from command name if provided
|
|
140
|
+
if (command) {
|
|
141
|
+
command = stripMdExtension(command);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Install commands
|
|
145
|
+
try {
|
|
146
|
+
let result;
|
|
147
|
+
if (command) {
|
|
148
|
+
result = await installSpecificCommand(owner, repoName, command, {
|
|
149
|
+
ref: values.ref,
|
|
150
|
+
yes: values.yes,
|
|
151
|
+
quiet: values.quiet,
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
result = await installAllCommands(owner, repoName, {
|
|
155
|
+
ref: values.ref,
|
|
156
|
+
yes: values.yes,
|
|
157
|
+
quiet: values.quiet,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Exit with error if any installations failed
|
|
162
|
+
if (result.failed.length > 0) {
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('');
|
|
167
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
168
|
+
console.error('');
|
|
169
|
+
|
|
170
|
+
// Provide helpful troubleshooting hints
|
|
171
|
+
if (error.message.includes('Not found')) {
|
|
172
|
+
console.error(chalk.yellow('Troubleshooting:'));
|
|
173
|
+
console.error(' - Verify the repository exists and is public');
|
|
174
|
+
console.error(' - Check that the repository has a crush/commands/ directory');
|
|
175
|
+
console.error(' - Ensure the repository name and owner are spelled correctly');
|
|
176
|
+
} else if (error.message.includes('Network error')) {
|
|
177
|
+
console.error(chalk.yellow('Troubleshooting:'));
|
|
178
|
+
console.error(' - Check your internet connection');
|
|
179
|
+
console.error(' - Verify GitHub is accessible');
|
|
180
|
+
} else if (error.message.includes('rate limit')) {
|
|
181
|
+
console.error(chalk.yellow('Troubleshooting:'));
|
|
182
|
+
console.error(' - GitHub API rate limit exceeded');
|
|
183
|
+
console.error(' - Wait a while before trying again');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main().catch((error) => {
|
|
191
|
+
console.error(chalk.red(`Unexpected error: ${error.message}`));
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
package/lib/github.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import https from 'https';
|
|
2
|
+
|
|
3
|
+
const GITHUB_API_BASE = 'api.github.com';
|
|
4
|
+
const USER_AGENT = 'crush-commands/1.0.0';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetch from GitHub API
|
|
8
|
+
*/
|
|
9
|
+
async function fetchGitHub(path) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const options = {
|
|
12
|
+
hostname: GITHUB_API_BASE,
|
|
13
|
+
path: path,
|
|
14
|
+
method: 'GET',
|
|
15
|
+
headers: {
|
|
16
|
+
'User-Agent': USER_AGENT,
|
|
17
|
+
Accept: 'application/vnd.github.v3+json',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const req = https.request(options, (res) => {
|
|
22
|
+
let data = '';
|
|
23
|
+
|
|
24
|
+
res.on('data', (chunk) => {
|
|
25
|
+
data += chunk;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
res.on('end', () => {
|
|
29
|
+
if (res.statusCode === 200) {
|
|
30
|
+
try {
|
|
31
|
+
resolve(JSON.parse(data));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
reject(new Error('Failed to parse GitHub API response'));
|
|
34
|
+
}
|
|
35
|
+
} else if (res.statusCode === 404) {
|
|
36
|
+
reject(new Error('Not found'));
|
|
37
|
+
} else if (res.statusCode === 403) {
|
|
38
|
+
reject(new Error('GitHub API rate limit exceeded'));
|
|
39
|
+
} else {
|
|
40
|
+
reject(
|
|
41
|
+
new Error(
|
|
42
|
+
`GitHub API returned status ${res.statusCode}: ${res.statusMessage}`
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
req.on('error', (error) => {
|
|
50
|
+
reject(new Error(`Network error: ${error.message}`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
req.setTimeout(30000, () => {
|
|
54
|
+
req.destroy();
|
|
55
|
+
reject(new Error('Request timeout'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
req.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List all .md files in crush/commands directory
|
|
64
|
+
*/
|
|
65
|
+
export async function listCommandFiles(owner, repo, ref = null) {
|
|
66
|
+
let path = `/repos/${owner}/${repo}/contents/crush/commands`;
|
|
67
|
+
if (ref) {
|
|
68
|
+
path += `?ref=${encodeURIComponent(ref)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const data = await fetchGitHub(path);
|
|
73
|
+
|
|
74
|
+
if (!Array.isArray(data)) {
|
|
75
|
+
throw new Error('Not a directory');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return data
|
|
79
|
+
.filter((item) => item.type === 'file' && item.name.endsWith('.md'))
|
|
80
|
+
.map((item) => ({
|
|
81
|
+
name: item.name,
|
|
82
|
+
path: item.path,
|
|
83
|
+
size: item.size,
|
|
84
|
+
}));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.message === 'Not found') {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Could not find 'crush/commands' directory in ${owner}/${repo}. ` +
|
|
89
|
+
'Make sure the repository exists and contains a crush/commands folder.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fetch content of a specific file
|
|
98
|
+
*/
|
|
99
|
+
export async function fetchFileContent(owner, repo, filepath, ref = null) {
|
|
100
|
+
let path = `/repos/${owner}/${repo}/contents/${filepath}`;
|
|
101
|
+
if (ref) {
|
|
102
|
+
path += `?ref=${encodeURIComponent(ref)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const data = await fetchGitHub(path);
|
|
107
|
+
|
|
108
|
+
if (data.encoding === 'base64') {
|
|
109
|
+
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return data.content;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.message === 'Not found') {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`File not found: ${filepath} in ${owner}/${repo}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find command file by name (case-insensitive)
|
|
125
|
+
*/
|
|
126
|
+
export function findCommandFile(files, name) {
|
|
127
|
+
const normalizedName = name.toLowerCase();
|
|
128
|
+
return files.find(
|
|
129
|
+
(f) => f.name.toLowerCase() === `${normalizedName}.md`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get all command names available
|
|
135
|
+
*/
|
|
136
|
+
export function getAvailableCommands(files) {
|
|
137
|
+
return files.map((f) => f.name.replace(/\.md$/, ''));
|
|
138
|
+
}
|
package/lib/installer.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
listCommandFiles,
|
|
7
|
+
fetchFileContent,
|
|
8
|
+
findCommandFile,
|
|
9
|
+
getAvailableCommands,
|
|
10
|
+
} from './github.js';
|
|
11
|
+
import { getTargetDirectory, ensureDirectory, stripMdExtension } from './utils.js';
|
|
12
|
+
import { confirmOverwrite, askOverwritePreference } from './prompts.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Install all commands from a repo
|
|
16
|
+
*/
|
|
17
|
+
export async function installAllCommands(
|
|
18
|
+
owner,
|
|
19
|
+
repo,
|
|
20
|
+
options = {}
|
|
21
|
+
) {
|
|
22
|
+
const { ref = null, yes = false, quiet = false } = options;
|
|
23
|
+
|
|
24
|
+
log(quiet, chalk.blue(`Fetching commands from ${owner}/${repo}...`));
|
|
25
|
+
|
|
26
|
+
// List all command files
|
|
27
|
+
const files = await listCommandFiles(owner, repo, ref);
|
|
28
|
+
|
|
29
|
+
if (files.length === 0) {
|
|
30
|
+
log(quiet, chalk.yellow('No command files found in crush/commands/'));
|
|
31
|
+
return { installed: [], skipped: [], failed: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log(
|
|
35
|
+
quiet,
|
|
36
|
+
chalk.green(`Found ${files.length} command(s) to install`)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Install each command
|
|
40
|
+
return installCommands(owner, repo, files.map(f => f.name), { ref, yes, quiet });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Install a specific command
|
|
45
|
+
*/
|
|
46
|
+
export async function installSpecificCommand(
|
|
47
|
+
owner,
|
|
48
|
+
repo,
|
|
49
|
+
commandName,
|
|
50
|
+
options = {}
|
|
51
|
+
) {
|
|
52
|
+
const { ref = null, yes = false, quiet = false } = options;
|
|
53
|
+
|
|
54
|
+
log(quiet, chalk.blue(`Fetching command '${commandName}' from ${owner}/${repo}...`));
|
|
55
|
+
|
|
56
|
+
// List all command files
|
|
57
|
+
const files = await listCommandFiles(owner, repo, ref);
|
|
58
|
+
|
|
59
|
+
// Find the specific command
|
|
60
|
+
const name = stripMdExtension(commandName);
|
|
61
|
+
const file = findCommandFile(files, name);
|
|
62
|
+
|
|
63
|
+
if (!file) {
|
|
64
|
+
const available = getAvailableCommands(files);
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Command '${commandName}' not found. ` +
|
|
67
|
+
(available.length > 0
|
|
68
|
+
? `Available commands: ${available.join(', ')}`
|
|
69
|
+
: 'No commands available in this repository.')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return installCommands(owner, repo, [file.name], { ref, yes, quiet });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Install commands by file names
|
|
78
|
+
*/
|
|
79
|
+
async function installCommands(
|
|
80
|
+
owner,
|
|
81
|
+
repo,
|
|
82
|
+
fileNames,
|
|
83
|
+
options = {}
|
|
84
|
+
) {
|
|
85
|
+
const { ref = null, yes = false, quiet = false } = options;
|
|
86
|
+
const targetDir = getTargetDirectory();
|
|
87
|
+
|
|
88
|
+
await ensureDirectory(targetDir);
|
|
89
|
+
|
|
90
|
+
const installed = [];
|
|
91
|
+
const skipped = [];
|
|
92
|
+
const failed = [];
|
|
93
|
+
|
|
94
|
+
let overwritePreference = null;
|
|
95
|
+
let yesToAll = yes;
|
|
96
|
+
let noToAll = false;
|
|
97
|
+
|
|
98
|
+
// Check for existing files
|
|
99
|
+
const existingFiles = [];
|
|
100
|
+
for (const fileName of fileNames) {
|
|
101
|
+
const commandName = fileName.replace(/\.md$/, '');
|
|
102
|
+
const targetPath = path.join(targetDir, fileName);
|
|
103
|
+
try {
|
|
104
|
+
await fs.access(targetPath);
|
|
105
|
+
existingFiles.push(commandName);
|
|
106
|
+
} catch {
|
|
107
|
+
// File doesn't exist, that's fine
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Ask about overwrites if needed
|
|
112
|
+
if (existingFiles.length > 0 && !yes) {
|
|
113
|
+
overwritePreference = await askOverwritePreference(existingFiles.length);
|
|
114
|
+
if (overwritePreference === 'all') {
|
|
115
|
+
yesToAll = true;
|
|
116
|
+
} else if (overwritePreference === 'none') {
|
|
117
|
+
noToAll = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Install each command
|
|
122
|
+
for (const fileName of fileNames) {
|
|
123
|
+
const commandName = fileName.replace(/\.md$/, '');
|
|
124
|
+
const targetPath = path.join(targetDir, fileName);
|
|
125
|
+
const sourcePath = `crush/commands/${fileName}`;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
// Check if file exists
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(targetPath);
|
|
131
|
+
if (noToAll) {
|
|
132
|
+
log(quiet, chalk.gray(` Skipped ${commandName} (already exists)`));
|
|
133
|
+
skipped.push(commandName);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!yesToAll) {
|
|
137
|
+
const shouldOverwrite = await confirmOverwrite(
|
|
138
|
+
commandName,
|
|
139
|
+
yesToAll,
|
|
140
|
+
noToAll
|
|
141
|
+
);
|
|
142
|
+
if (!shouldOverwrite) {
|
|
143
|
+
log(quiet, chalk.gray(` Skipped ${commandName}`));
|
|
144
|
+
skipped.push(commandName);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// File doesn't exist, that's fine
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Fetch content
|
|
153
|
+
const content = await fetchFileContent(owner, repo, sourcePath, ref);
|
|
154
|
+
|
|
155
|
+
// Write file
|
|
156
|
+
await fs.writeFile(targetPath, content, 'utf-8');
|
|
157
|
+
|
|
158
|
+
log(quiet, chalk.green(` ✓ Installed ${commandName}`));
|
|
159
|
+
installed.push(commandName);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
log(quiet, chalk.red(` ✗ Failed to install ${commandName}: ${error.message}`));
|
|
162
|
+
failed.push({ command: commandName, error: error.message });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Print summary
|
|
167
|
+
if (!quiet) {
|
|
168
|
+
console.log('');
|
|
169
|
+
if (installed.length > 0) {
|
|
170
|
+
console.log(chalk.green(`Installed ${installed.length} command(s):`));
|
|
171
|
+
installed.forEach((name) => console.log(` - ${name}`));
|
|
172
|
+
}
|
|
173
|
+
if (skipped.length > 0) {
|
|
174
|
+
console.log(chalk.yellow(`Skipped ${skipped.length} command(s):`));
|
|
175
|
+
skipped.forEach((name) => console.log(` - ${name}`));
|
|
176
|
+
}
|
|
177
|
+
if (failed.length > 0) {
|
|
178
|
+
console.log(chalk.red(`Failed to install ${failed.length} command(s):`));
|
|
179
|
+
failed.forEach(({ command, error }) => console.log(` - ${command}: ${error}`));
|
|
180
|
+
}
|
|
181
|
+
if (installed.length > 0) {
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(chalk.blue('Next steps:'));
|
|
184
|
+
console.log(' Run `crush reload` to refresh your commands');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { installed, skipped, failed };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function log(quiet, message) {
|
|
192
|
+
if (!quiet) {
|
|
193
|
+
console.log(message);
|
|
194
|
+
}
|
|
195
|
+
}
|
package/lib/prompts.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ask user for confirmation to overwrite a file
|
|
5
|
+
*/
|
|
6
|
+
export async function confirmOverwrite(commandName, yesToAll, noToAll) {
|
|
7
|
+
if (yesToAll) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
if (noToAll) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const answer = await inquirer.prompt([
|
|
15
|
+
{
|
|
16
|
+
type: 'confirm',
|
|
17
|
+
name: 'overwrite',
|
|
18
|
+
message: `Overwrite existing command '${commandName}'?`,
|
|
19
|
+
default: false,
|
|
20
|
+
},
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
return answer.overwrite;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ask user for overwrite preference (yes, no, yes to all, no to all)
|
|
28
|
+
*/
|
|
29
|
+
export async function askOverwritePreference(fileCount) {
|
|
30
|
+
const answer = await inquirer.prompt([
|
|
31
|
+
{
|
|
32
|
+
type: 'list',
|
|
33
|
+
name: 'preference',
|
|
34
|
+
message: `${fileCount} command(s) already exist. How would you like to proceed?`,
|
|
35
|
+
choices: [
|
|
36
|
+
{ name: 'Ask for each file individually', value: 'ask' },
|
|
37
|
+
{ name: 'Overwrite all', value: 'all' },
|
|
38
|
+
{ name: 'Skip all', value: 'none' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
return answer.preference;
|
|
44
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand ~ to user home directory
|
|
6
|
+
*/
|
|
7
|
+
export function expandHome(filepath) {
|
|
8
|
+
if (filepath.startsWith('~')) {
|
|
9
|
+
return path.join(os.homedir(), filepath.slice(1));
|
|
10
|
+
}
|
|
11
|
+
return filepath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get default target directory for commands
|
|
16
|
+
*/
|
|
17
|
+
export function getTargetDirectory() {
|
|
18
|
+
return expandHome('~/.crush/commands');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate repository format (owner/repo)
|
|
23
|
+
*/
|
|
24
|
+
export function validateRepoFormat(repo) {
|
|
25
|
+
const parts = repo.split('/');
|
|
26
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Invalid repository format. Expected format: <owner>/<repo>'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return { owner: parts[0], repo: parts[1] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip .md extension from command name
|
|
36
|
+
*/
|
|
37
|
+
export function stripMdExtension(name) {
|
|
38
|
+
if (name.toLowerCase().endsWith('.md')) {
|
|
39
|
+
return name.slice(0, -3);
|
|
40
|
+
}
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if file exists (basic check)
|
|
46
|
+
*/
|
|
47
|
+
export async function fileExists(filepath) {
|
|
48
|
+
try {
|
|
49
|
+
await import('fs').then((fs) =>
|
|
50
|
+
fs.promises.access(filepath, fs.constants.F_OK)
|
|
51
|
+
);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ensure directory exists, create if not
|
|
60
|
+
*/
|
|
61
|
+
export async function ensureDirectory(dirpath) {
|
|
62
|
+
const fs = await import('fs');
|
|
63
|
+
await fs.promises.mkdir(dirpath, { recursive: true });
|
|
64
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crush-commands",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Install Crush commands from GitHub repositories",
|
|
5
|
+
"main": "lib/installer.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crush-commands": "./bin/crush-commands.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18.0.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/**/*.test.js",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"format": "prettier --write ."
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"crush",
|
|
20
|
+
"commands",
|
|
21
|
+
"github",
|
|
22
|
+
"installer",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^5.3.0",
|
|
29
|
+
"inquirer": "^9.2.12",
|
|
30
|
+
"user": "^0.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"eslint": "^8.56.0",
|
|
34
|
+
"prettier": "^3.1.1"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# PRD: Crush Commands Installer
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
A Node.js CLI library called `crush-commands` that can be invoked via `npx` to install Crush command documentation files from a GitHub repository into `~/.crush/commands`. The tool provides a simple interface to either install all commands from a repo or install a specific command by name. Files are sourced from the `crush/commands/` directory in the target repository.
|
|
6
|
+
|
|
7
|
+
## Goals
|
|
8
|
+
|
|
9
|
+
- Enable installing all Crush commands from a repo via `npx crush-commands add <owner>/<repo>`
|
|
10
|
+
- Enable installing a specific command via `npx crush-commands <owner>/<repo> --command <name>`
|
|
11
|
+
- Provide a frictionless experience for extending Crush with custom commands
|
|
12
|
+
- Ensure safe file installation with confirmation before overwriting
|
|
13
|
+
- Source command files from `crush/commands/` directory in target repo
|
|
14
|
+
- Deliver clear, helpful error messages with troubleshooting guidance
|
|
15
|
+
- Require zero configuration for basic use cases
|
|
16
|
+
|
|
17
|
+
## User Stories
|
|
18
|
+
|
|
19
|
+
### US-001: Parse CLI arguments and commands
|
|
20
|
+
|
|
21
|
+
**Description:** As a user, I want to use a simple CLI format so that I can easily install commands.
|
|
22
|
+
|
|
23
|
+
**Acceptance Criteria:**
|
|
24
|
+
|
|
25
|
+
- [ ] Support `npx crush-commands add <owner>/<repo>` to install all commands
|
|
26
|
+
- [ ] Support `npx crush-commands <owner>/<repo> --command <name>` to install specific command
|
|
27
|
+
- [ ] Validate repo format (owner/repo)
|
|
28
|
+
- [ ] Show help message with `--help` flag
|
|
29
|
+
- [ ] Show version with `--version` flag
|
|
30
|
+
- [ ] Validate required arguments and provide clear error if missing
|
|
31
|
+
- [ ] Validate <name> doesn't include .md extension (strip if provided)
|
|
32
|
+
- [ ] Typecheck passes
|
|
33
|
+
|
|
34
|
+
### US-002: Fetch crush/commands directory from GitHub API
|
|
35
|
+
|
|
36
|
+
**Description:** As a system, I need to list all .md files in the crush/commands directory so that I can find command files.
|
|
37
|
+
|
|
38
|
+
**Acceptance Criteria:**
|
|
39
|
+
|
|
40
|
+
- [ ] Use GitHub REST API to fetch directory listing for `crush/commands/`
|
|
41
|
+
- [ ] Handle both default branch and specific branch/tag references (via optional `--ref` flag)
|
|
42
|
+
- [ ] Parse GitHub API response to extract file list
|
|
43
|
+
- [ ] Filter to only .md files
|
|
44
|
+
- [ ] Handle 404 errors if repo or directory doesn't exist
|
|
45
|
+
- [ ] Follow API rate limiting with proper error handling
|
|
46
|
+
- [ ] Handle 404 gracefully if crush/commands/ doesn't exist with helpful message
|
|
47
|
+
- [ ] Typecheck passes
|
|
48
|
+
|
|
49
|
+
### US-003: Resolve command files to install
|
|
50
|
+
|
|
51
|
+
**Description:** As a system, I need to determine which files to install based on the command used.
|
|
52
|
+
|
|
53
|
+
**Acceptance Criteria:**
|
|
54
|
+
|
|
55
|
+
- [ ] For `add` command: include all .md files in crush/commands/ directory
|
|
56
|
+
- [ ] For `--command <name>`: include only crush/commands/<name>.md
|
|
57
|
+
- [ ] Validate that specified command exists when using --command
|
|
58
|
+
- [ ] Return ordered list of files to install with full paths
|
|
59
|
+
- [ ] Handle case where no .md files found in directory
|
|
60
|
+
- [ ] Show list of available commands when --command specifies invalid name
|
|
61
|
+
- [ ] Typecheck passes
|
|
62
|
+
|
|
63
|
+
### US-004: Fetch file content from GitHub API
|
|
64
|
+
|
|
65
|
+
**Description:** As a system, I need to retrieve the actual content of each command file.
|
|
66
|
+
|
|
67
|
+
**Acceptance Criteria:**
|
|
68
|
+
|
|
69
|
+
- [ ] Use GitHub REST API to fetch raw content for each file
|
|
70
|
+
- [ ] Handle git blob content encoding (base64, utf-8)
|
|
71
|
+
- [ ] Parse content correctly with proper encoding
|
|
72
|
+
- [ ] Handle fetch failures for individual files with clear error messages
|
|
73
|
+
- [ ] Support fetching from specific branch/tag if --ref flag provided
|
|
74
|
+
- [ ] Typecheck passes
|
|
75
|
+
|
|
76
|
+
### US-005: Check for existing files and prompt for overwrite
|
|
77
|
+
|
|
78
|
+
**Description:** As a user, I want to be asked before overwriting existing commands so that I don't accidentally lose customizations.
|
|
79
|
+
|
|
80
|
+
**Acceptance Criteria:**
|
|
81
|
+
|
|
82
|
+
- [ ] Check if target files exist in ~/.crush/commands directory
|
|
83
|
+
- [ ] For each existing file, display command name and ask for confirmation
|
|
84
|
+
- [ ] Support "yes", "no", "yes to all", "no to all" responses
|
|
85
|
+
- [ ] Remember user's choice for remaining files
|
|
86
|
+
- [ ] Show count of commands that will be overwritten
|
|
87
|
+
- [ ] Allow `-y/--yes` flag to skip prompts for CI/automation
|
|
88
|
+
- [ ] Typecheck passes
|
|
89
|
+
|
|
90
|
+
### US-006: Write files to ~/.crush/commands
|
|
91
|
+
|
|
92
|
+
**Description:** As a system, I need to write command files with proper permissions so they're usable immediately.
|
|
93
|
+
|
|
94
|
+
**Acceptance Criteria:**
|
|
95
|
+
|
|
96
|
+
- [ ] Create ~/.crush/commands directory if it doesn't exist
|
|
97
|
+
- [ ] Write each command file to destination with proper permissions (644)
|
|
98
|
+
- [ ] Use the command name (without .md) as filename: ~/.crush/commands/<name>.md
|
|
99
|
+
- [ ] Show progress indicator for each command (verbose mode)
|
|
100
|
+
- [ ] Handle write failures gracefully with clear error messages
|
|
101
|
+
- [ ] Rollback partially installed files on critical failure
|
|
102
|
+
- [ ] Typecheck passes
|
|
103
|
+
|
|
104
|
+
### US-007: Display success summary and file locations
|
|
105
|
+
|
|
106
|
+
**Description:** As a user, I want to see what was installed so that I can verify and use the new commands.
|
|
107
|
+
|
|
108
|
+
**Acceptance Criteria:**
|
|
109
|
+
|
|
110
|
+
- [ ] Show list of installed commands with their names
|
|
111
|
+
- [ ] Display count of commands installed
|
|
112
|
+
- [ ] Show any skipped commands and reason
|
|
113
|
+
- [ ] Provide next steps (e.g., "Run `crush reload` to refresh commands")
|
|
114
|
+
- [ ] Format output clearly
|
|
115
|
+
- [ ] Support `--quiet` flag to suppress non-essential output
|
|
116
|
+
- [ ] Typecheck passes
|
|
117
|
+
|
|
118
|
+
### US-008: Handle errors with friendly messages and troubleshooting
|
|
119
|
+
|
|
120
|
+
**Description:** As a user, I want helpful error messages so that I can quickly diagnose and fix issues.
|
|
121
|
+
|
|
122
|
+
**Acceptance Criteria:**
|
|
123
|
+
|
|
124
|
+
- [ ] Network errors: suggest checking internet connection
|
|
125
|
+
- [ ] 404 errors: suggest verifying repo URL and that crush/commands/ exists
|
|
126
|
+
- [ ] Rate limiting: suggest waiting and retrying
|
|
127
|
+
- [ ] Permission errors: suggest checking write permissions on ~/.crush/commands
|
|
128
|
+
- [ ] Invalid command name: show list of available commands
|
|
129
|
+
- [ ] GitHub API errors: display error code and suggest checking if repo is public
|
|
130
|
+
- [ ] Exit with appropriate error codes (0=success, 1=error)
|
|
131
|
+
- [ ] Typecheck passes
|
|
132
|
+
|
|
133
|
+
### US-009: Package as npx-executable Node library
|
|
134
|
+
|
|
135
|
+
**Description:** As a user, I want to run the tool with `npx` without installation so that it's always available.
|
|
136
|
+
|
|
137
|
+
**Acceptance Criteria:**
|
|
138
|
+
|
|
139
|
+
- [ ] Package as executable npm package with proper bin entry named `crush-commands`
|
|
140
|
+
- [ ] Include Node.js engine requirement in package.json
|
|
141
|
+
- [ ] Set up proper file structure for npm publishing
|
|
142
|
+
- [ ] Test npx execution: `npx crush-commands add owner/repo`
|
|
143
|
+
- [ ] Test npx execution: `npx crush-commands owner/repo --command mycommand`
|
|
144
|
+
- [ ] Include README with usage examples
|
|
145
|
+
- [ ] Publish to npm registry (or provide install instructions)
|
|
146
|
+
- [ ] Typecheck passes
|
|
147
|
+
|
|
148
|
+
## Functional Requirements
|
|
149
|
+
|
|
150
|
+
- FR-1: Parse command format: `add <owner>/<repo>` for all commands, or `<owner>/<repo> --command <name>` for specific command
|
|
151
|
+
- FR-2: Validate repository format and extract owner/repo components
|
|
152
|
+
- FR-3: Fetch directory listing from GitHub API for `crush/commands/` path in target repo
|
|
153
|
+
- FR-4: Parse GitHub API response to extract all .md files from the directory
|
|
154
|
+
- FR-5: When `add` command is used: include all .md files from crush/commands/
|
|
155
|
+
- FR-6: When `--command <name>` flag is used: include only crush/commands/<name>.md
|
|
156
|
+
- FR-7: Strip .md extension from command name if user provides it
|
|
157
|
+
- FR-8: Validate that specified command exists when using --command flag
|
|
158
|
+
- FR-9: Fetch raw content of each file from GitHub API
|
|
159
|
+
- FR-10: Decode file content properly (handle base64 and utf-8 encodings)
|
|
160
|
+
- FR-11: Check for existing files in ~/.crush/commands directory before writing
|
|
161
|
+
- FR-12: Prompt user for confirmation before overwriting existing files
|
|
162
|
+
- FR-13: Support `-y/--yes` flag to skip all prompts
|
|
163
|
+
- FR-14: Create ~/.crush/commands directory if it doesn't exist
|
|
164
|
+
- FR-15: Write command files to ~/.crush/commands/<name>.md with appropriate permissions (644)
|
|
165
|
+
- FR-16: Display progress, success summary, and next steps to user
|
|
166
|
+
- FR-17: Provide friendly error messages with troubleshooting hints for all error cases
|
|
167
|
+
- FR-18: Exit with appropriate error codes (0 for success, non-zero for errors)
|
|
168
|
+
- FR-19: Support optional `--ref` flag to specify branch or tag (defaults to default branch)
|
|
169
|
+
- FR-20: Support `--help` and `--version` flags
|
|
170
|
+
- FR-21: Support `--quiet` flag to suppress non-essential output
|
|
171
|
+
|
|
172
|
+
## Non-Goals
|
|
173
|
+
|
|
174
|
+
- No support for private GitHub repositories (requires authentication tokens)
|
|
175
|
+
- No support for installing files outside of crush/commands/ directory
|
|
176
|
+
- No support for installing non-.md files
|
|
177
|
+
- No support for glob patterns or wildcards (must use specific command names or "add" for all)
|
|
178
|
+
- No support for other version control systems (GitLab, Bitbucket, etc.)
|
|
179
|
+
- No web UI or interactive prompts beyond overwrite confirmation
|
|
180
|
+
- No background monitoring or auto-update capabilities
|
|
181
|
+
- No command execution or testing of installed commands
|
|
182
|
+
- No support for installing commands from local directories
|
|
183
|
+
- No command aliasing or renaming during installation
|
|
184
|
+
|
|
185
|
+
## Design Considerations
|
|
186
|
+
|
|
187
|
+
- **CLI UX**: Follow common CLI conventions (flags, help text, exit codes)
|
|
188
|
+
- **User feedback**: Provide clear, human-readable messages at each step
|
|
189
|
+
- **Error recovery**: Continue installation if non-critical errors occur (e.g., skip one file, continue with others)
|
|
190
|
+
- **Progress indication**: Show what's happening during installation (especially for multiple files)
|
|
191
|
+
- **Backward compatibility**: Ensure default target directory (`~/.crush/commands`) works even if Crush changes structure
|
|
192
|
+
|
|
193
|
+
## Technical Considerations
|
|
194
|
+
|
|
195
|
+
- **Dependencies**: Minimal dependencies - use Node.js built-in modules where possible
|
|
196
|
+
- Use native `https` or `fetch` (Node 18+) for HTTP requests
|
|
197
|
+
- `inquirer` or native `readline` for user prompts
|
|
198
|
+
- `chalk` for colored output (optional, enhance readability)
|
|
199
|
+
- **GitHub API**: Use `https://api.github.com` endpoints
|
|
200
|
+
- Endpoint for directory listing: `GET /repos/{owner}/{repo}/contents/crush/commands/{path}`
|
|
201
|
+
- Endpoint for file content: `GET /repos/{owner}/{repo}/contents/crush/commands/{file}`
|
|
202
|
+
- Rate limit: 60 requests/hour for unauthenticated IP
|
|
203
|
+
- Support `?ref={branch|tag|sha}` parameter for specific references
|
|
204
|
+
- **Target directory**: Always use `~/.crush/commands` (expand `~` to user home directory)
|
|
205
|
+
- **File naming**: Strip .md extension from source files, write as `<name>.md` in destination
|
|
206
|
+
- **Encoding**: All command files are markdown (UTF-8), decode base64 if API returns it
|
|
207
|
+
- **Node.js version**: Target Node 18+ for native fetch support
|
|
208
|
+
- **Package structure**:
|
|
209
|
+
```
|
|
210
|
+
crush-commands/
|
|
211
|
+
├── package.json
|
|
212
|
+
├── README.md
|
|
213
|
+
├── bin/
|
|
214
|
+
│ └── crush-commands.js
|
|
215
|
+
├── lib/
|
|
216
|
+
│ ├── github.js (API client)
|
|
217
|
+
│ ├── installer.js (core logic)
|
|
218
|
+
│ ├── prompts.js (user interaction)
|
|
219
|
+
│ └── utils.js (helpers)
|
|
220
|
+
└── test/
|
|
221
|
+
├── github.test.js
|
|
222
|
+
├── installer.test.js
|
|
223
|
+
└── e2e.test.js
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Success Metrics
|
|
227
|
+
|
|
228
|
+
- Users can install all commands with one command: `npx crush-commands add owner/repo`
|
|
229
|
+
- Users can install a specific command: `npx crush-commands owner/repo --command mycommand`
|
|
230
|
+
- Installation completes in under 5 seconds for typical command files
|
|
231
|
+
- Error messages successfully guide users to resolution in under 2 attempts
|
|
232
|
+
- Zero data loss incidents (overwrites always confirmed)
|
|
233
|
+
- Works with all public GitHub repos with crush/commands/ directory
|
|
234
|
+
- Command name matching is case-insensitive or matches exactly (needs decision)
|
|
235
|
+
|
|
236
|
+
## Open Questions
|
|
237
|
+
|
|
238
|
+
- Should command name matching be case-insensitive (e.g., `MyCommand` matches `mycommand.md`)?
|
|
239
|
+
- Should we add a `--list` flag to show available commands without installing?
|
|
240
|
+
- Should we support a `--dry-run` flag to preview changes without installing?
|
|
241
|
+
- Should command names with spaces be supported (e.g., `--command "my command"`)?
|
|
242
|
+
- Should we validate that installed .md files are valid markdown or Crush command format?
|
|
243
|
+
- What's the maximum number of commands we should allow in a single `add` operation?
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import {
|
|
4
|
+
findCommandFile,
|
|
5
|
+
getAvailableCommands,
|
|
6
|
+
} from '../lib/github.js';
|
|
7
|
+
|
|
8
|
+
describe('github', () => {
|
|
9
|
+
describe('findCommandFile', () => {
|
|
10
|
+
const mockFiles = [
|
|
11
|
+
{ name: 'command1.md', path: 'crush/commands/command1.md', size: 100 },
|
|
12
|
+
{ name: 'command2.md', path: 'crush/commands/command2.md', size: 200 },
|
|
13
|
+
{ name: 'MyCommand.md', path: 'crush/commands/MyCommand.md', size: 150 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
it('should find command by name (case-insensitive)', () => {
|
|
17
|
+
const result = findCommandFile(mockFiles, 'command1');
|
|
18
|
+
assert.ok(result);
|
|
19
|
+
assert.strictEqual(result.name, 'command1.md');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should find command with different case', () => {
|
|
23
|
+
const result = findCommandFile(mockFiles, 'COMMAND1');
|
|
24
|
+
assert.ok(result);
|
|
25
|
+
assert.strictEqual(result.name, 'command1.md');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should find command with mixed case', () => {
|
|
29
|
+
const result = findCommandFile(mockFiles, 'mycommand');
|
|
30
|
+
assert.ok(result);
|
|
31
|
+
assert.strictEqual(result.name, 'MyCommand.md');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return undefined for non-existent command', () => {
|
|
35
|
+
const result = findCommandFile(mockFiles, 'nonexistent');
|
|
36
|
+
assert.strictEqual(result, undefined);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getAvailableCommands', () => {
|
|
41
|
+
const mockFiles = [
|
|
42
|
+
{ name: 'command1.md', path: 'crush/commands/command1.md', size: 100 },
|
|
43
|
+
{ name: 'command2.md', path: 'crush/commands/command2.md', size: 200 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
it('should return command names without .md extension', () => {
|
|
47
|
+
const result = getAvailableCommands(mockFiles);
|
|
48
|
+
assert.deepStrictEqual(result, ['command1', 'command2']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return empty array for no files', () => {
|
|
52
|
+
const result = getAvailableCommands([]);
|
|
53
|
+
assert.deepStrictEqual(result, []);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import {
|
|
4
|
+
expandHome,
|
|
5
|
+
getTargetDirectory,
|
|
6
|
+
validateRepoFormat,
|
|
7
|
+
stripMdExtension,
|
|
8
|
+
} from '../lib/utils.js';
|
|
9
|
+
|
|
10
|
+
describe('utils', () => {
|
|
11
|
+
describe('expandHome', () => {
|
|
12
|
+
it('should expand ~ to home directory', () => {
|
|
13
|
+
const result = expandHome('~/test');
|
|
14
|
+
assert.ok(result.includes('/test'));
|
|
15
|
+
assert.ok(!result.startsWith('~'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should not modify paths without ~', () => {
|
|
19
|
+
const result = expandHome('/test/path');
|
|
20
|
+
assert.strictEqual(result, '/test/path');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getTargetDirectory', () => {
|
|
25
|
+
it('should return ~/.crush/commands with ~ expanded', () => {
|
|
26
|
+
const result = getTargetDirectory();
|
|
27
|
+
assert.ok(result.endsWith('.crush/commands'));
|
|
28
|
+
assert.ok(!result.includes('~'));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('validateRepoFormat', () => {
|
|
33
|
+
it('should validate correct owner/repo format', () => {
|
|
34
|
+
const result = validateRepoFormat('owner/repo');
|
|
35
|
+
assert.deepStrictEqual(result, { owner: 'owner', repo: 'repo' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should validate format with hyphens', () => {
|
|
39
|
+
const result = validateRepoFormat('my-user/my-repo');
|
|
40
|
+
assert.deepStrictEqual(result, { owner: 'my-user', repo: 'my-repo' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should throw error for missing owner', () => {
|
|
44
|
+
assert.throws(() => {
|
|
45
|
+
validateRepoFormat('/repo');
|
|
46
|
+
}, /Invalid repository format/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw error for missing repo', () => {
|
|
50
|
+
assert.throws(() => {
|
|
51
|
+
validateRepoFormat('owner/');
|
|
52
|
+
}, /Invalid repository format/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw error for single component', () => {
|
|
56
|
+
assert.throws(() => {
|
|
57
|
+
validateRepoFormat('owner');
|
|
58
|
+
}, /Invalid repository format/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should throw error for too many components', () => {
|
|
62
|
+
assert.throws(() => {
|
|
63
|
+
validateRepoFormat('owner/repo/extra');
|
|
64
|
+
}, /Invalid repository format/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('stripMdExtension', () => {
|
|
69
|
+
it('should strip .md extension', () => {
|
|
70
|
+
assert.strictEqual(stripMdExtension('command.md'), 'command');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not modify name without .md', () => {
|
|
74
|
+
assert.strictEqual(stripMdExtension('command'), 'command');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should handle uppercase .MD', () => {
|
|
78
|
+
assert.strictEqual(stripMdExtension('command.MD'), 'command');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle mixed case', () => {
|
|
82
|
+
assert.strictEqual(stripMdExtension('command.Md'), 'command');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|