@echofiles/echo-pdf 0.2.0

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,593 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "echo-pdf-cli")
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const PROJECT_CONFIG_FILE = path.resolve(__dirname, "../echo-pdf.config.json")
11
+ const PROJECT_CONFIG = JSON.parse(fs.readFileSync(PROJECT_CONFIG_FILE, "utf-8"))
12
+ const PROVIDER_ENTRIES = Object.entries(PROJECT_CONFIG.providers || {})
13
+ const PROVIDER_ALIASES = PROVIDER_ENTRIES.map(([alias]) => alias)
14
+ const PROVIDER_ALIAS_BY_TYPE = new Map(
15
+ PROVIDER_ENTRIES.map(([alias, provider]) => [provider.type, alias])
16
+ )
17
+ const PROVIDER_SET_NAMES = Array.from(
18
+ new Set(PROVIDER_ENTRIES.flatMap(([alias, provider]) => [alias, provider.type]))
19
+ )
20
+ const PROJECT_DEFAULT_MODEL = String(PROJECT_CONFIG.agent?.defaultModel || "").trim()
21
+ const DEFAULT_WORKER_NAME = process.env.ECHO_PDF_WORKER_NAME || PROJECT_CONFIG.service?.name || "echo-pdf"
22
+ const DEFAULT_SERVICE_URL = process.env.ECHO_PDF_SERVICE_URL || `https://${DEFAULT_WORKER_NAME}.echofilesai.workers.dev`
23
+ const DEFAULT_MCP_HEADER = process.env.ECHO_PDF_MCP_HEADER?.trim() || PROJECT_CONFIG.mcp?.authHeader || "x-mcp-key"
24
+
25
+ const emptyProviders = () =>
26
+ Object.fromEntries(PROVIDER_ALIASES.map((providerAlias) => [providerAlias, { apiKey: "" }]))
27
+
28
+ const resolveProviderAliasInput = (input) => {
29
+ if (typeof input !== "string" || input.trim().length === 0) {
30
+ throw new Error("provider is required")
31
+ }
32
+ const raw = input.trim()
33
+ if (PROVIDER_ALIASES.includes(raw)) return raw
34
+ const fromType = PROVIDER_ALIAS_BY_TYPE.get(raw)
35
+ if (fromType) return fromType
36
+ throw new Error(`provider must be one of: ${PROVIDER_SET_NAMES.join(", ")}`)
37
+ }
38
+
39
+ function resolveDefaultProviderAlias() {
40
+ const configured = PROJECT_CONFIG.agent?.defaultProvider
41
+ if (typeof configured === "string" && configured.trim().length > 0) {
42
+ return resolveProviderAliasInput(configured.trim())
43
+ }
44
+ return PROVIDER_ALIASES[0] || "openai"
45
+ }
46
+
47
+ const DEFAULT_PROVIDER_ALIAS = resolveDefaultProviderAlias()
48
+
49
+ const defaultConfig = () => ({
50
+ serviceUrl: DEFAULT_SERVICE_URL,
51
+ profile: "default",
52
+ profiles: {
53
+ default: {
54
+ defaultProvider: DEFAULT_PROVIDER_ALIAS,
55
+ models: {},
56
+ providers: emptyProviders(),
57
+ },
58
+ },
59
+ })
60
+
61
+ const ensureConfig = () => {
62
+ if (!fs.existsSync(CONFIG_FILE)) {
63
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
64
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig(), null, 2))
65
+ }
66
+ }
67
+
68
+ const loadConfig = () => {
69
+ ensureConfig()
70
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"))
71
+ if (!config.profiles || typeof config.profiles !== "object") {
72
+ config.profiles = {}
73
+ }
74
+ if (typeof config.profile !== "string" || !config.profile) {
75
+ config.profile = "default"
76
+ }
77
+ const profile = getProfile(config, config.profile)
78
+ if (typeof profile.defaultProvider !== "string" || !profile.defaultProvider) {
79
+ profile.defaultProvider = DEFAULT_PROVIDER_ALIAS
80
+ }
81
+ if (!profile.providers || typeof profile.providers !== "object") {
82
+ profile.providers = emptyProviders()
83
+ }
84
+ for (const providerAlias of PROVIDER_ALIASES) {
85
+ if (!profile.providers[providerAlias] || typeof profile.providers[providerAlias] !== "object") {
86
+ profile.providers[providerAlias] = { apiKey: "" }
87
+ }
88
+ }
89
+ if (!profile.models || typeof profile.models !== "object") {
90
+ profile.models = {}
91
+ }
92
+ saveConfig(config)
93
+ return config
94
+ }
95
+
96
+ const saveConfig = (config) => {
97
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
98
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
99
+ }
100
+
101
+ const parseFlags = (args) => {
102
+ const flags = {}
103
+ for (let i = 0; i < args.length; i += 1) {
104
+ const token = args[i]
105
+ if (!token?.startsWith("--")) continue
106
+ const key = token.slice(2)
107
+ const next = args[i + 1]
108
+ if (!next || next.startsWith("--")) {
109
+ flags[key] = true
110
+ } else {
111
+ flags[key] = next
112
+ i += 1
113
+ }
114
+ }
115
+ return flags
116
+ }
117
+
118
+ const getProfile = (config, name) => {
119
+ const profileName = name || config.profile || "default"
120
+ if (!config.profiles[profileName]) {
121
+ config.profiles[profileName] = {
122
+ defaultProvider: DEFAULT_PROVIDER_ALIAS,
123
+ models: {},
124
+ providers: {},
125
+ }
126
+ }
127
+ const profile = config.profiles[profileName]
128
+ if (!profile.providers || typeof profile.providers !== "object") profile.providers = {}
129
+ for (const providerAlias of PROVIDER_ALIASES) {
130
+ if (!profile.providers[providerAlias] || typeof profile.providers[providerAlias] !== "object") {
131
+ profile.providers[providerAlias] = { apiKey: "" }
132
+ }
133
+ }
134
+ if (!profile.models || typeof profile.models !== "object") profile.models = {}
135
+ if (typeof profile.defaultProvider !== "string" || !profile.defaultProvider) {
136
+ profile.defaultProvider = DEFAULT_PROVIDER_ALIAS
137
+ }
138
+ return profile
139
+ }
140
+
141
+ const getProfileName = (config, profileName) => profileName || config.profile || "default"
142
+
143
+ const resolveProviderAlias = (profile, explicitProvider) =>
144
+ typeof explicitProvider === "string" && explicitProvider.length > 0
145
+ ? resolveProviderAliasInput(explicitProvider)
146
+ : resolveProviderAliasInput(profile.defaultProvider || DEFAULT_PROVIDER_ALIAS)
147
+
148
+ const resolveDefaultModel = (profile, providerAlias) => {
149
+ const model = profile.models?.[providerAlias]
150
+ if (typeof model === "string" && model.trim().length > 0) return model.trim()
151
+ return PROJECT_DEFAULT_MODEL
152
+ }
153
+
154
+ const buildProviderApiKeys = (config, profileName) => {
155
+ const profile = getProfile(config, profileName)
156
+ const providerApiKeys = {}
157
+ for (const [providerAlias, providerConfig] of PROVIDER_ENTRIES) {
158
+ const apiKey = profile.providers?.[providerAlias]?.apiKey || profile.providers?.[providerConfig.type]?.apiKey || ""
159
+ providerApiKeys[providerAlias] = apiKey
160
+ providerApiKeys[providerConfig.type] = apiKey
161
+ }
162
+ return providerApiKeys
163
+ }
164
+
165
+ const postJson = async (url, payload, extraHeaders = {}) => {
166
+ const response = await fetch(url, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json", ...extraHeaders },
169
+ body: JSON.stringify(payload),
170
+ })
171
+ const text = await response.text()
172
+ let data
173
+ try {
174
+ data = JSON.parse(text)
175
+ } catch {
176
+ data = { raw: text }
177
+ }
178
+ if (!response.ok) {
179
+ throw new Error(`${response.status} ${JSON.stringify(data)}`)
180
+ }
181
+ return data
182
+ }
183
+
184
+ const print = (data) => {
185
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
186
+ }
187
+
188
+ const buildMcpHeaders = () => {
189
+ const token = process.env.ECHO_PDF_MCP_KEY?.trim()
190
+ if (!token) return {}
191
+ return { [DEFAULT_MCP_HEADER]: token }
192
+ }
193
+
194
+ const buildModelsRequest = (provider, providerApiKeys) => ({ provider, providerApiKeys })
195
+
196
+ const buildToolCallRequest = (input) => ({
197
+ name: input.tool,
198
+ arguments: input.args,
199
+ provider: input.provider,
200
+ model: input.model,
201
+ providerApiKeys: input.providerApiKeys,
202
+ })
203
+
204
+ const buildMcpRequest = (id, method, params = {}) => ({
205
+ jsonrpc: "2.0",
206
+ id,
207
+ method,
208
+ params,
209
+ })
210
+
211
+ const parseConfigValue = (raw, type = "auto") => {
212
+ if (type === "string") return String(raw)
213
+ if (type === "number") {
214
+ const n = Number(raw)
215
+ if (!Number.isFinite(n)) throw new Error(`Invalid number: ${raw}`)
216
+ return n
217
+ }
218
+ if (type === "boolean") {
219
+ if (raw === "true") return true
220
+ if (raw === "false") return false
221
+ throw new Error(`Invalid boolean: ${raw}`)
222
+ }
223
+ if (type === "json") {
224
+ return JSON.parse(raw)
225
+ }
226
+ if (raw === "true") return true
227
+ if (raw === "false") return false
228
+ if (raw === "null") return null
229
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
230
+ if ((raw.startsWith("{") && raw.endsWith("}")) || (raw.startsWith("[") && raw.endsWith("]"))) {
231
+ try {
232
+ return JSON.parse(raw)
233
+ } catch {
234
+ return raw
235
+ }
236
+ }
237
+ return raw
238
+ }
239
+
240
+ const hasPath = (obj, dottedPath) => {
241
+ const parts = dottedPath.split(".").filter(Boolean)
242
+ let cur = obj
243
+ for (const part of parts) {
244
+ if (!cur || typeof cur !== "object" || !(part in cur)) return false
245
+ cur = cur[part]
246
+ }
247
+ return true
248
+ }
249
+
250
+ const setPath = (obj, dottedPath, value) => {
251
+ const parts = dottedPath.split(".").filter(Boolean)
252
+ if (parts.length === 0) throw new Error("config key is required")
253
+ let cur = obj
254
+ for (let i = 0; i < parts.length - 1; i += 1) {
255
+ const part = parts[i]
256
+ if (!cur[part] || typeof cur[part] !== "object" || Array.isArray(cur[part])) {
257
+ cur[part] = {}
258
+ }
259
+ cur = cur[part]
260
+ }
261
+ cur[parts[parts.length - 1]] = value
262
+ }
263
+
264
+ const readDevVarsConfigJson = (devVarsPath) => {
265
+ if (!fs.existsSync(devVarsPath)) return null
266
+ const lines = fs.readFileSync(devVarsPath, "utf-8").split(/\r?\n/)
267
+ for (const line of lines) {
268
+ if (line.startsWith("ECHO_PDF_CONFIG_JSON=")) {
269
+ const raw = line.slice("ECHO_PDF_CONFIG_JSON=".length).trim()
270
+ if (!raw) return null
271
+ return JSON.parse(raw)
272
+ }
273
+ }
274
+ return null
275
+ }
276
+
277
+ const writeDevVarsConfigJson = (devVarsPath, configJson) => {
278
+ const serialized = JSON.stringify(configJson)
279
+ const nextLine = `ECHO_PDF_CONFIG_JSON=${serialized}`
280
+ let lines = []
281
+ if (fs.existsSync(devVarsPath)) {
282
+ lines = fs.readFileSync(devVarsPath, "utf-8").split(/\r?\n/)
283
+ let replaced = false
284
+ lines = lines.map((line) => {
285
+ if (line.startsWith("ECHO_PDF_CONFIG_JSON=")) {
286
+ replaced = true
287
+ return nextLine
288
+ }
289
+ return line
290
+ })
291
+ if (!replaced) {
292
+ if (lines.length > 0 && lines[lines.length - 1].trim().length !== 0) lines.push("")
293
+ lines.push(nextLine)
294
+ }
295
+ } else {
296
+ lines = [nextLine]
297
+ }
298
+ fs.writeFileSync(devVarsPath, lines.join("\n"))
299
+ }
300
+
301
+ const usage = () => {
302
+ process.stdout.write(`echo-pdf CLI\n\n`)
303
+ process.stdout.write(`Commands:\n`)
304
+ process.stdout.write(` init [--service-url URL]\n`)
305
+ process.stdout.write(` provider set --provider <${PROVIDER_SET_NAMES.join("|")}> --api-key <KEY> [--profile name]\n`)
306
+ process.stdout.write(` provider use --provider <${PROVIDER_ALIASES.join("|")}> [--profile name]\n`)
307
+ process.stdout.write(` provider list [--profile name]\n`)
308
+ process.stdout.write(` models [--provider alias] [--profile name]\n`)
309
+ process.stdout.write(` config set --key <dotted.path> --value <value> [--type auto|string|number|boolean|json] [--dev-vars .dev.vars]\n`)
310
+ process.stdout.write(` model set --model <model-id> [--provider alias] [--profile name]\n`)
311
+ process.stdout.write(` model get [--provider alias] [--profile name]\n`)
312
+ process.stdout.write(` model list [--profile name]\n`)
313
+ process.stdout.write(` tools\n`)
314
+ process.stdout.write(` call --tool <name> --args '<json>' [--provider alias] [--model model] [--profile name]\n`)
315
+ process.stdout.write(` mcp initialize\n`)
316
+ process.stdout.write(` mcp tools\n`)
317
+ process.stdout.write(` mcp call --tool <name> --args '<json>'\n`)
318
+ process.stdout.write(` setup add <claude-desktop|claude-code|cursor|cline|windsurf|gemini|json>\n`)
319
+ }
320
+
321
+ const setupSnippet = (tool, serviceUrl) => {
322
+ const transport = {
323
+ type: "streamable-http",
324
+ url: `${serviceUrl}/mcp`,
325
+ }
326
+ if (tool === "json") {
327
+ return {
328
+ mcpServers: {
329
+ "echo-pdf": transport,
330
+ },
331
+ }
332
+ }
333
+ if (tool === "claude-desktop") {
334
+ return {
335
+ file: "claude_desktop_config.json",
336
+ snippet: {
337
+ mcpServers: {
338
+ "echo-pdf": transport,
339
+ },
340
+ },
341
+ }
342
+ }
343
+ if (tool === "cursor") {
344
+ return {
345
+ file: "~/.cursor/mcp.json",
346
+ snippet: {
347
+ mcpServers: {
348
+ "echo-pdf": transport,
349
+ },
350
+ },
351
+ }
352
+ }
353
+ if (tool === "cline") {
354
+ return {
355
+ file: "~/.cline/mcp_settings.json",
356
+ snippet: {
357
+ mcpServers: {
358
+ "echo-pdf": transport,
359
+ },
360
+ },
361
+ }
362
+ }
363
+ if (tool === "windsurf") {
364
+ return {
365
+ file: "~/.codeium/windsurf/mcp_config.json",
366
+ snippet: {
367
+ mcpServers: {
368
+ "echo-pdf": transport,
369
+ },
370
+ },
371
+ }
372
+ }
373
+ if (tool === "claude-code" || tool === "gemini") {
374
+ return {
375
+ note: "If your tool does not support streamable-http directly, use an HTTP-to-stdio MCP bridge (for example mcp-remote) and point it to /mcp.",
376
+ url: `${serviceUrl}/mcp`,
377
+ }
378
+ }
379
+ throw new Error(`Unsupported tool: ${tool}`)
380
+ }
381
+
382
+ const main = async () => {
383
+ const argv = process.argv.slice(2)
384
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
385
+ usage()
386
+ return
387
+ }
388
+
389
+ const [command, ...raw] = argv
390
+ let subcommand = ""
391
+ let rest = raw
392
+ if (["provider", "mcp", "setup", "model", "config"].includes(command)) {
393
+ subcommand = raw[0] || ""
394
+ rest = raw.slice(1)
395
+ }
396
+ const flags = parseFlags(rest)
397
+
398
+ if (command === "init") {
399
+ const config = loadConfig()
400
+ if (typeof flags["service-url"] === "string") {
401
+ config.serviceUrl = flags["service-url"]
402
+ saveConfig(config)
403
+ }
404
+ print({ ok: true, configFile: CONFIG_FILE, serviceUrl: config.serviceUrl })
405
+ return
406
+ }
407
+
408
+ if (command === "provider" && subcommand === "set") {
409
+ const providerAlias = resolveProviderAliasInput(flags.provider)
410
+ const apiKey = flags["api-key"]
411
+ if (typeof apiKey !== "string") {
412
+ throw new Error("provider set requires --provider and --api-key")
413
+ }
414
+ const config = loadConfig()
415
+ const profileName = getProfileName(config, flags.profile)
416
+ const profile = getProfile(config, profileName)
417
+ if (!profile.providers) profile.providers = {}
418
+ profile.providers[providerAlias] = { apiKey }
419
+ saveConfig(config)
420
+ print({ ok: true, provider: providerAlias, profile: profileName, configFile: CONFIG_FILE })
421
+ return
422
+ }
423
+
424
+ if (command === "provider" && subcommand === "use") {
425
+ const provider = resolveProviderAliasInput(flags.provider)
426
+ const config = loadConfig()
427
+ const profileName = getProfileName(config, flags.profile)
428
+ const profile = getProfile(config, profileName)
429
+ profile.defaultProvider = provider
430
+ saveConfig(config)
431
+ print({ ok: true, profile: profileName, defaultProvider: provider, configFile: CONFIG_FILE })
432
+ return
433
+ }
434
+
435
+ if (command === "provider" && subcommand === "list") {
436
+ const config = loadConfig()
437
+ const profileName = getProfileName(config, flags.profile)
438
+ const profile = getProfile(config, profileName)
439
+ const providers = Object.entries(profile.providers || {}).map(([name, value]) => ({
440
+ provider: name,
441
+ configured: Boolean(value?.apiKey),
442
+ apiKeyPreview: value?.apiKey ? `${String(value.apiKey).slice(0, 6)}...` : "",
443
+ }))
444
+ print({ profile: profileName, defaultProvider: profile.defaultProvider, providers })
445
+ return
446
+ }
447
+
448
+ if (command === "models") {
449
+ const config = loadConfig()
450
+ const profileName = getProfileName(config, flags.profile)
451
+ const profile = getProfile(config, profileName)
452
+ const provider = flags.provider ? resolveProviderAliasInput(flags.provider) : resolveProviderAlias(profile, flags.provider)
453
+ const providerApiKeys = buildProviderApiKeys(config, profileName)
454
+ const data = await postJson(`${config.serviceUrl}/providers/models`, buildModelsRequest(provider, providerApiKeys))
455
+ print(data)
456
+ return
457
+ }
458
+
459
+ if (command === "config" && subcommand === "set") {
460
+ const key = flags.key
461
+ const rawValue = flags.value
462
+ if (typeof key !== "string" || key.trim().length === 0) {
463
+ throw new Error("config set requires --key")
464
+ }
465
+ if (typeof rawValue !== "string") {
466
+ throw new Error("config set requires --value")
467
+ }
468
+ const type = typeof flags.type === "string" ? flags.type : "auto"
469
+ if (!["auto", "string", "number", "boolean", "json"].includes(type)) {
470
+ throw new Error("config set --type must be one of auto|string|number|boolean|json")
471
+ }
472
+ const devVarsPath = typeof flags["dev-vars"] === "string"
473
+ ? path.resolve(process.cwd(), flags["dev-vars"])
474
+ : path.resolve(process.cwd(), ".dev.vars")
475
+
476
+ const baseConfig = readDevVarsConfigJson(devVarsPath) || JSON.parse(JSON.stringify(PROJECT_CONFIG))
477
+ if (!hasPath(PROJECT_CONFIG, key)) {
478
+ throw new Error(`Unknown config key: ${key}`)
479
+ }
480
+ const value = parseConfigValue(rawValue, type)
481
+ setPath(baseConfig, key, value)
482
+ writeDevVarsConfigJson(devVarsPath, baseConfig)
483
+ print({ ok: true, key, value, devVarsPath })
484
+ return
485
+ }
486
+
487
+ if (command === "model" && subcommand === "set") {
488
+ const model = flags.model
489
+ if (typeof model !== "string" || model.length === 0) {
490
+ throw new Error("model set requires --model")
491
+ }
492
+ const config = loadConfig()
493
+ const profileName = getProfileName(config, flags.profile)
494
+ const profile = getProfile(config, profileName)
495
+ const provider = flags.provider ? resolveProviderAliasInput(flags.provider) : resolveProviderAlias(profile, flags.provider)
496
+ profile.models[provider] = model
497
+ saveConfig(config)
498
+ print({ ok: true, profile: profileName, provider, model, configFile: CONFIG_FILE })
499
+ return
500
+ }
501
+
502
+ if (command === "model" && subcommand === "get") {
503
+ const config = loadConfig()
504
+ const profileName = getProfileName(config, flags.profile)
505
+ const profile = getProfile(config, profileName)
506
+ const provider = flags.provider ? resolveProviderAliasInput(flags.provider) : resolveProviderAlias(profile, flags.provider)
507
+ const model = resolveDefaultModel(profile, provider)
508
+ print({ profile: profileName, provider, model })
509
+ return
510
+ }
511
+
512
+ if (command === "model" && subcommand === "list") {
513
+ const config = loadConfig()
514
+ const profileName = getProfileName(config, flags.profile)
515
+ const profile = getProfile(config, profileName)
516
+ print({
517
+ profile: profileName,
518
+ defaultProvider: profile.defaultProvider,
519
+ models: profile.models || {},
520
+ projectDefaultModel: PROJECT_DEFAULT_MODEL,
521
+ })
522
+ return
523
+ }
524
+
525
+ if (command === "tools") {
526
+ const config = loadConfig()
527
+ const response = await fetch(`${config.serviceUrl}/tools/catalog`)
528
+ const data = await response.json()
529
+ if (!response.ok) throw new Error(JSON.stringify(data))
530
+ print(data)
531
+ return
532
+ }
533
+
534
+ if (command === "call") {
535
+ const config = loadConfig()
536
+ const profileName = getProfileName(config, flags.profile)
537
+ const profile = getProfile(config, profileName)
538
+ const tool = flags.tool
539
+ if (typeof tool !== "string") throw new Error("call requires --tool")
540
+ const args = typeof flags.args === "string" ? JSON.parse(flags.args) : {}
541
+ const provider = resolveProviderAlias(profile, flags.provider)
542
+ const model = typeof flags.model === "string" ? flags.model : resolveDefaultModel(profile, provider)
543
+ const providerApiKeys = buildProviderApiKeys(config, profileName)
544
+ const payload = buildToolCallRequest({ tool, args, provider, model, providerApiKeys })
545
+ const data = await postJson(`${config.serviceUrl}/tools/call`, payload)
546
+ print(data)
547
+ return
548
+ }
549
+
550
+ if (command === "mcp" && subcommand === "initialize") {
551
+ const config = loadConfig()
552
+ const data = await postJson(`${config.serviceUrl}/mcp`, buildMcpRequest(1, "initialize"), buildMcpHeaders())
553
+ print(data)
554
+ return
555
+ }
556
+
557
+ if (command === "mcp" && subcommand === "tools") {
558
+ const config = loadConfig()
559
+ const data = await postJson(`${config.serviceUrl}/mcp`, buildMcpRequest(2, "tools/list"), buildMcpHeaders())
560
+ print(data)
561
+ return
562
+ }
563
+
564
+ if (command === "mcp" && subcommand === "call") {
565
+ const config = loadConfig()
566
+ const tool = flags.tool
567
+ if (typeof tool !== "string") throw new Error("mcp call requires --tool")
568
+ const args = typeof flags.args === "string" ? JSON.parse(flags.args) : {}
569
+ const data = await postJson(
570
+ `${config.serviceUrl}/mcp`,
571
+ buildMcpRequest(3, "tools/call", { name: tool, arguments: args }),
572
+ buildMcpHeaders()
573
+ )
574
+ print(data)
575
+ return
576
+ }
577
+
578
+ if (command === "setup" && subcommand === "add") {
579
+ const tool = rest[0]
580
+ if (!tool) throw new Error("setup add requires tool name")
581
+ const config = loadConfig()
582
+ print(setupSnippet(tool, config.serviceUrl))
583
+ return
584
+ }
585
+
586
+ usage()
587
+ process.exitCode = 1
588
+ }
589
+
590
+ main().catch((error) => {
591
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
592
+ process.exitCode = 1
593
+ })
@@ -0,0 +1,58 @@
1
+ {
2
+ "service": {
3
+ "name": "echo-pdf",
4
+ "maxPdfBytes": 10000000,
5
+ "maxPagesPerRequest": 20,
6
+ "defaultRenderScale": 2,
7
+ "storage": {
8
+ "maxFileBytes": 10000000,
9
+ "maxTotalBytes": 52428800,
10
+ "ttlHours": 24,
11
+ "cleanupBatchSize": 50
12
+ }
13
+ },
14
+ "pdfium": {
15
+ "wasmUrl": "https://cdn.jsdelivr.net/npm/@embedpdf/pdfium@2.7.0/dist/pdfium.wasm"
16
+ },
17
+ "agent": {
18
+ "defaultProvider": "openai",
19
+ "defaultModel": "",
20
+ "ocrPrompt": "Extract readable text from this PDF page image. Preserve line breaks and section structure.",
21
+ "tablePrompt": "Detect all tabular structures from this PDF page image. Output only valid LaTeX tabular environments, no explanations, no markdown fences."
22
+ },
23
+ "providers": {
24
+ "openai": {
25
+ "type": "openai",
26
+ "apiKeyEnv": "OPENAI_API_KEY",
27
+ "baseUrl": "https://api.openai.com/v1",
28
+ "endpoints": {
29
+ "chatCompletionsPath": "/chat/completions",
30
+ "modelsPath": "/models"
31
+ }
32
+ },
33
+ "vercel_gateway": {
34
+ "type": "vercel-ai-gateway",
35
+ "apiKeyEnv": "VERCEL_AI_GATEWAY_API_KEY",
36
+ "baseUrl": "https://ai-gateway.vercel.sh/v1",
37
+ "endpoints": {
38
+ "chatCompletionsPath": "/chat/completions",
39
+ "modelsPath": "/models"
40
+ }
41
+ },
42
+ "openrouter": {
43
+ "type": "openrouter",
44
+ "apiKeyEnv": "OPENROUTER_KEY",
45
+ "baseUrl": "https://openrouter.ai/api/v1",
46
+ "endpoints": {
47
+ "chatCompletionsPath": "/chat/completions",
48
+ "modelsPath": "/models"
49
+ }
50
+ }
51
+ },
52
+ "mcp": {
53
+ "serverName": "echo-pdf-mcp",
54
+ "version": "0.1.0",
55
+ "authHeader": "x-mcp-key",
56
+ "authEnv": "ECHO_PDF_MCP_KEY"
57
+ }
58
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@echofiles/echo-pdf",
3
+ "description": "MCP-first PDF agent on Cloudflare Workers with CLI and web demo.",
4
+ "version": "0.2.0",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "echo-pdf": "./bin/echo-pdf.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "scripts",
16
+ "README.md",
17
+ "wrangler.toml",
18
+ "echo-pdf.config.json"
19
+ ],
20
+ "scripts": {
21
+ "check:runtime": "bash ./scripts/check-runtime.sh",
22
+ "dev": "wrangler dev",
23
+ "deploy": "wrangler deploy",
24
+ "typecheck": "npm run check:runtime && tsc --noEmit",
25
+ "test:unit": "npm run check:runtime && vitest run tests/unit",
26
+ "test:integration": "npm run check:runtime && vitest run tests/integration",
27
+ "test": "npm run test:unit && npm run test:integration",
28
+ "smoke": "bash ./scripts/smoke.sh",
29
+ "prepublishOnly": "npm run typecheck && npm run test"
30
+ },
31
+ "engines": {
32
+ "node": ">=20.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@cloudflare/workers-types": "^4.20260301.0",
36
+ "typescript": "^5.7.3",
37
+ "vitest": "^2.1.9",
38
+ "wrangler": "^4.8.0"
39
+ },
40
+ "dependencies": {
41
+ "@cf-wasm/png": "^0.3.2",
42
+ "@embedpdf/pdfium": "^2.7.0"
43
+ }
44
+ }