Routes the WebSearch tool to Perplexity (pplx CLI) via a function hook; falls back to native search on failure

Personal dotfiles using GNU Stow and XDG Base Directory Specification.
Clone the dotfiles repository to your home directory:
git clone git@github.com:riethmayer/dotfiles $HOME/dotfiles
cd ~/dotfiles
mise run install
The following tools are managed via GNU Stow:
| Package | Description | XDG Compliant |
|---|---|---|
| agents | Shared AI agent instructions and personal-skill symlinks | ✅ |
| atuin | Shell history sync | ✅ |
| bootstrap | System setup scripts | N/A |
| brew | Homebrew bundle | N/A |
| claude | Claude AI assistant config | ✅ |
| ghostty | Ghostty terminal config | ✅ |
| herdr | Herdr agent workspace manager (sidebar rows, keys, theme) | ✅ |
| git | Git configuration | ✅ |
| kitty | Kitty terminal config | ✅ |
| mise | Mise (rtx) config | ✅ |
| opencode | OpenCode AI config | ✅ |
| pnpm | PNPM configuration | ✅ |
| ruby | Ruby/Gem configuration | ✅ |
| scripts | Custom shell scripts | N/A |
| ssh | SSH configuration | ❌ (hardcoded) |
| starship | Starship prompt | ✅ |
| tmux | Tmux configuration | ✅ |
| zsh | Zsh configuration | ✅ |
~/.config/nvim~/.gnupg path)mise run help # Show available tasks
mise run install # Install all stow packages
mise run bootstrap # Full system setup
mise run quick-bootstrap # Minimal dev setup (mise, atuin, zsh-xdg, LSP)
mise run lsp # Install LSP servers (bash, lua, yaml, toml, json, md)
mise run skills # Install/update personal skills for Claude and Codex
mise run update # Sync and update dotfiles
Personal agent skills live in ~/skills and reach each agent through its own marketplace install (Claude: jan-* plugins, Codex: jan-skills marketplace) — content is duplicated per agent, no shared symlink. mise run install prepares the checkout before stow; mise run skills registers the Git-backed Codex jan-skills marketplace and a LaunchAgent that fast-forwards the checkout hourly when it is clean.
stow/pi/.pi/agent/extensions/prompt-stash.ts adds a global Ctrl+S shortcut. Run /reload in an existing Pi session after installing the file.
Works for normal prompts, steering (Enter while working), and follow-ups (Alt+Enter). A footer hint stays visible while a draft is stashed. There is one stash slot; pressing Ctrl+S with another draft warns rather than replacing it. Restoration never overwrites non-empty input.
Drafts stay in process memory, including across /reload and session switches; quitting or crashing Pi loses them. No unsent draft is written to session history or disk. This is text-only: pasted text is preserved in full, but cursor position, collapsed-paste presentation, and separately managed image attachments are not captured. Built-in/extension commands bypass the input hook: use Ctrl+S to restore manually after those. Input queued during compaction restores when Pi processes it.
Verify with mise run pi:test (Node 22.6+; no dependencies or model calls).
cron/crontab.txt is the source of truth for non-factory cron entries — install with crontab ~/dotfiles/cron/crontab.txt. Factory-owned entries self-install afterwards from their owning repo (marker-managed blocks, idempotent):
node ~/skills/scripts/reap-agents.mjs --install-cron # herdr idle-agent reaper (15min)
dotfiles/
├── stow/ # GNU Stow packages
│ └── {tool}/ # Per-tool configurations
├── .planning/ # Sprint-based improvements
├── .rules/ # Shared rules for AI assistants
└── .mise.toml # Task runner configuration
Thanks to @andrzejsliwa and @sevos for the inspiration.
hooks/websearch.ts 84 lines1// WebSearch → Perplexity. Answers each WebSearch call with `pplx search web`
2// in WebSearch's own result shape. Any failure logs a warning and falls through
3// to next(e), so the native search runs instead. Key: PERPLEXITY_API_KEY, else
4// `op read` through the machine's 1Password service account (dotfiles ADR-010),
5// once per session. PPLX_WEBSEARCH=0 turns it off.
6import type { Register } from "claude-code";
7
8type Hit = { url: string; title: string; snippet?: string; summary?: string };
9
10const LIMIT = 8;
11const SNIPPET_CHARS = 700;
12const KEY_REF = "op://machines/Perplexity API Key/credential";
13const TOKEN_KEYCHAIN_SERVICE = "op-service-account-token";
14
15let apiKey: string | undefined;
16
17export const register: Register = (on) => {
18 on("tool.call", { tool: "WebSearch" }, async ($, e, next) => {
19 if ((await $.env.get("PPLX_WEBSEARCH")) === "0") return next(e);
20 const fallback = (reason: string) => {
21 $.ui.log(`⚠ websearch-perplexity: ${reason}; using Claude's WebSearch`);
22 return next(e);
23 };
24
25 const home = (await $.env.get("HOME")) ?? "";
26 apiKey ??= (await $.env.get("PERPLEXITY_API_KEY")) || undefined;
27 if (!apiKey) {
28 let token = await $.env.get("OP_SERVICE_ACCOUNT_TOKEN");
29 if (!token) {
30 const kc = await $.process
31 .run(["security", "find-generic-password", "-s", TOKEN_KEYCHAIN_SERVICE, "-w"])
32 .catch(() => undefined);
33 if (kc?.exitCode === 0) token = kc.stdout.trim();
34 }
35 if (!token) return fallback(`no 1Password service account token (Keychain ${TOKEN_KEYCHAIN_SERVICE})`);
36 const read = await $.process
37 .run(["op", "read", KEY_REF], { env: { OP_SERVICE_ACCOUNT_TOKEN: token }, timeoutMs: 15_000 })
38 .catch(() => undefined);
39 if (read?.exitCode !== 0) return fallback(`no Perplexity key: op read ${KEY_REF} failed ${read?.stderr.trim().slice(0, 160) ?? ""}`);
40 apiKey = read.stdout.trim();
41 }
42 if (!apiKey) return fallback("no Perplexity key: op read returned nothing");
43
44 const argv = [`${home}/.local/bin/pplx`, "search", "web", e.query, "-n", String(LIMIT), "--max-tokens-per-page", "400"];
45 if (e.allowed_domains?.length) argv.push("--domains", e.allowed_domains.join(","));
46 if (e.blocked_domains?.length) argv.push("--excluded-domains", e.blocked_domains.join(","));
47
48 const started = Date.now();
49 const run = await $.process
50 .run(argv, { env: { PERPLEXITY_API_KEY: apiKey }, timeoutMs: 45_000 })
51 .catch(() => undefined);
52 if (!run) return fallback("pplx did not run or timed out");
53 if (run.exitCode !== 0) return fallback(`pplx failed: ${run.stderr.trim().slice(0, 200)}`);
54
55 let hits: Hit[];
56 try {
57 hits = (JSON.parse(run.stdout) as { hits?: Hit[] }).hits ?? [];
58 } catch {
59 return fallback("pplx returned unreadable JSON");
60 }
61 if (hits.length === 0) return fallback("Perplexity returned no hits");
62
63 const digest = hits
64 .map((h) => {
65 const text = (h.snippet ?? h.summary ?? "").replace(/\s+/g, " ").slice(0, SNIPPET_CHARS);
66 return `- [${h.title}](${h.url})\n ${text}`;
67 })
68 .join("\n");
69
70 return {
71 result: {
72 query: e.query,
73 results: [
74 { tool_use_id: "perplexity", content: hits.map((h) => ({ title: h.title, url: h.url })) },
75 `Excerpts:\n${digest}`,
76 ],
77 durationSeconds: (Date.now() - started) / 1000,
78 searchCount: 1,
79 },
80 context: ["These WebSearch results came from Perplexity. Cite sources as markdown links."],
81 };
82 });
83};
84