diff --git a/README.md b/README.md index 7672fc0..d652878 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,25 @@ models **Alternative — run as a local app (npm / npx):** From the project root, after `npm install` and `npm run build`, use **`npm start`** (runs [`serve`](https://github.com/vercel/serve) on port **8080**) or **`npx serve dist -l 8080`**. Then open **http://localhost:8080** — same UI, stable URL you can bookmark. **`npm run preview`** runs a full **`npm run build`** and then serves **`dist/`** in one step. +### Agent CLI + +This repo also ships a Python CLI that reuses the existing Node transform runtime without changing the static-site workflow. + +```bash +uv run p4rs3lt0ngv3-cli list +uv run p4rs3lt0ngv3-cli inspect caesar --json +uv run p4rs3lt0ngv3-cli encode --transform base64 --text "Hello World" +uv run p4rs3lt0ngv3-cli decode --transform base64 --text "SGVsbG8gV29ybGQ=" +uv run p4rs3lt0ngv3-cli auto-decode --text "SGVsbG8=" +uv run p4rs3lt0ngv3-cli agent "encode 'Attack at dawn' as caesar shift 5" +``` + +Notes: + +- The CLI is managed with **`uv`** via [`pyproject.toml`](pyproject.toml). +- It shells into Node to execute the canonical transforms under `src/transformers/`. +- Existing web build and Node test flows remain unchanged. + ### **Development Setup** ```bash # Install dependencies diff --git a/p4rs3lt0ngv3_cli/__init__.py b/p4rs3lt0ngv3_cli/__init__.py new file mode 100644 index 0000000..9f5cba2 --- /dev/null +++ b/p4rs3lt0ngv3_cli/__init__.py @@ -0,0 +1,2 @@ +"""Python CLI package for P4RS3LT0NGV3.""" + diff --git a/p4rs3lt0ngv3_cli/__pycache__/__init__.cpython-314.pyc b/p4rs3lt0ngv3_cli/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..37ba5fd Binary files /dev/null and b/p4rs3lt0ngv3_cli/__pycache__/__init__.cpython-314.pyc differ diff --git a/p4rs3lt0ngv3_cli/__pycache__/agent.cpython-314.pyc b/p4rs3lt0ngv3_cli/__pycache__/agent.cpython-314.pyc new file mode 100644 index 0000000..b13b5cb Binary files /dev/null and b/p4rs3lt0ngv3_cli/__pycache__/agent.cpython-314.pyc differ diff --git a/p4rs3lt0ngv3_cli/__pycache__/bridge.cpython-314.pyc b/p4rs3lt0ngv3_cli/__pycache__/bridge.cpython-314.pyc new file mode 100644 index 0000000..00712bb Binary files /dev/null and b/p4rs3lt0ngv3_cli/__pycache__/bridge.cpython-314.pyc differ diff --git a/p4rs3lt0ngv3_cli/__pycache__/cli.cpython-314.pyc b/p4rs3lt0ngv3_cli/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000..68e91b3 Binary files /dev/null and b/p4rs3lt0ngv3_cli/__pycache__/cli.cpython-314.pyc differ diff --git a/p4rs3lt0ngv3_cli/__pycache__/models.cpython-314.pyc b/p4rs3lt0ngv3_cli/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..90ad03f Binary files /dev/null and b/p4rs3lt0ngv3_cli/__pycache__/models.cpython-314.pyc differ diff --git a/p4rs3lt0ngv3_cli/agent.py b/p4rs3lt0ngv3_cli/agent.py new file mode 100644 index 0000000..ef20209 --- /dev/null +++ b/p4rs3lt0ngv3_cli/agent.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import difflib +import re +from dataclasses import asdict +from typing import Any + +from .bridge import auto_decode, inspect_transform, list_transforms, run_transform +from .models import AgentStep, TransformInfo + + +TEXT_QUOTE_RE = re.compile(r"""['"](?P[^'"]+)['"]""") + + +def find_transform(query: str) -> TransformInfo | None: + normalized = query.strip().lower().replace("-", "_") + transforms = list_transforms() + + exact = {transform.key: transform for transform in transforms} + if normalized in exact: + return exact[normalized] + + for transform in transforms: + if normalized == transform.name.lower(): + return transform + if normalized in transform.search_tokens: + return transform + + name_map = {transform.key: transform for transform in transforms} + matches = difflib.get_close_matches(normalized, list(name_map), n=1, cutoff=0.55) + if matches: + return name_map[matches[0]] + + token_matches = [] + query_tokens = set(re.findall(r"[a-z0-9_]+", normalized)) + for transform in transforms: + score = len(query_tokens & transform.search_tokens) + if score: + token_matches.append((score, transform.priority, transform)) + if token_matches: + token_matches.sort(key=lambda item: (item[0], item[1]), reverse=True) + return token_matches[0][2] + + return None + + +def coerce_option_value(raw: str) -> Any: + lowered = raw.lower() + if lowered in {"true", "false"}: + return lowered == "true" + if re.fullmatch(r"-?\d+", raw): + return int(raw) + if re.fullmatch(r"-?\d+\.\d+", raw): + return float(raw) + return raw + + +def extract_text(prompt: str) -> str | None: + match = TEXT_QUOTE_RE.search(prompt) + if match: + return match.group("text") + return None + + +def extract_transform_query(prompt: str) -> str | None: + patterns = [ + r"\b(?:as|using|with|via|from|into|to)\s+([a-z0-9 _-]+?)(?:\s+with\b|\s+without\b|$)", + r"\b(?:inspect|show|details for|about)\s+([a-z0-9 _-]+)$", + ] + normalized = prompt.strip().lower() + for pattern in patterns: + match = re.search(pattern, normalized) + if match: + return match.group(1).strip() + return None + + +def extract_option_hints(prompt: str, transform: TransformInfo | None) -> dict[str, Any]: + if not transform: + return {} + + options: dict[str, Any] = {} + normalized = prompt.lower() + for option in transform.configurable_options: + id_pattern = option.id.replace("_", "[ _-]?") + value_match = re.search(rf"\b{id_pattern}\s+([^\s,]+)", normalized) + if value_match: + options[option.id] = coerce_option_value(value_match.group(1)) + continue + + label_tokens = re.findall(r"[a-z0-9]+", option.label.lower()) + if label_tokens: + label_pattern = r"[ _-]?".join(label_tokens) + value_match = re.search(rf"\b{label_pattern}\s+([^\s,]+)", normalized) + if value_match: + options[option.id] = coerce_option_value(value_match.group(1)) + continue + + if option.type == "boolean": + if re.search(rf"\b(?:with|enable)\s+{id_pattern}\b", normalized): + options[option.id] = True + if re.search(rf"\b(?:without|disable|no)\s+{id_pattern}\b", normalized): + options[option.id] = False + + generic_number = re.search(r"\bshift\s+(-?\d+)\b", normalized) + if generic_number and any(option.id == "shift" for option in transform.configurable_options): + options["shift"] = int(generic_number.group(1)) + + return options + + +def split_steps(prompt: str) -> list[str]: + return [segment.strip() for segment in re.split(r"\bthen\b|&&|->", prompt) if segment.strip()] + + +def plan_prompt(prompt: str) -> list[AgentStep]: + steps: list[AgentStep] = [] + segments = split_steps(prompt) + + for index, segment in enumerate(segments): + lowered = segment.lower() + text = extract_text(segment) + + if any(token in lowered for token in ["list", "show transforms", "available transforms", "what can you do"]): + steps.append(AgentStep(kind="list", explanation="List available transforms")) + continue + + if any(token in lowered for token in ["inspect", "details", "about"]) and not any( + token in lowered for token in ["decode", "encode", "transform", "convert"] + ): + transform_query = extract_transform_query(segment) or segment + transform = find_transform(transform_query) + steps.append( + AgentStep( + kind="inspect", + transform_key=transform.key if transform else None, + explanation=f"Inspect transform derived from '{transform_query}'", + ) + ) + continue + + action = None + if any(token in lowered for token in ["decode", "decrypt", "reverse", "undo"]): + action = "decode" + elif any(token in lowered for token in ["preview"]): + action = "preview" + elif any(token in lowered for token in ["encode", "transform", "convert", "make"]): + action = "encode" + + transform_query = extract_transform_query(segment) + transform = find_transform(transform_query) if transform_query else None + + if action == "decode" and transform is None and "from " not in lowered and "using " not in lowered: + steps.append(AgentStep(kind="auto-decode", text=text or segment.strip(), explanation="Use universal decoder")) + continue + + if action and transform: + steps.append( + AgentStep( + kind="run", + transform_key=transform.key, + action=action, + text=text, + options=extract_option_hints(segment, transform), + explanation=f"{action} with {transform.key}", + ) + ) + continue + + if index == 0 and text and transform is None and action == "decode": + steps.append(AgentStep(kind="auto-decode", text=text, explanation="Use universal decoder")) + continue + + raise ValueError(f"Could not resolve request segment: {segment}") + + return steps + + +def execute_plan(prompt: str) -> dict[str, Any]: + steps = plan_prompt(prompt) + current_text: str | None = None + outputs: list[dict[str, Any]] = [] + + for step in steps: + if step.kind == "list": + transforms = list_transforms() + outputs.append( + { + "kind": "list", + "count": len(transforms), + "transforms": [transform.key for transform in transforms[:25]], + "explanation": step.explanation, + } + ) + continue + + if step.kind == "inspect": + if not step.transform_key: + raise ValueError("Could not identify transform to inspect") + transform = inspect_transform(step.transform_key) + outputs.append( + { + "kind": "inspect", + "transform": { + "key": transform.key, + "name": transform.name, + "category": transform.category, + "can_decode": transform.can_decode, + "options": [asdict(option) for option in transform.configurable_options], + }, + "explanation": step.explanation, + } + ) + continue + + if step.kind == "auto-decode": + source_text = step.text or current_text + if not source_text: + raise ValueError("No text available for auto-decode") + result = auto_decode(source_text) + outputs.append({"kind": "auto-decode", "result": result, "explanation": step.explanation}) + current_text = result["text"] if result else None + continue + + if step.kind == "run": + source_text = step.text if step.text is not None else current_text + if source_text is None: + raise ValueError(f"No text available for {step.action}") + result = run_transform(step.action or "encode", step.transform_key or "", source_text, step.options) + outputs.append( + { + "kind": "run", + "action": result.action, + "transform": result.transform_key, + "transform_name": result.transform_name, + "options": result.options, + "output": result.output, + "explanation": step.explanation, + } + ) + current_text = result.output + continue + + return { + "input": prompt, + "steps": [asdict(step) for step in steps], + "outputs": outputs, + "final_output": current_text, + } + diff --git a/p4rs3lt0ngv3_cli/bridge.py b/p4rs3lt0ngv3_cli/bridge.py new file mode 100644 index 0000000..7040b08 --- /dev/null +++ b/p4rs3lt0ngv3_cli/bridge.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from functools import lru_cache +from pathlib import Path +from typing import Any + +from .models import TransformInfo, TransformOption, TransformResult + + +class BridgeError(RuntimeError): + """Raised when the Node bridge fails.""" + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +BRIDGE_PATH = PROJECT_ROOT / "scripts" / "cli_bridge.js" + + +def _run_bridge(payload: dict[str, Any]) -> dict[str, Any]: + process = subprocess.run( + ["node", str(BRIDGE_PATH)], + input=json.dumps(payload), + text=True, + capture_output=True, + cwd=PROJECT_ROOT, + check=False, + ) + if process.returncode != 0: + message = process.stderr.strip() or process.stdout.strip() or "Node bridge failed" + try: + parsed = json.loads(process.stdout) + message = parsed.get("error", message) + except json.JSONDecodeError: + pass + raise BridgeError(message) + + lines = [line for line in process.stdout.splitlines() if line.strip()] + if not lines: + raise BridgeError("Node bridge returned no output") + try: + data = json.loads(lines[-1]) + except json.JSONDecodeError as exc: + raise BridgeError(f"Node bridge returned invalid JSON: {lines[-1]}") from exc + if not data.get("ok"): + raise BridgeError(data.get("error", "Unknown bridge error")) + return data + + +@lru_cache(maxsize=1) +def list_transforms() -> list[TransformInfo]: + data = _run_bridge({"command": "list"}) + transforms = [] + for item in data["transforms"]: + transforms.append( + TransformInfo( + key=item["key"], + name=item["name"], + category=item["category"], + priority=item["priority"], + can_decode=item["canDecode"], + description=item.get("description", ""), + input_kind=item.get("inputKind", "textarea"), + configurable_options=[ + TransformOption( + id=opt["id"], + label=opt["label"], + type=opt["type"], + default=opt.get("default"), + min=opt.get("min"), + max=opt.get("max"), + step=opt.get("step"), + options=opt.get("options"), + ) + for opt in item.get("configurableOptions", []) + ], + ) + ) + return transforms + + +@lru_cache(maxsize=256) +def inspect_transform(transform_key: str) -> TransformInfo: + data = _run_bridge({"command": "inspect", "transform": transform_key}) + item = data["transform"] + return TransformInfo( + key=item["key"], + name=item["name"], + category=item["category"], + priority=item["priority"], + can_decode=item["canDecode"], + description=item.get("description", ""), + input_kind=item.get("inputKind", "textarea"), + configurable_options=[ + TransformOption( + id=opt["id"], + label=opt["label"], + type=opt["type"], + default=opt.get("default"), + min=opt.get("min"), + max=opt.get("max"), + step=opt.get("step"), + options=opt.get("options"), + ) + for opt in item.get("configurableOptions", []) + ], + ) + + +def run_transform(action: str, transform_key: str, text: str, options: dict[str, Any] | None = None) -> TransformResult: + data = _run_bridge( + { + "command": "run", + "action": action, + "transform": transform_key, + "text": text, + "options": options or {}, + } + ) + return TransformResult( + action=data["action"], + transform_key=data["transform"], + transform_name=data["name"], + options=data["options"], + output=data["output"], + ) + + +def auto_decode(text: str) -> dict[str, Any] | None: + data = _run_bridge({"command": "auto-decode", "text": text}) + return data["result"] + + +def ensure_node_available() -> None: + process = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False) + if process.returncode != 0: + raise BridgeError("Node.js is required to run the P4RS3LT0NGV3 CLI") + + +def main_check() -> int: + try: + ensure_node_available() + except BridgeError as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + diff --git a/p4rs3lt0ngv3_cli/cli.py b/p4rs3lt0ngv3_cli/cli.py new file mode 100644 index 0000000..d4d3d7d --- /dev/null +++ b/p4rs3lt0ngv3_cli/cli.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from .agent import execute_plan, find_transform +from .bridge import BridgeError, auto_decode, ensure_node_available, inspect_transform, list_transforms, run_transform + + +def parse_option_pairs(pairs: list[str]) -> dict[str, Any]: + options: dict[str, Any] = {} + for pair in pairs: + if "=" not in pair: + raise ValueError(f"Invalid option '{pair}'. Expected key=value") + key, value = pair.split("=", 1) + normalized = value.strip() + lowered = normalized.lower() + if lowered in {"true", "false"}: + coerced: Any = lowered == "true" + else: + try: + coerced = int(normalized) + except ValueError: + try: + coerced = float(normalized) + except ValueError: + coerced = normalized + options[key.strip()] = coerced + return options + + +def read_text_argument(value: str | None) -> str: + if value is not None: + return value + if not sys.stdin.isatty(): + return sys.stdin.read() + return "" + + +def emit(data: Any, as_json: bool) -> None: + if as_json: + print(json.dumps(data, indent=2, ensure_ascii=False)) + elif isinstance(data, str): + print(data) + else: + print(json.dumps(data, indent=2, ensure_ascii=False)) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="p4rs3lt0ngv3-cli", description="Agent-based CLI for P4RS3LT0NGV3") + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List available transforms") + list_parser.add_argument("--category") + list_parser.add_argument("--json", action="store_true") + + inspect_parser = subparsers.add_parser("inspect", help="Inspect a transform") + inspect_parser.add_argument("transform") + inspect_parser.add_argument("--json", action="store_true") + + for command_name, action in [("encode", "encode"), ("decode", "decode"), ("preview", "preview")]: + cmd = subparsers.add_parser(command_name, help=f"{command_name.title()} text with a specific transform") + cmd.add_argument("--transform", required=True) + cmd.add_argument("--text") + cmd.add_argument("--option", action="append", default=[], help="Transform option in key=value form") + cmd.add_argument("--json", action="store_true") + cmd.set_defaults(action=action) + + autod = subparsers.add_parser("auto-decode", help="Use the universal decoder") + autod.add_argument("--text") + autod.add_argument("--json", action="store_true") + + agent = subparsers.add_parser("agent", help="Resolve a natural-language request") + agent.add_argument("prompt") + agent.add_argument("--json", action="store_true") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + ensure_node_available() + + if args.command == "list": + transforms = list_transforms() + if args.category: + transforms = [transform for transform in transforms if transform.category == args.category] + if args.json: + emit( + [ + { + "key": transform.key, + "name": transform.name, + "category": transform.category, + "can_decode": transform.can_decode, + } + for transform in transforms + ], + True, + ) + else: + for transform in transforms: + decode_flag = "decode" if transform.can_decode else "encode-only" + print(f"{transform.key:24} {transform.category:12} {decode_flag:11} {transform.name}") + return 0 + + if args.command == "inspect": + transform = find_transform(args.transform) + if not transform: + raise BridgeError(f"Unknown transform: {args.transform}") + meta = inspect_transform(transform.key) + data = { + "key": meta.key, + "name": meta.name, + "category": meta.category, + "priority": meta.priority, + "can_decode": meta.can_decode, + "input_kind": meta.input_kind, + "options": [ + { + "id": option.id, + "label": option.label, + "type": option.type, + "default": option.default, + "min": option.min, + "max": option.max, + "step": option.step, + "options": option.options, + } + for option in meta.configurable_options + ], + } + emit(data, args.json) + return 0 + + if args.command in {"encode", "decode", "preview"}: + transform = find_transform(args.transform) + if not transform: + raise BridgeError(f"Unknown transform: {args.transform}") + text = read_text_argument(args.text) + options = parse_option_pairs(args.option) + result = run_transform(args.action, transform.key, text, options) + if args.json: + emit( + { + "action": result.action, + "transform": result.transform_key, + "transform_name": result.transform_name, + "options": result.options, + "output": result.output, + }, + True, + ) + else: + emit(result.output, False) + return 0 + + if args.command == "auto-decode": + text = read_text_argument(args.text) + result = auto_decode(text) + emit(result or {}, args.json) + return 0 if result else 1 + + if args.command == "agent": + result = execute_plan(args.prompt) + emit(result if args.json else (result.get("final_output") or result), args.json) + return 0 + + except (BridgeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/p4rs3lt0ngv3_cli/models.py b/p4rs3lt0ngv3_cli/models.py new file mode 100644 index 0000000..ee16e26 --- /dev/null +++ b/p4rs3lt0ngv3_cli/models.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class TransformOption: + id: str + label: str + type: str + default: Any = None + min: float | int | None = None + max: float | int | None = None + step: float | int | None = None + options: list[dict[str, Any]] | None = None + + +@dataclass(slots=True) +class TransformInfo: + key: str + name: str + category: str + priority: int + can_decode: bool + description: str = "" + input_kind: str = "textarea" + configurable_options: list[TransformOption] = field(default_factory=list) + + @property + def search_tokens(self) -> set[str]: + tokens = {self.key.lower(), self.name.lower(), self.name.lower().replace(" ", "_")} + tokens.update(part for part in self.key.lower().split("_") if part) + tokens.update(part for part in self.name.lower().replace("-", " ").split() if part) + return tokens + + +@dataclass(slots=True) +class TransformResult: + action: str + transform_key: str + transform_name: str + options: dict[str, Any] + output: str + + +@dataclass(slots=True) +class AgentStep: + kind: str + transform_key: str | None = None + action: str | None = None + text: str | None = None + options: dict[str, Any] = field(default_factory=dict) + explanation: str = "" + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a43aec8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "p4rs3lt0ngv3-cli" +version = "0.1.0" +description = "Agent-based CLI for the P4RS3LT0NGV3 transform catalog" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[project.scripts] +p4rs3lt0ngv3-cli = "p4rs3lt0ngv3_cli.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.3.5", +] + +[tool.hatch.build.targets.wheel] +packages = ["p4rs3lt0ngv3_cli"] + +[tool.pytest.ini_options] +testpaths = ["python_tests"] +addopts = "-q" + diff --git a/python_tests/__pycache__/test_cli_e2e.cpython-314-pytest-9.0.2.pyc b/python_tests/__pycache__/test_cli_e2e.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..d61fa1e Binary files /dev/null and b/python_tests/__pycache__/test_cli_e2e.cpython-314-pytest-9.0.2.pyc differ diff --git a/python_tests/test_cli_e2e.py b/python_tests/test_cli_e2e.py new file mode 100644 index 0000000..af9cbc4 --- /dev/null +++ b/python_tests/test_cli_e2e.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def run_cli(*args: str, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["uv", "run", "p4rs3lt0ngv3-cli", *args], + cwd=PROJECT_ROOT, + input=input_text, + text=True, + capture_output=True, + check=False, + ) + + +def parse_json_output(process: subprocess.CompletedProcess[str]): + assert process.returncode == 0, process.stderr + return json.loads(process.stdout) + + +def test_list_json_exposes_large_catalog() -> None: + process = run_cli("list", "--json") + payload = parse_json_output(process) + keys = {item["key"] for item in payload} + assert len(payload) >= 150 + assert "base64" in keys + assert "caesar" in keys + + +def test_inspect_includes_transform_options() -> None: + process = run_cli("inspect", "caesar", "--json") + payload = parse_json_output(process) + assert payload["key"] == "caesar" + assert any(option["id"] == "shift" for option in payload["options"]) + + +def test_encode_and_decode_round_trip() -> None: + encoded = run_cli("encode", "--transform", "base64", "--text", "Hello World") + assert encoded.returncode == 0, encoded.stderr + assert encoded.stdout.strip() == "SGVsbG8gV29ybGQ=" + + decoded = run_cli("decode", "--transform", "base64", "--text", encoded.stdout.strip()) + assert decoded.returncode == 0, decoded.stderr + assert decoded.stdout.strip() == "Hello World" + + +def test_encode_supports_transform_options() -> None: + process = run_cli( + "encode", + "--transform", + "binary", + "--text", + "Hi", + "--option", + "byteSpacing=false", + ) + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "0100100001101001" + + +def test_auto_decode_uses_universal_decoder() -> None: + process = run_cli("auto-decode", "--text", "SGVsbG8=", "--json") + payload = parse_json_output(process) + assert payload["text"] == "Hello" + assert payload["method"] == "Base64" + + +def test_agent_can_route_simple_encode_request() -> None: + process = run_cli("agent", "encode 'Hello' as base64") + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "SGVsbG8=" + + +def test_agent_can_route_decode_request_with_options() -> None: + process = run_cli("agent", "decode 'Fyyfhp fy ifbs' from caesar shift 5") + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "Attack at dawn" + + +def test_agent_can_chain_steps() -> None: + process = run_cli("agent", "encode 'Hi' as base64 then decode from base64", "--json") + payload = parse_json_output(process) + assert payload["final_output"] == "Hi" + assert len(payload["outputs"]) == 2 diff --git a/scripts/cli_bridge.js b/scripts/cli_bridge.js new file mode 100644 index 0000000..8e909f1 --- /dev/null +++ b/scripts/cli_bridge.js @@ -0,0 +1,217 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const projectRoot = path.join(__dirname, '..'); +const transformsRoot = path.join(projectRoot, 'src', 'transformers'); +const transforms = require(path.join(projectRoot, 'src', 'transformers', 'loader-node.js')); + +function buildRegistry() { + const skipFiles = new Set(['BaseTransformer.js', 'index.js', 'loader-node.js', 'README.md']); + const categories = fs.readdirSync(transformsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const registry = []; + for (const category of categories) { + const categoryPath = path.join(transformsRoot, category); + const files = fs.readdirSync(categoryPath) + .filter((file) => file.endsWith('.js') && !skipFiles.has(file)) + .sort(); + + for (const file of files) { + const key = file.replace('.js', '').replace(/-/g, '_'); + const transform = transforms[key]; + if (!transform) { + continue; + } + + registry.push({ + key, + category, + name: transform.name, + priority: transform.priority ?? 0, + canDecode: Boolean(transform.reverse), + description: transform.description || '', + inputKind: transform.inputKind || 'textarea', + configurableOptions: (transform.configurableOptions || []).map((opt) => ({ + id: opt.id, + label: opt.label, + type: opt.type, + default: opt.default, + min: opt.min, + max: opt.max, + step: opt.step, + options: opt.options || undefined + })) + }); + } + } + + return registry; +} + +function getDefaultOptions(transform) { + if (!transform || !transform.configurableOptions || !transform.configurableOptions.length) { + return {}; + } + + const defaults = {}; + for (const opt of transform.configurableOptions) { + let value = opt.default; + if (value === undefined || value === null) { + if (opt.type === 'boolean') { + value = false; + } else if (opt.type === 'select' && opt.options && opt.options.length) { + value = opt.options[0].value; + } else if (opt.type === 'number') { + value = 0; + } else { + value = ''; + } + } + defaults[opt.id] = value; + } + + return defaults; +} + +function loadUniversalDecoder() { + const transformOptionsCode = fs.readFileSync(path.join(projectRoot, 'js', 'core', 'transformOptions.js'), 'utf8'); + const decoderCode = fs.readFileSync(path.join(projectRoot, 'js', 'core', 'decoder.js'), 'utf8'); + const emojiWordMapCode = fs.readFileSync(path.join(projectRoot, 'src', 'emojiWordMap.js'), 'utf8'); + const emojiUtilsCode = fs.readFileSync(path.join(projectRoot, 'js', 'utils', 'emoji.js'), 'utf8'); + + const mockSteganography = { + hasEmojiInText: () => false, + decodeEmoji: () => null, + decodeInvisible: () => null + }; + + const sandbox = { + window: { + transforms, + steganography: mockSteganography, + emojiLibrary: {}, + emojiKeywords: {}, + emojiData: {} + }, + console, + TextEncoder, + TextDecoder, + Intl, + btoa: (str) => Buffer.from(str, 'binary').toString('base64'), + atob: (str) => Buffer.from(str, 'base64').toString('binary') + }; + + vm.createContext(sandbox); + vm.runInContext(emojiUtilsCode, sandbox); + vm.runInContext(emojiWordMapCode, sandbox); + vm.runInContext(transformOptionsCode, sandbox); + vm.runInContext(decoderCode, sandbox); + + return sandbox.universalDecode; +} + +function normalizeError(error) { + if (error && typeof error.message === 'string') { + return error.message; + } + return String(error); +} + +function readPayload() { + const raw = fs.readFileSync(0, 'utf8'); + return raw ? JSON.parse(raw) : {}; +} + +function writeResult(result, exitCode = 0) { + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exit(exitCode); +} + +function main() { + const payload = readPayload(); + const command = payload.command; + const registry = buildRegistry(); + + if (command === 'list') { + writeResult({ ok: true, transforms: registry }); + } + + const transformKey = payload.transform; + const transform = transformKey ? transforms[transformKey] : null; + + if (command === 'inspect') { + if (!transform || !transformKey) { + writeResult({ ok: false, error: `Unknown transform: ${transformKey}` }, 1); + } + + const meta = registry.find((entry) => entry.key === transformKey); + writeResult({ + ok: true, + transform: { + ...meta, + defaultOptions: getDefaultOptions(transform) + } + }); + } + + if (command === 'run') { + if (!transform || !transformKey) { + writeResult({ ok: false, error: `Unknown transform: ${transformKey}` }, 1); + } + + const action = payload.action || 'encode'; + const text = payload.text || ''; + const options = { + ...getDefaultOptions(transform), + ...(payload.options || {}) + }; + + try { + let output; + if (action === 'encode') { + output = transform.func(text, options); + } else if (action === 'decode') { + if (!transform.reverse) { + throw new Error(`${transform.name} does not support decode`); + } + output = transform.reverse(text, options); + } else if (action === 'preview') { + output = transform.preview(text, options); + } else { + throw new Error(`Unsupported action: ${action}`); + } + + writeResult({ + ok: true, + action, + transform: transformKey, + name: transform.name, + options, + output + }); + } catch (error) { + writeResult({ ok: false, error: normalizeError(error) }, 1); + } + } + + if (command === 'auto-decode') { + const decode = loadUniversalDecoder(); + try { + const result = decode(payload.text || '', {}); + writeResult({ ok: true, result }); + } catch (error) { + writeResult({ ok: false, error: normalizeError(error) }, 1); + } + } + + writeResult({ ok: false, error: `Unknown command: ${command}` }, 1); +} + +main(); + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8763594 --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "p4rs3lt0ngv3-cli" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3.5" }] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +]