Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
71 changes: 0 additions & 71 deletions src/__tests__/clipboard.test.ts

This file was deleted.

38 changes: 2 additions & 36 deletions src/__tests__/query-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ class ExitError extends Error {
const originalExit = process.exit;

const stubReaders: QueryReaders = {
clipboard: () => ({ text: '' }),
interactive: async () => ({ cancelled: false, text: '' }),
stdin: async () => '',
};

Expand All @@ -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' };

Expand All @@ -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.',
);
});
});
59 changes: 2 additions & 57 deletions src/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => '',
};

Expand Down Expand Up @@ -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' };

Expand All @@ -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');
});
});
7 changes: 1 addition & 6 deletions src/commands/query-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -39,9 +37,6 @@ export async function resolveQueryOrExit(
exitWithError(formatErrorLine(error));
}

if (resolved.cancelled) {
exitWithError('Cancelled', 0);
}
if (!resolved.query) {
exitWithError(usageLines);
}
Expand Down
3 changes: 0 additions & 3 deletions src/commands/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ type ResearchCommandOptions = {
excludeDomains?: string[];
fromDate?: Date;
toDate?: Date;
clipboard?: boolean;
file?: string;
wait?: boolean;
pollInterval: number;
Expand Down Expand Up @@ -194,7 +193,6 @@ async function runResearchSubmit(
const query = await resolveQueryOrExit(
{
args: queryParts,
clipboard: options.clipboard,
file: options.file,
},
queryUsageLines('research', 'your research question', [
Expand Down Expand Up @@ -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 <path>', 'Read query from a file')
.option('-w, --wait', 'Wait for the task to complete and print the result')
.addOption(createPollIntervalOption())
Expand Down
3 changes: 0 additions & 3 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ type SearchCommandOptions = {
toDate?: Date;
includeImages?: boolean;
maxResults?: number;
clipboard?: boolean;
file?: string;
async?: boolean;
wait?: boolean;
Expand Down Expand Up @@ -165,7 +164,6 @@ async function runSearch(
const query = await resolveQueryOrExit(
{
args: queryParts,
clipboard: options.clipboard,
file: options.file,
},
queryUsageLines('search', 'your query'),
Expand Down Expand Up @@ -234,7 +232,6 @@ export function registerSearchCommand(program: Command): void {
)
.option('--include-images', 'Request images in search results')
.option('--max-results <number>', 'Maximum number of search results', parsePositiveInt)
.option('-c, --clipboard', 'Read query from clipboard')
.option('-f, --file <path>', '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')
Expand Down
57 changes: 0 additions & 57 deletions src/input/clipboard.ts

This file was deleted.

Loading