From fb25edb1fe2ee7c60790a5f1ad1e02744079550e Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 12 Jun 2026 23:52:38 +0000 Subject: [PATCH 1/2] feat: add code-mode sandbox command for direct CLI execution of Python scripts and inline code --- README.md | 14 ++++++++++++- codemode.md | 15 ++++++++++++-- lkr/codemode/main.py | 45 +++++++++++++++++++++++++++++++++++++++++- tests/test_codemode.py | 39 +++++++++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 531ab59..ab1f3a8 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,19 @@ Go to the Looker API Explorer for Register OAuth App (https://your.looker.instan ## Code Mode -Execute Python code safely with full Looker SDK coverage within a secure sandbox environment. Constructed as an MCP tool, it dynamically inspects the Looker SDK for all public methods and injects them into the Monty sandbox safely. For detailed options, safe primitives transformations, and PKCE configurations, view the full [Code Mode Docs](./codemode.md). +Execute Python code safely with full Looker SDK coverage within a secure sandbox environment (`Monty`). It dynamically inspects the Looker SDK for all public methods and injects them into the sandbox safely, supporting both Model Context Protocol (MCP) server mode and direct standalone CLI execution. + +### Direct CLI Execution (Sandbox Mode) +Run scripts or inline code directly against your active Looker instance: +```bash +# Execute inline Python code +uvx lkr-dev-cli code-mode sandbox --code="return me()" + +# Execute a Python script file +uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py +``` + +For detailed options, safe primitive transformations, MCP server configurations, and PKCE recovery, view the full [Code Mode Docs](./codemode.md). ## MCP Built into the `lkr` is an MCP server. Right now its tools are based on helping you work within an IDE. To use it a tool like [Cursor](https://www.cursor.com/), add this to your mcp.json diff --git a/codemode.md b/codemode.md index 2f8f090..900f723 100644 --- a/codemode.md +++ b/codemode.md @@ -16,8 +16,19 @@ The tool instantiates Looker SDK natively, searches all bound methods, and passe ## Continuous Usage -### 1. Starting the Server -To immediately trigger the stdio listener, use: +### 1. Direct CLI Execution (Sandbox Mode) +If you want to execute Python scripts or inline code directly against your Looker instance without setting up an MCP client, use the `sandbox` command: + +```bash +# Execute inline Python code +uvx lkr-dev-cli code-mode sandbox --code="return me()" + +# Execute Python script file +uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py +``` + +### 2. Starting the Server +To immediately trigger the MCP stdio listener, use: ```bash uvx -q lkr-dev-cli[codemode] code-mode run ``` diff --git a/lkr/codemode/main.py b/lkr/codemode/main.py index b6b3bdd..eb060a0 100644 --- a/lkr/codemode/main.py +++ b/lkr/codemode/main.py @@ -1,6 +1,7 @@ import inspect import json import os +import re import sys import tempfile from contextlib import contextmanager @@ -21,7 +22,7 @@ __all__ = ["group"] mcp = FastMCP("lkr:codemode") -group = typer.Typer() +group = typer.Typer(no_args_is_help=True) ctx_lkr: LkrCtxObj | None = None class OSCapture: def __init__(self): @@ -156,6 +157,10 @@ class SDK: setattr(SDK, name, staticmethod(func)) external_funcs['sdk'] = SDK + # Monty external_functions do not support attribute lookups on objects. + # Pre-process the code to replace `sdk.method_name` with `method_name`. + code = re.sub(r"\bsdk\.([a-zA-Z_][a-zA-Z0-9_]*)\b", r"\1", code) + m = pydantic_monty.Monty(code) # Use low-level OS stdout capture to ensure any print() statements @@ -191,7 +196,45 @@ class SDK: return f"Error: {str(e)}" +@group.command(name="sandbox") +def sandbox( + ctx: typer.Context, + code: str | None = typer.Option( + None, "--code", "-c", help="Execute Python code directly in the sandbox" + ), + file: str | None = typer.Option( + None, "--file", "-f", help="Execute Python code from a file in the sandbox" + ), + dev_mode: bool = typer.Option( + False, "--dev-mode", help="Run in dev mode" + ), +): + if not code and not file: + logger.error("Must specify either --code or --file") + raise typer.Exit(1) + + if code and file: + logger.error("Cannot specify both --code and --file") + raise typer.Exit(1) + + if file: + try: + with open(file, "r") as f: + code_to_run = f.read() + except Exception as e: + logger.error(f"Failed to read file {file}: {e}") + raise typer.Exit(1) + else: + code_to_run = code + global ctx_lkr + ctx_lkr = ( + ctx.obj.get("ctx_lkr") + if (ctx.obj and "ctx_lkr" in ctx.obj) + else LkrCtxObj(force_oauth=False) + ) + result = run_python_code(code_to_run, dev_mode=dev_mode) + typer.echo(result) @group.command(name="run") diff --git a/tests/test_codemode.py b/tests/test_codemode.py index fb4b08c..c9c84e4 100644 --- a/tests/test_codemode.py +++ b/tests/test_codemode.py @@ -1,11 +1,15 @@ from typing import Any, cast import json +import os +import tempfile import pytest from unittest.mock import patch, MagicMock +from typer.testing import CliRunner from looker_sdk.rtl.auth_session import AuthSession from looker_sdk.rtl.transport import Transport from lkr.codemode.main import run_python_code from lkr.extended_sdk_methods import ExtendedLooker40SDK +from lkr.main import app class DummyAuth: @@ -67,7 +71,7 @@ def test_sdk_object(): return me_obj["first_name"] """ result = run_python_code(code_sdk) - assert len(result) > 0 + assert result == "Test" def test_examples(): code_examples = """ @@ -283,3 +287,36 @@ def test_parent_runtime_not_polluted(capfd): out, err = capfd.readouterr() assert out == "" + +runner = CliRunner() + +def test_cli_code_mode_sandbox_code(): + result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()"]) + assert result.exit_code == 0 + assert "Test" in result.stdout + +def test_cli_code_mode_sandbox_sdk_code(): + result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return sdk.me()"]) + assert result.exit_code == 0 + assert "Test" in result.stdout + +def test_cli_code_mode_sandbox_file(): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("return me()") + temp_path = f.name + + try: + result = runner.invoke(app, ["code-mode", "sandbox", "--file", temp_path]) + assert result.exit_code == 0 + assert "Test" in result.stdout + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + +def test_cli_code_mode_sandbox_both_error(): + result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()", "--file", "dummy.py"]) + assert result.exit_code == 1 + +def test_cli_code_mode_sandbox_neither_error(): + result = runner.invoke(app, ["code-mode", "sandbox"]) + assert result.exit_code == 1 From 1f993d274e3470ea3b740e6206962ccc8778e354 Mon Sep 17 00:00:00 2001 From: Bryan Weber Date: Fri, 12 Jun 2026 23:56:45 +0000 Subject: [PATCH 2/2] refactor: replace regex with AST-based transformation for sdk method rewriting and improve file handling and context validation --- lkr/codemode/main.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lkr/codemode/main.py b/lkr/codemode/main.py index eb060a0..a89ef1d 100644 --- a/lkr/codemode/main.py +++ b/lkr/codemode/main.py @@ -1,3 +1,4 @@ +import ast import inspect import json import os @@ -158,8 +159,22 @@ class SDK: external_funcs['sdk'] = SDK # Monty external_functions do not support attribute lookups on objects. - # Pre-process the code to replace `sdk.method_name` with `method_name`. - code = re.sub(r"\bsdk\.([a-zA-Z_][a-zA-Z0-9_]*)\b", r"\1", code) + # Pre-process the code to replace `sdk.method_name` with `method_name` safely using AST. + try: + class SDKAttributeRewriter(ast.NodeTransformer): + def visit_Attribute(self, node): + self.generic_visit(node) + if isinstance(node.value, ast.Name) and node.value.id == 'sdk': + return ast.copy_location(ast.Name(id=node.attr, ctx=node.ctx), node) + return node + + tree = ast.parse(code) + tree = SDKAttributeRewriter().visit(tree) + ast.fix_missing_locations(tree) + code = ast.unparse(tree) + except Exception: + # Fallback to regex if parsing fails + code = re.sub(r"\bsdk\.([a-zA-Z_][a-zA-Z0-9_]*)\b", r"\1", code) m = pydantic_monty.Monty(code) @@ -219,7 +234,7 @@ def sandbox( if file: try: - with open(file, "r") as f: + with open(file, "r", encoding="utf-8") as f: code_to_run = f.read() except Exception as e: logger.error(f"Failed to read file {file}: {e}") @@ -230,7 +245,7 @@ def sandbox( global ctx_lkr ctx_lkr = ( ctx.obj.get("ctx_lkr") - if (ctx.obj and "ctx_lkr" in ctx.obj) + if (ctx and ctx.obj and "ctx_lkr" in ctx.obj) else LkrCtxObj(force_oauth=False) ) result = run_python_code(code_to_run, dev_mode=dev_mode) @@ -245,7 +260,7 @@ def run( global ctx_lkr ctx_lkr = ( ctx.obj.get("ctx_lkr") - if (ctx.obj and "ctx_lkr" in ctx.obj) + if (ctx and ctx.obj and "ctx_lkr" in ctx.obj) else LkrCtxObj(force_oauth=False) ) mcp.run()