pplx-npx-search 0.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/LICENSE +21 -0
- package/README.md +110 -0
- package/bin/pplx.js +2 -0
- package/package.json +42 -0
- package/src/chrome-bridge.js +183 -0
- package/src/cli.js +331 -0
- package/src/config.js +14 -0
- package/src/constants.js +45 -0
- package/src/cookies.js +137 -0
- package/src/format.js +15 -0
- package/src/http.js +263 -0
- package/src/labs.js +128 -0
- package/src/retry.js +31 -0
- package/src/search.js +255 -0
- package/src/session.js +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rajan Rengasamy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# pplx-cli
|
|
2
|
+
|
|
3
|
+
CLI for Perplexity AI with cookie-based authentication. Designed for headless/agent-first usage — no browser required at runtime.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 20+
|
|
8
|
+
- Google Chrome (only for initial cookie extraction via `pplx auth`)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git clone https://github.com/rajanrengasamy/pplx-cli.git
|
|
14
|
+
cd pplx-cli
|
|
15
|
+
npm install
|
|
16
|
+
npm link
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Authentication
|
|
20
|
+
|
|
21
|
+
Extract cookies from Chrome (one-time setup):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pplx auth
|
|
25
|
+
pplx auth --test # verify cookies work
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Cookies are stored at `~/.config/pplx/cookies.json`.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Search (default: pro mode, headless HTTP)
|
|
34
|
+
pplx search "what is quantum computing"
|
|
35
|
+
|
|
36
|
+
# Reasoning mode
|
|
37
|
+
pplx reason "explain the Riemann hypothesis"
|
|
38
|
+
|
|
39
|
+
# Deep research
|
|
40
|
+
pplx research "compare React vs Vue in 2026"
|
|
41
|
+
|
|
42
|
+
# Labs (free, no auth needed)
|
|
43
|
+
pplx labs "hello world"
|
|
44
|
+
|
|
45
|
+
# List models
|
|
46
|
+
pplx models
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Options
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pplx search "query" --mode auto|pro|reasoning|deep-research
|
|
53
|
+
pplx search "query" --model claude-3.5-sonnet
|
|
54
|
+
pplx search "query" --json # single JSON object output
|
|
55
|
+
pplx search "query" --raw # plain text, no colors/spinner
|
|
56
|
+
pplx search "query" --chrome # use Chrome CDP bridge instead of HTTP
|
|
57
|
+
pplx search "query" --curl # force curl-impersonate for TLS
|
|
58
|
+
pplx search "query" --incognito # don't save to Perplexity history
|
|
59
|
+
pplx search "query" --citations-full # show full source details
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Agent/Automation Usage
|
|
63
|
+
|
|
64
|
+
pplx-cli is designed to work in automated pipelines and with AI agents:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Plain text output (no colors, no spinner)
|
|
68
|
+
pplx search "query" --raw
|
|
69
|
+
|
|
70
|
+
# Read query from stdin
|
|
71
|
+
echo "what is 2+2" | pplx search -
|
|
72
|
+
|
|
73
|
+
# JSON output (single object: {answer, sources, query, mode, model})
|
|
74
|
+
pplx search "query" --json
|
|
75
|
+
|
|
76
|
+
# Pipe-friendly (auto-detects non-TTY)
|
|
77
|
+
pplx search "query" | head -1
|
|
78
|
+
|
|
79
|
+
# Non-zero exit code when no answer is returned
|
|
80
|
+
pplx search "query" --json || echo "failed"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
- **Default mode:** Headless HTTP with stored cookies (no browser needed)
|
|
86
|
+
- **Optional:** `--chrome` flag uses Chrome CDP bridge for TLS fingerprinting bypass
|
|
87
|
+
- **SSE streaming:** Real-time answer streaming via Server-Sent Events
|
|
88
|
+
- **Cookie auth:** One-time extraction from Chrome, then fully headless
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
Optional config file at `~/.config/pplx/config.json`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mode": "pro",
|
|
97
|
+
"model": "claude-3.5-sonnet",
|
|
98
|
+
"lang": "en-US"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Acknowledgements
|
|
103
|
+
|
|
104
|
+
This project was inspired by and built upon the reverse-engineering work in [helallao/perplexity-ai](https://github.com/helallao/perplexity-ai) — a Python library for the Perplexity AI API. The authentication flow, SSE protocol handling, and API structure were all derived from studying that project. Big thanks to [@helallao](https://github.com/helallao) for figuring out the hard parts.
|
|
105
|
+
|
|
106
|
+
pplx-cli is a ground-up Node.js reimplementation for CLI/agentic use cases, but it wouldn't exist without that foundational work.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/bin/pplx.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pplx-npx-search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Perplexity AI with cookie-based auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pplx": "./bin/pplx.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/pplx.js",
|
|
11
|
+
"test": "node --test test/**/*.test.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"perplexity",
|
|
15
|
+
"ai",
|
|
16
|
+
"cli",
|
|
17
|
+
"search"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Rajan Rengasamy",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/rajanrengasamy/pplx-cli"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"better-sqlite3": "^11.0.0",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"commander": "^12.0.0",
|
|
38
|
+
"eventsource-parser": "^3.0.0",
|
|
39
|
+
"ora": "^8.0.0",
|
|
40
|
+
"ws": "^8.16.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome CDP Bridge — executes fetch() inside Chrome's Perplexity tab
|
|
3
|
+
* to bypass Cloudflare TLS fingerprinting.
|
|
4
|
+
*
|
|
5
|
+
* Connects via OpenClaw browser relay (ws://127.0.0.1:18793/cdp)
|
|
6
|
+
* or direct Chrome DevTools Protocol.
|
|
7
|
+
*/
|
|
8
|
+
import WebSocket from 'ws';
|
|
9
|
+
|
|
10
|
+
const RELAY_URL = 'ws://127.0.0.1:18793/cdp';
|
|
11
|
+
const RELAY_LIST = 'http://127.0.0.1:18793/json/list';
|
|
12
|
+
const CDP_LIST = 'http://localhost:9222/json';
|
|
13
|
+
|
|
14
|
+
export class ChromeBridge {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.ws = null;
|
|
17
|
+
this.sessionId = null;
|
|
18
|
+
this.msgId = 0;
|
|
19
|
+
this.pending = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async connect() {
|
|
23
|
+
// Find the Perplexity tab
|
|
24
|
+
let tabs, wsUrl;
|
|
25
|
+
try {
|
|
26
|
+
const resp = await fetch(RELAY_LIST);
|
|
27
|
+
tabs = await resp.json();
|
|
28
|
+
wsUrl = RELAY_URL;
|
|
29
|
+
} catch {
|
|
30
|
+
try {
|
|
31
|
+
const resp = await fetch(CDP_LIST);
|
|
32
|
+
tabs = await resp.json();
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'Cannot connect to Chrome.\n' +
|
|
36
|
+
' Ensure OpenClaw gateway is running (openclaw gateway start)\n' +
|
|
37
|
+
' and a Perplexity tab is open in Chrome.'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pplxTab = tabs.find(t => t.url?.includes('perplexity.ai'));
|
|
43
|
+
if (!pplxTab) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'No Perplexity tab found in Chrome.\n' +
|
|
46
|
+
' Open https://www.perplexity.ai/ in Chrome and try again.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const targetId = pplxTab.id || pplxTab.targetId;
|
|
51
|
+
const connectUrl = wsUrl || pplxTab.webSocketDebuggerUrl;
|
|
52
|
+
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
this.ws = new WebSocket(connectUrl);
|
|
55
|
+
this.ws.on('open', resolve);
|
|
56
|
+
this.ws.on('message', (raw) => this._onMessage(raw));
|
|
57
|
+
this.ws.on('error', reject);
|
|
58
|
+
setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Attach to the Perplexity tab
|
|
62
|
+
if (wsUrl === RELAY_URL) {
|
|
63
|
+
const resp = await this._sendAsync('Target.attachToTarget', {
|
|
64
|
+
targetId,
|
|
65
|
+
flatten: true,
|
|
66
|
+
});
|
|
67
|
+
this.sessionId = resp.result.sessionId;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_onMessage(raw) {
|
|
72
|
+
const msg = JSON.parse(raw.toString());
|
|
73
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
74
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
75
|
+
this.pending.delete(msg.id);
|
|
76
|
+
if (msg.error) reject(new Error(msg.error.message));
|
|
77
|
+
else resolve(msg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_sendAsync(method, params = {}, timeout = 60000) {
|
|
82
|
+
const id = ++this.msgId;
|
|
83
|
+
const msg = { id, method, params };
|
|
84
|
+
if (this.sessionId) msg.sessionId = this.sessionId;
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
this.pending.set(id, { resolve, reject });
|
|
87
|
+
this.ws.send(JSON.stringify(msg));
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
if (this.pending.has(id)) {
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
reject(new Error(`CDP timeout for ${method}`));
|
|
92
|
+
}
|
|
93
|
+
}, timeout);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Execute JavaScript in Chrome's Perplexity tab and return result.
|
|
99
|
+
*/
|
|
100
|
+
async evaluate(expression, awaitPromise = true) {
|
|
101
|
+
const resp = await this._sendAsync('Runtime.evaluate', {
|
|
102
|
+
expression,
|
|
103
|
+
awaitPromise,
|
|
104
|
+
returnByValue: true,
|
|
105
|
+
});
|
|
106
|
+
if (resp.result?.exceptionDetails) {
|
|
107
|
+
const desc = resp.result.exceptionDetails.exception?.description || 'Unknown error';
|
|
108
|
+
throw new Error('Chrome eval error: ' + desc);
|
|
109
|
+
}
|
|
110
|
+
return resp.result?.result?.value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Execute a streaming fetch — stores chunks in Chrome, polls them back.
|
|
115
|
+
* Returns an async generator of SSE text chunks.
|
|
116
|
+
*/
|
|
117
|
+
async *fetchSSE(url, fetchOpts = {}) {
|
|
118
|
+
const storeId = '_pplx_' + Date.now();
|
|
119
|
+
|
|
120
|
+
// Start the fetch in Chrome
|
|
121
|
+
await this.evaluate(`
|
|
122
|
+
(async () => {
|
|
123
|
+
window.${storeId} = { chunks: [], done: false, error: null, status: 0 };
|
|
124
|
+
try {
|
|
125
|
+
const r = await fetch(${JSON.stringify(url)}, ${JSON.stringify(fetchOpts)});
|
|
126
|
+
window.${storeId}.status = r.status;
|
|
127
|
+
if (!r.ok) {
|
|
128
|
+
window.${storeId}.error = 'HTTP ' + r.status + ': ' + (await r.text()).substring(0, 500);
|
|
129
|
+
window.${storeId}.done = true;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const reader = r.body.getReader();
|
|
133
|
+
const decoder = new TextDecoder();
|
|
134
|
+
while (true) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
window.${storeId}.chunks.push(decoder.decode(value, { stream: true }));
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
window.${storeId}.error = e.message;
|
|
141
|
+
}
|
|
142
|
+
window.${storeId}.done = true;
|
|
143
|
+
})();
|
|
144
|
+
'started'
|
|
145
|
+
`, false); // Don't await — let it run in background
|
|
146
|
+
|
|
147
|
+
// Poll for chunks
|
|
148
|
+
const deadline = Date.now() + (fetchOpts.timeout ?? 120000);
|
|
149
|
+
await new Promise(r => setTimeout(r, 200)); // Give it a moment to start
|
|
150
|
+
try {
|
|
151
|
+
while (true) {
|
|
152
|
+
if (Date.now() > deadline) throw new Error('SSE polling timeout');
|
|
153
|
+
const stateJson = await this.evaluate(`
|
|
154
|
+
(() => {
|
|
155
|
+
const s = window['${storeId}'];
|
|
156
|
+
if (!s) return JSON.stringify({ chunks: [], done: true, error: 'store missing' });
|
|
157
|
+
const c = s.chunks.splice(0);
|
|
158
|
+
return JSON.stringify({ chunks: c, done: s.done, error: s.error, status: s.status });
|
|
159
|
+
})()
|
|
160
|
+
`, false);
|
|
161
|
+
|
|
162
|
+
const state = JSON.parse(stateJson);
|
|
163
|
+
|
|
164
|
+
if (state.error) {
|
|
165
|
+
throw new Error(state.error);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const chunk of state.chunks) {
|
|
169
|
+
yield chunk;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (state.done) break;
|
|
173
|
+
await new Promise(r => setTimeout(r, 100));
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
await this.evaluate(`delete window['${storeId}']`, false).catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
close() {
|
|
181
|
+
this.ws?.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { program } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { extractFromChrome, loadCookies, saveCookies, cookieHeader } from './cookies.js';
|
|
5
|
+
import { initSession, testAuth } from './session.js';
|
|
6
|
+
import { search } from './search.js';
|
|
7
|
+
import { LabsClient } from './labs.js';
|
|
8
|
+
import { formatSources } from './format.js';
|
|
9
|
+
import { LABS_MODELS, MODEL_MAP } from './constants.js';
|
|
10
|
+
import { setUseCurl } from './http.js';
|
|
11
|
+
import { loadConfig } from './config.js';
|
|
12
|
+
|
|
13
|
+
// --- Output state ---
|
|
14
|
+
let rawMode = false;
|
|
15
|
+
|
|
16
|
+
function isQuiet() {
|
|
17
|
+
return rawMode || !process.stdout.isTTY;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeSpinner(text) {
|
|
21
|
+
if (isQuiet()) {
|
|
22
|
+
// noop spinner
|
|
23
|
+
return { start() { return this; }, stop() {}, succeed() {}, fail() {}, text: '' };
|
|
24
|
+
}
|
|
25
|
+
return ora(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Stdin helper ---
|
|
29
|
+
function readStdin() {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let data = '';
|
|
32
|
+
process.stdin.setEncoding('utf-8');
|
|
33
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
34
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
35
|
+
process.stdin.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveQuery(queryArg) {
|
|
40
|
+
if (queryArg && queryArg !== '-') return queryArg;
|
|
41
|
+
if (queryArg === '-' || !process.stdin.isTTY) {
|
|
42
|
+
const input = await readStdin();
|
|
43
|
+
if (!input) {
|
|
44
|
+
console.error('Error: no query provided via stdin');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
console.error('Error: no query provided');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Program setup ---
|
|
54
|
+
program
|
|
55
|
+
.name('pplx')
|
|
56
|
+
.description('CLI for Perplexity AI')
|
|
57
|
+
.version('0.1.0');
|
|
58
|
+
|
|
59
|
+
program.option('--verbose', 'Enable verbose logging');
|
|
60
|
+
program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
|
|
61
|
+
program.option('--raw', 'Plain text output, no colors, no spinner');
|
|
62
|
+
|
|
63
|
+
program.hook('preAction', (thisCmd) => {
|
|
64
|
+
const gopts = thisCmd.optsWithGlobals ? thisCmd.optsWithGlobals() : thisCmd.opts();
|
|
65
|
+
if (gopts.verbose) process.env.PPLX_VERBOSE = '1';
|
|
66
|
+
if (gopts.proxy) process.env.HTTPS_PROXY = gopts.proxy;
|
|
67
|
+
if (gopts.raw) {
|
|
68
|
+
rawMode = true;
|
|
69
|
+
chalk.level = 0;
|
|
70
|
+
}
|
|
71
|
+
if (!process.stdout.isTTY) {
|
|
72
|
+
chalk.level = 0;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Auth command
|
|
77
|
+
program
|
|
78
|
+
.command('auth')
|
|
79
|
+
.description('Extract and manage cookies from Chrome')
|
|
80
|
+
.option('--test', 'Test if stored cookies are valid')
|
|
81
|
+
.option('--profile <name>', 'Chrome profile', 'Default')
|
|
82
|
+
.action(async (opts) => {
|
|
83
|
+
if (opts.test) {
|
|
84
|
+
const cookies = loadCookies();
|
|
85
|
+
if (!cookies) {
|
|
86
|
+
console.log(chalk.red('No cookies stored. Run: pplx auth'));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const spinner = makeSpinner('Testing cookies...').start();
|
|
90
|
+
try {
|
|
91
|
+
const ok = await testAuth(cookies);
|
|
92
|
+
spinner.stop();
|
|
93
|
+
console.log(ok ? chalk.green('✓ Cookies are valid') : chalk.red('✗ Cookies are invalid or expired'));
|
|
94
|
+
process.exit(ok ? 0 : 1);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
spinner.stop();
|
|
97
|
+
console.error(chalk.red('Error:'), e.message);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const spinner = makeSpinner('Extracting cookies from Chrome...').start();
|
|
104
|
+
try {
|
|
105
|
+
const cookies = extractFromChrome(opts.profile);
|
|
106
|
+
const count = Object.keys(cookies).length;
|
|
107
|
+
spinner.text = `Found ${count} cookies. Testing auth...`;
|
|
108
|
+
|
|
109
|
+
const hasSession = cookies['next-auth.session-token'] || cookies['__Secure-next-auth.session-token'];
|
|
110
|
+
if (!hasSession) {
|
|
111
|
+
spinner.stop();
|
|
112
|
+
console.log(chalk.yellow(`⚠ Found ${count} cookies but no session token.`));
|
|
113
|
+
console.log(' Make sure you are logged into perplexity.ai in Chrome.');
|
|
114
|
+
if (count > 0) {
|
|
115
|
+
saveCookies(cookies);
|
|
116
|
+
console.log(chalk.dim(' Saved cookies anyway.'));
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { cookies: refreshed } = await initSession(cookies);
|
|
122
|
+
saveCookies(refreshed);
|
|
123
|
+
spinner.succeed(`Extracted ${Object.keys(refreshed).length} cookies and saved to ~/.config/pplx/cookies.json`);
|
|
124
|
+
|
|
125
|
+
const token = refreshed['__Secure-next-auth.session-token'] || refreshed['next-auth.session-token'];
|
|
126
|
+
console.log(chalk.dim(` Session token: ${token?.slice(0, 20)}...`));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
spinner.fail('Failed to extract cookies');
|
|
129
|
+
console.error(chalk.red(e.message));
|
|
130
|
+
if (e.message.includes('Keychain')) {
|
|
131
|
+
console.log(chalk.dim(' You may need to allow access in the Keychain prompt.'));
|
|
132
|
+
}
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Shared search logic
|
|
138
|
+
async function doSearch(query, opts) {
|
|
139
|
+
const cfg = loadConfig();
|
|
140
|
+
opts = { ...cfg, ...opts };
|
|
141
|
+
if (opts.curl) setUseCurl(true);
|
|
142
|
+
|
|
143
|
+
const cookies = loadCookies();
|
|
144
|
+
if (!cookies) {
|
|
145
|
+
console.error(chalk.red('No cookies. Run: pplx auth'));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mode = opts.mode || 'pro';
|
|
150
|
+
const sources = opts.sources ? opts.sources.split(',') : ['web'];
|
|
151
|
+
const lang = opts.lang || 'en-US';
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
let lastAnswer = '';
|
|
155
|
+
let lastData = null;
|
|
156
|
+
let spinnerStopped = false;
|
|
157
|
+
|
|
158
|
+
for await (const data of search(query, cookies, {
|
|
159
|
+
mode,
|
|
160
|
+
model: opts.model,
|
|
161
|
+
sources,
|
|
162
|
+
language: lang,
|
|
163
|
+
incognito: opts.incognito,
|
|
164
|
+
chrome: opts.chrome,
|
|
165
|
+
})) {
|
|
166
|
+
lastData = data;
|
|
167
|
+
|
|
168
|
+
if (opts.json) {
|
|
169
|
+
// For --json, we accumulate and output a single final object at the end
|
|
170
|
+
if (opts._spinner && !spinnerStopped) { opts._spinner.stop(); spinnerStopped = true; }
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Stream the answer diff
|
|
175
|
+
const answer = data.answer || '';
|
|
176
|
+
if (answer.length > lastAnswer.length) {
|
|
177
|
+
if (opts._spinner && !spinnerStopped) { opts._spinner.stop(); spinnerStopped = true; }
|
|
178
|
+
process.stdout.write(answer.slice(lastAnswer.length));
|
|
179
|
+
lastAnswer = answer;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (opts.json) {
|
|
184
|
+
// Output single final JSON object
|
|
185
|
+
const answer = lastData?.answer || lastAnswer || '';
|
|
186
|
+
const webResults = lastData?.web_results || [];
|
|
187
|
+
const jsonOut = {
|
|
188
|
+
answer,
|
|
189
|
+
sources: webResults.map(r => ({ title: r.name || r.title, url: r.url })),
|
|
190
|
+
query,
|
|
191
|
+
mode,
|
|
192
|
+
model: opts.model || 'default',
|
|
193
|
+
};
|
|
194
|
+
console.log(JSON.stringify(jsonOut));
|
|
195
|
+
if (!answer) process.exit(1);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!lastAnswer) {
|
|
200
|
+
if (opts._spinner && !spinnerStopped) { opts._spinner.stop(); spinnerStopped = true; }
|
|
201
|
+
console.error(chalk.yellow('No answer received. Try re-authing (pplx auth) or use --curl.'));
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.stdout.write('\n');
|
|
206
|
+
if (!rawMode && opts.citations !== false && lastData?.web_results) {
|
|
207
|
+
console.log(formatSources(lastData.web_results, { full: opts.citationsFull }));
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
console.error(chalk.red('\nError:'), e.message);
|
|
211
|
+
if (e.message.includes('403')) {
|
|
212
|
+
console.log(chalk.yellow('Possible TLS fingerprinting block. Try: pplx search --curl "query"'));
|
|
213
|
+
}
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Search command
|
|
219
|
+
program
|
|
220
|
+
.command('search [query]')
|
|
221
|
+
.description('Search with Perplexity (default: pro mode)')
|
|
222
|
+
.option('-m, --mode <mode>', 'Search mode: auto, pro, reasoning, deep-research', 'pro')
|
|
223
|
+
.option('--model <model>', 'Model name or raw model ID (see pplx models)')
|
|
224
|
+
.option('--sources <sources>', 'Comma-separated: web,scholar,social', 'web')
|
|
225
|
+
.option('--json', 'Output single JSON object with answer, sources, query, mode, model')
|
|
226
|
+
.option('--raw', 'Plain text answer only (alias for global --raw)')
|
|
227
|
+
.option('--no-citations', 'Hide citation numbers and sources')
|
|
228
|
+
.option('--citations-full', 'Show full citation details (title + URL)')
|
|
229
|
+
.option('--incognito', 'Don\'t save to Perplexity history')
|
|
230
|
+
.option('--lang <code>', 'Language code', 'en-US')
|
|
231
|
+
.option('--curl', 'Force curl-impersonate for TLS')
|
|
232
|
+
.option('--chrome', 'Use Chrome CDP bridge instead of HTTP')
|
|
233
|
+
.action(async (queryArg, opts) => {
|
|
234
|
+
if (opts.raw) { rawMode = true; chalk.level = 0; }
|
|
235
|
+
const query = await resolveQuery(queryArg);
|
|
236
|
+
await doSearch(query, opts);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Shorthand: reason
|
|
240
|
+
program
|
|
241
|
+
.command('reason [query]')
|
|
242
|
+
.description('Reasoning mode search')
|
|
243
|
+
.option('--model <model>', 'Model name')
|
|
244
|
+
.option('--json', 'Output raw JSON')
|
|
245
|
+
.option('--curl', 'Force curl-impersonate')
|
|
246
|
+
.option('--chrome', 'Use Chrome CDP bridge')
|
|
247
|
+
.action(async (queryArg, opts) => {
|
|
248
|
+
const query = await resolveQuery(queryArg);
|
|
249
|
+
await doSearch(query, { ...opts, mode: 'reasoning' });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Shorthand: research
|
|
253
|
+
program
|
|
254
|
+
.command('research [query]')
|
|
255
|
+
.description('Deep research mode')
|
|
256
|
+
.option('--json', 'Output raw JSON')
|
|
257
|
+
.option('--curl', 'Force curl-impersonate')
|
|
258
|
+
.option('--chrome', 'Use Chrome CDP bridge')
|
|
259
|
+
.action(async (queryArg, opts) => {
|
|
260
|
+
const query = await resolveQuery(queryArg);
|
|
261
|
+
const spinner = makeSpinner('Deep research in progress...').start();
|
|
262
|
+
try {
|
|
263
|
+
await doSearch(query, { ...opts, mode: 'deep-research', _spinner: spinner });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
spinner.fail(e.message);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Labs command
|
|
271
|
+
program
|
|
272
|
+
.command('labs [query]')
|
|
273
|
+
.description('Query open-source models (no auth needed)')
|
|
274
|
+
.option('--model <model>', `Model: ${LABS_MODELS.join(', ')}`, 'sonar')
|
|
275
|
+
.option('--json', 'Output raw JSON')
|
|
276
|
+
.action(async (queryArg, opts) => {
|
|
277
|
+
const query = await resolveQuery(queryArg);
|
|
278
|
+
const spinner = makeSpinner('Connecting to labs...').start();
|
|
279
|
+
const client = new LabsClient();
|
|
280
|
+
try {
|
|
281
|
+
await client.connect();
|
|
282
|
+
spinner.stop();
|
|
283
|
+
|
|
284
|
+
let lastOutput = '';
|
|
285
|
+
for await (const data of client.ask(query, opts.model)) {
|
|
286
|
+
if (opts.json) {
|
|
287
|
+
console.log(JSON.stringify(data));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const output = data.output || '';
|
|
291
|
+
if (output.length > lastOutput.length) {
|
|
292
|
+
process.stdout.write(output.slice(lastOutput.length));
|
|
293
|
+
lastOutput = output;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!opts.json) process.stdout.write('\n');
|
|
297
|
+
} catch (e) {
|
|
298
|
+
spinner.fail('Labs error: ' + e.message);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
} finally {
|
|
301
|
+
client.close();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Models command
|
|
306
|
+
program
|
|
307
|
+
.command('models')
|
|
308
|
+
.description('List available models')
|
|
309
|
+
.action(() => {
|
|
310
|
+
for (const [mode, models] of Object.entries(MODEL_MAP)) {
|
|
311
|
+
console.log(chalk.bold(`\n${mode.charAt(0).toUpperCase() + mode.slice(1)} models:`));
|
|
312
|
+
for (const [name, id] of Object.entries(models)) {
|
|
313
|
+
console.log(` ${name.padEnd(30)} ${chalk.dim(id)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
console.log(chalk.bold('\nLabs models:'), LABS_MODELS.join(', '));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Default: treat bare args as search
|
|
320
|
+
program
|
|
321
|
+
.argument('[query...]', 'Quick search (shorthand for pplx search)')
|
|
322
|
+
.action(async (query) => {
|
|
323
|
+
if (query.length > 0) {
|
|
324
|
+
await doSearch(query.join(' '), {});
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
program.parseAsync().catch((e) => {
|
|
329
|
+
console.error(e.message || e);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|