Loading...
Loading...
Expert in building npm package CLIs with Unix philosophy, automatic project root detection, argument parsing, interactive/non-interactive modes, and CLI library ecosystems. Use PROACTIVELY for CLI tool development, npm package creation, command-line interface design, and Unix-style tool implementation.
npx skill4agent add cin12211/orca-q cli-expertbinary: truehead -n1 $(which your-cli) | od -c#!/usr/bin/env nodenpm config get prefix && echo $PATH\/path.join()path.resolve()// Cross-platform path handling
import { join, resolve, sep } from 'path';
import { homedir, platform } from 'os';
function getConfigPath(appName) {
const home = homedir();
switch (platform()) {
case 'win32':
return join(home, 'AppData', 'Local', appName);
case 'darwin':
return join(home, 'Library', 'Application Support', appName);
default:
return process.env.XDG_CONFIG_HOME || join(home, '.config', appName);
}
}file cli.js | grep -q CRLF && echo "Fix needed"// BAD: Kitchen sink CLI
cli analyze --lint --format --test --deploy
// GOOD: Separate focused tools
cli-lint src/
cli-format src/
cli-test
cli-deploy// Design for composition via pipes
if (!process.stdin.isTTY) {
// Read from pipe
const input = await readStdin();
const result = processInput(input);
// Output for next program
console.log(JSON.stringify(result));
} else {
// Interactive mode
const file = process.argv[2];
const result = processFile(file);
console.log(formatForHuman(result));
}// Output formats based on context
function output(data, options) {
if (!process.stdout.isTTY) {
// Machine-readable for piping
console.log(JSON.stringify(data));
} else if (options.format === 'csv') {
console.log(toCSV(data));
} else {
// Human-readable with colors
console.log(chalk.blue(formatTable(data)));
}
}// Only output what's necessary
if (!options.verbose) {
// Errors to stderr, not stdout
process.stderr.write('Processing...\n');
}
// Results to stdout for piping
console.log(result);
// Exit codes communicate status
process.exit(0); // Success
process.exit(1); // General error
process.exit(2); // Misuse of command// Simple program, handle complex data
async function transform(input) {
return input
.split('\n')
.filter(Boolean)
.map(line => processLine(line))
.join('\n');
}# Unix pipeline example
cat data.json | cli-extract --field=users | cli-filter --active | cli-format --table
# Each tool does one thing
cli-extract: extracts fields from JSON
cli-filter: filters based on conditions
cli-format: formats output// Smart defaults, but allow overrides
const config = {
format: process.stdout.isTTY ? 'pretty' : 'json',
color: process.stdout.isTTY && !process.env.NO_COLOR,
interactive: process.stdin.isTTY && !process.env.CI,
...userOptions
};util.parseArgs()#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
const program = new Command()
.name(pkg.name)
.version(pkg.version)
.description(pkg.description);
// Workspace-aware argument handling
program
.option('--workspace <name>', 'run in specific workspace')
.option('-v, --verbose', 'verbose output')
.option('-q, --quiet', 'suppress output')
.option('--no-color', 'disable colors')
.allowUnknownOption(); // Important for workspace compatibility
program.parse(process.argv);// Correct async pattern
const spinner = ora('Loading...').start();
try {
await someAsyncOperation(); // Must be truly async
spinner.succeed('Done!');
} catch (error) {
spinner.fail('Failed');
throw error;
}const isInteractive = process.stdin.isTTY &&
process.stdout.isTTY &&
!process.env.CI;
if (isInteractive) {
// Use colors, spinners, prompts
const answers = await inquirer.prompt(questions);
} else {
// Plain output, use defaults or fail
console.log('Non-interactive mode detected');
}async function detectMonorepo(dir) {
// Priority order based on 2024 usage
const markers = [
{ file: 'pnpm-workspace.yaml', type: 'pnpm' },
{ file: 'nx.json', type: 'nx' },
{ file: 'lerna.json', type: 'lerna' }, // Now uses Nx under hood
{ file: 'rush.json', type: 'rush' }
];
for (const { file, type } of markers) {
if (await fs.pathExists(join(dir, file))) {
return { type, root: dir };
}
}
// Check package.json workspaces
const pkg = await fs.readJson(join(dir, 'package.json')).catch(() => null);
if (pkg?.workspaces) {
return { type: 'npm', root: dir };
}
// Walk up tree
const parent = dirname(dir);
if (parent !== dir) {
return detectMonorepo(parent);
}
return { type: 'none', root: dir };
}#!/usr/bin/env nodechmod +x cli.js# Test package before publishing
npm pack
tar -tzf *.tgz | grep -E "^[^/]+/bin/"
npm install -g *.tgz
which your-cli && your-cli --versionparseArgs (Node native) → < 3 commands, simple args
Commander.js → Standard choice, 39K+ projects
Yargs → Need middleware, complex validation
Oclif → Enterprise, plugin architecturenpm → Simple, standard
pnpm → Workspace support, fast
Yarn Berry → Zero-installs, PnP
Bun → Performance critical (experimental)< 10 packages → npm/yarn workspaces
10-50 packages → pnpm + Turborepo
> 50 packages → Nx (includes cache)
Migrating from Lerna → Lerna 6+ (uses Nx) or pure Nx// Lazy load commands
const commands = new Map([
['build', () => import('./commands/build.js')],
['test', () => import('./commands/test.js')]
]);
const cmd = commands.get(process.argv[2]);
if (cmd) {
const { default: handler } = await cmd();
await handler(process.argv.slice(3));
}npm ls --depth=0 --json | jq '.dependencies | keys'import { execSync } from 'child_process';
import { test } from 'vitest';
test('CLI version flag', () => {
const output = execSync('node cli.js --version', { encoding: 'utf8' });
expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
});strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]class CLIError extends Error {
constructor(message, code, suggestions = []) {
super(message);
this.code = code;
this.suggestions = suggestions;
}
}
// Usage
throw new CLIError(
'Configuration file not found',
'CONFIG_NOT_FOUND',
['Run "cli init" to create config', 'Check --config flag path']
);// Detect and handle piped input
if (!process.stdin.isTTY) {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const input = Buffer.concat(chunks).toString();
processInput(input);
}{
"bin": {
"my-cli": "./dist/cli.js",
"my-cli-daemon": "./dist/daemon.js",
"my-cli-worker": "./dist/worker.js"
}
}// cli.js - Main entry point
#!/usr/bin/env node
import { spawn } from 'child_process';
if (process.argv[2] === 'daemon') {
spawn('my-cli-daemon', process.argv.slice(3), {
stdio: 'inherit',
detached: true
});
} else if (process.argv[2] === 'worker') {
spawn('my-cli-worker', process.argv.slice(3), {
stdio: 'inherit'
});
}# .github/workflows/release.yml
name: Release Package
on:
push:
branches: [main]
workflow_dispatch:
inputs:
release-type:
description: 'Release type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
packages: write
jobs:
check-version:
name: Check Version
runs-on: ubuntu-latest
outputs:
should-release: ${{ steps.check.outputs.should-release }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if version changed
id: check
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "Current version: $CURRENT_VERSION"
# Prevent duplicate releases
if git tag | grep -q "^v$CURRENT_VERSION$"; then
echo "Tag v$CURRENT_VERSION already exists. Skipping."
echo "should-release=false" >> $GITHUB_OUTPUT
else
echo "should-release=true" >> $GITHUB_OUTPUT
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
fi
release:
name: Build and Publish
needs: check-version
if: needs.check-version.outputs.should-release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Run quality checks
run: |
npm run test
npm run lint
npm run typecheck
- name: Build package
run: npm run build
- name: Validate build output
run: |
# Ensure dist directory has content
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo "::error::Build output missing"
exit 1
fi
# Verify entry points exist
for file in dist/index.js dist/index.d.ts; do
if [ ! -f "$file" ]; then
echo "::error::Missing $file"
exit 1
fi
done
# Check CLI binaries
if [ -f "package.json" ]; then
node -e "
const pkg = require('./package.json');
if (pkg.bin) {
Object.values(pkg.bin).forEach(bin => {
if (!require('fs').existsSync(bin)) {
console.error('Missing binary:', bin);
process.exit(1);
}
});
}
"
fi
- name: Test local installation
run: |
npm pack
npm install -g *.tgz
# Test that CLI works
$(node -p "Object.keys(require('./package.json').bin)[0]") --version
- name: Create and push tag
run: |
VERSION=${{ needs.check-version.outputs.version }}
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$VERSION" -m "Release v$VERSION"
git push origin "v$VERSION"
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Prepare release notes
run: |
VERSION=${{ needs.check-version.outputs.version }}
REPO_NAME=${{ github.event.repository.name }}
# Try to extract changelog content if CHANGELOG.md exists
if [ -f "CHANGELOG.md" ]; then
CHANGELOG_CONTENT=$(awk -v version="$VERSION" '
BEGIN { found = 0; content = "" }
/^## \[/ {
if (found == 1) { exit }
if ($0 ~ "## \\[" version "\\]") { found = 1; next }
}
found == 1 { content = content $0 "\n" }
END { print content }
' CHANGELOG.md)
else
CHANGELOG_CONTENT="*Changelog not found. See commit history for changes.*"
fi
# Create release notes file
cat > release_notes.md << EOF
## Installation
\`\`\`bash
npm install -g ${REPO_NAME}@${VERSION}
\`\`\`
## What's Changed
${CHANGELOG_CONTENT}
## Links
- 📖 [Full Changelog](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
- 🔗 [NPM Package](https://www.npmjs.com/package/${REPO_NAME}/v/${VERSION})
- 📦 [All Releases](https://github.com/${{ github.repository }}/releases)
- 🔄 [Compare Changes](https://github.com/${{ github.repository }}/compare/v${{ needs.check-version.outputs.previous-version }}...v${VERSION})
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.check-version.outputs.version }}
name: Release v${{ needs.check-version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: false# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
# Skip some combinations to save CI time
- os: macos-latest
node: 18
- os: windows-latest
node: 18
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
if: matrix.os == 'ubuntu-latest' # Only lint once
- name: Type check
run: npm run typecheck
- name: Test
run: npm test
env:
CI: true
- name: Build
run: npm run build
- name: Test CLI installation (Unix)
if: matrix.os != 'windows-latest'
run: |
npm pack
npm install -g *.tgz
which $(node -p "Object.keys(require('./package.json').bin)[0]")
$(node -p "Object.keys(require('./package.json').bin)[0]") --version
- name: Test CLI installation (Windows)
if: matrix.os == 'windows-latest'
run: |
npm pack
npm install -g *.tgz
where $(node -p "Object.keys(require('./package.json').bin)[0]")
$(node -p "Object.keys(require('./package.json').bin)[0]") --version
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.node == '20'
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Check for security vulnerabilities
if: matrix.os == 'ubuntu-latest'
run: npm audit --audit-level=high
integration:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Integration tests
run: npm run test:integration
- name: E2E tests
run: npm run test:e2e#!/usr/bin/env nodebinpath.join()