Thank you for your interest in contributing to snip — the terminal-native CLI snippet manager.
- Code of Conduct
- Your First Command in 10 Minutes
- Project Structure
- Development Setup
- Running Tests & Lint
- Coding Standards
- How Can I Contribute?
- Commit Message Format
- License
Please read and follow our Code of Conduct to keep our community approachable and respectable.
This walkthrough shows you how to add a new snip subcommand from scratch. We'll use a real example — adding a snip greet command that says hello.
Every command lives in lib/commands/<name>.js. Create your file:
touch lib/commands/greet.jsPaste this template:
/**
* snip greet — a friendly hello command.
*
* Usage:
* snip greet # says hello to you
* snip greet "World" # says hello to World
* snip greet --json # JSON output
*/
const { c } = require('../colors');
const { log } = require('../quiet');
const { setExitCode } = require('../cli-utils');
function greetCmd(name, opts = {}) {
const target = name || 'you';
if (opts.json) {
// --json: always print to stdout (not log) for piping
console.log(JSON.stringify({ greeting: `Hello, ${target}!` }));
return;
}
// Standard terminal output
log(c.brand(` 👋 Hello, ${target}!`));
}
module.exports = greetCmd;Key conventions used:
- Use
require('../colors').cfor colors — never use chalk directly - Use
require('../quiet').logfor decorative output (suppressed by--quiet) - Use
require('../cli-utils').setExitCode(code)to set non-zero exit codes - Use
console.logfor data output (JSON, pipable content) — these pass through quiet mode - Always describe the command in a JSDoc block at the top
Open lib/cli.js and add two things:
A. Add the require at the top (around line 30, with the other requires):
const greetCmd = require('./commands/greet');B. Add the command registration (after the other commands, before the final program.parse):
program
.command('greet [name]')
.description('Say hello (example command for contributors)')
.option('--json', 'Output as JSON')
.action((name, opts) => greetCmd(name, opts));node bin/snip greet # → 👋 Hello, you!
node bin/snip greet "World" # → 👋 Hello, World!
node bin/snip greet --json # → { "greeting": "Hello, you!" }
node bin/snip greet --help # → shows your command descriptionCreate __tests__/greet.test.js:
const path = require('path');
const os = require('os');
const fs = require('fs');
describe('greet command', () => {
let testDir;
beforeEach(() => {
testDir = path.join(os.tmpdir(), `snip-greet-test-${Date.now()}`);
const configDir = path.join(testDir, 'config');
const dataDir = path.join(testDir, 'data');
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
process.env.XDG_CONFIG_HOME = configDir;
process.env.XDG_DATA_HOME = dataDir;
jest.resetModules();
});
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
test('greets the user', () => {
const greet = require('../lib/commands/greet');
const logs = [];
const spy = jest.spyOn(console, 'log').mockImplementation((...args) => {
logs.push(args.join(' '));
});
greet('World', {});
spy.mockRestore();
expect(logs.join(' ')).toContain('Hello, World!');
});
test('greets "you" by default', () => {
const greet = require('../lib/commands/greet');
const logs = [];
const spy = jest.spyOn(console, 'log').mockImplementation((...args) => {
logs.push(args.join(' '));
});
greet(undefined, {});
spy.mockRestore();
expect(logs.join(' ')).toContain('Hello, you!');
});
test('supports --json output', () => {
const greet = require('../lib/commands/greet');
const logs = [];
const spy = jest.spyOn(console, 'log').mockImplementation((...args) => {
logs.push(args.join(' '));
});
greet('World', { json: true });
spy.mockRestore();
expect(logs.join(' ')).toContain('"Hello, World!"');
});
});Run it:
npx jest __tests__/greet.test.js --verboseIf your command has flags, add them to completions/snip.bash and completions/snip.fish:
bash (completions/snip.bash):
greet)
COMPREPLY=( $(compgen -W "--json" -- "$cur") )
;;fish (completions/snip.fish):
complete -c snip -f -n "__snip_has_no_command" -a greet -d "Say hello"
complete -c snip -f -n "__snip_has_command greet" -l json -d "JSON output"From file creation to running tests — a new snip command in about 10 minutes. Here's the checklist for any new command:
| Step | File(s) | Done? |
|---|---|---|
| 1. Create command | lib/commands/<name>.js |
☐ |
| 2. Register command | lib/cli.js (require + program.command) |
☐ |
| 3. Test locally | Run node bin/snip <name> |
☐ |
| 4. Write tests | __tests__/<name>.test.js |
☐ |
| 5. Add completions | completions/snip.bash, completions/snip.fish |
☐ |
snip/
├── bin/snip # Entry point (small — delegates to lib/cli.js)
├── lib/
│ ├── cli.js # Commander.js program — all command registrations
│ ├── cli-utils.js # Shared helpers: parseNameWithLang, setExitCode, exitProcess
│ ├── colors.js # Unified brand color palette (chalk + fallback)
│ ├── quiet.js # Quiet-mode helpers (SNIP_QUIET env var)
│ ├── storage.js # Storage abstraction — JSON + SQLite backends
│ ├── search.js # Fuse.js fuzzy search with cache invalidation
│ ├── exec.js # Multi-language snippet runner
│ ├── template.js # {{variable:default}} template engine
│ ├── safety.js # Dangerous command detection (14+ patterns)
│ ├── config.js # Config loader (XDG_CONFIG_HOME)
│ ├── format.js # Terminal formatting (tables, progress bars)
│ ├── icons.js # Emoji icons for all languages/tags
│ ├── readline.js # Interactive prompt helpers
│ ├── clipboard.js # Cross-platform clipboard
│ ├── lock.js # File-based exclusive lock for storage
│ ├── streak.js # Usage streak tracking
│ └── commands/ # One file per CLI subcommand (25+)
│ ├── add.js # snip add
│ ├── list.js # snip list
│ ├── search.js # snip search
│ ├── stats.js # snip stats (good reference for simple commands)
│ └── ... # All other commands
├── __tests__/ # Jest tests — one file per module
│ ├── add.test.js
│ ├── list.test.js
│ └── ...
├── completions/ # Shell completion scripts
│ ├── snip.bash
│ └── snip.fish
├── docs/ # Website + demo + plans
│ ├── snip-demo.gif # Animated demo
│ └── ...
├── scripts/ # Utility scripts
│ └── seed-examples.js # Example snippet seeder
└── package.json
| Decision | Why |
|---|---|
| Single file per command | Easy to find, easy to add, easy to test |
| No daemon | Every invocation is stateless — fast cold starts |
| Dual storage (JSON + SQLite) | JSON for zero-config start, SQLite for scale |
Unified colors via lib/colors.js |
Brand consistency — use c.brand(), c.dim(), etc. |
Quiet mode via lib/quiet.js |
log() for decorative output, console.log for data — makes scripting/CI clean |
| Centralized exit codes | setExitCode() for normal returns, exitProcess() for immediate shutdown |
# Clone the repository
git clone https://github.com/bharath/snip.git
cd snip
# Install dependencies
npm install
# Run locally (no global install needed)
node bin/snip --help
# Seed example snippets
node bin/snip seed
# Try it out
node bin/snip list
node bin/snip search docker# Link the package globally for testing
npm link
# Now you can use `snip` directly
snip init
snip list
snip stats
# Unlink when done
npm unlink -g snip-manager# Run all tests
npm test
# Run a single test file
npx jest __tests__/stats.test.js --verbose
# Run tests with coverage
npx jest --coverage
# Run tests matching a pattern
npx jest --testPathPattern="safety|search"
# Run lint (ESLint on lib/)
npm run lint
# Both in sequence
npm test && npm run lintAll tests in this project follow these patterns:
- Isolation: Every test creates its own temp directory via
XDG_CONFIG_HOMEandXDG_DATA_HOME(thejest.setup.jshandles this automatically) - Module isolation: Use
jest.resetModules()inbeforeEachwhen testing modules that read from storage/config - Console mocking: Capture output by spying on
console.log/console.error— always restore with.mockRestore() - No side effects: Tests never modify real user data or shell history
Example test structure:
describe('My Command', () => {
beforeEach(() => {
jest.resetModules(); // fresh module state
// Environment is already isolated by jest.setup.js
});
test('does something', () => {
const logs = [];
const spy = jest.spyOn(console, 'log').mockImplementation((...args) => {
logs.push(args.join(' '));
});
// Test your command here...
spy.mockRestore();
expect(logs[0]).toContain('expected output');
});
});- 2 spaces for indentation (no tabs)
- Semicolons required
- Single quotes for strings (double quotes only for JSON or strings containing
') constby default,letonly when reassigning, nevervar- Arrow functions preferred over
functionkeyword for callbacks - Named exports — each command file exports one function
| Rule | Details |
|---|---|
Use c. colors |
Always use require('../colors').c — never chalk directly |
Use log() for decoration |
Use require('../quiet').log() for non-essential output |
Use console.log for data |
JSON, list results, snippet content must use console.log |
Use setExitCode() |
Never set process.exitCode directly — use the helper |
Use exitProcess() sparingly |
Only for signal handlers and TUI cleanup |
| Errors to stderr | Error messages go to console.error, never console.log |
| Descriptive JSDoc | Every command file starts with a usage comment block |
| Keep functions small | One responsibility per function |
Each command file follows this template:
/**
* snip <name> — <one-line description>.
*
* Usage:
* snip <name> [args]
* snip <name> [args] --json
*/
const { c } = require('../colors');
const { log } = require('../quiet');
const { setExitCode } = require('../cli-utils');
// ... other requires as needed
function myCommand(args, opts = {}) {
// 1. Validate input
// 2. Do the work
// 3. Output results
// 4. Handle errors with setExitCode(1)
}
module.exports = myCommand;- Search existing issues — Check if the bug has already been reported
- Use the bug template — Include detailed steps to reproduce
- Include context — OS, Node.js version, and snip version
- Check existing discussions — Maybe someone already proposed it
- Use the feature request template — Describe the problem and solution
- Explain use cases — How would you use this feature?
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Make your changes — Follow the coding standards above
- Add tests — See the "Your First Command" guide for test patterns
- Run tests & lint:
npm test && npm run lint - Commit with clear messages: See commit format below
- Push and create PR:
git push origin feature/my-feature
Look for issues labeled good first issue on GitHub.
<type>(<scope>): <description>
[optional body]
[optional footer]
Types: feat, fix, docs, style, refactor, test, chore
Scopes: add, list, search, exec, storage, ui, cli, docs, etc.
Examples:
feat(add): Add --tags flag to snip add command
Allows users to add multiple tags when creating a snippet.
Closes #123
fix(search): Handle empty query gracefully
Prevents crash when snip search is called with no query.
test(storage): Add SQLite backend tests
Covers listSnippets, addSnippet, deleteSnippet with SQLite.
By contributing, you agree that your contributions will be licensed under the MIT License.