@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 +38 -60
- package/dist/cli.js +186 -3
- package/dist/config.d.ts +13 -0
- package/dist/config.js +105 -0
- package/package.json +2 -1
- package/src/cli.ts +233 -5
- package/src/config.ts +141 -0
- package/test/config.test.ts +59 -0
package/README.md
CHANGED
|
@@ -14,99 +14,73 @@
|
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
echo 'export OPENAI_API_KEY="your_api_key_here"' >> ~/.zshrc
|
|
62
|
+
source ~/.zshrc
|
|
92
63
|
```
|
|
93
64
|
|
|
94
|
-
|
|
65
|
+
PowerShell (persistent):
|
|
95
66
|
|
|
96
|
-
```
|
|
97
|
-
|
|
67
|
+
```powershell
|
|
68
|
+
setx OPENAI_API_KEY "your_api_key_here"
|
|
69
|
+
# restart PowerShell after running setx
|
|
98
70
|
```
|
|
99
71
|
|
|
100
|
-
|
|
72
|
+
One-off execution:
|
|
101
73
|
|
|
102
74
|
```bash
|
|
103
|
-
papyrus ./path/to/
|
|
75
|
+
OPENAI_API_KEY="your_api_key_here" papyrus ./path/to/input.pdf
|
|
104
76
|
```
|
|
105
77
|
|
|
106
|
-
|
|
78
|
+
Papyrus config commands (optional, local persistent storage in `~/.config/papyrus/config.json`):
|
|
107
79
|
|
|
108
80
|
```bash
|
|
109
|
-
|
|
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
|
|
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(
|
|
344
|
-
|
|
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();
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robin7331/papyrus-cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
704
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
705
|
+
if (answer === "") {
|
|
706
|
+
return defaultYes;
|
|
707
|
+
}
|
|
480
708
|
|
|
481
|
-
return answer === "
|
|
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
|
+
});
|