Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ specs/
# Local development directories
plugindocs/
safetypluginclone/.claude/.state/

# Local refactor checklist
refactor-plan.md
1 change: 1 addition & 0 deletions src/scc_cli/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Concrete adapters for SCC ports."""
26 changes: 26 additions & 0 deletions src/scc_cli/adapters/claude_agent_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Claude Code adapter for AgentRunner port."""

from __future__ import annotations

from pathlib import Path
from typing import Any

from scc_cli.ports.agent_runner import AgentRunner
from scc_cli.ports.models import AgentCommand, AgentSettings

DEFAULT_SETTINGS_PATH = Path("/home/agent/.claude/settings.json")


class ClaudeAgentRunner(AgentRunner):
"""AgentRunner implementation for Claude Code."""

def build_settings(
self, config: dict[str, Any], *, path: Path = DEFAULT_SETTINGS_PATH
) -> AgentSettings:
return AgentSettings(content=config, path=path)

def build_command(self, settings: AgentSettings) -> AgentCommand:
return AgentCommand(argv=["claude"], env={}, workdir=settings.path.parent)

def describe(self) -> str:
return "Claude Code"
76 changes: 76 additions & 0 deletions src/scc_cli/adapters/docker_sandbox_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Docker sandbox runtime adapter for SandboxRuntime port."""

from __future__ import annotations

from scc_cli import docker
from scc_cli.ports.models import SandboxHandle, SandboxSpec, SandboxState, SandboxStatus
from scc_cli.ports.sandbox_runtime import SandboxRuntime


def _extract_container_name(cmd: list[str]) -> str | None:
for idx, arg in enumerate(cmd):
if arg == "--name" and idx + 1 < len(cmd):
return cmd[idx + 1]
if arg.startswith("--name="):
return arg.split("=", 1)[1]
if cmd and cmd[-1].startswith("scc-"):
return cmd[-1]
return None


class DockerSandboxRuntime(SandboxRuntime):
"""SandboxRuntime backed by Docker sandbox CLI."""

def ensure_available(self) -> None:
docker.check_docker_available()

def run(self, spec: SandboxSpec) -> SandboxHandle:
docker.prepare_sandbox_volume_for_credentials()
docker_cmd, _is_resume = docker.get_or_create_container(
workspace=spec.workspace_mount.source,
branch=None,
profile=None,
force_new=spec.force_new,
continue_session=spec.continue_session,
env_vars=spec.env or None,
)
container_name = _extract_container_name(docker_cmd)
plugin_settings = spec.agent_settings.content if spec.agent_settings else None
docker.run(
docker_cmd,
org_config=spec.org_config,
container_workdir=spec.workdir,
plugin_settings=plugin_settings,
)
return SandboxHandle(
sandbox_id=container_name or "sandbox",
name=container_name,
)

def resume(self, handle: SandboxHandle) -> None:
docker.resume_container(handle.sandbox_id)

def stop(self, handle: SandboxHandle) -> None:
docker.stop_container(handle.sandbox_id)

def remove(self, handle: SandboxHandle) -> None:
docker.remove_container(handle.sandbox_id, force=True)

def list_running(self) -> list[SandboxHandle]:
return [
SandboxHandle(sandbox_id=container.id, name=container.name)
for container in docker.list_running_sandboxes()
]

def status(self, handle: SandboxHandle) -> SandboxStatus:
status = docker.get_container_status(handle.sandbox_id)
if not status:
return SandboxStatus(state=SandboxState.UNKNOWN)
normalized = status.lower()
if "up" in normalized or "running" in normalized:
state = SandboxState.RUNNING
elif "exited" in normalized or "stopped" in normalized:
state = SandboxState.STOPPED
else:
state = SandboxState.UNKNOWN
return SandboxStatus(state=state)
55 changes: 55 additions & 0 deletions src/scc_cli/adapters/local_filesystem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Local filesystem adapter for Filesystem port."""

from __future__ import annotations

import os
import tempfile
from pathlib import Path

from scc_cli.ports.filesystem import Filesystem


class LocalFilesystem(Filesystem):
"""Filesystem adapter using the host OS."""

def read_text(self, path: Path, *, encoding: str = "utf-8") -> str:
return path.read_text(encoding=encoding)

def write_text(self, path: Path, content: str, *, encoding: str = "utf-8") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding=encoding)

def write_text_atomic(self, path: Path, content: str, *, encoding: str = "utf-8") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = self._write_temp_file(path, content, encoding=encoding)
try:
temp_path.replace(path)
except Exception:
temp_path.unlink(missing_ok=True)
raise

def exists(self, path: Path) -> bool:
return path.exists()

def mkdir(self, path: Path, *, parents: bool = False, exist_ok: bool = False) -> None:
path.mkdir(parents=parents, exist_ok=exist_ok)

def unlink(self, path: Path, *, missing_ok: bool = False) -> None:
path.unlink(missing_ok=missing_ok)

def iterdir(self, path: Path) -> list[Path]:
return list(path.iterdir())

def _write_temp_file(self, path: Path, content: str, *, encoding: str) -> Path:
with tempfile.NamedTemporaryFile(
mode="w",
encoding=encoding,
delete=False,
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
) as temp_file:
temp_file.write(content)
temp_file.flush()
os.fsync(temp_file.fileno())
return Path(temp_file.name)
37 changes: 37 additions & 0 deletions src/scc_cli/adapters/local_git_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Local git adapter for GitClient port."""

from __future__ import annotations

from pathlib import Path

from scc_cli.ports.git_client import GitClient
from scc_cli.services.git import branch as git_branch
from scc_cli.services.git import core as git_core


class LocalGitClient(GitClient):
"""Git client adapter backed by local git CLI."""

def check_available(self) -> None:
git_core.check_git_available()

def check_installed(self) -> bool:
return git_core.check_git_installed()

def get_version(self) -> str | None:
return git_core.get_git_version()

def is_git_repo(self, path: Path) -> bool:
return git_core.is_git_repo(path)

def init_repo(self, path: Path) -> bool:
return git_core.init_repo(path)

def create_empty_initial_commit(self, path: Path) -> tuple[bool, str | None]:
return git_core.create_empty_initial_commit(path)

def detect_workspace_root(self, start_dir: Path) -> tuple[Path | None, Path]:
return git_core.detect_workspace_root(start_dir)

def get_current_branch(self, path: Path) -> str | None:
return git_branch.get_current_branch(path)
27 changes: 27 additions & 0 deletions src/scc_cli/adapters/requests_fetcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Requests adapter for RemoteFetcher port."""

from __future__ import annotations

import requests

from scc_cli.ports.remote_fetcher import RemoteFetcher, RemoteResponse


class RequestsFetcher(RemoteFetcher):
"""RemoteFetcher implementation using requests."""

def get(
self,
url: str,
*,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> RemoteResponse:
response = requests.get(url, headers=headers, timeout=timeout)
normalized_headers = {key: str(value) for key, value in response.headers.items()}
return RemoteResponse(
status_code=response.status_code,
text=response.text,
content=response.content,
headers=normalized_headers,
)
14 changes: 14 additions & 0 deletions src/scc_cli/adapters/system_clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""System clock adapter for Clock port."""

from __future__ import annotations

from datetime import datetime, timezone

from scc_cli.ports.clock import Clock


class SystemClock(Clock):
"""Clock implementation using system time."""

def now(self) -> datetime:
return datetime.now(timezone.utc)
1 change: 1 addition & 0 deletions src/scc_cli/application/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Application use cases and orchestration."""
Loading
Loading