askpplx 1.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/LICENSE +21 -0
- package/README.md +46 -0
- package/bin/askpplx +2 -0
- package/dist/ask-perplexity.d.ts +18 -0
- package/dist/ask-perplexity.js +17 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +98 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +49 -0
- package/dist/load-system-prompt.d.ts +1 -0
- package/dist/load-system-prompt.js +22 -0
- package/dist/prompts/default-system.md +71 -0
- package/dist/run-cli.d.ts +28 -0
- package/dist/run-cli.js +63 -0
- package/dist/stream-output.d.ts +11 -0
- package/dist/stream-output.js +73 -0
- package/dist/strip-think-content.d.ts +16 -0
- package/dist/strip-think-content.js +27 -0
- package/package.json +73 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Łukasz Jerciński
|
|
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,46 @@
|
|
|
1
|
+
# askpplx
|
|
2
|
+
|
|
3
|
+
`askpplx` is a minimal Unix-style CLI for querying Perplexity Sonar.
|
|
4
|
+
|
|
5
|
+
> **Perplexity AI** is an AI-powered search engine and answer engine that delivers concise, accurate responses to user queries by combining real-time web searches with advanced language models.
|
|
6
|
+
|
|
7
|
+
- Command name: `askpplx`
|
|
8
|
+
- Output: plain text or JSON to stdout
|
|
9
|
+
- No MCPs, no agents, no plugins, no TUI
|
|
10
|
+
- Just a thin, script-friendly wrapper around the Perplexity API
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Provide your Perplexity API key via environment variable or persistent storage:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Option 1: Environment variable
|
|
18
|
+
export PERPLEXITY_API_KEY="pplx-..."
|
|
19
|
+
|
|
20
|
+
# Option 2: Store persistently
|
|
21
|
+
npx -y askpplx config --set-api-key "pplx-..."
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Examples
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# simple question
|
|
28
|
+
askpplx "Explain Raft vs Paxos in simple terms"
|
|
29
|
+
|
|
30
|
+
# web-enabled search with local context
|
|
31
|
+
askpplx "What are breaking changes in React 19 that affect this code? $(cat src/app.tsx)"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Agent Rule
|
|
35
|
+
|
|
36
|
+
Add this rule to your `CLAUDE.md` or `AGENTS.md` to enable automatic Perplexity lookups, no need to configure MCPs:
|
|
37
|
+
|
|
38
|
+
````markdown
|
|
39
|
+
# Rule: askpplx CLI Usage
|
|
40
|
+
|
|
41
|
+
Use `askpplx` to query Perplexity, an AI search engine combining real-time web search with advanced language models. Run it via `npx -y askpplx`.
|
|
42
|
+
|
|
43
|
+
Use concise prompts for quick facts and focused questions for deeper topics. If results are unexpected, refine your query and ask again.
|
|
44
|
+
|
|
45
|
+
Verification is fast and cheap, so prefer looking up information over making assumptions. Before first use, run `npx -y askpplx --help`.
|
|
46
|
+
````
|
package/bin/askpplx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { streamText } from "ai";
|
|
2
|
+
export type SearchContextSize = "low" | "medium" | "high";
|
|
3
|
+
type AskPerplexityOptions = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
model: string;
|
|
6
|
+
prompt: string;
|
|
7
|
+
system?: string;
|
|
8
|
+
searchContextSize?: SearchContextSize;
|
|
9
|
+
};
|
|
10
|
+
export type StreamPerplexityResult = ReturnType<typeof streamText>;
|
|
11
|
+
export type AskPerplexityResult = {
|
|
12
|
+
text: string;
|
|
13
|
+
sources: Awaited<StreamPerplexityResult["sources"]>;
|
|
14
|
+
usage: Awaited<StreamPerplexityResult["usage"]>;
|
|
15
|
+
providerMetadata: Awaited<StreamPerplexityResult["providerMetadata"]>;
|
|
16
|
+
};
|
|
17
|
+
export declare function streamPerplexity(options: AskPerplexityOptions): StreamPerplexityResult;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createPerplexity } from "@ai-sdk/perplexity";
|
|
2
|
+
import { streamText } from "ai";
|
|
3
|
+
export function streamPerplexity(options) {
|
|
4
|
+
const perplexity = createPerplexity({ apiKey: options.apiKey });
|
|
5
|
+
return streamText({
|
|
6
|
+
model: perplexity(options.model),
|
|
7
|
+
system: options.system,
|
|
8
|
+
prompt: options.prompt,
|
|
9
|
+
providerOptions: {
|
|
10
|
+
perplexity: {
|
|
11
|
+
web_search_options: {
|
|
12
|
+
search_context_size: options.searchContextSize ?? "high",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
4
|
+
import { clearPerplexityApiKey, getConfigPath, getPerplexityApiKey, maskApiKey, setPerplexityApiKey, } from "./config.js";
|
|
5
|
+
import { runCli } from "./run-cli.js";
|
|
6
|
+
const usageExamples = `
|
|
7
|
+
About Perplexity:
|
|
8
|
+
Perplexity AI is an AI-powered search engine and answer engine that delivers
|
|
9
|
+
concise, accurate responses to user queries by combining real-time web
|
|
10
|
+
searches with advanced language models.
|
|
11
|
+
|
|
12
|
+
Models:
|
|
13
|
+
sonar Fast, lightweight for quick searches (128K context)
|
|
14
|
+
sonar-pro Advanced multi-step research queries
|
|
15
|
+
sonar-reasoning-pro Deep reasoning with R1-1776 backend (default)
|
|
16
|
+
|
|
17
|
+
JSON output (--json):
|
|
18
|
+
Returns { text, sources[], usage, providerMetadata } - not structured AI output.
|
|
19
|
+
Use jq to extract fields: --json | jq -r '.text' or '.sources[].url'
|
|
20
|
+
|
|
21
|
+
System prompt:
|
|
22
|
+
Default prompt is optimized for technical/coding questions.
|
|
23
|
+
Use -s <file> or -S <text> to customize. Use -S "" to disable.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
askpplx "What is the capital of France?" -S ""
|
|
27
|
+
askpplx "Explain quantum computing" --model sonar-pro
|
|
28
|
+
askpplx "Latest news on AI" -c medium
|
|
29
|
+
askpplx "$(cat article.txt)" -s ./summarize.md
|
|
30
|
+
askpplx "$(cat article.txt)" -S "Summarize this article"
|
|
31
|
+
askpplx "Node.js LTS version" --json | jq -r '.text'
|
|
32
|
+
askpplx "Show reasoning" --show-thinking`;
|
|
33
|
+
const program = new Command()
|
|
34
|
+
.name("askpplx")
|
|
35
|
+
.description(packageJson.description)
|
|
36
|
+
.version(packageJson.version)
|
|
37
|
+
.argument("[prompt]", "The prompt to send to Perplexity Sonar")
|
|
38
|
+
.option("-m, --model <model>", "Model to use", "sonar-reasoning-pro")
|
|
39
|
+
.option("-s, --system <path>", "Path to custom system prompt file")
|
|
40
|
+
.option("-S, --system-text <text>", "System prompt text (overrides -s)")
|
|
41
|
+
.option("-c, --context <size>", "Search context size: low, medium, high", "high")
|
|
42
|
+
.option("--json", "Output full API response as JSON (text, sources, usage)")
|
|
43
|
+
.option("--show-thinking", "Show model thinking/reasoning blocks")
|
|
44
|
+
.option("--no-stream, --no-streaming", "Disable streaming output")
|
|
45
|
+
.addHelpText("after", usageExamples)
|
|
46
|
+
.action(async (prompt, options) => {
|
|
47
|
+
if (!prompt) {
|
|
48
|
+
program.help();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await runCli(prompt, options);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
56
|
+
console.error(`Error: ${message}`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
program
|
|
61
|
+
.command("config")
|
|
62
|
+
.description("Manage stored configuration")
|
|
63
|
+
.option("--set-api-key <key>", "Store Perplexity API key")
|
|
64
|
+
.option("--show-api-key", "Show stored API key (masked)")
|
|
65
|
+
.option("--clear-api-key", "Remove stored API key")
|
|
66
|
+
.option("--path", "Show config file path")
|
|
67
|
+
.action((options) => {
|
|
68
|
+
try {
|
|
69
|
+
if (options.setApiKey) {
|
|
70
|
+
setPerplexityApiKey(options.setApiKey);
|
|
71
|
+
console.log("API key stored successfully.");
|
|
72
|
+
}
|
|
73
|
+
else if (options.showApiKey) {
|
|
74
|
+
const key = getPerplexityApiKey();
|
|
75
|
+
const masked = maskApiKey(key);
|
|
76
|
+
console.log(masked ? `API key: ${masked}` : "No API key configured.");
|
|
77
|
+
}
|
|
78
|
+
else if (options.clearApiKey) {
|
|
79
|
+
clearPerplexityApiKey();
|
|
80
|
+
console.log("API key cleared.");
|
|
81
|
+
}
|
|
82
|
+
else if (options.path) {
|
|
83
|
+
console.log(getConfigPath());
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const configCmd = program.commands.find((c) => c.name() === "config");
|
|
87
|
+
configCmd?.help();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
const message = error instanceof Error
|
|
92
|
+
? error.message
|
|
93
|
+
: "An unexpected error occurred";
|
|
94
|
+
console.error(`Error: ${message}`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
program.parse();
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get API key: env var takes precedence over stored config.
|
|
3
|
+
* Note: An empty PERPLEXITY_API_KEY="" explicitly disables stored config.
|
|
4
|
+
*/
|
|
5
|
+
export declare function getPerplexityApiKey(): string | undefined;
|
|
6
|
+
/** Store API key in persistent config. */
|
|
7
|
+
export declare function setPerplexityApiKey(apiKey: string): void;
|
|
8
|
+
/** Remove stored API key from config. */
|
|
9
|
+
export declare function clearPerplexityApiKey(): void;
|
|
10
|
+
/** Get path to config file. */
|
|
11
|
+
export declare function getConfigPath(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Mask API key for display: shows first 4 and last 4 characters for keys longer than 16 chars.
|
|
14
|
+
* For keys of length 16 or less, returns "****".
|
|
15
|
+
* Returns undefined if key is undefined or empty.
|
|
16
|
+
*/
|
|
17
|
+
export declare function maskApiKey(key?: string): string | undefined;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
3
|
+
const schema = {
|
|
4
|
+
perplexityApiKey: {
|
|
5
|
+
type: "string",
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
let configInstance;
|
|
9
|
+
function getConfig() {
|
|
10
|
+
if (!configInstance) {
|
|
11
|
+
configInstance = new Conf({
|
|
12
|
+
projectName: packageJson.name,
|
|
13
|
+
projectVersion: packageJson.version,
|
|
14
|
+
schema,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return configInstance;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get API key: env var takes precedence over stored config.
|
|
21
|
+
* Note: An empty PERPLEXITY_API_KEY="" explicitly disables stored config.
|
|
22
|
+
*/
|
|
23
|
+
export function getPerplexityApiKey() {
|
|
24
|
+
return (process.env["PERPLEXITY_API_KEY"] ?? getConfig().get("perplexityApiKey"));
|
|
25
|
+
}
|
|
26
|
+
/** Store API key in persistent config. */
|
|
27
|
+
export function setPerplexityApiKey(apiKey) {
|
|
28
|
+
getConfig().set("perplexityApiKey", apiKey);
|
|
29
|
+
}
|
|
30
|
+
/** Remove stored API key from config. */
|
|
31
|
+
export function clearPerplexityApiKey() {
|
|
32
|
+
getConfig().delete("perplexityApiKey");
|
|
33
|
+
}
|
|
34
|
+
/** Get path to config file. */
|
|
35
|
+
export function getConfigPath() {
|
|
36
|
+
return getConfig().path;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Mask API key for display: shows first 4 and last 4 characters for keys longer than 16 chars.
|
|
40
|
+
* For keys of length 16 or less, returns "****".
|
|
41
|
+
* Returns undefined if key is undefined or empty.
|
|
42
|
+
*/
|
|
43
|
+
export function maskApiKey(key) {
|
|
44
|
+
if (!key)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (key.length <= 16)
|
|
47
|
+
return "****";
|
|
48
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function loadSystemPrompt(customPath?: string): Promise<string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const DEFAULT_SYSTEM_PROMPT_PATH = path.join(__dirname, "prompts", "default-system.md");
|
|
6
|
+
export async function loadSystemPrompt(customPath) {
|
|
7
|
+
const promptPath = customPath ?? DEFAULT_SYSTEM_PROMPT_PATH;
|
|
8
|
+
try {
|
|
9
|
+
const content = await readFile(promptPath, "utf8");
|
|
10
|
+
return content.trim();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const code = error.code;
|
|
14
|
+
if (code === "ENOENT") {
|
|
15
|
+
throw new Error(`System prompt file not found: ${promptPath}`);
|
|
16
|
+
}
|
|
17
|
+
if (code === "EACCES") {
|
|
18
|
+
throw new Error(`Permission denied reading system prompt: ${promptPath}`);
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Failed to read system prompt file: ${promptPath}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Role: Technical Decision & Analysis Agent
|
|
2
|
+
|
|
3
|
+
Research complex questions, compare approaches, and provide actionable recommendations. Optimized for:
|
|
4
|
+
|
|
5
|
+
- Architecture decisions and design patterns
|
|
6
|
+
- Library/framework selection and migration paths
|
|
7
|
+
- Performance optimization strategies
|
|
8
|
+
- Debugging complex issues across systems
|
|
9
|
+
- Best practices and trade-off analysis
|
|
10
|
+
|
|
11
|
+
# Instructions
|
|
12
|
+
|
|
13
|
+
- Start with a brief analysis plan (3-5 conceptual steps) to structure your research
|
|
14
|
+
- Search multiple sources to compare different approaches
|
|
15
|
+
- Analyze real-world usage patterns in popular repositories
|
|
16
|
+
- Weigh trade-offs based on the user's specific constraints
|
|
17
|
+
- Provide a decisive recommendation with clear justification
|
|
18
|
+
|
|
19
|
+
# Output Structure
|
|
20
|
+
|
|
21
|
+
- **Recommendation:** Your advised approach in 1-2 sentences
|
|
22
|
+
- **Why:** Key reasons with evidence from source code or benchmarks
|
|
23
|
+
- **Implementation:** Practical steps with working code example
|
|
24
|
+
- **Trade-offs:** What you gain vs what you sacrifice
|
|
25
|
+
- **Alternatives:** Other viable options if constraints change
|
|
26
|
+
|
|
27
|
+
# Authoritative Sources
|
|
28
|
+
|
|
29
|
+
## Code as Truth - Priority Order
|
|
30
|
+
|
|
31
|
+
1. **GitHub Repository Source Code**: Search actual implementation files first
|
|
32
|
+
- Find exact usage locations of parameters, methods, and configurations
|
|
33
|
+
- Look for test files showing real-world usage patterns
|
|
34
|
+
- Check example directories and demo code
|
|
35
|
+
- Trace through type definitions and interfaces
|
|
36
|
+
- Remember: Code is truth - implementation details override documentation
|
|
37
|
+
|
|
38
|
+
2. **GitHub Repository Documentation**
|
|
39
|
+
- README files, CHANGELOG, release notes
|
|
40
|
+
- API documentation within repositories
|
|
41
|
+
- Configuration examples and setup guides
|
|
42
|
+
|
|
43
|
+
3. **Official Documentation**
|
|
44
|
+
- TypeScript Handbook, Node.js docs, MDN, WHATWG, TC39
|
|
45
|
+
- npm registry entries (versions, files, types, exports)
|
|
46
|
+
- Library/framework official sites
|
|
47
|
+
|
|
48
|
+
4. **Verification Resources**
|
|
49
|
+
- Stack Overflow: only to clarify rare edge cases and always verify against source code
|
|
50
|
+
|
|
51
|
+
## Search Strategy
|
|
52
|
+
|
|
53
|
+
- When looking for how a specific parameter or API works, prioritize finding its actual usage in the source code over reading its description
|
|
54
|
+
- Documentation can be outdated, but code execution paths are always current
|
|
55
|
+
- Look for patterns: if multiple repositories use the same approach, it's likely correct
|
|
56
|
+
|
|
57
|
+
## Curated JavaScript & TypeScript References
|
|
58
|
+
|
|
59
|
+
- [Total TypeScript articles](https://www.totaltypescript.com/articles)
|
|
60
|
+
- [2ality blog](https://2ality.com)
|
|
61
|
+
- [Exploring JS book](https://exploringjs.com/js/book/index.html)
|
|
62
|
+
- [Deep JavaScript book](https://exploringjs.com/deep-js/toc.html)
|
|
63
|
+
- [Node.js Shell Scripting](https://exploringjs.com/nodejs-shell-scripting/toc.html)
|
|
64
|
+
|
|
65
|
+
- Default to using modern ESM and TypeScript for examples when relevant.
|
|
66
|
+
|
|
67
|
+
# Guidance
|
|
68
|
+
|
|
69
|
+
- Use modern ESM and TypeScript for examples by default, but adapt language and examples as appropriate to the question.
|
|
70
|
+
- Be decisive in your conclusions, but transparent about any uncertainty.
|
|
71
|
+
- Present only your final conclusions and justification—avoid extraneous commentary or process narration.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AskPerplexityResult, SearchContextSize } from "./ask-perplexity.js";
|
|
2
|
+
import { streamPerplexity } from "./ask-perplexity.js";
|
|
3
|
+
import { loadSystemPrompt } from "./load-system-prompt.js";
|
|
4
|
+
export type CliOptions = {
|
|
5
|
+
model: string;
|
|
6
|
+
json?: boolean;
|
|
7
|
+
system?: string;
|
|
8
|
+
systemText?: string;
|
|
9
|
+
context?: SearchContextSize;
|
|
10
|
+
showThinking?: boolean;
|
|
11
|
+
stream?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type CliDependencies = {
|
|
14
|
+
streamPerplexity: typeof streamPerplexity;
|
|
15
|
+
loadSystemPrompt: typeof loadSystemPrompt;
|
|
16
|
+
getApiKey: () => string | undefined;
|
|
17
|
+
output: (message: string) => void;
|
|
18
|
+
writeStream: (chunk: string) => void;
|
|
19
|
+
errorOutput: (message: string) => void;
|
|
20
|
+
exit: (code: number) => never;
|
|
21
|
+
};
|
|
22
|
+
type FormatOptions = {
|
|
23
|
+
json: boolean;
|
|
24
|
+
showThinking: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function formatResult(result: AskPerplexityResult, options: FormatOptions): string;
|
|
27
|
+
export declare function runCli(prompt: string, options: CliOptions, deps?: CliDependencies): Promise<void>;
|
|
28
|
+
export {};
|
package/dist/run-cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { streamPerplexity } from "./ask-perplexity.js";
|
|
2
|
+
import { getPerplexityApiKey } from "./config.js";
|
|
3
|
+
import { loadSystemPrompt } from "./load-system-prompt.js";
|
|
4
|
+
import { collectStreamToResult, formatSources, handleStreamingOutput, } from "./stream-output.js";
|
|
5
|
+
import { stripThinkContent } from "./strip-think-content.js";
|
|
6
|
+
const defaultDependencies = {
|
|
7
|
+
streamPerplexity,
|
|
8
|
+
loadSystemPrompt,
|
|
9
|
+
getApiKey: getPerplexityApiKey,
|
|
10
|
+
output: (message) => {
|
|
11
|
+
console.log(message);
|
|
12
|
+
},
|
|
13
|
+
writeStream: (chunk) => {
|
|
14
|
+
process.stdout.write(chunk);
|
|
15
|
+
},
|
|
16
|
+
errorOutput: (message) => {
|
|
17
|
+
console.error(message);
|
|
18
|
+
},
|
|
19
|
+
// eslint-disable-next-line unicorn/no-process-exit
|
|
20
|
+
exit: (code) => process.exit(code),
|
|
21
|
+
};
|
|
22
|
+
export function formatResult(result, options) {
|
|
23
|
+
const text = options.showThinking
|
|
24
|
+
? result.text
|
|
25
|
+
: stripThinkContent(result.text);
|
|
26
|
+
if (options.json) {
|
|
27
|
+
return JSON.stringify({
|
|
28
|
+
text,
|
|
29
|
+
sources: result.sources,
|
|
30
|
+
usage: result.usage,
|
|
31
|
+
providerMetadata: result.providerMetadata,
|
|
32
|
+
}, undefined, 2);
|
|
33
|
+
}
|
|
34
|
+
return text.trim() + formatSources(result.sources);
|
|
35
|
+
}
|
|
36
|
+
export async function runCli(prompt, options, deps = defaultDependencies) {
|
|
37
|
+
const apiKey = deps.getApiKey();
|
|
38
|
+
if (!apiKey) {
|
|
39
|
+
deps.errorOutput("Error: Perplexity API key is required\n" +
|
|
40
|
+
"Set it with: export PERPLEXITY_API_KEY='your-api-key'\n" +
|
|
41
|
+
"Or store it: askpplx config --set-api-key 'your-api-key'");
|
|
42
|
+
deps.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const systemPrompt = options.systemText ?? (await deps.loadSystemPrompt(options.system));
|
|
45
|
+
const stream = deps.streamPerplexity({
|
|
46
|
+
apiKey,
|
|
47
|
+
model: options.model,
|
|
48
|
+
prompt,
|
|
49
|
+
system: systemPrompt,
|
|
50
|
+
searchContextSize: options.context,
|
|
51
|
+
});
|
|
52
|
+
const useStreaming = options.stream !== false && !options.json;
|
|
53
|
+
if (useStreaming) {
|
|
54
|
+
await handleStreamingOutput(stream, { showThinking: options.showThinking ?? false }, deps);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const result = await collectStreamToResult(stream);
|
|
58
|
+
deps.output(formatResult(result, {
|
|
59
|
+
json: options.json ?? false,
|
|
60
|
+
showThinking: options.showThinking ?? false,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AskPerplexityResult, StreamPerplexityResult } from "./ask-perplexity.js";
|
|
2
|
+
type StreamDependencies = {
|
|
3
|
+
writeStream: (chunk: string) => void;
|
|
4
|
+
output: (message: string) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function formatSources(sources: AskPerplexityResult["sources"]): string;
|
|
7
|
+
export declare function handleStreamingOutput(stream: StreamPerplexityResult, options: {
|
|
8
|
+
showThinking: boolean;
|
|
9
|
+
}, deps: StreamDependencies): Promise<void>;
|
|
10
|
+
export declare function collectStreamToResult(stream: StreamPerplexityResult): Promise<AskPerplexityResult>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function formatSources(sources) {
|
|
2
|
+
if (sources.length === 0) {
|
|
3
|
+
return "";
|
|
4
|
+
}
|
|
5
|
+
const lines = [];
|
|
6
|
+
for (const [index, source] of sources.entries()) {
|
|
7
|
+
if (source.sourceType === "url") {
|
|
8
|
+
lines.push(`[${String(index + 1)}] ${source.url}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
if (lines.length === 0) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
return `\n\nSources:\n${lines.join("\n")}`;
|
|
15
|
+
}
|
|
16
|
+
async function streamWithThinking(stream, deps) {
|
|
17
|
+
for await (const chunk of stream.textStream) {
|
|
18
|
+
deps.writeStream(chunk);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function streamWithoutThinking(stream, deps) {
|
|
22
|
+
let buffer = "";
|
|
23
|
+
let insideThink = false;
|
|
24
|
+
let thinkEnded = false;
|
|
25
|
+
for await (const chunk of stream.textStream) {
|
|
26
|
+
if (thinkEnded) {
|
|
27
|
+
deps.writeStream(chunk);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
buffer += chunk;
|
|
31
|
+
if (!insideThink && buffer.includes("<think>")) {
|
|
32
|
+
insideThink = true;
|
|
33
|
+
}
|
|
34
|
+
if (insideThink && buffer.includes("</think>")) {
|
|
35
|
+
thinkEnded = true;
|
|
36
|
+
const thinkEnd = buffer.lastIndexOf("</think>") + "</think>".length;
|
|
37
|
+
const afterThink = buffer.slice(thinkEnd);
|
|
38
|
+
if (afterThink) {
|
|
39
|
+
deps.writeStream(afterThink);
|
|
40
|
+
}
|
|
41
|
+
buffer = "";
|
|
42
|
+
}
|
|
43
|
+
else if (!insideThink) {
|
|
44
|
+
deps.writeStream(buffer);
|
|
45
|
+
buffer = "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function handleStreamingOutput(stream, options, deps) {
|
|
50
|
+
await (options.showThinking
|
|
51
|
+
? streamWithThinking(stream, deps)
|
|
52
|
+
: streamWithoutThinking(stream, deps));
|
|
53
|
+
const sources = await stream.sources;
|
|
54
|
+
const sourcesOutput = formatSources(sources);
|
|
55
|
+
if (sourcesOutput) {
|
|
56
|
+
deps.output(sourcesOutput);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
deps.writeStream("\n");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function collectStreamToResult(stream) {
|
|
63
|
+
let text = "";
|
|
64
|
+
for await (const chunk of stream.textStream) {
|
|
65
|
+
text += chunk;
|
|
66
|
+
}
|
|
67
|
+
const [sources, usage, providerMetadata] = await Promise.all([
|
|
68
|
+
stream.sources,
|
|
69
|
+
stream.usage,
|
|
70
|
+
stream.providerMetadata,
|
|
71
|
+
]);
|
|
72
|
+
return { text, sources, usage, providerMetadata };
|
|
73
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes <think> blocks from the response content.
|
|
3
|
+
* The sonar-reasoning-pro model outputs reasoning tokens in <think> blocks
|
|
4
|
+
* that should be filtered out before returning to the client.
|
|
5
|
+
*
|
|
6
|
+
* Intentional behavior: We remove everything from the first "<think>" to the
|
|
7
|
+
* last "</think>", treating any nested or stray think tags as a single block.
|
|
8
|
+
* For example, the input "A <think>1</think> B <think>2</think> C" becomes
|
|
9
|
+
* "A C" (note that " B " is removed). This is by design: there should only be
|
|
10
|
+
* one reasoning block, and any accidental inner think tags must not interfere
|
|
11
|
+
* with the primary goal of stripping the reasoning block entirely.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} input - The raw content from the API response
|
|
14
|
+
* @returns {string} The content with <think> blocks removed
|
|
15
|
+
*/
|
|
16
|
+
export declare function stripThinkContent(input: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes <think> blocks from the response content.
|
|
3
|
+
* The sonar-reasoning-pro model outputs reasoning tokens in <think> blocks
|
|
4
|
+
* that should be filtered out before returning to the client.
|
|
5
|
+
*
|
|
6
|
+
* Intentional behavior: We remove everything from the first "<think>" to the
|
|
7
|
+
* last "</think>", treating any nested or stray think tags as a single block.
|
|
8
|
+
* For example, the input "A <think>1</think> B <think>2</think> C" becomes
|
|
9
|
+
* "A C" (note that " B " is removed). This is by design: there should only be
|
|
10
|
+
* one reasoning block, and any accidental inner think tags must not interfere
|
|
11
|
+
* with the primary goal of stripping the reasoning block entirely.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} input - The raw content from the API response
|
|
14
|
+
* @returns {string} The content with <think> blocks removed
|
|
15
|
+
*/
|
|
16
|
+
export function stripThinkContent(input) {
|
|
17
|
+
const open = "<think>";
|
|
18
|
+
const close = "</think>";
|
|
19
|
+
const firstOpen = input.indexOf(open);
|
|
20
|
+
if (firstOpen === -1)
|
|
21
|
+
return input;
|
|
22
|
+
const lastClose = input.lastIndexOf(close);
|
|
23
|
+
if (lastClose === -1 || lastClose < firstOpen)
|
|
24
|
+
return input;
|
|
25
|
+
const end = lastClose + close.length;
|
|
26
|
+
return input.slice(0, firstOpen) + input.slice(end);
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "askpplx",
|
|
3
|
+
"author": "Łukasz Jerciński",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Minimal Unix-style CLI for querying Perplexity Sonar API.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Jercik/askpplx.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"askpplx": "bin/askpplx"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"dist/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "pnpm -s run rebuild && node --env-file=.env bin/askpplx",
|
|
23
|
+
"build": "tsc -p tsconfig.app.json && cp -r src/prompts dist/",
|
|
24
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
25
|
+
"rebuild": "pnpm run clean && pnpm run build",
|
|
26
|
+
"prepare": "husky",
|
|
27
|
+
"prepublishOnly": "pnpm run rebuild",
|
|
28
|
+
"typecheck": "tsc -b --noEmit",
|
|
29
|
+
"format": "prettier --write .",
|
|
30
|
+
"format:check": "prettier --check .",
|
|
31
|
+
"lint": "eslint",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage",
|
|
35
|
+
"knip": "knip",
|
|
36
|
+
"fta:check": "fta-check"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [],
|
|
39
|
+
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.14.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@ai-sdk/perplexity": "^2.0.21",
|
|
45
|
+
"ai": "^5.0.106",
|
|
46
|
+
"commander": "^14.0.2",
|
|
47
|
+
"conf": "^15.0.2",
|
|
48
|
+
"zod": "^4.1.13"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@commitlint/cli": "^20.1.0",
|
|
52
|
+
"@commitlint/config-conventional": "^20.0.0",
|
|
53
|
+
"@eslint/compat": "^1.4.1",
|
|
54
|
+
"@eslint/js": "^9.39.1",
|
|
55
|
+
"@total-typescript/ts-reset": "^0.6.1",
|
|
56
|
+
"@types/node": "^24.10.1",
|
|
57
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
58
|
+
"@vitest/eslint-plugin": "^1.4.4",
|
|
59
|
+
"eslint": "^9.39.1",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
61
|
+
"eslint-plugin-unicorn": "^62.0.0",
|
|
62
|
+
"fta-check": "^1.2.0",
|
|
63
|
+
"fta-cli": "^3.0.0",
|
|
64
|
+
"globals": "^16.5.0",
|
|
65
|
+
"husky": "^9.1.7",
|
|
66
|
+
"knip": "^5.70.1",
|
|
67
|
+
"prettier": "3.6.2",
|
|
68
|
+
"semantic-release": "^25.0.2",
|
|
69
|
+
"typescript": "^5.9.3",
|
|
70
|
+
"typescript-eslint": "^8.47.0",
|
|
71
|
+
"vitest": "^3.2.4"
|
|
72
|
+
}
|
|
73
|
+
}
|