@extraktor/cli 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dennis Kortsch
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 CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # Extraktor CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read live web pages as Markdown and search the web from the terminal. Made for AI agents: each command prints Markdown that an agent can use directly. Run `extraktor --help` for every command and option.
4
+
5
+ ```sh
6
+ npm install --global @extraktor/cli
7
+ extraktor login # or: export EXTRAKTOR_API_KEY=ext_...
8
+ extraktor extract https://example.com
9
+ ```
10
+
11
+ Extraktor needs a Pro plan. Make API keys at <https://extraktor.app/developers>.
12
+
13
+ ## Tasks
14
+
15
+ | Task | Command |
16
+ | --- | --- |
17
+ | Read a page, then answer or summarize | `extraktor extract <url>` |
18
+ | Get facts from a page | `extraktor extract <url> --find "<text>"` |
19
+ | Read or compare 2 to 5 pages | `extraktor extract <url> <url> ...` |
20
+ | Quote a page with a link to each quote | `extraktor extract <url> --excerpts --focus "<topic>"` |
21
+ | Save the complete page as Markdown | `extraktor extract <url> --save page.md` |
22
+ | Get contact details | `extraktor extract <url> --contacts` |
23
+ | Take a full-page screenshot | `extraktor extract <url> --screenshot` |
24
+ | Get the design system as DESIGN.md | `extraktor extract <url> --design-file DESIGN.md` |
25
+ | Audit the SEO of a page | `extraktor extract <url> --seo --keyword "<keyword>"` |
26
+ | Find how agents can use a site | `extraktor extract <url> --agent-access` |
27
+ | Find pages when you have no URL | `extraktor search "<query>"` |
28
+ | See what Google shows for a keyword | `extraktor search "<query>" --serp` |
29
+
30
+ A long page comes in parts of about 24,000 characters. Each part gives the command for the next part. The CLI keeps each page for 10 minutes, so `--find`, `--offset` and the same command again use no credit.
31
+
32
+ ## Add the MCP server to your agents
33
+
34
+ ```sh
35
+ npx -y @extraktor/cli mcp add
36
+ ```
37
+
38
+ The command finds Claude Code, Codex, Cursor, VS Code, Gemini CLI and Windsurf on this computer and adds the server `https://extraktor.app/mcp` to each one. It keeps the other servers and settings, and a second run changes nothing. Each agent opens a sign-in page when it first uses Extraktor. The output shows the sign-in step for each agent.
39
+
40
+ Use `--agent <name>` to change only some agents, and `--json` for a JSON result. `extraktor mcp remove` removes the server. For the Claude app, open **Settings > Connectors > Add custom connector** and paste the server URL.
41
+
42
+ ## Exit codes
43
+
44
+ | Code | Meaning |
45
+ | --- | --- |
46
+ | 0 | Success. |
47
+ | 1 | The page, the search or the server failed. Read the message. |
48
+ | 2 | The command or an option is not correct. The message tells the fix. |
49
+ | 3 | Sign-in, a plan or credits are necessary. |
50
+
51
+ Each error tells the next step. With `--json`, errors are JSON on standard output.
52
+
53
+ ## Privacy
54
+
55
+ The CLI calls the Extraktor MCP server at `https://extraktor.app/mcp` (or `$EXTRAKTOR_URL`). It saves the API key in `~/.config/extraktor/credentials.json` and results for 10 minutes in `~/.cache/extraktor/results`. When a coding agent runs the CLI, the CLI sends the agent name (for example `claude-code`) in the `X-Extraktor-Agent` header, and no other data about the agent.
56
+
57
+ ## License
58
+
59
+ [MIT](LICENSE)
package/dist/agent.js ADDED
@@ -0,0 +1,26 @@
1
+ const is = (name, value) => (env) => env[name] === value;
2
+ const has = (name) => (env) => Boolean(env[name]?.trim());
3
+ const AGENTS = [
4
+ ["claude-code", is("CLAUDECODE", "1")],
5
+ ["codex", has("CODEX_THREAD_ID")],
6
+ ["cursor", is("CURSOR_AGENT", "1")],
7
+ ["gemini-cli", is("GEMINI_CLI", "1")],
8
+ ["opencode", is("OPENCODE", "1")],
9
+ ["amp", has("AMP_CURRENT_THREAD_ID")],
10
+ ["qwen-code", has("QWEN_CODE_SESSION_ID")],
11
+ ["auggie", is("AUGMENT_AGENT", "1")],
12
+ ["github-copilot", is("COPILOT_AGENT", "1")],
13
+ ["warp", has("OZ_RUN_ID")],
14
+ ];
15
+ const AGENT_NAME = /^[a-z0-9][a-z0-9._-]{0,39}$/iu;
16
+ /** The agent name, or null when no known agent runs the CLI. */
17
+ export const detectAgent = (env) => {
18
+ for (const [name, detect] of AGENTS) {
19
+ if (detect(env)) {
20
+ return name;
21
+ }
22
+ }
23
+ // AI_AGENT is a shared convention: its value is the agent name.
24
+ const named = env.AI_AGENT?.trim();
25
+ return named && AGENT_NAME.test(named) ? named.toLowerCase() : null;
26
+ };
package/dist/auth.js ADDED
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { CliError, EXIT } from "./errors.js";
5
+ export const API_KEY_FORMAT = /^ext_[a-f0-9]{64}$/u;
6
+ export const credentialsPath = (env = process.env) => {
7
+ const base = env.XDG_CONFIG_HOME ||
8
+ (process.platform === "win32" && env.APPDATA) ||
9
+ path.join(homedir(), ".config");
10
+ return path.join(base, "extraktor", "credentials.json");
11
+ };
12
+ const readCredentials = async (file) => {
13
+ let text;
14
+ try {
15
+ text = await readFile(file, "utf-8");
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ const { credentialsSchema } = await import("./schemas.js");
21
+ try {
22
+ return credentialsSchema.parse(JSON.parse(text));
23
+ }
24
+ catch {
25
+ throw new CliError(`The saved credentials are not correct: ${file}`, EXIT.access, 'Delete the file, then run "extraktor login" again.');
26
+ }
27
+ };
28
+ const writeCredentials = async (file, credentials) => {
29
+ if (Object.keys(credentials).length === 0) {
30
+ await rm(file, { force: true });
31
+ return;
32
+ }
33
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
34
+ await writeFile(file, `${JSON.stringify(credentials, null, 2)}\n`, {
35
+ mode: 0o600,
36
+ });
37
+ };
38
+ /** The API key for this server: EXTRAKTOR_API_KEY first, then the saved key. */
39
+ export const resolveApiKey = async (baseUrl, env = process.env) => {
40
+ if (env.EXTRAKTOR_API_KEY) {
41
+ return env.EXTRAKTOR_API_KEY.trim();
42
+ }
43
+ const credentials = await readCredentials(credentialsPath(env));
44
+ return credentials[baseUrl]?.apiKey ?? null;
45
+ };
46
+ export const saveApiKey = async (baseUrl, apiKey, env = process.env) => {
47
+ if (!API_KEY_FORMAT.test(apiKey)) {
48
+ throw new CliError("The API key is not correct. An Extraktor API key starts with ext_.", EXIT.usage);
49
+ }
50
+ const file = credentialsPath(env);
51
+ const credentials = await readCredentials(file);
52
+ await writeCredentials(file, {
53
+ ...credentials,
54
+ [baseUrl]: { apiKey, savedAt: new Date().toISOString() },
55
+ });
56
+ return file;
57
+ };
58
+ /** Deletes the saved key for this server. Returns false when there was none. */
59
+ export const deleteSavedApiKey = async (baseUrl, env = process.env) => {
60
+ const file = credentialsPath(env);
61
+ const credentials = await readCredentials(file);
62
+ if (!credentials[baseUrl]) {
63
+ return false;
64
+ }
65
+ await writeCredentials(file, Object.fromEntries(Object.entries(credentials).filter(([server]) => server !== baseUrl)));
66
+ return true;
67
+ };
package/dist/cache.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A short-lived local cache of tool results. Agents read long output in
3
+ * slices: they run the same command again with head, sed or grep. Without the
4
+ * cache, each slice is a new extraction that costs a credit and a page load.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import path from "node:path";
10
+ import { callTool } from "./mcp.js";
11
+ export const CACHE_TTL_MS = 10 * 60_000;
12
+ export const cacheDir = (env) => path.join(env.XDG_CACHE_HOME ||
13
+ (process.platform === "win32" && env.LOCALAPPDATA) ||
14
+ path.join(homedir(), ".cache"), "extraktor", "results");
15
+ const cacheFile = (dir, { baseUrl, name, args }) => path.join(dir, `${createHash("sha256")
16
+ .update(JSON.stringify([baseUrl, name, args]))
17
+ .digest("hex")}.json`);
18
+ const isFresh = async (file, now) => {
19
+ try {
20
+ const { mtimeMs } = await stat(file);
21
+ return now - mtimeMs < CACHE_TTL_MS;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ };
27
+ const readCached = async (file) => {
28
+ if (!(await isFresh(file, Date.now()))) {
29
+ return null;
30
+ }
31
+ try {
32
+ const { toolResultSchema } = await import("./schemas.js");
33
+ return toolResultSchema.parse(JSON.parse(await readFile(file, "utf-8")));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ };
39
+ /** Deletes results older than the cache time, so the directory stays small. */
40
+ const pruneExpired = async (dir, now) => {
41
+ const files = await readdir(dir);
42
+ await Promise.all(files.map(async (name) => {
43
+ const file = path.join(dir, name);
44
+ if (!(await isFresh(file, now))) {
45
+ await rm(file, { force: true });
46
+ }
47
+ }));
48
+ };
49
+ const writeCached = async (dir, file, result) => {
50
+ await mkdir(dir, { recursive: true, mode: 0o700 });
51
+ const temporary = `${file}.${process.pid}.tmp`;
52
+ await writeFile(temporary, JSON.stringify(result), { mode: 0o600 });
53
+ await rename(temporary, file);
54
+ await pruneExpired(dir, Date.now());
55
+ };
56
+ const OUTPUT_KEYS = new Set([
57
+ "excerpts",
58
+ "summary",
59
+ "screenshot",
60
+ "contacts",
61
+ "messaging",
62
+ "seo",
63
+ "design",
64
+ "agentAccess",
65
+ ]);
66
+ /**
67
+ * The page text part of an extract result with outputs: the result of the
68
+ * same page without outputs. A later read, --find or --offset of the page
69
+ * then uses it and makes no new request.
70
+ */
71
+ const plainPage = (options, result) => {
72
+ if (options.name !== "extract" ||
73
+ !Object.entries(options.args).some(([key, value]) => value !== undefined && OUTPUT_KEYS.has(key))) {
74
+ return null;
75
+ }
76
+ const page = Object.fromEntries(Object.entries(result.structuredContent ?? {}).filter(([key]) => !OUTPUT_KEYS.has(key)));
77
+ return [
78
+ { ...options, args: { url: options.args.url } },
79
+ {
80
+ ...result,
81
+ // Keep the page warnings. Drop the image of a screenshot.
82
+ content: [
83
+ { type: "text", text: JSON.stringify(page) },
84
+ ...result.content.slice(1).filter((item) => item.type === "text"),
85
+ ],
86
+ structuredContent: page,
87
+ },
88
+ ];
89
+ };
90
+ /**
91
+ * Calls the tool, or returns the saved result of the same call from the last
92
+ * 10 minutes. Only successful results are saved.
93
+ */
94
+ export const callToolCached = async (options, env, useCache) => {
95
+ const dir = cacheDir(env);
96
+ const file = cacheFile(dir, options);
97
+ const cached = useCache ? await readCached(file) : null;
98
+ if (cached) {
99
+ return cached;
100
+ }
101
+ const result = await callTool(options);
102
+ try {
103
+ await writeCached(dir, file, result);
104
+ const plain = plainPage(options, result);
105
+ if (plain) {
106
+ await writeCached(dir, cacheFile(dir, plain[0]), plain[1]);
107
+ }
108
+ }
109
+ catch {
110
+ // The cache only saves time. A full disk must not fail the command.
111
+ }
112
+ return result;
113
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { text } from "node:stream/consumers";
3
+ import { run } from "./run.js";
4
+ const exitCode = await run(process.argv.slice(2), {
5
+ stdout: (output) => process.stdout.write(output),
6
+ stderr: (output) => process.stderr.write(output),
7
+ env: process.env,
8
+ readStdin: () => text(process.stdin),
9
+ cwd: process.cwd(),
10
+ });
11
+ process.exitCode = exitCode;
package/dist/errors.js ADDED
@@ -0,0 +1,44 @@
1
+ /** Exit codes and the error type that every command reports. */
2
+ export const EXIT = {
3
+ ok: 0,
4
+ /** The page, the search or the server did not complete the request. */
5
+ failure: 1,
6
+ /** The command or its options are not correct. */
7
+ usage: 2,
8
+ /** Sign-in, plan or credits are necessary. */
9
+ access: 3,
10
+ };
11
+ const DEFAULT_CODES = new Map([
12
+ [EXIT.failure, "FAILED"],
13
+ [EXIT.usage, "INVALID_USAGE"],
14
+ [EXIT.access, "ACCESS_REQUIRED"],
15
+ ]);
16
+ /** A failure that the CLI reports to the user and stops on. */
17
+ export class CliError extends Error {
18
+ name = "CliError";
19
+ exitCode;
20
+ guidance;
21
+ /** A stable code for --json output, for example PAGE_UNAVAILABLE. */
22
+ code;
23
+ constructor(message, exitCode, guidance, code) {
24
+ super(message);
25
+ this.exitCode = exitCode;
26
+ this.guidance = guidance;
27
+ this.code = code ?? DEFAULT_CODES.get(exitCode) ?? "FAILED";
28
+ }
29
+ /** The error as --json prints it. */
30
+ toJSON() {
31
+ const suffix = ` (${this.code})`;
32
+ const error = {
33
+ code: this.code,
34
+ message: this.message.endsWith(suffix)
35
+ ? this.message.slice(0, -suffix.length)
36
+ : this.message,
37
+ exitCode: this.exitCode,
38
+ };
39
+ if (this.guidance) {
40
+ error.guidance = this.guidance;
41
+ }
42
+ return { error };
43
+ }
44
+ }