@robin7331/papyrus-cli 0.1.1

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.
@@ -0,0 +1,75 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { basename, dirname, extname, join, relative } from "node:path";
3
+ export function parseMode(value) {
4
+ if (value === "auto" || value === "prompt") {
5
+ return value;
6
+ }
7
+ throw new InvalidArgumentError("Mode must be either 'auto' or 'prompt'.");
8
+ }
9
+ export function parseFormat(value) {
10
+ if (value === "md" || value === "txt") {
11
+ return value;
12
+ }
13
+ throw new InvalidArgumentError("Format must be either 'md' or 'txt'.");
14
+ }
15
+ export function parseConcurrency(value) {
16
+ const parsed = Number(value);
17
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
18
+ throw new InvalidArgumentError("Concurrency must be an integer between 1 and 100.");
19
+ }
20
+ return parsed;
21
+ }
22
+ export function validateOptionCombination(options) {
23
+ if (options.mode === "prompt") {
24
+ const promptSourceCount = Number(Boolean(options.prompt)) + Number(Boolean(options.promptFile));
25
+ if (promptSourceCount !== 1) {
26
+ throw new Error("Prompt mode requires exactly one of --prompt or --prompt-file.");
27
+ }
28
+ if (options.instructions) {
29
+ throw new Error("--instructions is only supported in auto mode.");
30
+ }
31
+ return;
32
+ }
33
+ if (options.prompt || options.promptFile) {
34
+ throw new Error("--prompt and --prompt-file are only supported in prompt mode.");
35
+ }
36
+ }
37
+ export function defaultOutputPath(inputPath, format) {
38
+ const extension = format === "md" ? ".md" : ".txt";
39
+ if (extname(inputPath).toLowerCase() === ".pdf") {
40
+ return inputPath.slice(0, -4) + extension;
41
+ }
42
+ return inputPath + extension;
43
+ }
44
+ export function resolveFolderOutputPath(inputPath, inputRoot, outputRoot, format) {
45
+ if (!outputRoot) {
46
+ return defaultOutputPath(inputPath, format);
47
+ }
48
+ const relativePath = relative(inputRoot, inputPath);
49
+ const relativeDir = dirname(relativePath);
50
+ const base = basename(relativePath, extname(relativePath));
51
+ const filename = `${base}.${format}`;
52
+ if (relativeDir === ".") {
53
+ return join(outputRoot, filename);
54
+ }
55
+ return join(outputRoot, relativeDir, filename);
56
+ }
57
+ export function isPdfPath(inputPath) {
58
+ return extname(inputPath).toLowerCase() === ".pdf";
59
+ }
60
+ export function looksLikeFileOutput(outputPath) {
61
+ const outputExt = extname(outputPath).toLowerCase();
62
+ return outputExt === ".md" || outputExt === ".txt";
63
+ }
64
+ export function truncate(value, maxLength) {
65
+ if (value.length <= maxLength) {
66
+ return value;
67
+ }
68
+ if (maxLength <= 3) {
69
+ return value.slice(0, maxLength);
70
+ }
71
+ return `${value.slice(0, maxLength - 3)}...`;
72
+ }
73
+ export function formatDurationMs(durationMs) {
74
+ return `${(durationMs / 1000).toFixed(2)}s`;
75
+ }
@@ -0,0 +1,22 @@
1
+ export type ConvertOptions = {
2
+ inputPath: string;
3
+ model: string;
4
+ mode: ConversionMode;
5
+ format?: OutputFormat;
6
+ instructions?: string;
7
+ promptText?: string;
8
+ };
9
+ export type ConversionMode = "auto" | "prompt";
10
+ export type OutputFormat = "md" | "txt";
11
+ export type ConvertResult = {
12
+ format: OutputFormat;
13
+ content: string;
14
+ usage: ConvertUsage;
15
+ };
16
+ export type ConvertUsage = {
17
+ requests: number;
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ totalTokens: number;
21
+ };
22
+ export declare function convertPdf(options: ConvertOptions): Promise<ConvertResult>;
@@ -0,0 +1,144 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { Agent, run } from "@openai/agents";
5
+ import OpenAI from "openai";
6
+ import { z } from "zod";
7
+ const AUTO_RESPONSE_SCHEMA = z.object({
8
+ format: z.enum(["md", "txt"]),
9
+ content: z.string().min(1)
10
+ });
11
+ export async function convertPdf(options) {
12
+ const inputPath = resolve(options.inputPath);
13
+ await access(inputPath);
14
+ const apiKey = process.env.OPENAI_API_KEY;
15
+ if (!apiKey) {
16
+ throw new Error("OPENAI_API_KEY is not set.");
17
+ }
18
+ const client = new OpenAI({ apiKey });
19
+ const uploaded = await client.files.create({
20
+ file: createReadStream(inputPath),
21
+ purpose: "user_data"
22
+ });
23
+ const agent = new Agent({
24
+ name: "PDF Converter",
25
+ instructions: "You convert PDF files precisely according to the requested output format.",
26
+ model: options.model
27
+ });
28
+ const promptText = buildPromptText(options);
29
+ const result = await run(agent, [
30
+ {
31
+ role: "user",
32
+ content: [
33
+ {
34
+ type: "input_text",
35
+ text: promptText
36
+ },
37
+ {
38
+ type: "input_file",
39
+ file: { id: uploaded.id }
40
+ }
41
+ ]
42
+ }
43
+ ]);
44
+ const rawOutput = (result.finalOutput ?? "").trim();
45
+ if (!rawOutput) {
46
+ throw new Error("No content returned by the API.");
47
+ }
48
+ const usage = {
49
+ requests: result.state.usage.requests,
50
+ inputTokens: result.state.usage.inputTokens,
51
+ outputTokens: result.state.usage.outputTokens,
52
+ totalTokens: result.state.usage.totalTokens
53
+ };
54
+ if (options.mode === "auto" && !options.format) {
55
+ return { ...parseAutoResponse(rawOutput), usage };
56
+ }
57
+ const format = options.format ?? "txt";
58
+ return { format, content: rawOutput, usage };
59
+ }
60
+ function buildPromptText(options) {
61
+ if (options.mode === "prompt") {
62
+ if (!options.promptText) {
63
+ throw new Error("promptText is required when mode is 'prompt'.");
64
+ }
65
+ const promptModeParts = [
66
+ "Apply the following user prompt to the PDF.",
67
+ "Return only the final converted content.",
68
+ `User prompt:\n${options.promptText}`
69
+ ];
70
+ if (options.format === "md") {
71
+ promptModeParts.push("Output format requirement: Return only GitHub-flavored Markdown.");
72
+ }
73
+ else if (options.format === "txt") {
74
+ promptModeParts.push("Output format requirement: Return plain text only and do not use Markdown syntax.");
75
+ }
76
+ else {
77
+ promptModeParts.push("If the prompt does not enforce a format, prefer plain text without Markdown syntax.");
78
+ }
79
+ return promptModeParts.join("\n\n");
80
+ }
81
+ if (options.format === "md") {
82
+ return withAdditionalInstructions([
83
+ "Convert this PDF into clean GitHub-flavored Markdown.",
84
+ "Preserve headings, paragraphs, lists, and tables.",
85
+ "Render tables as Markdown pipe tables with header separators.",
86
+ "If cells are empty due to merged cells, keep the table readable and consistent.",
87
+ "Return only Markdown without code fences."
88
+ ].join(" "), options.instructions);
89
+ }
90
+ if (options.format === "txt") {
91
+ return withAdditionalInstructions([
92
+ "Convert this PDF into clean plain text.",
93
+ "Preserve reading order and paragraph boundaries.",
94
+ "Represent tables in readable plain text (no Markdown syntax).",
95
+ "Return plain text only and do not use Markdown syntax or code fences."
96
+ ].join(" "), options.instructions);
97
+ }
98
+ return withAdditionalInstructions([
99
+ "Decide the best output format for this PDF: Markdown ('md') or plain text ('txt').",
100
+ "Choose 'md' for documents with meaningful headings, lists, and tables that benefit from Markdown.",
101
+ "Choose 'txt' for mostly linear text where Markdown adds little value.",
102
+ "Respond with JSON only, using this exact schema:",
103
+ "{\"format\":\"md|txt\",\"content\":\"<converted content>\"}",
104
+ "If format is 'md', use clean GitHub-flavored Markdown and pipe tables where appropriate.",
105
+ "If format is 'txt', output plain text only and do not use Markdown syntax.",
106
+ "Do not wrap the JSON in code fences."
107
+ ].join("\n"), options.instructions);
108
+ }
109
+ function withAdditionalInstructions(base, additional) {
110
+ if (!additional) {
111
+ return base;
112
+ }
113
+ return `${base}\n\nAdditional user instructions:\n${additional}`;
114
+ }
115
+ function parseAutoResponse(rawOutput) {
116
+ let candidate = rawOutput.trim();
117
+ const fencedMatch = candidate.match(/```(?:json)?\s*([\s\S]*?)```/i);
118
+ if (fencedMatch?.[1]) {
119
+ candidate = fencedMatch[1].trim();
120
+ }
121
+ const firstBrace = candidate.indexOf("{");
122
+ const lastBrace = candidate.lastIndexOf("}");
123
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) {
124
+ throw new Error("Auto mode response is not valid JSON.");
125
+ }
126
+ const jsonPayload = candidate.slice(firstBrace, lastBrace + 1);
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(jsonPayload);
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`Failed to parse auto mode JSON response: ${message}`);
134
+ }
135
+ const validated = AUTO_RESPONSE_SCHEMA.safeParse(parsed);
136
+ if (!validated.success) {
137
+ throw new Error("Auto mode JSON must match { format: 'md' | 'txt', content: string }.");
138
+ }
139
+ const content = validated.data.content.trim();
140
+ if (!content) {
141
+ throw new Error("Auto mode returned empty content.");
142
+ }
143
+ return { format: validated.data.format, content };
144
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@robin7331/papyrus-cli",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Convert PDF to markdown or text with the OpenAI Agents SDK",
6
+ "type": "module",
7
+ "bin": {
8
+ "papyrus": "./dist/cli.js",
9
+ "papyrus-cli": "./dist/cli.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "dev": "tsx src/cli.ts",
14
+ "start": "node dist/cli.js",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "tsx --test test/**/*.test.ts"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.0.0"
20
+ },
21
+ "dependencies": {
22
+ "@openai/agents": "^0.5.3",
23
+ "commander": "^14.0.0",
24
+ "dotenv": "^17.3.1",
25
+ "openai": "^6.7.0",
26
+ "zod": "^4.3.6"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.9.1",
30
+ "tsx": "^4.20.6",
31
+ "typescript": "^5.9.3"
32
+ }
33
+ }