@robin7331/papyrus-cli 0.1.3 → 0.1.4

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