Skip to content

Commit a520d74

Browse files
committed
Merge test-verify-agent-clean into cli-user-skill and fix CLI tests
2 parents dc2dc7d + 7a996ac commit a520d74

File tree

13 files changed

+514
-1139
lines changed

13 files changed

+514
-1139
lines changed

.github/workflows/cli-build-binary-and-optionally-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
push:
88
branches: [main]
99
tags:
10-
- '*-cli'
10+
- '*'
1111
pull_request:
1212
branches: ['**']
1313

.github/workflows/pypi-release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ jobs:
2222
runs-on: blacksmith-2vcpu-ubuntu-2404
2323
permissions:
2424
id-token: write
25-
# Run when manually dispatched for "cli" OR for tag pushes that contain '-cli'
25+
# Run when manually dispatched for "cli" OR for any tag pushes
2626
if: |
2727
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'cli')
28-
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli'))
28+
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
2929
steps:
3030
- name: Checkout repository
3131
uses: actions/checkout@v4

openhands_cli/acp_impl/event.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,23 @@
5252
TaskTrackerObservation,
5353
TaskTrackerStatusType,
5454
)
55-
from openhands.tools.terminal.definition import TerminalAction
55+
56+
57+
try:
58+
from openhands.tools.terminal.definition import TerminalAction
59+
except ModuleNotFoundError: # e.g. missing fcntl on Windows
60+
61+
class _TerminalActionStub:
62+
"""Fallback TerminalAction stub for platforms without terminal backend.
63+
64+
This allows ACP event processing code and tests to import on Windows
65+
where the underlying openhands terminal implementation depends on
66+
fcntl, which is unavailable.
67+
"""
68+
69+
command: str | None = None
70+
71+
TerminalAction = _TerminalActionStub # type: ignore[assignment]
5672

5773

5874
logger = get_logger(__name__)

openhands_cli/agent_chat.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ def run_cli_entry(
6666
) -> None:
6767
"""Run the agent chat session using the agent SDK.
6868
69-
7069
Raises:
7170
AgentSetupError: If agent setup fails
7271
KeyboardInterrupt: If user interrupts the session

openhands_cli/setup.py

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
from typing import Any
22
from uuid import UUID
3-
import uuid
43

5-
from openhands.sdk.conversation import visualizer
6-
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
74
from prompt_toolkit import HTML, print_formatted_text
85

96
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
107
from openhands.sdk.context import AgentContext, Skill
11-
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
12-
from openhands_cli.tui.settings.store import AgentStore
13-
from openhands.sdk.security.confirmation_policy import (
14-
AlwaysConfirm,
8+
from openhands.sdk.conversation import (
9+
visualizer, # noqa: F401 (ensures tools registered)
1510
)
11+
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
12+
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
13+
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
1614
from openhands_cli.tui.settings.settings_screen import SettingsScreen
15+
from openhands_cli.tui.settings.store import AgentStore
1716
from openhands_cli.tui.visualizer import CLIVisualizer
1817

18+
1919
# register tools
20-
from openhands.tools.terminal import TerminalTool
21-
from openhands.tools.file_editor import FileEditorTool
22-
from openhands.tools.task_tracker import TaskTrackerTool
20+
try:
21+
from openhands.tools.file_editor import (
22+
FileEditorTool, # type: ignore[attr-defined] # noqa: F401
23+
)
24+
from openhands.tools.task_tracker import (
25+
TaskTrackerTool, # type: ignore[attr-defined] # noqa: F401
26+
)
27+
from openhands.tools.terminal import (
28+
TerminalTool, # type: ignore[attr-defined] # noqa: F401
29+
)
30+
except ModuleNotFoundError:
31+
# Some platforms (e.g., Windows) may not have all low-level deps like fcntl.
32+
# The core CLI and tests don't require these tools at import time.
33+
TerminalTool = None # type: ignore[assignment]
34+
FileEditorTool = None # type: ignore[assignment]
35+
TaskTrackerTool = None # type: ignore[assignment]
2336

2437

2538
class MissingAgentSpec(Exception):
@@ -28,83 +41,95 @@ class MissingAgentSpec(Exception):
2841
pass
2942

3043

31-
3244
def load_agent_specs(
3345
conversation_id: str | None = None,
34-
*,
35-
load_user_skills: bool = True,
36-
load_project_skills: bool = True,
46+
mcp_servers: dict[str, dict[str, Any]] | None = None,
47+
skills: list[Skill] | None = None,
3748
) -> Agent:
49+
"""Load agent specifications.
50+
51+
Args:
52+
conversation_id: Optional conversation ID for session tracking
53+
mcp_servers: Optional dict of MCP servers to augment agent configuration
54+
skills: Optional list of skills to include in the agent configuration
55+
56+
Returns:
57+
Configured Agent instance
58+
59+
Raises:
60+
MissingAgentSpec: If agent specification is not found or invalid
61+
"""
3862
agent_store = AgentStore()
39-
agent = agent_store.load(
40-
session_id=conversation_id,
41-
load_user_skills=load_user_skills,
42-
load_project_skills=load_project_skills,
43-
)
63+
agent = agent_store.load(session_id=conversation_id)
4464
if not agent:
4565
raise MissingAgentSpec(
46-
'Agent specification not found. Please configure your agent settings.'
66+
"Agent specification not found. Please configure your agent settings."
4767
)
68+
69+
# If MCP servers are provided, augment the agent's MCP configuration
70+
if mcp_servers:
71+
# Merge with existing MCP configuration (provided servers take precedence)
72+
mcp_config: dict[str, Any] = agent.mcp_config or {}
73+
existing_servers: dict[str, dict[str, Any]] = mcp_config.get("mcpServers", {})
74+
existing_servers.update(mcp_servers)
75+
agent = agent.model_copy(
76+
update={"mcp_config": {"mcpServers": existing_servers}}
77+
)
78+
79+
if skills:
80+
if agent.agent_context is not None:
81+
existing_skills = agent.agent_context.skills
82+
existing_skills.extend(skills)
83+
agent = agent.model_copy(
84+
update={
85+
"agent_context": agent.agent_context.model_copy(
86+
update={"skills": existing_skills}
87+
)
88+
}
89+
)
90+
else:
91+
agent = agent.model_copy(
92+
update={"agent_context": AgentContext(skills=skills)}
93+
)
94+
4895
return agent
4996

5097

51-
def verify_agent_exists_or_setup_agent(
52-
*,
53-
load_user_skills: bool = True,
54-
load_project_skills: bool = True,
55-
) -> Agent:
98+
def verify_agent_exists_or_setup_agent() -> Agent:
5699
"""Verify agent specs exists by attempting to load it.
57100
101+
If missing, run the settings flow and try once more.
58102
"""
59103
settings_screen = SettingsScreen()
60104
try:
61-
agent = load_agent_specs(
62-
load_user_skills=load_user_skills,
63-
load_project_skills=load_project_skills,
64-
)
105+
agent = load_agent_specs()
65106
return agent
66107
except MissingAgentSpec:
67-
# For first-time users, show the full settings flow with choice between basic/advanced
108+
# For first-time users, show the full settings flow with
109+
# choice between basic/advanced
68110
settings_screen.configure_settings(first_time=True)
69111

70-
71112
# Try once again after settings setup attempt
72-
return load_agent_specs(
73-
load_user_skills=load_user_skills,
74-
load_project_skills=load_project_skills,
75-
)
113+
return load_agent_specs()
76114

77115

78116
def setup_conversation(
79117
conversation_id: UUID,
80118
include_security_analyzer: bool = True,
81-
*,
82-
load_user_skills: bool = True,
83-
load_project_skills: bool = True,
84-
mcp_servers: dict[str, dict[str, Any]] | None = None,
85-
skills: list[Skill] | None = None,
86119
) -> BaseConversation:
87-
"""
88-
Setup the conversation with agent.
120+
"""Setup the conversation with agent.
89121
90122
Args:
91-
conversation_id: conversation ID to use. If not provided, a random UUID will be generated.
123+
conversation_id: conversation ID to use.
124+
If not provided, a random UUID will be generated.
92125
93126
Raises:
94127
MissingAgentSpec: If agent specification is not found or invalid.
95128
"""
96129

97-
print_formatted_text(
98-
HTML(f'<white>Initializing agent...</white>')
99-
)
100-
101-
agent = load_agent_specs(
102-
str(conversation_id),
103-
load_user_skills=load_user_skills,
104-
load_project_skills=load_project_skills,
105-
)
106-
130+
print_formatted_text(HTML("<white>Initializing agent...</white>"))
107131

132+
agent = load_agent_specs(str(conversation_id))
108133

109134
# Create conversation - agent context is now set in AgentStore.load()
110135
conversation: BaseConversation = Conversation(
@@ -113,7 +138,7 @@ def setup_conversation(
113138
# Conversation will add /<conversation_id> to this path
114139
persistence_dir=CONVERSATIONS_DIR,
115140
conversation_id=conversation_id,
116-
visualizer=CLIVisualizer
141+
visualizer=CLIVisualizer,
117142
)
118143

119144
# Security analyzer is set though conversation API now
@@ -124,6 +149,6 @@ def setup_conversation(
124149
conversation.set_confirmation_policy(AlwaysConfirm())
125150

126151
print_formatted_text(
127-
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
152+
HTML(f"<green>✓ Agent initialized with model: {agent.llm.model}</green>")
128153
)
129-
return conversation
154+
return conversation

openhands_cli/tui/tui.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from prompt_toolkit.shortcuts import clear
99

1010
from openhands_cli.pt_style import get_cli_style
11+
from openhands_cli.version_check import check_for_updates
1112

1213

1314
DEFAULT_STYLE = get_cli_style()
@@ -93,6 +94,23 @@ def display_welcome(conversation_id: UUID, resume: bool = False) -> None:
9394
"""Display welcome message."""
9495
clear()
9596
display_banner(str(conversation_id), resume)
97+
98+
# Check for updates and display version info
99+
version_info = check_for_updates()
100+
print_formatted_text(HTML(f"<grey>Version: {version_info.current_version}</grey>"))
101+
102+
if version_info.needs_update and version_info.latest_version:
103+
print_formatted_text(
104+
HTML(f"<yellow>⚠ Update available: {version_info.latest_version}</yellow>")
105+
)
106+
print_formatted_text(
107+
HTML(
108+
"<grey>Run</grey> <gold>uv tool upgrade openhands</gold> "
109+
"<grey>to update</grey>"
110+
)
111+
)
112+
113+
print_formatted_text("")
96114
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
97115
print_formatted_text(
98116
HTML(

openhands_cli/version_check.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Version checking utilities for OpenHands CLI."""
2+
3+
import json
4+
import urllib.request
5+
from typing import NamedTuple
6+
7+
from openhands_cli import __version__
8+
9+
10+
class VersionInfo(NamedTuple):
11+
"""Version information for display."""
12+
13+
current_version: str
14+
latest_version: str | None
15+
needs_update: bool
16+
error: str | None
17+
18+
19+
def parse_version(version_str: str) -> tuple[int, ...]:
20+
"""Parse a version string into a tuple of integers for comparison.
21+
22+
Args:
23+
version_str: Version string like "1.2.1"
24+
25+
Returns:
26+
Tuple of integers like (1, 2, 1)
27+
"""
28+
return tuple(int(x) for x in version_str.split("."))
29+
30+
31+
def check_for_updates(timeout: float = 2.0) -> VersionInfo:
32+
"""Check if a newer version is available on PyPI.
33+
34+
Args:
35+
timeout: Timeout for PyPI request in seconds
36+
37+
Returns:
38+
VersionInfo with update information
39+
"""
40+
current = __version__
41+
42+
# Handle dev versions or special cases
43+
if current == "0.0.0" or "dev" in current:
44+
return VersionInfo(
45+
current_version=current,
46+
latest_version=None,
47+
needs_update=False,
48+
error=None,
49+
)
50+
51+
try:
52+
# Fetch latest version from PyPI
53+
url = "https://pypi.org/pypi/openhands/json"
54+
req = urllib.request.Request(url)
55+
req.add_header("User-Agent", f"openhands-cli/{current}")
56+
57+
with urllib.request.urlopen(req, timeout=timeout) as response:
58+
data = json.loads(response.read().decode("utf-8"))
59+
latest = data["info"]["version"]
60+
61+
# Compare versions
62+
try:
63+
current_tuple = parse_version(current)
64+
latest_tuple = parse_version(latest)
65+
needs_update = latest_tuple > current_tuple
66+
except (ValueError, AttributeError):
67+
# If we can't parse versions, assume no update needed
68+
needs_update = False
69+
70+
return VersionInfo(
71+
current_version=current,
72+
latest_version=latest,
73+
needs_update=needs_update,
74+
error=None,
75+
)
76+
except Exception as e:
77+
# Don't block on network errors - just return current version
78+
return VersionInfo(
79+
current_version=current,
80+
latest_version=None,
81+
needs_update=False,
82+
error=str(e),
83+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
44

55
[project]
66
name = "openhands"
7-
version = "1.2.1"
7+
version = "1.3.0"
88
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
99
readme = "README.md"
1010
license = { text = "MIT" }

0 commit comments

Comments
 (0)