@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.
- package/README.md +240 -0
- package/bin/echo-pdf.js +593 -0
- package/echo-pdf.config.json +58 -0
- package/package.json +44 -0
- package/scripts/check-runtime.sh +26 -0
- package/scripts/export-fixtures.sh +204 -0
- package/scripts/smoke.sh +14 -0
- package/src/agent-defaults.ts +25 -0
- package/src/file-ops.ts +52 -0
- package/src/file-store-do.ts +340 -0
- package/src/file-utils.ts +43 -0
- package/src/index.ts +334 -0
- package/src/mcp-server.ts +109 -0
- package/src/pdf-agent.ts +224 -0
- package/src/pdf-config.ts +105 -0
- package/src/pdf-storage.ts +94 -0
- package/src/pdf-types.ts +79 -0
- package/src/pdfium-engine.ts +207 -0
- package/src/provider-client.ts +176 -0
- package/src/provider-keys.ts +44 -0
- package/src/tool-registry.ts +203 -0
- package/src/types.ts +39 -0
- package/src/wasm.d.ts +4 -0
- package/wrangler.toml +15 -0
package/bin/echo-pdf.js
ADDED
|
@@ -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
|
+
}
|