From eb5280a117a2bc6eaaed030f59af4d020069f754 Mon Sep 17 00:00:00 2001 From: Spencer Gilbert Date: Fri, 14 Aug 2026 00:23:49 -0400 Subject: [PATCH] [skills/kagi] Add Kagi search and extract skill --- NOTICE | 4 ++ README.md | 11 +++- skills/kagi/SKILL.md | 52 +++++++++++++++++ skills/kagi/extract.mjs | 124 ++++++++++++++++++++++++++++++++++++++++ skills/kagi/search.mjs | 114 ++++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 skills/kagi/SKILL.md create mode 100755 skills/kagi/extract.mjs create mode 100755 skills/kagi/search.mjs diff --git a/NOTICE b/NOTICE index d4a90ba..f8f6481 100644 --- a/NOTICE +++ b/NOTICE @@ -17,3 +17,7 @@ extensions/handoff.ts themes/catppuccin-frappe.json Taken from pi-catppuccin (MIT, Copyright (c) 2026 Madeleine Ostoja): https://github.com/madeleineostoja/pi-catppuccin/blob/main/themes/catppuccin-frappe.json + +skills/kagi/{search,extract}.mjs + CLI structure inspired by badlogic's pi-skills brave-search (MIT, Copyright (c) 2024 Mario Zechner): + https://github.com/badlogic/pi-skills/blob/main/brave-search/search.js diff --git a/README.md b/README.md index 6134a82..27a1344 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,16 @@ Taken from pi's handoff example (examples/extensions/handoff.ts). ## Skills -_Add skills in `skills/` — see [Pi skills docs](https://pi.dev/docs/skills)._ +### kagi + +Web search and page-content extraction via the [Kagi API](https://kagi.com/api/docs) (v1). Pure Node.js, no browser or npm dependencies — just `KAGI_API_KEY`. + +```bash +skills/kagi/search.mjs "query" -n 5 --time week +skills/kagi/extract.mjs https://example.com/article +``` + +See [skills/kagi/SKILL.md](skills/kagi/SKILL.md) for full usage. ## Prompts diff --git a/skills/kagi/SKILL.md b/skills/kagi/SKILL.md new file mode 100644 index 0000000..3aeaf25 --- /dev/null +++ b/skills/kagi/SKILL.md @@ -0,0 +1,52 @@ +--- +name: kagi +description: Web search and content extraction via the Kagi Universal Search API. Use for searching documentation, facts, or any web content, and for extracting readable markdown from URLs. Requires a Kagi account and the KAGI_API_KEY environment variable. Pure Node.js, no browser or npm dependencies. +--- + +# Kagi + +Web search and page-content extraction using the Kagi API (v1). Lightweight — no browser, no npm dependencies, just Node.js 18+ (uses global `fetch`). + +## Setup + +1. Create an account at https://kagi.com — any paid plan includes API access. +2. Create an API token at https://kagi.com/settings?p=api +3. Add it to your shell profile (`~/.profile`, or `~/.zprofile` for zsh): + ```bash + export KAGI_API_KEY="your-token-here" + ``` +4. No other setup required — the scripts run with plain `node`. + +## Search + +```bash +{baseDir}/search.mjs "query" # Basic search (10 results) +{baseDir}/search.mjs "query" -n 5 # Fewer results +{baseDir}/search.mjs "query" --time week # Results from the last week +``` + +### Options + +- `-n ` — Number of results (default: 10, max: 1024) +- `--time ` — Time filter: `day`, `week`, or `month` + +## Extract Page Content + +```bash +{baseDir}/extract.mjs https://example.com/article # Markdown to stdout +{baseDir}/extract.mjs https://a.com https://b.com # Up to 10 URLs at once +{baseDir}/extract.mjs https://a.com --out article.md # Write to file +``` + +Extracts readable content as markdown from up to 10 HTTPS URLs in a single request. + +## Output + +Both scripts request `format: "markdown"` from the API and pass the response through to stdout unchanged (markdown from search results; page markdown from extraction). + +## When to Use + +- Searching for documentation or API references +- Looking up facts or current information +- Fetching readable content from specific URLs as markdown +- Any task requiring web search without interactive browsing diff --git a/skills/kagi/extract.mjs b/skills/kagi/extract.mjs new file mode 100755 index 0000000..8cacce9 --- /dev/null +++ b/skills/kagi/extract.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Kagi Extract API — POST https://kagi.com/api/v1/extract + * + * Extracts readable markdown content from up to 10 HTTPS URLs in a single call. + * + * Usage: + * node extract.mjs [ ...] [--out ] + * + * Options: + * --out Write extracted markdown to a file + * + * The API serializes the response as markdown (format: "markdown"); we pass + * it through to stdout as-is. JSON is only parsed for error responses. + * + * Environment: + * KAGI_API_KEY Required. Kagi API token. + * Get one at https://kagi.com/settings?p=api + * + * Examples: + * node extract.mjs https://example.com/article + * node extract.mjs https://a.com https://b.com + * node extract.mjs https://a.com --out article.md + */ + +const args = process.argv.slice(2); + +const API_URL = process.env.KAGI_API_URL ?? "https://kagi.com/api/v1/extract"; +const MAX_URLS = 10; + +function usage() { + console.log(`Usage: extract.mjs [ ...] [--out ] + +Options: + --out Write extracted markdown to a file + +Environment: + KAGI_API_KEY Required. Kagi API token. + Get one at https://kagi.com/settings?p=api`); +} + +function fail(msg, code = 1) { + console.error(msg); + process.exit(code); +} + +// --- argument parsing --- + +const urls = []; +let outFile = null; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--out") { + const value = args[i + 1]; + if (value === undefined || value.startsWith("--")) fail("Error: expected a value after --out"); + outFile = value; + i++; + } else if (a.startsWith("--")) { + fail(`Error: unknown flag ${a}\n\nRun without arguments to see usage.`); + } else { + urls.push(a); + } +} + +if (urls.length === 0) { + usage(); + process.exit(1); +} + +if (urls.length > MAX_URLS) { + fail(`Error: at most ${MAX_URLS} URLs per request, got ${urls.length}.`); +} + +for (const url of urls) { + if (!/^https:\/\//i.test(url)) { + fail(`Error: URL must be a valid HTTPS URL: ${url}`); + } +} + +const apiKey = process.env.KAGI_API_KEY; +if (!apiKey) { + fail("Error: KAGI_API_KEY environment variable is required.\nGet your API token at: https://kagi.com/settings?p=api"); +} + +// --- call the API --- + +const response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + pages: urls.map((url) => ({ url })), + format: "markdown", + }), +}); + +const text = await response.text(); + +if (!response.ok) { + let detail = text; + try { + const err = JSON.parse(text); + if (Array.isArray(err.error)) { + detail = err.error + .map((e) => ` ${e.code}: ${e.message ?? "(no message)"}${e.location ? ` @ ${e.location}` : ""}`) + .join("\n"); + } + } catch { + // keep raw text + } + fail(`Error: HTTP ${response.status} ${response.statusText}\n${detail}`); +} + +if (outFile) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(outFile, text, "utf-8"); + process.stdout.write(text); + console.log(`\n[Saved to ${outFile}]`); +} else { + process.stdout.write(text); +} diff --git a/skills/kagi/search.mjs b/skills/kagi/search.mjs new file mode 100755 index 0000000..ebd91ce --- /dev/null +++ b/skills/kagi/search.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Kagi Universal Search API — POST https://kagi.com/api/v1/search + * + * Usage: + * node search.mjs [-n ] [--time ] + * + * Options: + * -n Limit results (default: 10, max: 1024) + * --time Time filter: day | week | month + * + * The API serializes the response as markdown (format: "markdown"); we pass + * it through to stdout as-is. JSON is only parsed for error responses. + * + * Environment: + * KAGI_API_KEY Required. Kagi API token. + * Get one at https://kagi.com/settings?p=api + * + * Examples: + * node search.mjs "javascript async await" + * node search.mjs "rust news" -n 5 --time week + */ + +const args = process.argv.slice(2); + +const API_URL = process.env.KAGI_API_URL ?? "https://kagi.com/api/v1/search"; + +function usage() { + console.log(`Usage: search.mjs [-n ] [--time ] + +Options: + -n Limit results (default: 10, max: 1024) + --time Time filter: day | week | month + +Environment: + KAGI_API_KEY Required. Kagi API token. + Get one at https://kagi.com/settings?p=api`); +} + +function fail(msg, code = 1) { + console.error(msg); + process.exit(code); +} + +// --- argument parsing --- + +const queryParts = []; +let limit = null; +let timeRelative = null; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "-n") { + const value = args[i + 1]; + if (value === undefined || value.startsWith("-")) fail("Error: expected a number after -n"); + limit = parseInt(value, 10); + i++; + } else if (a === "--time") { + const value = args[i + 1]; + if (value === undefined || value.startsWith("-")) fail("Error: expected day, week, or month after --time"); + if (!["day", "week", "month"].includes(value)) fail(`Error: --time must be day, week, or month (got "${value}")`); + timeRelative = value; + i++; + } else if (a.startsWith("-")) { + fail(`Error: unknown flag ${a}\n\nRun without arguments to see usage.`); + } else { + queryParts.push(a); + } +} + +const query = queryParts.join(" "); +if (!query) { + usage(); + process.exit(1); +} + +const apiKey = process.env.KAGI_API_KEY; +if (!apiKey) { + fail("Error: KAGI_API_KEY environment variable is required.\nGet your API token at: https://kagi.com/settings?p=api"); +} + +// --- call the API --- + +const body = { query, format: "markdown" }; +if (limit !== null) body.limit = limit; +if (timeRelative) body.lens = { time_relative: timeRelative }; + +const response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), +}); + +const text = await response.text(); + +if (!response.ok) { + let detail = text; + try { + const err = JSON.parse(text); + if (Array.isArray(err.error)) { + detail = err.error + .map((e) => ` ${e.code}: ${e.message ?? "(no message)"}${e.location ? ` @ ${e.location}` : ""}`) + .join("\n"); + } + } catch { + // keep raw text + } + fail(`Error: HTTP ${response.status} ${response.statusText}\n${detail}`); +} + +process.stdout.write(text); -- 2.51.2