From aed7dbefaa3926a6ede688c3321ccad79e061835 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 12 May 2026 00:56:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(tools):=20add=20pandoc=5Fconvert=20tool=20?= =?UTF-8?q?=E2=80=94=20universal=20document=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pandoc is the de-facto Swiss Army knife for moving prose between the formats engineers and writers actually use: Markdown to HTML, HTML to Markdown, reST to anything, anything to LaTeX / DOCX / EPUB. Surfacing it as a model-callable tool unblocks a large class of "rewrite this report as ..." / "publish this changelog as ..." workflows that previously required the user to drop into a terminal between turns. The tool reads `source_path` (any pandoc-supported input format — pandoc autodetects from the extension), converts to one of the 11 whitelisted target formats, and either writes the result to `output_path` (when provided) or returns the converted text inline. Target whitelist: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc Picked for coverage of real document-handling without dragging in additional system tooling (no PDF target — that needs a LaTeX engine; no S5/Slidy — niche and surprising). Binary targets (docx, odt, epub) reject inline-text requests with a clear error naming the required `output_path`; text targets work in either mode. Registration is gated on `crate::dependencies::resolve_pandoc()` through the new `ToolRegistryBuilder::with_pandoc_tools()` builder method, hooked into `with_agent_tools` so the tool surface picks it up everywhere the existing diagnostics / project tools do. When pandoc is missing the tool simply isn't registered (same probe-then-decide pattern v0.8.31 introduced for Python). Approval routes through the WritesFiles / Suggest tier matching other file-writing tools. `deepseek doctor`'s "Tool Dependencies" section reports pandoc as present / absent with platform-aware install hints (`brew install pandoc` / `apt install pandoc` / `winget install JohnMacFarlane.Pandoc`). Tests cover the format whitelist round-tripping into the schema's `enum` field, the binary-format rejection path, the unsupported- format rejection path, the missing-source-file rejection, the Markdown→HTML inline round-trip, and the `output_path` write roundtrip + summary message. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 ++ crates/tui/src/dependencies.rs | 26 +++ crates/tui/src/main.rs | 23 ++ crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/pandoc.rs | 361 +++++++++++++++++++++++++++++++ crates/tui/src/tools/registry.rs | 18 +- 6 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/tools/pandoc.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43690fb6..90bf29dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,22 @@ real world uses." ### Added +- **`pandoc_convert` tool — convert documents between formats via + the local pandoc binary.** Pandoc is the Swiss Army knife the + real world uses for moving prose around — Markdown to HTML, + HTML to Markdown, reST to anything, anything to DOCX / EPUB / + LaTeX — and surfacing it as a model-callable tool unblocks + "rewrite this report as ..." / "publish this changelog as ..." + workflows that previously needed the user to drop into a + terminal between turns. Curated target whitelist of 11 formats + (markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, + plain, asciidoc) so the model can't ask for `pdf` (would need + LaTeX) or typos like `markown`. Binary targets (docx, odt, + epub) require an `output_path`; text targets can return the + converted text inline. Approval routes through the WritesFiles + / Suggest tier on every call. Registration is gated on + `dependencies::resolve_pandoc()`; `deepseek doctor` surfaces + the binary's status with platform-aware install hints. - **`js_execution` tool — execute model-provided JavaScript via a local Node.js runtime.** Mirrors `code_execution` (Python) so the model has a single consistent surface for "run this snippet diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 3b2ad607..d37b5f66 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -117,6 +117,32 @@ pub fn resolve_pdftotext() -> Option { .clone() } +/// Resolve `pandoc` (universal document converter) once per +/// process. Used by the `pandoc_convert` tool to decide whether +/// to register itself with the model. Pandoc is a single-binary +/// install, so the candidate list is just `pandoc` — no platform +/// fallback path. +pub fn resolve_pandoc() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + if probe_executable("pandoc") { + tracing::info!( + target: "tool_dependencies", + "Resolved pandoc binary for pandoc_convert", + ); + Some("pandoc".to_string()) + } else { + tracing::warn!( + target: "tool_dependencies", + "pandoc binary not found; pandoc_convert tool will not be registered", + ); + None + } + }) + .clone() +} + /// Resolve the Node.js runtime once per process. Used by the /// `js_execution` tool to decide whether to advertise itself in /// the catalog. Unlike Python, the executable name `node` is the diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d4dee0ea..cf122ad2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2157,6 +2157,29 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt } } + match crate::dependencies::resolve_pandoc() { + Some(_) => println!( + " {} pandoc: present → pandoc_convert tool registered", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + ), + None => { + println!(" {} pandoc: not found (optional)", "·".dimmed(),); + println!( + " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:" + ); + match std::env::consts::OS { + "macos" => println!(" brew install pandoc"), + "linux" => println!( + " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent" + ), + "windows" => { + println!(" winget install JohnMacFarlane.Pandoc") + } + other => println!(" install pandoc for {other} from pandoc.org"), + } + } + } + // PDF reader: pure-Rust `pdf-extract` is the v0.8.32 default, so // `pdftotext` is no longer required for `read_file` to handle PDFs. // We still surface its presence (a) so users with column-heavy PDFs diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index dad1fab5..c5e510d1 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -26,6 +26,7 @@ pub mod github; pub mod js_execution; pub mod large_output_router; pub mod notify; +pub mod pandoc; pub mod parallel; pub mod plan; pub mod project; diff --git a/crates/tui/src/tools/pandoc.rs b/crates/tui/src/tools/pandoc.rs new file mode 100644 index 00000000..1299b5d3 --- /dev/null +++ b/crates/tui/src/tools/pandoc.rs @@ -0,0 +1,361 @@ +//! `pandoc_convert` tool — universal document conversion via the +//! `pandoc` binary (https://pandoc.org). +//! +//! Pandoc is the de-facto Swiss Army knife for moving prose between +//! the formats writers and engineers actually use: Markdown to HTML, +//! HTML to Markdown, anything to LaTeX or DOCX, RST to Markdown, +//! ReST imports, etc. Surfacing it as a model-callable tool unblocks +//! a large class of "rewrite this report as ..." / "publish this +//! changelog as ..." workflows that previously required the user +//! to drop into a terminal between turns. +//! +//! Registration is gated by [`crate::dependencies::resolve_pandoc`] +//! (see [`crate::tools::registry::ToolRegistryBuilder::with_pandoc_tools`]). +//! When pandoc isn't installed the tool simply doesn't appear in the +//! catalog, so the model never sees a binary it can't actually use. +//! +//! ## Format whitelist +//! +//! Pandoc supports ~30 input and ~50 output formats, and exposing +//! every one of them as a free-text string would let the model +//! ask for `pdf` (which needs LaTeX installed), `epub3` (works +//! everywhere but ambiguous vs. `epub`), or typos like `markown`. +//! The whitelist below is the curated subset that a) covers ~95% +//! of real document-handling needs and b) doesn't require additional +//! system dependencies (LaTeX engines, ImageMagick) beyond pandoc +//! itself. +//! +//! Adding a format: append to [`SUPPORTED_TARGET_FORMATS`] and the +//! schema description; the dispatch logic is whitelist-driven so +//! anything in the list goes through unchanged. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, + optional_str, required_str, +}; + +/// Curated whitelist of pandoc target formats. Each entry corresponds +/// to a `--to=` value pandoc accepts natively without +/// additional system tooling. Keep this list short and intentional — +/// the schema description below references it verbatim. +pub(crate) const SUPPORTED_TARGET_FORMATS: &[&str] = &[ + "markdown", // Pandoc-flavored Markdown (the safe round-trip default) + "gfm", // GitHub-Flavored Markdown + "commonmark", // strict CommonMark + "html", // HTML5 + "rst", // reStructuredText + "latex", // LaTeX source (does not require a TeX install to *generate*) + "docx", // Microsoft Word .docx + "odt", // OpenDocument Text + "epub", // EPUB 2/3 + "plain", // plain text (formatting stripped) + "asciidoc", // AsciiDoc +]; + +/// Tool implementing `pandoc_convert`. Converts a source file into +/// a target format and either writes the output to disk or returns +/// the converted text inline. +pub struct PandocConvertTool; + +#[async_trait] +impl ToolSpec for PandocConvertTool { + fn name(&self) -> &'static str { + "pandoc_convert" + } + + fn description(&self) -> &'static str { + "Convert a document between formats via pandoc. Reads `source_path` (any pandoc-supported input format — pandoc autodetects from extension), converts to `target_format`, and either writes the result to `output_path` (when provided) or returns the converted text inline. Supported targets: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc. Use this instead of shelling out to pandoc via `exec_shell` — no approval prompt for output_path-less reads, structured errors, and a curated format whitelist." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "source_path": { + "type": "string", + "description": "Path to the source document (relative to workspace or absolute). Pandoc autodetects the input format from the file extension." + }, + "target_format": { + "type": "string", + "description": "One of: markdown, gfm, commonmark, html, rst, latex, docx, odt, epub, plain, asciidoc.", + "enum": SUPPORTED_TARGET_FORMATS, + }, + "output_path": { + "type": "string", + "description": "Optional path to write the converted document to. When omitted, the converted text is returned inline (text formats only — binary formats like docx/odt/epub require output_path)." + } + }, + "required": ["source_path", "target_format"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::WritesFiles, + ToolCapability::Sandboxable, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Suggest + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let source_path_str = required_str(&input, "source_path")?; + let target_format = required_str(&input, "target_format")?.trim().to_lowercase(); + let output_path_str = optional_str(&input, "output_path"); + + if !SUPPORTED_TARGET_FORMATS.contains(&target_format.as_str()) { + return Err(ToolError::invalid_input(format!( + "unsupported target_format `{target_format}`. Pick one of: {}", + SUPPORTED_TARGET_FORMATS.join(", ") + ))); + } + + let source_path = context.resolve_path(source_path_str)?; + if !source_path.exists() { + return Err(ToolError::execution_failed(format!( + "source_path does not exist: {}", + source_path.display() + ))); + } + + // Resolve the pandoc binary at execution time too — registration + // gated on resolve_pandoc(), but a concurrent uninstall between + // catalog build and the model's call should produce a clear + // error rather than the cryptic "program not found" from raw + // Command::spawn. + let pandoc = crate::dependencies::resolve_pandoc().ok_or_else(|| { + ToolError::execution_failed( + "pandoc_convert: pandoc binary not found on PATH. \ + Install pandoc (macOS: `brew install pandoc`; \ + Debian/Ubuntu: `apt install pandoc`; \ + Windows: `winget install JohnMacFarlane.Pandoc`) and restart deepseek-tui.", + ) + })?; + + let resolved_output_path: Option = match output_path_str { + Some(p) => Some(context.resolve_path(p)?), + None => None, + }; + + // Binary formats can't round-trip through stdout reliably — + // require an output_path so the bytes survive the trip. + if resolved_output_path.is_none() && format_is_binary(&target_format) { + return Err(ToolError::invalid_input(format!( + "target_format `{target_format}` is binary; provide an `output_path` to write the converted file." + ))); + } + + let mut cmd = Command::new(&pandoc); + cmd.arg(&source_path); + cmd.arg("--to").arg(&target_format); + if let Some(out) = resolved_output_path.as_ref() { + cmd.arg("--output").arg(out); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = cmd + .output() + .map_err(|e| ToolError::execution_failed(format!("failed to launch pandoc: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(ToolError::execution_failed(format!( + "pandoc failed (exit {:?}): {stderr}", + output.status.code() + ))); + } + + let summary = if let Some(out) = resolved_output_path { + format!( + "Converted {} → {} via pandoc; wrote {}", + source_path.display(), + target_format, + out.display() + ) + } else { + let text = String::from_utf8_lossy(&output.stdout).to_string(); + return Ok(ToolResult::success(text)); + }; + Ok(ToolResult::success(summary)) + } +} + +/// Whitelist of target formats whose output is binary (and therefore +/// can't be returned as inline text). `docx`, `odt`, and `epub` are +/// ZIP archives; everything else in [`SUPPORTED_TARGET_FORMATS`] +/// renders to UTF-8 text. +pub(crate) fn format_is_binary(target_format: &str) -> bool { + matches!(target_format, "docx" | "odt" | "epub") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn pandoc_present() -> bool { + crate::dependencies::resolve_pandoc().is_some() + } + + #[test] + fn supported_target_formats_match_schema_enum() { + let tool = PandocConvertTool; + let schema = tool.input_schema(); + let enum_vals = schema + .get("properties") + .and_then(|p| p.get("target_format")) + .and_then(|t| t.get("enum")) + .and_then(|e| e.as_array()) + .expect("target_format enum must be present in schema"); + let from_schema: Vec<&str> = enum_vals.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!( + from_schema, SUPPORTED_TARGET_FORMATS, + "schema enum must mirror the SUPPORTED_TARGET_FORMATS constant exactly", + ); + } + + #[test] + fn binary_formats_require_output_path() { + for fmt in ["docx", "odt", "epub"] { + assert!(format_is_binary(fmt)); + } + for fmt in [ + "markdown", + "html", + "rst", + "latex", + "plain", + "gfm", + "commonmark", + ] { + assert!(!format_is_binary(fmt)); + } + } + + #[tokio::test] + async fn pandoc_convert_rejects_unsupported_target_format() { + let tmp = tempdir().expect("tempdir"); + let src = tmp.path().join("in.md"); + fs::write(&src, "# hi").unwrap(); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let err = PandocConvertTool + .execute( + json!({"source_path": "in.md", "target_format": "definitely-not-real"}), + &ctx, + ) + .await + .expect_err("unsupported target format must reject before pandoc spawn"); + assert!( + err.to_string().contains("unsupported target_format"), + "error must call out the unsupported format; got {err}" + ); + } + + #[tokio::test] + async fn pandoc_convert_rejects_inline_request_for_binary_format() { + let tmp = tempdir().expect("tempdir"); + let src = tmp.path().join("in.md"); + fs::write(&src, "# hi").unwrap(); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let err = PandocConvertTool + .execute( + json!({"source_path": "in.md", "target_format": "docx"}), + &ctx, + ) + .await + .expect_err("missing output_path for docx must reject"); + assert!( + err.to_string().contains("binary") && err.to_string().contains("output_path"), + "error must explain why output_path is required; got {err}" + ); + } + + #[tokio::test] + async fn pandoc_convert_roundtrips_markdown_to_html_inline() { + if !pandoc_present() { + // Tool wouldn't be registered without pandoc; mirror the + // catalog-build behaviour. + return; + } + let tmp = tempdir().expect("tempdir"); + let src = tmp.path().join("note.md"); + fs::write(&src, "# Title\n\nA paragraph with `inline code`.\n").unwrap(); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let result = PandocConvertTool + .execute( + json!({"source_path": "note.md", "target_format": "html"}), + &ctx, + ) + .await + .expect("execute"); + assert!(result.success); + assert!( + result.content.contains(" Self { + if crate::dependencies::resolve_pandoc().is_some() { + use super::pandoc::PandocConvertTool; + self.with_tool(Arc::new(PandocConvertTool)) + } else { + self + } + } + /// Include the `load_skill` tool (#434) so the model can pull a /// SKILL.md body + companion file list into context with one /// call instead of `read_file` + `list_dir` against the path @@ -732,7 +747,8 @@ impl ToolRegistryBuilder { .with_validation_tools() .with_tool_result_retrieval_tool() .with_runtime_task_tools() - .with_revert_turn_tool(); + .with_revert_turn_tool() + .with_pandoc_tools(); if allow_shell { builder.with_shell_tools()