diff --git a/README.md b/README.md index d652878..027106c 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,9 @@ 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" +uv run p4rs3lt0ngv3-cli /base64 Hello +uv run p4rs3lt0ngv3-cli /base64 --decode SGVsbG8= +uv run p4rs3lt0ngv3-cli /caesar --shift 5 "Attack at dawn" ``` Notes: diff --git a/p4rs3lt0ngv3_cli/cli.py b/p4rs3lt0ngv3_cli/cli.py index d4d3d7d..4b3dc6a 100644 --- a/p4rs3lt0ngv3_cli/cli.py +++ b/p4rs3lt0ngv3_cli/cli.py @@ -48,6 +48,109 @@ def emit(data: Any, as_json: bool) -> None: print(json.dumps(data, indent=2, ensure_ascii=False)) +def coerce_option_value(raw: str) -> Any: + lowered = raw.lower() + if lowered in {"true", "false"}: + return lowered == "true" + try: + return int(raw) + except ValueError: + try: + return float(raw) + except ValueError: + return raw + + +def parse_slash_command(argv: list[str]) -> tuple[str, dict[str, Any]] | None: + if not argv: + return None + + first = argv[0] + if not first.startswith("/") or first == "/": + return None + + name = first[1:] + if not name: + return None + + if name == "inspect": + if len(argv) < 2: + raise ValueError("Usage: /inspect [--json]") + return ("inspect", {"transform": argv[1], "json": "--json" in argv[2:]}) + + if name == "list": + category = None + json_mode = False + tokens = argv[1:] + index = 0 + while index < len(tokens): + token = tokens[index] + if token == "--json": + json_mode = True + elif token == "--category": + if index + 1 >= len(tokens): + raise ValueError("Missing value for --category") + category = tokens[index + 1] + index += 1 + else: + raise ValueError(f"Unsupported option for /list: {token}") + index += 1 + return ("list", {"category": category, "json": json_mode}) + + if name == "decode": + tokens = argv[1:] + json_mode = False + if "--json" in tokens: + json_mode = True + tokens = [token for token in tokens if token != "--json"] + return ("auto-decode", {"text": " ".join(tokens), "json": json_mode}) + + action = "encode" + json_mode = False + options: dict[str, Any] = {} + text_tokens: list[str] = [] + tokens = argv[1:] + index = 0 + + while index < len(tokens): + token = tokens[index] + if token == "--decode": + action = "decode" + elif token == "--preview": + action = "preview" + elif token == "--json": + json_mode = True + elif token == "--option": + if index + 1 >= len(tokens): + raise ValueError("Missing value for --option") + options.update(parse_option_pairs([tokens[index + 1]])) + index += 1 + elif token.startswith("--"): + flag = token[2:] + if "=" in flag: + key, value = flag.split("=", 1) + options[key] = coerce_option_value(value) + elif index + 1 < len(tokens) and not tokens[index + 1].startswith("--"): + options[flag] = coerce_option_value(tokens[index + 1]) + index += 1 + else: + options[flag] = True + else: + text_tokens.append(token) + index += 1 + + return ( + "transform-shortcut", + { + "transform": name, + "action": action, + "text": " ".join(text_tokens), + "json": json_mode, + "options": options, + }, + ) + + 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) @@ -80,8 +183,101 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: + raw_argv = list(sys.argv[1:] if argv is None else argv) + + try: + slash_command = parse_slash_command(raw_argv) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + if slash_command is not None: + command, payload = slash_command + try: + ensure_node_available() + if command == "list": + transforms = list_transforms() + if payload["category"]: + transforms = [transform for transform in transforms if transform.category == payload["category"]] + if payload["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 command == "inspect": + transform = find_transform(payload["transform"]) + if not transform: + raise BridgeError(f"Unknown transform: {payload['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, payload["json"]) + return 0 + + if command == "auto-decode": + result = auto_decode(payload["text"]) + emit(result or {}, payload["json"]) + return 0 if result else 1 + + if command == "transform-shortcut": + transform = find_transform(payload["transform"]) + if not transform: + raise BridgeError(f"Unknown transform: {payload['transform']}") + text = payload["text"] or read_text_argument(None) + result = run_transform(payload["action"], transform.key, text, payload["options"]) + if payload["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 + except (BridgeError, ValueError) as exc: + print(str(exc), file=sys.stderr) + return 1 + parser = build_parser() - args = parser.parse_args(argv) + args = parser.parse_args(raw_argv) try: ensure_node_available() @@ -180,4 +376,3 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/python_tests/test_cli_e2e.py b/python_tests/test_cli_e2e.py index af9cbc4..8fe3a7a 100644 --- a/python_tests/test_cli_e2e.py +++ b/python_tests/test_cli_e2e.py @@ -88,3 +88,27 @@ def test_agent_can_chain_steps() -> None: payload = parse_json_output(process) assert payload["final_output"] == "Hi" assert len(payload["outputs"]) == 2 + + +def test_slash_command_encodes_text() -> None: + process = run_cli("/base64", "Hello") + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "SGVsbG8=" + + +def test_slash_command_decodes_text() -> None: + process = run_cli("/base64", "--decode", "SGVsbG8=") + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "Hello" + + +def test_slash_command_supports_transform_flags() -> None: + process = run_cli("/caesar", "--shift", "5", "Attack", "at", "dawn") + assert process.returncode == 0, process.stderr + assert process.stdout.strip() == "Fyyfhp fy ifbs" + + +def test_slash_command_supports_inspect() -> None: + process = run_cli("/inspect", "caesar", "--json") + payload = parse_json_output(process) + assert payload["key"] == "caesar"