diff --git a/README.md b/README.md index e50b976..8a114ff 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Official command-line interface for [Linkup](https://linkup.so) — AI-powered w - **Search the web** with three depth modes: `fast`, `standard`, and `deep`. - **Fetch** any URL as clean markdown. - **Research** asynchronously, and batch mixed jobs with **tasks**. -- **Scriptable**: `--json` output for any command, plus stdin, file, and clipboard input. +- **Scriptable**: `--json` output for any command, plus stdin and file input. ## Install diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 5a4adea..bba558f 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -166,9 +166,8 @@ describe('linkup CLI', () => { expect(output).not.toContain('--reasoning-depth L'); }); - it('research --help lists clipboard and file query sources', () => { + it('research --help lists the file query source', () => { const output = execFileSync('node', [bin, 'research', '--help']).toString(); - expect(output).toContain('--clipboard'); expect(output).toContain('--file'); }); diff --git a/src/__tests__/clipboard.test.ts b/src/__tests__/clipboard.test.ts deleted file mode 100644 index 65454a5..0000000 --- a/src/__tests__/clipboard.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { readClipboard } from '../input/clipboard'; - -jest.mock('node:child_process', () => ({ - execFileSync: jest.fn(), -})); - -const execFileSyncMock = execFileSync as unknown as jest.Mock; -const originalPlatform = process.platform; - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { - configurable: true, - value: platform, - }); -} - -function missingBinaryError(): NodeJS.ErrnoException { - const error = new Error('not found') as NodeJS.ErrnoException; - error.code = 'ENOENT'; - return error; -} - -afterEach(() => { - execFileSyncMock.mockReset(); - setPlatform(originalPlatform); -}); - -describe('readClipboard', () => { - it('reads from pbpaste on macOS', () => { - setPlatform('darwin'); - execFileSyncMock.mockReturnValue(' copied text\n'); - - expect(readClipboard()).toEqual({ text: 'copied text' }); - expect(execFileSyncMock).toHaveBeenCalledWith('pbpaste', [], { encoding: 'utf8' }); - }); - - it('tries the next Linux clipboard tool when an installed tool returns empty output', () => { - setPlatform('linux'); - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'xclip') { - return ''; - } - if (command === 'xsel') { - throw missingBinaryError(); - } - return 'wayland clipboard\n'; - }); - - expect(readClipboard()).toEqual({ text: 'wayland clipboard' }); - expect(execFileSyncMock).toHaveBeenCalledTimes(3); - }); - - it('reports an empty Linux clipboard when tools exist but have no text', () => { - setPlatform('linux'); - execFileSyncMock.mockReturnValue(''); - - expect(readClipboard()).toEqual({ text: '' }); - }); - - it('reports missing Linux clipboard tools', () => { - setPlatform('linux'); - execFileSyncMock.mockImplementation(() => { - throw missingBinaryError(); - }); - - expect(readClipboard()).toEqual({ - error: 'No clipboard tool found. Install xclip, xsel, or wl-clipboard.', - }); - }); -}); diff --git a/src/__tests__/query-input.test.ts b/src/__tests__/query-input.test.ts index 4f6baa4..107c0f1 100644 --- a/src/__tests__/query-input.test.ts +++ b/src/__tests__/query-input.test.ts @@ -10,8 +10,6 @@ class ExitError extends Error { const originalExit = process.exit; const stubReaders: QueryReaders = { - clipboard: () => ({ text: '' }), - interactive: async () => ({ cancelled: false, text: '' }), stdin: async () => '', }; @@ -38,15 +36,6 @@ describe('resolveQueryOrExit', () => { ).resolves.toBe('hello world'); }); - it('reads from the clipboard and prints the notice', async () => { - const readers: QueryReaders = { ...stubReaders, clipboard: () => ({ text: 'pasted text' }) }; - - await expect( - resolveQueryOrExit({ args: [], clipboard: true }, usageLines, readers, true), - ).resolves.toBe('pasted text'); - expect(errorSpy).toHaveBeenCalledWith('Read 11 characters from clipboard'); - }); - it('reads from stdin when input is piped', async () => { const readers: QueryReaders = { ...stubReaders, stdin: async () => ' piped query\n' }; @@ -62,35 +51,12 @@ describe('resolveQueryOrExit', () => { expect(errorSpy).toHaveBeenCalledWith('Error: No query provided'); }); - it('exits cleanly (code 0) when the interactive prompt is cancelled', async () => { - const readers: QueryReaders = { - ...stubReaders, - interactive: async () => ({ cancelled: true, text: '' }), - }; - - await expect(resolveQueryOrExit({ args: [] }, usageLines, readers, true)).rejects.toMatchObject( - { code: 0 }, - ); - }); - - it('exits with code 1 on a hard resolution error', async () => { - const readers: QueryReaders = { - ...stubReaders, - clipboard: () => ({ error: 'pbpaste not found' }), - }; - - await expect( - resolveQueryOrExit({ args: [], clipboard: true }, usageLines, readers, true), - ).rejects.toMatchObject({ code: 1 }); - expect(errorSpy).toHaveBeenCalledWith('Error: pbpaste not found'); - }); - it('exits with code 1 when multiple query sources are provided', async () => { await expect( - resolveQueryOrExit({ args: ['typed'], clipboard: true }, usageLines, stubReaders, true), + resolveQueryOrExit({ args: ['typed'], file: '/ignored' }, usageLines, stubReaders, true), ).rejects.toMatchObject({ code: 1 }); expect(errorSpy).toHaveBeenCalledWith( - 'Error: Multiple query sources provided: --clipboard, positional query. Use only one.', + 'Error: Multiple query sources provided: --file, positional query. Use only one.', ); }); }); diff --git a/src/__tests__/query.test.ts b/src/__tests__/query.test.ts index 018b72c..eb8a3af 100644 --- a/src/__tests__/query.test.ts +++ b/src/__tests__/query.test.ts @@ -5,8 +5,6 @@ import type { QueryReaders } from '../input/query'; import { resolveQuery } from '../input/query'; const stubReaders: QueryReaders = { - clipboard: () => ({ text: '' }), - interactive: async () => ({ cancelled: false, text: '' }), stdin: async () => '', }; @@ -34,34 +32,6 @@ describe('resolveQuery', () => { ).rejects.toThrow('Could not read query file'); }); - it('reads from the clipboard and reports the character count', async () => { - const readers: QueryReaders = { ...stubReaders, clipboard: () => ({ text: 'pasted text' }) }; - - const result = await resolveQuery({ args: [], clipboard: true }, readers, true); - - expect(result.query).toBe('pasted text'); - expect(result.notices).toContain('Read 11 characters from clipboard'); - }); - - it('errors when the clipboard is empty', async () => { - const readers: QueryReaders = { ...stubReaders, clipboard: () => ({ text: '' }) }; - - await expect(resolveQuery({ args: [], clipboard: true }, readers, true)).rejects.toThrow( - 'Clipboard is empty', - ); - }); - - it('surfaces clipboard tool errors', async () => { - const readers: QueryReaders = { - ...stubReaders, - clipboard: () => ({ error: 'pbpaste not found' }), - }; - - await expect(resolveQuery({ args: [], clipboard: true }, readers, true)).rejects.toThrow( - 'pbpaste not found', - ); - }); - it('reads from stdin when input is piped (not a TTY)', async () => { const readers: QueryReaders = { ...stubReaders, stdin: async () => ' piped query\n' }; @@ -70,34 +40,9 @@ describe('resolveQuery', () => { expect(result.query).toBe('piped query'); }); - it('falls back to the interactive prompt on a TTY', async () => { - const readers: QueryReaders = { - ...stubReaders, - interactive: async () => ({ cancelled: false, text: 'typed query' }), - }; - - const result = await resolveQuery({ args: [] }, readers, true); - - expect(result.query).toBe('typed query'); - }); - - it('flags interactive cancellation', async () => { - const readers: QueryReaders = { - ...stubReaders, - interactive: async () => ({ cancelled: true, text: '' }), - }; - - const result = await resolveQuery({ args: [] }, readers, true); - - expect(result.cancelled).toBe(true); - expect(result.query).toBe(''); - }); - it('rejects multiple explicit query sources', async () => { - const readers: QueryReaders = { ...stubReaders, clipboard: () => ({ text: 'from clipboard' }) }; - await expect( - resolveQuery({ args: ['from', 'args'], clipboard: true, file: '/ignored' }, readers, true), - ).rejects.toThrow('Multiple query sources provided: --clipboard, --file, positional query'); + resolveQuery({ args: ['from', 'args'], file: '/ignored' }, stubReaders, true), + ).rejects.toThrow('Multiple query sources provided: --file, positional query'); }); }); diff --git a/src/commands/query-input.ts b/src/commands/query-input.ts index 7730275..09ed90d 100644 --- a/src/commands/query-input.ts +++ b/src/commands/query-input.ts @@ -17,15 +17,13 @@ export function queryUsageLines( 'Usage:', ` linkup ${commandName} "${queryPlaceholder}"`, ...extraExamples.map(example => ` ${example}`), - ` linkup ${commandName} --clipboard # read from clipboard`, ` linkup ${commandName} --file query.txt # read from file`, - ` linkup ${commandName} # interactive mode`, ]; } // Resolve a query from any supported source and exit with usage/errors when it // cannot be resolved. Shared by the search and research commands so the -// resolve/cancel/empty/notice contract stays in one place. +// resolve/empty/notice contract stays in one place. export async function resolveQueryOrExit( input: QueryInput, usageLines: string[], @@ -39,9 +37,6 @@ export async function resolveQueryOrExit( exitWithError(formatErrorLine(error)); } - if (resolved.cancelled) { - exitWithError('Cancelled', 0); - } if (!resolved.query) { exitWithError(usageLines); } diff --git a/src/commands/research.ts b/src/commands/research.ts index 17539bd..bbe09da 100644 --- a/src/commands/research.ts +++ b/src/commands/research.ts @@ -88,7 +88,6 @@ type ResearchCommandOptions = { excludeDomains?: string[]; fromDate?: Date; toDate?: Date; - clipboard?: boolean; file?: string; wait?: boolean; pollInterval: number; @@ -194,7 +193,6 @@ async function runResearchSubmit( const query = await resolveQueryOrExit( { args: queryParts, - clipboard: options.clipboard, file: options.file, }, queryUsageLines('research', 'your research question', [ @@ -323,7 +321,6 @@ export function registerResearchCommand(program: Command): void { 'Only include results published on or before this date', parseDateOption('--to-date'), ) - .option('-c, --clipboard', 'Read query from clipboard') .option('-f, --file ', 'Read query from a file') .option('-w, --wait', 'Wait for the task to complete and print the result') .addOption(createPollIntervalOption()) diff --git a/src/commands/search.ts b/src/commands/search.ts index bff6608..f686c52 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -60,7 +60,6 @@ type SearchCommandOptions = { toDate?: Date; includeImages?: boolean; maxResults?: number; - clipboard?: boolean; file?: string; async?: boolean; wait?: boolean; @@ -165,7 +164,6 @@ async function runSearch( const query = await resolveQueryOrExit( { args: queryParts, - clipboard: options.clipboard, file: options.file, }, queryUsageLines('search', 'your query'), @@ -234,7 +232,6 @@ export function registerSearchCommand(program: Command): void { ) .option('--include-images', 'Request images in search results') .option('--max-results ', 'Maximum number of search results', parsePositiveInt) - .option('-c, --clipboard', 'Read query from clipboard') .option('-f, --file ', 'Read query from a file') .option('--async', 'Run the search as an asynchronous task') .option('-w, --wait', 'Wait for the asynchronous task to complete and print the result') diff --git a/src/input/clipboard.ts b/src/input/clipboard.ts deleted file mode 100644 index 6be7344..0000000 --- a/src/input/clipboard.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { execFileSync } from 'node:child_process'; - -export type ClipboardResult = { text: string } | { error: string }; - -type ClipboardCommand = { command: string; args: string[] }; - -// Run a clipboard command. Returns its stdout, or null when the binary is missing. -function run({ command, args }: ClipboardCommand): string | null { - try { - return execFileSync(command, args, { encoding: 'utf8' }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - // The tool exists but exited non-zero (e.g. empty selection); surface its stdout. - const stdout = (error as { stdout?: string | Buffer }).stdout; - return typeof stdout === 'string' ? stdout : (stdout?.toString() ?? null); - } -} - -// Read text from the system clipboard, with a per-OS strategy. -export function readClipboard(): ClipboardResult { - switch (process.platform) { - case 'darwin': { - const out = run({ args: [], command: 'pbpaste' }); - return out === null ? { error: 'pbpaste not found' } : { text: out.trim() }; - } - case 'linux': { - const candidates: ClipboardCommand[] = [ - { args: ['-selection', 'clipboard', '-o'], command: 'xclip' }, - { args: ['--clipboard', '--output'], command: 'xsel' }, - { args: [], command: 'wl-paste' }, - ]; - let foundClipboardTool = false; - for (const candidate of candidates) { - const out = run(candidate); - if (out !== null) { - foundClipboardTool = true; - if (!out.trim()) { - continue; - } - return { text: out.trim() }; - } - } - if (foundClipboardTool) { - return { text: '' }; - } - return { error: 'No clipboard tool found. Install xclip, xsel, or wl-clipboard.' }; - } - case 'win32': { - const out = run({ args: ['-NoProfile', '-Command', 'Get-Clipboard'], command: 'powershell' }); - return out === null ? { error: 'powershell not found' } : { text: out.trim() }; - } - default: - return { error: `Clipboard not supported on ${process.platform}` }; - } -} diff --git a/src/input/query.ts b/src/input/query.ts index 2121e9e..e9fa9b7 100644 --- a/src/input/query.ts +++ b/src/input/query.ts @@ -1,9 +1,7 @@ import { readFileSync } from 'node:fs'; import { readStdin } from '../utils'; -import { type ClipboardResult, readClipboard } from './clipboard'; export type QueryInput = { - clipboard?: boolean; file?: string; args: string[]; }; @@ -11,40 +9,19 @@ export type QueryInput = { export type ResolvedQuery = { query: string; notices: string[]; - cancelled?: boolean; }; -export type InteractiveResult = { cancelled: boolean; text: string }; - // Injectable readers so the resolver stays unit-testable without real I/O. export type QueryReaders = { - clipboard: () => ClipboardResult; stdin: () => Promise; - interactive: () => Promise; }; -async function readInteractive(): Promise { - try { - const { input } = await import('@inquirer/prompts'); - const text = await input({ message: 'Enter your query:' }); - return { cancelled: false, text }; - } catch (error) { - if (error instanceof Error && ['AbortPromptError', 'ExitPromptError'].includes(error.name)) { - return { cancelled: true, text: '' }; - } - throw error; - } -} - export const defaultReaders: QueryReaders = { - clipboard: readClipboard, - interactive: readInteractive, stdin: readStdin, }; function assertSingleQuerySource(input: QueryInput): void { const sources = [ - input.clipboard ? '--clipboard' : null, input.file ? '--file' : null, input.args.length > 0 ? 'positional query' : null, ].filter((source): source is string => source !== null); @@ -55,11 +32,10 @@ function assertSingleQuerySource(input: QueryInput): void { } // Resolve the search query from one source: -// clipboard, file, positional args, piped stdin, or interactive prompt. +// file, positional args, or piped stdin. // -// Throws on hard failures (clipboard tool missing/empty, unreadable file). -// Returns an empty `query` when nothing resolved (caller prints usage), or -// `cancelled: true` when the interactive prompt was aborted with Ctrl+C. +// Throws on hard failures (unreadable file). +// Returns an empty `query` when nothing resolved (caller prints usage). export async function resolveQuery( input: QueryInput, readers: QueryReaders = defaultReaders, @@ -68,18 +44,6 @@ export async function resolveQuery( const notices: string[] = []; assertSingleQuerySource(input); - if (input.clipboard) { - const result = readers.clipboard(); - if ('error' in result) { - throw new Error(result.error); - } - if (!result.text) { - throw new Error('Clipboard is empty'); - } - notices.push(`Read ${result.text.length} characters from clipboard`); - return { notices, query: result.text }; - } - if (input.file) { let content: string; try { @@ -101,9 +65,5 @@ export async function resolveQuery( return { notices, query: stdin.trim() }; } - const interactive = await readers.interactive(); - if (interactive.cancelled) { - return { cancelled: true, notices, query: '' }; - } - return { notices, query: interactive.text.trim() }; + return { notices, query: '' }; }