From d064d6f7dcf4b09996ea5c15760870f59f034f22 Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Thu, 12 Feb 2026 14:26:51 +0530 Subject: [PATCH 1/4] Add a FastAPI webserver for claude code remote call on /oape:api-implement command Co-Authored-By: Claude Opus 4.6 Signed-off-by: Swarup Ghosh --- server/requirements.txt | 3 + server/server.py | 123 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 server/requirements.txt create mode 100644 server/server.py diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..23c01e4 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +claude-agent-sdk>=0.1.0 diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..9edfd40 --- /dev/null +++ b/server/server.py @@ -0,0 +1,123 @@ +""" +FastAPI server that exposes the /oape:api-implement Claude Code skill +via the Claude Agent SDK. + +Usage: + uvicorn api.server:app --reload + +Endpoint: + GET /api-implement?ep_url=&cwd= +""" + +import os +import re +from pathlib import Path + +import anyio +from fastapi import FastAPI, HTTPException, Query +from claude_agent_sdk import ( + query, + ClaudeAgentOptions, + AssistantMessage, + ResultMessage, + TextBlock, +) + +app = FastAPI( + title="OAPE API Implement", + description="Invokes the /oape:api-implement Claude Code skill to generate " + "controller/reconciler code from an OpenShift enhancement proposal.", + version="0.1.0", +) + +EP_URL_PATTERN = re.compile( + r"^https://github\.com/openshift/enhancements/pull/\d+/?$" +) + +# Resolve the plugin directory (repo root) relative to this file. +PLUGIN_DIR = str(Path(__file__).resolve().parent.parent) + + +@app.get("/api-implement") +async def api_implement( + ep_url: str = Query( + ..., + description="GitHub PR URL for the OpenShift enhancement proposal " + "(e.g. https://github.com/openshift/enhancements/pull/1234)", + ), + cwd: str = Query( + default="", + description="Absolute path to the operator repository where code " + "will be generated. Defaults to the current working directory.", + ), +): + """Generate controller/reconciler code from an enhancement proposal.""" + + # --- Validate EP URL --- + if not EP_URL_PATTERN.match(ep_url.rstrip("/")): + raise HTTPException( + status_code=400, + detail=( + "Invalid enhancement PR URL. " + "Expected format: https://github.com/openshift/enhancements/pull/" + ), + ) + + # --- Resolve working directory --- + working_dir = cwd if cwd else os.getcwd() + if not os.path.isdir(working_dir): + raise HTTPException( + status_code=400, + detail=f"The provided cwd is not a valid directory: {working_dir}", + ) + + # --- Build SDK options --- + options = ClaudeAgentOptions( + system_prompt=( + "You are an OpenShift operator code generation assistant. " + "Execute the /oape:api-implement skill with the provided EP URL. " + "Generate production-ready controller code with zero TODOs." + ), + cwd=working_dir, + permission_mode="bypassPermissions", + allowed_tools=[ + "Bash", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "WebFetch", + "Task", + ], + plugins=[PLUGIN_DIR], + ) + + # --- Run the agent --- + output_parts: list[str] = [] + cost_usd = 0.0 + + try: + async for message in query( + prompt=f"/oape:api-implement {ep_url}", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + output_parts.append(block.text) + elif isinstance(message, ResultMessage): + cost_usd = message.total_cost_usd + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"Agent execution failed: {exc}", + ) + + return { + "status": "success", + "ep_url": ep_url, + "cwd": working_dir, + "output": "\n".join(output_parts), + "cost_usd": cost_usd, + } From d67e1480a2c6e45cf93279cee04fa7288c382ba9 Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Fri, 13 Feb 2026 16:45:14 +0530 Subject: [PATCH 2/4] Fixes in incorrect plugin path, export path Signed-off-by: Swarup Ghosh --- server/config.json | 13 +++++++++++++ server/server.py | 34 ++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 server/config.json diff --git a/server/config.json b/server/config.json new file mode 100644 index 0000000..a984ffa --- /dev/null +++ b/server/config.json @@ -0,0 +1,13 @@ + +{ + "claude_allowed_tools": [ + "Bash(*)", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "WebFetch", + "Task" + ] +} \ No newline at end of file diff --git a/server/server.py b/server/server.py index 9edfd40..c1fad66 100644 --- a/server/server.py +++ b/server/server.py @@ -11,6 +11,7 @@ import os import re +import json from pathlib import Path import anyio @@ -23,9 +24,15 @@ TextBlock, ) + +with open("config.json") as cf: + config_json_str = cf.read() +CONFIGS = json.loads(config_json_str) + + app = FastAPI( - title="OAPE API Implement", - description="Invokes the /oape:api-implement Claude Code skill to generate " + title="OAPE Operator Feature Developer", + description="Invokes the /oape:api-implement Claude Code command to generate " "controller/reconciler code from an OpenShift enhancement proposal.", version="0.1.0", ) @@ -35,7 +42,9 @@ ) # Resolve the plugin directory (repo root) relative to this file. -PLUGIN_DIR = str(Path(__file__).resolve().parent.parent) +# The SDK expects the path to the plugin root (containing .claude-plugin/). +PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape") +print(PLUGIN_DIR) @app.get("/api-implement") @@ -75,22 +84,12 @@ async def api_implement( options = ClaudeAgentOptions( system_prompt=( "You are an OpenShift operator code generation assistant. " - "Execute the /oape:api-implement skill with the provided EP URL. " - "Generate production-ready controller code with zero TODOs." + "Execute the oape:api-implement plugin with the provided EP URL. " ), cwd=working_dir, permission_mode="bypassPermissions", - allowed_tools=[ - "Bash", - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "WebFetch", - "Task", - ], - plugins=[PLUGIN_DIR], + allowed_tools=CONFIGS['claude_allowed_tools'], + plugins=[{"type": "local", "path": PLUGIN_DIR}], ) # --- Run the agent --- @@ -100,6 +99,7 @@ async def api_implement( try: async for message in query( prompt=f"/oape:api-implement {ep_url}", + # prompt="explain the enhancement proposal to me like I'm 5 in 10 sentences, {ep_url}", options=options, ): if isinstance(message, AssistantMessage): @@ -108,6 +108,8 @@ async def api_implement( output_parts.append(block.text) elif isinstance(message, ResultMessage): cost_usd = message.total_cost_usd + if message.result: + output_parts.append(message.result) except Exception as exc: raise HTTPException( status_code=500, From 0973770acbcd63228beac778441bd08f09bf96ea Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Fri, 13 Feb 2026 17:33:44 +0530 Subject: [PATCH 3/4] Add GitHubb action, Dockerfile Signed-off-by: Swarup Ghosh --- .github/workflows/build-image.yaml | 46 ++++++++++++++++++++ Dockerfile | 36 ++++++++++++++++ deploy/deployment.yaml | 67 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 .github/workflows/build-image.yaml create mode 100644 Dockerfile create mode 100644 deploy/deployment.yaml diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml new file mode 100644 index 0000000..a87dd24 --- /dev/null +++ b/.github/workflows/build-image.yaml @@ -0,0 +1,46 @@ +name: Build and Push Container Image + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..919e0e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM registry.access.redhat.com/ubi9/python-312 + +# Install system dependencies required by operator tooling +USER 0 +RUN dnf install -y \ + git \ + make && \ + # Install Go toolchain + curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz && \ + # Install GitHub CLI + dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh && \ + dnf clean all + +ENV PATH="/usr/local/go/bin:${PATH}" + +WORKDIR /app + +# Install Python dependencies +COPY server/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy server code and config +COPY server/server.py server/config.json ./ + +# Copy plugins directory +# server.py resolves: Path(__file__).parent.parent / "plugins" / "oape" +# With __file__=/app/server.py, parent.parent=/, so it expects /plugins/oape +COPY plugins /plugins + +USER 1001 + +EXPOSE 8000 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..ef4d6fa --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: oape-server-config +data: + CLAUDE_CODE_USE_VERTEX: "1" + CLOUD_ML_REGION: "us-east5" + ANTHROPIC_VERTEX_PROJECT_ID: "[gcp-project-id]" + ANTHROPIC_MODEL: "claude-opus-4-6" +--- +apiVersion: v1 +kind: Secret +metadata: + name: gcloud-adc-secret +type: Opaque +stringData: + # Replace with ~/.config/gcloud/application_default_credentials.json + # that can authenticate with GCP project. + application_default_credentials.json: '{}' +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oape-server + labels: + app: oape-server +spec: + replicas: 1 + selector: + matchLabels: + app: oape-server + template: + metadata: + labels: + app: oape-server + spec: + containers: + - name: oape-server + image: ghcr.io/shiftweek/oape-ai-e2e:latest + ports: + - containerPort: 8000 + envFrom: + - configMapRef: + name: oape-server-config + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /secrets/gcloud/application_default_credentials.json + volumeMounts: + - name: gcloud-adc + mountPath: /secrets/gcloud + readOnly: true + volumes: + - name: gcloud-adc + secret: + secretName: gcloud-adc-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: oape-server +spec: + selector: + app: oape-server + ports: + - port: 8000 + targetPort: 8000 + type: ClusterIP From 4d31efa2922599493d77834b5400eaec507c00ad Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Fri, 13 Feb 2026 17:46:40 +0530 Subject: [PATCH 4/4] Add debugging for agent conversation in /tmp/conversation.log Signed-off-by: Swarup Ghosh --- server/server.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/server/server.py b/server/server.py index c1fad66..6d2a0ff 100644 --- a/server/server.py +++ b/server/server.py @@ -9,12 +9,12 @@ GET /api-implement?ep_url=&cwd= """ +import json +import logging import os import re -import json +import traceback from pathlib import Path - -import anyio from fastapi import FastAPI, HTTPException, Query from claude_agent_sdk import ( query, @@ -46,6 +46,14 @@ PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape") print(PLUGIN_DIR) +CONVERSATION_LOG = Path("/tmp/conversation.log") + +conv_logger = logging.getLogger("conversation") +conv_logger.setLevel(logging.INFO) +_handler = logging.FileHandler(CONVERSATION_LOG) +_handler.setFormatter(logging.Formatter("%(message)s")) +conv_logger.addHandler(_handler) + @app.get("/api-implement") async def api_implement( @@ -94,27 +102,42 @@ async def api_implement( # --- Run the agent --- output_parts: list[str] = [] + conversation: list[dict] = [] cost_usd = 0.0 + def _log(role: str, content, **extra): + entry = {"role": role, "content": content, **extra} + conversation.append(entry) + conv_logger.info(f"[{role}] {content}") + + conv_logger.info(f"\n{'=' * 60}\n[request] ep_url={ep_url} cwd={working_dir}\n{'=' * 60}") + try: async for message in query( prompt=f"/oape:api-implement {ep_url}", - # prompt="explain the enhancement proposal to me like I'm 5 in 10 sentences, {ep_url}", options=options, ): if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, TextBlock): output_parts.append(block.text) + _log("assistant", block.text) + else: + _log(f"assistant:{type(block).__name__}", + json.dumps(getattr(block, "__dict__", str(block)), default=str)) elif isinstance(message, ResultMessage): cost_usd = message.total_cost_usd if message.result: output_parts.append(message.result) + _log("result", message.result, cost_usd=cost_usd) + else: + _log(type(message).__name__, + json.dumps(getattr(message, "__dict__", str(message)), default=str)) except Exception as exc: - raise HTTPException( - status_code=500, - detail=f"Agent execution failed: {exc}", - ) + conv_logger.info(f"[error] {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Agent execution failed: {exc}") + + conv_logger.info(f"[done] cost=${cost_usd:.4f} parts={len(output_parts)}\n") return { "status": "success",