Skip to content

Commit 75b4100

Browse files
author
Akido
committed
feat(cli): add --copy / -c flag for clipboard output
Copies the generated prompt to the system clipboard instead of printing to stdout. Cross-platform: uses pbcopy (macOS), xclip or xsel (Linux), clip.exe (Windows/WSL). Falls back to stdout with a stderr warning if no clipboard tool is detected. - src/clipboard.ts: new utility with commandExists probe loop - src/index.ts: wire --copy / -c option via commander - src/__tests__/clipboard.test.ts: unit tests with mocked execSync - README.md: document --copy flag with examples Closes #1
1 parent 45f7a35 commit 75b4100

4 files changed

Lines changed: 138 additions & 1 deletion

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ commitprompt --branch main --type feat --mode commit
8383

8484
Valid types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`
8585

86+
### Copy to clipboard
87+
88+
```bash
89+
# Copy the generated prompt directly to your clipboard
90+
commitprompt --copy
91+
commitprompt -c
92+
93+
# Works with all other flags
94+
commitprompt --mode pr --branch main --copy
95+
commitprompt --diff changes.diff -c
96+
```
97+
98+
On **macOS** this uses `pbcopy`. On **Linux** it tries `xclip` then `xsel`. On **Windows/WSL** it uses `clip.exe`. If no clipboard tool is found, the prompt is printed to stdout as a fallback with a warning on stderr.
99+
86100
### Shell autocomplete
87101

88102
```bash
@@ -132,6 +146,7 @@ If the change is complex, add a body paragraph explaining WHY (not WHAT).
132146
| `--type <type>` | Override auto-detected change type | - |
133147
| `--context` | Include repo context (package.json name, README intro) in prompt | - |
134148
| `--completions <bash\|zsh>` | Print shell completion script and exit | - |
149+
| `-c, --copy` | Copy the generated prompt to the system clipboard | - |
135150

136151
**Input priority:** `--diff` > `--branch` > stdin pipe > `--staged` (default)
137152

src/__tests__/clipboard.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* clipboard.test.ts
3+
* Tests for the cross-platform clipboard utility.
4+
* We mock execSync to avoid requiring a real clipboard tool in CI.
5+
*/
6+
import { describe, it, expect, vi, afterEach } from 'vitest';
7+
import * as childProcess from 'child_process';
8+
9+
// We need to mock before importing the module under test
10+
vi.mock('child_process');
11+
12+
const mockedExecSync = vi.mocked(childProcess.execSync);
13+
14+
describe('copyToClipboard', () => {
15+
afterEach(() => {
16+
vi.resetAllMocks();
17+
});
18+
19+
it('returns null (success) when a clipboard command is available and succeeds', async () => {
20+
// First call: commandExists -> success for pbcopy
21+
// Second call: actual copy -> success
22+
mockedExecSync.mockReturnValueOnce(Buffer.from('')); // command -v pbcopy
23+
mockedExecSync.mockReturnValueOnce(Buffer.from('')); // pbcopy pipe
24+
25+
const { copyToClipboard } = await import('../clipboard.js');
26+
const result = copyToClipboard('hello clipboard');
27+
expect(result).toBeNull();
28+
});
29+
30+
it('returns an error string when the clipboard command throws', async () => {
31+
// command -v pbcopy succeeds, but pbcopy itself throws
32+
mockedExecSync.mockReturnValueOnce(Buffer.from('')); // command -v pbcopy
33+
mockedExecSync.mockImplementationOnce(() => {
34+
throw new Error('pbcopy: pipe broken');
35+
});
36+
37+
const { copyToClipboard } = await import('../clipboard.js');
38+
const result = copyToClipboard('hello');
39+
expect(result).toContain('pbcopy');
40+
expect(result).toContain('failed');
41+
});
42+
43+
it('returns a "no clipboard tool found" message when all commands are missing', async () => {
44+
// All commandExists calls fail
45+
mockedExecSync.mockImplementation(() => {
46+
throw new Error('not found');
47+
});
48+
49+
const { copyToClipboard } = await import('../clipboard.js');
50+
const result = copyToClipboard('hello');
51+
expect(result).toContain('no clipboard tool found');
52+
});
53+
});

src/clipboard.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* clipboard.ts - Cross-platform clipboard write utility
3+
*
4+
* Tries, in order:
5+
* macOS: pbcopy
6+
* Linux: xclip -selection clipboard
7+
* Linux: xsel --clipboard --input
8+
* WSL/Win: clip.exe
9+
*
10+
* Returns an error message string if no clipboard tool is available,
11+
* or null on success.
12+
*/
13+
import { execSync } from 'child_process';
14+
15+
/** Commands to probe + pipe into (in priority order). */
16+
const CLIPBOARD_CMDS: ReadonlyArray<{ cmd: string; args: string[] }> = [
17+
{ cmd: 'pbcopy', args: [] },
18+
{ cmd: 'xclip', args: ['-selection', 'clipboard'] },
19+
{ cmd: 'xsel', args: ['--clipboard', '--input'] },
20+
{ cmd: 'clip.exe', args: [] },
21+
];
22+
23+
function commandExists(cmd: string): boolean {
24+
try {
25+
execSync(`command -v ${cmd}`, { stdio: 'ignore' });
26+
return true;
27+
} catch {
28+
return false;
29+
}
30+
}
31+
32+
/**
33+
* Write `text` to the system clipboard.
34+
* @returns null on success, or an error string if clipboard is unavailable.
35+
*/
36+
export function copyToClipboard(text: string): string | null {
37+
for (const { cmd, args } of CLIPBOARD_CMDS) {
38+
if (!commandExists(cmd)) continue;
39+
try {
40+
const fullCmd = [cmd, ...args].join(' ');
41+
execSync(fullCmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
42+
return null; // success
43+
} catch (err: unknown) {
44+
const msg = err instanceof Error ? err.message : String(err);
45+
return `clipboard: "${cmd}" failed: ${msg}`;
46+
}
47+
}
48+
return (
49+
'clipboard: no clipboard tool found. ' +
50+
'Install pbcopy (macOS), xclip or xsel (Linux), or use clip.exe (Windows/WSL).'
51+
);
52+
}

src/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { parseDiff } from './diff-parser.js';
1515
import { buildPrompt, type Mode } from './prompt-builder.js';
1616
import { readRepoContext } from './context-reader.js';
17+
import { copyToClipboard } from './clipboard.js';
1718

1819
// Valid commit types (also used for autocomplete)
1920
const VALID_TYPES = ['feat', 'fix', 'docs', 'refactor', 'test', 'chore', 'ci', 'perf'];
@@ -47,6 +48,10 @@ program
4748
'--completions <shell>',
4849
'Print shell completion script for bash or zsh and exit'
4950
)
51+
.option(
52+
'-c, --copy',
53+
'Copy the generated prompt to the system clipboard instead of (or in addition to) printing it'
54+
)
5055
.parse(process.argv);
5156

5257
const opts = program.opts<{
@@ -57,6 +62,7 @@ const opts = program.opts<{
5762
type?: string;
5863
context?: boolean;
5964
completions?: string;
65+
copy?: boolean;
6066
}>();
6167

6268
// Handle --completions before anything else
@@ -123,7 +129,18 @@ if (opts.type) {
123129

124130
const prompt = buildPrompt(parsed, raw, mode, 120, contextString);
125131

126-
process.stdout.write(prompt + '\n');
132+
if (opts.copy) {
133+
const err = copyToClipboard(prompt);
134+
if (err) {
135+
// Clipboard failed: print the prompt anyway and warn on stderr
136+
process.stderr.write(`Warning: ${err}\n`);
137+
process.stdout.write(prompt + '\n');
138+
} else {
139+
process.stderr.write('Prompt copied to clipboard.\n');
140+
}
141+
} else {
142+
process.stdout.write(prompt + '\n');
143+
}
127144

128145
// --- Completion scripts ---
129146

0 commit comments

Comments
 (0)