@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 +45 -46
- package/dist/cli.js +203 -3
- package/dist/config.d.ts +13 -0
- package/dist/config.js +105 -0
- package/package.json +1 -1
- package/src/cli.ts +251 -5
- package/src/config.ts +141 -0
- package/test/config.test.ts +59 -0
package/README.md
CHANGED
|
@@ -21,80 +21,69 @@ npm i -g @robin7331/papyrus-cli
|
|
|
21
21
|
papyrus --help
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
##
|
|
25
|
-
|
|
26
|
-
Papyrus requires `OPENAI_API_KEY`.
|
|
27
|
-
|
|
28
|
-
macOS/Linux (persistent):
|
|
24
|
+
## Usage
|
|
29
25
|
|
|
30
26
|
```bash
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
36
|
+
# Auto mode with extra instructions
|
|
37
|
+
papyrus ./path/to/input.pdf --instructions "Prioritize table accuracy." --format txt
|
|
49
38
|
|
|
50
|
-
|
|
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
|
-
|
|
42
|
+
# Prompt mode (prompt file)
|
|
43
|
+
papyrus ./path/to/input.pdf --mode prompt --prompt-file ./my-prompt.txt --format txt
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
papyrus ./path/to/
|
|
56
|
-
```
|
|
45
|
+
# Folder mode (recursive scan, asks for confirmation)
|
|
46
|
+
papyrus ./path/to/folder
|
|
57
47
|
|
|
58
|
-
|
|
48
|
+
# Folder mode with explicit concurrency and output directory
|
|
49
|
+
papyrus ./path/to/folder --concurrency 4 --output ./out
|
|
59
50
|
|
|
60
|
-
|
|
61
|
-
papyrus ./path/to/
|
|
51
|
+
# Folder mode without confirmation prompt
|
|
52
|
+
papyrus ./path/to/folder --yes
|
|
62
53
|
```
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
papyrus ./path/to/input.pdf --instructions "Prioritize table accuracy." --format txt
|
|
68
|
-
```
|
|
55
|
+
## API Key Setup
|
|
69
56
|
|
|
70
|
-
|
|
57
|
+
Papyrus requires `OPENAI_API_KEY`.
|
|
71
58
|
|
|
72
|
-
|
|
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
|
-
|
|
61
|
+
macOS/Linux (persistent):
|
|
77
62
|
|
|
78
63
|
```bash
|
|
79
|
-
|
|
64
|
+
echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
|
|
65
|
+
source ~/.zshrc
|
|
80
66
|
```
|
|
81
67
|
|
|
82
|
-
|
|
68
|
+
PowerShell (persistent):
|
|
83
69
|
|
|
84
|
-
```
|
|
85
|
-
|
|
70
|
+
```powershell
|
|
71
|
+
setx OPENAI_API_KEY "your_api_key_here"
|
|
72
|
+
# restart PowerShell after running setx
|
|
86
73
|
```
|
|
87
74
|
|
|
88
|
-
|
|
75
|
+
One-off execution:
|
|
89
76
|
|
|
90
77
|
```bash
|
|
91
|
-
papyrus ./path/to/
|
|
78
|
+
OPENAI_API_KEY="your_api_key_here" papyrus ./path/to/input.pdf
|
|
92
79
|
```
|
|
93
80
|
|
|
94
|
-
|
|
81
|
+
Papyrus config commands (optional, local persistent storage in `~/.config/papyrus/config.json`):
|
|
95
82
|
|
|
96
83
|
```bash
|
|
97
|
-
papyrus
|
|
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
|
|
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(
|
|
344
|
-
|
|
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
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
|
|
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
|
-
|
|
479
|
-
|
|
707
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
708
|
+
if (answer === "") {
|
|
709
|
+
return defaultYes;
|
|
710
|
+
}
|
|
480
711
|
|
|
481
|
-
return answer === "
|
|
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
|
+
});
|