Implement issue 154: CLI -i flag with image file reading

- Added read_image_as_data_uri() function to base64_util for reading,
  validating, and base64-encoding image files
- Added -i CLI flag to accept image file paths (multiple allowed)
- Added -I debug flag for testing image file reading
- Image files are validated at startup with clear error messages for
  missing or unreadable files
- Updated help text and README.md documentation
- Added test_image_flag.sh with tests for PNG, JPEG, GIF formats,
  error cases, and multiple -i flags
This commit is contained in:
2026-06-11 21:32:59 +00:00
committed by Jacob Gamble
parent ae0462baea
commit 068557b06c
6 changed files with 136 additions and 0 deletions
+1
View File
@@ -73,6 +73,7 @@ configuration.
* `-p <file>`: Pretty-print a conversation JSON file without running the harness.
* `-t <tool>`: Manually constrain the harness to a specific subset of tools (by default no tools are provided to the model)
* `-s <name>`: Manually constrain the harness to a specific subset of skills (can be specified multiple times)
* `-i <path>`: Image file to attach to the prompt (can be specified multiple times)
* `-m <prompt>`: The user prompt to send to the agent.
* `-M <model>`: Override the Ollama model (e.g., `mistral`, `llama3`).
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <expected>
#include <vector>
// Base64 encode binary data to base64 string
std::string base64_encode(const std::string& input);
@@ -13,3 +14,6 @@ std::string get_mime_type_from_extension(const std::string& extension);
// Generate data URI prefix with base64 data
std::string generate_data_uri(const std::string& extension, const std::string& base64_data);
// Read an image file and return it as a base64 data URI
std::expected<std::string, std::string> read_image_as_data_uri(const std::string& file_path);
+35
View File
@@ -1,5 +1,7 @@
#include "base64_util.hpp"
#include <unordered_map>
#include <fstream>
#include <filesystem>
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -128,3 +130,36 @@ std::string generate_data_uri(const std::string& extension, const std::string& b
std::string mime_type = get_mime_type_from_extension(extension);
return "data:image/" + mime_type + ";base64," + base64_data;
}
std::expected<std::string, std::string> read_image_as_data_uri(const std::string& file_path) {
// Check if file exists
if (!std::filesystem::exists(file_path)) {
return std::unexpected("Image file not found: " + file_path);
}
// Check if it's a regular file
if (!std::filesystem::is_regular_file(file_path)) {
return std::unexpected("Not a regular file: " + file_path);
}
// Open and read the file
std::ifstream file(file_path, std::ios::binary);
if (!file.is_open()) {
return std::unexpected("Cannot open image file: " + file_path);
}
// Read file contents
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
file.close();
// Get file extension
std::filesystem::path p(file_path);
std::string extension = p.extension().string();
// Base64 encode the content
std::string encoded = base64_encode(content);
// Generate data URI
return generate_data_uri(extension, encoded);
}
+35
View File
@@ -95,6 +95,7 @@ int main(int argc, char** argv) {
char const* test_mime_type = nullptr;
char const* test_data_uri_ext = nullptr;
char const* test_data_uri_b64 = nullptr;
char const* test_image_file = nullptr;
ToolRegistry master_registery;
ToolRegistry agent_tools;
@@ -132,6 +133,7 @@ int main(int argc, char** argv) {
std::vector<std::string> tools;
bool full_history = false;
std::vector<std::string> skill_filters;
std::vector<std::string> image_paths;
consume_arg(); // progname
@@ -252,6 +254,16 @@ int main(int argc, char** argv) {
consume_arg();
skill_filters.push_back(consume_arg());
}
else if (argv[0] == std::string("-i"))
{
consume_arg();
image_paths.push_back(consume_arg());
}
else if (argv[0] == std::string("-I"))
{
consume_arg();
test_image_file = consume_arg();
}
else if (argv[0] == std::string("-h"))
{
std::cout << "Usage: drharness [options]\n"
@@ -260,6 +272,7 @@ int main(int argc, char** argv) {
<< " -p <file> Pretty print a saved conversation JSON file\n"
<< " -t <tool> Tool name (can be specified multiple times)\n"
<< " -s <name> Skill name to enable (can be specified multiple times)\n"
<< " -i <path> Image file to attach (can be specified multiple times)\n"
<< " -n Start a new conversation (auto-generates temp file if -F not specified)\n"
<< " -m <prompt> Prompt message (required)\n"
<< " -M <model> Ollama model name\n"
@@ -277,6 +290,7 @@ int main(int argc, char** argv) {
<< " -b <string> Debug: test base64 decode and exit\n"
<< " -T <ext> Debug: test MIME type detection and exit\n"
<< " -U <ext> <b64> Debug: test data URI generation and exit\n"
<< " -I <file> Debug: test image file reading and exit\n"
<< " -d Use debug client (mock API via stdin)\n"
<< " -h Display this help menu\n";
exit(0);
@@ -323,6 +337,17 @@ int main(int argc, char** argv) {
expect_or_exit(master_registery.register_tool(make_skill_tool(enabled_skills)));
// Validate and read image files
std::vector<std::string> image_data_uris;
for (const auto& path : image_paths) {
auto result = read_image_as_data_uri(path);
if (!result) {
std::cerr << "Error: " << result.error() << std::endl;
return 1;
}
image_data_uris.push_back(result.value());
}
if (debug_paths) {
print_discovered_paths(discovered_paths);
return 0;
@@ -412,6 +437,16 @@ int main(int argc, char** argv) {
return 0;
}
if (test_image_file) {
auto result = read_image_as_data_uri(test_image_file);
if (!result) {
std::cerr << "Error: " << result.error() << std::endl;
return 1;
}
std::cout << result.value() << std::endl;
return 0;
}
if (list_models)
{
auto models_result = create_client(use_debug)->list_models();
+3
View File
@@ -56,3 +56,6 @@ add_dr_test(test_expected_errors)
# Add base64 encoding tests
add_dr_test(test_base64_encode)
# Add image file reading tests
add_dr_test(test_image_flag)
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# test_image_flag.sh - Tests for -i CLI flag with image file reading
# Test 1: Nonexistent file produces clear error and non-zero exit
OUTPUT=$("${DRHARNESS}" -I "nonexistent.png" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Image file not found"; then
echo "FAIL: nonexistent file error - expected 'Image file not found', got '${OUTPUT}'"
exit 1
fi
# Test 2: Create a test image file and read it
# Create a minimal 1x1 PNG file
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/test.png"
# Test 3: Valid PNG file is read and returns data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.png" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/png;base64,"; then
echo "FAIL: valid PNG file - expected data URI starting with 'data:image/png;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 4: Create a minimal JPEG file
printf '\xFF\xD8\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9' > "${TEST_TEMP_DIR}/test.jpg"
# Test 5: Valid JPG file is read and returns correct data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.jpg" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/jpeg;base64,"; then
echo "FAIL: valid JPG file - expected data URI starting with 'data:image/jpeg;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 6: Create a GIF file
printf '\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xFF\xFF\xFF\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B' > "${TEST_TEMP_DIR}/test.gif"
# Test 7: Valid GIF file is read and returns correct data URI
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/test.gif" 2>&1)
if ! echo "${OUTPUT}" | grep -q "^data:image/gif;base64,"; then
echo "FAIL: valid GIF file - expected data URI starting with 'data:image/gif;base64,', got '${OUTPUT}'"
exit 1
fi
# Test 8: Not a file (directory) produces error
mkdir -p "${TEST_TEMP_DIR}/not_a_file"
OUTPUT=$("${DRHARNESS}" -I "${TEST_TEMP_DIR}/not_a_file" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Not a regular file"; then
echo "FAIL: directory error - expected 'Not a regular file', got '${OUTPUT}'"
exit 1
fi
# Test 9: Multiple images - second file nonexistent should produce error
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xDE\x00\x00\x00\x0C\x49\x44\x41\x54\x08\xD7\x63\xF8\xCF\xC0\x00\x00\x03\x01\x01\x00\x18\xDD\x8D\xB4\x00\x00\x00\x00\x49\x45\x4E\x44\xAE\x42\x60\x82' > "${TEST_TEMP_DIR}/valid.png"
OUTPUT=$("${DRHARNESS}" -i "${TEST_TEMP_DIR}/valid.png" -i "nonexistent.png" -F "${TEST_TEMP_DIR}/chat.json" -n -m "test" 2>&1) || true
if ! echo "${OUTPUT}" | grep -q "Image file not found"; then
echo "FAIL: multiple images with nonexistent second file - expected error, got '${OUTPUT}'"
exit 1
fi
echo "All image flag tests passed"