pplx-zero 1.1.8 → 2.1.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/src/index.ts ADDED
@@ -0,0 +1,179 @@
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
+ import { appendHistory, readHistory, getLastEntry } from './history';
8
+
9
+ getEnv();
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
+ history: { type: 'boolean', default: false },
20
+ 'no-history': { type: 'boolean', default: false },
21
+ continue: { type: 'boolean', short: 'c', default: false },
22
+ output: { type: 'string', short: 'o' },
23
+ },
24
+ allowPositionals: true,
25
+ strict: true,
26
+ });
27
+
28
+ if (values.help) {
29
+ console.log(`
30
+ pplx - Perplexity AI search from terminal
31
+
32
+ Usage: pplx [options] <query>
33
+
34
+ Options:
35
+ -m, --model <name> Model: ${MODELS.join(', ')} (default: sonar)
36
+ -f, --file <path> Attach a file (PDF, TXT, etc.)
37
+ -i, --image <path> Attach an image (PNG, JPG, etc.)
38
+ -o, --output <path> Save output to file (.md, .txt)
39
+ -c, --continue Continue from last query (add context)
40
+ --history Show query history
41
+ --no-history Don't save this query to history
42
+ --json Output as JSON
43
+ -h, --help Show this help
44
+
45
+ Examples:
46
+ pplx "what is bun"
47
+ pplx -m sonar-pro "explain quantum computing"
48
+ pplx -f report.pdf "summarize this document"
49
+ pplx -c "tell me more about that"
50
+ pplx --history | grep "bun"
51
+ `);
52
+ process.exit(0);
53
+ }
54
+
55
+ if (values.history) {
56
+ const entries = await readHistory(20);
57
+ if (entries.length === 0) {
58
+ console.log('No history yet.');
59
+ } else {
60
+ for (const entry of entries) {
61
+ console.log(fmt.historyEntry(entry.ts, entry.m, entry.q));
62
+ }
63
+ }
64
+ process.exit(0);
65
+ }
66
+
67
+ if (positionals.length === 0 && !values.continue) {
68
+ console.error(fmt.error('No query provided. Use -h for help.'));
69
+ process.exit(2);
70
+ }
71
+
72
+ let query = positionals.join(' ');
73
+ const model = (MODELS.includes(values.model as Model) ? values.model : 'sonar') as Model;
74
+
75
+ if (values.continue) {
76
+ const last = await getLastEntry();
77
+ if (last) {
78
+ const context = `Previous question: "${last.q}"\nPrevious answer: "${last.a.slice(0, 500)}..."\n\nFollow-up question: ${query || 'Continue and elaborate on the previous answer.'}`;
79
+ query = context;
80
+ if (!values.json) {
81
+ await write(fmt.continuing(last.q));
82
+ }
83
+ } else if (!query) {
84
+ console.error(fmt.error('No previous query to continue from.'));
85
+ process.exit(2);
86
+ }
87
+ }
88
+
89
+ const filePath = values.file || values.image;
90
+ const file = filePath ? await encodeFile(filePath) : undefined;
91
+
92
+ const startTime = Date.now();
93
+ let fullContent = '';
94
+ let outputBuffer = '';
95
+
96
+ if (!values.json) {
97
+ await write(fmt.model(model) + ' ');
98
+ await write(fmt.searching());
99
+ }
100
+
101
+ await search(query, model, {
102
+ onContent: async (text) => {
103
+ fullContent += text;
104
+ if (!values.json) {
105
+ await write(text);
106
+ }
107
+ },
108
+ onDone: async (citations, usage) => {
109
+ const elapsed = Date.now() - startTime;
110
+ const citationUrls = citations.map((c) => c.url);
111
+
112
+ if (values.json) {
113
+ const output = {
114
+ answer: fullContent,
115
+ citations: citationUrls,
116
+ model,
117
+ tokens: usage.prompt_tokens + usage.completion_tokens,
118
+ latency_ms: elapsed,
119
+ };
120
+ console.log(JSON.stringify(output, null, 2));
121
+ } else {
122
+ if (citations.length > 0) {
123
+ await writeLn(fmt.sources());
124
+ for (let i = 0; i < citations.length; i++) {
125
+ await writeLn(fmt.citation(i + 1, citations[i]!.url));
126
+ }
127
+ }
128
+ await write(fmt.stats(usage.prompt_tokens + usage.completion_tokens, elapsed));
129
+ }
130
+
131
+ if (values.output) {
132
+ const ext = values.output.split('.').pop()?.toLowerCase();
133
+ let content = '';
134
+
135
+ if (ext === 'md') {
136
+ content = `# ${positionals.join(' ') || 'Query'}\n\n`;
137
+ content += `**Model:** ${model}\n`;
138
+ content += `**Date:** ${new Date().toISOString()}\n\n`;
139
+ content += `## Answer\n\n${fullContent}\n\n`;
140
+ if (citationUrls.length > 0) {
141
+ content += `## Sources\n\n`;
142
+ citationUrls.forEach((url, i) => {
143
+ content += `${i + 1}. ${url}\n`;
144
+ });
145
+ }
146
+ } else {
147
+ content = fullContent;
148
+ if (citationUrls.length > 0) {
149
+ content += '\n\nSources:\n';
150
+ citationUrls.forEach((url, i) => {
151
+ content += `${i + 1}. ${url}\n`;
152
+ });
153
+ }
154
+ }
155
+
156
+ await Bun.write(values.output, content);
157
+ if (!values.json) {
158
+ await writeLn(`\n${fmt.model('saved')} ${values.output}`);
159
+ }
160
+ }
161
+
162
+ if (!values['no-history'] && !values.json) {
163
+ await appendHistory({
164
+ q: positionals.join(' ') || '(continued)',
165
+ m: model,
166
+ a: fullContent,
167
+ citations: citationUrls,
168
+ });
169
+ }
170
+ },
171
+ onError: async (error) => {
172
+ if (values.json) {
173
+ console.error(JSON.stringify({ error: error.message }));
174
+ } else {
175
+ await write(fmt.error(error.message));
176
+ }
177
+ process.exit(1);
178
+ },
179
+ }, 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,35 @@
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
+ historyEntry: (ts: number, model: string, query: string) => {
21
+ const date = new Date(ts).toLocaleString('en-US', {
22
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
23
+ });
24
+ return `${c.dim}${date}${c.reset} ${c.cyan}[${model}]${c.reset} ${query}`;
25
+ },
26
+ continuing: (query: string) => `${c.dim}Continuing from:${c.reset} ${query.slice(0, 50)}${query.length > 50 ? '...' : ''}\n`,
27
+ };
28
+
29
+ export async function write(text: string): Promise<void> {
30
+ await Bun.write(Bun.stdout, text);
31
+ }
32
+
33
+ export async function writeLn(text: string): Promise<void> {
34
+ await Bun.write(Bun.stdout, text + '\n');
35
+ }