pplx-zero 2.2.0 → 2.2.2
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/README.md +0 -1
- package/bin/pplx.js +5 -2
- package/package.json +3 -2
- package/src/api.ts +16 -5
- package/src/files.ts +19 -5
- package/src/index.ts +26 -2
- package/src/api.test.ts +0 -21
- package/src/files.test.ts +0 -71
- package/src/history.test.ts +0 -84
- package/src/output.test.ts +0 -41
package/README.md
CHANGED
package/bin/pplx.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { spawn, execSync } from 'child_process';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
8
|
|
|
6
9
|
const hasBun = () => {
|
|
7
10
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pplx-zero",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "Minimal Perplexity AI CLI - search from terminal",
|
|
5
5
|
"author": "kenzo",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
|
-
"bin"
|
|
28
|
+
"bin",
|
|
29
|
+
"!src/**/*.test.ts"
|
|
29
30
|
],
|
|
30
31
|
"repository": {
|
|
31
32
|
"type": "git",
|
package/src/api.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { FileAttachment } from './files';
|
|
|
3
3
|
|
|
4
4
|
const API_URL = 'https://api.perplexity.ai/chat/completions';
|
|
5
5
|
|
|
6
|
-
export const MODELS = ['sonar', 'sonar-pro', 'sonar-reasoning
|
|
6
|
+
export const MODELS = ['sonar', 'sonar-pro', 'sonar-reasoning-pro', 'sonar-deep-research'] as const;
|
|
7
7
|
export type Model = (typeof MODELS)[number];
|
|
8
8
|
|
|
9
9
|
export interface SearchResult {
|
|
@@ -19,9 +19,10 @@ export interface StreamCallbacks {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface MessageContent {
|
|
22
|
-
type: 'text' | 'file_url';
|
|
22
|
+
type: 'text' | 'file_url' | 'image_url';
|
|
23
23
|
text?: string;
|
|
24
24
|
file_url?: { url: string };
|
|
25
|
+
image_url?: { url: string };
|
|
25
26
|
file_name?: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -32,12 +33,22 @@ function buildMessages(query: string, file?: FileAttachment): { role: string; co
|
|
|
32
33
|
|
|
33
34
|
const content: MessageContent[] = [
|
|
34
35
|
{ type: 'text', text: query },
|
|
35
|
-
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
if (file.type === 'image') {
|
|
39
|
+
// Images: use image_url with data URL prefix per Perplexity API spec
|
|
40
|
+
content.push({
|
|
41
|
+
type: 'image_url',
|
|
42
|
+
image_url: { url: `data:${file.mimeType};base64,${file.data}` },
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
// Documents: use file_url with raw base64
|
|
46
|
+
content.push({
|
|
36
47
|
type: 'file_url',
|
|
37
48
|
file_url: { url: file.data },
|
|
38
49
|
file_name: file.filename,
|
|
39
|
-
}
|
|
40
|
-
|
|
50
|
+
});
|
|
51
|
+
}
|
|
41
52
|
|
|
42
53
|
return [{ role: 'user', content }];
|
|
43
54
|
}
|
package/src/files.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { extname } from 'node:path';
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, basename, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB - Perplexity API limit
|
|
3
5
|
|
|
4
6
|
const MIME_TYPES: Record<string, string> = {
|
|
5
7
|
'.pdf': 'application/pdf',
|
|
@@ -20,14 +22,26 @@ export interface FileAttachment {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export async function encodeFile(path: string): Promise<FileAttachment> {
|
|
23
|
-
|
|
25
|
+
// Security: prevent path traversal
|
|
26
|
+
if (path.includes('..')) {
|
|
27
|
+
throw new Error('Path traversal not allowed');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const resolved = resolve(path);
|
|
31
|
+
const ext = extname(resolved).toLowerCase();
|
|
24
32
|
const mimeType = MIME_TYPES[ext];
|
|
25
33
|
|
|
26
34
|
if (!mimeType) {
|
|
27
35
|
throw new Error(`Unsupported file type: ${ext}`);
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
// Security: check file size before reading into memory
|
|
39
|
+
const stats = await stat(resolved);
|
|
40
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
41
|
+
throw new Error(`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB (max 50MB)`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const buffer = await readFile(resolved);
|
|
31
45
|
const data = buffer.toString('base64');
|
|
32
46
|
const isImage = mimeType.startsWith('image/');
|
|
33
47
|
|
|
@@ -35,7 +49,7 @@ export async function encodeFile(path: string): Promise<FileAttachment> {
|
|
|
35
49
|
type: isImage ? 'image' : 'file',
|
|
36
50
|
data,
|
|
37
51
|
mimeType,
|
|
38
|
-
filename:
|
|
52
|
+
filename: basename(resolved),
|
|
39
53
|
};
|
|
40
54
|
}
|
|
41
55
|
|
package/src/index.ts
CHANGED
|
@@ -72,8 +72,14 @@ if (positionals.length === 0 && !values.continue) {
|
|
|
72
72
|
process.exit(2);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Validate model before proceeding
|
|
76
|
+
if (!MODELS.includes(values.model as Model)) {
|
|
77
|
+
console.error(fmt.error(`Invalid model: ${values.model}. Available: ${MODELS.join(', ')}`));
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
const model = values.model as Model;
|
|
81
|
+
|
|
75
82
|
let query = positionals.join(' ');
|
|
76
|
-
const model = (MODELS.includes(values.model as Model) ? values.model : 'sonar') as Model;
|
|
77
83
|
|
|
78
84
|
if (values.continue) {
|
|
79
85
|
const last = await getLastEntry();
|
|
@@ -90,7 +96,25 @@ if (values.continue) {
|
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
const filePath = values.file || values.image;
|
|
93
|
-
|
|
99
|
+
let file;
|
|
100
|
+
if (filePath) {
|
|
101
|
+
try {
|
|
102
|
+
file = await encodeFile(filePath);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
let msg = err instanceof Error ? err.message : 'Unknown error reading file';
|
|
105
|
+
if (msg.includes('ENOENT')) {
|
|
106
|
+
msg = `File not found: ${filePath}`;
|
|
107
|
+
}
|
|
108
|
+
console.error(fmt.error(msg));
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Validate model supports image input
|
|
114
|
+
if (file?.type === 'image' && model === 'sonar-deep-research') {
|
|
115
|
+
console.error(fmt.error('sonar-deep-research does not support image input. Use sonar or sonar-pro.'));
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
94
118
|
|
|
95
119
|
const startTime = Date.now();
|
|
96
120
|
let fullContent = '';
|
package/src/api.test.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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/files.test.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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/history.test.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { test, expect, beforeEach, afterAll } from 'bun:test';
|
|
2
|
-
import { appendHistory, readHistory, getLastEntry, clearHistory } from './history';
|
|
3
|
-
|
|
4
|
-
beforeEach(async () => {
|
|
5
|
-
await clearHistory();
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
afterAll(async () => {
|
|
9
|
-
await clearHistory();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('appendHistory creates entry', async () => {
|
|
13
|
-
await appendHistory({ q: 'test query', m: 'sonar', a: 'test answer' });
|
|
14
|
-
const entries = await readHistory();
|
|
15
|
-
expect(entries.length).toBe(1);
|
|
16
|
-
expect(entries[0]!.q).toBe('test query');
|
|
17
|
-
expect(entries[0]!.m).toBe('sonar');
|
|
18
|
-
expect(entries[0]!.a).toBe('test answer');
|
|
19
|
-
expect(entries[0]!.ts).toBeGreaterThan(0);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('readHistory returns entries in reverse order', async () => {
|
|
23
|
-
await appendHistory({ q: 'first', m: 'sonar', a: 'a1' });
|
|
24
|
-
await appendHistory({ q: 'second', m: 'sonar-pro', a: 'a2' });
|
|
25
|
-
await appendHistory({ q: 'third', m: 'sonar', a: 'a3' });
|
|
26
|
-
|
|
27
|
-
const entries = await readHistory();
|
|
28
|
-
expect(entries.length).toBe(3);
|
|
29
|
-
expect(entries[0]!.q).toBe('third');
|
|
30
|
-
expect(entries[1]!.q).toBe('second');
|
|
31
|
-
expect(entries[2]!.q).toBe('first');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('readHistory respects limit', async () => {
|
|
35
|
-
await appendHistory({ q: 'one', m: 'sonar', a: 'a' });
|
|
36
|
-
await appendHistory({ q: 'two', m: 'sonar', a: 'a' });
|
|
37
|
-
await appendHistory({ q: 'three', m: 'sonar', a: 'a' });
|
|
38
|
-
|
|
39
|
-
const entries = await readHistory(2);
|
|
40
|
-
expect(entries.length).toBe(2);
|
|
41
|
-
expect(entries[0]!.q).toBe('three');
|
|
42
|
-
expect(entries[1]!.q).toBe('two');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('getLastEntry returns most recent', async () => {
|
|
46
|
-
await appendHistory({ q: 'old', m: 'sonar', a: 'old answer' });
|
|
47
|
-
await appendHistory({ q: 'new', m: 'sonar-pro', a: 'new answer' });
|
|
48
|
-
|
|
49
|
-
const last = await getLastEntry();
|
|
50
|
-
expect(last?.q).toBe('new');
|
|
51
|
-
expect(last?.m).toBe('sonar-pro');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('getLastEntry returns null when empty', async () => {
|
|
55
|
-
const last = await getLastEntry();
|
|
56
|
-
expect(last).toBeNull();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('clearHistory removes all entries', async () => {
|
|
60
|
-
await appendHistory({ q: 'test', m: 'sonar', a: 'answer' });
|
|
61
|
-
await clearHistory();
|
|
62
|
-
const entries = await readHistory();
|
|
63
|
-
expect(entries.length).toBe(0);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('appendHistory stores citations', async () => {
|
|
67
|
-
await appendHistory({
|
|
68
|
-
q: 'query',
|
|
69
|
-
m: 'sonar',
|
|
70
|
-
a: 'answer',
|
|
71
|
-
citations: ['https://example.com', 'https://test.com']
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const entries = await readHistory();
|
|
75
|
-
expect(entries[0]!.citations).toEqual(['https://example.com', 'https://test.com']);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('appendHistory truncates long answers', async () => {
|
|
79
|
-
const longAnswer = 'x'.repeat(3000);
|
|
80
|
-
await appendHistory({ q: 'query', m: 'sonar', a: longAnswer });
|
|
81
|
-
|
|
82
|
-
const entries = await readHistory();
|
|
83
|
-
expect(entries[0]!.a.length).toBe(2000);
|
|
84
|
-
});
|
package/src/output.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
});
|