feat: add adapter code + Dockerfile for standalone deployment
Adapters extracted from molecule-monorepo/workspace-template. Uses molecule-ai-workspace-runtime PyPI package for shared infrastructure. - adapter.py — runtime-specific adapter class - requirements.txt — runtime-specific deps + molecule-ai-workspace-runtime - Dockerfile — FROM python:3.11-slim, pip install, COPY adapter, molecule-runtime entrypoint - ADAPTER_MODULE=adapter tells the runtime to load this repo's Adapter class Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b8859da375
commit
7f9b2b4189
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# System deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl gosu nodejs npm ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install claude-code CLI via npm
|
||||
RUN npm install -g @anthropic-ai/claude-code 2>/dev/null || true
|
||||
|
||||
# Create agent user
|
||||
RUN useradd -u 1000 -m -s /bin/bash agent
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy adapter code
|
||||
COPY adapter.py .
|
||||
COPY __init__.py .
|
||||
|
||||
# Set the adapter module for runtime discovery
|
||||
ENV ADAPTER_MODULE=adapter
|
||||
|
||||
ENTRYPOINT ["molecule-runtime"]
|
||||
3
__init__.py
Normal file
3
__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .adapter import ClaudeCodeAdapter
|
||||
|
||||
Adapter = ClaudeCodeAdapter
|
||||
167
adapter.py
Normal file
167
adapter.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Claude Code adapter — wraps the Claude Code CLI as an agent runtime."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from molecule_runtime.adapters.base import BaseAdapter, AdapterConfig
|
||||
from a2a.server.agent_execution import AgentExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap one transcript response at 1000 lines so a paranoid client can't OOM
|
||||
# the workspace by polling /transcript?limit=999999.
|
||||
_TRANSCRIPT_MAX_LIMIT = 1000
|
||||
|
||||
|
||||
class ClaudeCodeAdapter(BaseAdapter):
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "claude-code"
|
||||
|
||||
@staticmethod
|
||||
def display_name() -> str:
|
||||
return "Claude Code"
|
||||
|
||||
@staticmethod
|
||||
def description() -> str:
|
||||
return "Claude Code CLI — full agentic coding with hooks, CLAUDE.md, auto-memory, and MCP support"
|
||||
|
||||
@staticmethod
|
||||
def get_config_schema() -> dict:
|
||||
return {
|
||||
"model": {"type": "string", "description": "Claude model (e.g. sonnet, opus, haiku)", "default": "sonnet"},
|
||||
"required_env": {"type": "array", "description": "Required env vars", "default": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
||||
"timeout": {"type": "integer", "description": "Timeout in seconds (0 = no timeout)", "default": 0},
|
||||
}
|
||||
|
||||
async def setup(self, config: AdapterConfig) -> None:
|
||||
"""Install plugins via the per-runtime adaptor registry.
|
||||
|
||||
The legacy claude-code-specific ``inject_plugins()`` override is gone:
|
||||
each plugin now ships (or has registered in the platform registry) a
|
||||
per-runtime adaptor, and ``BaseAdapter.install_plugins_via_registry``
|
||||
routes installs through it. The Claude Code SDK still reads
|
||||
``CLAUDE.md`` and ``/configs/skills/`` natively, and the default
|
||||
:class:`AgentskillsAdaptor` writes to both.
|
||||
"""
|
||||
from plugins import load_plugins
|
||||
workspace_plugins_dir = os.path.join(config.config_path, "plugins")
|
||||
plugins = load_plugins(
|
||||
workspace_plugins_dir=workspace_plugins_dir,
|
||||
shared_plugins_dir=os.environ.get("PLUGINS_DIR", "/plugins"),
|
||||
)
|
||||
await self.install_plugins_via_registry(config, plugins)
|
||||
|
||||
async def create_executor(self, config: AdapterConfig) -> AgentExecutor:
|
||||
from claude_sdk_executor import ClaudeSDKExecutor
|
||||
|
||||
# Load system prompt if exists
|
||||
system_prompt = config.system_prompt
|
||||
if not system_prompt:
|
||||
prompt_file = os.path.join(config.config_path, "system-prompt.md")
|
||||
if os.path.exists(prompt_file):
|
||||
with open(prompt_file) as f:
|
||||
system_prompt = f.read()
|
||||
|
||||
# runtime_config may arrive as a dict (from main.py vars(...)) or as a
|
||||
# RuntimeConfig dataclass. Read `model` defensively from either shape.
|
||||
rc = config.runtime_config
|
||||
if isinstance(rc, dict):
|
||||
model = rc.get("model") or "sonnet"
|
||||
else:
|
||||
model = getattr(rc, "model", None) or "sonnet"
|
||||
|
||||
return ClaudeSDKExecutor(
|
||||
system_prompt=system_prompt,
|
||||
config_path=config.config_path,
|
||||
heartbeat=config.heartbeat,
|
||||
model=model,
|
||||
)
|
||||
|
||||
async def transcript_lines(self, since: int = 0, limit: int = 100) -> dict:
|
||||
"""Read the live Claude Code session transcript.
|
||||
|
||||
Claude Code writes every session to
|
||||
``$HOME/.claude/projects/<cwd-as-dirname>/<session-uuid>.jsonl`` —
|
||||
every line is a JSON event (user/assistant/tool_use/attachment/etc).
|
||||
We pick the most-recently-modified .jsonl in the projects dir for
|
||||
the agent's working directory, then return ``[since:since+limit]``.
|
||||
|
||||
Returns ``supported: True`` even if no .jsonl exists yet (empty
|
||||
``lines`` + ``cursor=0``) so the canvas can show "agent hasn't
|
||||
produced output yet" instead of "feature unavailable".
|
||||
"""
|
||||
limit = max(1, min(limit, _TRANSCRIPT_MAX_LIMIT))
|
||||
since = max(0, since)
|
||||
|
||||
# Resolve the projects-dir name. Claude Code maps cwd → dirname by
|
||||
# replacing "/" with "-" (so "/configs" → "-configs"). The exact
|
||||
# rule lives inside the CLI binary, but the leading-dash + path-
|
||||
# without-trailing-slash pattern is stable across versions.
|
||||
#
|
||||
# Match ClaudeSDKExecutor._resolve_cwd: prefer /workspace if populated,
|
||||
# else /configs. Override via CLAUDE_PROJECT_CWD for tests.
|
||||
WORKSPACE_MOUNT = "/workspace"
|
||||
CONFIG_MOUNT = "/configs"
|
||||
cwd_override = os.environ.get("CLAUDE_PROJECT_CWD")
|
||||
if cwd_override:
|
||||
cwd = cwd_override
|
||||
elif os.path.isdir(WORKSPACE_MOUNT) and os.listdir(WORKSPACE_MOUNT):
|
||||
cwd = WORKSPACE_MOUNT
|
||||
else:
|
||||
cwd = CONFIG_MOUNT
|
||||
|
||||
# Normalize: strip trailing slash, replace path separators with "-"
|
||||
cwd_norm = cwd.rstrip("/") or "/"
|
||||
projdir_name = cwd_norm.replace("/", "-") # "/configs" → "-configs"
|
||||
|
||||
home = Path(os.environ.get("HOME", "/home/agent"))
|
||||
projdir = home / ".claude" / "projects" / projdir_name
|
||||
result_base = {
|
||||
"runtime": self.name(),
|
||||
"supported": True,
|
||||
"lines": [],
|
||||
"cursor": since,
|
||||
"more": False,
|
||||
"source": str(projdir),
|
||||
}
|
||||
|
||||
if not projdir.is_dir():
|
||||
return result_base
|
||||
|
||||
# Pick most-recently-modified .jsonl
|
||||
candidates = sorted(projdir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not candidates:
|
||||
return result_base
|
||||
target = candidates[0]
|
||||
result_base["source"] = str(target)
|
||||
|
||||
lines = []
|
||||
more = False
|
||||
try:
|
||||
with target.open("r") as f:
|
||||
for i, raw in enumerate(f):
|
||||
if i < since:
|
||||
continue
|
||||
if len(lines) >= limit:
|
||||
more = True
|
||||
break
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
lines.append(json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
# Skip malformed lines but keep cursor advancing
|
||||
lines.append({"_parse_error": True, "_raw": raw[:200]})
|
||||
except OSError as exc:
|
||||
logger.warning("transcript_lines: read failed for %s: %s", target, exc)
|
||||
return result_base
|
||||
|
||||
result_base["lines"] = lines
|
||||
result_base["cursor"] = since + len(lines)
|
||||
result_base["more"] = more
|
||||
return result_base
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
# Molecule AI workspace runtime — shared infrastructure
|
||||
molecule-ai-workspace-runtime>=0.1.0
|
||||
|
||||
# Claude Code adapter specific deps
|
||||
# Claude Agent SDK — programmatic API to Claude Code engine.
|
||||
# Replaces CLI subprocess approach (no more --print, --resume, json parsing).
|
||||
# The Claude Code CLI is still pre-installed via npm because the SDK uses it under the hood.
|
||||
claude-agent-sdk>=0.1.58
|
||||
Loading…
Reference in New Issue
Block a user