import os
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
BROWSER_USE_REPO_SKILL_URL = 'https://raw.githubusercontent.com/browser-use/browser-use/main/skills/browser-use/SKILL.md'
EXPECTED_SKILL_INSTALL_PATHS = (
	Path('.agents') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.claude') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.codex') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.copilot') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.cursor') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.gemini') / 'skills' / 'browser-use' / 'SKILL.md',
	Path('.config') / 'opencode' / 'skills' / 'browser-use' / 'SKILL.md',
)

def _fake_browser_harness_tools(tmp_path: Path, skill_text: str) -> Path:
	bin_dir = tmp_path / 'bin'
	bin_dir.mkdir()

	uv = bin_dir / 'uv'
	uv.write_text(
		'#!/usr/bin/env python3\n'
		'import os, pathlib, sys\n'
		'pathlib.Path(os.environ["UV_TOOL_INSTALL_ARGS_FILE"]).write_text(" ".join(sys.argv[1:]), encoding="utf-8")\n',
		encoding='utf-8',
	)
	uv.chmod(0o755)

	browser_harness = bin_dir / 'browser-harness'
	browser_harness.write_text(
		'#!/usr/bin/env python3\n'
		'import sys\n'
		f'text = {skill_text!r}\n'
		'if sys.argv[1:] == ["skill"]:\n'
		'    print(text, end="")\n'
		'else:\n'
		'    print("usage: browser-harness skill", file=sys.stderr)\n'
		'    sys.exit(2)\n',
		encoding='utf-8',
	)
	browser_harness.chmod(0o755)
	return bin_dir

def test_docs_install_browser_use_skill_from_package_alias():
	readme = (ROOT / 'README.md').read_text(encoding='utf-8')

	assert 'run `browser-use skill install` to register the skill' in readme
	assert 'mkdir -p ~/.claude/skills/browser-use' not in readme
	assert 'uv run --with "browser-use[browser-harness]" python -c' not in readme
	assert 'from browser_use.skills import browser_use_skill_text' not in readme
	assert BROWSER_USE_REPO_SKILL_URL not in readme
	assert 'raw.githubusercontent.com/browser-use/browser-harness/main/SKILL.md' not in readme

def test_browser_use_cli_installs_browser_harness_package_skill(tmp_path):
	bin_dir = _fake_browser_harness_tools(tmp_path, '---\nname: browser-harness\n---\n\n# Browser Harness\n')

	home = tmp_path / 'home'
	for stale in (home / path for path in EXPECTED_SKILL_INSTALL_PATHS):
		stale.parent.mkdir(parents=True)
		stale.write_text('stale browser-use skill', encoding='utf-8')

	uv_args = tmp_path / 'uv-args.txt'
	env = os.environ.copy()
	env['HOME'] = str(home)
	env['PATH'] = os.pathsep.join(part for part in (str(bin_dir), env.get('PATH', '')) if part)
	env['PYTHONPATH'] = os.pathsep.join(part for part in (str(ROOT), env.get('PYTHONPATH', '')) if part)
	env['UV_TOOL_INSTALL_ARGS_FILE'] = str(uv_args)

	result = subprocess.run(
		[sys.executable, '-m', 'browser_use.cli', 'skill', 'install'],
		cwd=ROOT,
		env=env,
		capture_output=True,
		text=True,
		timeout=10,
	)

	assert result.returncode == 0, result.stderr
	assert uv_args.read_text(encoding='utf-8') == 'tool install --python 3.12 --upgrade --force browser-use'
	expected = (
		'---\n'
		'name: browser-use\n'
		'description: "Direct browser control via CDP for web interaction: automation, scraping, testing, screenshots, and site/app work."\n'
		'---\n\n'
		'# Browser Use\n'
	)
	for installed in (home / path for path in EXPECTED_SKILL_INSTALL_PATHS):
		assert installed.read_text(encoding='utf-8') == expected

def test_browser_use_cli_validates_destination_before_installing_harness(tmp_path):
	bin_dir = _fake_browser_harness_tools(tmp_path, '---\nname: browser-harness\n---\n\n# Browser Harness\n')
	blocking_file = tmp_path / 'not-a-directory'
	blocking_file.write_text('blocks skill directory creation', encoding='utf-8')

	uv_args = tmp_path / 'uv-args.txt'
	env = os.environ.copy()
	env['HOME'] = str(tmp_path / 'home')
	env['PATH'] = os.pathsep.join(part for part in (str(bin_dir), env.get('PATH', '')) if part)
	env['PYTHONPATH'] = os.pathsep.join(part for part in (str(ROOT), env.get('PYTHONPATH', '')) if part)
	env['UV_TOOL_INSTALL_ARGS_FILE'] = str(uv_args)

	result = subprocess.run(
		[sys.executable, '-m', 'browser_use.cli', 'skill', 'install', '--path', str(blocking_file / 'nested')],
		cwd=ROOT,
		env=env,
		capture_output=True,
		text=True,
		timeout=10,
	)

	assert result.returncode == 1
	assert 'is not a directory' in result.stderr
	assert not uv_args.exists()


import asyncio
import base64
import socketserver

import pytest
from pytest_httpserver import HTTPServer

from browser_use.browser import BrowserProfile, BrowserSession

# Fix for httpserver hanging on shutdown - prevent blocking on socket close
socketserver.ThreadingMixIn.block_on_close = False
socketserver.ThreadingMixIn.daemon_threads = True

class TestBrowserContext:
	"""Tests for browser context functionality using real browser instances."""

	@pytest.fixture(scope='session')
	def http_server(self):
		"""Create and provide a test HTTP server that serves static content."""
		server = HTTPServer()
		server.start()

		# Add routes for test pages
		server.expect_request('/').respond_with_data(
			'<title>Test Home Page</title>
Test Home Page

Welcome to the test site
',
			content_type='text/html',
		)

		server.expect_request('/scroll_test').respond_with_data(
			"""

                <title>Scroll Test</title>

Top of the page

Middle of the page

Bottom of the page

            """,
			content_type='text/html',
		)

		yield server
		server.stop()

	@pytest.fixture(scope='session')
	def base_url(self, http_server):
		"""Return the base URL for the test HTTP server."""
		return f'http://{http_server.host}:{http_server.port}'

	@pytest.fixture(scope='module')
	async def browser_session(self):
		"""Create and provide a BrowserSession instance with security disabled."""
		browser_session = BrowserSession(
			browser_profile=BrowserProfile(
				headless=True,
				user_data_dir=None,
				keep_alive=True,
			)
		)
		await browser_session.start()
		yield browser_session
		await browser_session.kill()
		# Ensure event bus is properly stopped
		await browser_session.event_bus.stop(clear=True, timeout=5)

	@pytest.mark.skip(reason='TODO: fix')
	def test_is_url_allowed(self):
		"""
		Test the _is_url_allowed method to verify that it correctly checks URLs against
		the allowed domains configuration.
		"""
		# Scenario 1: allowed_domains is None, any URL should be allowed.
		from bubus import EventBus

		from browser_use.browser.watchdogs.security_watchdog import SecurityWatchdog

		config1 = BrowserProfile(allowed_domains=None, headless=True, user_data_dir=None)
		context1 = BrowserSession(browser_profile=config1)
		event_bus1 = EventBus()
		watchdog1 = SecurityWatchdog(browser_session=context1, event_bus=event_bus1)
		assert watchdog1._is_url_allowed('http://anydomain.com') is True
		assert watchdog1._is_url_allowed('https://anotherdomain.org/path') is True

		# Scenario 2: allowed_domains is provided.
		# Note: match_url_with_domain_pattern defaults to https:// scheme when none is specified
		allowed = ['https://example.com', 'http://example.com', 'http://*.mysite.org', 'https://*.mysite.org']
		config2 = BrowserProfile(allowed_domains=allowed, headless=True, user_data_dir=None)
		context2 = BrowserSession(browser_profile=config2)
		event_bus2 = EventBus()
		watchdog2 = SecurityWatchdog(browser_session=context2, event_bus=event_bus2)

		# URL exactly matching
		assert watchdog2._is_url_allowed('http://example.com') is True
		# URL with subdomain (should not be allowed)
		assert watchdog2._is_url_allowed('http://sub.example.com/path') is False
		# URL with subdomain for wildcard pattern (should be allowed)
		assert watchdog2._is_url_allowed('http://sub.mysite.org') is True
		# URL that matches second allowed domain
		assert watchdog2._is_url_allowed('https://mysite.org/page') is True
		# URL with port number, still allowed (port is stripped)
		assert watchdog2._is_url_allowed('http://example.com:8080') is True
		assert watchdog2._is_url_allowed('https://example.com:443') is True

		# Scenario 3: Malformed URL or empty domain
		# urlparse will return an empty netloc for some malformed URLs.
		assert watchdog2._is_url_allowed('notaurl') is False

	# Method was removed from BrowserSession

	def test_enhanced_css_selector_for_element(self):
		"""
		Test removed: _enhanced_css_selector_for_element method no longer exists.
		"""
		pass  # Method was removed from BrowserSession

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_navigate_and_get_current_page(self, browser_session, base_url):
		"""Test that navigate method changes the URL and get_current_page returns the proper page."""
		# Navigate to the test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Get the current page
		url = await browser_session.get_current_page_url()

		# Verify the page URL matches what we navigated to
		assert f'{base_url}/' in url

		# Verify the page title
		title = await browser_session.get_current_page_title()
		assert title == 'Test Home Page'

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_refresh_page(self, browser_session, base_url):
		"""Test that refresh_page correctly reloads the current page."""
		# Navigate to the test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Get the current page info before refresh
		url_before = await browser_session.get_current_page_url()
		title_before = await browser_session.get_current_page_title()

		# Refresh the page
		await browser_session.refresh()

		# Get the current page info after refresh
		url_after = await browser_session.get_current_page_url()
		title_after = await browser_session.get_current_page_title()

		# Verify it's still on the same URL
		assert url_after == url_before

		# Verify the page title is still correct
		assert title_after == 'Test Home Page'

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_execute_javascript(self, browser_session, base_url):
		"""Test that execute_javascript correctly executes JavaScript in the current page."""
		# Navigate to a test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Execute a simple JavaScript snippet that returns a value
		result = await browser_session.execute_javascript('document.title')

		# Verify the result
		assert result == 'Test Home Page'

		# Execute JavaScript that modifies the page
		await browser_session.execute_javascript("document.body.style.backgroundColor = 'red'")

		# Verify the change by reading back the value
		bg_color = await browser_session.execute_javascript('document.body.style.backgroundColor')
		assert bg_color == 'red'

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	@pytest.mark.skip(reason='get_scroll_info API changed - depends on page object that no longer exists')
	async def test_get_scroll_info(self, browser_session, base_url):
		"""Test that get_scroll_info returns the correct scroll position information."""
		# Navigate to the scroll test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/scroll_test'))
		await event
		page = await browser_session.get_current_page()

		# Get initial scroll info
		pixels_above_initial, pixels_below_initial = await browser_session.get_scroll_info(page)

		# Verify initial scroll position
		assert pixels_above_initial == 0, 'Initial scroll position should be at the top'
		assert pixels_below_initial > 0, 'There should be content below the viewport'

		# Scroll down the page
		await browser_session.execute_javascript('window.scrollBy(0, 500)')
		await asyncio.sleep(0.2)  # Brief delay for scroll to complete

		# Get new scroll info
		pixels_above_after_scroll, pixels_below_after_scroll = await browser_session.get_scroll_info(page)

		# Verify new scroll position
		assert pixels_above_after_scroll > 0, 'Page should be scrolled down'
		assert pixels_above_after_scroll >= 400, 'Page should be scrolled down at least 400px'
		assert pixels_below_after_scroll < pixels_below_initial, 'Less content should be below viewport after scrolling'

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_take_screenshot(self, browser_session, base_url):
		"""Test that take_screenshot returns a valid base64 encoded image."""
		# Navigate to the test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Take a screenshot
		screenshot_base64 = await browser_session.take_screenshot()

		# Verify the screenshot is a valid base64 string
		assert isinstance(screenshot_base64, str)
		assert len(screenshot_base64) > 0

		# Verify it can be decoded as base64
		try:
			image_data = base64.b64decode(screenshot_base64)
			# Verify the data starts with a valid image signature (PNG file header)
			assert image_data[:8] == b'\x89PNG\r\n\x1a\n', 'Screenshot is not a valid PNG image'
		except Exception as e:
			pytest.fail(f'Failed to decode screenshot as base64: {e}')

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_switch_tab_operations(self, browser_session, base_url):
		"""Test tab creation, switching, and closing operations."""
		# Navigate to home page in first tab
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Create a new tab
		await browser_session.create_new_tab(f'{base_url}/scroll_test')

		# Verify we have two tabs now
		tabs_info = await browser_session.get_tabs()
		assert len(tabs_info) == 2, 'Should have two tabs open'

		# Verify current tab is the scroll test page
		current_url = await browser_session.get_current_page_url()
		assert f'{base_url}/scroll_test' in current_url

		# Switch back to the first tab
		await browser_session.switch_to_tab(0)

		# Verify we're back on the home page
		current_url = await browser_session.get_current_page_url()
		assert f'{base_url}/' in current_url

		# Close the second tab
		await browser_session.close_tab(1)

		# Verify we have the expected number of tabs
		# The first tab remains plus any about:blank tabs created by AboutBlankWatchdog
		tabs_info = await browser_session.get_tabs_info()
		# Filter out about:blank tabs created by the watchdog
		non_blank_tabs = [tab for tab in tabs_info if 'about:blank' not in tab.url]
		assert len(non_blank_tabs) == 1, (
			f'Should have one non-blank tab open after closing the second, but got {len(non_blank_tabs)}: {non_blank_tabs}'
		)
		assert base_url in non_blank_tabs[0].url, 'The remaining tab should be the home page'

	# TODO: highlighting doesn't exist anymore
	# @pytest.mark.asyncio
	# async def test_remove_highlights(self, browser_session, base_url):
	# 	"""Test that remove_highlights successfully removes highlight elements."""
	# 	# Navigate to a test page
	# 	from browser_use.browser.events import NavigateToUrlEvent; event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/')

	# 	# Add a highlight via JavaScript
	# 	await browser_session.execute_javascript("""
	#         const container = document.createElement('div');
	#         container.id = 'playwright-highlight-container';
	#         document.body.appendChild(container);

	#         const highlight = document.createElement('div');
	#         highlight.id = 'playwright-highlight-1';
	#         container.appendChild(highlight);

	#         const element = document.querySelector('h1');
	#         element.setAttribute('browser-user-highlight-id', 'playwright-highlight-1');
	#     """)

	# 	# Verify the highlight container exists
	# 	container_exists = await browser_session.execute_javascript(
	# 		"document.getElementById('playwright-highlight-container') !== null"
	# 	)
	# 	assert container_exists, 'Highlight container should exist before removal'

	# 	# Call remove_highlights
	# 	await browser_session.remove_highlights()

	# 	# Verify the highlight container was removed
	# 	container_exists_after = await browser_session.execute_javascript(
	# 		"document.getElementById('playwright-highlight-container') !== null"
	# 	)
	# 	assert not container_exists_after, 'Highlight container should be removed'

	# 	# Verify the highlight attribute was removed from the element
	# 	attribute_exists = await browser_session.execute_javascript(
	# 		"document.querySelector('h1').hasAttribute('browser-user-highlight-id')"
	# 	)
	# 	assert not attribute_exists, 'browser-user-highlight-id attribute should be removed'

	@pytest.mark.asyncio
	@pytest.mark.skip(reason='TODO: fix')
	async def test_custom_action_with_no_arguments(self, browser_session, base_url):
		"""Test that custom actions with no arguments are handled correctly"""
		from browser_use.agent.views import ActionResult
		from browser_use.tools.registry.service import Registry

		# Create a registry
		registry = Registry()

		# Register a custom action with no arguments
		@registry.action('Some custom action with no args')
		def simple_action():
			return ActionResult(extracted_content='return some result')

		# Navigate to a test page
		from browser_use.browser.events import NavigateToUrlEvent

		event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=f'{base_url}/'))
		await event

		# Execute the action
		result = await registry.execute_action('simple_action', {})

		# Verify the result
		assert isinstance(result, ActionResult)
		assert result.extracted_content == 'return some result'

		# Test that the action model is created correctly
		action_model = registry.create_action_model()

		# The action should be in the model fields
		assert 'simple_action' in action_model.model_fields

		# Create an instance with the simple_action
		action_instance = action_model(simple_action={})  # type: ignore[call-arg]

		# Test that model_dump works correctly
		dumped = action_instance.model_dump(exclude_unset=True)
		assert 'simple_action' in dumped
		assert dumped['simple_action'] == {}

		# Test async version as well
		@registry.action('Async custom action with no args')
		async def async_simple_action():
			return ActionResult(extracted_content='async result')

		result = await registry.execute_action('async_simple_action', {})
		assert result.extracted_content == 'async result'

		# Test with special parameters but no regular arguments
		@registry.action('Action with only special params')
		async def special_params_only(browser_session):
			current_url = await browser_session.get_current_page_url()
			return ActionResult(extracted_content=f'Page URL: {current_url}')

		result = await registry.execute_action('special_params_only', {}, browser_session=browser_session)
		assert 'Page URL:' in result.extracted_content
		assert base_url in result.extracted_content


package internal

import (
	"net/netip"
	"testing"
)

func TestClampIP(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		// IPv4 addresses
		{
			name:     "IPv4 normal address",
			input:    "192.168.1.100",
			expected: "192.168.1.0/24",
		},
		{
			name:     "IPv4 boundary - network address",
			input:    "192.168.1.0",
			expected: "192.168.1.0/24",
		},
		{
			name:     "IPv4 boundary - broadcast address",
			input:    "192.168.1.255",
			expected: "192.168.1.0/24",
		},
		{
			name:     "IPv4 class A address",
			input:    "10.0.0.1",
			expected: "10.0.0.0/24",
		},
		{
			name:     "IPv4 loopback",
			input:    "127.0.0.1",
			expected: "127.0.0.0/24",
		},
		{
			name:     "IPv4 link-local",
			input:    "169.254.0.1",
			expected: "169.254.0.0/24",
		},
		{
			name:     "IPv4 public address",
			input:    "203.0.113.1",
			expected: "203.0.113.0/24",
		},

		// IPv6 addresses
		{
			name:     "IPv6 normal address",
			input:    "2001:db8::1",
			expected: "2001:db8::/48",
		},
		{
			name:     "IPv6 with full expansion",
			input:    "2001:0db8:0000:0000:0000:0000:0000:0001",
			expected: "2001:db8::/48",
		},
		{
			name:     "IPv6 loopback",
			input:    "::1",
			expected: "::/48",
		},
		{
			name:     "IPv6 unspecified address",
			input:    "::",
			expected: "::/48",
		},
		{
			name:     "IPv6 link-local",
			input:    "fe80::1",
			expected: "fe80::/48",
		},
		{
			name:     "IPv6 unique local",
			input:    "fc00::1",
			expected: "fc00::/48",
		},
		{
			name:     "IPv6 documentation prefix",
			input:    "2001:db8:abcd:ef01::1234",
			expected: "2001:db8:abcd::/48",
		},
		{
			name:     "IPv6 global unicast",
			input:    "2606:4700:4700::1111",
			expected: "2606:4700:4700::/48",
		},
		{
			name:     "IPv6 multicast",
			input:    "ff02::1",
			expected: "ff02::/48",
		},

		// IPv4-mapped IPv6 addresses
		{
			name:     "IPv4-mapped IPv6 address",
			input:    "::ffff:192.168.1.100",
			expected: "192.168.1.0/24",
		},
		{
			name:     "IPv4-mapped IPv6 with different format",
			input:    "::ffff:10.0.0.1",
			expected: "10.0.0.0/24",
		},
		{
			name:     "IPv4-mapped IPv6 loopback",
			input:    "::ffff:127.0.0.1",
			expected: "127.0.0.0/24",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			addr := netip.MustParseAddr(tt.input)

			result, ok := ClampIP(addr)
			if !ok {
				t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
			}

			if result.String() != tt.expected {
				t.Errorf("ClampIP(%s) = %s, want %s", tt.input, result.String(), tt.expected)
			}
		})
	}
}

func TestClampIPSuccess(t *testing.T) {
	// Test that valid inputs return success
	tests := []struct {
		name  string
		input string
	}{
		{
			name:  "IPv4 address",
			input: "192.168.1.100",
		},
		{
			name:  "IPv6 address",
			input: "2001:db8::1",
		},
		{
			name:  "IPv4-mapped IPv6",
			input: "::ffff:192.168.1.100",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			addr := netip.MustParseAddr(tt.input)

			result, ok := ClampIP(addr)
			if !ok {
				t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
			}

			// For valid inputs, we should get the clamped prefix
			if addr.Is4() || addr.Is4In6() {
				if result.Bits() != 24 {
					t.Errorf("Expected 24 bits for IPv4, got %d", result.Bits())
				}
			} else if addr.Is6() {
				if result.Bits() != 48 {
					t.Errorf("Expected 48 bits for IPv6, got %d", result.Bits())
				}
			}
		})
	}
}

func TestClampIPZeroValue(t *testing.T) {
	// Test that when ClampIP fails, it returns zero value
	// Note: It's hard to make addr.Prefix() fail with valid inputs,
	// so this test demonstrates the expected behavior
	addr := netip.MustParseAddr("192.168.1.100")

	// Manually create a zero value for comparison
	zeroPrefix := netip.Prefix{}

	// Call ClampIP - it should succeed with valid input
	result, ok := ClampIP(addr)

	// Verify the function succeeded
	if !ok {
		t.Error("ClampIP should succeed with valid input")
	}

	// Verify that the result is not a zero value
	if result == zeroPrefix {
		t.Error("Result should not be zero value for successful operation")
	}
}

func TestClampIPSpecialCases(t *testing.T) {
	tests := []struct {
		name            string
		input           string
		expectedPrefix  int
		expectedNetwork string
	}{
		{
			name:            "Minimum IPv4",
			input:           "0.0.0.0",
			expectedPrefix:  24,
			expectedNetwork: "0.0.0.0",
		},
		{
			name:            "Maximum IPv4",
			input:           "255.255.255.255",
			expectedPrefix:  24,
			expectedNetwork: "255.255.255.0",
		},
		{
			name:            "Minimum IPv6",
			input:           "::",
			expectedPrefix:  48,
			expectedNetwork: "::",
		},
		{
			name:            "Maximum IPv6 prefix part",
			input:           "ffff:ffff:ffff::",
			expectedPrefix:  48,
			expectedNetwork: "ffff:ffff:ffff::",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			addr := netip.MustParseAddr(tt.input)

			result, ok := ClampIP(addr)
			if !ok {
				t.Fatalf("ClampIP(%s) returned false, want true", tt.input)
			}

			if result.Bits() != tt.expectedPrefix {
				t.Errorf("ClampIP(%s) bits = %d, want %d", tt.input, result.Bits(), tt.expectedPrefix)
			}

			if result.Addr().String() != tt.expectedNetwork {
				t.Errorf("ClampIP(%s) network = %s, want %s", tt.input, result.Addr().String(), tt.expectedNetwork)
			}
		})
	}
}

// Benchmark to ensure the function is performant
func BenchmarkClampIP(b *testing.B) {
	ipv4 := netip.MustParseAddr("192.168.1.100")
	ipv6 := netip.MustParseAddr("2001:db8::1")
	ipv4mapped := netip.MustParseAddr("::ffff:192.168.1.100")

	b.Run("IPv4", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			ClampIP(ipv4)
		}
	})

	b.Run("IPv6", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			ClampIP(ipv6)
		}
	})

	b.Run("IPv4-mapped", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			ClampIP(ipv4mapped)
		}
	})
}


use std::env::consts::EXE_EXTENSION;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use anyhow::{Context, Result};
use futures_util::StreamExt;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use semver::Version;
use target_lexicon::HOST;
use tracing::{debug, trace, warn};

use crate::checksum::Sha256Digest;
use crate::fs::{LockedFile, make_executable};
use crate::http::{REQWEST_CLIENT, download_artifact};
use crate::languages::rust::version::RustVersion;
use crate::process::Cmd;
use crate::store::Store;
use crate::warn_user;

#[derive(Clone)]
pub(crate) struct Rustup {
    bin: PathBuf,
    rustup_home: PathBuf,
}

pub(crate) struct ToolchainInfo {
    pub(crate) name: String,
    pub(crate) path: PathBuf,
    pub(crate) version: RustVersion,
}

static RUSTUP_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
    EnvVars
        .var(EnvVars::PREK_INTERNAL__RUSTUP_BINARY_NAME)
        .unwrap_or_else(|_| "rustup".to_string())
});

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, strum::AsRefStr, strum::EnumString)]
#[strum(serialize_all = "lowercase")]
enum RustupProfile {
    #[default]
    Minimal,
    Default,
    Complete,
}

fn rustup_profile(env_vars: &impl EnvVarsRead) -> RustupProfile {
    let Ok(value) = env_vars.var(EnvVars::PREK_RUST_PROFILE) else {
        return RustupProfile::default();
    };

    value.parse().unwrap_or_else(|_| {
        let default = RustupProfile::default();
        warn_user!(
            "Invalid value for {}: {:?}. Expected minimal, default, or complete; using default ({:?})",
            EnvVars::PREK_RUST_PROFILE,
            value,
            default.as_ref(),
        );
        default
    })
}

impl Rustup {
    pub(crate) fn rustup_home(&self) -> &Path {
        &self.rustup_home
    }

    /// Install rustup if not already installed.
    pub(crate) async fn install(store: &Store, rustup_home: &Path) -> Result<Self> {
        // 1) Check system installed `rustup`
        if let Ok(rustup_path) = which::which(&*RUSTUP_BINARY_NAME) {
            trace!("Using system installed rustup at {}", rustup_path.display());
            return Ok(Self {
                bin: rustup_path,
                rustup_home: rustup_home.to_path_buf(),
            });
        }

        // 2) Check if already installed in store
        let rustup_path = rustup_home.join("rustup").with_extension(EXE_EXTENSION);

        if rustup_path.is_file() {
            trace!("Using managed rustup at {}", rustup_path.display());
            return Ok(Self {
                bin: rustup_path,
                rustup_home: rustup_home.to_path_buf(),
            });
        }

        // 3) Install rustup
        fs_err::tokio::create_dir_all(&rustup_home).await?;
        let _lock = LockedFile::acquire(rustup_home.join(".lock"), "rustup").await?;

        if rustup_path.is_file() {
            trace!("Using managed rustup at {}", rustup_path.display());
            return Ok(Self {
                bin: rustup_path,
                rustup_home: rustup_home.to_path_buf(),
            });
        }

        Self::download(store, rustup_home)
            .await
            .context("Failed to install rustup")
    }

    async fn download(store: &Store, rustup_home: &Path) -> Result<Self> {
        let triple = HOST.to_string();
        let filename = if cfg!(windows) {
            "rustup-init.exe"
        } else {
            "rustup-init"
        };
        let url = format!("https://static.rust-lang.org/rustup/dist/{triple}/{filename}");
        // Save "rustup-init" as "rustup", this is what "rustup-init" does when setting up.
        let target = rustup_home.join("rustup").with_extension(EXE_EXTENSION);
        let checksum_url = format!("{url}.sha256");

        let download = download_artifact(&url, filename, store, async || {
            Self::fetch_checksum(&checksum_url).await
        })
        .await?;
        make_executable(download.path())?;

        // Move to final location
        if target.exists() {
            debug!(path = %target.display(), "Removing existing rustup");
            fs_err::tokio::remove_file(&target).await?;
        }
        debug!(path = %target.display(), "Installing rustup");
        fs_err::tokio::rename(download.path(), &target).await?;

        Ok(Self {
            bin: target,
            rustup_home: rustup_home.to_path_buf(),
        })
    }

    async fn fetch_checksum(checksum_url: &str) -> Result<Option<Sha256Digest>> {
        let response = REQWEST_CLIENT
            .get(checksum_url)
            .send()
            .await
            .with_context(|| format!("Failed to fetch rustup checksum from {checksum_url}"))?;
        if response.status() == reqwest::StatusCode::NOT_FOUND {
            return Ok(None);
        }

        let checksum = response
            .error_for_status()
            .with_context(|| format!("Failed to fetch rustup checksum from {checksum_url}"))?
            .text()
            .await?;
        digest_from_rustup_checksum(&checksum)
    }

    pub(crate) async fn install_toolchain(&self, toolchain: &str) -> Result<PathBuf> {
        let output = Cmd::new(&self.bin)
            .env(EnvVars::RUSTUP_HOME, &self.rustup_home)
            .env(EnvVars::RUSTUP_AUTO_INSTALL, "0")
            .arg("toolchain")
            .arg("install")
            .arg("--no-self-update")
            .arg("--profile")
            .arg(rustup_profile(&EnvVars).as_ref())
            .arg(toolchain)
            .check(true)
            .output()
            .await
            .with_context(|| format!("Failed to install rust toolchain {toolchain}"))?;

        // Parse installed toolchain name from output
        let stdout = String::from_utf8_lossy(&output.stdout);
        let installed_name = stdout
            .lines()
            .find_map(|line| {
                let line = line.trim();
                let (name, _) = line.split_once(" installed")?;
                let name = name.trim();
                if name.is_empty() {
                    None
                } else {
                    Some(name.to_string())
                }
            })
            .with_context(|| {
                format!(
                    "Unable to detect installed toolchain name from rustup output for `{toolchain}`"
                )
            })?;

        Ok(self.rustup_home.join("toolchains").join(installed_name))
    }

    /// List installed toolchains managed by prek.
    pub(crate) async fn list_installed_toolchains(&self) -> Result<Vec<ToolchainInfo>> {
        let output = Cmd::new(&self.bin)
            .arg("toolchain")
            .arg("list")
            .arg("-v")
            .env(EnvVars::RUSTUP_HOME, &self.rustup_home)
            .env(EnvVars::RUSTUP_AUTO_INSTALL, "0")
            .check(true)
            .output()
            .await
            .context("Failed to list installed toolchains")?;

        let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)?
            .lines()
            .filter_map(parse_toolchain_line)
            .collect();

        let infos: Vec<ToolchainInfo> = futures_util::stream::iter(entries)
            .map(async move |(name, path)| toolchain_info(name, path).await)
            .buffer_unordered(8)
            .filter_map(async move |result| match result {
                Ok(info) => Some(info),
                Err(e) => {
                    warn!("Skipping invalid toolchain: {e:#}");
                    None
                }
            })
            .collect()
            .await;

        Ok(infos)
    }

    /// List system-installed Rust toolchains.
    pub(crate) async fn list_system_toolchains(&self) -> Result<Vec<ToolchainInfo>> {
        let output = Cmd::new(&self.bin)
            .arg("toolchain")
            .arg("list")
            .arg("-v")
            .env(EnvVars::RUSTUP_AUTO_INSTALL, "0")
            .check(true)
            .output()
            .await
            .context("Failed to list system toolchains")?;

        let entries: Vec<(String, PathBuf)> = str::from_utf8(&output.stdout)?
            .lines()
            .filter_map(parse_toolchain_line)
            .collect();

        let infos: Vec<ToolchainInfo> = futures_util::stream::iter(entries)
            .map(async move |(name, path)| toolchain_info(name, path).await)
            .buffer_unordered(8)
            .filter_map(async move |result| match result {
                Ok(info) => Some(info),
                Err(e) => {
                    warn!("Skipping invalid toolchain: {e:#}");
                    None
                }
            })
            .collect()
            .await;

        Ok(infos)
    }
}

fn digest_from_rustup_checksum(contents: &str) -> Result<Option<Sha256Digest>> {
    let Some(digest) = contents.split_whitespace().next() else {
        return Ok(None);
    };
    digest.parse().map(Some)
}

fn parse_toolchain_line(line: &str) -> Option<(String, PathBuf)> {
    // Typical formats:
    // "stable-aarch64-apple-darwin (default) local-path-redacted"
    // "nightly-x86_64-unknown-linux-gnu local-path-redacted"
    let parts: Vec<_> = line.split_whitespace().collect();
    let name = (*parts.first()?).to_string();
    let path = parts.last()?;
    let path = PathBuf::from(path);
    if path.exists() {
        Some((name, path))
    } else {
        None
    }
}

async fn toolchain_info(name: String, toolchain_dir: PathBuf) -> Result<ToolchainInfo> {
    let rustc = toolchain_dir
        .join("bin")
        .join("rustc")
        .with_extension(EXE_EXTENSION);

    let output = Cmd::new(&rustc)
        .arg("--version")
        .check(true)
        .output()
        .await
        .with_context(|| format!("Failed to read version from {}", rustc.display()))?;

    let version_str = str::from_utf8(&output.stdout)?
        .split_whitespace()
        .nth(1)
        .context("Failed to parse rustc --version output")?;
    let version = Version::parse(version_str)?;
    let version = RustVersion::from_path(&version, &toolchain_dir);

    Ok(ToolchainInfo {
        name,
        path: toolchain_dir,
        version,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

    #[test]
    fn parses_rustup_checksum() -> Result<()> {
        let digest = digest_from_rustup_checksum(EMPTY_SHA256)?;
        assert_eq!(digest.unwrap().to_string(), EMPTY_SHA256);

        let digest = digest_from_rustup_checksum(&format!("{EMPTY_SHA256}  rustup-init\n"))?;
        assert_eq!(digest.unwrap().to_string(), EMPTY_SHA256);

        let result = digest_from_rustup_checksum("")?;
        assert!(result.is_none());
        Ok(())
    }

    #[test]
    fn rustup_profile_reads_env() {
        fn profile_with(value: &str) -> RustupProfile {
            rustup_profile(&EnvVars::from_map(&[(EnvVars::PREK_RUST_PROFILE, value)]))
        }

        assert_eq!(
            rustup_profile(&EnvVars::from_map(&[])),
            RustupProfile::Minimal
        );
        assert_eq!(profile_with("default"), RustupProfile::Default);
        assert_eq!(profile_with("complete"), RustupProfile::Complete);
        assert_eq!(profile_with("invalid"), RustupProfile::Minimal);
    }
}


# template-artifact-pipeline Specification

## Purpose

Define a unified architecture for workflow template generation that centralizes workflow definitions, tool capability wiring, transform execution, and artifact synchronization while preserving output fidelity.

## ADDED Requirements

### Requirement: Canonical Workflow Manifest

The system SHALL define a canonical workflow manifest as the single source of truth for generated skill and command artifacts.

#### Scenario: Register workflow once

- **WHEN** a workflow (for example `explore`, `ff`, or `onboard`) is added or modified
- **THEN** its canonical definition SHALL be registered once in the workflow manifest
- **AND** skill/command projections SHALL be derived from that manifest
- **AND** duplicate hand-maintained lists SHALL NOT be required

#### Scenario: Required skill metadata

- **WHEN** defining a workflow skill entry in the manifest
- **THEN** it SHALL include required metadata fields (`license`, `compatibility`, and `metadata`)
- **AND** generation SHALL use those values or explicit defaults in a consistent way for all workflows

### Requirement: Tool Profile Registry

The system SHALL define a tool profile registry that captures generation capabilities per tool.

#### Scenario: Resolve tool capabilities

- **WHEN** generating artifacts for a selected tool
- **THEN** the system SHALL resolve a tool profile that declares skill path capability, command adapter linkage, and transform set
- **AND** tools with skills support but no command adapter SHALL be handled explicitly without implicit fallback behavior

#### Scenario: Capability consistency validation

- **WHEN** running validation checks
- **THEN** the system SHALL detect mismatches between configured tools, profile definitions, and registered adapters
- **AND** fail with actionable errors in development/CI

### Requirement: Ordered Transform Pipeline

The system SHALL support ordered artifact transforms with explicit scope and phase semantics.

#### Scenario: Execute pre-adapter and post-adapter transforms

- **WHEN** generating an artifact
- **THEN** matching transforms SHALL execute in deterministic order based on phase and priority
- **AND** `preAdapter` transforms SHALL run before command adapter formatting
- **AND** `postAdapter` transforms SHALL run after adapter formatting

#### Scenario: Apply tool-specific rewrites declaratively

- **WHEN** a tool requires instruction rewrites (for example command reference syntax changes)
- **THEN** those rewrites SHALL be implemented as registered transforms with explicit applicability predicates
- **AND** generation entry points SHALL NOT implement ad-hoc rewrite logic

### Requirement: Shared Artifact Sync Engine

The system SHALL provide a shared artifact sync engine used by all generation entry points.

#### Scenario: Init and update use same engine

- **WHEN** `openspec init` or `openspec update` writes skills/commands
- **THEN** both flows SHALL use the same orchestration engine for planning, rendering, validating, and writing artifacts
- **AND** behavior differences SHALL be configuration-driven rather than separate duplicated loops

#### Scenario: Legacy upgrade path reuses engine

- **WHEN** legacy cleanup triggers artifact regeneration
- **THEN** the regeneration path SHALL use the same shared engine
- **AND** generated outputs SHALL follow the same transform and validation rules

### Requirement: Fidelity Guardrails

The system SHALL enforce guardrails that prevent output drift during refactors.

#### Scenario: Projection parity checks

- **WHEN** CI runs template generation tests
- **THEN** it SHALL verify manifest-derived projections remain consistent (workflows, command IDs, skill directories)
- **AND** detect missing exports or missing workflow registration

#### Scenario: Output parity checks

- **WHEN** running parity tests for representative workflow/tool combinations
- **THEN** generated artifacts SHALL remain behaviorally equivalent to approved baselines unless intentionally changed
- **AND** intentional changes SHALL be captured in explicit spec/proposal updates


// opencode native install — fresh install, idempotency, uninstall, plugin smoke.
//
// Detection of opencode is gated behind `command -v opencode`, so to run on a
// CI box without opencode installed we prepend a tmpdir with a no-op `opencode`
// shim to PATH. The installer's per-provider dispatch only checks PATH; it
// never invokes the binary itself.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const requireCjs = createRequire(import.meta.url);
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));

const IS_WIN = process.platform === 'win32';

function freshTmpDir() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-opencode-'));
}

// Make a throwaway `opencode` binary on PATH so detectMatch('command:opencode')
// returns true. The shim never executes — installer only checks PATH presence.
function shimOpencode() {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-shim-'));
  if (IS_WIN) {
    fs.writeFileSync(path.join(dir, 'opencode.cmd'), '@echo off\r\n');
  } else {
    const f = path.join(dir, 'opencode');
    fs.writeFileSync(f, '#!/bin/sh\nexit 0\n');
    fs.chmodSync(f, 0o755);
  }
  return dir;
}

function runInstaller(args, env) {
  return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], {
    env, encoding: 'utf8',
  });
}

function pathWith(prependDir) {
  const sep = IS_WIN ? ';' : ':';
  return prependDir + sep + (process.env.PATH || '');
}

// ── 1. Fresh install populates expected files ────────────────────────────
test('opencode fresh install drops plugin, commands, agents, skills, AGENTS.md, opencode.json', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const r = runInstaller(['--only', 'opencode'], {
      ...process.env,
      XDG_CONFIG_HOME: xdg,
      PATH: pathWith(shimDir),
      NO_COLOR: '1',
    });
    assert.notEqual(r.status, 2, `argv error: ${r.stderr}`);

    const ocDir = path.join(xdg, 'opencode');
    assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'plugin.js')), 'plugin.js missing');
    assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'package.json')), 'plugin package.json missing');
    assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'caveman-config.cjs')), 'caveman-config.cjs sibling missing');

    for (const f of ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md']) {
      assert.ok(fs.existsSync(path.join(ocDir, 'commands', f)), `command ${f} missing`);
    }
    for (const f of ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']) {
      assert.ok(fs.existsSync(path.join(ocDir, 'agents', f)), `agent ${f} missing`);
    }
    for (const name of ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']) {
      assert.ok(fs.existsSync(path.join(ocDir, 'skills', name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
    }
    assert.ok(fs.existsSync(path.join(ocDir, 'AGENTS.md')), 'AGENTS.md missing');
    const agentsBody = fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8');
    assert.match(agentsBody, /Respond terse like smart caveman/);
    // Block must be wrapped in begin/end markers so uninstall can isolate it
    // from user-authored content above and below.
    assert.match(agentsBody, /<!-- caveman-begin -->/);
    assert.match(agentsBody, /<!-- caveman-end -->/);

    const cfgPath = path.join(ocDir, 'opencode.json');
    assert.ok(fs.existsSync(cfgPath), 'opencode.json missing');
    const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
    assert.ok(Array.isArray(cfg.plugin), 'opencode.json missing plugin array');
    assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing');
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 2. Idempotency: install twice, plugin array stays length 1 ───────────
test('opencode idempotent install does not duplicate plugin entries', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r1 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r1.status, 2);
    const r2 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r2.status, 2);

    const cfg = JSON.parse(fs.readFileSync(path.join(xdg, 'opencode', 'opencode.json'), 'utf8'));
    const matches = cfg.plugin.filter(p => p === './plugins/caveman/plugin.js');
    assert.equal(matches.length, 1, `expected 1 plugin entry, got ${matches.length}`);

    // AGENTS.md should not have the ruleset duplicated either.
    const agentsMd = fs.readFileSync(path.join(xdg, 'opencode', 'AGENTS.md'), 'utf8');
    const sentinelCount = (agentsMd.match(/Respond terse like smart caveman/g) || []).length;
    assert.equal(sentinelCount, 1, `expected 1 sentinel, got ${sentinelCount}`);
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 2b. Plugin payload not overwritten on re-install (without --force) ────
test('opencode re-install preserves user edits to plugin.js without --force', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r1 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r1.status, 2);

    const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
    const tweak = '\n// USER-TWEAK-DO-NOT-OVERWRITE\n';
    fs.appendFileSync(pluginPath, tweak);
    const beforeBytes = fs.readFileSync(pluginPath, 'utf8');

    const r2 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r2.status, 2);

    const afterBytes = fs.readFileSync(pluginPath, 'utf8');
    assert.equal(afterBytes, beforeBytes, 'second install should not overwrite plugin.js without --force');
    assert.match(afterBytes, /USER-TWEAK-DO-NOT-OVERWRITE/);

    // With --force, the file should be replaced (no tweak afterward).
    const r3 = runInstaller(['--only', 'opencode', '--force'], env);
    assert.notEqual(r3.status, 2);
    const forced = fs.readFileSync(pluginPath, 'utf8');
    assert.doesNotMatch(forced, /USER-TWEAK-DO-NOT-OVERWRITE/, '--force should overwrite plugin.js');
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 2c. AGENTS.md fence preserves user content above and below ───────────
test('opencode uninstall strips fenced AGENTS.md block, preserving user prefix and suffix', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r1 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r1.status, 2);

    const agentsMd = path.join(xdg, 'opencode', 'AGENTS.md');
    const installed = fs.readFileSync(agentsMd, 'utf8');
    // Sandwich the caveman block between user prefix and suffix.
    const userPrefix = '# my project\n\nuse 2-space indent.\n\n';
    const userSuffix = '\n## extra\n\nkeep PRs small.\n';
    fs.writeFileSync(agentsMd, userPrefix + installed.trimEnd() + '\n' + userSuffix);

    const r2 = runInstaller(['--uninstall'], env);
    assert.notEqual(r2.status, 2);

    const after = fs.readFileSync(agentsMd, 'utf8');
    assert.doesNotMatch(after, /<!-- caveman-begin -->/, 'caveman block should be stripped');
    assert.doesNotMatch(after, /<!-- caveman-end -->/, 'caveman end marker should be stripped');
    assert.doesNotMatch(after, /Respond terse like smart caveman/, 'caveman body should be stripped');
    assert.match(after, /# my project/, 'user prefix should survive');
    assert.match(after, /use 2-space indent/, 'user prefix body should survive');
    assert.match(after, /## extra/, 'user suffix should survive');
    assert.match(after, /keep PRs small/, 'user suffix body should survive');
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 3. Tolerates JSONC opencode.json (#249-class regression guard) ───────
test('opencode install tolerates JSONC opencode.json (comments + trailing commas)', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const ocDir = path.join(xdg, 'opencode');
    fs.mkdirSync(ocDir, { recursive: true });
    fs.writeFileSync(path.join(ocDir, 'opencode.json'),
      `// hand-written
{
  /* user prefs */
  "model": "anthropic/claude-sonnet-4-5",
  "theme": "dark",
}
`);

    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r.status, 2);

    const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8'));
    assert.equal(cfg.model, 'anthropic/claude-sonnet-4-5', 'user model setting wiped');
    assert.equal(cfg.theme, 'dark', 'user theme setting wiped');
    assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing');
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 4. Uninstall removes opencode artifacts and prunes config ────────────
test('opencode uninstall removes plugin dir, command/agent/skill files, prunes opencode.json', () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  try {
    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r1 = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r1.status, 2);

    const r2 = runInstaller(['--uninstall'], env);
    assert.notEqual(r2.status, 2);

    const ocDir = path.join(xdg, 'opencode');
    assert.equal(fs.existsSync(path.join(ocDir, 'plugins', 'caveman')), false, 'plugin dir survived');
    assert.equal(fs.existsSync(path.join(ocDir, 'commands', 'caveman.md')), false, 'caveman.md command survived');
    assert.equal(fs.existsSync(path.join(ocDir, 'agents', 'cavecrew-builder.md')), false, 'cavecrew agent survived');
    assert.equal(fs.existsSync(path.join(ocDir, 'skills', 'caveman')), false, 'caveman skill dir survived');
    assert.equal(fs.existsSync(path.join(ocDir, 'AGENTS.md')), false, 'AGENTS.md (we wrote it) survived');

    if (fs.existsSync(path.join(ocDir, 'opencode.json'))) {
      const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8'));
      const stillHasPlugin = Array.isArray(cfg.plugin) && cfg.plugin.includes('./plugins/caveman/plugin.js');
      assert.equal(stillHasPlugin, false, 'plugin entry survived in opencode.json');
    }
  } finally {
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});

// ── 5. Plugin smoke: load installed plugin.js, fire the real opencode hooks ──
// opencode (>= 1.15) has no `tui.prompt.append` or top-level `session.created`
// plugin-hook keys (#418/#421). The plugin now uses `chat.message` for mode
// parsing, `experimental.chat.system.transform` for reinforcement, and the
// `event` dispatcher (filtering event.type === 'session.created') for session
// init. This test drives those real hooks.
test('opencode plugin handles /caveman ultra, stop caveman, and session init via real hooks', async () => {
  const xdg = freshTmpDir();
  const shimDir = shimOpencode();
  const origDefault = process.env.CAVEMAN_DEFAULT_MODE;
  try {
    const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
    const r = runInstaller(['--only', 'opencode'], env);
    assert.notEqual(r.status, 2);

    const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
    const flagPath = path.join(xdg, 'opencode', '.caveman-active');

    // Set XDG_CONFIG_HOME so the plugin's flagPath resolves to our temp dir,
    // and pin the default mode so session-init is deterministic regardless of
    // any ambient user/repo-local caveman config.
    process.env.XDG_CONFIG_HOME = xdg;
    process.env.CAVEMAN_DEFAULT_MODE = 'full';

    const mod = await import(pathToFileURL(pluginPath).href);
    const factory = mod.default || mod.CavemanPlugin;
    const handlers = await factory({});

    // The dead direct-key hooks must NOT be registered.
    assert.equal(handlers['tui.prompt.append'], undefined, 'tui.prompt.append should not exist');
    assert.equal(handlers['session.created'], undefined, 'session.created direct key should not exist');
    assert.equal(typeof handlers.event, 'function', 'event dispatcher should be a function');
    assert.equal(typeof handlers['chat.message'], 'function', 'chat.message should be a function');
    assert.equal(typeof handlers['experimental.chat.system.transform'], 'function',
      'system.transform should be a function');

    // Slash command in a chat.message text part activates ultra.
    await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');

    // opencode expands "/caveman <level>" into the command template before
    // chat.message fires — the level must be recovered from the expanded text.
    await handlers['chat.message']({}, { parts: [{ type: 'text', text:
      'Activate caveman mode: wenyan-lite\n\nIf no level given, use full. If "off", deactivate.' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'wenyan-lite');
    await handlers['chat.message']({}, { parts: [{ type: 'text', text:
      'Activate caveman mode: off\n\nIf no level given, use full. If "off", deactivate.' }] });
    assert.equal(fs.existsSync(flagPath), false, 'expanded template with off should delete the flag');
    await handlers['chat.message']({}, { parts: [{ type: 'text', text:
      'Activate caveman mode: \n\nIf no level given, use full. If "off", deactivate.' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full', 'expanded template without level uses default');
    await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');

    // opencode's non-interactive `run` path wraps the message in literal
    // quotes ("/caveman lite"\n) — the parser must unwrap them.
    await handlers['chat.message']({}, { parts: [{ type: 'text', text: '"/caveman lite"\n' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'lite');
    await handlers['chat.message']({}, { parts: [{ type: 'text', text: '/caveman ultra' }] });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');

    // system.transform injects the reinforcement line while active.
    const sys1 = { system: [] };
    await handlers['experimental.chat.system.transform']({}, sys1);
    assert.equal(sys1.system.length, 1, 'expected one reinforcement line');
    assert.match(sys1.system[0], /CAVEMAN MODE ACTIVE \(ultra\)/);

    // Natural-language deactivation removes the flag.
    await handlers['chat.message']({}, { parts: [{ type: 'text', text: 'stop caveman please' }] });
    assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation');

    // No reinforcement injected when inactive.
    const sys2 = { system: [] };
    await handlers['experimental.chat.system.transform']({}, sys2);
    assert.equal(sys2.system.length, 0, 'no reinforcement when flag absent');

    // The `event` dispatcher writes the default mode on session.created, and
    // ignores unrelated event types.
    await handlers.event({ event: { type: 'session.idle' } });
    assert.equal(fs.existsSync(flagPath), false, 'non-session.created event must not write the flag');
    await handlers.event({ event: { type: 'session.created' } });
    assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full');
  } finally {
    if (origDefault === undefined) delete process.env.CAVEMAN_DEFAULT_MODE;
    else process.env.CAVEMAN_DEFAULT_MODE = origDefault;
    fs.rmSync(xdg, { recursive: true, force: true });
    fs.rmSync(shimDir, { recursive: true, force: true });
  }
});


"""Tests for ax_name (accessible name) element matching in history rerun.

This tests Level 4 matching which uses the accessibility tree's name property
to match elements when hash, stable_hash, and xpath all fail.
This is particularly useful for dynamic SPAs where DOM structure changes
but accessible names remain stable.

Also tests dropdown/menu re-opening behavior when menu items can't be found
because the dropdown closed during the wait between steps.
"""

from unittest.mock import AsyncMock

from browser_use.agent.service import Agent
from browser_use.agent.views import ActionResult, AgentHistory, AgentHistoryList, RerunSummaryAction, StepMetadata
from browser_use.browser.views import BrowserStateHistory
from browser_use.dom.views import DOMInteractedElement, DOMRect, MatchLevel, NodeType
from tests.ci.conftest import create_mock_llm

async def test_ax_name_matching_succeeds_when_hash_fails(httpserver):
	"""Test that ax_name matching finds elements when hash/xpath matching fails.

	This simulates a dynamic SPA where the element hash and xpath change between
	sessions, but the accessible name (ax_name) remains stable.
	"""
	# Set up a test page with a menu item that has an aria-label
	# The aria-label becomes the accessible name (ax_name)
	test_html = """<!DOCTYPE html>

New Contact

Search

	"""
	httpserver.expect_request('/test').respond_with_data(test_html, content_type='text/html')
	test_url = httpserver.url_for('/test')

	# Create a mock LLM for summary
	summary_action = RerunSummaryAction(
		summary='Rerun completed',
		success=True,
		completion_status='complete',
	)

	async def custom_ainvoke(*args, **kwargs):
		output_format = args[1] if len(args) > 1 else kwargs.get('output_format')
		if output_format is RerunSummaryAction:
			from browser_use.llm.views import ChatInvokeCompletion

			return ChatInvokeCompletion(completion=summary_action, usage=None)
		raise ValueError('Unexpected output_format')

	mock_summary_llm = AsyncMock()
	mock_summary_llm.ainvoke.side_effect = custom_ainvoke

	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Create an element with DIFFERENT hash/xpath but SAME ax_name as the real element
	# This simulates what happens in dynamic SPAs where the DOM changes but
	# accessible names remain stable
	historical_element = DOMInteractedElement(
		node_id=9999,  # Different node_id
		backend_node_id=9999,  # Different backend_node_id
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',  # Same node type
		# Note: aria-label is NOT in attributes - this tests that ax_name matching
		# is used as a fallback when attribute matching fails
		attributes={'role': 'menuitem', 'class': 'dynamic-class-12345'},
		x_path='html/body/div[1]/div[4]/div[4]/div[1]',  # Different xpath
		element_hash=123456789,  # Different hash (won't match)
		stable_hash=987654321,  # Different stable_hash (won't match)
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='New Contact',  # SAME ax_name - this should match!
	)

	# Step 1: Navigate to test page
	navigate_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Navigate to test page',
			next_goal=None,
			action=[{'navigate': {'url': test_url}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Navigated')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test Page',
			tabs=[],
			interacted_element=[None],
		),
		metadata=StepMetadata(
			step_start_time=0,
			step_end_time=1,
			step_number=1,
			step_interval=0.1,
		),
	)

	# Step 2: Click on element that has different hash/xpath but same ax_name
	click_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Click New Contact menu',
			next_goal=None,
			action=[{'click': {'index': 100}}],  # type: ignore[arg-type]  # Original index doesn't matter
		),
		result=[ActionResult(long_term_memory='Clicked New Contact')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test Page',
			tabs=[],
			interacted_element=[historical_element],
		),
		metadata=StepMetadata(
			step_start_time=1,
			step_end_time=2,
			step_number=2,
			step_interval=0.1,
		),
	)

	history = AgentHistoryList(history=[navigate_step, click_step])

	try:
		# Run rerun - should succeed because ax_name matching finds the element
		results = await agent.rerun_history(
			history,
			skip_failures=False,
			max_retries=1,
			summary_llm=mock_summary_llm,
		)

		# Should have 3 results: navigate + click + AI summary
		assert len(results) == 3

		# First result should be navigation success
		nav_result = results[0]
		assert nav_result.error is None

		# Second result should be click success (matched via ax_name)
		click_result = results[1]
		assert click_result.error is None, f'Click should succeed via ax_name matching, got error: {click_result.error}'

		# Third result should be AI summary
		summary_result = results[2]
		assert summary_result.is_done is True

	finally:
		await agent.close()

async def test_ax_name_matching_requires_same_node_type(httpserver):
	"""Test that ax_name matching also requires matching node type.

	Even if ax_name matches, the node type (DIV, BUTTON, etc.) must also match.
	"""
	test_html = """<!DOCTYPE html>

		<button aria-label="Submit">Submit</button>

Submit Label

	"""
	httpserver.expect_request('/test').respond_with_data(test_html, content_type='text/html')
	test_url = httpserver.url_for('/test')

	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Historical element is a SPAN with ax_name "Submit"
	# Page has BUTTON and DIV with same ax_name, but no SPAN
	historical_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='SPAN',  # SPAN - won't match BUTTON or DIV
		attributes={},
		x_path='html/body/span',
		element_hash=111,
		stable_hash=111,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='Submit',  # Same ax_name, but wrong node type
	)

	navigate_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Navigate',
			next_goal=None,
			action=[{'navigate': {'url': test_url}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Navigated')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test',
			tabs=[],
			interacted_element=[None],
		),
		metadata=StepMetadata(step_start_time=0, step_end_time=1, step_number=1, step_interval=0.1),
	)

	click_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Click Submit',
			next_goal=None,
			action=[{'click': {'index': 1}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Clicked')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test',
			tabs=[],
			interacted_element=[historical_element],
		),
		metadata=StepMetadata(step_start_time=1, step_end_time=2, step_number=2, step_interval=0.1),
	)

	history = AgentHistoryList(history=[navigate_step, click_step])

	try:
		# Should fail because no SPAN with ax_name "Submit" exists
		await agent.rerun_history(
			history,
			skip_failures=False,
			max_retries=1,
		)
		assert False, 'Expected RuntimeError - no matching SPAN element'
	except RuntimeError as e:
		# Expected - no SPAN element with ax_name "Submit"
		assert 'failed after 1 attempts' in str(e)
	finally:
		await agent.close()

def test_match_level_enum_includes_ax_name():
	"""Test that MatchLevel enum includes AX_NAME level."""
	assert hasattr(MatchLevel, 'AX_NAME')
	assert MatchLevel.AX_NAME.value == 4
	assert MatchLevel.ATTRIBUTE.value == 5  # AX_NAME comes before ATTRIBUTE

async def test_ax_name_matching_before_attribute_matching(httpserver):
	"""Test that ax_name matching (Level 4) is tried before attribute matching (Level 5).

	This ensures the correct matching order: EXACT -> STABLE -> XPATH -> AX_NAME -> ATTRIBUTE
	"""
	# Page has element with text content that becomes its ax_name
	# The DIV has role="menuitem" and text "Contact" which becomes its accessible name
	# but NO aria-label/id/name attributes - so attribute matching will fail but ax_name should work
	test_html = """<!DOCTYPE html>

Contact

	"""
	httpserver.expect_request('/test').respond_with_data(test_html, content_type='text/html')
	test_url = httpserver.url_for('/test')

	summary_action = RerunSummaryAction(
		summary='Rerun completed',
		success=True,
		completion_status='complete',
	)

	async def custom_ainvoke(*args, **kwargs):
		output_format = args[1] if len(args) > 1 else kwargs.get('output_format')
		if output_format is RerunSummaryAction:
			from browser_use.llm.views import ChatInvokeCompletion

			return ChatInvokeCompletion(completion=summary_action, usage=None)
		raise ValueError('Unexpected output_format')

	mock_summary_llm = AsyncMock()
	mock_summary_llm.ainvoke.side_effect = custom_ainvoke

	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Historical element has NO aria-label attribute (attribute matching will fail)
	# but HAS ax_name (ax_name matching should work)
	historical_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',
		# No aria-label, id, or name - attribute matching will fail
		attributes={'role': 'menuitem'},
		x_path='html/body/div[99]',  # Wrong xpath
		element_hash=12345,  # Wrong hash
		stable_hash=12345,  # Wrong stable hash
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='Contact',  # ax_name from accessibility tree
	)

	navigate_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Navigate',
			next_goal=None,
			action=[{'navigate': {'url': test_url}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Navigated')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test',
			tabs=[],
			interacted_element=[None],
		),
		metadata=StepMetadata(step_start_time=0, step_end_time=1, step_number=1, step_interval=0.1),
	)

	click_step = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Click Contact',
			next_goal=None,
			action=[{'click': {'index': 1}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Clicked')],
		state=BrowserStateHistory(
			url=test_url,
			title='Test',
			tabs=[],
			interacted_element=[historical_element],
		),
		metadata=StepMetadata(step_start_time=1, step_end_time=2, step_number=2, step_interval=0.1),
	)

	history = AgentHistoryList(history=[navigate_step, click_step])

	try:
		# Should succeed via ax_name matching (Level 4)
		# since hash, stable_hash, xpath all fail but ax_name matches
		results = await agent.rerun_history(
			history,
			skip_failures=False,
			max_retries=1,
			summary_llm=mock_summary_llm,
		)

		# Navigation + click + summary
		assert len(results) == 3
		# Click should succeed (matched via ax_name)
		click_result = results[1]
		assert click_result.error is None, f'Expected ax_name match to succeed, got: {click_result.error}'

	finally:
		await agent.close()

# Tests for dropdown/menu re-opening behavior

def test_is_menu_opener_step_detects_aria_haspopup():
	"""Test that _is_menu_opener_step detects aria-haspopup elements."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Element with aria-haspopup="true" should be detected as menu opener
	opener_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',
		attributes={'aria-haspopup': 'true', 'class': 'dropdown-trigger'},
		x_path='html/body/div',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='Contact',
	)

	history_item = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Click dropdown',
			next_goal=None,
			action=[{'click': {'index': 1}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Clicked')],
		state=BrowserStateHistory(
			url='http://test.com',
			title='Test',
			tabs=[],
			interacted_element=[opener_element],
		),
		metadata=StepMetadata(step_start_time=0, step_end_time=1, step_number=1, step_interval=0.1),
	)

	assert agent._is_menu_opener_step(history_item) is True

def test_is_menu_opener_step_detects_guidewire_toggle():
	"""Test that _is_menu_opener_step detects Guidewire toggleSubMenu pattern."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Element with data-gw-click="toggleSubMenu" should be detected
	opener_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',
		attributes={'data-gw-click': 'toggleSubMenu', 'class': 'gw-action--expand-button'},
		x_path='html/body/div',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name=None,
	)

	history_item = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Toggle menu',
			next_goal=None,
			action=[{'click': {'index': 1}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Toggled')],
		state=BrowserStateHistory(
			url='http://test.com',
			title='Test',
			tabs=[],
			interacted_element=[opener_element],
		),
		metadata=StepMetadata(step_start_time=0, step_end_time=1, step_number=1, step_interval=0.1),
	)

	assert agent._is_menu_opener_step(history_item) is True

def test_is_menu_opener_step_returns_false_for_regular_element():
	"""Test that _is_menu_opener_step returns False for non-menu elements."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)
	AgentOutput = agent.AgentOutput

	# Regular button without menu attributes
	regular_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='BUTTON',
		attributes={'class': 'submit-btn', 'type': 'submit'},
		x_path='html/body/button',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='Submit',
	)

	history_item = AgentHistory(
		model_output=AgentOutput(
			evaluation_previous_goal=None,
			memory='Click submit',
			next_goal=None,
			action=[{'click': {'index': 1}}],  # type: ignore[arg-type]
		),
		result=[ActionResult(long_term_memory='Clicked')],
		state=BrowserStateHistory(
			url='http://test.com',
			title='Test',
			tabs=[],
			interacted_element=[regular_element],
		),
		metadata=StepMetadata(step_start_time=0, step_end_time=1, step_number=1, step_interval=0.1),
	)

	assert agent._is_menu_opener_step(history_item) is False

def test_is_menu_item_element_detects_role_menuitem():
	"""Test that _is_menu_item_element detects role=menuitem."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)

	menu_item = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',
		attributes={'role': 'menuitem', 'class': 'menu-option'},
		x_path='html/body/div/div',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='New Contact',
	)

	assert agent._is_menu_item_element(menu_item) is True

def test_is_menu_item_element_detects_guidewire_class():
	"""Test that _is_menu_item_element detects Guidewire gw-action--inner class."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)

	menu_item = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='DIV',
		attributes={'class': 'gw-action--inner gw-hasDivider', 'aria-haspopup': 'true'},
		x_path='html/body/div/div',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='New Contact',
	)

	assert agent._is_menu_item_element(menu_item) is True

def test_is_menu_item_element_returns_false_for_regular_element():
	"""Test that _is_menu_item_element returns False for non-menu elements."""
	llm = create_mock_llm(actions=None)
	agent = Agent(task='Test task', llm=llm)

	regular_element = DOMInteractedElement(
		node_id=1,
		backend_node_id=1,
		frame_id=None,
		node_type=NodeType.ELEMENT_NODE,
		node_value='',
		node_name='BUTTON',
		attributes={'class': 'submit-btn', 'type': 'submit'},
		x_path='html/body/button',
		element_hash=12345,
		stable_hash=12345,
		bounds=DOMRect(x=0, y=0, width=100, height=50),
		ax_name='Submit',
	)

	assert agent._is_menu_item_element(regular_element) is False


import * as fs from "fs";
import * as os from "os";
import { CLI_CLIENT_ID } from "../constants.js";
import { getBaseUrl } from "./api.js";
import {
  CREDENTIALS_FILE_NAME,
  getConfigDir,
  getCredentialsFilePath,
  getLegacyFilePath,
  migrateLegacyFileSync,
  resolveReadPathSync,
} from "./storage-paths.js";

export interface TokenData {
  access_token: string;
  refresh_token?: string;
  token_type: string;
  expires_in?: number;
  expires_at?: number;
  scope?: string;
}

function ensureConfigDir(): void {
  const configDir = getConfigDir();
  if (!fs.existsSync(configDir)) {
    fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
  }
}

// Credentials must never be group/world-readable, even if a migrated or
// pre-existing file carried looser permissions.
const CREDENTIALS_MODE = 0o600;

export function saveTokens(tokens: TokenData): void {
  const credentialsFile = getCredentialsFilePath();
  migrateLegacyFileSync(CREDENTIALS_FILE_NAME, credentialsFile, CREDENTIALS_MODE);
  ensureConfigDir();
  const data = {
    ...tokens,
    expires_at:
      tokens.expires_at ?? (tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined),
  };
  fs.writeFileSync(credentialsFile, JSON.stringify(data, null, 2), { mode: CREDENTIALS_MODE });
  // `mode` is ignored when the file already exists; enforce it explicitly.
  fs.chmodSync(credentialsFile, CREDENTIALS_MODE);
}

export function loadTokens(): TokenData | null {
  const credentialsFile = resolveReadPathSync(
    CREDENTIALS_FILE_NAME,
    getCredentialsFilePath(),
    CREDENTIALS_MODE
  );
  if (!fs.existsSync(credentialsFile)) {
    return null;
  }
  try {
    const data = JSON.parse(fs.readFileSync(credentialsFile, "utf-8"));
    return data as TokenData;
  } catch {
    return null;
  }
}

export function clearTokens(): boolean {
  const credentialsFile = getCredentialsFilePath();
  let removed = false;
  if (fs.existsSync(credentialsFile)) {
    fs.unlinkSync(credentialsFile);
    removed = true;
  }
  const legacyCredentialsFile = getLegacyFilePath(CREDENTIALS_FILE_NAME);
  if (fs.existsSync(legacyCredentialsFile)) {
    fs.unlinkSync(legacyCredentialsFile);
    removed = true;
  }
  return removed;
}

export function isTokenExpired(tokens: TokenData): boolean {
  if (!tokens.expires_at) {
    return false;
  }
  return Date.now() > tokens.expires_at - 60000;
}

async function refreshAccessToken(refreshToken: string): Promise<TokenData> {
  return oauthRequest<TokenData>(
    `${getBaseUrl()}/api/oauth/token`,
    new URLSearchParams({
      grant_type: "refresh_token",
      client_id: CLI_CLIENT_ID,
      refresh_token: refreshToken,
    }),
    "Failed to refresh token"
  );
}

/**
 * Returns a valid access token, refreshing if expired. Returns null if no
 * tokens are stored or refresh fails. Pre-0.5 installs may have OAuth tokens
 * with a `refresh_token`; new installs hold long-lived API keys that never
 * expire and skip the refresh path entirely.
 */
export async function getValidAccessToken(): Promise<string | null> {
  const tokens = loadTokens();
  if (!tokens) return null;

  if (!isTokenExpired(tokens)) {
    return tokens.access_token;
  }

  if (!tokens.refresh_token) {
    return null;
  }

  try {
    const newTokens = await refreshAccessToken(tokens.refresh_token);
    saveTokens(newTokens);
    return newTokens.access_token;
  } catch {
    return null;
  }
}

interface TokenErrorResponse {
  error?: string;
  error_description?: string;
}

export interface DeviceAuthorizationResponse {
  device_code: string;
  user_code: string;
  verification_uri: string;
  verification_uri_complete?: string;
  expires_in: number;
  /** Optional per RFC 8628 §3.2; clients MUST default to 5s when absent. */
  interval?: number;
}

const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";

async function describeErrorResponse(response: Response, fallback: string): Promise<string> {
  const body = await response.text().catch(() => "");

  try {
    const err = JSON.parse(body) as TokenErrorResponse;
    const message = err.error_description || err.error;
    if (message) return message;
  } catch {
    // An interceptor's HTML, not an OAuth error object.
  }

  const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
  const detail = `HTTP ${response.status} from ${response.url}`;
  return excerpt ? `${fallback} (${detail}): ${excerpt}` : `${fallback} (${detail})`;
}

const TLS_HINT =
  "The TLS certificate could not be verified, which usually means a proxy is inspecting HTTPS traffic. Point NODE_EXTRA_CA_CERTS at your organization's root CA.";
const DNS_HINT = "DNS lookup failed. Check your network or VPN connection.";
const BLOCKED_HINT =
  "The connection was refused or reset, which usually means a firewall or proxy is blocking it.";
const TIMEOUT_HINT = "The connection timed out. A proxy or firewall may be dropping the request.";
const DEFAULT_HINT =
  "If you are behind a corporate proxy, note that Node does not use HTTPS_PROXY automatically.";

const CONNECTION_HINTS: Record<string, string> = {
  UNABLE_TO_VERIFY_LEAF_SIGNATURE: TLS_HINT,
  SELF_SIGNED_CERT_IN_CHAIN: TLS_HINT,
  DEPTH_ZERO_SELF_SIGNED_CERT: TLS_HINT,
  CERT_HAS_EXPIRED: TLS_HINT,
  ENOTFOUND: DNS_HINT,
  EAI_AGAIN: DNS_HINT,
  ECONNREFUSED: BLOCKED_HINT,
  ECONNRESET: BLOCKED_HINT,
  EHOSTUNREACH: BLOCKED_HINT,
  ENETUNREACH: BLOCKED_HINT,
  UND_ERR_CONNECT_TIMEOUT: TIMEOUT_HINT,
  ETIMEDOUT: TIMEOUT_HINT,
};

function getErrorCause(error: unknown): { code?: string; message?: string } {
  if (typeof error !== "object" || error === null || !("cause" in error)) return {};
  const cause = (error as { cause: unknown }).cause;
  if (typeof cause !== "object" || cause === null) return {};

  const { code, message } = cause as { code?: unknown; message?: unknown };
  return {
    code: typeof code === "string" ? code : undefined,
    message: typeof message === "string" ? message : undefined,
  };
}

function describeConnectionError(error: unknown, url: string): string {
  const { code, message } = getErrorCause(error);
  const detail = message || (error instanceof Error ? error.message : String(error));
  const hint = (code && CONNECTION_HINTS[code]) || DEFAULT_HINT;

  return `Could not reach ${url}: ${detail}${code ? ` (${code})` : ""}\n${hint}`;
}

async function postForm(url: string, params: URLSearchParams): Promise<Response> {
  try {
    return await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: params.toString(),
    });
  } catch (error) {
    throw new Error(describeConnectionError(error, url));
  }
}

async function oauthRequest<T>(url: string, params: URLSearchParams, fallback: string): Promise<T> {
  const response = await postForm(url, params);
  if (!response.ok) {
    throw new Error(await describeErrorResponse(response, fallback));
  }
  return (await response.json()) as T;
}

/** RFC 8628 §3.2 default poll interval when the server omits `interval`. */
export const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5;

export async function startDeviceAuthorization(
  baseUrl: string,
  clientId: string
): Promise<DeviceAuthorizationResponse> {
  // Hostname is shown on the server's verification page so the user can confirm
  // that the device they're authorizing matches the one running the CLI
  // (RFC 8628 §5.4 phishing resistance). Best-effort.
  const params = new URLSearchParams({ client_id: clientId });
  try {
    const hostname = os.hostname();
    if (hostname) params.set("hostname", hostname);
  } catch {
    // ignore
  }

  return oauthRequest<DeviceAuthorizationResponse>(
    `${baseUrl}/api/oauth/device/code`,
    params,
    "Failed to start device authorization"
  );
}

export interface PollDeviceTokenResult {
  status: "approved" | "pending" | "slow_down" | "denied" | "expired" | "transient";
  tokens?: TokenData;
  errorMessage?: string;
}

export async function pollDeviceToken(
  baseUrl: string,
  clientId: string,
  deviceCode: string
): Promise<PollDeviceTokenResult> {
  let response: Response;
  try {
    response = await postForm(
      `${baseUrl}/api/oauth/device/token`,
      new URLSearchParams({
        grant_type: DEVICE_CODE_GRANT,
        device_code: deviceCode,
        client_id: clientId,
      })
    );
  } catch (error) {
    // Network blip — keep polling.
    return {
      status: "transient",
      errorMessage: error instanceof Error ? error.message : "network error",
    };
  }

  if (response.ok) {
    const tokens = (await response.json()) as TokenData;
    return { status: "approved", tokens };
  }

  // Treat any 5xx as transient so a flaky backend doesn't end the user's session.
  if (response.status >= 500) {
    const err = (await response.json().catch(() => ({}))) as TokenErrorResponse;
    return {
      status: "transient",
      errorMessage: err.error_description || err.error || `HTTP ${response.status}`,
    };
  }

  const err = (await response.json().catch(() => ({}))) as TokenErrorResponse;
  switch (err.error) {
    case "authorization_pending":
      return { status: "pending" };
    case "slow_down":
      return { status: "slow_down" };
    case "access_denied":
      return { status: "denied" };
    case "expired_token":
      return { status: "expired" };
    default:
      throw new Error(err.error_description || err.error || "Device token poll failed");
  }
}


#!/usr/bin/env node
// caveman — shared configuration resolver
//
// Resolution order for default mode:
//   1. CAVEMAN_DEFAULT_MODE environment variable
//   2. Repo-local config (checked-in, per-project default):
//      - <cwd>/.caveman/config.json
//      - <cwd>/.caveman.json
//      Walks up from process.cwd() to the nearest ancestor containing one of
//      these (stops at filesystem root). Lets a team pin a project's default
//      mode without polluting every contributor's user-level config or env.
//   3. User config file defaultMode field:
//      - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set)
//      - ~/.config/caveman/config.json (macOS / Linux fallback)
//      - %APPDATA%\caveman\config.json (Windows fallback)
//   4. 'full'

const fs = require('fs');
const path = require('path');
const os = require('os');

const VALID_MODES = [
  'off', 'lite', 'full', 'ultra',
  'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra',
  'commit', 'review', 'compress'
];

function getConfigDir() {
  if (process.env.XDG_CONFIG_HOME) {
    return path.join(process.env.XDG_CONFIG_HOME, 'caveman');
  }
  if (process.platform === 'win32') {
    return path.join(
      process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
      'caveman'
    );
  }
  return path.join(os.homedir(), '.config', 'caveman');
}

function getConfigPath() {
  return path.join(getConfigDir(), 'config.json');
}

// Walk up from `start` looking for a repo-local caveman config. Returns the
// absolute path of the first match, or null. Stops at the filesystem root.
// Candidates per dir (first wins): .caveman/config.json, .caveman.json.
//
// Bounded to 64 levels to defend against symlink cycles on pathological mounts.
function findRepoConfigPath(start) {
  try {
    let dir = path.resolve(start || process.cwd());
    const candidates = ['.caveman/config.json', '.caveman.json'];
    for (let i = 0; i < 64; i++) {
      for (const rel of candidates) {
        const p = path.join(dir, rel);
        try {
          const st = fs.lstatSync(p);
          // Refuse symlinks — symmetric with safeWriteFlag/readFlag policy.
          if (st.isSymbolicLink() || !st.isFile()) continue;
          return p;
        } catch (e) {
          // not present, try next candidate
        }
      }
      const parent = path.dirname(dir);
      if (parent === dir) return null;
      dir = parent;
    }
  } catch (e) {
    // Defensive: any cwd / fs failure → no repo config
  }
  return null;
}

function readModeFromConfigFile(configPath) {
  try {
    const raw = fs.readFileSync(configPath, 'utf8');
    const config = JSON.parse(raw);
    if (config && config.defaultMode &&
        VALID_MODES.includes(String(config.defaultMode).toLowerCase())) {
      return String(config.defaultMode).toLowerCase();
    }
  } catch (e) {
    // Missing / unreadable / invalid JSON → caller falls through
  }
  return null;
}

function getDefaultMode() {
  // 1. Environment variable (highest priority)
  const envMode = process.env.CAVEMAN_DEFAULT_MODE;
  if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
    return envMode.toLowerCase();
  }

  // 2. Repo-local config (checked-in, per-project default)
  const repoConfigPath = findRepoConfigPath(process.cwd());
  if (repoConfigPath) {
    const repoMode = readModeFromConfigFile(repoConfigPath);
    if (repoMode) return repoMode;
  }

  // 3. User config file
  const userMode = readModeFromConfigFile(getConfigPath());
  if (userMode) return userMode;

  // 4. Default
  return 'full';
}

// Symlink-safe flag file write.
// Uses O_NOFOLLOW where available, writes atomically via temp + rename with
// 0600 permissions. Protects against local attackers replacing the predictable
// flag path (~/.claude/.caveman-active) with a symlink to clobber other files.
//
// When the parent directory is itself a symlink (legitimate pattern: ~/.claude
// symlinked to another drive or shared config dir), resolves through to the
// real path and verifies ownership on Unix (uid match). This allows e.g.
//   ln -s /opt/shared-claude-config ~/.claude
// while still refusing attacker-planted symlinks pointing to dirs owned by
// another user.
//
// On Windows, uid checks are unavailable — falls back to verifying the resolved
// path lives under the user's home directory.
//
// The flag file itself must never be a symlink (that's the actual clobber vector).
//
// Set CAVEMAN_DEBUG=1 to emit stderr diagnostics when flag writes are refused.
//
// Silent-fails on any filesystem error — the flag is best-effort.
function safeWriteFlag(flagPath, content) {
  const debug = process.env.CAVEMAN_DEBUG === '1';
  try {
    const flagDir = path.dirname(flagPath);
    fs.mkdirSync(flagDir, { recursive: true });

    // When the parent directory is a symlink, resolve it and verify ownership.
    // This allows legitimate symlinked ~/.claude dirs while still refusing
    // attacker-planted symlinks pointing at dirs owned by another user.
    let realFlagDir;
    try {
      const lstat = fs.lstatSync(flagDir);
      if (lstat.isSymbolicLink()) {
        realFlagDir = fs.realpathSync(flagDir);
        const realStat = fs.statSync(realFlagDir);
        if (!realStat.isDirectory()) {
          if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not a directory\n`);
          return;
        }
        if (typeof process.getuid === 'function') {
          if (realStat.uid !== process.getuid()) {
            if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`);
            return;
          }
        } else {
          const home = os.homedir();
          const normalizedReal = path.resolve(realFlagDir);
          const normalizedHome = path.resolve(home);
          if (!normalizedReal.toLowerCase().startsWith(normalizedHome.toLowerCase() + path.sep) &&
              normalizedReal.toLowerCase() !== normalizedHome.toLowerCase()) {
            if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${normalizedReal} is outside home directory ${normalizedHome}\n`);
            return;
          }
        }
      } else {
        realFlagDir = flagDir;
      }
    } catch (e) {
      return;
    }

    // The flag file itself must never be a symlink (that's the actual clobber vector).
    const realFlagPath = path.join(realFlagDir, path.basename(flagPath));
    try {
      if (fs.lstatSync(realFlagPath).isSymbolicLink()) return;
    } catch (e) {
      if (e.code !== 'ENOENT') return;
    }

    const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
    const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
    const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
    let fd;
    try {
      fd = fs.openSync(tempPath, flags, 0o600);
      fs.writeSync(fd, String(content));
      try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
    } finally {
      if (fd !== undefined) fs.closeSync(fd);
    }
    fs.renameSync(tempPath, realFlagPath);
  } catch (e) {
    // Silent fail — flag is best-effort
  }
}

// Symlink-safe, size-capped, whitelist-validated flag file read.
// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read,
// and rejects anything that isn't a known mode. Returns null on any anomaly.
//
// Without this, a local attacker with write access to ~/.claude/ could replace
// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every
// reader — statusline, per-turn reinforcement — would slurp that content and
// either echo it to the terminal or inject it into model context.
//
// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is "wenyan-ultra"
// (12 bytes); 64 leaves slack without enabling exfil.
const MAX_FLAG_BYTES = 64;

function readFlag(flagPath) {
  try {
    let st;
    try {
      st = fs.lstatSync(flagPath);
    } catch (e) {
      return null;
    }
    if (st.isSymbolicLink() || !st.isFile()) return null;
    if (st.size > MAX_FLAG_BYTES) return null;

    const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
    const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
    let fd;
    let out;
    try {
      fd = fs.openSync(flagPath, flags);
      const buf = Buffer.alloc(MAX_FLAG_BYTES);
      const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
      out = buf.slice(0, n).toString('utf8');
    } finally {
      if (fd !== undefined) fs.closeSync(fd);
    }

    const raw = out.trim().toLowerCase();
    if (!VALID_MODES.includes(raw)) return null;
    return raw;
  } catch (e) {
    return null;
  }
}

// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag,
// but opens with O_APPEND so concurrent writers from different sessions don't
// clobber each other. Used for the lifetime stats log
// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl).
//
// Silent-fails on any filesystem error.
function appendFlag(filePath, line) {
  const debug = process.env.CAVEMAN_DEBUG === '1';
  try {
    const dir = path.dirname(filePath);
    fs.mkdirSync(dir, { recursive: true });

    let realDir;
    try {
      const lstat = fs.lstatSync(dir);
      if (lstat.isSymbolicLink()) {
        realDir = fs.realpathSync(dir);
        const realStat = fs.statSync(realDir);
        if (!realStat.isDirectory()) return;
        if (typeof process.getuid === 'function') {
          if (realStat.uid !== process.getuid()) {
            if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`);
            return;
          }
        } else {
          const home = os.homedir();
          const normalized = path.resolve(realDir).toLowerCase();
          const normalizedHome = path.resolve(home).toLowerCase();
          if (!normalized.startsWith(normalizedHome + path.sep) && normalized !== normalizedHome) return;
        }
      } else {
        realDir = dir;
      }
    } catch (e) {
      return;
    }

    const realPath = path.join(realDir, path.basename(filePath));
    try {
      if (fs.lstatSync(realPath).isSymbolicLink()) return;
    } catch (e) {
      if (e.code !== 'ENOENT') return;
    }

    const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
    const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW;
    let fd;
    try {
      fd = fs.openSync(realPath, flags, 0o600);
      fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n');
      try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
    } finally {
      if (fd !== undefined) fs.closeSync(fd);
    }
  } catch (e) {
    // Silent fail — history is best-effort
  }
}

// Mode-transition log (#601). Whenever the active-mode flag actually changes,
// append {ts, mode, prev} to $CLAUDE_CONFIG_DIR/.caveman-mode-log.jsonl so
// caveman-stats can attribute output tokens to the mode that was active when
// each message was generated, instead of whatever mode the flag holds at
// stats time. mode/prev are a VALID_MODES string or null (null = caveman off).
// prev lets stats attribute messages that predate the first logged transition
// of a session. No-op when the mode is unchanged; best-effort like all flag IO.
const MODE_LOG_BASENAME = '.caveman-mode-log.jsonl';

function recordModeChange(claudeDir, newMode) {
  try {
    const current = readFlag(path.join(claudeDir, '.caveman-active'));
    const next = newMode || null;
    if ((current || null) === next) return;
    appendFlag(
      path.join(claudeDir, MODE_LOG_BASENAME),
      JSON.stringify({ ts: Date.now(), mode: next, prev: current || null })
    );
  } catch (e) {
    // Silent fail — the log is best-effort
  }
}

// Symlink-safe history read. Returns lines (untrimmed) or empty array on any
// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap
// the way readFlag does — history is expected to grow with use.
function readHistory(filePath) {
  try {
    const st = fs.lstatSync(filePath);
    if (st.isSymbolicLink() || !st.isFile()) return [];
    const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
    const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
    let fd;
    let raw;
    try {
      fd = fs.openSync(filePath, flags);
      raw = fs.readFileSync(fd, 'utf8');
    } finally {
      if (fd !== undefined) fs.closeSync(fd);
    }
    return raw.split('\n').filter(line => line.trim());
  } catch (e) {
    return [];
  }
}

module.exports = { getDefaultMode, getConfigDir, getConfigPath, findRepoConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory, recordModeChange, MODE_LOG_BASENAME };