feat(tools): add pandoc_convert tool — universal document conversion
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -117,6 +117,32 @@ pub fn resolve_pdftotext() -> Option<String> {
|
||||
.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<String> {
|
||||
static CACHE: OnceLock<Option<String>> = 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=<format>` 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<ToolCapability> {
|
||||
vec![
|
||||
ToolCapability::WritesFiles,
|
||||
ToolCapability::Sandboxable,
|
||||
ToolCapability::RequiresApproval,
|
||||
]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Suggest
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
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<PathBuf> = 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("<h1") && result.content.contains("Title"),
|
||||
"html output must contain the heading; got {}",
|
||||
result.content
|
||||
);
|
||||
assert!(
|
||||
result.content.contains("<code") || result.content.contains("inline code"),
|
||||
"html output must preserve inline code; got {}",
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pandoc_convert_writes_output_path_and_reports_summary() {
|
||||
if !pandoc_present() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let src = tmp.path().join("note.md");
|
||||
fs::write(&src, "# Title\n").unwrap();
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let result = PandocConvertTool
|
||||
.execute(
|
||||
json!({
|
||||
"source_path": "note.md",
|
||||
"target_format": "html",
|
||||
"output_path": "out.html",
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("wrote"));
|
||||
let written = fs::read_to_string(tmp.path().join("out.html")).expect("read");
|
||||
assert!(
|
||||
written.contains("Title"),
|
||||
"written file must contain converted body; got {written}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pandoc_convert_surfaces_missing_source_path_clearly() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let err = PandocConvertTool
|
||||
.execute(
|
||||
json!({"source_path": "missing.md", "target_format": "html"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect_err("nonexistent source must reject");
|
||||
assert!(
|
||||
err.to_string().contains("source_path") && err.to_string().contains("does not exist"),
|
||||
"error must call out missing source; got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,21 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(DiagnosticsTool))
|
||||
}
|
||||
|
||||
/// Include the `pandoc_convert` tool only when the `pandoc`
|
||||
/// binary is present on this host. Same probe-then-decide
|
||||
/// pattern v0.8.31 introduced for Python — when pandoc is
|
||||
/// missing the tool is not registered, so the model never
|
||||
/// sees a binary it can't actually use.
|
||||
#[must_use]
|
||||
pub fn with_pandoc_tools(self) -> 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()
|
||||
|
||||
Reference in New Issue
Block a user