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 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
+ }
@@ -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
+ });