@echofiles/echo-pdf 0.5.0 → 0.6.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.
Files changed (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +85 -562
  3. package/bin/echo-pdf.js +130 -525
  4. package/dist/file-utils.d.ts +0 -3
  5. package/dist/file-utils.js +0 -18
  6. package/dist/local/document.d.ts +10 -0
  7. package/dist/local/document.js +133 -0
  8. package/dist/local/index.d.ts +3 -135
  9. package/dist/local/index.js +2 -555
  10. package/dist/local/semantic.d.ts +2 -0
  11. package/dist/local/semantic.js +231 -0
  12. package/dist/local/shared.d.ts +50 -0
  13. package/dist/local/shared.js +173 -0
  14. package/dist/local/types.d.ts +183 -0
  15. package/dist/local/types.js +2 -0
  16. package/dist/node/pdfium-local.js +30 -6
  17. package/dist/pdf-config.js +2 -65
  18. package/dist/pdf-types.d.ts +1 -58
  19. package/dist/types.d.ts +1 -87
  20. package/echo-pdf.config.json +1 -21
  21. package/package.json +25 -22
  22. package/bin/lib/http.js +0 -97
  23. package/bin/lib/mcp-stdio.js +0 -99
  24. package/dist/auth.d.ts +0 -18
  25. package/dist/auth.js +0 -36
  26. package/dist/core/index.d.ts +0 -50
  27. package/dist/core/index.js +0 -7
  28. package/dist/file-ops.d.ts +0 -11
  29. package/dist/file-ops.js +0 -36
  30. package/dist/file-store-do.d.ts +0 -36
  31. package/dist/file-store-do.js +0 -298
  32. package/dist/http-error.d.ts +0 -9
  33. package/dist/http-error.js +0 -14
  34. package/dist/index.d.ts +0 -1
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-server.d.ts +0 -3
  37. package/dist/mcp-server.js +0 -124
  38. package/dist/node/semantic-local.d.ts +0 -16
  39. package/dist/node/semantic-local.js +0 -113
  40. package/dist/pdf-agent.d.ts +0 -18
  41. package/dist/pdf-agent.js +0 -217
  42. package/dist/pdf-storage.d.ts +0 -8
  43. package/dist/pdf-storage.js +0 -86
  44. package/dist/pdfium-engine.d.ts +0 -9
  45. package/dist/pdfium-engine.js +0 -180
  46. package/dist/r2-file-store.d.ts +0 -20
  47. package/dist/r2-file-store.js +0 -176
  48. package/dist/response-schema.d.ts +0 -15
  49. package/dist/response-schema.js +0 -159
  50. package/dist/tool-registry.d.ts +0 -16
  51. package/dist/tool-registry.js +0 -175
  52. package/dist/worker.d.ts +0 -7
  53. package/dist/worker.js +0 -386
  54. package/scripts/export-fixtures.sh +0 -204
  55. package/wrangler.toml +0 -19
package/bin/echo-pdf.js CHANGED
@@ -1,11 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process"
3
2
  import fs from "node:fs"
4
3
  import os from "node:os"
5
4
  import path from "node:path"
6
5
  import { fileURLToPath } from "node:url"
7
- import { downloadFile, postJson, prepareArgsWithLocalUploads, uploadFile, withUploadedLocalFile } from "./lib/http.js"
8
- import { runMcpStdio } from "./lib/mcp-stdio.js"
9
6
 
10
7
  const CONFIG_DIR = path.join(os.homedir(), ".config", "echo-pdf-cli")
11
8
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
@@ -14,16 +11,9 @@ const PROJECT_CONFIG_FILE = path.resolve(__dirname, "../echo-pdf.config.json")
14
11
  const PROJECT_CONFIG = JSON.parse(fs.readFileSync(PROJECT_CONFIG_FILE, "utf-8"))
15
12
  const PROVIDER_ENTRIES = Object.entries(PROJECT_CONFIG.providers || {})
16
13
  const PROVIDER_ALIASES = PROVIDER_ENTRIES.map(([alias]) => alias)
17
- const PROVIDER_ALIAS_BY_TYPE = new Map(
18
- PROVIDER_ENTRIES.map(([alias, provider]) => [provider.type, alias])
19
- )
20
- const PROVIDER_SET_NAMES = Array.from(
21
- new Set(PROVIDER_ENTRIES.flatMap(([alias, provider]) => [alias, provider.type]))
22
- )
14
+ const PROVIDER_ALIAS_BY_TYPE = new Map(PROVIDER_ENTRIES.map(([alias, provider]) => [provider.type, alias]))
15
+ const PROVIDER_SET_NAMES = Array.from(new Set(PROVIDER_ENTRIES.flatMap(([alias, provider]) => [alias, provider.type])))
23
16
  const PROJECT_DEFAULT_MODEL = String(PROJECT_CONFIG.agent?.defaultModel || "").trim()
24
- const DEFAULT_WORKER_NAME = process.env.ECHO_PDF_WORKER_NAME || PROJECT_CONFIG.service?.name || "echo-pdf"
25
- const DEFAULT_SERVICE_URL = process.env.ECHO_PDF_SERVICE_URL || `https://${DEFAULT_WORKER_NAME}.echofilesai.workers.dev`
26
- const DEFAULT_MCP_HEADER = process.env.ECHO_PDF_MCP_HEADER?.trim() || PROJECT_CONFIG.mcp?.authHeader || "x-mcp-key"
27
17
 
28
18
  const emptyProviders = () =>
29
19
  Object.fromEntries(PROVIDER_ALIASES.map((providerAlias) => [providerAlias, { apiKey: "" }]))
@@ -50,7 +40,6 @@ function resolveDefaultProviderAlias() {
50
40
  const DEFAULT_PROVIDER_ALIAS = resolveDefaultProviderAlias()
51
41
 
52
42
  const defaultConfig = () => ({
53
- serviceUrl: DEFAULT_SERVICE_URL,
54
43
  profile: "default",
55
44
  profiles: {
56
45
  default: {
@@ -68,56 +57,11 @@ const ensureConfig = () => {
68
57
  }
69
58
  }
70
59
 
71
- const loadConfig = () => {
72
- ensureConfig()
73
- const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"))
74
- if (!config.profiles || typeof config.profiles !== "object") {
75
- config.profiles = {}
76
- }
77
- if (typeof config.profile !== "string" || !config.profile) {
78
- config.profile = "default"
79
- }
80
- const profile = getProfile(config, config.profile)
81
- if (typeof profile.defaultProvider !== "string" || !profile.defaultProvider) {
82
- profile.defaultProvider = DEFAULT_PROVIDER_ALIAS
83
- }
84
- if (!profile.providers || typeof profile.providers !== "object") {
85
- profile.providers = emptyProviders()
86
- }
87
- for (const providerAlias of PROVIDER_ALIASES) {
88
- if (!profile.providers[providerAlias] || typeof profile.providers[providerAlias] !== "object") {
89
- profile.providers[providerAlias] = { apiKey: "" }
90
- }
91
- }
92
- if (!profile.models || typeof profile.models !== "object") {
93
- profile.models = {}
94
- }
95
- saveConfig(config)
96
- return config
97
- }
98
-
99
60
  const saveConfig = (config) => {
100
61
  fs.mkdirSync(CONFIG_DIR, { recursive: true })
101
62
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
102
63
  }
103
64
 
104
- const parseFlags = (args) => {
105
- const flags = {}
106
- for (let i = 0; i < args.length; i += 1) {
107
- const token = args[i]
108
- if (!token?.startsWith("--")) continue
109
- const key = token.slice(2)
110
- const next = args[i + 1]
111
- if (!next || next.startsWith("--")) {
112
- flags[key] = true
113
- } else {
114
- flags[key] = next
115
- i += 1
116
- }
117
- }
118
- return flags
119
- }
120
-
121
65
  const getProfile = (config, name) => {
122
66
  const profileName = name || config.profile || "default"
123
67
  if (!config.profiles[profileName]) {
@@ -141,6 +85,33 @@ const getProfile = (config, name) => {
141
85
  return profile
142
86
  }
143
87
 
88
+ const loadConfig = () => {
89
+ ensureConfig()
90
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"))
91
+ if (!config.profiles || typeof config.profiles !== "object") config.profiles = {}
92
+ if (typeof config.profile !== "string" || !config.profile) config.profile = "default"
93
+ getProfile(config, config.profile)
94
+ saveConfig(config)
95
+ return config
96
+ }
97
+
98
+ const parseFlags = (args) => {
99
+ const flags = {}
100
+ for (let i = 0; i < args.length; i += 1) {
101
+ const token = args[i]
102
+ if (!token?.startsWith("--")) continue
103
+ const key = token.slice(2)
104
+ const next = args[i + 1]
105
+ if (!next || next.startsWith("--")) {
106
+ flags[key] = true
107
+ } else {
108
+ flags[key] = next
109
+ i += 1
110
+ }
111
+ }
112
+ return flags
113
+ }
114
+
144
115
  const getProfileName = (config, profileName) => profileName || config.profile || "default"
145
116
 
146
117
  const resolveProviderAlias = (profile, explicitProvider) =>
@@ -154,6 +125,25 @@ const resolveDefaultModel = (profile, providerAlias) => {
154
125
  return PROJECT_DEFAULT_MODEL
155
126
  }
156
127
 
128
+ const readEnvApiKey = (providerAlias) => {
129
+ const providerConfig = PROJECT_CONFIG.providers?.[providerAlias]
130
+ const keyName = providerConfig?.apiKeyEnv
131
+ if (typeof keyName !== "string" || keyName.trim().length === 0) return ""
132
+ const read = (name) => {
133
+ const value = process.env[name]
134
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : ""
135
+ }
136
+ const direct = read(keyName)
137
+ if (direct) return direct
138
+ if (keyName.endsWith("_API_KEY")) {
139
+ return read(keyName.replace(/_API_KEY$/, "_KEY"))
140
+ }
141
+ if (keyName.endsWith("_KEY")) {
142
+ return read(keyName.replace(/_KEY$/, "_API_KEY"))
143
+ }
144
+ return ""
145
+ }
146
+
157
147
  const buildProviderApiKeys = (config, profileName) => {
158
148
  const profile = getProfile(config, profileName)
159
149
  const providerApiKeys = {}
@@ -165,165 +155,41 @@ const buildProviderApiKeys = (config, profileName) => {
165
155
  return providerApiKeys
166
156
  }
167
157
 
168
- const print = (data) => {
169
- process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
170
- }
171
-
172
- const buildMcpHeaders = () => {
173
- const token = process.env.ECHO_PDF_MCP_KEY?.trim()
174
- if (!token) return {}
175
- return { [DEFAULT_MCP_HEADER]: token }
176
- }
177
-
178
- const buildModelsRequest = (provider, providerApiKeys) => ({ provider, providerApiKeys })
179
-
180
- const buildToolCallRequest = (input) => ({
181
- name: input.tool,
182
- arguments: input.args,
183
- provider: input.provider,
184
- model: input.model,
185
- providerApiKeys: input.providerApiKeys,
186
- })
187
-
188
- const buildMcpRequest = (id, method, params = {}) => ({
189
- jsonrpc: "2.0",
190
- id,
191
- method,
192
- params,
193
- })
194
-
195
- const runDevServer = (port, host) => {
196
- const wranglerBin = path.resolve(__dirname, "../node_modules/.bin/wrangler")
197
- const wranglerArgs = ["dev", "--port", String(port), "--ip", host]
198
- const cmd = fs.existsSync(wranglerBin) ? wranglerBin : "npx"
199
- const args = fs.existsSync(wranglerBin) ? wranglerArgs : ["-y", "wrangler", ...wranglerArgs]
200
- const child = spawn(cmd, args, {
201
- stdio: "inherit",
202
- env: process.env,
203
- cwd: process.cwd(),
204
- })
205
- child.on("exit", (code, signal) => {
206
- if (signal) process.kill(process.pid, signal)
207
- process.exit(code ?? 0)
208
- })
209
- }
210
-
211
- const printLocalServiceHints = (host, port) => {
212
- const resolvedHost = host === "0.0.0.0" ? "127.0.0.1" : host
213
- const baseUrl = `http://${resolvedHost}:${port}`
214
- const mcpUrl = `${baseUrl}/mcp`
215
- process.stdout.write(`\nLocal component endpoints:\n`)
216
- process.stdout.write(` ECHO_PDF_BASE_URL=${baseUrl}\n`)
217
- process.stdout.write(` ECHO_PDF_MCP_URL=${mcpUrl}\n`)
218
- process.stdout.write(`\nExport snippet:\n`)
219
- process.stdout.write(` export ECHO_PDF_BASE_URL=${baseUrl}\n`)
220
- process.stdout.write(` export ECHO_PDF_MCP_URL=${mcpUrl}\n\n`)
221
- }
222
-
223
- const runMcpStdioCommand = async (serviceUrlOverride) => {
158
+ const resolveLocalSemanticContext = (flags) => {
224
159
  const config = loadConfig()
225
- const serviceUrl = typeof serviceUrlOverride === "string" && serviceUrlOverride.trim().length > 0
226
- ? serviceUrlOverride.trim()
227
- : config.serviceUrl
228
- await runMcpStdio({
229
- serviceUrl,
230
- headers: buildMcpHeaders(),
231
- postJson,
232
- withUploadedLocalFile,
233
- })
234
- }
235
-
236
- const parseConfigValue = (raw, type = "auto") => {
237
- if (type === "string") return String(raw)
238
- if (type === "number") {
239
- const n = Number(raw)
240
- if (!Number.isFinite(n)) throw new Error(`Invalid number: ${raw}`)
241
- return n
242
- }
243
- if (type === "boolean") {
244
- if (raw === "true") return true
245
- if (raw === "false") return false
246
- throw new Error(`Invalid boolean: ${raw}`)
247
- }
248
- if (type === "json") {
249
- return JSON.parse(raw)
250
- }
251
- if (raw === "true") return true
252
- if (raw === "false") return false
253
- if (raw === "null") return null
254
- if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
255
- if ((raw.startsWith("{") && raw.endsWith("}")) || (raw.startsWith("[") && raw.endsWith("]"))) {
256
- try {
257
- return JSON.parse(raw)
258
- } catch {
259
- return raw
260
- }
261
- }
262
- return raw
263
- }
264
-
265
- const hasPath = (obj, dottedPath) => {
266
- const parts = dottedPath.split(".").filter(Boolean)
267
- let cur = obj
268
- for (const part of parts) {
269
- if (!cur || typeof cur !== "object" || !(part in cur)) return false
270
- cur = cur[part]
271
- }
272
- return true
273
- }
274
-
275
- const setPath = (obj, dottedPath, value) => {
276
- const parts = dottedPath.split(".").filter(Boolean)
277
- if (parts.length === 0) throw new Error("config key is required")
278
- let cur = obj
279
- for (let i = 0; i < parts.length - 1; i += 1) {
280
- const part = parts[i]
281
- if (!cur[part] || typeof cur[part] !== "object" || Array.isArray(cur[part])) {
282
- cur[part] = {}
283
- }
284
- cur = cur[part]
160
+ const profileName = getProfileName(config, flags.profile)
161
+ const profile = getProfile(config, profileName)
162
+ const provider = resolveProviderAlias(profile, flags.provider)
163
+ const model = typeof flags.model === "string" ? flags.model.trim() : resolveDefaultModel(profile, provider)
164
+ if (!model) {
165
+ throw new Error(
166
+ [
167
+ `semantic requires a configured model for provider "${provider}".`,
168
+ `Pass \`--model <model-id>\`, or run \`echo-pdf model set --provider ${provider} --model <model-id>${profileName ? ` --profile ${profileName}` : ""}\`.`,
169
+ ].join(" ")
170
+ )
285
171
  }
286
- cur[parts[parts.length - 1]] = value
287
- }
288
-
289
- const readDevVarsConfigJson = (devVarsPath) => {
290
- if (!fs.existsSync(devVarsPath)) return null
291
- const lines = fs.readFileSync(devVarsPath, "utf-8").split(/\r?\n/)
292
- for (const line of lines) {
293
- if (line.startsWith("ECHO_PDF_CONFIG_JSON=")) {
294
- const raw = line.slice("ECHO_PDF_CONFIG_JSON=".length).trim()
295
- if (!raw) return null
296
- return JSON.parse(raw)
297
- }
172
+ const providerApiKeys = buildProviderApiKeys(config, profileName)
173
+ const configuredApiKey = typeof providerApiKeys[provider] === "string" ? providerApiKeys[provider].trim() : ""
174
+ if (!configuredApiKey && !readEnvApiKey(provider)) {
175
+ const apiKeyEnv = PROJECT_CONFIG.providers?.[provider]?.apiKeyEnv || "PROVIDER_API_KEY"
176
+ throw new Error(
177
+ [
178
+ `semantic requires an API key for provider "${provider}".`,
179
+ `Run \`echo-pdf provider set --provider ${provider} --api-key <KEY>${profileName ? ` --profile ${profileName}` : ""}\``,
180
+ `or export \`${apiKeyEnv}\` before running the VL-first semantic path.`,
181
+ ].join(" ")
182
+ )
298
183
  }
299
- return null
184
+ return { provider, model, providerApiKeys }
300
185
  }
301
186
 
302
- const writeDevVarsConfigJson = (devVarsPath, configJson) => {
303
- const serialized = JSON.stringify(configJson)
304
- const nextLine = `ECHO_PDF_CONFIG_JSON=${serialized}`
305
- let lines = []
306
- if (fs.existsSync(devVarsPath)) {
307
- lines = fs.readFileSync(devVarsPath, "utf-8").split(/\r?\n/)
308
- let replaced = false
309
- lines = lines.map((line) => {
310
- if (line.startsWith("ECHO_PDF_CONFIG_JSON=")) {
311
- replaced = true
312
- return nextLine
313
- }
314
- return line
315
- })
316
- if (!replaced) {
317
- if (lines.length > 0 && lines[lines.length - 1].trim().length !== 0) lines.push("")
318
- lines.push(nextLine)
319
- }
320
- } else {
321
- lines = [nextLine]
322
- }
323
- fs.writeFileSync(devVarsPath, lines.join("\n"))
187
+ const print = (data) => {
188
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
324
189
  }
325
190
 
326
191
  const LOCAL_DOCUMENT_DIST_ENTRY = new URL("../dist/local/index.js", import.meta.url)
192
+ const LOCAL_DOCUMENT_DIST_PATH = fileURLToPath(LOCAL_DOCUMENT_DIST_ENTRY)
327
193
  const LOCAL_DOCUMENT_SOURCE_ENTRY = new URL("../src/local/index.ts", import.meta.url)
328
194
  const IS_BUN_RUNTIME = typeof process.versions?.bun === "string"
329
195
  const SHOULD_PREFER_SOURCE_DOCUMENT_API = process.env.ECHO_PDF_SOURCE_DEV === "1"
@@ -334,45 +200,54 @@ const loadLocalDocumentApi = async () => {
334
200
  return import(LOCAL_DOCUMENT_SOURCE_ENTRY.href)
335
201
  }
336
202
  throw new Error(
337
- "Source-checkout document dev mode requires Bun and src/local/index.ts. " +
338
- "Use `npm run document:dev -- <command> ...` from a source checkout."
203
+ "Internal source-checkout CLI dev mode requires Bun and src/local/index.ts. " +
204
+ "Use `npm run cli:dev -- <primitive> ...` only from a source checkout."
339
205
  )
340
206
  }
341
- try {
342
- return await import(LOCAL_DOCUMENT_DIST_ENTRY.href)
343
- } catch (error) {
344
- const code = error && typeof error === "object" ? error.code : ""
345
- if (code === "ERR_MODULE_NOT_FOUND") {
346
- throw new Error(
347
- "Local document commands require built artifacts in a source checkout. " +
348
- "Run `npm run build` first, use `npm run document:dev -- <command> ...` in a source checkout, or install the published package."
349
- )
350
- }
351
- throw error
207
+ if (!fs.existsSync(LOCAL_DOCUMENT_DIST_PATH)) {
208
+ throw new Error(
209
+ "Local primitive commands require built artifacts in a source checkout. " +
210
+ "Run `npm run build` first, use the internal `npm run cli:dev -- <primitive> ...` path in this repo, or install the published package."
211
+ )
352
212
  }
213
+ return import(LOCAL_DOCUMENT_DIST_ENTRY.href)
353
214
  }
354
215
 
355
- const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render", "ocr"]
356
- const LEGACY_DOCUMENT_SUBCOMMANDS = ["index", "get", "structure", "semantic", "page", "render", "ocr"]
216
+ const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render"]
217
+ const REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE = {
218
+ index: "document",
219
+ get: "document",
220
+ structure: "structure",
221
+ semantic: "semantic",
222
+ page: "page",
223
+ render: "render",
224
+ }
357
225
 
358
- const isLegacyDocumentSubcommand = (value) => typeof value === "string" && LEGACY_DOCUMENT_SUBCOMMANDS.includes(value)
226
+ const isRemovedDocumentAlias = (value) =>
227
+ typeof value === "string" && Object.hasOwn(REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE, value)
228
+
229
+ const removedDocumentAliasMessage = (alias) => {
230
+ const primitive = REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE[alias]
231
+ return `Legacy \`document ${alias}\` was removed. Use \`echo-pdf ${primitive} <file.pdf>\` instead.`
232
+ }
359
233
 
360
234
  const readDocumentPrimitiveArgs = (command, subcommand, rest) => {
361
- if (command === "document" && isLegacyDocumentSubcommand(subcommand)) {
362
- const primitive = subcommand === "index" || subcommand === "get" ? "document" : subcommand
235
+ if (command === "document") {
236
+ if (isRemovedDocumentAlias(subcommand) && typeof rest[0] === "string" && !rest[0].startsWith("--")) {
237
+ throw new Error(removedDocumentAliasMessage(subcommand))
238
+ }
363
239
  return {
364
- primitive,
365
- pdfPath: rest[0],
240
+ primitive: "document",
241
+ pdfPath: subcommand,
366
242
  }
367
243
  }
368
244
  return {
369
245
  primitive: command,
370
- pdfPath: command === "document" ? subcommand : rest[0],
246
+ pdfPath: rest[0],
371
247
  }
372
248
  }
373
249
 
374
250
  const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
375
- const local = await loadLocalDocumentApi()
376
251
  const { primitive, pdfPath } = readDocumentPrimitiveArgs(command, subcommand, rest)
377
252
  const workspaceDir = typeof flags.workspace === "string" ? flags.workspace : undefined
378
253
  const forceRefresh = flags["force-refresh"] === true
@@ -383,26 +258,28 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
383
258
  }
384
259
 
385
260
  if (primitive === "document") {
386
- const data = await local.get_document({ pdfPath, workspaceDir, forceRefresh })
387
- print(data)
261
+ const local = await loadLocalDocumentApi()
262
+ print(await local.get_document({ pdfPath, workspaceDir, forceRefresh }))
388
263
  return
389
264
  }
390
265
 
391
266
  if (primitive === "structure") {
392
- const data = await local.get_document_structure({ pdfPath, workspaceDir, forceRefresh })
393
- print(data)
267
+ const local = await loadLocalDocumentApi()
268
+ print(await local.get_document_structure({ pdfPath, workspaceDir, forceRefresh }))
394
269
  return
395
270
  }
396
271
 
397
272
  if (primitive === "semantic") {
398
- const data = await local.get_semantic_document_structure({
273
+ const semanticContext = resolveLocalSemanticContext(flags)
274
+ const local = await loadLocalDocumentApi()
275
+ print(await local.get_semantic_document_structure({
399
276
  pdfPath,
400
277
  workspaceDir,
401
278
  forceRefresh,
402
- provider: typeof flags.provider === "string" ? flags.provider : undefined,
403
- model: typeof flags.model === "string" ? flags.model : undefined,
404
- })
405
- print(data)
279
+ provider: semanticContext.provider,
280
+ model: semanticContext.model,
281
+ providerApiKeys: semanticContext.providerApiKeys,
282
+ }))
406
283
  return
407
284
  }
408
285
 
@@ -412,29 +289,14 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
412
289
  }
413
290
 
414
291
  if (primitive === "page") {
415
- const data = await local.get_page_content({ pdfPath, workspaceDir, forceRefresh, pageNumber })
416
- print(data)
292
+ const local = await loadLocalDocumentApi()
293
+ print(await local.get_page_content({ pdfPath, workspaceDir, forceRefresh, pageNumber }))
417
294
  return
418
295
  }
419
296
 
420
297
  if (primitive === "render") {
421
- const data = await local.get_page_render({ pdfPath, workspaceDir, forceRefresh, pageNumber, renderScale })
422
- print(data)
423
- return
424
- }
425
-
426
- if (primitive === "ocr") {
427
- const data = await local.get_page_ocr({
428
- pdfPath,
429
- workspaceDir,
430
- forceRefresh,
431
- pageNumber,
432
- renderScale,
433
- provider: typeof flags.provider === "string" ? flags.provider : undefined,
434
- model: typeof flags.model === "string" ? flags.model : undefined,
435
- prompt: typeof flags.prompt === "string" ? flags.prompt : undefined,
436
- })
437
- print(data)
298
+ const local = await loadLocalDocumentApi()
299
+ print(await local.get_page_render({ pdfPath, workspaceDir, forceRefresh, pageNumber, renderScale }))
438
300
  return
439
301
  }
440
302
 
@@ -443,114 +305,19 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
443
305
 
444
306
  const usage = () => {
445
307
  process.stdout.write(`echo-pdf CLI\n\n`)
446
- process.stdout.write(`Commands:\n`)
308
+ process.stdout.write(`Primary local primitive commands:\n`)
447
309
  process.stdout.write(` document <file.pdf> [--workspace DIR] [--force-refresh]\n`)
448
310
  process.stdout.write(` structure <file.pdf> [--workspace DIR] [--force-refresh]\n`)
449
- process.stdout.write(` semantic <file.pdf> [--provider alias] [--model model] [--workspace DIR] [--force-refresh]\n`)
311
+ process.stdout.write(` semantic <file.pdf> [--provider alias] [--model model] [--profile name] [--workspace DIR] [--force-refresh]\n`)
450
312
  process.stdout.write(` page <file.pdf> --page <N> [--workspace DIR] [--force-refresh]\n`)
451
313
  process.stdout.write(` render <file.pdf> --page <N> [--scale N] [--workspace DIR] [--force-refresh]\n`)
452
- process.stdout.write(` ocr <file.pdf> --page <N> [--scale N] [--provider alias] [--model model] [--prompt text] [--workspace DIR] [--force-refresh]\n`)
453
- process.stdout.write(`\nCompatibility / existing service commands:\n`)
454
- process.stdout.write(` init [--service-url URL]\n`)
455
- process.stdout.write(` dev [--port 8788] [--host 127.0.0.1]\n`)
314
+ process.stdout.write(`\nLocal config commands:\n`)
456
315
  process.stdout.write(` provider set --provider <${PROVIDER_SET_NAMES.join("|")}> --api-key <KEY> [--profile name]\n`)
457
316
  process.stdout.write(` provider use --provider <${PROVIDER_ALIASES.join("|")}> [--profile name]\n`)
458
317
  process.stdout.write(` provider list [--profile name]\n`)
459
- process.stdout.write(` models [--provider alias] [--profile name]\n`)
460
- process.stdout.write(` config set --key <dotted.path> --value <value> [--type auto|string|number|boolean|json] [--dev-vars .dev.vars]\n`)
461
318
  process.stdout.write(` model set --model <model-id> [--provider alias] [--profile name]\n`)
462
319
  process.stdout.write(` model get [--provider alias] [--profile name]\n`)
463
320
  process.stdout.write(` model list [--profile name]\n`)
464
- process.stdout.write(` tools\n`)
465
- process.stdout.write(` call --tool <name> --args '<json>' [--provider alias] [--model model] [--profile name] [--auto-upload]\n`)
466
- process.stdout.write(` document get <file.pdf> [--workspace DIR] [--force-refresh]\n`)
467
- process.stdout.write(` document structure <file.pdf> [--workspace DIR] [--force-refresh]\n`)
468
- process.stdout.write(` document semantic <file.pdf> [--provider alias] [--model model] [--workspace DIR] [--force-refresh]\n`)
469
- process.stdout.write(` document page <file.pdf> --page <N> [--workspace DIR] [--force-refresh]\n`)
470
- process.stdout.write(` document render <file.pdf> --page <N> [--scale N] [--workspace DIR] [--force-refresh]\n`)
471
- process.stdout.write(` document ocr <file.pdf> --page <N> [--scale N] [--provider alias] [--model model] [--prompt text] [--workspace DIR] [--force-refresh]\n`)
472
- process.stdout.write(` file upload <local.pdf>\n`)
473
- process.stdout.write(` file get --file-id <id> --out <path>\n`)
474
- process.stdout.write(` mcp initialize\n`)
475
- process.stdout.write(` mcp tools\n`)
476
- process.stdout.write(` mcp call --tool <name> --args '<json>'\n`)
477
- process.stdout.write(` mcp-stdio [--service-url URL]\n`)
478
- process.stdout.write(` mcp stdio\n`)
479
- process.stdout.write(` setup add <claude-desktop|claude-code|cursor|cline|windsurf|gemini|json>\n`)
480
- }
481
-
482
- const setupSnippet = (tool, serviceUrl, mode = "http") => {
483
- if (mode === "stdio") {
484
- return {
485
- mcpServers: {
486
- "echo-pdf": {
487
- command: "echo-pdf",
488
- args: ["mcp-stdio"],
489
- env: {
490
- ECHO_PDF_SERVICE_URL: serviceUrl,
491
- },
492
- },
493
- },
494
- }
495
- }
496
- const transport = {
497
- type: "streamable-http",
498
- url: `${serviceUrl}/mcp`,
499
- }
500
- if (tool === "json") {
501
- return {
502
- mcpServers: {
503
- "echo-pdf": transport,
504
- },
505
- }
506
- }
507
- if (tool === "claude-desktop") {
508
- return {
509
- file: "claude_desktop_config.json",
510
- snippet: {
511
- mcpServers: {
512
- "echo-pdf": transport,
513
- },
514
- },
515
- }
516
- }
517
- if (tool === "cursor") {
518
- return {
519
- file: "~/.cursor/mcp.json",
520
- snippet: {
521
- mcpServers: {
522
- "echo-pdf": transport,
523
- },
524
- },
525
- }
526
- }
527
- if (tool === "cline") {
528
- return {
529
- file: "~/.cline/mcp_settings.json",
530
- snippet: {
531
- mcpServers: {
532
- "echo-pdf": transport,
533
- },
534
- },
535
- }
536
- }
537
- if (tool === "windsurf") {
538
- return {
539
- file: "~/.codeium/windsurf/mcp_config.json",
540
- snippet: {
541
- mcpServers: {
542
- "echo-pdf": transport,
543
- },
544
- },
545
- }
546
- }
547
- if (tool === "claude-code" || tool === "gemini") {
548
- return {
549
- 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.",
550
- url: `${serviceUrl}/mcp`,
551
- }
552
- }
553
- throw new Error(`Unsupported tool: ${tool}`)
554
321
  }
555
322
 
556
323
  const main = async () => {
@@ -563,34 +330,16 @@ const main = async () => {
563
330
  const [command, ...raw] = argv
564
331
  let subcommand = ""
565
332
  let rest = raw
566
- if (["provider", "mcp", "setup", "model", "config", "document"].includes(command)) {
333
+ if (["provider", "model", "document"].includes(command)) {
567
334
  subcommand = raw[0] || ""
568
335
  rest = raw.slice(1)
569
336
  }
570
337
  const flags = parseFlags(rest)
571
338
 
572
- if (command === "init") {
573
- const config = loadConfig()
574
- if (typeof flags["service-url"] === "string") {
575
- config.serviceUrl = flags["service-url"]
576
- saveConfig(config)
577
- }
578
- print({ ok: true, configFile: CONFIG_FILE, serviceUrl: config.serviceUrl })
579
- return
580
- }
581
-
582
- if (command === "dev") {
583
- const port = typeof flags.port === "string" ? Number(flags.port) : 8788
584
- const host = typeof flags.host === "string" ? flags.host : "127.0.0.1"
585
- if (!Number.isFinite(port) || port <= 0) throw new Error("dev --port must be positive number")
586
- printLocalServiceHints(host, Math.floor(port))
587
- runDevServer(Math.floor(port), host)
588
- return
589
- }
590
-
591
- if (command === "mcp-stdio") {
592
- await runMcpStdioCommand(typeof flags["service-url"] === "string" ? flags["service-url"] : undefined)
593
- return
339
+ if (command === "ocr") {
340
+ throw new Error(
341
+ "`echo-pdf ocr` was removed from the first-class CLI surface. OCR is migration-only and no longer a supported primary command."
342
+ )
594
343
  }
595
344
 
596
345
  if (command === "provider" && subcommand === "set") {
@@ -633,45 +382,6 @@ const main = async () => {
633
382
  return
634
383
  }
635
384
 
636
- if (command === "models") {
637
- const config = loadConfig()
638
- const profileName = getProfileName(config, flags.profile)
639
- const profile = getProfile(config, profileName)
640
- const provider = flags.provider ? resolveProviderAliasInput(flags.provider) : resolveProviderAlias(profile, flags.provider)
641
- const providerApiKeys = buildProviderApiKeys(config, profileName)
642
- const data = await postJson(`${config.serviceUrl}/providers/models`, buildModelsRequest(provider, providerApiKeys))
643
- print(data)
644
- return
645
- }
646
-
647
- if (command === "config" && subcommand === "set") {
648
- const key = flags.key
649
- const rawValue = flags.value
650
- if (typeof key !== "string" || key.trim().length === 0) {
651
- throw new Error("config set requires --key")
652
- }
653
- if (typeof rawValue !== "string") {
654
- throw new Error("config set requires --value")
655
- }
656
- const type = typeof flags.type === "string" ? flags.type : "auto"
657
- if (!["auto", "string", "number", "boolean", "json"].includes(type)) {
658
- throw new Error("config set --type must be one of auto|string|number|boolean|json")
659
- }
660
- const devVarsPath = typeof flags["dev-vars"] === "string"
661
- ? path.resolve(process.cwd(), flags["dev-vars"])
662
- : path.resolve(process.cwd(), ".dev.vars")
663
-
664
- const baseConfig = readDevVarsConfigJson(devVarsPath) || JSON.parse(JSON.stringify(PROJECT_CONFIG))
665
- if (!hasPath(PROJECT_CONFIG, key)) {
666
- throw new Error(`Unknown config key: ${key}`)
667
- }
668
- const value = parseConfigValue(rawValue, type)
669
- setPath(baseConfig, key, value)
670
- writeDevVarsConfigJson(devVarsPath, baseConfig)
671
- print({ ok: true, key, value, devVarsPath })
672
- return
673
- }
674
-
675
385
  if (command === "model" && subcommand === "set") {
676
386
  const model = flags.model
677
387
  if (typeof model !== "string" || model.length === 0) {
@@ -710,116 +420,11 @@ const main = async () => {
710
420
  return
711
421
  }
712
422
 
713
- if (command === "tools") {
714
- const config = loadConfig()
715
- const response = await fetch(`${config.serviceUrl}/tools/catalog`)
716
- const data = await response.json()
717
- if (!response.ok) throw new Error(JSON.stringify(data))
718
- print(data)
719
- return
720
- }
721
-
722
- if (LOCAL_PRIMITIVE_COMMANDS.includes(command) || (command === "document" && isLegacyDocumentSubcommand(subcommand))) {
423
+ if (LOCAL_PRIMITIVE_COMMANDS.includes(command)) {
723
424
  await runLocalPrimitiveCommand(command, subcommand, rest, flags)
724
425
  return
725
426
  }
726
427
 
727
- if (command === "call") {
728
- const config = loadConfig()
729
- const profileName = getProfileName(config, flags.profile)
730
- const profile = getProfile(config, profileName)
731
- const tool = flags.tool
732
- if (typeof tool !== "string") throw new Error("call requires --tool")
733
- const args = typeof flags.args === "string" ? JSON.parse(flags.args) : {}
734
- const autoUpload = flags["auto-upload"] === true
735
- const prepared = await prepareArgsWithLocalUploads(config.serviceUrl, tool, args, {
736
- autoUpload,
737
- })
738
- if (prepared.uploads.length > 0) {
739
- process.stderr.write(`[echo-pdf] auto-uploaded local files:\n`)
740
- for (const item of prepared.uploads) {
741
- process.stderr.write(` - ${item.localPath} -> ${item.fileId} (${item.tool})\n`)
742
- }
743
- }
744
- const preparedArgs = prepared.args
745
- const provider = resolveProviderAlias(profile, flags.provider)
746
- const model = typeof flags.model === "string" ? flags.model : resolveDefaultModel(profile, provider)
747
- const providerApiKeys = buildProviderApiKeys(config, profileName)
748
- const payload = buildToolCallRequest({ tool, args: preparedArgs, provider, model, providerApiKeys })
749
- const data = await postJson(`${config.serviceUrl}/tools/call`, payload)
750
- print(data)
751
- return
752
- }
753
-
754
- if (command === "file") {
755
- const action = rest[0] || ""
756
- const config = loadConfig()
757
- if (action === "upload") {
758
- const filePath = rest[1]
759
- if (!filePath) throw new Error("file upload requires a path")
760
- const data = await uploadFile(config.serviceUrl, filePath)
761
- print({
762
- fileId: data?.file?.id || "",
763
- filename: data?.file?.filename || path.basename(filePath),
764
- sizeBytes: data?.file?.sizeBytes || 0,
765
- file: data?.file || null,
766
- })
767
- return
768
- }
769
- if (action === "get") {
770
- const fileId = typeof flags["file-id"] === "string" ? flags["file-id"] : ""
771
- const out = typeof flags.out === "string" ? flags.out : ""
772
- if (!fileId || !out) throw new Error("file get requires --file-id and --out")
773
- const savedTo = await downloadFile(config.serviceUrl, fileId, out)
774
- print({ ok: true, fileId, savedTo })
775
- return
776
- }
777
- throw new Error("file command supports: upload|get")
778
- }
779
-
780
- if (command === "mcp" && subcommand === "initialize") {
781
- const config = loadConfig()
782
- const data = await postJson(`${config.serviceUrl}/mcp`, buildMcpRequest(1, "initialize"), buildMcpHeaders())
783
- print(data)
784
- return
785
- }
786
-
787
- if (command === "mcp" && subcommand === "tools") {
788
- const config = loadConfig()
789
- const data = await postJson(`${config.serviceUrl}/mcp`, buildMcpRequest(2, "tools/list"), buildMcpHeaders())
790
- print(data)
791
- return
792
- }
793
-
794
- if (command === "mcp" && subcommand === "call") {
795
- const config = loadConfig()
796
- const tool = flags.tool
797
- if (typeof tool !== "string") throw new Error("mcp call requires --tool")
798
- const args = typeof flags.args === "string" ? JSON.parse(flags.args) : {}
799
- const data = await postJson(
800
- `${config.serviceUrl}/mcp`,
801
- buildMcpRequest(3, "tools/call", { name: tool, arguments: args }),
802
- buildMcpHeaders()
803
- )
804
- print(data)
805
- return
806
- }
807
-
808
- if (command === "mcp" && subcommand === "stdio") {
809
- await runMcpStdioCommand(typeof flags["service-url"] === "string" ? flags["service-url"] : undefined)
810
- return
811
- }
812
-
813
- if (command === "setup" && subcommand === "add") {
814
- const tool = rest[0]
815
- if (!tool) throw new Error("setup add requires tool name")
816
- const config = loadConfig()
817
- const mode = typeof flags.mode === "string" ? flags.mode : "http"
818
- if (!["http", "stdio"].includes(mode)) throw new Error("setup add --mode must be http|stdio")
819
- print(setupSnippet(tool, config.serviceUrl, mode))
820
- return
821
- }
822
-
823
428
  usage()
824
429
  process.exitCode = 1
825
430
  }