trashclaw / plugins /json_diff.py
elyanlabs's picture
Source snapshot of github.com/Scottcjn/trashclaw@6dce9ed
53b2ac9 verified
Raw History Blame Contribute Delete
4.74 kB
# SPDX-License-Identifier: MIT
"""
JSON Diff Plugin for TrashClaw
Diff two JSON files or strings showing added, removed, and changed keys.
Handles nested structures with dot-path notation.
Plugin contract:
TOOL_DEF = dict with name, description, parameters (OpenAI function schema)
run(**kwargs) -> str (called with the parsed arguments)
"""
import json
import os
TOOL_DEF = {
"name": "json_diff",
"description": "Diff two JSON files or strings. Shows added, removed, and changed keys with dot-path notation for nested structures.",
"parameters": {
"type": "object",
"properties": {
"file_a": {
"type": "string",
"description": "Path to first JSON file (or use json_a for inline JSON)"
},
"file_b": {
"type": "string",
"description": "Path to second JSON file (or use json_b for inline JSON)"
},
"json_a": {
"type": "string",
"description": "First JSON string (alternative to file_a)"
},
"json_b": {
"type": "string",
"description": "Second JSON string (alternative to file_b)"
}
},
"required": []
}
}
def _flatten(obj, prefix=""):
"""Flatten a nested dict/list into dot-path keys."""
items = {}
if isinstance(obj, dict):
for k, v in obj.items():
new_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, (dict, list)):
items.update(_flatten(v, new_key))
else:
items[new_key] = v
elif isinstance(obj, list):
for i, v in enumerate(obj):
new_key = f"{prefix}[{i}]"
if isinstance(v, (dict, list)):
items.update(_flatten(v, new_key))
else:
items[new_key] = v
else:
items[prefix] = obj
return items
def _load_json(file_path: str = "", json_str: str = "", label: str = "input"):
"""Load JSON from file or string. Returns (data, error_msg)."""
if file_path:
if not os.path.exists(file_path):
return None, f"Error: file not found: {file_path}"
try:
with open(file_path, "r") as f:
return json.load(f), None
except json.JSONDecodeError as e:
return None, f"Error: invalid JSON in {file_path}: {e}"
except Exception as e:
return None, f"Error reading {file_path}: {e}"
elif json_str:
try:
return json.loads(json_str), None
except json.JSONDecodeError as e:
return None, f"Error: invalid JSON in {label}: {e}"
return None, f"Error: no {label} provided (use file or inline JSON)."
def _format_value(v):
"""Format a value for display, truncating long strings."""
s = json.dumps(v)
if len(s) > 80:
s = s[:77] + "..."
return s
def run(file_a: str = "", file_b: str = "", json_a: str = "", json_b: str = "", **kwargs) -> str:
data_a, err = _load_json(file_a, json_a, "A")
if err:
return err
data_b, err = _load_json(file_b, json_b, "B")
if err:
return err
flat_a = _flatten(data_a)
flat_b = _flatten(data_b)
keys_a = set(flat_a.keys())
keys_b = set(flat_b.keys())
added = sorted(keys_b - keys_a)
removed = sorted(keys_a - keys_b)
common = sorted(keys_a & keys_b)
changed = []
for k in common:
if flat_a[k] != flat_b[k]:
changed.append(k)
if not added and not removed and not changed:
return "No differences found. The JSON structures are identical."
lines = ["JSON Diff Results", "=" * 40]
label_a = file_a or "(inline A)"
label_b = file_b or "(inline B)"
lines.append(f"A: {label_a}")
lines.append(f"B: {label_b}")
lines.append("")
if added:
lines.append(f"ADDED ({len(added)} keys, only in B):")
for k in added:
lines.append(f" + {k}: {_format_value(flat_b[k])}")
lines.append("")
if removed:
lines.append(f"REMOVED ({len(removed)} keys, only in A):")
for k in removed:
lines.append(f" - {k}: {_format_value(flat_a[k])}")
lines.append("")
if changed:
lines.append(f"CHANGED ({len(changed)} keys):")
for k in changed:
lines.append(f" ~ {k}:")
lines.append(f" A: {_format_value(flat_a[k])}")
lines.append(f" B: {_format_value(flat_b[k])}")
lines.append("")
summary = f"Summary: +{len(added)} added, -{len(removed)} removed, ~{len(changed)} changed"
lines.append(summary)
return "\n".join(lines)