Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The [skill](skills/convert-documents-to-markdown/SKILL.md) teaches the agent to
```bash
npx @firecrawl/anydoc report.docx # Markdown to stdout
npx @firecrawl/anydoc slides.pptx -o slides.md # or to a file
npx @firecrawl/anydoc --batch ./docs -o ./out # convert a directory tree
npx @firecrawl/anydoc - --format csv < data.csv # read stdin
```

Expand Down
1 change: 1 addition & 0 deletions node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The package ships an `anydoc` command, so `npx` converts a document with no inst
```bash
npx @firecrawl/anydoc report.docx # Markdown to stdout
npx @firecrawl/anydoc slides.pptx -o slides.md # or to a file
npx @firecrawl/anydoc --batch ./docs -o ./out # convert a directory tree
npx @firecrawl/anydoc - --format csv < data.csv # read stdin
```

Expand Down
162 changes: 147 additions & 15 deletions node/cli.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
#!/usr/bin/env node
'use strict'

const { readFile, writeFile } = require('node:fs/promises')
const { copyFile, mkdir, readdir, readFile, stat, writeFile } = require('node:fs/promises')
const { dirname, extname, join, relative, resolve, sep } = require('node:path')

const FORMATS = 'doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv'

// Plain-text extensions copied as-is in batch mode (names unchanged).
const PASSTHROUGH_EXTS = new Set(['.txt', '.json', '.md', '.py'])

const HELP = `anydoc: convert documents to GitHub-Flavored Markdown

Usage:
anydoc <file> [options]
anydoc --batch <dir> -o <dir> [options]
anydoc - [options] < file

Converts one document per invocation and writes the Markdown to stdout.
Pass - as the input to read the document from stdin. Never prompts; all
diagnostics go to stderr.
Converts one document per invocation and writes the Markdown to stdout,
or converts a directory tree in batch mode. Pass - as the input to read
the document from stdin. Never prompts; all diagnostics go to stderr.

Options:
-o, --output <path> Write the Markdown to <path> instead of stdout
--batch Convert every supported file under <dir>
recursively into -o, preserving relative paths.
Requires -o. Converted files keep the original
name and gain a .md suffix (report.pdf becomes
report.pdf.md). Plain-text files (.txt, .json,
.md, .py) are copied with names unchanged.
Unsupported files are skipped silently. Existing
outputs are overwritten. A conversion failure on
one file is logged to stderr and the run continues;
the process exits 1 if any conversion failed.
-o, --output <path> Write Markdown to <path> (single-file), or the
output directory (required with --batch)
-f, --format <format> Name the input format instead of detecting it:
${FORMATS}
(extension aliases like xls, docm, ppsx resolve
to these)
to these). Not valid with --batch.
-h, --help Print this help and exit
-V, --version Print the version and exit

Expand All @@ -31,12 +47,14 @@ which anydoc does not do, and error as unsupported.

Exit codes:
0 success
1 the document could not be read or converted
1 the document could not be read or converted (in batch mode: at least
one file failed to convert)
2 usage error: unknown option, missing input, or invalid --format

Examples:
anydoc report.docx
anydoc slides.pptx -o slides.md
anydoc --batch ./docs -o ./out
anydoc - --format csv < data.csv
curl -s https://example.com/paper.pdf | anydoc -
`
Expand All @@ -50,7 +68,7 @@ function fail(code, message) {
}

function parseArgs(argv) {
const args = { input: null, output: null, format: null }
const args = { input: null, output: null, format: null, batch: false }
let positionalOnly = false
for (let i = 0; i < argv.length; i++) {
let arg = argv[i]
Expand Down Expand Up @@ -87,6 +105,21 @@ function parseArgs(argv) {
process.stdout.write(`${require('./package.json').version}\n`)
process.exit(0)
break
case '--batch':
// Allow --batch <dir> (value form) or --batch with a separate positional.
if (inline !== null) {
if (args.input !== null) {
fail(USAGE_ERROR, `one document per invocation: unexpected second input '${inline}'`)
}
args.input = inline
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
if (args.input !== null) {
fail(USAGE_ERROR, `one document per invocation: unexpected second input '${argv[i + 1]}'`)
}
args.input = argv[++i]
}
args.batch = true
break
case '-o':
case '--output':
args.output = value()
Expand All @@ -113,16 +146,93 @@ async function readStdin() {
return Buffer.concat(chunks)
}

async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.input === null) {
fail(USAGE_ERROR, 'missing input: pass a document path, or - for stdin (see anydoc --help)')
function isPassthrough(path) {
return PASSTHROUGH_EXTS.has(extname(path).toLowerCase())
}

/** Walk a directory tree depth-first; yield absolute file paths. */
async function* walkFiles(root) {
const entries = await readdir(root, { withFileTypes: true })
for (const entry of entries) {
const path = join(root, entry.name)
if (entry.isDirectory()) {
yield* walkFiles(path)
} else if (entry.isFile()) {
yield path
}
}
}

// Loaded after argument handling so --help and --version work even where
// no native binding is available.
const { formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js')
async function runBatch(args, { formatFromPath, toMarkdown }) {
if (args.input === '-') {
fail(USAGE_ERROR, '--batch does not read stdin; pass an input directory')
}
if (args.output === null) {
fail(USAGE_ERROR, '--batch requires -o/--output naming an output directory')
}
if (args.format !== null) {
fail(USAGE_ERROR, '--format is not valid with --batch (format is detected per file)')
}

let inputStat
try {
inputStat = await stat(args.input)
} catch (error) {
fail(CONVERSION_ERROR, error.message)
}
if (!inputStat.isDirectory()) {
fail(USAGE_ERROR, `--batch expects a directory, got '${args.input}'`)
}

const inputRoot = resolve(args.input)
const outputRoot = resolve(args.output)

try {
await mkdir(outputRoot, { recursive: true })

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When -o points into (or equals) the input directory tree — e.g. anydoc --batch ./docs -o ./docs/out — the output directory is created before walking, so walkFiles descends into it and re-processes its contents. Freshly written *.md files and copied passthrough files get detected, converted/copied again, and passthrough copies can land back inside the tree; on repeated runs the prior output is re-consumed as input. There is no guard rejecting or warning when the output root is inside the input root. Refuse (or at least warn) when relative(inputRoot, outputRoot) is not strictly outside the input tree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/cli.js, line 191:

<comment>When `-o` points into (or equals) the input directory tree — e.g. `anydoc --batch ./docs -o ./docs/out` — the output directory is created before walking, so `walkFiles` descends into it and re-processes its contents. Freshly written `*.md` files and copied passthrough files get detected, converted/copied again, and passthrough copies can land back inside the tree; on repeated runs the prior output is re-consumed as input. There is no guard rejecting or warning when the output root is inside the input root. Refuse (or at least warn) when `relative(inputRoot, outputRoot)` is not strictly outside the input tree.</comment>

<file context>
@@ -113,16 +146,93 @@ async function readStdin() {
+  const outputRoot = resolve(args.output)
+
+  try {
+    await mkdir(outputRoot, { recursive: true })
+  } catch (error) {
+    fail(CONVERSION_ERROR, error.message)
</file context>
Fix with cubic

} catch (error) {
fail(CONVERSION_ERROR, error.message)
}

let failed = 0
for await (const absPath of walkFiles(inputRoot)) {

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a nested directory cannot be read, walkFiles rejects outside the per-file error handling and aborts the batch. Catch traversal errors per directory so sibling files still run and the command reports exit 1 with a concise diagnostic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/cli.js, line 197:

<comment>When a nested directory cannot be read, `walkFiles` rejects outside the per-file error handling and aborts the batch. Catch traversal errors per directory so sibling files still run and the command reports exit 1 with a concise diagnostic.</comment>

<file context>
@@ -113,16 +146,93 @@ async function readStdin() {
+  }
+
+  let failed = 0
+  for await (const absPath of walkFiles(inputRoot)) {
+    const rel = relative(inputRoot, absPath)
+    // Guard against path escape on odd relative() results.
</file context>
Fix with cubic

const rel = relative(inputRoot, absPath)
// Guard against path escape on odd relative() results.
if (rel.startsWith(`..${sep}`) || rel === '..') continue

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This path-escape guard is unreachable. absPath is always produced by join(inputRoot, entry.name) inside walkFiles, and inputRoot is resolve()d, so relative(inputRoot, absPath) can never be .. or start with ../ — its first path segment is always a child of the input root. The continue is dead code that can mislead future readers into thinking escaping paths are possible. Consider dropping it (and the now-misleading comment) or replacing it with a comment explaining why it cannot occur.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/cli.js, line 200:

<comment>This path-escape guard is unreachable. `absPath` is always produced by `join(inputRoot, entry.name)` inside `walkFiles`, and `inputRoot` is `resolve()`d, so `relative(inputRoot, absPath)` can never be `..` or start with `../` — its first path segment is always a child of the input root. The `continue` is dead code that can mislead future readers into thinking escaping paths are possible. Consider dropping it (and the now-misleading comment) or replacing it with a comment explaining why it cannot occur.</comment>

<file context>
@@ -113,16 +146,93 @@ async function readStdin() {
+  for await (const absPath of walkFiles(inputRoot)) {
+    const rel = relative(inputRoot, absPath)
+    // Guard against path escape on odd relative() results.
+    if (rel.startsWith(`..${sep}`) || rel === '..') continue
 
+    if (isPassthrough(absPath)) {
</file context>
Fix with cubic


if (isPassthrough(absPath)) {
const outPath = join(outputRoot, rel)
try {
await mkdir(dirname(outPath), { recursive: true })
await copyFile(absPath, outPath)
} catch (error) {
process.stderr.write(`anydoc: ${rel}: ${error.message}\n`)
failed++
}
continue
}

if (formatFromPath(absPath) === null) {

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Batch skips valid documents when their filenames lack a recognized extension. toMarkdown supports content-based detection before extension fallback; use content detection before treating a file as unsupported, while still silently skipping genuinely unsupported content.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/cli.js, line 214:

<comment>Batch skips valid documents when their filenames lack a recognized extension. `toMarkdown` supports content-based detection before extension fallback; use content detection before treating a file as unsupported, while still silently skipping genuinely unsupported content.</comment>

<file context>
@@ -113,16 +146,93 @@ async function readStdin() {
+      continue
+    }
+
+    if (formatFromPath(absPath) === null) {
+      // Unsupported (images, videos, archives, unknown extensions): skip.
+      continue
</file context>
Fix with cubic

// Unsupported (images, videos, archives, unknown extensions): skip.
continue
}

const outPath = join(outputRoot, `${rel}.md`)

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a converted file and a passthrough .md file share a target name, batch silently overwrites one source's output. Detect duplicate output paths and report the collision instead of allowing data loss.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/cli.js, line 219:

<comment>When a converted file and a passthrough `.md` file share a target name, batch silently overwrites one source's output. Detect duplicate output paths and report the collision instead of allowing data loss.</comment>

<file context>
@@ -113,16 +146,93 @@ async function readStdin() {
+      continue
+    }
+
+    const outPath = join(outputRoot, `${rel}.md`)
+    try {
+      const markdown = await toMarkdown(absPath)
</file context>
Fix with cubic

try {
const markdown = await toMarkdown(absPath)
await mkdir(dirname(outPath), { recursive: true })
await writeFile(outPath, markdown)
} catch (error) {
process.stderr.write(`anydoc: ${rel}: ${error.message}\n`)
failed++
}
}

if (failed > 0) {
process.exit(CONVERSION_ERROR)
}
}

async function runSingle(args, { formatFromExtension, toMarkdown, toMarkdownBytes }) {
let format
if (args.format !== null) {
format = formatFromExtension(args.format)
Expand Down Expand Up @@ -160,4 +270,26 @@ async function main() {
}
}

async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.input === null) {
fail(
USAGE_ERROR,
args.batch
? 'missing input: pass a directory with --batch (see anydoc --help)'
: 'missing input: pass a document path, or - for stdin (see anydoc --help)',
)
}

// Loaded after argument handling so --help and --version work even where
// no native binding is available.
const bindings = require('./index.js')

if (args.batch) {
await runBatch(args, bindings)
} else {
await runSingle(args, bindings)
}
}

main()
60 changes: 59 additions & 1 deletion node/test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Smoke test: the bindings load and every entry point round-trips a fixture.
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
Expand Down Expand Up @@ -136,6 +136,64 @@ test('cli help and version go to stdout and exit 0', async () => {
const help = await runCli(['--help'])
assert.equal(help.code, undefined)
assert.match(help.stdout, /Exit codes:/)
assert.match(help.stdout, /--batch/)
const version = await runCli(['--version'])
assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/)
})

test('cli --batch converts a tree and copies passthrough files', async () => {
const dir = await mkdtemp(join(tmpdir(), 'anydoc-batch-'))
const input = join(dir, 'in')
const output = join(dir, 'out')
try {
await mkdir(join(input, 'nested'), { recursive: true })
await copyFile(OUTLINE, join(input, 'nested', 'handbook.docx'))
await copyFile(CSV, join(input, 'sheet.csv'))
await writeFile(join(input, 'notes.txt'), 'plain text\n')

@cubic-dev-ai cubic-dev-ai Bot Aug 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The batch test covers only .txt passthrough files, so regressions in the documented .json, .md, or .py passthrough behavior will go undetected. Add fixtures and byte-for-byte output assertions for all four extensions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node/test.mjs, line 152:

<comment>The batch test covers only `.txt` passthrough files, so regressions in the documented `.json`, `.md`, or `.py` passthrough behavior will go undetected. Add fixtures and byte-for-byte output assertions for all four extensions.</comment>

<file context>
@@ -136,6 +136,64 @@ test('cli help and version go to stdout and exit 0', async () => {
+    await mkdir(join(input, 'nested'), { recursive: true })
+    await copyFile(OUTLINE, join(input, 'nested', 'handbook.docx'))
+    await copyFile(CSV, join(input, 'sheet.csv'))
+    await writeFile(join(input, 'notes.txt'), 'plain text\n')
+    await writeFile(join(input, 'nested', 'skip.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
+
</file context>
Fix with cubic

await writeFile(join(input, 'nested', 'skip.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))

const { code, stdout, stderr } = await runCli(['--batch', input, '-o', output])
assert.equal(code, undefined)
assert.equal(stdout, '')
assert.equal(stderr, '')

assert.match(await readFile(join(output, 'nested', 'handbook.docx.md'), 'utf8'), /^# /m)
assert.match(await readFile(join(output, 'sheet.csv.md'), 'utf8'), /\| --- \|/)
assert.equal(await readFile(join(output, 'notes.txt'), 'utf8'), 'plain text\n')
await assert.rejects(stat(join(output, 'nested', 'skip.png')))
await assert.rejects(stat(join(output, 'nested', 'skip.png.md')))
} finally {
await rm(dir, { recursive: true, force: true })
}
})

test('cli --batch continues after a conversion failure and exits 1', async () => {
const dir = await mkdtemp(join(tmpdir(), 'anydoc-batch-fail-'))
const input = join(dir, 'in')
const output = join(dir, 'out')
try {
await mkdir(input, { recursive: true })
await copyFile(OUTLINE, join(input, 'ok.docx'))
await copyFile(ENCRYPTED, join(input, 'bad.odt'))

const { code, stderr } = await runCli(['--batch', input, '-o', output])
assert.equal(code, 1)
assert.match(stderr, /bad\.odt/)
assert.match(await readFile(join(output, 'ok.docx.md'), 'utf8'), /^# /m)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

test('cli --batch usage errors exit 2', async () => {
for (const args of [
['--batch', CSV, '-o', 'out'],
['--batch', '-o', 'out'],
['--batch', OUTLINE],
['--batch', OUTLINE, '-o', 'out', '--format', 'docx'],
]) {
const { code, stderr } = await runCli(args)
assert.equal(code, 2, `expected usage error for ${JSON.stringify(args)}`)
assert.match(stderr, /^anydoc: /)
}
})