pplx-npx-search 0.2.2 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to pplx-cli will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-05-22
9
+
10
+ ### Changed
11
+ - **Node 26 compatibility.** Upgraded `better-sqlite3` so installs work on Homebrew Node 26 hosts.
12
+
13
+ ## [0.3.0] - 2026-05-22
14
+
15
+ ### Added
16
+ - **Standard artifacts.** Query-producing commands now save run folders by default with `meta.json`, `query.txt`, `answer.md`, `result.json`, and `sources.json`.
17
+ - **Artifact controls.** `--out <dir>`, `--artifact-id <id>`, and `--no-artifact` support local agent workflows and deterministic run folders.
18
+ - **Perplexity Computer handoff.** `pplx computer` creates artifact-first task prompts for Perplexity Computer and validates `computer-result.json` outputs for local agents.
19
+
8
20
  ## [0.2.2] - 2026-05-21
9
21
 
10
22
  ### Added
@@ -55,6 +67,8 @@ First public release worth telling people about. (v0.2.0 was unpublished before
55
67
  - SSE streaming for real-time answers
56
68
  - Optional Playwright and Chrome CDP transports
57
69
 
70
+ [0.3.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.3.0...v0.3.1
71
+ [0.3.0]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.2...v0.3.0
58
72
  [0.2.2]: https://github.com/thatsrajan/pplx-cli/compare/v0.2.1...v0.2.2
59
73
  [0.2.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.1...v0.2.1
60
74
  [0.1.1]: https://github.com/thatsrajan/pplx-cli/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -101,6 +101,7 @@ pplx search "what is quantum computing"
101
101
  pplx reason "explain the Riemann hypothesis"
102
102
  pplx research "compare React vs Vue in 2026"
103
103
  pplx labs "hello world" # free, no auth needed
104
+ pplx computer new "compare dinner options nearby"
104
105
  pplx models # list available models
105
106
  ```
106
107
 
@@ -145,12 +146,46 @@ pplx search "research this topic" --json --raw --mode pro
145
146
  ],
146
147
  "query": "...",
147
148
  "mode": "pro",
148
- "model": "..."
149
+ "model": "...",
150
+ "artifactDir": "/Users/you/.config/pplx/artifacts/...",
151
+ "artifactId": "..."
149
152
  }
150
153
  ```
151
154
 
152
155
  ---
153
156
 
157
+ ## Artifacts
158
+
159
+ Query-producing commands save artifacts by default:
160
+
161
+ - `pplx search`
162
+ - `pplx reason`
163
+ - `pplx research`
164
+ - `pplx labs`
165
+ - bare `pplx "query"`
166
+ - `pplx computer new`
167
+
168
+ Each run gets a folder containing `meta.json`, `query.txt`, `answer.md`, `result.json`, and `sources.json`. Use `--out <dir>` to choose the destination for one run, or set `"artifactDir"` in `~/.config/pplx/config.json` to make it persistent. Use `--artifact-id <id>` when an agent needs a deterministic run folder, and `--no-artifact` to disable saving for one search-style run.
169
+
170
+ ```bash
171
+ pplx search "compare laptops under $1500" --out ~/Dropbox/pplx-runs
172
+ pplx research "best places to stay in Kyoto" --artifact-id kyoto-research
173
+ pplx search "what is 2+2" --no-artifact --json --raw
174
+ ```
175
+
176
+ `pplx computer` is an artifact handoff for Perplexity Computer. It creates a task prompt and a result contract without calling private Computer APIs:
177
+
178
+ ```bash
179
+ pplx computer new "compare dinner options nearby" --out ~/Dropbox/pplx-runs
180
+ pplx computer open <run-id> --copy
181
+ pplx computer status <run-id> --out ~/Dropbox/pplx-runs
182
+ pplx computer import <run-id> --out ~/Dropbox/pplx-runs --json
183
+ ```
184
+
185
+ Computer runs include `task.md`, `result.schema.json`, and `computer-result.json`. Paste `task.md` into Perplexity Computer; when the task is done, place the structured result in `computer-result.json` so local agents can read it.
186
+
187
+ ---
188
+
154
189
  ## Options
155
190
 
156
191
  | Flag | Description |
@@ -163,6 +198,9 @@ pplx search "research this topic" --json --raw --mode pro
163
198
  | `--playwright` | Use Playwright headless Chromium |
164
199
  | `--no-playwright` | Force HTTP transport even if config enables Playwright |
165
200
  | `--timeout-ms 120000\|120s\|10m` | Overall stream timeout |
201
+ | `--out <dir>` | Directory for saved artifacts |
202
+ | `--artifact-id <id>` | Deterministic artifact id for this run |
203
+ | `--no-artifact` | Disable artifact saving for one search-style run |
166
204
  | `--curl` | Force curl-impersonate (auto-downloads if missing) |
167
205
  | `--allow-anonymous` | Allow anonymous Perplexity responses when cookies are expired |
168
206
  | `--incognito` | Do not save the query to Perplexity history |
@@ -193,7 +231,8 @@ Optional config file at `~/.config/pplx/config.json`:
193
231
  "model": "claude-3.5-sonnet",
194
232
  "lang": "en-US",
195
233
  "playwright": true,
196
- "playwrightHeadless": false
234
+ "playwrightHeadless": false,
235
+ "artifactDir": "/Users/you/Dropbox/pplx-runs"
197
236
  }
198
237
  ```
199
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pplx-npx-search",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "CLI for Perplexity AI with cookie-based auth. Headless, agent-friendly, no API key required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "node": ">=20.0.0"
38
38
  },
39
39
  "dependencies": {
40
- "better-sqlite3": "^11.0.0",
40
+ "better-sqlite3": "^12.10.0",
41
41
  "chalk": "^5.3.0",
42
42
  "commander": "^12.0.0",
43
43
  "eventsource-parser": "^3.0.0",
@@ -0,0 +1,85 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CONFIG_DIR } from './constants.js';
6
+
7
+ export const DEFAULT_ARTIFACT_DIR = join(CONFIG_DIR, 'artifacts');
8
+ export const ARTIFACT_SCHEMA_VERSION = 1;
9
+
10
+ function expandHome(value) {
11
+ if (!value) return value;
12
+ if (value === '~') return homedir();
13
+ if (value.startsWith('~/')) return join(homedir(), value.slice(2));
14
+ return value;
15
+ }
16
+
17
+ export function resolveArtifactDir({ out, config = {} } = {}) {
18
+ const selected = out || config.artifactDir || DEFAULT_ARTIFACT_DIR;
19
+ const expanded = expandHome(selected);
20
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
21
+ }
22
+
23
+ export function createArtifactId(id) {
24
+ if (id) {
25
+ if (!/^[A-Za-z0-9._-]+$/.test(id) || id === '.' || id === '..') {
26
+ throw new Error('artifact id may only contain letters, numbers, dots, underscores, and hyphens');
27
+ }
28
+ return id;
29
+ }
30
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
31
+ return `${stamp}-${randomUUID().slice(0, 8)}`;
32
+ }
33
+
34
+ export function makeArtifactContext({ command, query, opts = {}, config = {} }) {
35
+ if (opts.artifact === false) return null;
36
+ const artifactId = createArtifactId(opts.artifactId);
37
+ const artifactRoot = resolveArtifactDir({ out: opts.out, config });
38
+ const artifactDir = join(artifactRoot, artifactId);
39
+ return { command, query, artifactId, artifactRoot, artifactDir };
40
+ }
41
+
42
+ export function writeJson(file, value) {
43
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
44
+ }
45
+
46
+ export function writeStandardArtifact(ctx, payload) {
47
+ if (!ctx) return null;
48
+ mkdirSync(ctx.artifactDir, { recursive: true });
49
+
50
+ const createdAt = payload.createdAt || new Date().toISOString();
51
+ const sources = payload.sources || [];
52
+ const result = {
53
+ query: ctx.query,
54
+ answer: payload.answer || '',
55
+ sources,
56
+ command: ctx.command,
57
+ mode: payload.mode || null,
58
+ model: payload.model || null,
59
+ artifactId: ctx.artifactId,
60
+ artifactDir: ctx.artifactDir,
61
+ createdAt,
62
+ };
63
+ const meta = {
64
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
65
+ command: ctx.command,
66
+ query: ctx.query,
67
+ mode: payload.mode || null,
68
+ model: payload.model || null,
69
+ artifactId: ctx.artifactId,
70
+ artifactDir: ctx.artifactDir,
71
+ createdAt,
72
+ status: payload.status || 'complete',
73
+ };
74
+
75
+ writeJson(join(ctx.artifactDir, 'meta.json'), meta);
76
+ writeFileSync(join(ctx.artifactDir, 'query.txt'), `${ctx.query}\n`, 'utf8');
77
+ writeFileSync(join(ctx.artifactDir, 'answer.md'), payload.answer || '', 'utf8');
78
+ writeJson(join(ctx.artifactDir, 'result.json'), result);
79
+ writeJson(join(ctx.artifactDir, 'sources.json'), sources);
80
+ return { artifactId: ctx.artifactId, artifactDir: ctx.artifactDir };
81
+ }
82
+
83
+ export function readJsonFile(file) {
84
+ return JSON.parse(readFileSync(file, 'utf8'));
85
+ }
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
2
3
  import { program } from 'commander';
3
4
  import chalk from 'chalk';
4
5
  import ora from 'ora';
@@ -12,6 +13,15 @@ import { LABS_MODELS, MODEL_MAP } from './constants.js';
12
13
  import { setUseCurl } from './http.js';
13
14
  import { loadConfig } from './config.js';
14
15
  import { resolveTimeoutMs } from './timeout.js';
16
+ import { makeArtifactContext, resolveArtifactDir, writeStandardArtifact } from './artifacts.js';
17
+ import {
18
+ createComputerRun,
19
+ copyTextToClipboard,
20
+ importComputerResult,
21
+ inspectComputerRun,
22
+ openComputerUrl,
23
+ readTaskFile,
24
+ } from './computer.js';
15
25
 
16
26
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
17
27
 
@@ -93,6 +103,9 @@ program
93
103
  program.option('--verbose', 'Enable verbose logging');
94
104
  program.option('--proxy <url>', 'Set proxy URL (sets HTTPS_PROXY env var)');
95
105
  program.option('--raw', 'Plain text output, no colors, no spinner');
106
+ program.option('--out <dir>', 'Directory for saved artifacts');
107
+ program.option('--no-artifact', 'Disable artifact saving for this run');
108
+ program.option('--artifact-id <id>', 'Deterministic artifact id for this run');
96
109
 
97
110
  program.hook('preAction', (thisCmd) => {
98
111
  const gopts = thisCmd.optsWithGlobals ? thisCmd.optsWithGlobals() : thisCmd.opts();
@@ -107,6 +120,35 @@ program.hook('preAction', (thisCmd) => {
107
120
  }
108
121
  });
109
122
 
123
+ function getOpts(commandOrOpts) {
124
+ const globals = program.opts();
125
+ const locals = commandOrOpts.optsWithGlobals
126
+ ? { ...commandOrOpts.optsWithGlobals(), ...commandOrOpts.opts() }
127
+ : commandOrOpts;
128
+ const merged = { ...globals, ...locals };
129
+ if (globals.artifact === false || locals.artifact === false) merged.artifact = false;
130
+ return merged;
131
+ }
132
+
133
+ function addArtifactOptions(command, { allowDisable = true } = {}) {
134
+ command
135
+ .option('--out <dir>', 'Directory for saved artifacts')
136
+ .option('--artifact-id <id>', 'Deterministic artifact id for this run');
137
+ if (allowDisable) command.option('--no-artifact', 'Disable artifact saving for this run');
138
+ return command;
139
+ }
140
+
141
+ function maybePrintArtifactInfo(info, opts) {
142
+ if (!info || opts.json || rawMode) return;
143
+ console.log(chalk.dim(`\nArtifact: ${info.artifactDir}`));
144
+ }
145
+
146
+ function resolveRunDir(runId, opts = {}) {
147
+ if (isAbsolute(runId) || runId.includes('/')) return resolve(runId);
148
+ const cfg = loadConfig();
149
+ return join(resolveArtifactDir({ out: opts.out, config: cfg }), runId);
150
+ }
151
+
110
152
  // Auth command
111
153
  program
112
154
  .command('auth')
@@ -259,9 +301,9 @@ program
259
301
  });
260
302
 
261
303
  // Shared search logic
262
- async function doSearch(query, opts) {
304
+ async function doSearch(query, opts, commandName = 'search') {
263
305
  const cfg = loadConfig();
264
- opts = { ...cfg, ...opts };
306
+ opts = { ...cfg, ...getOpts(opts) };
265
307
  if (opts.curl) setUseCurl(true);
266
308
 
267
309
  const cookies = loadCookies() || {};
@@ -287,6 +329,13 @@ async function doSearch(query, opts) {
287
329
  }
288
330
  const sources = opts.sources ? opts.sources.split(',') : ['web'];
289
331
  const lang = opts.lang || 'en-US';
332
+ let artifactCtx = null;
333
+ try {
334
+ artifactCtx = makeArtifactContext({ command: commandName, query, opts, config: cfg });
335
+ } catch (e) {
336
+ console.error(chalk.red(e.message));
337
+ process.exit(1);
338
+ }
290
339
 
291
340
  try {
292
341
  let lastAnswer = '';
@@ -325,12 +374,21 @@ async function doSearch(query, opts) {
325
374
  // Output single final JSON object
326
375
  const answer = lastData?.answer || lastAnswer || '';
327
376
  const webResults = lastData?.web_results || [];
377
+ const normalizedSources = webResults.map(r => ({ title: r.name || r.title, url: r.url }));
378
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
379
+ answer,
380
+ sources: normalizedSources,
381
+ mode,
382
+ model: opts.model || 'default',
383
+ });
328
384
  const jsonOut = {
329
385
  answer,
330
- sources: webResults.map(r => ({ title: r.name || r.title, url: r.url })),
386
+ sources: normalizedSources,
331
387
  query,
332
388
  mode,
333
389
  model: opts.model || 'default',
390
+ artifactDir: artifactInfo?.artifactDir,
391
+ artifactId: artifactInfo?.artifactId,
334
392
  };
335
393
  console.log(JSON.stringify(jsonOut));
336
394
  if (!answer) process.exit(1);
@@ -347,6 +405,14 @@ async function doSearch(query, opts) {
347
405
  if (!rawMode && opts.citations !== false && lastData?.web_results) {
348
406
  console.log(formatSources(lastData.web_results, { full: opts.citationsFull }));
349
407
  }
408
+ const webResults = lastData?.web_results || [];
409
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
410
+ answer: lastAnswer,
411
+ sources: webResults.map(r => ({ title: r.name || r.title, url: r.url })),
412
+ mode,
413
+ model: opts.model || 'default',
414
+ });
415
+ maybePrintArtifactInfo(artifactInfo, opts);
350
416
  } catch (e) {
351
417
  console.error(chalk.red('\nError:'), e.message);
352
418
  if (e.message.includes('403')) {
@@ -357,7 +423,7 @@ async function doSearch(query, opts) {
357
423
  }
358
424
 
359
425
  // Search command
360
- program
426
+ addArtifactOptions(program
361
427
  .command('search [query]')
362
428
  .description('Search with Perplexity (default: pro mode)')
363
429
  .option('-m, --mode <mode>', 'Search mode: auto, pro, reasoning, deep-research', 'pro')
@@ -374,15 +440,16 @@ program
374
440
  .option('--playwright', 'Use Playwright headless Chromium instead of HTTP')
375
441
  .option('--no-playwright', 'Disable Playwright even if config enables it')
376
442
  .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
377
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
443
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
378
444
  .action(async (queryArg, opts) => {
445
+ opts = getOpts(opts);
379
446
  if (opts.raw) { rawMode = true; chalk.level = 0; }
380
447
  const query = await resolveQuery(queryArg);
381
- await doSearch(query, opts);
448
+ await doSearch(query, opts, 'search');
382
449
  });
383
450
 
384
451
  // Shorthand: reason
385
- program
452
+ addArtifactOptions(program
386
453
  .command('reason [query]')
387
454
  .description('Reasoning mode search')
388
455
  .option('--model <model>', 'Model name')
@@ -392,14 +459,15 @@ program
392
459
  .option('--playwright', 'Use Playwright headless Chromium')
393
460
  .option('--no-playwright', 'Disable Playwright even if config enables it')
394
461
  .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
395
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
462
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
396
463
  .action(async (queryArg, opts) => {
464
+ opts = getOpts(opts);
397
465
  const query = await resolveQuery(queryArg);
398
- await doSearch(query, { ...opts, mode: 'reasoning' });
466
+ await doSearch(query, { ...opts, mode: 'reasoning' }, 'reason');
399
467
  });
400
468
 
401
469
  // Shorthand: research
402
- program
470
+ addArtifactOptions(program
403
471
  .command('research [query]')
404
472
  .description('Deep research mode')
405
473
  .option('--json', 'Output raw JSON')
@@ -408,12 +476,13 @@ program
408
476
  .option('--playwright', 'Use Playwright headless Chromium')
409
477
  .option('--no-playwright', 'Disable Playwright even if config enables it')
410
478
  .option('--timeout-ms <duration>', 'Overall stream timeout: milliseconds by default, or use 120s / 10m')
411
- .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired')
479
+ .option('--allow-anonymous', 'Allow anonymous Perplexity responses when cookies are expired'))
412
480
  .action(async (queryArg, opts) => {
481
+ opts = getOpts(opts);
413
482
  const query = await resolveQuery(queryArg);
414
483
  const spinner = makeSpinner('Deep research in progress...').start();
415
484
  try {
416
- await doSearch(query, { ...opts, mode: 'deep-research', _spinner: spinner });
485
+ await doSearch(query, { ...opts, mode: 'deep-research', _spinner: spinner }, 'research');
417
486
  } catch (e) {
418
487
  spinner.fail(e.message);
419
488
  process.exit(1);
@@ -421,13 +490,22 @@ program
421
490
  });
422
491
 
423
492
  // Labs command
424
- program
493
+ addArtifactOptions(program
425
494
  .command('labs [query]')
426
495
  .description('Query open-source models (no auth needed)')
427
496
  .option('--model <model>', `Model: ${LABS_MODELS.join(', ')}`, 'sonar')
428
- .option('--json', 'Output raw JSON')
497
+ .option('--json', 'Output single JSON object with answer, events, and artifact metadata'))
429
498
  .action(async (queryArg, opts) => {
499
+ opts = getOpts(opts);
500
+ const cfg = loadConfig();
430
501
  const query = await resolveQuery(queryArg);
502
+ let artifactCtx = null;
503
+ try {
504
+ artifactCtx = makeArtifactContext({ command: 'labs', query, opts, config: cfg });
505
+ } catch (e) {
506
+ console.error(chalk.red(e.message));
507
+ process.exit(1);
508
+ }
431
509
  const spinner = makeSpinner('Connecting to labs...').start();
432
510
  const client = new LabsClient();
433
511
  try {
@@ -435,9 +513,10 @@ program
435
513
  spinner.stop();
436
514
 
437
515
  let lastOutput = '';
516
+ const events = [];
438
517
  for await (const data of client.ask(query, opts.model)) {
518
+ events.push(data);
439
519
  if (opts.json) {
440
- console.log(JSON.stringify(data));
441
520
  continue;
442
521
  }
443
522
  const output = data.output || '';
@@ -447,14 +526,129 @@ program
447
526
  }
448
527
  }
449
528
  if (!opts.json) process.stdout.write('\n');
529
+ const artifactInfo = writeStandardArtifact(artifactCtx, {
530
+ answer: lastOutput,
531
+ sources: [],
532
+ mode: 'labs',
533
+ model: opts.model,
534
+ });
535
+ if (opts.json) {
536
+ console.log(JSON.stringify({
537
+ answer: lastOutput,
538
+ events,
539
+ query,
540
+ mode: 'labs',
541
+ model: opts.model,
542
+ artifactDir: artifactInfo?.artifactDir,
543
+ artifactId: artifactInfo?.artifactId,
544
+ }));
545
+ } else {
546
+ maybePrintArtifactInfo(artifactInfo, opts);
547
+ }
450
548
  } catch (e) {
451
549
  spinner.fail('Labs error: ' + e.message);
550
+ if (isQuiet()) console.error(chalk.red('Labs error:'), e.message);
452
551
  process.exit(1);
453
552
  } finally {
454
553
  client.close();
455
554
  }
456
555
  });
457
556
 
557
+ // Computer artifact handoff workflow
558
+ const computer = program
559
+ .command('computer')
560
+ .description('Create and manage Perplexity Computer artifact handoffs');
561
+
562
+ addArtifactOptions(computer
563
+ .command('new [task]')
564
+ .description('Create a Perplexity Computer task artifact')
565
+ .option('--template <name>', 'Computer task template', 'compare')
566
+ .option('--json', 'Output run metadata as JSON'), { allowDisable: false })
567
+ .action(async (taskArg, opts) => {
568
+ opts = getOpts(opts);
569
+ const task = await resolveQuery(taskArg);
570
+ try {
571
+ const run = createComputerRun({
572
+ task,
573
+ template: opts.template,
574
+ opts,
575
+ config: loadConfig(),
576
+ });
577
+ if (opts.json) {
578
+ console.log(JSON.stringify(run));
579
+ return;
580
+ }
581
+ console.log(chalk.green(`✓ Computer task artifact created: ${run.artifactDir}`));
582
+ console.log(chalk.dim(` Task: ${run.taskPath}`));
583
+ console.log(chalk.dim(` Result: ${run.resultPath}`));
584
+ } catch (e) {
585
+ console.error(chalk.red(e.message));
586
+ process.exit(1);
587
+ }
588
+ });
589
+
590
+ addArtifactOptions(computer
591
+ .command('open <run>')
592
+ .description('Open Perplexity Computer and optionally copy task.md')
593
+ .option('--copy', 'Copy task.md to the clipboard'), { allowDisable: false })
594
+ .action((runId, opts) => {
595
+ opts = getOpts(opts);
596
+ const runDir = resolveRunDir(runId, opts);
597
+ try {
598
+ if (opts.copy) {
599
+ const taskText = readTaskFile(runDir);
600
+ if (!copyTextToClipboard(taskText)) {
601
+ console.log(chalk.yellow('Clipboard copy is only supported on macOS.'));
602
+ }
603
+ }
604
+ if (!openComputerUrl()) {
605
+ console.log(chalk.yellow('Opening Perplexity Computer is only supported on macOS.'));
606
+ console.log('https://www.perplexity.ai/computer');
607
+ return;
608
+ }
609
+ console.log(chalk.green(`✓ Opened Perplexity Computer for ${runDir}`));
610
+ if (opts.copy) console.log(chalk.dim(' Copied task.md to clipboard.'));
611
+ } catch (e) {
612
+ console.error(chalk.red(e.message));
613
+ process.exit(1);
614
+ }
615
+ });
616
+
617
+ addArtifactOptions(computer
618
+ .command('status <run>')
619
+ .description('Inspect a Perplexity Computer artifact run')
620
+ .option('--json', 'Output status as JSON'), { allowDisable: false })
621
+ .action((runId, opts) => {
622
+ opts = getOpts(opts);
623
+ const status = inspectComputerRun(resolveRunDir(runId, opts));
624
+ if (opts.json) {
625
+ console.log(JSON.stringify(status));
626
+ return;
627
+ }
628
+ const label = status.status === 'complete'
629
+ ? chalk.green('[✓] Complete')
630
+ : status.status === 'pending'
631
+ ? chalk.yellow('[○] Pending')
632
+ : chalk.red(status.status === 'invalid' ? '[!] Invalid' : '[✗] Missing');
633
+ console.log(`${label} ${status.artifactDir}`);
634
+ if (status.reason) console.log(chalk.dim(` ${status.reason}`));
635
+ });
636
+
637
+ addArtifactOptions(computer
638
+ .command('import <run>')
639
+ .description('Print a completed Perplexity Computer result')
640
+ .option('--json', 'Output compact JSON'), { allowDisable: false })
641
+ .action((runId, opts) => {
642
+ opts = getOpts(opts);
643
+ try {
644
+ const result = importComputerResult(resolveRunDir(runId, opts));
645
+ console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
646
+ } catch (e) {
647
+ console.error(chalk.red(e.message));
648
+ process.exit(1);
649
+ }
650
+ });
651
+
458
652
  // Models command
459
653
  program
460
654
  .command('models')
@@ -472,9 +666,9 @@ program
472
666
  // Default: treat bare args as search
473
667
  program
474
668
  .argument('[query...]', 'Quick search (shorthand for pplx search)')
475
- .action(async (query) => {
669
+ .action(async (query, opts) => {
476
670
  if (query.length > 0) {
477
- await doSearch(query.join(' '), {});
671
+ await doSearch(query.join(' '), getOpts(opts || program), 'search');
478
672
  }
479
673
  });
480
674
 
@@ -0,0 +1,193 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import {
5
+ ARTIFACT_SCHEMA_VERSION,
6
+ makeArtifactContext,
7
+ readJsonFile,
8
+ writeJson,
9
+ } from './artifacts.js';
10
+
11
+ export const COMPUTER_URL = 'https://www.perplexity.ai/computer';
12
+ export const COMPUTER_RESULT_FILE = 'computer-result.json';
13
+ export const PENDING_COMPUTER_RESULT = {
14
+ summary: '',
15
+ winner: '',
16
+ confidence: 'low',
17
+ items: [],
18
+ sources: [],
19
+ checked_at: '',
20
+ notes: [],
21
+ _status: 'pending',
22
+ };
23
+
24
+ const RESULT_SCHEMA = {
25
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
26
+ title: 'pplx computer comparison result',
27
+ type: 'object',
28
+ required: ['summary', 'winner', 'confidence', 'items', 'sources', 'checked_at', 'notes'],
29
+ properties: {
30
+ summary: { type: 'string' },
31
+ winner: { type: 'string' },
32
+ confidence: { enum: ['low', 'medium', 'high'] },
33
+ items: { type: 'array' },
34
+ sources: { type: 'array' },
35
+ checked_at: { type: 'string' },
36
+ notes: { type: 'array' },
37
+ },
38
+ };
39
+
40
+ export function buildComputerTask({ task, template = 'compare', resultPath }) {
41
+ if (template !== 'compare') {
42
+ throw new Error(`unsupported computer template: ${template}`);
43
+ }
44
+
45
+ return `# Perplexity Computer Task
46
+
47
+ You are running a live comparison task for a local agent workflow.
48
+
49
+ ## User request
50
+
51
+ ${task}
52
+
53
+ ## Instructions
54
+
55
+ - Use live web pages and direct source pages where possible, not only search snippets.
56
+ - Compare any relevant prices, fees, availability, location, timing, quality signals, eligibility rules, and constraints.
57
+ - This template is intentionally broad: it can cover products, real estate, restaurants, food prices, travel, rewards portals, services, and other comparison tasks.
58
+ - Preserve source URLs for every material claim.
59
+ - Include the time the information was checked.
60
+ - Mark uncertainty explicitly. Do not invent missing values.
61
+ - Prefer structured evidence over prose.
62
+
63
+ ## Output target
64
+
65
+ Write the final result as JSON matching \`result.schema.json\`.
66
+
67
+ If you can access the local filesystem, save it here:
68
+
69
+ \`${resultPath}\`
70
+
71
+ If you cannot access the filesystem, return the JSON in the chat so the local agent or user can place it in that file.
72
+ `;
73
+ }
74
+
75
+ export function createComputerRun({ task, template = 'compare', opts = {}, config = {} }) {
76
+ const ctx = makeArtifactContext({ command: 'computer', query: task, opts, config });
77
+ if (!ctx) {
78
+ throw new Error('computer runs require artifacts; omit --no-artifact for this command');
79
+ }
80
+
81
+ mkdirSync(ctx.artifactDir, { recursive: true });
82
+ const createdAt = new Date().toISOString();
83
+ const resultPath = join(ctx.artifactDir, COMPUTER_RESULT_FILE);
84
+ const taskText = buildComputerTask({ task, template, resultPath });
85
+ const meta = {
86
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
87
+ command: 'computer',
88
+ query: task,
89
+ template,
90
+ artifactId: ctx.artifactId,
91
+ artifactDir: ctx.artifactDir,
92
+ createdAt,
93
+ status: 'pending',
94
+ };
95
+ const result = {
96
+ query: task,
97
+ answer: '',
98
+ sources: [],
99
+ command: 'computer',
100
+ mode: 'computer',
101
+ model: null,
102
+ artifactId: ctx.artifactId,
103
+ artifactDir: ctx.artifactDir,
104
+ createdAt,
105
+ status: 'pending',
106
+ computerResultFile: COMPUTER_RESULT_FILE,
107
+ };
108
+
109
+ writeJson(join(ctx.artifactDir, 'meta.json'), meta);
110
+ writeFileSync(join(ctx.artifactDir, 'query.txt'), `${task}\n`, 'utf8');
111
+ writeFileSync(join(ctx.artifactDir, 'answer.md'), taskText, 'utf8');
112
+ writeJson(join(ctx.artifactDir, 'result.json'), result);
113
+ writeJson(join(ctx.artifactDir, 'sources.json'), []);
114
+ writeFileSync(join(ctx.artifactDir, 'task.md'), taskText, 'utf8');
115
+ writeJson(join(ctx.artifactDir, 'result.schema.json'), RESULT_SCHEMA);
116
+ writeJson(resultPath, PENDING_COMPUTER_RESULT);
117
+
118
+ return {
119
+ artifactId: ctx.artifactId,
120
+ artifactDir: ctx.artifactDir,
121
+ taskPath: join(ctx.artifactDir, 'task.md'),
122
+ resultPath,
123
+ };
124
+ }
125
+
126
+ function assertComputerResult(value) {
127
+ const missing = ['summary', 'winner', 'confidence', 'items', 'sources', 'checked_at', 'notes']
128
+ .filter((key) => !(key in value));
129
+ if (missing.length) return { ok: false, reason: `missing fields: ${missing.join(', ')}` };
130
+ if (!['low', 'medium', 'high'].includes(value.confidence)) {
131
+ return { ok: false, reason: 'confidence must be low, medium, or high' };
132
+ }
133
+ if (!Array.isArray(value.items)) return { ok: false, reason: 'items must be an array' };
134
+ if (!Array.isArray(value.sources)) return { ok: false, reason: 'sources must be an array' };
135
+ if (!Array.isArray(value.notes)) return { ok: false, reason: 'notes must be an array' };
136
+ return { ok: true, reason: null };
137
+ }
138
+
139
+ export function inspectComputerRun(runDir) {
140
+ const artifactDir = resolve(runDir);
141
+ const resultPath = join(artifactDir, COMPUTER_RESULT_FILE);
142
+ const metaPath = join(artifactDir, 'meta.json');
143
+ if (!existsSync(metaPath)) {
144
+ return { status: 'missing', artifactDir, resultPath, reason: 'meta.json not found' };
145
+ }
146
+ if (!existsSync(resultPath)) {
147
+ return { status: 'pending', artifactDir, resultPath, reason: `${COMPUTER_RESULT_FILE} not found` };
148
+ }
149
+ try {
150
+ const result = readJsonFile(resultPath);
151
+ if (result._status === 'pending') {
152
+ return { status: 'pending', artifactDir, resultPath, reason: `${COMPUTER_RESULT_FILE} is still pending` };
153
+ }
154
+ const validation = assertComputerResult(result);
155
+ return {
156
+ status: validation.ok ? 'complete' : 'invalid',
157
+ artifactDir,
158
+ resultPath,
159
+ reason: validation.reason,
160
+ result,
161
+ };
162
+ } catch (e) {
163
+ return { status: 'invalid', artifactDir, resultPath, reason: e.message };
164
+ }
165
+ }
166
+
167
+ export function importComputerResult(runDir) {
168
+ const status = inspectComputerRun(runDir);
169
+ if (status.status !== 'complete') {
170
+ throw new Error(`computer result is ${status.status}: ${status.reason}`);
171
+ }
172
+ return status.result;
173
+ }
174
+
175
+ export function copyTextToClipboard(text) {
176
+ if (process.platform === 'darwin') {
177
+ execFileSync('pbcopy', { input: text });
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ export function openComputerUrl() {
184
+ if (process.platform === 'darwin') {
185
+ execFileSync('open', [COMPUTER_URL]);
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+
191
+ export function readTaskFile(runDir) {
192
+ return readFileSync(join(resolve(runDir), 'task.md'), 'utf8');
193
+ }
package/src/labs.js CHANGED
@@ -45,6 +45,9 @@ export class LabsClient {
45
45
  headers: { 'user-agent': HEADERS['user-agent'] },
46
46
  });
47
47
  const pollText = await pollResp.text();
48
+ if (!pollResp.ok) {
49
+ throw new Error(`Labs polling failed (${pollResp.status}): ${pollText.slice(0, 120)}`);
50
+ }
48
51
 
49
52
  const handshake = parseEngineIO(pollText);
50
53
  this.sid = handshake.sid;