@echofiles/echo-pdf 0.5.0 → 0.7.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 (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +100 -563
  3. package/bin/echo-pdf.js +147 -536
  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 +2 -59
  19. package/dist/provider-client.js +1 -1
  20. package/dist/provider-keys.js +4 -1
  21. package/dist/types.d.ts +2 -88
  22. package/echo-pdf.config.json +10 -21
  23. package/package.json +25 -22
  24. package/bin/lib/http.js +0 -97
  25. package/bin/lib/mcp-stdio.js +0 -99
  26. package/dist/auth.d.ts +0 -18
  27. package/dist/auth.js +0 -36
  28. package/dist/core/index.d.ts +0 -50
  29. package/dist/core/index.js +0 -7
  30. package/dist/file-ops.d.ts +0 -11
  31. package/dist/file-ops.js +0 -36
  32. package/dist/file-store-do.d.ts +0 -36
  33. package/dist/file-store-do.js +0 -298
  34. package/dist/http-error.d.ts +0 -9
  35. package/dist/http-error.js +0 -14
  36. package/dist/index.d.ts +0 -1
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-server.d.ts +0 -3
  39. package/dist/mcp-server.js +0 -124
  40. package/dist/node/semantic-local.d.ts +0 -16
  41. package/dist/node/semantic-local.js +0 -113
  42. package/dist/pdf-agent.d.ts +0 -18
  43. package/dist/pdf-agent.js +0 -217
  44. package/dist/pdf-storage.d.ts +0 -8
  45. package/dist/pdf-storage.js +0 -86
  46. package/dist/pdfium-engine.d.ts +0 -9
  47. package/dist/pdfium-engine.js +0 -180
  48. package/dist/r2-file-store.d.ts +0 -20
  49. package/dist/r2-file-store.js +0 -176
  50. package/dist/response-schema.d.ts +0 -15
  51. package/dist/response-schema.js +0 -159
  52. package/dist/tool-registry.d.ts +0 -16
  53. package/dist/tool-registry.js +0 -175
  54. package/dist/worker.d.ts +0 -7
  55. package/dist/worker.js +0 -386
  56. package/scripts/export-fixtures.sh +0 -204
  57. package/wrangler.toml +0 -19
package/bin/echo-pdf.js CHANGED
@@ -1,42 +1,34 @@
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")
12
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
10
  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
- const PROVIDER_ENTRIES = Object.entries(PROJECT_CONFIG.providers || {})
16
- 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
- )
23
12
  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"
13
+
14
+ const getProviderEntries = () => Object.entries(PROJECT_CONFIG.providers || {})
15
+ const getProviderAliases = () => getProviderEntries().map(([alias]) => alias)
16
+ const getProviderAliasByType = () => new Map(getProviderEntries().map(([alias, provider]) => [provider.type, alias]))
17
+ const getProviderSetNames = () => Array.from(new Set(getProviderEntries().flatMap(([alias, provider]) => [alias, provider.type])))
27
18
 
28
19
  const emptyProviders = () =>
29
- Object.fromEntries(PROVIDER_ALIASES.map((providerAlias) => [providerAlias, { apiKey: "" }]))
20
+ Object.fromEntries(getProviderAliases().map((providerAlias) => [providerAlias, { apiKey: "" }]))
30
21
 
31
22
  const resolveProviderAliasInput = (input) => {
32
23
  if (typeof input !== "string" || input.trim().length === 0) {
33
24
  throw new Error("provider is required")
34
25
  }
35
26
  const raw = input.trim()
36
- if (PROVIDER_ALIASES.includes(raw)) return raw
37
- const fromType = PROVIDER_ALIAS_BY_TYPE.get(raw)
27
+ const providerAliases = getProviderAliases()
28
+ if (providerAliases.includes(raw)) return raw
29
+ const fromType = getProviderAliasByType().get(raw)
38
30
  if (fromType) return fromType
39
- throw new Error(`provider must be one of: ${PROVIDER_SET_NAMES.join(", ")}`)
31
+ throw new Error(`provider must be one of: ${getProviderSetNames().join(", ")}`)
40
32
  }
41
33
 
42
34
  function resolveDefaultProviderAlias() {
@@ -44,13 +36,12 @@ function resolveDefaultProviderAlias() {
44
36
  if (typeof configured === "string" && configured.trim().length > 0) {
45
37
  return resolveProviderAliasInput(configured.trim())
46
38
  }
47
- return PROVIDER_ALIASES[0] || "openai"
39
+ return getProviderAliases()[0] || "openai"
48
40
  }
49
41
 
50
42
  const DEFAULT_PROVIDER_ALIAS = resolveDefaultProviderAlias()
51
43
 
52
44
  const defaultConfig = () => ({
53
- serviceUrl: DEFAULT_SERVICE_URL,
54
45
  profile: "default",
55
46
  profiles: {
56
47
  default: {
@@ -68,56 +59,11 @@ const ensureConfig = () => {
68
59
  }
69
60
  }
70
61
 
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
62
  const saveConfig = (config) => {
100
63
  fs.mkdirSync(CONFIG_DIR, { recursive: true })
101
64
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
102
65
  }
103
66
 
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
67
  const getProfile = (config, name) => {
122
68
  const profileName = name || config.profile || "default"
123
69
  if (!config.profiles[profileName]) {
@@ -129,7 +75,7 @@ const getProfile = (config, name) => {
129
75
  }
130
76
  const profile = config.profiles[profileName]
131
77
  if (!profile.providers || typeof profile.providers !== "object") profile.providers = {}
132
- for (const providerAlias of PROVIDER_ALIASES) {
78
+ for (const providerAlias of getProviderAliases()) {
133
79
  if (!profile.providers[providerAlias] || typeof profile.providers[providerAlias] !== "object") {
134
80
  profile.providers[providerAlias] = { apiKey: "" }
135
81
  }
@@ -141,6 +87,33 @@ const getProfile = (config, name) => {
141
87
  return profile
142
88
  }
143
89
 
90
+ const loadConfig = () => {
91
+ ensureConfig()
92
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"))
93
+ if (!config.profiles || typeof config.profiles !== "object") config.profiles = {}
94
+ if (typeof config.profile !== "string" || !config.profile) config.profile = "default"
95
+ getProfile(config, config.profile)
96
+ saveConfig(config)
97
+ return config
98
+ }
99
+
100
+ const parseFlags = (args) => {
101
+ const flags = {}
102
+ for (let i = 0; i < args.length; i += 1) {
103
+ const token = args[i]
104
+ if (!token?.startsWith("--")) continue
105
+ const key = token.slice(2)
106
+ const next = args[i + 1]
107
+ if (typeof next !== "string" || next.startsWith("--")) {
108
+ flags[key] = true
109
+ } else {
110
+ flags[key] = next
111
+ i += 1
112
+ }
113
+ }
114
+ return flags
115
+ }
116
+
144
117
  const getProfileName = (config, profileName) => profileName || config.profile || "default"
145
118
 
146
119
  const resolveProviderAlias = (profile, explicitProvider) =>
@@ -154,10 +127,29 @@ const resolveDefaultModel = (profile, providerAlias) => {
154
127
  return PROJECT_DEFAULT_MODEL
155
128
  }
156
129
 
130
+ const readEnvApiKey = (providerAlias) => {
131
+ const providerConfig = PROJECT_CONFIG.providers?.[providerAlias]
132
+ const keyName = providerConfig?.apiKeyEnv
133
+ if (typeof keyName !== "string" || keyName.trim().length === 0) return ""
134
+ const read = (name) => {
135
+ const value = process.env[name]
136
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : ""
137
+ }
138
+ const direct = read(keyName)
139
+ if (direct) return direct
140
+ if (keyName.endsWith("_API_KEY")) {
141
+ return read(keyName.replace(/_API_KEY$/, "_KEY"))
142
+ }
143
+ if (keyName.endsWith("_KEY")) {
144
+ return read(keyName.replace(/_KEY$/, "_API_KEY"))
145
+ }
146
+ return ""
147
+ }
148
+
157
149
  const buildProviderApiKeys = (config, profileName) => {
158
150
  const profile = getProfile(config, profileName)
159
151
  const providerApiKeys = {}
160
- for (const [providerAlias, providerConfig] of PROVIDER_ENTRIES) {
152
+ for (const [providerAlias, providerConfig] of getProviderEntries()) {
161
153
  const apiKey = profile.providers?.[providerAlias]?.apiKey || profile.providers?.[providerConfig.type]?.apiKey || ""
162
154
  providerApiKeys[providerAlias] = apiKey
163
155
  providerApiKeys[providerConfig.type] = apiKey
@@ -165,165 +157,41 @@ const buildProviderApiKeys = (config, profileName) => {
165
157
  return providerApiKeys
166
158
  }
167
159
 
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) => {
160
+ const resolveLocalSemanticContext = (flags) => {
224
161
  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]
162
+ const profileName = getProfileName(config, flags.profile)
163
+ const profile = getProfile(config, profileName)
164
+ const provider = resolveProviderAlias(profile, flags.provider)
165
+ const model = typeof flags.model === "string" ? flags.model.trim() : resolveDefaultModel(profile, provider)
166
+ if (!model) {
167
+ throw new Error(
168
+ [
169
+ `semantic requires a configured model for provider "${provider}".`,
170
+ `Pass \`--model <model-id>\`, or run \`echo-pdf model set --provider ${provider} --model <model-id>${profileName ? ` --profile ${profileName}` : ""}\`.`,
171
+ ].join(" ")
172
+ )
285
173
  }
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
- }
174
+ const providerApiKeys = buildProviderApiKeys(config, profileName)
175
+ const configuredApiKey = typeof providerApiKeys[provider] === "string" ? providerApiKeys[provider].trim() : ""
176
+ const apiKeyEnv = PROJECT_CONFIG.providers?.[provider]?.apiKeyEnv || ""
177
+ if (apiKeyEnv && !configuredApiKey && !readEnvApiKey(provider)) {
178
+ throw new Error(
179
+ [
180
+ `semantic requires an API key for provider "${provider}".`,
181
+ `Run \`echo-pdf provider set --provider ${provider} --api-key <KEY>${profileName ? ` --profile ${profileName}` : ""}\``,
182
+ `or export \`${apiKeyEnv}\` before running the VL-first semantic path.`,
183
+ ].join(" ")
184
+ )
298
185
  }
299
- return null
186
+ return { provider, model, providerApiKeys }
300
187
  }
301
188
 
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"))
189
+ const print = (data) => {
190
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
324
191
  }
325
192
 
326
193
  const LOCAL_DOCUMENT_DIST_ENTRY = new URL("../dist/local/index.js", import.meta.url)
194
+ const LOCAL_DOCUMENT_DIST_PATH = fileURLToPath(LOCAL_DOCUMENT_DIST_ENTRY)
327
195
  const LOCAL_DOCUMENT_SOURCE_ENTRY = new URL("../src/local/index.ts", import.meta.url)
328
196
  const IS_BUN_RUNTIME = typeof process.versions?.bun === "string"
329
197
  const SHOULD_PREFER_SOURCE_DOCUMENT_API = process.env.ECHO_PDF_SOURCE_DEV === "1"
@@ -334,45 +202,54 @@ const loadLocalDocumentApi = async () => {
334
202
  return import(LOCAL_DOCUMENT_SOURCE_ENTRY.href)
335
203
  }
336
204
  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."
205
+ "Internal source-checkout CLI dev mode requires Bun and src/local/index.ts. " +
206
+ "Use `npm run cli:dev -- <primitive> ...` only from a source checkout."
339
207
  )
340
208
  }
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
209
+ if (!fs.existsSync(LOCAL_DOCUMENT_DIST_PATH)) {
210
+ throw new Error(
211
+ "Local primitive commands require built artifacts in a source checkout. " +
212
+ "Run `npm run build` first, use the internal `npm run cli:dev -- <primitive> ...` path in this repo, or install the published package."
213
+ )
352
214
  }
215
+ return import(LOCAL_DOCUMENT_DIST_ENTRY.href)
353
216
  }
354
217
 
355
- const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render", "ocr"]
356
- const LEGACY_DOCUMENT_SUBCOMMANDS = ["index", "get", "structure", "semantic", "page", "render", "ocr"]
218
+ const LOCAL_PRIMITIVE_COMMANDS = ["document", "structure", "semantic", "page", "render"]
219
+ const REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE = {
220
+ index: "document",
221
+ get: "document",
222
+ structure: "structure",
223
+ semantic: "semantic",
224
+ page: "page",
225
+ render: "render",
226
+ }
357
227
 
358
- const isLegacyDocumentSubcommand = (value) => typeof value === "string" && LEGACY_DOCUMENT_SUBCOMMANDS.includes(value)
228
+ const isRemovedDocumentAlias = (value) =>
229
+ typeof value === "string" && Object.hasOwn(REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE, value)
230
+
231
+ const removedDocumentAliasMessage = (alias) => {
232
+ const primitive = REMOVED_DOCUMENT_ALIAS_TO_PRIMITIVE[alias]
233
+ return `Legacy \`document ${alias}\` was removed. Use \`echo-pdf ${primitive} <file.pdf>\` instead.`
234
+ }
359
235
 
360
236
  const readDocumentPrimitiveArgs = (command, subcommand, rest) => {
361
- if (command === "document" && isLegacyDocumentSubcommand(subcommand)) {
362
- const primitive = subcommand === "index" || subcommand === "get" ? "document" : subcommand
237
+ if (command === "document") {
238
+ if (isRemovedDocumentAlias(subcommand) && typeof rest[0] === "string" && !rest[0].startsWith("--")) {
239
+ throw new Error(removedDocumentAliasMessage(subcommand))
240
+ }
363
241
  return {
364
- primitive,
365
- pdfPath: rest[0],
242
+ primitive: "document",
243
+ pdfPath: subcommand,
366
244
  }
367
245
  }
368
246
  return {
369
247
  primitive: command,
370
- pdfPath: command === "document" ? subcommand : rest[0],
248
+ pdfPath: rest[0],
371
249
  }
372
250
  }
373
251
 
374
252
  const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
375
- const local = await loadLocalDocumentApi()
376
253
  const { primitive, pdfPath } = readDocumentPrimitiveArgs(command, subcommand, rest)
377
254
  const workspaceDir = typeof flags.workspace === "string" ? flags.workspace : undefined
378
255
  const forceRefresh = flags["force-refresh"] === true
@@ -383,26 +260,28 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
383
260
  }
384
261
 
385
262
  if (primitive === "document") {
386
- const data = await local.get_document({ pdfPath, workspaceDir, forceRefresh })
387
- print(data)
263
+ const local = await loadLocalDocumentApi()
264
+ print(await local.get_document({ pdfPath, workspaceDir, forceRefresh }))
388
265
  return
389
266
  }
390
267
 
391
268
  if (primitive === "structure") {
392
- const data = await local.get_document_structure({ pdfPath, workspaceDir, forceRefresh })
393
- print(data)
269
+ const local = await loadLocalDocumentApi()
270
+ print(await local.get_document_structure({ pdfPath, workspaceDir, forceRefresh }))
394
271
  return
395
272
  }
396
273
 
397
274
  if (primitive === "semantic") {
398
- const data = await local.get_semantic_document_structure({
275
+ const semanticContext = resolveLocalSemanticContext(flags)
276
+ const local = await loadLocalDocumentApi()
277
+ print(await local.get_semantic_document_structure({
399
278
  pdfPath,
400
279
  workspaceDir,
401
280
  forceRefresh,
402
- provider: typeof flags.provider === "string" ? flags.provider : undefined,
403
- model: typeof flags.model === "string" ? flags.model : undefined,
404
- })
405
- print(data)
281
+ provider: semanticContext.provider,
282
+ model: semanticContext.model,
283
+ providerApiKeys: semanticContext.providerApiKeys,
284
+ }))
406
285
  return
407
286
  }
408
287
 
@@ -412,29 +291,14 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
412
291
  }
413
292
 
414
293
  if (primitive === "page") {
415
- const data = await local.get_page_content({ pdfPath, workspaceDir, forceRefresh, pageNumber })
416
- print(data)
294
+ const local = await loadLocalDocumentApi()
295
+ print(await local.get_page_content({ pdfPath, workspaceDir, forceRefresh, pageNumber }))
417
296
  return
418
297
  }
419
298
 
420
299
  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)
300
+ const local = await loadLocalDocumentApi()
301
+ print(await local.get_page_render({ pdfPath, workspaceDir, forceRefresh, pageNumber, renderScale }))
438
302
  return
439
303
  }
440
304
 
@@ -443,114 +307,23 @@ const runLocalPrimitiveCommand = async (command, subcommand, rest, flags) => {
443
307
 
444
308
  const usage = () => {
445
309
  process.stdout.write(`echo-pdf CLI\n\n`)
446
- process.stdout.write(`Commands:\n`)
310
+ process.stdout.write(`Primary local primitive commands:\n`)
447
311
  process.stdout.write(` document <file.pdf> [--workspace DIR] [--force-refresh]\n`)
448
312
  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`)
313
+ process.stdout.write(` semantic <file.pdf> [--provider alias] [--model model] [--profile name] [--workspace DIR] [--force-refresh]\n`)
450
314
  process.stdout.write(` page <file.pdf> --page <N> [--workspace DIR] [--force-refresh]\n`)
451
315
  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`)
456
- process.stdout.write(` provider set --provider <${PROVIDER_SET_NAMES.join("|")}> --api-key <KEY> [--profile name]\n`)
457
- process.stdout.write(` provider use --provider <${PROVIDER_ALIASES.join("|")}> [--profile name]\n`)
316
+ process.stdout.write(`\nLocal config commands:\n`)
317
+ process.stdout.write(` provider set --provider <${getProviderSetNames().join("|")}> --api-key <KEY> [--profile name]\n`)
318
+ process.stdout.write(` provider use --provider <${getProviderAliases().join("|")}> [--profile name]\n`)
458
319
  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
320
  process.stdout.write(` model set --model <model-id> [--provider alias] [--profile name]\n`)
462
321
  process.stdout.write(` model get [--provider alias] [--profile name]\n`)
463
322
  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}`)
323
+ process.stdout.write(`\nLocal LLM example (no auth):\n`)
324
+ process.stdout.write(` echo-pdf provider set --provider ollama --api-key \"\"\n`)
325
+ process.stdout.write(` echo-pdf model set --provider ollama --model llava:13b\n`)
326
+ process.stdout.write(` echo-pdf semantic ./sample.pdf --provider ollama\n`)
554
327
  }
555
328
 
556
329
  const main = async () => {
@@ -563,34 +336,16 @@ const main = async () => {
563
336
  const [command, ...raw] = argv
564
337
  let subcommand = ""
565
338
  let rest = raw
566
- if (["provider", "mcp", "setup", "model", "config", "document"].includes(command)) {
339
+ if (["provider", "model", "document"].includes(command)) {
567
340
  subcommand = raw[0] || ""
568
341
  rest = raw.slice(1)
569
342
  }
570
343
  const flags = parseFlags(rest)
571
344
 
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
345
+ if (command === "ocr") {
346
+ throw new Error(
347
+ "`echo-pdf ocr` was removed from the first-class CLI surface. OCR is migration-only and no longer a supported primary command."
348
+ )
594
349
  }
595
350
 
596
351
  if (command === "provider" && subcommand === "set") {
@@ -633,45 +388,6 @@ const main = async () => {
633
388
  return
634
389
  }
635
390
 
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
391
  if (command === "model" && subcommand === "set") {
676
392
  const model = flags.model
677
393
  if (typeof model !== "string" || model.length === 0) {
@@ -710,116 +426,11 @@ const main = async () => {
710
426
  return
711
427
  }
712
428
 
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))) {
429
+ if (LOCAL_PRIMITIVE_COMMANDS.includes(command)) {
723
430
  await runLocalPrimitiveCommand(command, subcommand, rest, flags)
724
431
  return
725
432
  }
726
433
 
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
434
  usage()
824
435
  process.exitCode = 1
825
436
  }