@robin7331/papyrus-cli 0.1.2 → 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
@@ -14,99 +14,73 @@
14
14
 
15
15
  ## Installation
16
16
 
17
- Run directly with `npx`:
18
-
19
- ```bash
20
- npx @robin7331/papyrus-cli --help
21
- ```
22
-
23
- Or install globally:
17
+ Install globally:
24
18
 
25
19
  ```bash
26
20
  npm i -g @robin7331/papyrus-cli
27
21
  papyrus --help
28
22
  ```
29
23
 
30
- ## API Key Setup
31
-
32
- Papyrus requires `OPENAI_API_KEY`.
33
-
34
- macOS/Linux (persistent):
35
-
36
- ```bash
37
- echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
38
- source ~/.zshrc
39
- ```
40
-
41
- PowerShell (persistent):
42
-
43
- ```powershell
44
- setx OPENAI_API_KEY "your_api_key_here"
45
- # restart PowerShell after running setx
46
- ```
47
-
48
- One-off execution:
49
-
50
- ```bash
51
- OPENAI_API_KEY="your_api_key_here" npx @robin7331/papyrus-cli ./path/to/input.pdf
52
- ```
53
-
54
- Security note: Papyrus intentionally does not provide an `--api-key` flag to avoid leaking keys via shell history or process lists.
55
-
56
24
  ## Usage
57
25
 
58
- Single file (auto mode):
59
-
60
26
  ```bash
27
+ # Single file (auto mode; if no API key is found, Papyrus prompts you to paste one)
61
28
  papyrus ./path/to/input.pdf
62
- ```
63
-
64
- Single file with explicit format/output/model:
65
29
 
66
- ```bash
30
+ # Single file with explicit format/output/model
67
31
  papyrus ./path/to/input.pdf --format md --output ./out/result.md --model gpt-4o-mini
68
- ```
69
32
 
70
- Auto mode with extra instructions:
71
-
72
- ```bash
33
+ # Auto mode with extra instructions
73
34
  papyrus ./path/to/input.pdf --instructions "Prioritize table accuracy." --format txt
74
- ```
75
35
 
76
- Prompt mode (inline prompt):
77
-
78
- ```bash
36
+ # Prompt mode (inline prompt)
79
37
  papyrus ./path/to/input.pdf --mode prompt --prompt "Extract all invoice line items as bullet points." --format md
80
- ```
81
-
82
- Prompt mode (prompt file):
83
38
 
84
- ```bash
39
+ # Prompt mode (prompt file)
85
40
  papyrus ./path/to/input.pdf --mode prompt --prompt-file ./my-prompt.txt --format txt
41
+
42
+ # Folder mode (recursive scan, asks for confirmation)
43
+ papyrus ./path/to/folder
44
+
45
+ # Folder mode with explicit concurrency and output directory
46
+ papyrus ./path/to/folder --concurrency 4 --output ./out
47
+
48
+ # Folder mode without confirmation prompt
49
+ papyrus ./path/to/folder --yes
86
50
  ```
87
51
 
88
- Folder mode (recursive scan, asks for confirmation):
52
+ ## API Key Setup
53
+
54
+ Papyrus requires `OPENAI_API_KEY`.
55
+
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.
57
+
58
+ macOS/Linux (persistent):
89
59
 
90
60
  ```bash
91
- papyrus ./path/to/folder
61
+ echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
62
+ source ~/.zshrc
92
63
  ```
93
64
 
94
- Folder mode with explicit concurrency and output directory:
65
+ PowerShell (persistent):
95
66
 
96
- ```bash
97
- papyrus ./path/to/folder --concurrency 4 --output ./out
67
+ ```powershell
68
+ setx OPENAI_API_KEY "your_api_key_here"
69
+ # restart PowerShell after running setx
98
70
  ```
99
71
 
100
- Folder mode without confirmation prompt:
72
+ One-off execution:
101
73
 
102
74
  ```bash
103
- papyrus ./path/to/folder --yes
75
+ OPENAI_API_KEY="your_api_key_here" papyrus ./path/to/input.pdf
104
76
  ```
105
77
 
106
- `npx` alternative for any command:
78
+ Papyrus config commands (optional, local persistent storage in `~/.config/papyrus/config.json`):
107
79
 
108
80
  ```bash
109
- npx @robin7331/papyrus-cli ./path/to/input.pdf --mode auto
81
+ papyrus config init
82
+ papyrus config show
83
+ papyrus config clear
110
84
  ```
111
85
 
112
86
  ## Arguments Reference
@@ -232,3 +206,7 @@ npm run build
232
206
  npm run dev -- ./path/to/input.pdf
233
207
  npm test
234
208
  ```
209
+
210
+ ## License
211
+
212
+ MIT
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.2",
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": {
@@ -10,6 +10,7 @@
10
10
  "bugs": {
11
11
  "url": "https://github.com/robin7331/papyrus-cli/issues"
12
12
  },
13
+ "license": "MIT",
13
14
  "keywords": [
14
15
  "pdf",
15
16
  "markdown",
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
+ });