pplx-zero 1.1.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,80 +1,46 @@
1
1
  {
2
2
  "name": "pplx-zero",
3
- "version": "1.1.8",
4
- "description": "Fast Perplexity AI search CLI with multimodal support - minimal setup, maximal results",
3
+ "version": "2.0.0",
4
+ "description": "Minimal Perplexity AI CLI - search from terminal",
5
+ "author": "kenzo",
6
+ "license": "MIT",
5
7
  "type": "module",
6
- "main": "dist/index.js",
8
+ "module": "src/index.ts",
7
9
  "bin": {
8
- "pplx": "dist/index.js"
10
+ "pplx": "bin/pplx.js"
9
11
  },
10
12
  "scripts": {
11
- "dev": "bun run src/cli/index.ts",
12
- "dev:legacy": "bun run src/cli.ts",
13
- "build": "rm -rf dist && bun build src/cli/index.ts --target node --outdir dist",
14
- "build:legacy": "rm -rf dist && bun build src/cli.ts --target node --outdir dist",
15
- "build:binary": "bun build --compile src/cli/index.ts --outfile=dist/pplx",
16
- "build:binary:legacy": "bun build --compile src/cli.ts --outfile=dist/pplx-legacy",
17
- "test": "bun test",
18
- "test:watch": "bun test --watch",
19
- "typecheck": "bun tsc --noEmit",
20
- "lint": "bun run --bun eslint src/**/*.ts",
21
- "lint:fix": "bun run --bun eslint src/**/*.ts --fix",
22
- "clean": "rm -rf dist",
23
- "dev:debug": "bun --inspect src/cli/index.ts",
24
- "dev:debug:legacy": "bun --inspect src/cli.ts"
13
+ "build": "bun build src/index.ts --compile --outfile=pplx",
14
+ "dev": "bun run src/index.ts",
15
+ "test": "bun test"
25
16
  },
26
17
  "dependencies": {
27
- "@perplexity-ai/perplexity_ai": "^0.11.0",
28
- "abort-controller": "^3.0.0",
29
- "commander": "^12.0.0",
30
- "dotenv": "^16.3.1",
31
- "zod": "^3.22.4"
18
+ "zod": "^4.0.0"
32
19
  },
33
20
  "devDependencies": {
34
- "@types/node": "^20.0.0",
35
- "typescript": "^5.0.0",
36
- "bun-types": "latest",
37
- "@typescript-eslint/eslint-plugin": "^6.0.0",
38
- "@typescript-eslint/parser": "^6.0.0",
39
- "eslint": "^8.0.0"
21
+ "@types/bun": "latest"
40
22
  },
41
- "engines": {
42
- "bun": ">=1.0.0"
23
+ "peerDependencies": {
24
+ "typescript": "^5"
43
25
  },
44
- "keywords": [
45
- "pplx-zero",
46
- "perplexity",
47
- "search",
48
- "ai",
49
- "cli",
50
- "command-line",
51
- "tool",
52
- "typescript",
53
- "bun",
54
- "api",
55
- "minimal",
56
- "fast",
57
- "productivity",
58
- "zero-config",
59
- "multimodal",
60
- "attachments",
61
- "images",
62
- "documents",
63
- "sonar",
64
- "reasoning",
65
- "research",
66
- "async"
26
+ "files": [
27
+ "src",
28
+ "bin"
67
29
  ],
68
- "author": "Kenzo",
69
- "license": "MIT",
70
30
  "repository": {
71
31
  "type": "git",
72
32
  "url": "git+https://github.com/codewithkenzo/pplx-zero.git"
73
33
  },
74
- "files": [
75
- "dist",
76
- "README.md",
77
- "LICENSE",
78
- "package.json"
34
+ "bugs": {
35
+ "url": "https://github.com/codewithkenzo/pplx-zero/issues"
36
+ },
37
+ "homepage": "https://github.com/codewithkenzo/pplx-zero#readme",
38
+ "keywords": [
39
+ "perplexity",
40
+ "ai",
41
+ "search",
42
+ "cli",
43
+ "terminal",
44
+ "bun"
79
45
  ]
80
- }
46
+ }
@@ -0,0 +1,21 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { MODELS, type Model } from './api';
3
+
4
+ describe('MODELS', () => {
5
+ test('includes all expected models', () => {
6
+ expect(MODELS).toContain('sonar');
7
+ expect(MODELS).toContain('sonar-pro');
8
+ expect(MODELS).toContain('sonar-reasoning');
9
+ expect(MODELS).toContain('sonar-reasoning-pro');
10
+ expect(MODELS).toContain('sonar-deep-research');
11
+ });
12
+
13
+ test('has exactly 5 models', () => {
14
+ expect(MODELS).toHaveLength(5);
15
+ });
16
+
17
+ test('Model type matches MODELS array', () => {
18
+ const model: Model = MODELS[0]!;
19
+ expect(MODELS.includes(model)).toBe(true);
20
+ });
21
+ });
package/src/api.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { getEnv } from './env';
2
+ import type { FileAttachment } from './files';
3
+
4
+ const API_URL = 'https://api.perplexity.ai/chat/completions';
5
+
6
+ export const MODELS = ['sonar', 'sonar-pro', 'sonar-reasoning', 'sonar-reasoning-pro', 'sonar-deep-research'] as const;
7
+ export type Model = (typeof MODELS)[number];
8
+
9
+ export interface SearchResult {
10
+ title: string;
11
+ url: string;
12
+ date?: string;
13
+ }
14
+
15
+ export interface StreamCallbacks {
16
+ onContent: (text: string) => void;
17
+ onDone: (citations: SearchResult[], usage: { prompt_tokens: number; completion_tokens: number }) => void;
18
+ onError: (error: Error) => void;
19
+ }
20
+
21
+ interface MessageContent {
22
+ type: 'text' | 'file_url';
23
+ text?: string;
24
+ file_url?: { url: string };
25
+ file_name?: string;
26
+ }
27
+
28
+ function buildMessages(query: string, file?: FileAttachment): { role: string; content: string | MessageContent[] }[] {
29
+ if (!file) {
30
+ return [{ role: 'user', content: query }];
31
+ }
32
+
33
+ const content: MessageContent[] = [
34
+ { type: 'text', text: query },
35
+ {
36
+ type: 'file_url',
37
+ file_url: { url: file.data },
38
+ file_name: file.filename,
39
+ },
40
+ ];
41
+
42
+ return [{ role: 'user', content }];
43
+ }
44
+
45
+ export async function search(
46
+ query: string,
47
+ model: Model,
48
+ callbacks: StreamCallbacks,
49
+ file?: FileAttachment
50
+ ): Promise<void> {
51
+ const body = JSON.stringify({
52
+ model,
53
+ messages: buildMessages(query, file),
54
+ stream: true,
55
+ });
56
+
57
+ const response = await fetch(API_URL, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Authorization': `Bearer ${getEnv().PERPLEXITY_API_KEY}`,
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ body,
64
+ });
65
+
66
+ if (!response.ok) {
67
+ const text = await response.text();
68
+ callbacks.onError(new Error(`API error ${response.status}: ${text}`));
69
+ return;
70
+ }
71
+
72
+ if (!response.body) {
73
+ callbacks.onError(new Error('No response body'));
74
+ return;
75
+ }
76
+
77
+ const reader = response.body.getReader();
78
+ const decoder = new TextDecoder();
79
+ let buffer = '';
80
+ let citations: SearchResult[] = [];
81
+ let usage = { prompt_tokens: 0, completion_tokens: 0 };
82
+
83
+ while (true) {
84
+ const { done, value } = await reader.read();
85
+ if (done) break;
86
+
87
+ buffer += decoder.decode(value, { stream: true });
88
+ const lines = buffer.split('\n');
89
+ buffer = lines.pop() || '';
90
+
91
+ for (const line of lines) {
92
+ if (!line.startsWith('data: ')) continue;
93
+ const data = line.slice(6).trim();
94
+ if (data === '[DONE]') continue;
95
+
96
+ try {
97
+ const parsed = JSON.parse(data);
98
+ const delta = parsed.choices?.[0]?.delta?.content;
99
+ if (delta) {
100
+ callbacks.onContent(delta);
101
+ }
102
+
103
+ if (parsed.search_results) {
104
+ citations = parsed.search_results;
105
+ }
106
+ if (parsed.usage) {
107
+ usage = parsed.usage;
108
+ }
109
+ } catch {
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+
115
+ callbacks.onDone(citations, usage);
116
+ }
package/src/env.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+
3
+ const c = {
4
+ reset: '\x1b[0m',
5
+ red: '\x1b[31m',
6
+ yellow: '\x1b[33m',
7
+ cyan: '\x1b[36m',
8
+ dim: '\x1b[2m',
9
+ } as const;
10
+
11
+ const envSchema = z.object({
12
+ PERPLEXITY_API_KEY: z.string().min(1),
13
+ });
14
+
15
+ let _env: z.infer<typeof envSchema> | null = null;
16
+
17
+ export function getEnv() {
18
+ if (_env) return _env;
19
+
20
+ const key = process.env.PERPLEXITY_API_KEY || process.env.PERPLEXITY_AI_API_KEY;
21
+
22
+ if (!key) {
23
+ console.error(`
24
+ ${c.red}✗ Missing API Key${c.reset}
25
+
26
+ Set your Perplexity API key:
27
+
28
+ ${c.cyan}export PERPLEXITY_API_KEY="pplx-..."${c.reset}
29
+
30
+ ${c.dim}Get one at: https://perplexity.ai/settings/api${c.reset}
31
+ `);
32
+ process.exit(2);
33
+ }
34
+
35
+ _env = { PERPLEXITY_API_KEY: key };
36
+ return _env;
37
+ }
@@ -0,0 +1,71 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { encodeFile, toDataUrl, type FileAttachment } from './files';
3
+ import { writeFile, unlink } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+
6
+ const TMP_DIR = '/tmp';
7
+
8
+ describe('encodeFile', () => {
9
+ test('encodes text file correctly', async () => {
10
+ const testPath = join(TMP_DIR, 'test.txt');
11
+ await writeFile(testPath, 'hello world');
12
+
13
+ const result = await encodeFile(testPath);
14
+
15
+ expect(result.type).toBe('file');
16
+ expect(result.mimeType).toBe('text/plain');
17
+ expect(result.filename).toBe('test.txt');
18
+ expect(result.data).toBe(Buffer.from('hello world').toString('base64'));
19
+
20
+ await unlink(testPath);
21
+ });
22
+
23
+ test('encodes PDF as file type', async () => {
24
+ const testPath = join(TMP_DIR, 'test.pdf');
25
+ await writeFile(testPath, '%PDF-1.4 test');
26
+
27
+ const result = await encodeFile(testPath);
28
+
29
+ expect(result.type).toBe('file');
30
+ expect(result.mimeType).toBe('application/pdf');
31
+
32
+ await unlink(testPath);
33
+ });
34
+
35
+ test('encodes PNG as image type', async () => {
36
+ const testPath = join(TMP_DIR, 'test.png');
37
+ const pngHeader = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
38
+ await writeFile(testPath, pngHeader);
39
+
40
+ const result = await encodeFile(testPath);
41
+
42
+ expect(result.type).toBe('image');
43
+ expect(result.mimeType).toBe('image/png');
44
+
45
+ await unlink(testPath);
46
+ });
47
+
48
+ test('throws on unsupported file type', async () => {
49
+ const testPath = join(TMP_DIR, 'test.xyz');
50
+ await writeFile(testPath, 'test');
51
+
52
+ await expect(encodeFile(testPath)).rejects.toThrow('Unsupported file type: .xyz');
53
+
54
+ await unlink(testPath);
55
+ });
56
+ });
57
+
58
+ describe('toDataUrl', () => {
59
+ test('creates valid data URL', () => {
60
+ const attachment: FileAttachment = {
61
+ type: 'image',
62
+ data: 'aGVsbG8gd29ybGQ=',
63
+ mimeType: 'image/png',
64
+ filename: 'test.png',
65
+ };
66
+
67
+ const result = toDataUrl(attachment);
68
+
69
+ expect(result).toBe('data:image/png;base64,aGVsbG8gd29ybGQ=');
70
+ });
71
+ });
package/src/files.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+
4
+ const MIME_TYPES: Record<string, string> = {
5
+ '.pdf': 'application/pdf',
6
+ '.txt': 'text/plain',
7
+ '.md': 'text/markdown',
8
+ '.png': 'image/png',
9
+ '.jpg': 'image/jpeg',
10
+ '.jpeg': 'image/jpeg',
11
+ '.gif': 'image/gif',
12
+ '.webp': 'image/webp',
13
+ };
14
+
15
+ export interface FileAttachment {
16
+ type: 'file' | 'image';
17
+ data: string;
18
+ mimeType: string;
19
+ filename: string;
20
+ }
21
+
22
+ export async function encodeFile(path: string): Promise<FileAttachment> {
23
+ const ext = extname(path).toLowerCase();
24
+ const mimeType = MIME_TYPES[ext];
25
+
26
+ if (!mimeType) {
27
+ throw new Error(`Unsupported file type: ${ext}`);
28
+ }
29
+
30
+ const buffer = await readFile(path);
31
+ const data = buffer.toString('base64');
32
+ const isImage = mimeType.startsWith('image/');
33
+
34
+ return {
35
+ type: isImage ? 'image' : 'file',
36
+ data,
37
+ mimeType,
38
+ filename: path.split('/').pop() || 'file',
39
+ };
40
+ }
41
+
42
+ export function toDataUrl(attachment: FileAttachment): string {
43
+ return `data:${attachment.mimeType};base64,${attachment.data}`;
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bun
2
+ import { parseArgs } from 'node:util';
3
+ import { search, MODELS, type Model } from './api';
4
+ import { encodeFile } from './files';
5
+ import { getEnv } from './env';
6
+ import { fmt, write, writeLn } from './output';
7
+
8
+ getEnv();
9
+
10
+
11
+ const { values, positionals } = parseArgs({
12
+ args: Bun.argv.slice(2),
13
+ options: {
14
+ model: { type: 'string', short: 'm', default: 'sonar' },
15
+ file: { type: 'string', short: 'f' },
16
+ image: { type: 'string', short: 'i' },
17
+ json: { type: 'boolean', default: false },
18
+ help: { type: 'boolean', short: 'h' },
19
+ },
20
+ allowPositionals: true,
21
+ strict: true,
22
+ });
23
+
24
+ if (values.help || positionals.length === 0) {
25
+ console.log(`
26
+ pplx - Perplexity AI search from terminal
27
+
28
+ Usage: pplx [options] <query>
29
+
30
+ Options:
31
+ -m, --model <name> Model: ${MODELS.join(', ')} (default: sonar)
32
+ -f, --file <path> Attach a file (PDF, TXT, etc.)
33
+ -i, --image <path> Attach an image (PNG, JPG, etc.)
34
+ --json Output as JSON
35
+ -h, --help Show this help
36
+
37
+ Examples:
38
+ pplx "what is bun"
39
+ pplx -m sonar-pro "explain quantum computing"
40
+ pplx -f report.pdf "summarize this document"
41
+ `);
42
+ process.exit(0);
43
+ }
44
+
45
+ const query = positionals.join(' ');
46
+ const model = (MODELS.includes(values.model as Model) ? values.model : 'sonar') as Model;
47
+
48
+ const filePath = values.file || values.image;
49
+ const file = filePath ? await encodeFile(filePath) : undefined;
50
+
51
+ const startTime = Date.now();
52
+ let fullContent = '';
53
+
54
+ if (!values.json) {
55
+ await write(fmt.model(model) + ' ');
56
+ await write(fmt.searching());
57
+ }
58
+
59
+ await search(query, model, {
60
+ onContent: async (text) => {
61
+ fullContent += text;
62
+ if (!values.json) {
63
+ await write(text);
64
+ }
65
+ },
66
+ onDone: async (citations, usage) => {
67
+ const elapsed = Date.now() - startTime;
68
+
69
+ if (values.json) {
70
+ const output = {
71
+ answer: fullContent,
72
+ citations: citations.map((c) => c.url),
73
+ model,
74
+ tokens: usage.prompt_tokens + usage.completion_tokens,
75
+ latency_ms: elapsed,
76
+ };
77
+ console.log(JSON.stringify(output, null, 2));
78
+ } else {
79
+ if (citations.length > 0) {
80
+ await writeLn(fmt.sources());
81
+ for (let i = 0; i < citations.length; i++) {
82
+ await writeLn(fmt.citation(i + 1, citations[i]!.url));
83
+ }
84
+ }
85
+ await write(fmt.stats(usage.prompt_tokens + usage.completion_tokens, elapsed));
86
+ }
87
+ },
88
+ onError: async (error) => {
89
+ if (values.json) {
90
+ console.error(JSON.stringify({ error: error.message }));
91
+ } else {
92
+ await write(fmt.error(error.message));
93
+ }
94
+ process.exit(1);
95
+ },
96
+ }, file);
@@ -0,0 +1,41 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { fmt } from './output';
3
+
4
+ describe('fmt', () => {
5
+ test('model formats with cyan color', () => {
6
+ const result = fmt.model('sonar');
7
+ expect(result).toContain('[sonar]');
8
+ expect(result).toContain('\x1b[36m');
9
+ expect(result).toContain('\x1b[0m');
10
+ });
11
+
12
+ test('searching shows dim text', () => {
13
+ const result = fmt.searching();
14
+ expect(result).toContain('Searching...');
15
+ expect(result).toContain('\x1b[2m');
16
+ });
17
+
18
+ test('error formats with red color', () => {
19
+ const result = fmt.error('test error');
20
+ expect(result).toContain('Error: test error');
21
+ expect(result).toContain('\x1b[31m');
22
+ });
23
+
24
+ test('citation formats with number and URL', () => {
25
+ const result = fmt.citation(1, 'https://example.com');
26
+ expect(result).toContain('1.');
27
+ expect(result).toContain('https://example.com');
28
+ });
29
+
30
+ test('stats formats tokens and time', () => {
31
+ const result = fmt.stats(100, 1500);
32
+ expect(result).toContain('100 tokens');
33
+ expect(result).toContain('1.5s');
34
+ });
35
+
36
+ test('sources shows yellow header', () => {
37
+ const result = fmt.sources();
38
+ expect(result).toContain('Sources:');
39
+ expect(result).toContain('\x1b[33m');
40
+ });
41
+ });
package/src/output.ts ADDED
@@ -0,0 +1,28 @@
1
+ const c = {
2
+ reset: '\x1b[0m',
3
+ dim: '\x1b[2m',
4
+ bold: '\x1b[1m',
5
+ cyan: '\x1b[36m',
6
+ green: '\x1b[32m',
7
+ yellow: '\x1b[33m',
8
+ red: '\x1b[31m',
9
+ gray: '\x1b[90m',
10
+ } as const;
11
+
12
+ export const fmt = {
13
+ model: (name: string) => `${c.cyan}[${name}]${c.reset}`,
14
+ searching: () => `${c.dim}Searching...${c.reset}\n`,
15
+ error: (msg: string) => `${c.red}Error: ${msg}${c.reset}\n`,
16
+ citation: (i: number, url: string) => `${c.dim} ${i}. ${url}${c.reset}`,
17
+ stats: (tokens: number, ms: number) =>
18
+ `\n${c.gray}[${tokens} tokens, ${(ms / 1000).toFixed(1)}s]${c.reset}\n`,
19
+ sources: () => `\n${c.yellow}Sources:${c.reset}`,
20
+ };
21
+
22
+ export async function write(text: string): Promise<void> {
23
+ await Bun.write(Bun.stdout, text);
24
+ }
25
+
26
+ export async function writeLn(text: string): Promise<void> {
27
+ await Bun.write(Bun.stdout, text + '\n');
28
+ }