diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 1e919c90..02e12285 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -49,7 +49,11 @@ jobs:
- name: Container execution substrate contract
run: tests/container-exec-contract.sh
-
+ - name: Conversion contracts
+ run: |
+ bash tests/document-convert-contract.sh
+ bash tests/image-convert-contract.sh
+ bash tests/media-convert-contract.sh
- name: Environment contract
run: tests/env-contract.sh
@@ -117,6 +121,9 @@ jobs:
- name: Smoke direct provider on minimum Graphify
run: bash tests/graphify-direct-provider-contract.sh
+ - name: Validate document merge round trip on minimum Graphify
+ run: bash tests/graphify-roundtrip-contract.sh
+
- name: Validate latest Graphify contract
run: |
python -m pip install --upgrade "graphifyy[openai]"
@@ -139,6 +146,9 @@ jobs:
- name: Smoke direct provider on latest Graphify
run: bash tests/graphify-direct-provider-contract.sh
+ - name: Validate document merge round trip on latest Graphify
+ run: bash tests/graphify-roundtrip-contract.sh
+
compose:
name: Compose contract
@@ -178,6 +188,18 @@ jobs:
- name: Validate published compatibility images
run: tests/published-images.sh
+ - name: Smoke host document conversion
+ run: |
+ set -euo pipefail
+ tmp="$(mktemp -d)"
+ trap 'rm -rf "$tmp"' EXIT
+ mkdir -p "$tmp/input" "$tmp/output"
+ printf '# LocalDevStack conversion\n\nPandoc-backed host document.\n' >"$tmp/input/source.md"
+ ./lds convert docs "$tmp/input/source.md" "$tmp/output/source.html" --standalone
+ grep -Fq '
` starts a temporary Tools container with the current host directory at
+`/workspace`, shares the running server-tools network/volumes, preserves stdin/TTY, and
+inherits the active LDS AI/Git runtime settings. Because `server-tools` owns the Docker
+socket and trusted control-plane/secret mounts, this runner is a privileged workstation
+context—not a sandbox—and should be used only with trusted commands from the Tools image.
+Use `lds tools run ...` when a tool name collides with an LDS `tools` subcommand.
+The older `tools sh/exec/file` forms continue to target the long-running control-plane
+container.
+
+## File conversion
+
+Pandoc is available from the Tools image without installing it on the host or starting
+the LocalDevStack services:
+
+```bash
+lds convert docs README.md README.html
+lds convert docs docs/guide.rst guide.docx --toc
+lds convert docs report.docx report.md --wrap=none
+lds convert docs book.md book.epub --toc
+lds convert docs --list-input-formats
+lds convert docs --list-output-formats
+lds convert image photo.jpg photo.webp -- -quality 82 -strip
+lds convert image animation.gif animation.webp
+lds convert image --formats
+lds convert audio recording.wav recording.mp3
+lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k
+lds convert video recording.mov recording.mp4
+lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0
+```
+
+Documents use Pandoc, images use ImageMagick, and audio/video use FFmpeg. The input mount is read-only and only the output mount is writable. The
+short-lived converter receives no Docker socket or LocalDevStack networks. Existing
+outputs require `--force`. PDF generation additionally depends on a PDF engine; the base
+Tools image ships Pandoc itself, not a TeX/PDF rendering stack.
## Optional local AI
Enable the `ai` profile through `lds setup profile`.
@@ -286,7 +341,7 @@ lds graphify ./your-project --mode deep
Nginx owns the loopback-only native route `127.0.0.1:11434 -> nginx:11434 -> llm:11434`. Provider containers do not publish host ports.
-`lds graphify` keeps the host Graphify CLI on `http://llm.localhost:11434/v1`, with no Graphify proxy service or Python adapter. When the Tools image supports `docstruct`, `.md/.rst/.yaml/.yml/.json/.toml/.ini/.cfg` files are extracted mechanically, optionally reviewed in bounded AI chunks, validated as a Graphify fragment, and merged into a reserved document layer; Graphify continues to own code ASTs and unsupported semantic formats. `LDS_GRAPHIFY_DOCSTRUCT=auto|on|off` and `LDS_GRAPHIFY_DOC_REVIEW=auto|on|off` control the handoff. Explicit `--code-only` remains code-only.
+`lds graphify` keeps the host Graphify CLI on `http://llm.localhost:11434/v1`, with no Graphify proxy service or Python adapter. When the Tools image supports `docstruct`, `.md/.rst/.yaml/.yml/.json/.toml/.ini/.cfg` files are extracted mechanically, optionally reviewed in bounded AI chunks, validated as a Graphify fragment, and merged into a staged reserved document layer. Before publication, LocalDevStack runs that staged graph through an isolated LLM-free `graphify cluster-only --no-label --no-viz` round trip and publishes only Graphify's canonical output; Graphify continues to own code ASTs and unsupported semantic formats. Document review retries one malformed structured response once before deterministic fallback. `LDS_GRAPHIFY_DOCSTRUCT=auto|on|off` and `LDS_GRAPHIFY_DOC_REVIEW=auto|on|off` control the handoff. Explicit `--code-only` remains code-only.
The built-in Compose layout keeps both provider definitions in `docker/compose/companion.yaml`, but runtime-generated profile selectors enable exactly one. NVIDIA/ROCm hardware augmentation is generated ephemerally under `docker/.runtime/`; FastFlow's `/dev/accel/accel0` + memlock contract lives in its tracked service definition.
diff --git a/bin/tool-runner b/bin/tool-runner
index fe32d71a..bdb8a8cf 100755
--- a/bin/tool-runner
+++ b/bin/tool-runner
@@ -63,7 +63,38 @@ server_tools_image() {
server_tools_env_value() {
local key="${1:?}"
"$(bin_path docker)" inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null \
- | awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }'
+ | awk -F= -v k="$key" '
+ $1 == k && !found {
+ sub(/^[^=]*=/, "")
+ print
+ found=1
+ }
+ END { if (!found) exit 1 }
+ '
+}
+
+stdin_has_data() {
+ [[ -p /dev/stdin || -f /dev/stdin ]]
+}
+
+append_server_tools_env() {
+ local key value
+ local -n target="${1:?}"
+
+ for key in \
+ TZ USERNAME GIT_USER_NAME GIT_USER_EMAIL GIT_CREDENTIAL_MODE \
+ LDS_AI_ENABLED LDS_AI_RUNTIME LDS_AI_MODEL LDS_AI_THINK \
+ LDS_AI_CONNECT_TIMEOUT LDS_AI_PREFLIGHT_TIMEOUT LDS_AI_TIMEOUT \
+ LDS_AI_AVAILABILITY_TTL LDS_AI_MAX_CONTEXT_BYTES \
+ LDS_AI_MAX_REQUEST_BYTES LDS_AI_MAX_RESPONSE_BYTES
+ do
+ value="$(server_tools_env_value "$key" || true)"
+ if [[ -n "$value" ]]; then
+ target+=(-e "$key=$value")
+ fi
+ done
+
+ return 0
}
resolve_workspace() {
@@ -100,15 +131,13 @@ main() {
server_tools_running || die "$SERVER_TOOLS_CONTAINER is not running"
server_tools_has "$cmd" || die "command '$cmd' not found in $SERVER_TOOLS_CONTAINER"
- local image workspace tz username home_dir
+ local image workspace home_dir
image="$(server_tools_image)" || die "unable to inspect $SERVER_TOOLS_CONTAINER image"
[[ -n "$image" ]] || die "unable to resolve $SERVER_TOOLS_CONTAINER image"
workspace="$(resolve_workspace)"
[[ -n "$workspace" ]] || die "unable to resolve workspace"
- tz="$(server_tools_env_value TZ || true)"
- username="$(server_tools_env_value USERNAME || true)"
home_dir="/home/root"
local -a flags envs
@@ -120,14 +149,15 @@ main() {
-e TERM="${TERM:-xterm-256color}"
)
- [[ -n "$tz" ]] && envs+=(-e TZ="$tz")
- [[ -n "$username" ]] && envs+=(-e USERNAME="$username")
+ append_server_tools_env envs
[[ -n "${COLORTERM:-}" ]] && envs+=(-e COLORTERM="$COLORTERM")
[[ -n "${NO_COLOR:-}" ]] && envs+=(-e NO_COLOR="$NO_COLOR")
[[ -n "${CLICOLOR_FORCE:-}" ]] && envs+=(-e CLICOLOR_FORCE="$CLICOLOR_FORCE")
[[ -n "${FORCE_COLOR:-}" ]] && envs+=(-e FORCE_COLOR="$FORCE_COLOR")
- [[ -t 0 ]] && flags+=(-i)
+ if [[ -t 0 ]] || stdin_has_data; then
+ flags+=(-i)
+ fi
[[ -t 1 ]] && flags+=(-t)
if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then
diff --git a/docs/guides/conversion.rst b/docs/guides/conversion.rst
new file mode 100644
index 00000000..cefd4bb6
--- /dev/null
+++ b/docs/guides/conversion.rst
@@ -0,0 +1,131 @@
+Conversion
+==========
+
+LocalDevStack exposes Pandoc from the Tools image as a host-file conversion command.
+Pandoc does not need to be installed on the workstation and the main LocalDevStack
+services do not need to be running.
+
+Basic Usage
+-----------
+
+Convert one host file to another::
+
+ lds convert docs README.md README.html
+ lds convert docs docs/guide.rst guide.docx
+ lds convert docs report.docx report.md
+ lds convert docs book.md book.epub --toc
+
+The first path is mounted read-only. Only the output directory is mounted writable.
+The short-lived conversion container receives no Docker socket, project volumes, or
+LocalDevStack networks.
+
+Pandoc Options
+--------------
+
+Arguments after the output path are passed to Pandoc without shell flattening::
+
+ lds convert docs README.md README.html --toc --standalone
+ lds convert docs report.docx report.md --wrap=none
+ lds convert docs book.md book.epub --metadata title="Developer Guide"
+
+An optional ``--`` separator is accepted::
+
+ lds convert docs README.md README.html -- --toc --standalone
+
+``-o`` / ``--output`` is intentionally rejected because LocalDevStack owns the output
+path through the second positional argument.
+
+Relative Assets
+---------------
+
+Pandoc runs with the input directory as its working directory and with
+``--resource-path=/lds-input``. Relative images and other resources next to the input
+document therefore remain available during conversion.
+
+Auxiliary file options such as a reference document should use files below the input
+directory and refer to them with relative paths.
+
+Overwrite Safety
+----------------
+
+Existing output files are not replaced unless ``--force`` is supplied::
+
+ lds convert docs --force README.md README.html
+
+The input and output may not resolve to the same file.
+
+Format Discovery
+----------------
+
+The supported readers and writers come from the Pandoc version currently shipped by the
+Tools image::
+
+ lds convert docs --list-input-formats
+ lds convert docs --list-output-formats
+ lds convert docs --version
+
+Pandoc supports many text/document formats, but not every format can be converted to
+every other format. PDF output additionally requires a compatible PDF engine; the base
+Tools image currently provides Pandoc itself, not a TeX/PDF rendering stack.
+
+Windows and Git Bash
+--------------------
+
+The converter normalizes Windows/Git Bash host paths before creating Docker mounts and
+disables MSYS argument rewriting for the Docker invocation. Paths containing spaces are
+preserved as individual argv values.
+
+Image Conversion
+----------------
+
+Raster/image conversion uses ImageMagick from the same Tools image::
+
+ lds convert image photo.jpg photo.png
+ lds convert image photo.png photo.webp -- -quality 82 -strip
+ lds convert image animation.gif animation.webp
+ lds convert image animation.gif preview.jpg
+
+Use ``lds convert image --formats`` to inspect the delegates/formats available in the current image. Static outputs such as JPEG and PNG use the first frame of animated inputs by default; animation-capable GIF/WebP outputs preserve frames when supported.
+
+Audio and Video Conversion
+--------------------------
+
+Audio and video conversion use FFmpeg from the Tools image::
+
+ lds convert audio recording.wav recording.mp3
+ lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k
+ lds convert video recording.mov recording.mp4
+ lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0
+
+The first-class media converter owns one input, overwrite policy, and one output path.
+FFmpeg output options are passed after the input as exact argv. LDS reserves ``-i``,
+``-y``, and ``-n`` because it owns input/output and ``--force`` behavior.
+
+Inspect FFmpeg capabilities with::
+
+ lds convert audio --formats
+ lds convert audio --codecs
+ lds convert audio --encoders
+ lds convert video --version
+
+For multi-input, concat, capture, or complex filtergraph workflows use the raw Tools
+surface instead::
+
+ lds tools ffmpeg ...
+ lds tools ffprobe media.mkv
+
+Specialist Media Tools
+----------------------
+
+The Tools image also exposes media utilities directly::
+
+ lds tools sox ...
+ lds tools soxi recording.wav
+ lds tools mkvmerge ...
+ lds tools mkvinfo media.mkv
+ lds tools mkvextract ...
+ lds tools mkvpropedit ...
+ lds tools mediainfo media.mkv
+
+``xvidcore`` is installed as an explicit FFmpeg codec runtime dependency; it is a
+library rather than a standalone command.
diff --git a/docs/guides/local-ai.rst b/docs/guides/local-ai.rst
index 42f51a1e..c79b3919 100644
--- a/docs/guides/local-ai.rst
+++ b/docs/guides/local-ai.rst
@@ -295,8 +295,12 @@ When the active docker-tools image exposes the docstruct Graphify handoff,
5. optional semantic review runs in bounded chunks against the active local model;
6. docker-tools emits a Graphify-compatible fragment;
7. Graphify's public ``merge-chunks`` validates that fragment;
-8. docker-tools atomically replaces only the reserved ``docstruct_`` semantic layer;
-9. ``graphify label`` reclusters and relabels the final combined graph.
+8. docker-tools replaces only the reserved ``docstruct_`` semantic layer in a staged graph;
+9. LocalDevStack copies that staged graph into an isolated temporary workspace and runs
+ ``graphify cluster-only --no-label --no-viz``; only Graphify's canonical round-trip
+ output is eligible for publication;
+10. the canonical graph is atomically published to ``graphify-out/graph.json``, then
+ ``graphify label`` reclusters and relabels the final combined graph.
This removes Markdown/RST/config parsing and recognized Python pip requirement manifests
from the fragile raw LLM extraction path while preserving Graphify's existing support for
@@ -321,7 +325,8 @@ Controls
``LDS_GRAPHIFY_DOC_REVIEW``:
- ``auto`` (default): review bounded document chunks on the built-in local provider;
- if review fails, keep the deterministic structure and continue;
+ docker-tools retries one malformed structured response once, then LocalDevStack keeps
+ the deterministic structure and continues if review still fails;
- ``on``: require semantic review to succeed;
- ``off``: use deterministic document structure only.
diff --git a/docs/index.rst b/docs/index.rst
index f496b401..378aec29 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -28,6 +28,7 @@ LocalDevStack is designed for trusted developer workstations, not production dep
guides/domain-setup
guides/databases-and-clients
+ guides/conversion
guides/tls-and-certificates
guides/local-ai
guides/operations-and-support
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index 53fa8143..0f243525 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -19,9 +19,10 @@ Windows
macOS
Docker Desktop.
-Docker is always a host-side requirement. Some developer utilities such as ``jq``,
-``yq``, ``rg``, ``fd``, ``tree``, and ``shellcheck`` can be proxied through a running
-``server-tools`` container when they are not installed on the host.
+Docker is always a host-side requirement. Bundled developer utilities are available through
+``lds tools `` without installing them on the host. The temporary runner mounts the
+current workspace and shares the running ``server-tools`` context. Use ``lds tools list``
+to see the curated Toolset/data/search/file utilities.
Recommended Layout
------------------
@@ -125,6 +126,23 @@ qualified target when names collide.
``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is
deliberately required.
+File conversion is also available without starting the stack::
+
+ lds convert docs README.md README.html
+
+Pandoc runs from the Tools image; use ``lds convert docs --list-output-formats`` to inspect the
+writers available in the current image.
+
+Image conversion uses ImageMagick::
+
+ lds convert image photo.jpg photo.webp -- -quality 82
+
+Audio/video conversion uses FFmpeg::
+
+ lds convert audio recording.wav recording.mp3
+ lds convert video recording.mov recording.mp4
+
+
Updating an Existing Installation
---------------------------------
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index 11ad0c24..b0310e73 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -108,6 +108,40 @@ Configuration
``config show`` is redacted by default.
+Conversion
+----------
+
+Pandoc-backed host conversion runs in a short-lived Tools container; the stack does not
+need to be running::
+
+ lds convert docs [--force] [--] [pandoc-options...]
+ lds convert docs --list-input-formats
+ lds convert docs --list-output-formats
+ lds convert docs --version
+
+The input directory is read-only and the output directory is the only writable host
+mount. Existing output requires ``--force``. ``-o`` / ``--output`` is reserved by LDS.
+
+Image conversion uses ImageMagick::
+
+ lds convert image [--force] [--] [imagemagick-options...]
+ lds convert image --formats
+ lds convert image --version
+
+Static JPEG/PNG-style outputs use the first frame of animated inputs; GIF/WebP outputs preserve animation when supported.
+
+Audio/video conversion uses FFmpeg::
+
+ lds convert audio [--force] [--] [ffmpeg-output-options...]
+ lds convert video [--force] [--] [ffmpeg-output-options...]
+ lds convert audio|video --formats
+ lds convert audio|video --codecs
+ lds convert audio|video --encoders
+ lds convert audio|video --version
+
+The first-class media converter owns one input and one output. Use ``lds tools ffmpeg``
+for multi-input/concat/capture/complex filtergraph workflows. ``ffprobe``, ``sox``,
+``soxi``, MKVToolNix commands, and ``mediainfo`` are available through ``lds tools``.
Certificates
------------
@@ -213,13 +247,29 @@ The older execution surfaces remain compatible during migration::
lds core [domain|service|container] [--] [command...]
lds cli [--] [command...]
lds stack exec [--] [command...]
+ lds tools list
+ lds tools [args...]
+ lds tools run [args...]
+ lds tools ui [args...]
lds tools sh
lds tools exec [--] [args...]
lds tools shell-exec
lds tools file
-``stack exec`` remains service-only. ``tools file`` remains inspection
-functionality rather than generic shell navigation.
+``tools `` and ``tools run`` start a short-lived Tools container with the
+current host workspace mounted at ``/workspace``, inherit the active server-tools
+network/volumes and selected LDS AI/Git environment, preserve stdin/TTY, then remove the
+container. The inherited volumes include the Docker socket and trusted control-plane /
+secret material, so this is a privileged workstation context rather than a sandbox and
+must be used only with trusted Tools-image commands. This is the preferred surface for
+Toolset utilities such as ``gitx``,
+``sqlitex``, ``chromacat``, and ``netx``, plus bundled utilities such as
+``jq``, ``yq``, ``rg``, ``fd``, ``tree``, ``shellcheck``,
+``ncdu``, ``zip``, and ``unzip``.
+
+``tools sh/exec/shell-exec/file`` intentionally remain operations against the
+long-running ``server-tools`` control-plane container. ``stack exec`` remains
+service-only.
Secrets
-------
@@ -262,11 +312,14 @@ Graphify's raw semantic LLM extractor. Other semantic formats remain Graphify-ow
Use ``LDS_GRAPHIFY_DOCSTRUCT=off`` to force the legacy path,
``LDS_GRAPHIFY_DOCSTRUCT=on`` to require the deterministic path, and
``LDS_GRAPHIFY_DOC_REVIEW=off|auto|on`` to control bounded semantic review.
-For a brand-new graph it performs a code-only ``extract --no-cluster`` first, clusters
-that structural graph, then performs a normal incremental ``extract --no-cluster`` to
-enrich docs/papers/images and reclusters and force-relabels the combined graph again. Existing graphs use a
-single incremental extract followed by one ``cluster-only`` pass. Explicit ``--code-only``
-remains a single structural build.
+For a brand-new docstruct-enabled graph it performs a code-only
+``extract --no-cluster``, then a second ``extract --no-cluster`` for semantic
+formats not owned by docstruct. The deterministic document layer is merged into a staged
+graph, and LocalDevStack runs an isolated LLM-free
+``graphify cluster-only --no-label --no-viz`` round trip before publishing it. The
+normal ``graphify label`` pass then relabels the canonical combined graph. Existing
+docstruct-enabled graphs use the same staged merge + canonicalization before labeling.
+Explicit ``--code-only`` remains a single structural build.
For the built-in local route, LocalDevStack creates a temporary Graphify provider
configuration that points directly to ``http://llm.localhost:11434/v1``. No Graphify
diff --git a/lds b/lds
index f5f44b79..a53d9f69 100755
--- a/lds
+++ b/lds
@@ -484,6 +484,9 @@ source "$DIR/lib/certificates.sh"
# shellcheck source=lib/services.sh
source "$DIR/lib/services.sh"
+# shellcheck source=lib/conversion.sh
+source "$DIR/lib/conversion.sh"
+
# ─────────────────────────────────────────────────────────────────────────────
# 6d. DIAG / SNIFF
# ─────────────────────────────────────────────────────────────────────────────
@@ -780,6 +783,10 @@ cmd_help() {
- `lds core [domain|service|container] [--] [command...]` — compatibility application/domain-aware execution with resolved working directory.
- `lds cli [--] [command...]` — generic current-project service or exact-container execution.
- `lds stack exec [--] [command...]` *(alias: `lds exec`)* — Compose-service-only execution.
+- `lds tools list` — show the curated Tools/Toolset catalog.
+- `lds tools [args...]` — run a Tools command in a temporary workspace-aware container.
+- `lds tools run [args...]` — explicit collision-safe form of the same runner.
+- `lds tools ui [args...]` — run lazydocker through the temporary Tools runner.
- `lds tools sh` — interactive shell in the project `server-tools` container.
- `lds tools exec [--] [args...]` — argv-preserving execution in `server-tools`.
- `lds tools shell-exec ` — intentional shell parsing in `server-tools`.
@@ -791,6 +798,15 @@ cmd_help() {
## AI consumer
- `lds ai status|ask|explain|troubleshoot|review|repo-review|graphify ...`
+## Conversion
+- `lds convert docs [--force] [--] [pandoc-options...]`
+- `lds convert docs --list-input-formats|--list-output-formats|--version`
+- `lds convert image [--force] [--] [imagemagick-options...]`
+- `lds convert image --formats|--version`
+- `lds convert audio [--force] [--] [ffmpeg-output-options...]`
+- `lds convert video [--force] [--] [ffmpeg-output-options...]`
+- `lds convert audio|video --formats|--codecs|--encoders|--version`
+
## Host Graphify workflow
- `lds graphify [path] [graphify-extract-options...]`
@@ -870,11 +886,21 @@ ${CYAN}Execution / Shells:${NC}
core [domain|service|container] [--] [command...] Compatibility domain/app entry
cli [--] [command...] Generic service/container exec
stack exec [--] [command...] Compose-service only
- tools sh|exec|shell-exec|file server-tools only
+ tools list|run|ui Curated/temporary Tools runner
+ tools [args...] Run a Tools command in workspace
+ tools sh|exec|shell-exec|file Long-running server-tools context
${CYAN}Secrets:${NC}
secrets
+${CYAN}Conversion:${NC}
+ convert docs [--force] [--] [pandoc-options...]
+ convert docs --list-input-formats|--list-output-formats|--version
+ convert image [--force] [--] [imagemagick-options...]
+ convert image --formats|--version
+ convert audio|video [--force] [--] [ffmpeg-output-options...]
+ convert audio|video --formats|--codecs|--encoders|--version
+
${CYAN}AI:${NC}
ai status|ask|explain|troubleshoot|review|repo-review|graphify
graphify [path] [graphify-extract-options...]
@@ -903,7 +929,7 @@ EOF
_is_public_lds_command() {
case "${1:-}" in
- stack|domain|support|bundle|up|start|down|stop|restart|reboot|status|ps|logs|exec|events|clean|config|http|host|setup|profiles|cert|certificate|doctor|diag|sniff|open|notify|ui|images|urls|tools|cli|core|shell|graphify|secrets|rebuild|run)
+ stack|domain|support|bundle|up|start|down|stop|restart|reboot|status|ps|logs|exec|events|clean|config|http|host|setup|profiles|cert|certificate|doctor|diag|sniff|open|notify|ui|images|urls|tools|cli|core|shell|convert|graphify|secrets|rebuild|run)
return 0
;;
esac
diff --git a/lib/ai.sh b/lib/ai.sh
index 8c0b1acd..eb2bbef9 100644
--- a/lib/ai.sh
+++ b/lib/ai.sh
@@ -320,6 +320,46 @@ _graphify_tools_put() {
docker cp "$host_file" "$_GRAPHIFY_TOOLS_CTR:$container_file" >/dev/null
}
+_graphify_canonicalize_staged_graph() {
+ local graphify_bin="$1" staged_graph="$2"
+ local roundtrip_dir roundtrip_input roundtrip_output rc=0
+
+ roundtrip_dir="$(mktemp -d "${TMPDIR:-/tmp}/lds-graphify-roundtrip.XXXXXX")" ||
+ die "Unable to create temporary Graphify round-trip directory"
+ chmod 700 "$roundtrip_dir" 2>/dev/null || true
+ roundtrip_input="$roundtrip_dir/staged.json"
+ roundtrip_output="$roundtrip_dir/graphify-out/graph.json"
+
+ if ! cp "$staged_graph" "$roundtrip_input"; then
+ rm -rf "$roundtrip_dir"
+ die "Unable to stage merged graph for Graphify round-trip validation"
+ fi
+
+ printf '%s\n' "[lds graphify] documents: canonicalizing merged graph through Graphify before publication" >&2
+ if (
+ cd "$roundtrip_dir"
+ "$graphify_bin" cluster-only --graph "$roundtrip_input" --no-label --no-viz >/dev/null
+ ); then
+ :
+ else
+ rc=$?
+ rm -rf "$roundtrip_dir"
+ return "$rc"
+ fi
+
+ if [[ ! -s "$roundtrip_output" ]]; then
+ rm -rf "$roundtrip_dir"
+ die "Graphify round-trip validation did not produce graph.json"
+ fi
+
+ if ! cp "$roundtrip_output" "$staged_graph"; then
+ rm -rf "$roundtrip_dir"
+ die "Unable to stage Graphify-canonical document merge"
+ fi
+
+ rm -rf "$roundtrip_dir"
+}
+
_graphify_docstruct_enrich() {
local graphify_bin="$1" target_abs="$2" review_mode="$3"
shift 3
@@ -407,6 +447,14 @@ _graphify_docstruct_enrich() {
return "$rc"
fi
+ if ! _graphify_canonicalize_staged_graph "$graphify_bin" "$publish_tmp"; then
+ rc=$?
+ rm -f "$publish_tmp"
+ _graphify_tools_session_cleanup
+ rm -rf "$workdir"
+ die "Merged document graph is not round-trip compatible with the installed Graphify"
+ fi
+
chmod 0644 "$publish_tmp" 2>/dev/null || true
if ! mv -f "$publish_tmp" "$graph_path"; then
rm -f "$publish_tmp"
diff --git a/lib/conversion.sh b/lib/conversion.sh
new file mode 100644
index 00000000..b6ccd167
--- /dev/null
+++ b/lib/conversion.sh
@@ -0,0 +1,428 @@
+# shellcheck shell=bash
+
+_CONVERT_TOOLS_IMAGE="infocyph/tools:latest"
+_CONVERT_INPUT_ABS=''
+_CONVERT_OUTPUT_ABS=''
+_CONVERT_INPUT_DIR=''
+_CONVERT_OUTPUT_DIR=''
+_CONVERT_INPUT_NAME=''
+_CONVERT_OUTPUT_NAME=''
+_CONVERT_INPUT_MOUNT=''
+_CONVERT_OUTPUT_MOUNT=''
+
+_convert_usage() {
+ cat <<'EOF'
+Usage:
+ lds convert docs [--force] [--] [pandoc-options...]
+ lds convert docs --list-input-formats|--list-output-formats|--version
+ lds convert image [--force] [--] [imagemagick-options...]
+ lds convert image --formats|--version
+ lds convert audio [--force] [--] [ffmpeg-output-options...]
+ lds convert audio --formats|--codecs|--encoders|--version
+ lds convert video [--force] [--] [ffmpeg-output-options...]
+ lds convert video --formats|--codecs|--encoders|--version
+
+Examples:
+ lds convert docs README.md README.html
+ lds convert docs docs/guide.rst guide.docx --toc
+ lds convert image photo.jpg photo.png
+ lds convert image photo.png photo.webp -- -quality 82
+ lds convert image animation.gif animation.webp
+ lds convert audio recording.wav recording.mp3
+ lds convert video recording.mov recording.mp4 -- -c:v libx264 -crf 23 -c:a aac
+EOF
+}
+
+_convert_docs_usage() {
+ cat <<'EOF'
+Usage:
+ lds convert docs [--force] [--] [pandoc-options...]
+ lds convert docs --list-input-formats
+ lds convert docs --list-output-formats
+ lds convert docs --version
+EOF
+}
+
+_convert_image_usage() {
+ cat <<'EOF'
+Usage:
+ lds convert image [--force] [--] [imagemagick-options...]
+ lds convert image --formats
+ lds convert image --version
+
+Examples:
+ lds convert image photo.jpg photo.png
+ lds convert image photo.png photo.webp -- -quality 82
+ lds convert image animation.gif animation.webp
+ lds convert image animation.gif preview.jpg
+
+GIF/WebP output preserves animation when supported by ImageMagick. Static output
+formats such as JPEG/PNG use the first frame of a multi-frame input by default.
+EOF
+}
+
+_convert_media_usage() {
+ local kind="${1:-media}"
+ cat < [--] [ffmpeg-output-options...]
+ lds convert $kind --formats
+ lds convert $kind --codecs
+ lds convert $kind --encoders
+ lds convert $kind --version
+
+Examples:
+ lds convert audio recording.wav recording.mp3
+ lds convert audio recording.wav recording.ogg -- -c:a libopus -b:a 128k
+ lds convert video recording.mov recording.mp4
+ lds convert video recording.mkv recording.webm -- -c:v libvpx-vp9 -crf 32 -b:v 0
+
+The first-class media converter owns the single input, overwrite policy and one
+output path. For multi-input, concat, capture, or advanced filtergraph workflows
+use "lds tools ffmpeg ..." directly.
+EOF
+}
+
+_convert_ffmpeg_capability() {
+ local option="${1:-}"
+ case "$option" in
+ --version)
+ _convert_engine ffmpeg -version
+ ;;
+ --formats)
+ _convert_engine ffmpeg -hide_banner -formats
+ ;;
+ --codecs)
+ _convert_engine ffmpeg -hide_banner -codecs
+ ;;
+ --encoders)
+ _convert_engine ffmpeg -hide_banner -encoders
+ ;;
+ *)
+ return 64
+ ;;
+ esac
+}
+
+_convert_reject_ffmpeg_owned_options() {
+ local kind="${1:-media}"
+ shift || true
+ local arg
+ for arg in "$@"; do
+ case "$arg" in
+ -i|-y|-n)
+ err "lds convert $kind owns FFmpeg input/output and overwrite selection; option $arg is not allowed"
+ return 64
+ ;;
+ esac
+ done
+}
+
+_convert_media() {
+ local kind="${1:-media}"
+ shift || true
+ local force=0 input='' output='' overwrite_flag=-n
+ local -a args=() run_args=()
+
+ case "${1:-}" in
+ ''|-h|--help|help)
+ _convert_media_usage "$kind"
+ return 0
+ ;;
+ --version|--formats|--codecs|--encoders)
+ _convert_ffmpeg_capability "$1"
+ return $?
+ ;;
+ --force)
+ force=1
+ overwrite_flag=-y
+ shift
+ ;;
+ esac
+
+ input="${1:-}"
+ output="${2:-}"
+ [[ -n "$input" && -n "$output" ]] || {
+ _convert_media_usage "$kind" >&2
+ return 64
+ }
+ shift 2
+ [[ "${1:-}" != '--' ]] || shift
+ args=("$@")
+ _convert_reject_ffmpeg_owned_options "$kind" "${args[@]}" || return $?
+ _convert_prepare_paths "$input" "$output" "$force" || return $?
+
+ run_args=(
+ run --rm --pull=missing --network none
+ --user "$(id -u):$(id -g)"
+ -e HOME=/tmp
+ -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro"
+ -v "$_CONVERT_OUTPUT_MOUNT:/lds-output"
+ --entrypoint ffmpeg
+ "$_CONVERT_TOOLS_IMAGE"
+ -hide_banner
+ -nostdin
+ "$overwrite_flag"
+ -i "/lds-input/$_CONVERT_INPUT_NAME"
+ )
+ run_args+=("${args[@]}")
+ run_args+=("/lds-output/$_CONVERT_OUTPUT_NAME")
+ _convert_docker "${run_args[@]}"
+}
+
+_convert_host_fs_path() {
+ local path="${1:-}"
+ [[ -n "$path" ]] || return 1
+
+ if [[ "$path" =~ ^[A-Za-z]:[\\/].* ]] && has_bin cygpath; then
+ cygpath -u "$path"
+ return $?
+ fi
+
+ printf '%s' "$path"
+}
+
+_convert_abs_existing_file() {
+ local raw="${1:-}" path
+ path="$(_convert_host_fs_path "$raw")" || return 1
+ [[ -f "$path" ]] || return 1
+ _realpath "$path"
+}
+
+_convert_abs_output() {
+ local raw="${1:-}" path dir base abs_dir
+ path="$(_convert_host_fs_path "$raw")" || return 1
+ dir="$(dirname -- "$path")"
+ base="$(basename -- "$path")"
+ [[ -d "$dir" ]] || return 1
+ abs_dir="$(cd -P -- "$dir" 2>/dev/null && pwd -P)" || return 1
+ printf '%s/%s' "$abs_dir" "$base"
+}
+
+_convert_docker_mount_path() {
+ local path="${1:-}"
+ [[ -n "$path" ]] || return 1
+
+ if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]] && has_bin cygpath; then
+ cygpath -w "$path"
+ return $?
+ fi
+
+ printf '%s' "$path"
+}
+
+_convert_prepare_paths() {
+ local input="${1:-}" output="${2:-}" force="${3:-0}" existing_output_abs
+
+ _CONVERT_INPUT_ABS="$(_convert_abs_existing_file "$input")" || {
+ err "Input file not found or is not a regular file: $input"
+ return 66
+ }
+ _CONVERT_OUTPUT_ABS="$(_convert_abs_output "$output")" || {
+ err "Output directory does not exist: $(dirname -- "$output")"
+ return 66
+ }
+
+ [[ "$_CONVERT_INPUT_ABS" != "$_CONVERT_OUTPUT_ABS" ]] || {
+ err "Input and output must be different files"
+ return 64
+ }
+ if [[ -e "$_CONVERT_OUTPUT_ABS" ]]; then
+ existing_output_abs="$(_realpath "$_CONVERT_OUTPUT_ABS" 2>/dev/null || true)"
+ [[ -z "$existing_output_abs" || "$existing_output_abs" != "$_CONVERT_INPUT_ABS" ]] || {
+ err "Input and output must be different files"
+ return 64
+ }
+ fi
+ if [[ -e "$_CONVERT_OUTPUT_ABS" && "$force" -ne 1 ]]; then
+ err "Output already exists: $_CONVERT_OUTPUT_ABS (use --force to replace it)"
+ return 73
+ fi
+
+ _CONVERT_INPUT_DIR="$(dirname -- "$_CONVERT_INPUT_ABS")"
+ _CONVERT_OUTPUT_DIR="$(dirname -- "$_CONVERT_OUTPUT_ABS")"
+ _CONVERT_INPUT_NAME="$(basename -- "$_CONVERT_INPUT_ABS")"
+ _CONVERT_OUTPUT_NAME="$(basename -- "$_CONVERT_OUTPUT_ABS")"
+ _CONVERT_INPUT_MOUNT="$(_convert_docker_mount_path "$_CONVERT_INPUT_DIR")" || return 66
+ _CONVERT_OUTPUT_MOUNT="$(_convert_docker_mount_path "$_CONVERT_OUTPUT_DIR")" || return 66
+}
+
+_convert_docker() {
+ if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then
+ (
+ export MSYS_NO_PATHCONV=1
+ export MSYS2_ARG_CONV_EXCL='*'
+ "$(bin_path docker)" "$@"
+ )
+ else
+ "$(bin_path docker)" "$@"
+ fi
+}
+
+_convert_engine() {
+ local entrypoint="${1:-}"
+ shift || true
+ [[ -n "$entrypoint" ]] || return 64
+ _convert_docker run --rm --pull=missing --network none --entrypoint "$entrypoint" "$_CONVERT_TOOLS_IMAGE" "$@"
+}
+
+_convert_reject_pandoc_output_option() {
+ local arg
+ for arg in "$@"; do
+ case "$arg" in
+ -o|-o?*|--output|--output=*)
+ err "lds convert docs owns Pandoc output selection; use the LDS output path instead of $arg"
+ return 64
+ ;;
+ esac
+ done
+}
+
+_convert_reject_imagemagick_output_option() {
+ local arg
+ for arg in "$@"; do
+ case "$arg" in
+ -write|+write)
+ err "lds convert image owns ImageMagick output selection; additional -write outputs are not allowed"
+ return 64
+ ;;
+ esac
+ done
+}
+
+_convert_image_static_output() {
+ local ext="${1##*.}"
+ ext="${ext,,}"
+ case "$ext" in
+ jpg|jpeg|jpe|png|bmp|ico) return 0 ;;
+ esac
+ return 1
+}
+
+_convert_docs() {
+ local force=0 input='' output=''
+ local -a args=() run_args=()
+
+ case "${1:-}" in
+ ''|-h|--help|help)
+ _convert_docs_usage
+ return 0
+ ;;
+ --version|--list-input-formats|--list-output-formats)
+ _convert_engine pandoc "$1"
+ return $?
+ ;;
+ --force)
+ force=1
+ shift
+ ;;
+ esac
+
+ input="${1:-}"
+ output="${2:-}"
+ [[ -n "$input" && -n "$output" ]] || {
+ _convert_docs_usage >&2
+ return 64
+ }
+ shift 2
+ [[ "${1:-}" != '--' ]] || shift
+ args=("$@")
+ _convert_reject_pandoc_output_option "${args[@]}" || return $?
+ _convert_prepare_paths "$input" "$output" "$force" || return $?
+
+ run_args=(
+ run --rm --pull=missing --network none
+ --user "$(id -u):$(id -g)"
+ -e HOME=/tmp
+ -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro"
+ -v "$_CONVERT_OUTPUT_MOUNT:/lds-output"
+ -w /lds-input
+ --entrypoint pandoc
+ "$_CONVERT_TOOLS_IMAGE"
+ --resource-path=/lds-input
+ "./$_CONVERT_INPUT_NAME"
+ -o "/lds-output/$_CONVERT_OUTPUT_NAME"
+ )
+ run_args+=("${args[@]}")
+ _convert_docker "${run_args[@]}"
+}
+
+_convert_image() {
+ local force=0 input='' output='' image_input
+ local -a args=() run_args=()
+
+ case "${1:-}" in
+ ''|-h|--help|help)
+ _convert_image_usage
+ return 0
+ ;;
+ --version)
+ _convert_engine magick -version
+ return $?
+ ;;
+ --formats|--list-formats)
+ _convert_engine magick -list format
+ return $?
+ ;;
+ --force)
+ force=1
+ shift
+ ;;
+ esac
+
+ input="${1:-}"
+ output="${2:-}"
+ [[ -n "$input" && -n "$output" ]] || {
+ _convert_image_usage >&2
+ return 64
+ }
+ shift 2
+ [[ "${1:-}" != '--' ]] || shift
+ args=("$@")
+ _convert_reject_imagemagick_output_option "${args[@]}" || return $?
+ _convert_prepare_paths "$input" "$output" "$force" || return $?
+
+ image_input="/lds-input/$_CONVERT_INPUT_NAME"
+ if _convert_image_static_output "$_CONVERT_OUTPUT_NAME"; then
+ image_input+='[0]'
+ fi
+
+ run_args=(
+ run --rm --pull=missing --network none
+ --user "$(id -u):$(id -g)"
+ -e HOME=/tmp
+ -v "$_CONVERT_INPUT_MOUNT:/lds-input:ro"
+ -v "$_CONVERT_OUTPUT_MOUNT:/lds-output"
+ --entrypoint magick
+ "$_CONVERT_TOOLS_IMAGE"
+ "$image_input"
+ )
+ run_args+=("${args[@]}")
+ run_args+=("/lds-output/$_CONVERT_OUTPUT_NAME")
+ _convert_docker "${run_args[@]}"
+}
+
+cmd_convert() {
+ local kind="${1:-}"
+ shift || true
+
+ case "${kind,,}" in
+ docs)
+ _convert_docs "$@"
+ ;;
+ image)
+ _convert_image "$@"
+ ;;
+ audio | video)
+ _convert_media "${kind,,}" "$@"
+ ;;
+ ''|-h|--help|help)
+ _convert_usage
+ ;;
+ *)
+ err "Unknown conversion type: $kind (expected docs, image, audio, or video)"
+ _convert_usage >&2
+ return 64
+ ;;
+ esac
+}
diff --git a/lib/services.sh b/lib/services.sh
index 66869d7d..af936d46 100644
--- a/lib/services.sh
+++ b/lib/services.sh
@@ -826,12 +826,98 @@ cmd_rebuild() {
}
+_tools_catalog_print() {
+ cat <<'EOF'
+Tools / Toolset catalog
+
+Toolset:
+ gitx Git workflow, summaries, worklogs and commit helpers
+ sqlitex SQLite administration, migrations, backup/export and tuning
+ chromacat Pipeline-safe colour/log/banner presentation
+ netx Network/DNS/TLS/HTTP diagnostics from the Tools network context
+
+Media:
+ ffmpeg audio/video transcode, remux and filter
+ ffprobe stream/container probing
+ sox audio processing/effects
+ soxi audio metadata inspection
+ mkvmerge Matroska muxing/remuxing
+ mkvinfo Matroska structure inspection
+ mkvextract Matroska stream/attachment extraction
+ mkvpropedit Matroska property editing
+ mediainfo media/container metadata inspection
+ xvidcore codec runtime used by FFmpeg (library; no standalone CLI)
+
+Data / search:
+ jq JSON processor
+ yq YAML processor
+ rg ripgrep
+ fd file finder
+
+Files / quality:
+ tree directory tree
+ shellcheck shell static analysis
+ ncdu terminal disk-usage browser
+ zip ZIP archive creator
+ unzip ZIP archive extractor
+
+TUI:
+ lazydocker Docker terminal UI (also: lds support ui)
+
+Usage:
+ lds tools [args...]
+ lds tools run [args...]
+ lds tools list
+
+Examples:
+ lds tools gitx status
+ lds tools sqlitex --db app.db tables
+ cat app.log | lds tools chromacat --log
+ lds tools netx route show
+ lds tools ffprobe media.mkv
+ lds tools mediainfo media.mkv
+ lds tools soxi recording.wav
+ lds tools jq --version
+
+Any non-reserved tool name is delegated to the current Tools image. Use
+"lds tools run " when a tool name collides with an LDS tools subcommand.
+EOF
+}
+
+_tools_runner_exec() {
+ (($# > 0)) || {
+ err "Usage: lds tools run [args...]"
+ return 64
+ }
+ "$DIR/bin/tool-runner" "$@"
+}
+
cmd_tools() {
local sub="${1:-sh}"
shift || true
- _shell_context_reset
- _shell_resolve_tools || return $?
+ case "${sub,,}" in
+ list | catalog | help | -h | --help)
+ _tools_catalog_print
+ return 0
+ ;;
+ run)
+ _tools_runner_exec "$@"
+ return $?
+ ;;
+ ui)
+ _tools_runner_exec lazydocker "$@"
+ return $?
+ ;;
+ sh | shell | "" | exec | shell-exec | file)
+ _shell_context_reset
+ _shell_resolve_tools || return $?
+ ;;
+ *)
+ _tools_runner_exec "$sub" "$@"
+ return $?
+ ;;
+ esac
case "${sub,,}" in
sh | shell | "")
@@ -865,7 +951,8 @@ cmd_tools() {
' sh "$path"
;;
*)
- die "tools "
+ err "Usage: lds tools "
+ return 64
;;
esac
}
diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh
index fc47583c..b8cbb4c0 100755
--- a/tests/cli-contract.sh
+++ b/tests/cli-contract.sh
@@ -13,7 +13,7 @@ assert_contains "$help_output" "Setup"
assert_contains "$help_output" "Execution / Shells"
assert_contains "$help_output" "Generic service/container exec"
assert_contains "$help_output" "Compose-service only"
-assert_contains "$help_output" "server-tools only"
+assert_contains "$help_output" "Long-running server-tools context"
pass "lds help"
markdown_output="$("$ROOT/lds" help --markdown)"
@@ -55,7 +55,7 @@ if grep -Fq 'declare -F "cmd_$cmd"' "$ROOT/lds"; then
fail "top-level dispatch still exposes arbitrary cmd_* functions dynamically"
fi
assert_file_contains "$ROOT/lds" 'stack|domain|support|bundle|up|start'
-assert_file_contains "$ROOT/lds" 'tools|cli|core|shell|graphify|secrets|rebuild|run)'
+assert_file_contains "$ROOT/lds" 'tools|cli|core|shell|convert|graphify|secrets|rebuild|run)'
pass "top-level LDS command routing is explicit and collision-safe"
if PATH="$tmpbin:$PATH" "$ROOT/lds" graphify --help 2>&1 | grep -Fq 'SERVER_TOOLS is not running'; then
@@ -67,6 +67,24 @@ assert_contains "$help_output" "shell [target]"
assert_contains "$markdown_output" "lds shell "
pass "unified shell is exposed in embedded help"
+assert_contains "$help_output" "convert docs [--force] "
+assert_contains "$markdown_output" "lds convert docs [--force] "
+assert_contains "$markdown_output" "lds convert docs --list-input-formats"
+assert_contains "$markdown_output" "lds convert image [--force] "
+assert_contains "$markdown_output" "lds convert image --formats"
+assert_contains "$help_output" "convert audio|video [--force] "
+assert_contains "$markdown_output" "lds convert audio [--force] "
+assert_contains "$markdown_output" "lds convert video [--force] "
+assert_contains "$markdown_output" "lds convert audio|video --formats|--codecs|--encoders|--version"
+pass "docs image audio and video conversion are exposed in embedded help"
+
+assert_contains "$help_output" "tools list|run|ui"
+assert_contains "$help_output" "tools [args...]"
+assert_contains "$markdown_output" "lds tools list"
+assert_contains "$markdown_output" "lds tools [args...]"
+assert_contains "$markdown_output" "lds tools run [args...]"
+pass "Tools catalog and temporary runner are exposed in embedded help"
+
graphify_log="$(mktemp)"
cat >"$tmpbin/graphify" <<'SH'
#!/usr/bin/env sh
@@ -282,6 +300,18 @@ if [[ "${1:-}" == merge-chunks ]]; then
esac
done
cp "$input" "$out"
+elif [[ "${1:-}" == cluster-only ]]; then
+ shift
+ graph=''
+ while (($#)); do
+ case "$1" in
+ --graph) graph="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+ [[ -n "$graph" ]] || exit 96
+ mkdir -p graphify-out
+ cp "$graph" graphify-out/graph.json
fi
SH
chmod +x "$graphify_hybrid"
@@ -323,6 +353,10 @@ SH
fail "external graph publication failed"
grep -Fq 'merge-chunks ' "$graphify_hybrid_log" ||
fail "Graphify public fragment validation was not invoked"
+ grep -Fq 'cluster-only --graph ' "$graphify_hybrid_log" ||
+ fail "Graphify merged graph round-trip canonicalization was not invoked"
+ grep -Fq -- '--no-label --no-viz' "$graphify_hybrid_log" ||
+ fail "Graphify merged graph round-trip must stay deterministic and LLM-free"
[[ "$(_graphify_docstruct_mode)" == auto ]] ||
fail "Graphify docstruct default mode drifted"
@@ -351,6 +385,9 @@ assert_file_contains "$ROOT/lib/ai.sh" 'server-tools tail -f /dev/null'
assert_file_contains "$ROOT/lib/ai.sh" 'docker exec -i "$_GRAPHIFY_TOOLS_CTR"'
assert_file_contains "$ROOT/lib/ai.sh" 'docker rm -f "$ctr"'
assert_file_contains "$ROOT/lib/ai.sh" 'mktemp "$target_abs/graphify-out/.graph.json.docstruct.XXXXXX"'
+assert_file_contains "$ROOT/lib/ai.sh" '_graphify_canonicalize_staged_graph'
+assert_file_contains "$ROOT/lib/ai.sh" 'cluster-only --graph "$roundtrip_input" --no-label --no-viz'
+assert_file_contains "$ROOT/lib/ai.sh" 'documents: canonicalizing merged graph through Graphify before publication'
assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'requirements*.txt'"
assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'constraints*.txt'"
assert_file_contains "$ROOT/lib/ai.sh" "--exclude 'requirements/*.txt'"
diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh
index f3411e88..afb961e3 100644
--- a/tests/docs-contract.sh
+++ b/tests/docs-contract.sh
@@ -15,18 +15,51 @@ domain="$ROOT/docs/guides/domain-setup.rst"
tls="$ROOT/docs/guides/tls-and-certificates.rst"
ai="$ROOT/docs/guides/local-ai.rst"
databases="$ROOT/docs/guides/databases-and-clients.rst"
+conversion="$ROOT/docs/guides/conversion.rst"
ops="$ROOT/docs/guides/operations-and-support.rst"
runner="$ROOT/docs/guides/ad-hoc-runner.rst"
notify="$ROOT/docs/guides/notifications.rst"
secrets="$ROOT/docs/guides/secrets-sops-age.rst"
cli="$ROOT/docs/reference/cli.rst"
-for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$ops" "$runner" "$notify" "$secrets" "$cli"; do
+for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$conversion" "$ops" "$runner" "$notify" "$secrets" "$cli"; do
assert_file "$file"
done
assert_file_contains "$index" 'guides/local-ai'
assert_file_contains "$index" 'guides/databases-and-clients'
+assert_file_contains "$index" 'guides/conversion'
+assert_file_contains "$conversion" 'lds convert docs README.md README.html'
+assert_file_contains "$conversion" 'lds convert docs --list-output-formats'
+assert_file_contains "$conversion" 'lds convert image photo.jpg photo.png'
+assert_file_contains "$conversion" 'lds convert image --formats'
+assert_file_contains "$conversion" 'lds convert audio recording.wav recording.mp3'
+assert_file_contains "$conversion" 'lds convert video recording.mov recording.mp4'
+assert_file_contains "$conversion" 'lds tools ffprobe media.mkv'
+assert_file_contains "$conversion" 'lds tools mkvinfo media.mkv'
+assert_file_contains "$conversion" 'lds tools mediainfo media.mkv'
+assert_file_contains "$cli" 'lds convert audio [--force] '
+assert_file_contains "$cli" 'lds convert video [--force] '
+assert_file_contains "$quick" 'lds convert audio recording.wav recording.mp3'
+assert_file_contains "$quick" 'lds convert video recording.mov recording.mp4'
+assert_file_contains "$readme" 'lds tools ffprobe media.mkv'
+assert_file_contains "$readme" 'lds tools mediainfo media.mkv'
+assert_file_contains "$conversion" 'no Docker socket'
+assert_file_contains "$cli" 'lds convert docs [--force] '
+assert_file_contains "$cli" 'lds convert image [--force] '
+assert_file_contains "$quick" 'lds convert docs README.md README.html'
+assert_file_contains "$quick" 'lds convert image photo.jpg photo.webp'
+assert_file_contains "$readme" 'lds convert docs docs/guide.rst guide.docx --toc'
+assert_file_contains "$readme" 'lds convert image animation.gif animation.webp'
+assert_file_contains "$readme" 'lds tools gitx status'
+assert_file_contains "$readme" 'lds tools sqlitex --db app.db tables'
+assert_file_contains "$readme" 'cat app.log | lds tools chromacat --log'
+assert_file_contains "$quick" 'lds tools list'
+assert_file_contains "$cli" 'lds tools [args...]'
+assert_file_contains "$cli" 'lds tools run [args...]'
+assert_file_contains "$cli" 'Docker socket'
+assert_file_contains "$cli" 'privileged workstation context'
+assert_file_contains "$readme" 'not a sandbox'
assert_file_contains "$index" 'guides/operations-and-support'
assert_file_contains "$index" 'guides/ad-hoc-runner'
assert_file_contains "$index" 'reference/cli'
@@ -107,6 +140,9 @@ assert_file_contains "$ai" '.md .markdown .rst .yaml .yml .json .toml .ini .cfg'
assert_file_contains "$ai" 'LDS_GRAPHIFY_DOCSTRUCT'
assert_file_contains "$ai" 'LDS_GRAPHIFY_DOC_REVIEW'
assert_file_contains "$ai" '--token-budget 3000'
+assert_file_contains "$ai" 'cluster-only --no-label --no-viz'
+assert_file_contains "$ai" 'retries one malformed structured response once'
+assert_file_contains "$cli" 'cluster-only --no-label --no-viz'
assert_file_contains "$ai" 'There is no LocalDevStack Graphify HTTP proxy or Python compatibility adapter.'
assert_file_contains "$ai" 'Graphify'
assert_file_contains "$ai" 'docstruct'
@@ -157,7 +193,7 @@ pass "documentation toctree targets exist"
help_md="$("$ROOT/lds" help --markdown)"
-for required in 'lds profiles add ' 'lds support trace ' 'lds support bundle [--redact|--full] [output.zip]' 'lds shell [--] [args...]' 'lds shell --shell ' 'lds cli [--] [command...]' 'lds core [domain|service|container] [--] [command...]' 'lds stack exec [--] [command...]' 'lds tools exec [--] [args...]' 'lds tools shell-exec ' 'lds run shell|ps|logs|stop|rm|open' 'MongoDB:' 'Elasticsearch:'; do
+for required in 'lds profiles add ' 'lds support trace ' 'lds support bundle [--redact|--full] [output.zip]' 'lds shell [--] [args...]' 'lds shell --shell ' 'lds cli [--] [command...]' 'lds core [domain|service|container] [--] [command...]' 'lds stack exec [--] [command...]' 'lds tools list' 'lds tools [args...]' 'lds tools run [args...]' 'lds tools exec [--] [args...]' 'lds tools shell-exec ' 'lds run shell|ps|logs|stop|rm|open' 'MongoDB:' 'Elasticsearch:'; do
assert_contains "$help_md" "$required"
done
pass "embedded CLI help covers documented command groups"
@@ -171,6 +207,9 @@ assert_file_contains "$cli" 'Image'
assert_file_contains "$cli" 'lds core [domain|service|container] [--] [command...]'
assert_file_contains "$cli" 'lds cli [--] [command...]'
assert_file_contains "$cli" 'lds stack exec [--] [command...]'
+assert_file_contains "$cli" 'lds tools list'
+assert_file_contains "$cli" 'lds tools [args...]'
+assert_file_contains "$cli" 'lds tools run [args...]'
assert_file_contains "$cli" 'lds tools exec [--] [args...]'
assert_file_contains "$cli" 'lds tools shell-exec '
assert_file_contains "$cli" 'preserve argv exactly'
diff --git a/tests/document-convert-contract.sh b/tests/document-convert-contract.sh
new file mode 100644
index 00000000..30850aeb
--- /dev/null
+++ b/tests/document-convert-contract.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=tests/lib/assertions.sh
+source "$ROOT/tests/lib/assertions.sh"
+
+tmp="$(mktemp -d)"
+trap 'rm -rf -- "$tmp"' EXIT
+
+bin="$tmp/bin"
+mkdir -p "$bin" "$tmp/Input Docs" "$tmp/Output Docs"
+log="$tmp/docker.log"
+: >"$log"
+
+cat >"$bin/docker" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+: "${DOCUMENT_CONVERT_TEST_LOG:?}"
+printf '%s\n' '---' >>"$DOCUMENT_CONVERT_TEST_LOG"
+for arg in "$@"; do
+ printf '<%s>\n' "$arg" >>"$DOCUMENT_CONVERT_TEST_LOG"
+done
+
+out_host=''
+out_name=''
+args=("$@")
+for ((i = 0; i < ${#args[@]}; i++)); do
+ if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then
+ mount="${args[$((i + 1))]}"
+ if [[ "$mount" == *":/lds-output" ]]; then
+ out_host="${mount%:/lds-output}"
+ fi
+ fi
+ if [[ "${args[$i]}" == "-o" && $((i + 1)) -lt ${#args[@]} ]]; then
+ target="${args[$((i + 1))]}"
+ out_name="${target#/lds-output/}"
+ fi
+done
+
+case " $* " in
+ *" --list-input-formats "*)
+ printf '%s\n' markdown rst html docx epub
+ exit 0
+ ;;
+ *" --list-output-formats "*)
+ printf '%s\n' html5 markdown docx epub
+ exit 0
+ ;;
+ *" --version "*)
+ printf '%s\n' 'pandoc 3.test'
+ exit 0
+ ;;
+esac
+
+if [[ -n "$out_host" && -n "$out_name" ]]; then
+ mkdir -p -- "$(dirname -- "$out_host/$out_name")"
+ printf '%s\n' 'converted' >"$out_host/$out_name"
+fi
+SH
+chmod +x "$bin/docker"
+
+input="$tmp/Input Docs/Guide File.md"
+output="$tmp/Output Docs/Guide File.html"
+printf '# Guide\n' >"$input"
+
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$output" --toc --standalone
+
+[[ -f "$output" ]] || fail "lds convert docs did not publish the host output file"
+grep -Fq "<$tmp/Input Docs:/lds-input:ro>" "$log" ||
+ fail "lds convert docs did not mount the input directory read-only"
+grep -Fq "<$tmp/Output Docs:/lds-output>" "$log" ||
+ fail "lds convert docs did not mount the output directory writable"
+grep -Fq '<--entrypoint>' "$log" || fail "lds convert docs did not use an explicit Pandoc entrypoint"
+grep -Fq '' "$log" || fail "lds convert docs did not invoke Pandoc"
+grep -Fq '<--network>' "$log" || fail "lds convert docs did not disable container networking"
+grep -Fq '' "$log" || fail "lds convert docs did not use the none network"
+grep -Fq '<--user>' "$log" || fail "lds convert docs did not preserve host output ownership"
+grep -Fq '<--resource-path=/lds-input>' "$log" ||
+ fail "lds convert docs did not preserve relative input resources"
+grep -Fq '<./Guide File.md>' "$log" || fail "input filename with spaces was not preserved"
+grep -Fq '' "$log" || fail "output filename with spaces was not preserved"
+grep -Fq '<--toc>' "$log" || fail "Pandoc option passthrough lost --toc"
+grep -Fq '<--standalone>' "$log" || fail "Pandoc option passthrough lost --standalone"
+if grep -Fq '/var/run/docker.sock' "$log"; then
+ fail "lds convert docs must not expose the Docker socket"
+fi
+if grep -Fq '' "$log"; then
+ fail "lds convert docs must not require a running Compose stack"
+fi
+pass "containerized host document conversion"
+
+set +e
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$output" >/dev/null 2>"$tmp/existing.err"
+rc=$?
+set -e
+[[ "$rc" -eq 73 ]] || fail "existing output returned $rc instead of 73"
+grep -Fq 'use --force to replace it' "$tmp/existing.err" ||
+ fail "existing output refusal did not explain --force"
+
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --force "$input" "$output" --wrap=none
+grep -Fq '<--wrap=none>' "$log" || fail "--force conversion lost Pandoc options"
+pass "document conversion overwrite protection"
+
+before="$(wc -l <"$log" | tr -d '[:space:]')"
+set +e
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --force "$input" "$output" -- -oelsewhere.html >/dev/null 2>"$tmp/output-option.err"
+rc=$?
+set -e
+after="$(wc -l <"$log" | tr -d '[:space:]')"
+[[ "$rc" -eq 64 ]] || fail "conflicting Pandoc output option returned $rc instead of 64"
+[[ "$before" == "$after" ]] || fail "conflicting output option reached Docker"
+grep -Fq 'owns Pandoc output selection' "$tmp/output-option.err" ||
+ fail "conflicting output option diagnostic missing"
+ln -s "$input" "$tmp/Output Docs/input-link.md"
+before="$(wc -l <"$log" | tr -d '[:space:]')"
+set +e
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert docs --force "$input" "$tmp/Output Docs/input-link.md" >/dev/null 2>"$tmp/same.err"
+rc=$?
+set -e
+after="$(wc -l <"$log" | tr -d '[:space:]')"
+[[ "$rc" -eq 64 ]] || fail "input/output symlink collision returned $rc instead of 64"
+[[ "$before" == "$after" ]] || fail "input/output symlink collision reached Docker"
+grep -Fq 'Input and output must be different files' "$tmp/same.err" ||
+ fail "input/output symlink collision diagnostic missing"
+pass "document conversion blocks in-place and symlink overwrite"
+
+formats="$(
+ DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --list-input-formats
+)"
+grep -qx 'markdown' <<<"$formats" || fail "input format discovery did not reach Pandoc"
+version="$(
+ DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs --version
+)"
+grep -q '^pandoc ' <<<"$version" || fail "Pandoc version discovery failed"
+pass "document conversion capability discovery"
+
+set +e
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$tmp/missing.md" "$tmp/Output Docs/missing.html" >/dev/null 2>"$tmp/missing.err"
+rc=$?
+set -e
+[[ "$rc" -eq 66 ]] || fail "missing input returned $rc instead of 66"
+
+set +e
+DOCUMENT_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert docs "$input" "$tmp/no-such-dir/output.html" >/dev/null 2>"$tmp/outdir.err"
+rc=$?
+set -e
+[[ "$rc" -eq 66 ]] || fail "missing output directory returned $rc instead of 66"
+pass "document conversion validates host paths"
diff --git a/tests/execution-contract.sh b/tests/execution-contract.sh
index 0aa45635..ad6ef236 100755
--- a/tests/execution-contract.sh
+++ b/tests/execution-contract.sh
@@ -731,6 +731,64 @@ assert_file_contains "$log" ' <-lc>'
assert_file_contains "$log" ' '
pass "batch 4: lds tools file passes paths as shell positional argv rather than interpolating them"
+case_tools_catalog_offline() {
+ _project_tools_container_running() { return 1; }
+ cmd_tools list >"$tmp/tools-catalog.out"
+}
+run_case case_tools_catalog_offline
+assert_file_contains "$tmp/tools-catalog.out" 'gitx'
+assert_file_contains "$tmp/tools-catalog.out" 'sqlitex'
+assert_file_contains "$tmp/tools-catalog.out" 'chromacat'
+assert_file_contains "$tmp/tools-catalog.out" 'netx'
+assert_file_contains "$tmp/tools-catalog.out" 'lazydocker'
+assert_file_contains "$tmp/tools-catalog.out" 'ffmpeg'
+assert_file_contains "$tmp/tools-catalog.out" 'ffprobe'
+assert_file_contains "$tmp/tools-catalog.out" 'sox'
+assert_file_contains "$tmp/tools-catalog.out" 'soxi'
+assert_file_contains "$tmp/tools-catalog.out" 'mkvmerge'
+assert_file_contains "$tmp/tools-catalog.out" 'mkvinfo'
+assert_file_contains "$tmp/tools-catalog.out" 'mkvextract'
+assert_file_contains "$tmp/tools-catalog.out" 'mkvpropedit'
+assert_file_contains "$tmp/tools-catalog.out" 'mediainfo'
+assert_file_contains "$tmp/tools-catalog.out" 'xvidcore'
+pass "tools catalog is discoverable without a running Tools container"
+
+case_tools_direct_runner() {
+ _tools_runner_exec() {
+ printf 'tool-runner:' >>"$EXECUTION_TEST_LOG"
+ printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG"
+ printf '\n' >>"$EXECUTION_TEST_LOG"
+ }
+ cmd_tools gitx status --short
+}
+run_case case_tools_direct_runner
+assert_file_contains "$log" 'tool-runner: <--short>'
+pass "curated/non-reserved tools delegate argv to the temporary tool runner"
+
+case_tools_explicit_runner() {
+ _tools_runner_exec() {
+ printf 'tool-runner:' >>"$EXECUTION_TEST_LOG"
+ printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG"
+ printf '\n' >>"$EXECUTION_TEST_LOG"
+ }
+ cmd_tools run sqlitex --db 'db path/app.db' tables
+}
+run_case case_tools_explicit_runner
+assert_file_contains "$log" 'tool-runner: <--db> '
+pass "tools run provides an explicit collision-safe extension path"
+
+case_tools_ui_runner() {
+ _tools_runner_exec() {
+ printf 'tool-runner:' >>"$EXECUTION_TEST_LOG"
+ printf ' <%s>' "$@" >>"$EXECUTION_TEST_LOG"
+ printf '\n' >>"$EXECUTION_TEST_LOG"
+ }
+ cmd_tools ui --debug
+}
+run_case case_tools_ui_runner
+assert_file_contains "$log" 'tool-runner: <--debug>'
+pass "tools ui reuses the temporary runner for the Docker TUI"
+
case_ui_interactive() {
force_interactive_tty
cmd_ui
diff --git a/tests/graphify-roundtrip-contract.sh b/tests/graphify-roundtrip-contract.sh
new file mode 100644
index 00000000..a46ab896
--- /dev/null
+++ b/tests/graphify-roundtrip-contract.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+command -v graphify >/dev/null 2>&1 || {
+ printf 'graphify-roundtrip-contract: graphify is required\n' >&2
+ exit 69
+}
+command -v jq >/dev/null 2>&1 || {
+ printf 'graphify-roundtrip-contract: jq is required\n' >&2
+ exit 69
+}
+
+tmp="$(mktemp -d)"
+trap 'rm -rf -- "$tmp"' EXIT INT TERM
+
+cat >"$tmp/staged.json" <<'JSON'
+{
+ "directed": false,
+ "multigraph": false,
+ "graph": {},
+ "nodes": [
+ {
+ "id": "src_runtime_php_runtime",
+ "label": "Runtime",
+ "file_type": "code",
+ "source_file": "src/Runtime.php",
+ "source_location": "L1",
+ "_origin": "ast"
+ },
+ {
+ "id": "docstruct_docs_guide_repeat_a",
+ "label": "Repeated heading",
+ "file_type": "document",
+ "source_file": "/workspace/docs/guide.md",
+ "source_location": "L2",
+ "docstruct_origin": "docker-tools.docstruct/v1"
+ },
+ {
+ "id": "docstruct_docs_guide_repeat_b",
+ "label": "Repeated heading",
+ "file_type": "document",
+ "source_file": "/workspace/docs/guide.md",
+ "source_location": "L8",
+ "docstruct_origin": "docker-tools.docstruct/v1"
+ }
+ ],
+ "links": [
+ {
+ "source": "src_runtime_php_runtime",
+ "target": "docstruct_docs_guide_repeat_a",
+ "relation": "references",
+ "confidence": "EXTRACTED",
+ "confidence_score": 1.0,
+ "source_file": "src/Runtime.php"
+ },
+ {
+ "source": "src_runtime_php_runtime",
+ "target": "docstruct_docs_guide_repeat_b",
+ "relation": "references",
+ "confidence": "EXTRACTED",
+ "confidence_score": 1.0,
+ "source_file": "src/Runtime.php"
+ }
+ ],
+ "hyperedges": []
+}
+JSON
+
+run_roundtrip() {
+ local input="$1" dir="$2"
+ mkdir -p "$dir"
+ cp "$input" "$dir/staged.json"
+ (
+ cd "$dir"
+ graphify cluster-only --graph "$dir/staged.json" --no-label --no-viz >/dev/null
+ )
+ [[ -s "$dir/graphify-out/graph.json" ]] || {
+ printf 'graphify-roundtrip-contract: no canonical graph produced\n' >&2
+ exit 1
+ }
+}
+
+run_roundtrip "$tmp/staged.json" "$tmp/first"
+run_roundtrip "$tmp/first/graphify-out/graph.json" "$tmp/second"
+
+raw_nodes="$(jq '.nodes | length' "$tmp/staged.json")"
+first_nodes="$(jq '.nodes | length' "$tmp/first/graphify-out/graph.json")"
+second_nodes="$(jq '.nodes | length' "$tmp/second/graphify-out/graph.json")"
+
+((first_nodes <= raw_nodes)) || {
+ printf 'graphify-roundtrip-contract: canonicalization unexpectedly grew nodes: %s -> %s\n' "$raw_nodes" "$first_nodes" >&2
+ exit 1
+}
+[[ "$first_nodes" == "$second_nodes" ]] || {
+ printf 'graphify-roundtrip-contract: canonical output is not idempotent: %s -> %s\n' "$first_nodes" "$second_nodes" >&2
+ exit 1
+}
+
+for graph in "$tmp/first/graphify-out/graph.json" "$tmp/second/graphify-out/graph.json"; do
+ jq -e '.nodes | any(.id == "src_runtime_php_runtime" and .file_type == "code")' "$graph" >/dev/null ||
+ {
+ printf 'graphify-roundtrip-contract: code node was lost during canonicalization\n' >&2
+ exit 1
+ }
+done
+
+printf 'graphify-roundtrip-contract: ok (%s -> %s -> %s nodes)\n' "$raw_nodes" "$first_nodes" "$second_nodes"
diff --git a/tests/image-convert-contract.sh b/tests/image-convert-contract.sh
new file mode 100644
index 00000000..1abb5aa2
--- /dev/null
+++ b/tests/image-convert-contract.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=tests/lib/assertions.sh
+source "$ROOT/tests/lib/assertions.sh"
+
+tmp="$(mktemp -d)"
+trap 'rm -rf -- "$tmp"' EXIT
+
+bin="$tmp/bin"
+mkdir -p "$bin" "$tmp/Input Images" "$tmp/Output Images"
+log="$tmp/docker.log"
+: >"$log"
+
+cat >"$bin/docker" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+: "${IMAGE_CONVERT_TEST_LOG:?}"
+printf '%s\n' '---' >>"$IMAGE_CONVERT_TEST_LOG"
+for arg in "$@"; do
+ printf '<%s>\n' "$arg" >>"$IMAGE_CONVERT_TEST_LOG"
+done
+
+case " $* " in
+ *" -list format "*)
+ printf '%s\n' ' JPEG* rw- Joint Photographic Experts Group JFIF format' ' PNG* rw- Portable Network Graphics' ' GIF* rw+ CompuServe graphics interchange format' ' WEBP* rw+ WebP Image Format'
+ exit 0
+ ;;
+ *" -version "*)
+ printf '%s\n' 'Version: ImageMagick 7.test'
+ exit 0
+ ;;
+esac
+
+out_host=''
+out_name=''
+args=("$@")
+for ((i = 0; i < ${#args[@]}; i++)); do
+ if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then
+ mount="${args[$((i + 1))]}"
+ if [[ "$mount" == *":/lds-output" ]]; then
+ out_host="${mount%:/lds-output}"
+ fi
+ fi
+done
+if ((${#args[@]} > 0)); then
+ target="${args[$((${#args[@]} - 1))]}"
+ [[ "$target" == /lds-output/* ]] && out_name="${target#/lds-output/}"
+fi
+if [[ -n "$out_host" && -n "$out_name" ]]; then
+ mkdir -p -- "$(dirname -- "$out_host/$out_name")"
+ printf '%s\n' 'converted-image' >"$out_host/$out_name"
+fi
+SH
+chmod +x "$bin/docker"
+
+input="$tmp/Input Images/Photo File.png"
+output="$tmp/Output Images/Photo File.webp"
+printf 'png' >"$input"
+
+IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert image "$input" "$output" -- -quality 82 -strip
+[[ -f "$output" ]] || fail "lds convert image did not publish the host output file"
+grep -Fq "<$tmp/Input Images:/lds-input:ro>" "$log" ||
+ fail "image conversion did not mount input read-only"
+grep -Fq "<$tmp/Output Images:/lds-output>" "$log" ||
+ fail "image conversion did not mount output writable"
+grep -Fq '<--entrypoint>' "$log" || fail "image conversion did not set entrypoint"
+grep -Fq '' "$log" || fail "image conversion did not invoke ImageMagick"
+grep -Fq '<--network>' "$log" || fail "image conversion did not disable networking"
+grep -Fq '' "$log" || fail "image conversion did not use network none"
+grep -Fq '<--user>' "$log" || fail "image conversion did not preserve host output ownership"
+grep -Fq '' "$log" || fail "image input with spaces was not preserved"
+grep -Fq '<-quality>' "$log" || fail "ImageMagick quality option was lost"
+grep -Fq '<82>' "$log" || fail "ImageMagick quality value was lost"
+grep -Fq '<-strip>' "$log" || fail "ImageMagick strip option was lost"
+grep -Fq '' "$log" || fail "image output with spaces was not preserved"
+if grep -Fq '/var/run/docker.sock' "$log"; then fail "image conversion exposed Docker socket"; fi
+pass "containerized host image conversion"
+
+gif="$tmp/Input Images/Animation.gif"
+jpg="$tmp/Output Images/Preview.jpg"
+webp="$tmp/Output Images/Animation.webp"
+printf 'gif' >"$gif"
+: >"$log"
+IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert image "$gif" "$jpg"
+grep -Fq '' "$log" ||
+ fail "static image output did not select first animation frame"
+: >"$log"
+IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert image "$gif" "$webp"
+if grep -Fq '' "$log"; then
+ fail "animation-capable WebP output incorrectly discarded animation frames"
+fi
+grep -Fq '' "$log" || fail "animated WebP input path missing"
+pass "static and animated image output semantics"
+
+formats="$(IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert image --formats)"
+grep -q 'JPEG' <<<"$formats" || fail "JPEG capability discovery missing"
+grep -q 'PNG' <<<"$formats" || fail "PNG capability discovery missing"
+grep -q 'GIF' <<<"$formats" || fail "GIF capability discovery missing"
+grep -q 'WEBP' <<<"$formats" || fail "WebP capability discovery missing"
+version="$(IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert image --version)"
+grep -q 'ImageMagick' <<<"$version" || fail "ImageMagick version discovery failed"
+pass "image conversion capability discovery"
+
+before="$(wc -l <"$log" | tr -d '[:space:]')"
+set +e
+IMAGE_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert image --force "$input" "$output" -- -write extra.png >/dev/null 2>"$tmp/write.err"
+rc=$?
+set -e
+after="$(wc -l <"$log" | tr -d '[:space:]')"
+[[ "$rc" -eq 64 ]] || fail "ImageMagick -write returned $rc instead of 64"
+[[ "$before" == "$after" ]] || fail "ImageMagick -write reached Docker"
+grep -Fq 'owns ImageMagick output selection' "$tmp/write.err" || fail "ImageMagick -write diagnostic missing"
+pass "image conversion owns output path"
diff --git a/tests/media-convert-contract.sh b/tests/media-convert-contract.sh
new file mode 100644
index 00000000..9cbf17b4
--- /dev/null
+++ b/tests/media-convert-contract.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+# shellcheck source=tests/lib/assertions.sh
+source "$ROOT/tests/lib/assertions.sh"
+
+tmp="$(mktemp -d)"
+trap 'rm -rf -- "$tmp"' EXIT
+
+bin="$tmp/bin"
+mkdir -p "$bin" "$tmp/Input Media" "$tmp/Output Media"
+log="$tmp/docker.log"
+: >"$log"
+
+cat >"$bin/docker" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+: "${MEDIA_CONVERT_TEST_LOG:?}"
+printf '%s\n' '---' >>"$MEDIA_CONVERT_TEST_LOG"
+for arg in "$@"; do
+ printf '<%s>\n' "$arg" >>"$MEDIA_CONVERT_TEST_LOG"
+done
+
+case " $* " in
+ *" -formats "*) printf '%s\n' ' D matroska,webm' ' DE mp3' ; exit 0 ;;
+ *" -codecs "*) printf '%s\n' ' DEV.L. h264' ' DEA.L. mp3' ; exit 0 ;;
+ *" -encoders "*) printf '%s\n' ' V..... libx264' ' A..... libmp3lame' ; exit 0 ;;
+ *" -version "*) printf '%s\n' 'ffmpeg version test' ; exit 0 ;;
+esac
+
+out_host=''
+args=("$@")
+for ((i = 0; i < ${#args[@]}; i++)); do
+ if [[ "${args[$i]}" == "-v" && $((i + 1)) -lt ${#args[@]} ]]; then
+ mount="${args[$((i + 1))]}"
+ [[ "$mount" == *":/lds-output" ]] && out_host="${mount%:/lds-output}"
+ fi
+done
+if ((${#args[@]} > 0)); then
+ target="${args[$((${#args[@]} - 1))]}"
+ if [[ "$target" == /lds-output/* && -n "$out_host" ]]; then
+ out_name="${target#/lds-output/}"
+ printf '%s\n' converted >"$out_host/$out_name"
+ fi
+fi
+SH
+chmod +x "$bin/docker"
+
+audio_in="$tmp/Input Media/Source Audio.wav"
+audio_out="$tmp/Output Media/Output Audio.mp3"
+video_in="$tmp/Input Media/Source Video.mkv"
+video_out="$tmp/Output Media/Output Video.mp4"
+printf 'audio' >"$audio_in"
+printf 'video' >"$video_in"
+
+MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert audio "$audio_in" "$audio_out" -- -c:a libmp3lame -b:a 160k
+[[ -f "$audio_out" ]] || fail "audio conversion did not publish output"
+grep -Fq '<--network>' "$log" || fail "audio conversion did not disable networking"
+grep -Fq '' "$log" || fail "audio conversion did not use network none"
+grep -Fq '<--user>' "$log" || fail "audio conversion did not preserve host ownership"
+grep -Fq "<$tmp/Input Media:/lds-input:ro>" "$log" || fail "audio input mount was not read-only"
+grep -Fq '<--entrypoint>' "$log" || fail "audio conversion did not set FFmpeg entrypoint"
+grep -Fq '' "$log" || fail "audio conversion did not invoke FFmpeg"
+grep -Fq '<-nostdin>' "$log" || fail "audio conversion did not disable FFmpeg interactive stdin"
+grep -Fq '<-n>' "$log" || fail "audio conversion did not use no-overwrite mode"
+grep -Fq '<-i>' "$log" || fail "audio conversion input marker missing"
+grep -Fq '' "$log" || fail "audio input path with spaces was lost"
+grep -Fq '<-c:a>' "$log" || fail "audio codec option was lost"
+grep -Fq '' "$log" || fail "audio codec value was lost"
+grep -Fq '' "$log" || fail "audio output path with spaces was lost"
+pass "FFmpeg audio conversion contract"
+
+: >"$log"
+MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert video "$video_in" "$video_out" -- -c:v libx264 -crf 23 -c:a aac
+[[ -f "$video_out" ]] || fail "video conversion did not publish output"
+grep -Fq '' "$log" || fail "video input path missing"
+grep -Fq '<-c:v>' "$log" || fail "video codec option missing"
+grep -Fq '' "$log" || fail "video codec value missing"
+grep -Fq '<-crf>' "$log" || fail "video CRF option missing"
+grep -Fq '' "$log" || fail "video output path missing"
+pass "FFmpeg video conversion contract"
+
+: >"$log"
+MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert audio --force "$audio_in" "$audio_out" -- -c:a copy
+grep -Fq '<-y>' "$log" || fail "--force did not map to FFmpeg overwrite mode"
+pass "media conversion overwrite policy"
+
+for forbidden in -i -y -n; do
+ before="$(wc -l <"$log" | tr -d '[:space:]')"
+ set +e
+ MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" \
+ "$ROOT/lds" convert video --force "$video_in" "$video_out" -- "$forbidden" extra >/dev/null 2>"$tmp/forbidden.err"
+ rc=$?
+ set -e
+ after="$(wc -l <"$log" | tr -d '[:space:]')"
+ [[ "$rc" -eq 64 ]] || fail "reserved FFmpeg option $forbidden returned $rc instead of 64"
+ [[ "$before" == "$after" ]] || fail "reserved FFmpeg option $forbidden reached Docker"
+done
+grep -Fq 'owns FFmpeg input/output and overwrite selection' "$tmp/forbidden.err" ||
+ fail "reserved FFmpeg option diagnostic missing"
+pass "media converter owns FFmpeg input/output controls"
+
+formats="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert video --formats)"
+grep -q matroska <<<"$formats" || fail "FFmpeg format discovery failed"
+codecs="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert audio --codecs)"
+grep -q mp3 <<<"$codecs" || fail "FFmpeg codec discovery failed"
+encoders="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert video --encoders)"
+grep -q libx264 <<<"$encoders" || fail "FFmpeg encoder discovery failed"
+version="$(MEDIA_CONVERT_TEST_LOG="$log" PATH="$bin:$PATH" "$ROOT/lds" convert audio --version)"
+grep -q '^ffmpeg version' <<<"$version" || fail "FFmpeg version discovery failed"
+pass "media conversion capability discovery"
diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1
index 6f8dd0ec..82c9a67d 100644
--- a/tests/windows-bridge.ps1
+++ b/tests/windows-bridge.ps1
@@ -54,11 +54,16 @@ Write-Host "PASS: Windows bridge quoting/discovery contract"
$ldsPath = Join-Path $root "lds"
$execPath = Join-Path $root "lib/container-exec.sh"
+$conversionPath = Join-Path $root "lib/conversion.sh"
if (-not (Test-Path $execPath)) {
throw "shared container execution helper not found"
}
+if (-not (Test-Path $conversionPath)) {
+ throw "conversion helper not found"
+}
$ldsContent = Get-Content -Raw -Path $ldsPath
$execContent = Get-Content -Raw -Path $execPath
+$conversionContent = Get-Content -Raw -Path $conversionPath
foreach ($needle in @(
'source "$DIR/lib/container-exec.sh"',
'_is_public_lds_command()'
@@ -78,3 +83,14 @@ foreach ($needle in @(
}
}
Write-Host "PASS: Windows bridge uses MSYS-safe shared container execution"
+
+foreach ($needle in @(
+ '_convert_docker_mount_path',
+ 'MSYS_NO_PATHCONV=1',
+ "MSYS2_ARG_CONV_EXCL='*'"
+)) {
+ if (-not $conversionContent.Contains($needle)) {
+ throw "conversion is missing Windows/Git Bash contract: $needle"
+ }
+}
+Write-Host "PASS: conversion uses MSYS-safe host mounts"
diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh
index 771364e9..2f7f1816 100755
--- a/tests/wrappers-contract.sh
+++ b/tests/wrappers-contract.sh
@@ -20,15 +20,101 @@ done
pass "wrapper syntax and Docker-DNS independence"
runner="$ROOT/bin/tool-runner"
-assert_file_contains "$runner" '[[ -t 0 ]] && flags+=(-i)'
+assert_file_contains "$runner" 'stdin_has_data()'
+assert_file_contains "$runner" 'if [[ -t 0 ]] || stdin_has_data; then'
assert_file_contains "$runner" '[[ -t 1 ]] && flags+=(-t)'
assert_file_contains "$runner" 'MSYS_NO_PATHCONV=1'
assert_file_contains "$runner" 'MSYS2_ARG_CONV_EXCL='
assert_file_contains "$runner" '--network "container:$SERVER_TOOLS_CONTAINER"'
assert_file_contains "$runner" '--volumes-from "$SERVER_TOOLS_CONTAINER"'
+assert_file_contains "$runner" 'append_server_tools_env envs'
+assert_file_contains "$runner" 'LDS_AI_RUNTIME'
+assert_file_contains "$runner" 'LDS_AI_MODEL'
+assert_file_contains "$runner" 'GIT_CREDENTIAL_MODE'
assert_file_contains "$runner" 'exec "$(bin_path docker)" run'
pass "tool-runner preserves TTY, path, namespace and exit-code contracts"
+runner_tmp="$(mktemp -d)"
+runner_bin="$runner_tmp/bin"
+runner_log="$runner_tmp/docker.log"
+runner_stdin="$runner_tmp/stdin.log"
+runner_workspace="$runner_tmp/work space"
+mkdir -p "$runner_bin" "$runner_workspace"
+
+cat >"$runner_bin/docker" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+: "${TOOL_RUNNER_TEST_LOG:?}"
+: "${TOOL_RUNNER_TEST_STDIN:?}"
+
+if [[ "${1:-}" == inspect && "${2:-}" == -f ]]; then
+ case "${3:-}" in
+ *State.Running*) printf '%s\n' true ;;
+ *Config.Image*) printf '%s\n' infocyph/tools:test ;;
+ *Config.Env*)
+ cat <<'ENV'
+TZ=Asia/Dhaka
+USERNAME=tester
+GIT_USER_NAME=Test User
+GIT_USER_EMAIL=test@example.com
+GIT_CREDENTIAL_MODE=store
+LDS_AI_ENABLED=1
+LDS_AI_RUNTIME=npu
+LDS_AI_MODEL=qwen-test
+LDS_AI_THINK=medium
+LDS_AI_TIMEOUT=321
+ENV
+ ;;
+ esac
+ exit 0
+fi
+
+if [[ "${1:-}" == exec ]]; then
+ exit 0
+fi
+
+if [[ "${1:-}" == run ]]; then
+ printf 'docker-run:' >>"$TOOL_RUNNER_TEST_LOG"
+ printf ' <%s>' "$@" >>"$TOOL_RUNNER_TEST_LOG"
+ printf '\n' >>"$TOOL_RUNNER_TEST_LOG"
+ cat >"$TOOL_RUNNER_TEST_STDIN" || true
+ exit 0
+fi
+
+exit 0
+SH
+chmod +x "$runner_bin/docker"
+
+runner_err="$runner_tmp/runner.err"
+set +e
+printf '%s\n' 'stream payload' | (
+ cd "$runner_workspace"
+ PATH="$runner_bin:$PATH" \
+ WORKDIR="$runner_workspace" \
+ TOOL_RUNNER_TEST_LOG="$runner_log" \
+ TOOL_RUNNER_TEST_STDIN="$runner_stdin" \
+ "$runner" chromacat --log
+) 2>"$runner_err"
+runner_rc=$?
+set -e
+if ((runner_rc != 0)); then
+ cat "$runner_err" >&2 || true
+ fail "tool-runner mock exited $runner_rc"
+fi
+
+assert_file_contains "$runner_log" '<-i>'
+assert_file_contains "$runner_log" '<--network> '
+assert_file_contains "$runner_log" '<--volumes-from> '
+assert_file_contains "$runner_log" '<-v>'
+assert_file_contains "$runner_log" '<-e> '
+assert_file_contains "$runner_log" '<-e> '
+assert_file_contains "$runner_log" '<-e> '
+assert_file_contains "$runner_log" '<--entrypoint> '
+assert_file_contains "$runner_log" '<--log>'
+assert_file_contains "$runner_stdin" 'stream payload'
+rm -rf "$runner_tmp"
+pass "tool-runner preserves piped stdin and active Tools runtime environment"
+
php="$ROOT/bin/php"
assert_file_contains "$php" '-V|--v|--php)'
assert_file_contains "$php" 'pick_highest_php_container()'