Skip to content
Open
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
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Development commands

### Python / CLI
- Install dev dependencies: `uv sync --extra dev`
- Run the CLI: `uv run oh`
- Run the CLI with an active environment: `oh`
- Lint: `uv run ruff check src tests scripts`
- Run tests: `uv run pytest -q`
- Run a single test: `uv run pytest tests/path/to/test_file.py::test_name -q`
- Optional type check: `uv run mypy src/openharness`

### Frontend terminal UI
Run these from `frontend/terminal/`:
- Install dependencies: `npm ci`
- Start the Ink terminal UI: `npm start`
- Typecheck: `npx tsc --noEmit`

## Repository architecture
- `src/openharness/cli.py` is the Typer entrypoint and command surface for the Python CLI, including session flags and subcommands such as MCP, plugin, and auth.
- `src/openharness/engine/query.py` and `src/openharness/engine/query_engine.py` are the core agent loop. They stream model output, execute tool calls, apply permission checks, run hooks, and append tool results back into the conversation.
- `src/openharness/tools/` is the action surface exposed to the model.
- `src/openharness/permissions/` and `src/openharness/hooks/` are the governance rails around tool execution.
- `src/openharness/plugins/`, `src/openharness/skills/`, `src/openharness/mcp/`, `src/openharness/memory/`, and `src/openharness/services/` provide extensibility and runtime support.
- `frontend/terminal/` is a separate React + Ink terminal client; treat it as a separate app from the Python runtime.

## Where to look before changing behavior
- CLI and command wiring: `src/openharness/cli.py`
- Core runtime flow: `src/openharness/engine/query.py`, `src/openharness/engine/query_engine.py`
- Tool registration and execution: `src/openharness/tools/`
- Permission and approval behavior: `src/openharness/permissions/`
- Hook lifecycle: `src/openharness/hooks/`
- Plugin / skill / MCP integration: `src/openharness/plugins/`, `src/openharness/skills/`, `src/openharness/mcp/`
- Frontend terminal behavior: `frontend/terminal/`

## Repo-specific guidance
- Use `uv` for Python environment and dependency management. The repo requires Python 3.10+.
- Node.js 18+ is only needed when working on the frontend terminal UI.
- Before opening a PR, run the same core checks as CI: `uv run ruff check src tests scripts`, `uv run pytest -q`, and `cd frontend/terminal && npx tsc --noEmit` if frontend code changed.
- Keep PRs scoped and reviewable. When behavior changes, add or update tests.
- Update docs when CLI flags, workflows, or compatibility claims change.
- Add a short entry under `Unreleased` in `CHANGELOG.md` for user-visible changes.
- The PR template expects a concise summary of the problem and change, plus a validation section with the commands you ran.
23 changes: 18 additions & 5 deletions src/openharness/engine/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)
from openharness.hooks import HookEvent, HookExecutor
from openharness.permissions.checker import PermissionChecker
from openharness.skills.runtime import ActiveSkillContext, build_effective_system_prompt, filter_tool_registry
from openharness.tools.base import ToolExecutionContext
from openharness.tools.base import ToolRegistry

Expand Down Expand Up @@ -95,6 +96,7 @@ class QueryContext:
max_turns: int | None = 200
hook_executor: HookExecutor | None = None
tool_metadata: dict[str, object] | None = None
active_skill: ActiveSkillContext | None = None


def _append_capped_unique(bucket: list[Any], value: Any, *, limit: int) -> None:
Expand Down Expand Up @@ -525,14 +527,23 @@ async def _progress(event: CompactProgressEvent) -> None:
final_message: ConversationMessage | None = None
usage = UsageSnapshot()

# Skill overrides for this turn
active_skill = context.active_skill
model = active_skill.model_override if active_skill is not None and active_skill.model_override else context.model
system_prompt = build_effective_system_prompt(context.system_prompt, active_skill)
tool_registry = filter_tool_registry(
context.tool_registry,
active_skill.allowed_tools if active_skill is not None else None,
)

try:
async for event in context.api_client.stream_message(
ApiMessageRequest(
model=context.model,
model=model,
messages=messages,
system_prompt=context.system_prompt,
system_prompt=system_prompt,
max_tokens=context.max_tokens,
tools=context.tool_registry.to_api_schema(),
tools=tool_registry.to_api_schema(),
)
):
if isinstance(event, ApiTextDeltaEvent):
Expand Down Expand Up @@ -606,18 +617,19 @@ async def _progress(event: CompactProgressEvent) -> None:
if len(tool_calls) == 1:
# Single tool: sequential (stream events immediately)
tc = tool_calls[0]
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_call_id=tc.id), None
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
tool_call_id=tc.id,
is_error=result.is_error,
), None
tool_results = [result]
else:
# Multiple tools: execute concurrently, emit events after
for tc in tool_calls:
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input, tool_call_id=tc.id), None

async def _run(tc):
return await _execute_tool_call(context, tc.name, tc.id, tc.input)
Expand Down Expand Up @@ -649,6 +661,7 @@ async def _run(tc):
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
tool_call_id=tc.id,
is_error=result.is_error,
), None

Expand Down
13 changes: 13 additions & 0 deletions src/openharness/engine/query_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from openharness.engine.stream_events import AssistantTurnComplete, StreamEvent
from openharness.hooks import HookEvent, HookExecutor
from openharness.permissions.checker import PermissionChecker
from openharness.skills.runtime import ActiveSkillContext
from openharness.tools.base import ToolRegistry


Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(
self._tool_metadata = tool_metadata or {}
self._messages: list[ConversationMessage] = []
self._cost_tracker = CostTracker()
self._active_skill: ActiveSkillContext | None = None

@property
def messages(self) -> list[ConversationMessage]:
Expand Down Expand Up @@ -114,6 +116,15 @@ def set_permission_checker(self, checker: PermissionChecker) -> None:
"""Update the active permission checker for future turns."""
self._permission_checker = checker

def set_active_skill(self, active_skill: ActiveSkillContext | None) -> None:
"""Update the active skill scope for future turns."""
self._active_skill = active_skill

@property
def active_skill(self) -> ActiveSkillContext | None:
"""Return the currently active skill context."""
return self._active_skill

def _build_coordinator_context_message(self) -> ConversationMessage | None:
"""Build a synthetic user message carrying coordinator runtime context."""
context = get_coordinator_user_context()
Expand Down Expand Up @@ -177,6 +188,7 @@ async def submit_message(self, prompt: str | ConversationMessage) -> AsyncIterat
ask_user_prompt=self._ask_user_prompt,
hook_executor=self._hook_executor,
tool_metadata=self._tool_metadata,
active_skill=self._active_skill,
)
query_messages = list(self._messages)
coordinator_context = self._build_coordinator_context_message()
Expand Down Expand Up @@ -206,6 +218,7 @@ async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterat
ask_user_prompt=self._ask_user_prompt,
hook_executor=self._hook_executor,
tool_metadata=self._tool_metadata,
active_skill=self._active_skill,
)
async for event, usage in run_query(context, self._messages):
if usage is not None:
Expand Down
2 changes: 2 additions & 0 deletions src/openharness/engine/stream_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class ToolExecutionStarted:

tool_name: str
tool_input: dict[str, Any]
tool_call_id: str


@dataclass(frozen=True)
Expand All @@ -38,6 +39,7 @@ class ToolExecutionCompleted:

tool_name: str
output: str
tool_call_id: str
is_error: bool = False


Expand Down
123 changes: 123 additions & 0 deletions src/openharness/skills/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Shared markdown skill parsing helpers."""

from __future__ import annotations

from dataclasses import asdict
from pathlib import Path
from typing import Any

import yaml

from openharness.skills.types import SkillDefinition


_SKILL_FRONTMATTER_FIELDS = {
"name",
"description",
"user_invocable",
"model_invocable",
"aliases",
"allowed_tools",
"model_override",
"effort_override",
"execution_mode",
}


def _split_frontmatter(content: str) -> tuple[dict[str, Any], str]:
lines = content.splitlines()
if not lines or lines[0].strip() != "---":
return {}, content
for i, line in enumerate(lines[1:], 1):
if line.strip() != "---":
continue
raw = "\n".join(lines[1:i])
body = "\n".join(lines[i + 1 :])
parsed = yaml.safe_load(raw) or {}
return parsed if isinstance(parsed, dict) else {}, body
return {}, content


def _coerce_aliases(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
cleaned = value.strip()
return (cleaned,) if cleaned else ()
if isinstance(value, list):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()


def _coerce_allowed_tools(value: Any) -> tuple[str, ...] | None:
if value is None:
return None
if isinstance(value, str):
cleaned = value.strip()
return (cleaned,) if cleaned else None
if isinstance(value, list):
tools = tuple(str(item).strip() for item in value if str(item).strip())
return tools or None
return None


def _fallback_name_and_description(default_name: str, body: str) -> tuple[str, str]:
name = default_name
description = ""
for line in body.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
name = stripped[2:].strip() or default_name
continue
if stripped and not stripped.startswith("#"):
description = stripped[:200]
break
return name, description or f"Skill: {name}"


def parse_skill_markdown(
default_name: str,
content: str,
*,
source: str,
path: str | None = None,
) -> SkillDefinition:
"""Parse one markdown skill file into a normalized definition."""
frontmatter, body = _split_frontmatter(content)
fallback_name, fallback_description = _fallback_name_and_description(default_name, body)
metadata = {k: frontmatter[k] for k in _SKILL_FRONTMATTER_FIELDS if k in frontmatter}
skill = SkillDefinition(
name=str(frontmatter.get("name") or fallback_name),
description=str(frontmatter.get("description") or fallback_description),
content=content,
instructions=body.strip() or content.strip(),
source=source,
path=path,
user_invocable=bool(frontmatter.get("user_invocable", True)),
model_invocable=bool(frontmatter.get("model_invocable", True)),
aliases=_coerce_aliases(frontmatter.get("aliases")),
allowed_tools=_coerce_allowed_tools(frontmatter.get("allowed_tools")),
model_override=(str(frontmatter["model_override"]).strip() if frontmatter.get("model_override") else None),
effort_override=(str(frontmatter["effort_override"]).strip() if frontmatter.get("effort_override") else None),
execution_mode=str(frontmatter.get("execution_mode") or "inline"),
metadata=metadata,
)
return SkillDefinition(**asdict(skill))


def load_skills_from_directory(path: Path, *, source: str) -> list[SkillDefinition]:
"""Load all markdown skills from a directory."""
if not path.exists():
return []
skills: list[SkillDefinition] = []
for skill_path in sorted(path.glob("*.md")):
content = skill_path.read_text(encoding="utf-8")
skills.append(
parse_skill_markdown(
skill_path.stem,
content,
source=source,
path=str(skill_path),
)
)
return skills
29 changes: 25 additions & 4 deletions src/openharness/skills/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,40 @@


class SkillRegistry:
"""Store loaded skills by name."""
"""Store loaded skills by name and alias."""

def __init__(self) -> None:
self._skills: dict[str, SkillDefinition] = {}
self._aliases: dict[str, str] = {}

@staticmethod
def _normalize(name: str) -> str:
return name.strip().lower()

def register(self, skill: SkillDefinition) -> None:
"""Register one skill."""
self._skills[skill.name] = skill
canonical = self._normalize(skill.name)
self._skills[canonical] = skill
self._aliases[canonical] = canonical
for alias in skill.aliases:
normalized = self._normalize(alias)
if normalized:
self._aliases[normalized] = canonical

def get(self, name: str) -> SkillDefinition | None:
"""Return a skill by name."""
return self._skills.get(name)
"""Return a skill by name or alias."""
normalized = self._normalize(name)
canonical = self._aliases.get(normalized, normalized)
return self._skills.get(canonical)

def list_skills(self) -> list[SkillDefinition]:
"""Return all skills sorted by name."""
return sorted(self._skills.values(), key=lambda skill: skill.name)

def list_user_invocable(self) -> list[SkillDefinition]:
"""Return skills exposed as slash commands."""
return [skill for skill in self.list_skills() if skill.user_invocable]

def list_model_invocable(self) -> list[SkillDefinition]:
"""Return skills exposed to the model."""
return [skill for skill in self.list_skills() if skill.model_invocable]
Loading