@robin7331/papyrus-cli 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -21,80 +21,69 @@ npm i -g @robin7331/papyrus-cli
21
21
  papyrus --help
22
22
  ```
23
23
 
24
- ## API Key Setup
25
-
26
- Papyrus requires `OPENAI_API_KEY`.
27
-
28
- macOS/Linux (persistent):
24
+ ## Usage
29
25
 
30
26
  ```bash
31
- echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
32
- source ~/.zshrc
33
- ```
34
-
35
- PowerShell (persistent):
36
-
37
- ```powershell
38
- setx OPENAI_API_KEY "your_api_key_here"
39
- # restart PowerShell after running setx
40
- ```
27
+ # Show installed CLI version
28
+ papyrus --version
41
29
 
42
- One-off execution:
30
+ # Single file (auto mode; if no API key is found, Papyrus prompts you to paste one)
31
+ papyrus ./path/to/input.pdf
43
32
 
44
- ```bash
45
- OPENAI_API_KEY="your_api_key_here" papyrus ./path/to/input.pdf
46
- ```
33
+ # Single file with explicit format/output/model
34
+ papyrus ./path/to/input.pdf --format md --output ./out/result.md --model gpt-4o-mini
47
35
 
48
- Security note: Papyrus intentionally does not provide an `--api-key` flag to avoid leaking keys via shell history or process lists.
36
+ # Auto mode with extra instructions
37
+ papyrus ./path/to/input.pdf --instructions "Prioritize table accuracy." --format txt
49
38
 
50
- ## Usage
39
+ # Prompt mode (inline prompt)
40
+ papyrus ./path/to/input.pdf --mode prompt --prompt "Extract all invoice line items as bullet points." --format md
51
41
 
52
- Single file (auto mode):
42
+ # Prompt mode (prompt file)
43
+ papyrus ./path/to/input.pdf --mode prompt --prompt-file ./my-prompt.txt --format txt
53
44
 
54
- ```bash
55
- papyrus ./path/to/input.pdf
56
- ```
45
+ # Folder mode (recursive scan, asks for confirmation)
46
+ papyrus ./path/to/folder
57
47
 
58
- Single file with explicit format/output/model:
48
+ # Folder mode with explicit concurrency and output directory
49
+ papyrus ./path/to/folder --concurrency 4 --output ./out
59
50
 
60
- ```bash
61
- papyrus ./path/to/input.pdf --format md --output ./out/result.md --model gpt-4o-mini
51
+ # Folder mode without confirmation prompt
52
+ papyrus ./path/to/folder --yes
62
53
  ```
63
54
 
64
- Auto mode with extra instructions:
65
-
66
- ```bash
67
- papyrus ./path/to/input.pdf --instructions "Prioritize table accuracy." --format txt
68
- ```
55
+ ## API Key Setup
69
56
 
70
- Prompt mode (inline prompt):
57
+ Papyrus requires `OPENAI_API_KEY`.
71
58
 
72
- ```bash
73
- papyrus ./path/to/input.pdf --mode prompt --prompt "Extract all invoice line items as bullet points." --format md
74
- ```
59
+ If no API key is found in your environment or local config, Papyrus will prompt you interactively to paste one, and can save it for future runs.
75
60
 
76
- Prompt mode (prompt file):
61
+ macOS/Linux (persistent):
77
62
 
78
63
  ```bash
79
- papyrus ./path/to/input.pdf --mode prompt --prompt-file ./my-prompt.txt --format txt
64
+ echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
65
+ source ~/.zshrc
80
66
  ```
81
67
 
82
- Folder mode (recursive scan, asks for confirmation):
68
+ PowerShell (persistent):
83
69
 
84
- ```bash
85
- papyrus ./path/to/folder
70
+ ```powershell
71
+ setx OPENAI_API_KEY "your_api_key_here"
72
+ # restart PowerShell after running setx
86
73
  ```
87
74
 
88
- Folder mode with explicit concurrency and output directory:
75
+ One-off execution:
89
76
 
90
77
  ```bash
91
- papyrus ./path/to/folder --concurrency 4 --output ./out
78
+ OPENAI_API_KEY="your_api_key_here" papyrus ./path/to/input.pdf
92
79
  ```
93
80
 
94
- Folder mode without confirmation prompt:
81
+ Papyrus config commands (optional, local persistent storage in `~/.config/papyrus/config.json`):
95
82
 
96
83
  ```bash
97
- papyrus ./path/to/folder --yes
84
+ papyrus config init
85
+ papyrus config show
86
+ papyrus config clear
98
87
  ```
99
88
 
100
89
  ## Arguments Reference
@@ -109,6 +98,16 @@ Example:
109
98
  papyrus ./docs/invoice.pdf
110
99
  ```
111
100
 
101
+ ### `-v, --version`
102
+
103
+ Print the installed Papyrus CLI version.
104
+
105
+ Example:
106
+
107
+ ```bash
108
+ papyrus --version
109
+ ```
110
+
112
111
  ### `--format <format>`
113
112
 
114
113
  Output format override:
package/dist/cli.js CHANGED
@@ -1,13 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import "dotenv/config";
3
+ import { readFileSync } from "node:fs";
3
4
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
5
  import { dirname, join, relative, resolve } from "node:path";
5
6
  import { Command } from "commander";
7
+ import { clearStoredApiKey, getConfigFilePath, getStoredApiKey, maskApiKey, setStoredApiKey } from "./config.js";
6
8
  import { convertPdf } from "./openaiPdfToMarkdown.js";
7
9
  import { defaultOutputPath, formatDurationMs, isPdfPath, looksLikeFileOutput, parseConcurrency, parseFormat, parseMode, resolveFolderOutputPath, truncate, validateOptionCombination } from "./cliHelpers.js";
8
10
  const program = new Command();
11
+ const configFilePath = getConfigFilePath();
12
+ const OPENAI_API_KEYS_URL = "https://platform.openai.com/settings/organization/api-keys";
13
+ const cliVersion = getCliVersion();
9
14
  program
10
15
  .name("papyrus")
16
+ .version(cliVersion, "-v, --version", "display version number")
11
17
  .description("Convert PDF files to Markdown or text using the OpenAI Agents SDK")
12
18
  .argument("<input>", "Path to input PDF file or folder")
13
19
  .option("-o, --output <path>", "Path to output file (single input) or output directory (folder input)")
@@ -47,11 +53,70 @@ program
47
53
  process.exitCode = 1;
48
54
  }
49
55
  });
50
- program.parseAsync(process.argv);
56
+ const configCommand = program
57
+ .command("config")
58
+ .description("Manage persistent configuration");
59
+ configCommand
60
+ .command("init")
61
+ .description("Interactively save an OpenAI API key to the local Papyrus config file")
62
+ .option("-f, --force", "Overwrite an existing saved API key without confirmation")
63
+ .action(async (options) => {
64
+ try {
65
+ await handleConfigInit(options);
66
+ }
67
+ catch (error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ console.error(`Config init failed: ${message}`);
70
+ process.exitCode = 1;
71
+ }
72
+ });
73
+ configCommand
74
+ .command("show")
75
+ .description("Show the saved API key in masked form")
76
+ .action(async () => {
77
+ try {
78
+ const storedApiKey = await getStoredApiKey();
79
+ console.log(`Config file: ${configFilePath}`);
80
+ if (!storedApiKey) {
81
+ console.log("OpenAI API key: not set");
82
+ return;
83
+ }
84
+ console.log(`OpenAI API key: ${maskApiKey(storedApiKey)}`);
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ console.error(`Config show failed: ${message}`);
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ configCommand
93
+ .command("clear")
94
+ .description("Remove the saved API key from local config")
95
+ .action(async () => {
96
+ try {
97
+ const didClear = await clearStoredApiKey();
98
+ if (!didClear) {
99
+ console.log(`No saved API key found in ${configFilePath}.`);
100
+ return;
101
+ }
102
+ console.log(`Removed saved API key from ${configFilePath}.`);
103
+ }
104
+ catch (error) {
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ console.error(`Config clear failed: ${message}`);
107
+ process.exitCode = 1;
108
+ }
109
+ });
110
+ program.parseAsync(process.argv).catch((error) => {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ console.error(`Command failed: ${message}`);
113
+ process.exitCode = 1;
114
+ });
51
115
  async function processSingleFile(inputPath, options, promptText) {
52
116
  if (!isPdfPath(inputPath)) {
53
117
  throw new Error("Input file must have a .pdf extension.");
54
118
  }
119
+ await ensureApiKey();
55
120
  const result = await convertPdf({
56
121
  inputPath,
57
122
  model: options.model,
@@ -80,6 +145,7 @@ async function processFolder(inputDir, options, promptText) {
80
145
  console.log("Cancelled. No files were processed.");
81
146
  return { total: files.length, succeeded: 0, failed: 0, cancelled: true, usage: emptyUsage() };
82
147
  }
148
+ await ensureApiKey();
83
149
  const outputRoot = options.output ? resolve(options.output) : undefined;
84
150
  let succeeded = 0;
85
151
  let failed = 0;
@@ -171,6 +237,120 @@ async function resolvePromptText(options) {
171
237
  }
172
238
  return promptFromFile;
173
239
  }
240
+ async function handleConfigInit(options) {
241
+ const existingKey = await getStoredApiKey();
242
+ if (existingKey && !options.force) {
243
+ const overwrite = await askYesNo("An API key is already stored. Overwrite it? [y/N] ", false);
244
+ if (!overwrite) {
245
+ console.log("No changes made.");
246
+ return;
247
+ }
248
+ }
249
+ const apiKey = await promptForApiKey();
250
+ await setStoredApiKey(apiKey);
251
+ console.log(`Saved OpenAI API key to ${configFilePath}`);
252
+ }
253
+ async function ensureApiKey() {
254
+ const envApiKey = normalizeApiKey(process.env.OPENAI_API_KEY);
255
+ if (envApiKey) {
256
+ process.env.OPENAI_API_KEY = envApiKey;
257
+ return;
258
+ }
259
+ const storedApiKey = await getStoredApiKey();
260
+ if (storedApiKey) {
261
+ process.env.OPENAI_API_KEY = storedApiKey;
262
+ return;
263
+ }
264
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
265
+ throw new Error([
266
+ "OPENAI_API_KEY is not set.",
267
+ `Run "papyrus config init" to store one in ${configFilePath},`,
268
+ "or set OPENAI_API_KEY in your environment."
269
+ ].join(" "));
270
+ }
271
+ console.log(`No OpenAI API key found in environment or ${configFilePath}.`);
272
+ const apiKey = await promptForApiKey();
273
+ const shouldSave = await askYesNo(`Save this key to ${configFilePath} for future runs? [Y/n] `, true);
274
+ if (shouldSave) {
275
+ await setStoredApiKey(apiKey);
276
+ console.log(`Saved OpenAI API key to ${configFilePath}`);
277
+ }
278
+ process.env.OPENAI_API_KEY = apiKey;
279
+ }
280
+ async function promptForApiKey() {
281
+ console.log(`Create a new API key at: ${OPENAI_API_KEYS_URL}`);
282
+ const apiKey = normalizeApiKey(await promptHidden("Paste OpenAI API key: "));
283
+ if (!apiKey) {
284
+ throw new Error("API key cannot be empty.");
285
+ }
286
+ return apiKey;
287
+ }
288
+ async function promptHidden(question) {
289
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
290
+ throw new Error("This prompt requires an interactive terminal.");
291
+ }
292
+ return new Promise((resolve, reject) => {
293
+ const stdin = process.stdin;
294
+ let value = "";
295
+ let consumeEscapeSequence = false;
296
+ const wasRaw = stdin.isRaw ?? false;
297
+ process.stdout.write(question);
298
+ const cleanup = () => {
299
+ stdin.off("data", onData);
300
+ if (stdin.isTTY) {
301
+ stdin.setRawMode(wasRaw);
302
+ }
303
+ stdin.pause();
304
+ process.stdout.write("\n");
305
+ };
306
+ const onData = (chunk) => {
307
+ const content = typeof chunk === "string" ? chunk : chunk.toString("utf8");
308
+ for (const character of content) {
309
+ if (consumeEscapeSequence) {
310
+ if (/[A-Za-z~]/.test(character)) {
311
+ consumeEscapeSequence = false;
312
+ }
313
+ continue;
314
+ }
315
+ if (character === "\u001b") {
316
+ consumeEscapeSequence = true;
317
+ continue;
318
+ }
319
+ if (character === "\u0003") {
320
+ cleanup();
321
+ reject(new Error("Cancelled by user."));
322
+ return;
323
+ }
324
+ if (character === "\r" || character === "\n") {
325
+ cleanup();
326
+ resolve(value);
327
+ return;
328
+ }
329
+ if (character === "\u007f" || character === "\b") {
330
+ value = value.slice(0, -1);
331
+ continue;
332
+ }
333
+ if (character < " ") {
334
+ continue;
335
+ }
336
+ value += character;
337
+ }
338
+ };
339
+ stdin.setRawMode(true);
340
+ stdin.resume();
341
+ stdin.on("data", onData);
342
+ });
343
+ }
344
+ function normalizeApiKey(value) {
345
+ if (!value) {
346
+ return undefined;
347
+ }
348
+ const trimmed = value.trim();
349
+ if (!trimmed) {
350
+ return undefined;
351
+ }
352
+ return trimmed;
353
+ }
174
354
  async function detectInputKind(inputPath) {
175
355
  const metadata = await stat(inputPath);
176
356
  if (metadata.isFile()) {
@@ -334,14 +514,20 @@ async function confirmFolderProcessing(totalFiles, concurrency, skipPrompt) {
334
514
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
335
515
  throw new Error("Folder mode requires an interactive terminal confirmation. Use --yes to skip the prompt.");
336
516
  }
517
+ return askYesNo(`Process ${totalFiles} PDF file(s) with concurrency ${concurrency}? [Y/n] `, true);
518
+ }
519
+ async function askYesNo(question, defaultYes) {
337
520
  const { createInterface } = await import("node:readline/promises");
338
521
  const rl = createInterface({
339
522
  input: process.stdin,
340
523
  output: process.stdout
341
524
  });
342
525
  try {
343
- const answer = (await rl.question(`Process ${totalFiles} PDF file(s) with concurrency ${concurrency}? [Y/n] `)).trim().toLowerCase();
344
- return answer === "" || answer === "y" || answer === "yes";
526
+ const answer = (await rl.question(question)).trim().toLowerCase();
527
+ if (answer === "") {
528
+ return defaultYes;
529
+ }
530
+ return answer === "y" || answer === "yes";
345
531
  }
346
532
  finally {
347
533
  rl.close();
@@ -364,3 +550,17 @@ function mergeUsage(target, delta) {
364
550
  function printUsageTotals(usage) {
365
551
  console.log(`Token usage: input=${usage.inputTokens}, output=${usage.outputTokens}, total=${usage.totalTokens}, requests=${usage.requests}`);
366
552
  }
553
+ function getCliVersion() {
554
+ try {
555
+ const packageJsonPath = new URL("../package.json", import.meta.url);
556
+ const raw = readFileSync(packageJsonPath, "utf8");
557
+ const parsed = JSON.parse(raw);
558
+ if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
559
+ return parsed.version;
560
+ }
561
+ }
562
+ catch {
563
+ // ignore and use fallback
564
+ }
565
+ return "0.0.0";
566
+ }
@@ -0,0 +1,13 @@
1
+ export type PapyrusConfig = {
2
+ openaiApiKey?: string;
3
+ };
4
+ type ConfigPathOptions = {
5
+ configFilePath?: string;
6
+ };
7
+ export declare function getConfigFilePath(options?: ConfigPathOptions): string;
8
+ export declare function readPapyrusConfig(options?: ConfigPathOptions): Promise<PapyrusConfig>;
9
+ export declare function getStoredApiKey(options?: ConfigPathOptions): Promise<string | undefined>;
10
+ export declare function setStoredApiKey(apiKey: string, options?: ConfigPathOptions): Promise<void>;
11
+ export declare function clearStoredApiKey(options?: ConfigPathOptions): Promise<boolean>;
12
+ export declare function maskApiKey(apiKey: string): string;
13
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,105 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function getConfigFilePath(options) {
5
+ if (options?.configFilePath) {
6
+ return options.configFilePath;
7
+ }
8
+ return join(homedir(), ".config", "papyrus", "config.json");
9
+ }
10
+ export async function readPapyrusConfig(options) {
11
+ const configObject = await readConfigObject(options);
12
+ const openaiApiKey = normalizeApiKey(configObject.openaiApiKey);
13
+ return openaiApiKey ? { openaiApiKey } : {};
14
+ }
15
+ export async function getStoredApiKey(options) {
16
+ const config = await readPapyrusConfig(options);
17
+ return config.openaiApiKey;
18
+ }
19
+ export async function setStoredApiKey(apiKey, options) {
20
+ const normalizedApiKey = normalizeApiKey(apiKey);
21
+ if (!normalizedApiKey) {
22
+ throw new Error("API key cannot be empty.");
23
+ }
24
+ const configObject = await readConfigObject(options);
25
+ configObject.openaiApiKey = normalizedApiKey;
26
+ await writeConfigObject(configObject, options);
27
+ }
28
+ export async function clearStoredApiKey(options) {
29
+ const configPath = getConfigFilePath(options);
30
+ const configObject = await readConfigObject(options);
31
+ if (!("openaiApiKey" in configObject)) {
32
+ return false;
33
+ }
34
+ delete configObject.openaiApiKey;
35
+ if (Object.keys(configObject).length === 0) {
36
+ await rm(configPath, { force: true });
37
+ return true;
38
+ }
39
+ await writeConfigObject(configObject, options);
40
+ return true;
41
+ }
42
+ export function maskApiKey(apiKey) {
43
+ const normalizedApiKey = normalizeApiKey(apiKey);
44
+ if (!normalizedApiKey) {
45
+ return "(empty)";
46
+ }
47
+ if (normalizedApiKey.length <= 8) {
48
+ return `${normalizedApiKey[0]}***${normalizedApiKey[normalizedApiKey.length - 1]}`;
49
+ }
50
+ return `${normalizedApiKey.slice(0, 4)}...${normalizedApiKey.slice(-4)}`;
51
+ }
52
+ async function readConfigObject(options) {
53
+ const configPath = getConfigFilePath(options);
54
+ let raw;
55
+ try {
56
+ raw = await readFile(configPath, "utf8");
57
+ }
58
+ catch (error) {
59
+ if (isNodeError(error) && error.code === "ENOENT") {
60
+ return {};
61
+ }
62
+ throw error;
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ throw new Error(`Invalid JSON in ${configPath}: ${message}`);
71
+ }
72
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
73
+ throw new Error(`Invalid config in ${configPath}: expected a JSON object.`);
74
+ }
75
+ return { ...parsed };
76
+ }
77
+ async function writeConfigObject(configObject, options) {
78
+ const configPath = getConfigFilePath(options);
79
+ const configDir = dirname(configPath);
80
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
81
+ try {
82
+ await chmod(configDir, 0o700);
83
+ }
84
+ catch (error) {
85
+ if (!isNodeError(error) || error.code !== "ENOENT") {
86
+ throw error;
87
+ }
88
+ }
89
+ const serialized = `${JSON.stringify(configObject, null, 2)}\n`;
90
+ await writeFile(configPath, serialized, { encoding: "utf8", mode: 0o600 });
91
+ await chmod(configPath, 0o600);
92
+ }
93
+ function normalizeApiKey(value) {
94
+ if (typeof value !== "string") {
95
+ return undefined;
96
+ }
97
+ const trimmed = value.trim();
98
+ if (!trimmed) {
99
+ return undefined;
100
+ }
101
+ return trimmed;
102
+ }
103
+ function isNodeError(error) {
104
+ return error instanceof Error && "code" in error;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robin7331/papyrus-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "description": "Convert PDF to markdown or text with the OpenAI Agents SDK",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import "dotenv/config";
4
+ import { readFileSync } from "node:fs";
4
5
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
6
  import { dirname, join, relative, resolve } from "node:path";
6
7
  import { Command } from "commander";
8
+ import {
9
+ clearStoredApiKey,
10
+ getConfigFilePath,
11
+ getStoredApiKey,
12
+ maskApiKey,
13
+ setStoredApiKey
14
+ } from "./config.js";
7
15
  import {
8
16
  convertPdf,
9
17
  type ConvertUsage
@@ -23,9 +31,17 @@ import {
23
31
  } from "./cliHelpers.js";
24
32
 
25
33
  const program = new Command();
34
+ const configFilePath = getConfigFilePath();
35
+ const OPENAI_API_KEYS_URL = "https://platform.openai.com/settings/organization/api-keys";
36
+ const cliVersion = getCliVersion();
37
+
38
+ type ConfigInitOptions = {
39
+ force?: boolean;
40
+ };
26
41
 
27
42
  program
28
43
  .name("papyrus")
44
+ .version(cliVersion, "-v, --version", "display version number")
29
45
  .description("Convert PDF files to Markdown or text using the OpenAI Agents SDK")
30
46
  .argument("<input>", "Path to input PDF file or folder")
31
47
  .option("-o, --output <path>", "Path to output file (single input) or output directory (folder input)")
@@ -75,7 +91,68 @@ program
75
91
  }
76
92
  });
77
93
 
78
- program.parseAsync(process.argv);
94
+ const configCommand = program
95
+ .command("config")
96
+ .description("Manage persistent configuration");
97
+
98
+ configCommand
99
+ .command("init")
100
+ .description("Interactively save an OpenAI API key to the local Papyrus config file")
101
+ .option("-f, --force", "Overwrite an existing saved API key without confirmation")
102
+ .action(async (options: ConfigInitOptions) => {
103
+ try {
104
+ await handleConfigInit(options);
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ console.error(`Config init failed: ${message}`);
108
+ process.exitCode = 1;
109
+ }
110
+ });
111
+
112
+ configCommand
113
+ .command("show")
114
+ .description("Show the saved API key in masked form")
115
+ .action(async () => {
116
+ try {
117
+ const storedApiKey = await getStoredApiKey();
118
+ console.log(`Config file: ${configFilePath}`);
119
+ if (!storedApiKey) {
120
+ console.log("OpenAI API key: not set");
121
+ return;
122
+ }
123
+
124
+ console.log(`OpenAI API key: ${maskApiKey(storedApiKey)}`);
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ console.error(`Config show failed: ${message}`);
128
+ process.exitCode = 1;
129
+ }
130
+ });
131
+
132
+ configCommand
133
+ .command("clear")
134
+ .description("Remove the saved API key from local config")
135
+ .action(async () => {
136
+ try {
137
+ const didClear = await clearStoredApiKey();
138
+ if (!didClear) {
139
+ console.log(`No saved API key found in ${configFilePath}.`);
140
+ return;
141
+ }
142
+
143
+ console.log(`Removed saved API key from ${configFilePath}.`);
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ console.error(`Config clear failed: ${message}`);
147
+ process.exitCode = 1;
148
+ }
149
+ });
150
+
151
+ program.parseAsync(process.argv).catch((error: unknown) => {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ console.error(`Command failed: ${message}`);
154
+ process.exitCode = 1;
155
+ });
79
156
 
80
157
  async function processSingleFile(
81
158
  inputPath: string,
@@ -86,6 +163,7 @@ async function processSingleFile(
86
163
  throw new Error("Input file must have a .pdf extension.");
87
164
  }
88
165
 
166
+ await ensureApiKey();
89
167
  const result = await convertPdf({
90
168
  inputPath,
91
169
  model: options.model,
@@ -133,6 +211,7 @@ async function processFolder(
133
211
  return { total: files.length, succeeded: 0, failed: 0, cancelled: true, usage: emptyUsage() };
134
212
  }
135
213
 
214
+ await ensureApiKey();
136
215
  const outputRoot = options.output ? resolve(options.output) : undefined;
137
216
  let succeeded = 0;
138
217
  let failed = 0;
@@ -248,6 +327,150 @@ async function resolvePromptText(options: CliOptions): Promise<string | undefine
248
327
  return promptFromFile;
249
328
  }
250
329
 
330
+ async function handleConfigInit(options: ConfigInitOptions): Promise<void> {
331
+ const existingKey = await getStoredApiKey();
332
+ if (existingKey && !options.force) {
333
+ const overwrite = await askYesNo("An API key is already stored. Overwrite it? [y/N] ", false);
334
+ if (!overwrite) {
335
+ console.log("No changes made.");
336
+ return;
337
+ }
338
+ }
339
+
340
+ const apiKey = await promptForApiKey();
341
+ await setStoredApiKey(apiKey);
342
+ console.log(`Saved OpenAI API key to ${configFilePath}`);
343
+ }
344
+
345
+ async function ensureApiKey(): Promise<void> {
346
+ const envApiKey = normalizeApiKey(process.env.OPENAI_API_KEY);
347
+ if (envApiKey) {
348
+ process.env.OPENAI_API_KEY = envApiKey;
349
+ return;
350
+ }
351
+
352
+ const storedApiKey = await getStoredApiKey();
353
+ if (storedApiKey) {
354
+ process.env.OPENAI_API_KEY = storedApiKey;
355
+ return;
356
+ }
357
+
358
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
359
+ throw new Error(
360
+ [
361
+ "OPENAI_API_KEY is not set.",
362
+ `Run "papyrus config init" to store one in ${configFilePath},`,
363
+ "or set OPENAI_API_KEY in your environment."
364
+ ].join(" ")
365
+ );
366
+ }
367
+
368
+ console.log(`No OpenAI API key found in environment or ${configFilePath}.`);
369
+ const apiKey = await promptForApiKey();
370
+ const shouldSave = await askYesNo(
371
+ `Save this key to ${configFilePath} for future runs? [Y/n] `,
372
+ true
373
+ );
374
+
375
+ if (shouldSave) {
376
+ await setStoredApiKey(apiKey);
377
+ console.log(`Saved OpenAI API key to ${configFilePath}`);
378
+ }
379
+
380
+ process.env.OPENAI_API_KEY = apiKey;
381
+ }
382
+
383
+ async function promptForApiKey(): Promise<string> {
384
+ console.log(`Create a new API key at: ${OPENAI_API_KEYS_URL}`);
385
+ const apiKey = normalizeApiKey(await promptHidden("Paste OpenAI API key: "));
386
+ if (!apiKey) {
387
+ throw new Error("API key cannot be empty.");
388
+ }
389
+
390
+ return apiKey;
391
+ }
392
+
393
+ async function promptHidden(question: string): Promise<string> {
394
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
395
+ throw new Error("This prompt requires an interactive terminal.");
396
+ }
397
+
398
+ return new Promise((resolve, reject) => {
399
+ const stdin = process.stdin;
400
+ let value = "";
401
+ let consumeEscapeSequence = false;
402
+ const wasRaw = stdin.isRaw ?? false;
403
+ process.stdout.write(question);
404
+
405
+ const cleanup = (): void => {
406
+ stdin.off("data", onData);
407
+ if (stdin.isTTY) {
408
+ stdin.setRawMode(wasRaw);
409
+ }
410
+
411
+ stdin.pause();
412
+ process.stdout.write("\n");
413
+ };
414
+
415
+ const onData = (chunk: string | Buffer): void => {
416
+ const content = typeof chunk === "string" ? chunk : chunk.toString("utf8");
417
+ for (const character of content) {
418
+ if (consumeEscapeSequence) {
419
+ if (/[A-Za-z~]/.test(character)) {
420
+ consumeEscapeSequence = false;
421
+ }
422
+ continue;
423
+ }
424
+
425
+ if (character === "\u001b") {
426
+ consumeEscapeSequence = true;
427
+ continue;
428
+ }
429
+
430
+ if (character === "\u0003") {
431
+ cleanup();
432
+ reject(new Error("Cancelled by user."));
433
+ return;
434
+ }
435
+
436
+ if (character === "\r" || character === "\n") {
437
+ cleanup();
438
+ resolve(value);
439
+ return;
440
+ }
441
+
442
+ if (character === "\u007f" || character === "\b") {
443
+ value = value.slice(0, -1);
444
+ continue;
445
+ }
446
+
447
+ if (character < " ") {
448
+ continue;
449
+ }
450
+
451
+ value += character;
452
+ }
453
+ };
454
+
455
+ stdin.setRawMode(true);
456
+ stdin.resume();
457
+ stdin.on("data", onData);
458
+ });
459
+ }
460
+
461
+ function normalizeApiKey(value: string | undefined): string | undefined {
462
+ if (!value) {
463
+ return undefined;
464
+ }
465
+
466
+ const trimmed = value.trim();
467
+ if (!trimmed) {
468
+ return undefined;
469
+ }
470
+
471
+ return trimmed;
472
+ }
473
+
251
474
  async function detectInputKind(inputPath: string): Promise<"file" | "directory"> {
252
475
  const metadata = await stat(inputPath);
253
476
  if (metadata.isFile()) {
@@ -467,6 +690,13 @@ async function confirmFolderProcessing(
467
690
  );
468
691
  }
469
692
 
693
+ return askYesNo(
694
+ `Process ${totalFiles} PDF file(s) with concurrency ${concurrency}? [Y/n] `,
695
+ true
696
+ );
697
+ }
698
+
699
+ async function askYesNo(question: string, defaultYes: boolean): Promise<boolean> {
470
700
  const { createInterface } = await import("node:readline/promises");
471
701
  const rl = createInterface({
472
702
  input: process.stdin,
@@ -474,11 +704,12 @@ async function confirmFolderProcessing(
474
704
  });
475
705
 
476
706
  try {
477
- const answer = (await rl.question(
478
- `Process ${totalFiles} PDF file(s) with concurrency ${concurrency}? [Y/n] `
479
- )).trim().toLowerCase();
707
+ const answer = (await rl.question(question)).trim().toLowerCase();
708
+ if (answer === "") {
709
+ return defaultYes;
710
+ }
480
711
 
481
- return answer === "" || answer === "y" || answer === "yes";
712
+ return answer === "y" || answer === "yes";
482
713
  } finally {
483
714
  rl.close();
484
715
  }
@@ -505,3 +736,18 @@ function printUsageTotals(usage: ConvertUsage): void {
505
736
  `Token usage: input=${usage.inputTokens}, output=${usage.outputTokens}, total=${usage.totalTokens}, requests=${usage.requests}`
506
737
  );
507
738
  }
739
+
740
+ function getCliVersion(): string {
741
+ try {
742
+ const packageJsonPath = new URL("../package.json", import.meta.url);
743
+ const raw = readFileSync(packageJsonPath, "utf8");
744
+ const parsed = JSON.parse(raw) as { version?: unknown };
745
+ if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
746
+ return parsed.version;
747
+ }
748
+ } catch {
749
+ // ignore and use fallback
750
+ }
751
+
752
+ return "0.0.0";
753
+ }
package/src/config.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export type PapyrusConfig = {
6
+ openaiApiKey?: string;
7
+ };
8
+
9
+ type ConfigPathOptions = {
10
+ configFilePath?: string;
11
+ };
12
+
13
+ export function getConfigFilePath(options?: ConfigPathOptions): string {
14
+ if (options?.configFilePath) {
15
+ return options.configFilePath;
16
+ }
17
+
18
+ return join(homedir(), ".config", "papyrus", "config.json");
19
+ }
20
+
21
+ export async function readPapyrusConfig(options?: ConfigPathOptions): Promise<PapyrusConfig> {
22
+ const configObject = await readConfigObject(options);
23
+ const openaiApiKey = normalizeApiKey(configObject.openaiApiKey);
24
+ return openaiApiKey ? { openaiApiKey } : {};
25
+ }
26
+
27
+ export async function getStoredApiKey(options?: ConfigPathOptions): Promise<string | undefined> {
28
+ const config = await readPapyrusConfig(options);
29
+ return config.openaiApiKey;
30
+ }
31
+
32
+ export async function setStoredApiKey(
33
+ apiKey: string,
34
+ options?: ConfigPathOptions
35
+ ): Promise<void> {
36
+ const normalizedApiKey = normalizeApiKey(apiKey);
37
+ if (!normalizedApiKey) {
38
+ throw new Error("API key cannot be empty.");
39
+ }
40
+
41
+ const configObject = await readConfigObject(options);
42
+ configObject.openaiApiKey = normalizedApiKey;
43
+ await writeConfigObject(configObject, options);
44
+ }
45
+
46
+ export async function clearStoredApiKey(options?: ConfigPathOptions): Promise<boolean> {
47
+ const configPath = getConfigFilePath(options);
48
+ const configObject = await readConfigObject(options);
49
+ if (!("openaiApiKey" in configObject)) {
50
+ return false;
51
+ }
52
+
53
+ delete configObject.openaiApiKey;
54
+ if (Object.keys(configObject).length === 0) {
55
+ await rm(configPath, { force: true });
56
+ return true;
57
+ }
58
+
59
+ await writeConfigObject(configObject, options);
60
+ return true;
61
+ }
62
+
63
+ export function maskApiKey(apiKey: string): string {
64
+ const normalizedApiKey = normalizeApiKey(apiKey);
65
+ if (!normalizedApiKey) {
66
+ return "(empty)";
67
+ }
68
+
69
+ if (normalizedApiKey.length <= 8) {
70
+ return `${normalizedApiKey[0]}***${normalizedApiKey[normalizedApiKey.length - 1]}`;
71
+ }
72
+
73
+ return `${normalizedApiKey.slice(0, 4)}...${normalizedApiKey.slice(-4)}`;
74
+ }
75
+
76
+ async function readConfigObject(options?: ConfigPathOptions): Promise<Record<string, unknown>> {
77
+ const configPath = getConfigFilePath(options);
78
+
79
+ let raw: string;
80
+ try {
81
+ raw = await readFile(configPath, "utf8");
82
+ } catch (error) {
83
+ if (isNodeError(error) && error.code === "ENOENT") {
84
+ return {};
85
+ }
86
+
87
+ throw error;
88
+ }
89
+
90
+ let parsed: unknown;
91
+ try {
92
+ parsed = JSON.parse(raw);
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ throw new Error(`Invalid JSON in ${configPath}: ${message}`);
96
+ }
97
+
98
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
99
+ throw new Error(`Invalid config in ${configPath}: expected a JSON object.`);
100
+ }
101
+
102
+ return { ...(parsed as Record<string, unknown>) };
103
+ }
104
+
105
+ async function writeConfigObject(
106
+ configObject: Record<string, unknown>,
107
+ options?: ConfigPathOptions
108
+ ): Promise<void> {
109
+ const configPath = getConfigFilePath(options);
110
+ const configDir = dirname(configPath);
111
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
112
+
113
+ try {
114
+ await chmod(configDir, 0o700);
115
+ } catch (error) {
116
+ if (!isNodeError(error) || error.code !== "ENOENT") {
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ const serialized = `${JSON.stringify(configObject, null, 2)}\n`;
122
+ await writeFile(configPath, serialized, { encoding: "utf8", mode: 0o600 });
123
+ await chmod(configPath, 0o600);
124
+ }
125
+
126
+ function normalizeApiKey(value: unknown): string | undefined {
127
+ if (typeof value !== "string") {
128
+ return undefined;
129
+ }
130
+
131
+ const trimmed = value.trim();
132
+ if (!trimmed) {
133
+ return undefined;
134
+ }
135
+
136
+ return trimmed;
137
+ }
138
+
139
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
140
+ return error instanceof Error && "code" in error;
141
+ }
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import {
7
+ clearStoredApiKey,
8
+ getStoredApiKey,
9
+ maskApiKey,
10
+ readPapyrusConfig,
11
+ setStoredApiKey
12
+ } from "../src/config.js";
13
+
14
+ async function withTempConfigPath(
15
+ run: (configFilePath: string) => Promise<void>
16
+ ): Promise<void> {
17
+ const root = await mkdtemp(join(tmpdir(), "papyrus-config-test-"));
18
+ try {
19
+ await run(join(root, "config.json"));
20
+ } finally {
21
+ await rm(root, { recursive: true, force: true });
22
+ }
23
+ }
24
+
25
+ test("setStoredApiKey and getStoredApiKey persist and read the key", async () => {
26
+ await withTempConfigPath(async (configFilePath) => {
27
+ await setStoredApiKey("sk-test-123", { configFilePath });
28
+ const storedApiKey = await getStoredApiKey({ configFilePath });
29
+ assert.equal(storedApiKey, "sk-test-123");
30
+
31
+ const raw = JSON.parse(await readFile(configFilePath, "utf8")) as Record<string, string>;
32
+ assert.equal(raw.openaiApiKey, "sk-test-123");
33
+ });
34
+ });
35
+
36
+ test("clearStoredApiKey removes the key and reports state transitions", async () => {
37
+ await withTempConfigPath(async (configFilePath) => {
38
+ await setStoredApiKey("sk-test-123", { configFilePath });
39
+
40
+ assert.equal(await clearStoredApiKey({ configFilePath }), true);
41
+ assert.equal(await getStoredApiKey({ configFilePath }), undefined);
42
+ assert.equal(await clearStoredApiKey({ configFilePath }), false);
43
+ });
44
+ });
45
+
46
+ test("readPapyrusConfig rejects invalid JSON structures", async () => {
47
+ await withTempConfigPath(async (configFilePath) => {
48
+ await writeFile(configFilePath, "[]\n", "utf8");
49
+ await assert.rejects(
50
+ () => readPapyrusConfig({ configFilePath }),
51
+ /expected a JSON object/
52
+ );
53
+ });
54
+ });
55
+
56
+ test("maskApiKey masks short and long keys", () => {
57
+ assert.equal(maskApiKey("sk-short"), "s***t");
58
+ assert.equal(maskApiKey("sk-test-123456789"), "sk-t...6789");
59
+ });