Skill: leafbird/csharp-lsp (version 1.2.0)

--- name: csharp-lsp slug: csharp-lsp version: 1.2.0 description: "C# language server providing code intelligence, diagnostics, and navigation for .cs and .csx files. Uses csharp-ls (lightweight Roslyn-based). Requires .sln or .csproj." metadata: openclaw: emoji: "💜" requires: bins: ["dotnet"] os: ["linux", "darwin", "win32"] install: - id: dotnet kind: manual label: "Install .NET SDK (https://dot.net/download)" --- # C# LSP C# code intelligence via [csharp-ls](https://github.com/razzmatazz/csharp-language-server) — a lightweight Roslyn-based language server. ## Capabilities - **Go-to-definition**: Jump to symbol definitions across solution - **Find references**: Locate all usages of a symbol - **Hover info**: Type signatures, XML docs, parameter info - **Diagnostics**: Real-time compiler errors and warnings - **Document symbols**: List classes, methods, properties in a file - **Workspace symbol search**: Find symbols across the entire solution Supported extensions: `.cs`, `.csx` ## Prerequisites - **.NET SDK** (9.0+): https://dot.net/download - **Python 3**: Required to run the lsp-query daemon - **`.sln` or `.csproj`**: Project file is required (loose .cs files give limited results) ## Setup Run the one-time setup script (idempotent — safe to re-run): ```bash bash {baseDir}/scripts/setup.sh # setup only bash {baseDir}/scripts/setup.sh --verify # setup + verification test ``` What it does: 1. Checks for .NET SDK 2. Installs `csharp-ls` via `dotnet tool install --global` 3. Registers `~/.dotnet/tools` in PATH 4. Creates `lsp-query` symlink 5. Creates cache directory ## Usage ```bash # Set workspace to the directory containing .sln/.csproj export LSP_WORKSPACE=/path/to/project # Go to definition lsp-query definition src/Program.cs 15 8 # Find all references lsp-query references src/Models/User.cs 42 10 # Type info / hover lsp-query hover src/Services/AuthService.cs 30 22 # List symbols in a file lsp-query symbols src/Program.cs # Search symbols across workspace lsp-query workspace-symbols "UserService" # Compiler diagnostics lsp-query diagnostics src/Program.cs # Check running servers lsp-query servers # Shut down daemon lsp-query shutdown ``` Line/column numbers are **1-indexed**. ## Architecture ``` lsp-query CLI → Unix Socket → lsp-query daemon (Python) ↓ csharp-ls (subprocess, stdin/stdout JSON-RPC) ↓ Roslyn (.sln → full type system) ``` - **Daemon**: Auto-forks on first call. Shuts down after 5 min idle. - **csharp-ls**: Starts on first C# query. Shuts down after 5 min idle. - **Cold start**: Solution loading takes 30–60s for large projects. Subsequent queries ~200ms. ## Project Detection 1. **Solution file** (`.sln`) — best: enables cross-project references 2. **Project file** (`.csproj`) — good: single project analysis 3. **Loose `.cs` files** — limited: basic syntax only ## What's Included ``` {baseDir}/ ├── SKILL.md # This file └── scripts/ ├── setup.sh # One-time setup (idempotent) └── lsp-query.py # LSP daemon + CLI (self-contained) ``` ## Troubleshooting - **Install fails (`DotnetToolSettings.xml`)**: Pin version with `dotnet tool install --global csharp-ls --version 0.20.0` - **Empty results**: Check that `LSP_WORKSPACE` points to a directory with `.sln` or `.csproj` - **Slow first query**: Normal — Roslyn project loading takes 30–60s for large solutions - **PATH issues**: Add `export PATH="$PATH:$HOME/.dotnet/tools"` to your shell profile - **Stale daemon**: Run `lsp-query shutdown` and retry ## Links - [csharp-ls on GitHub](https://github.com/razzmatazz/csharp-language-server) - [.NET SDK Downloads](https://dot.net/download) - [LSP Specification](https://microsoft.github.io/language-server-protocol/)

path: CHANGELOG.md
content: # Changelog

## [1.2.0] - 2026-03-05

### Changed
- SKILL.md rewritten in English (was Korean)
- Added ClawHub badge to README

### Added
- GitHub Actions workflow for automated ClawHub publish on tag push
- ClawHub install option in README

## [1.1.0] - 2026-03-05

### Changed
- Removed OmniSharp support — now csharp-ls only
- Self-contained: no longer depends on base `lsp` skill
- Rewrote SKILL.md for csharp-ls single-server focus

### Added
- `setup.sh` — idempotent one-time setup script with `--verify` option
- Docker-based clean environment testing
- `LSP_CSHARP_SERVER` env var removed (csharp-ls hardcoded)

### Fixed
- Server-to-client request handling (`client/registerCapability`, `workspace/configuration`, `window/workDoneProgress/create`)
- Solution loading wait logic (diagnostics-based, up to 30s)
- PATH detection for `~/.dotnet/tools`

## [1.0.0] - 2026-03-05

### Added
- Initial release
- csharp-ls integration via lsp-query daemon
- Hover, definition, references, symbols, diagnostics, workspace-symbols
- Daemon auto-fork on first call, 5-min idle shutdown
- Support for .sln and .csproj project detection


path: README.md
content: # OpenClaw Skill: csharp-lsp

[![CI](https://github.com/leafbird/openclaw-csharp-lsp/actions/workflows/test.yml/badge.svg)](https://github.com/leafbird/openclaw-csharp-lsp/actions)
[![ClawHub](https://img.shields.io/badge/ClawHub-csharp--lsp-7c3aed)](https://clawhub.ai/skills/csharp-lsp)
[![Version](https://img.shields.io/github/v/tag/leafbird/openclaw-csharp-lsp?label=version&sort=semver)](https://github.com/leafbird/openclaw-csharp-lsp/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![.NET](https://img.shields.io/badge/.NET-9.0+-512BD4)](https://dot.net/download)
[![csharp-ls](https://img.shields.io/badge/LSP-csharp--ls-68217A)](https://github.com/razzmatazz/csharp-language-server)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-lightgrey)]()

C# code intelligence for [OpenClaw](https://github.com/openclaw/openclaw) agents — powered by [csharp-ls](https://github.com/razzmatazz/csharp-language-server), a lightweight Roslyn-based language server.

> Go-to-definition, find references, hover, diagnostics, symbol search — all from the command line.

## Features

- **Go-to-definition** — jump to symbol definitions across entire solution
- **Find references** — locate all usages of a symbol (200+ refs in seconds)
- **Hover info** — type signatures, XML docs, parameter info
- **Diagnostics** — real-time compiler errors and warnings
- **Document symbols** — list classes, methods, properties in a file
- **Workspace symbol search** — find symbols across the entire solution
- **Daemon architecture** — persistent background process, ~200ms per query after cold start

## Quick Start

### Prerequisites

- [.NET SDK](https://dot.net/download) 9.0+
- Python 3
- A C# project with `.sln` or `.csproj`

### Install

**Option A: ClawHub** (recommended for OpenClaw users)

```bash
clawhub install csharp-lsp
bash skills/csharp-lsp/scripts/setup.sh --verify
```

**Option B: GitHub**

```bash
git clone https://github.com/leafbird/openclaw-csharp-lsp.git skills/csharp-lsp
bash skills/csharp-lsp/scripts/setup.sh --verify
```

The setup script is **idempotent** — safe to run multiple times.

### Use

```bash
export LSP_WORKSPACE=/path/to/your/solution

lsp-query hover     src/Program.cs 15 8      # type info
lsp-query definition src/Program.cs 15 8     # go to definition
lsp-query references src/Models/User.cs 42 10 # find all references
lsp-query symbols   src/Program.cs            # list symbols
lsp-query workspace-symbols "UserService"     # search workspace
lsp-query diagnostics src/Program.cs          # compiler errors
```

Line/column numbers are **1-indexed**.

## How It Works

```
lsp-query CLI ──Unix Socket──▸ lsp-query daemon (Python)
                                     │
                                     ▼
                               csharp-ls (subprocess)
                                     │
                                     ▼
                               Roslyn engine (.sln → full type system)
```

1. First `lsp-query` call auto-forks a background daemon
2. Daemon lazy-starts `csharp-ls` on first C# query
3. Roslyn loads the solution (30–60s for large projects)
4. Subsequent queries: **~200ms**
5. Both daemon and server auto-shutdown after 5 min idle

See [docs/architecture.md](docs/architecture.md) for details.

## Project Structure

```
csharp-lsp/
├── README.md
├── SKILL.md              # OpenClaw skill descriptor
├── CHANGELOG.md
├── LICENSE
├── scripts/
│   ├── setup.sh          # One-time setup (idempotent)
│   └── lsp-query.py      # LSP daemon + CLI (self-contained)
├── tests/
│   ├── Dockerfile
│   └── test.sh           # Docker-based integration tests
├── docs/
│   ├── architecture.md
│   ├── deployment.md
│   └── troubleshooting.md
└── .github/
    └── workflows/
        └── test.yml      # CI: auto-test on push/PR
```

## Testing

Run the full test suite in a clean Docker environment:

```bash
bash tests/test.sh
```

Tests verify: clean install, hover, definition, references, symbols, and idempotency.

## Troubleshooting

See [docs/troubleshooting.md](docs/troubleshooting.md) for common issues.

Quick fixes:
- **Empty results?** — Check `LSP_WORKSPACE` points to a dir with `.sln`/`.csproj`
- **Slow first query?** — Normal. Roslyn loading takes 30–60s. After that, ~200ms.
- **Stale daemon?** — `lsp-query shutdown` and retry

## License

MIT

## Links

- [csharp-ls](https://github.com/razzmatazz/csharp-language-server) — the language server
- [OpenClaw](https://github.com/openclaw/openclaw) — AI agent framework
- [LSP Specification](https://microsoft.github.io/language-server-protocol/)


path: docs/architecture.md
content: # Architecture

## Overview

csharp-lsp provides C# code intelligence through a three-layer architecture:

```
┌─────────────┐     Unix Socket     ┌──────────────────┐     stdin/stdout     ┌───────────┐
│ lsp-query   │ ──────────────────▸ │ lsp-query daemon │ ─────────────────▸  │ csharp-ls │
│ CLI         │                     │ (Python)         │   JSON-RPC (LSP)    │ (Roslyn)  │
└─────────────┘                     └──────────────────┘                     └───────────┘
  one-shot                            persistent                              persistent
  process                             background                              subprocess
```

## Components

### lsp-query CLI

The user-facing command. Each invocation:

1. Checks if daemon is running (via pid file)
2. If not, forks a new daemon process (`fork()` + `setsid()`)
3. Connects to daemon via Unix socket (`~/.cache/lsp-query/daemon.sock`)
4. Sends JSON request, receives JSON response, prints result, exits

### lsp-query Daemon (MultiLangDaemon)

A single Python process managing LSP clients for multiple languages. For C#:

1. Receives query from CLI via Unix socket
2. Determines language from file extension (`.cs` → csharp)
3. Lazy-starts `csharp-ls` subprocess if not already running
4. Forwards LSP request, collects response
5. Returns result to CLI

**Lifecycle:**
- Created on first `lsp-query` call (auto-fork)
- Stays alive serving subsequent requests
- Shuts down after 5 minutes of inactivity (configurable via `LSP_TIMEOUT`)

### csharp-ls (Language Server)

Roslyn-based C# language server running as a subprocess:

1. Communicates via stdin/stdout using JSON-RPC (LSP protocol)
2. On startup, loads the solution file (`.sln`) and builds the full type system
3. Responds to LSP requests: hover, definition, references, symbols, diagnostics

**Key behavior:**
- **Initialize handshake**: Daemon sends `rootUri` (solution path). Server responds with capabilities.
- **Server-to-client requests**: csharp-ls sends `client/registerCapability`, `workspace/configuration`, `window/workDoneProgress/create` — daemon responds with `result: null` to unblock.
- **Solution loading**: Roslyn compiles all `.csproj` → full type system. Takes 30–60s for large solutions.
- **Diagnostics signal**: `publishDiagnostics` notification indicates loading is complete.

## Communication Flow

```
CLI                    Daemon                   csharp-ls
 │                       │                         │
 │──── connect ─────────▸│                         │
 │                       │──── spawn ─────────────▸│
 │                       │◂──── initialize ───────▸│
 │                       │◂── registerCapability ──│
 │                       │── result: null ────────▸│
 │                       │◂── workspace/config ────│
 │                       │── result: null ────────▸│
 │                       │◂── publishDiagnostics ──│ (loading done)
 │                       │                         │
 │── hover request ─────▸│                         │
 │                       │── textDocument/hover ──▸│
 │                       │◂── hover result ────────│
 │◂── response ──────────│                         │
 │                       │                         │
 │                       │   (5 min idle)          │
 │                       │── shutdown ────────────▸│
 │                       │                         ✕
 │                       │   (5 min idle)
 │                       ✕
```

## File Locations

| File | Purpose |
|------|---------|
| `~/.cache/lsp-query/daemon.sock` | Unix socket for CLI↔daemon |
| `~/.cache/lsp-query/daemon.sock.pid` | Daemon PID file |
| `/tmp/lsp-query-debug.log` | Debug log (when `LSP_DEBUG=1`) |
| `~/.dotnet/tools/csharp-ls` | Language server binary |

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `LSP_WORKSPACE` | current dir | Root directory containing `.sln`/`.csproj` |
| `LSP_TIMEOUT` | `300` | Idle timeout in seconds before server shutdown |
| `LSP_DEBUG` | (unset) | Set to `1` for debug logging to `/tmp/lsp-query-debug.log` |


path: docs/deployment.md
content: # Deployment Pipeline

## Overview

```
[개발]              [검증]              [배포]
skills/csharp-lsp   bash tests/test.sh  git tag + push
에서 수정           Docker 테스트        GitHub release
```

## Workflow

### 1. 개발

`skills/csharp-lsp/` 디렉토리가 곧 git working copy.
수정 사항은 여기서 직접 편집하고, 즉시 실사용 가능 (심볼릭 링크가 이 경로를 가리킴).

```bash
# 수정
vim scripts/lsp-query.py

# 로컬에서 바로 동작 확인
LSP_WORKSPACE=/path/to/project lsp-query hover file.cs 10 20
```

### 2. 배포 전 검증

Docker 클린 환경에서 자동 테스트:

```bash
bash tests/test.sh
```

검증 항목:
- 클린 환경 설치 (setup.sh)
- 기능 테스트 (hover, definition, references, symbols)
- 멱등성 (setup.sh 재실행)

### 3. 배포

```bash
# 버전 업데이트
# - SKILL.md의 version 필드
# - CHANGELOG.md에 변경 내역 추가

git add -A
git commit -m "feat: 변경 내용 설명"
git tag v1.2.0
git push origin main --tags
```

GitHub Actions CI가 push 시 자동으로 테스트를 실행합니다.

### 4. 배포본 전환

**별도 전환 작업이 필요 없습니다.**

`skills/csharp-lsp/` 자체가 git repo이므로, push한 시점의 코드가 곧 배포본입니다.
다른 사용자는 `git clone`으로 동일한 코드를 받습니다.

## Branch 전략 (선택)

현재는 `main` 단일 브랜치로 운영.
필요 시 `dev` 브랜치를 분리할 수 있습니다:

| 브랜치 | 용도 |
|--------|------|
| `main` | 검증 완료된 안정본 |
| `dev`  | 작업 중 수정사항 |

```bash
# dev에서 작업
git checkout dev
# ... 수정 ...
bash tests/test.sh  # 테스트

# main에 머지
git checkout main
git merge dev
git tag v1.2.0
git push origin main --tags

# 다시 dev로
git checkout dev
```

브랜치 전환만으로 실사용 버전이 변경됩니다 (심볼릭 링크 경로 불변).

## Version Numbering

[Semantic Versioning](https://semver.org/) 사용:

- **MAJOR**: 호환성 깨지는 변경 (LSP 프로토콜 변경, CLI 인터페이스 변경 등)
- **MINOR**: 기능 추가 (새 쿼리 타입, 새 언어 지원 등)
- **PATCH**: 버그 수정, 문서 수정

## CI (GitHub Actions)

`.github/workflows/test.yml`이 push/PR 시 자동 실행:

1. Docker 이미지 빌드 (`mcr.microsoft.com/dotnet/sdk:9.0` + Python3)
2. `setup.sh` 실행
3. 기능 테스트 (hover, definition, references, symbols)
4. 멱등성 테스트 (setup.sh 재실행)


path: docs/troubleshooting.md
content: # Troubleshooting

## Common Issues

### Empty results from hover / definition / references

**Cause**: `LSP_WORKSPACE` not set or not pointing to a directory with `.sln`/`.csproj`.

```bash
# Check
echo $LSP_WORKSPACE
ls $LSP_WORKSPACE/*.sln

# Fix
export LSP_WORKSPACE=/path/to/solution/directory
```

### First query is very slow (30–60 seconds)

**Normal behavior.** Roslyn needs to load the entire solution and build the type system on first use. Subsequent queries are ~200ms.

For large solutions (100+ projects), initial load can take up to 60 seconds.

### "csharp-ls: command not found"

**Cause**: `~/.dotnet/tools` not in PATH.

```bash
export PATH="$HOME/.dotnet/tools:$PATH"

# Permanent fix
echo 'export PATH="$HOME/.dotnet/tools:$PATH"' >> ~/.bashrc
```

Or re-run setup:
```bash
bash scripts/setup.sh
```

### Stale daemon (old code still running)

**Cause**: Daemon was started before code changes and is still running the old version.

```bash
# Kill daemon
lsp-query shutdown

# Clean up
rm -f ~/.cache/lsp-query/daemon.sock ~/.cache/lsp-query/daemon.sock.pid

# Next lsp-query call will start fresh daemon
```

### "dotnet tool install fails with DotnetToolSettings.xml"

**Cause**: Some csharp-ls versions have packaging issues.

```bash
# Pin to known-good version
dotnet tool install --global csharp-ls --version 0.20.0
```

### No symbols in Unity project

**Cause**: Unity projects don't commit `.sln`/`.csproj` to version control. These are generated locally by Unity Editor.

**Options**:
1. Open the project in Unity Editor → "Regenerate project files" → commit the generated files
2. Use text search (`rg`) as fallback for Unity code

### Socket connection refused

**Cause**: Daemon died but left socket/pid files behind.

```bash
rm -f ~/.cache/lsp-query/daemon.sock ~/.cache/lsp-query/daemon.sock.pid
lsp-query hover your_file.cs 1 1  # Will start fresh daemon
```

## Debug Mode

Enable detailed logging:

```bash
export LSP_DEBUG=1
lsp-query hover file.cs 10 20

# Check log
cat /tmp/lsp-query-debug.log
```

Log includes:
- All JSON-RPC messages sent/received
- Server-to-client requests and responses
- Timing information

## Health Check

```bash
# Check if daemon is running
lsp-query servers

# Check csharp-ls binary
csharp-ls --version

# Check .NET SDK
dotnet --version

# Full verify
bash scripts/setup.sh --verify
```


path: scripts/lsp-query.py
content: #!/usr/bin/env python3
"""
Multi-language LSP client for AI agent code navigation.

Manages per-language background daemons (pyright for Python, typescript-language-server
for JS/TS, rust-analyzer for Rust, gopls for Go, clangd for C/C++, bash-language-server
for shell scripts). Auto-detects language from file extension and routes to the correct
daemon. Each daemon idles out independently after a configurable timeout.

Usage:
    lsp-query definition <file> <line> <col>
    lsp-query references <file> <line> <col>
    lsp-query hover <file> <line> <col>
    lsp-query symbols <file>
    lsp-query diagnostics <file>
    lsp-query completions <file> <line> <col>
    lsp-query signature <file> <line> <col>
    lsp-query rename <file> <line> <col> <new_name>
    lsp-query workspace-symbols <query>
    lsp-query servers                              List running language daemons
    lsp-query shutdown [language]                   Stop one or all daemons
    lsp-query languages                             Show supported languages

Lines and columns are 1-indexed (human-friendly).

Environment:
    LSP_WORKSPACE   Override workspace root (default: git root or cwd)
    LSP_SERVER      Override LSP server command for ALL languages
    LSP_TIMEOUT     Server idle timeout in seconds (default: 300)
    LSP_LANG        Force a specific language (bypass auto-detection)
"""

import json
import os
import socket
import subprocess
import sys
import threading
import time
import signal
import shutil
from pathlib import Path


# ---------------------------------------------------------------------------
# Language configuration
# ---------------------------------------------------------------------------

LANGUAGES = {
    "csharp": {
        "extensions": {".cs", ".csx"},
        "server_cmd": ["csharp-ls"],
        "lang_id": "csharp",
        "install": "dotnet tool install --global csharp-ls",
    },
    "python": {
        "extensions": {".py", ".pyi", ".pyx"},
        "server_cmd": ["pyright-langserver", "--stdio"],
        "lang_id": "python",
        "install": "npm install -g pyright",
    },
    "typescript": {
        "extensions": {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"},
        "server_cmd": ["typescript-language-server", "--stdio"],
        "lang_id_map": {
            ".ts": "typescript",
            ".tsx": "typescriptreact",
            ".js": "javascript",
            ".jsx": "javascriptreact",
            ".mjs": "javascript",
            ".cjs": "javascript",
        },
        "lang_id": "typescript",
        "install": "npm install -g typescript-language-server typescript",
    },
    "rust": {
        "extensions": {".rs"},
        "server_cmd": ["rust-analyzer"],
        "lang_id": "rust",
        "install": "rustup component add rust-analyzer",
    },
    "go": {
        "extensions": {".go"},
        "server_cmd": ["gopls", "serve"],
        "lang_id": "go",
        "install": "go install golang.org/x/tools/gopls@latest",
    },
    "c": {
        "extensions": {".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx", ".hh"},
        "server_cmd": ["clangd"],
        "lang_id_map": {
            ".c": "c", ".h": "c",
            ".cpp": "cpp", ".cxx": "cpp", ".cc": "cpp",
            ".hpp": "cpp", ".hxx": "cpp", ".hh": "cpp",
        },
        "lang_id": "c",
        "install": "apt install clangd  # or brew install llvm",
    },
    "bash": {
        "extensions": {".sh", ".bash", ".zsh"},
        "server_cmd": ["bash-language-server", "start"],
        "lang_id": "shellscript",
        "install": "npm install -g bash-language-server",
    },
    "java": {
        "extensions": {".java"},
        "server_cmd": ["jdtls"],
        "lang_id": "java",
        "install": "https://github.com/eclipse-jdtls/eclipse.jdt.ls#installation",
    },
    "css": {
        "extensions": {".css", ".scss", ".less"},
        "server_cmd": ["vscode-css-language-server", "--stdio"],
        "lang_id_map": {
            ".css": "css", ".scss": "scss", ".less": "less",
        },
        "lang_id": "css",
        "install": "npm install -g vscode-langservers-extracted",
    },
    "html": {
        "extensions": {".html", ".htm"},
        "server_cmd": ["vscode-html-language-server", "--stdio"],
        "lang_id": "html",
        "install": "npm install -g vscode-langservers-extracted",
    },
    "json": {
        "extensions": {".json", ".jsonc"},
        "server_cmd": ["vscode-json-language-server", "--stdio"],
        "lang_id": "json",
        "install": "npm install -g vscode-langservers-extracted",
    },
}

# Build extension -> language lookup.
_EXT_TO_LANG = {}
for _lang, _cfg in LANGUAGES.items():
    for _ext in _cfg["extensions"]:
        _EXT_TO_LANG[_ext] = _lang


def detect_language(filepath):
    """Detect language from file extension."""
    ext = Path(filepath).suffix.lower()
    return _EXT_TO_LANG.get(ext)


def get_lang_id(filepath, lang):
    """Get the LSP languageId for a specific file."""
    cfg = LANGUAGES.get(lang, {})
    ext = Path(filepath).suffix.lower()
    lang_id_map = cfg.get("lang_id_map", {})
    return lang_id_map.get(ext, cfg.get("lang_id", lang))


def get_server_cmd(lang):
    """Get the server command for a language, checking if the binary exists."""
    override = os.environ.get("LSP_SERVER")
    if override:
        return override.split()
    cfg = LANGUAGES.get(lang)
    if not cfg:
        return None
    cmd = cfg["server_cmd"]
    binary = cmd[0]
    if shutil.which(binary):
        return cmd
    return None


# ---------------------------------------------------------------------------
# JSON-RPC / LSP protocol helpers
# ---------------------------------------------------------------------------

def encode_msg(obj):
    body = json.dumps(obj).encode("utf-8")
    header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
    return header + body


def read_msg(stream):
    """Read one LSP JSON-RPC message from a byte stream."""
    headers = {}
    while True:
        line = b""
        while not line.endswith(b"\r\n"):
            ch = stream.read(1)
            if not ch:
                return None
            line += ch
        line = line.strip()
        if not line:
            break
        k, v = line.decode("ascii").split(":", 1)
        headers[k.strip().lower()] = v.strip()
    length = int(headers.get("content-length", 0))
    if length == 0:
        return None
    body = b""
    while len(body) < length:
        chunk = stream.read(length - len(body))
        if not chunk:
            return None
        body += chunk
    return json.loads(body.decode("utf-8"))


def uri(path):
    p = os.path.abspath(path)
    return "file://" + p


def path_from_uri(u):
    if u.startswith("file://"):
        return u[7:]
    return u


# ---------------------------------------------------------------------------
# LSP Client (manages a single language server subprocess)
# ---------------------------------------------------------------------------

class LSPClient:
    def __init__(self, workspace, server_cmd, lang="python"):
        self.workspace = os.path.abspath(workspace)
        self.server_cmd = server_cmd
        self.lang = lang
        self._id = 0
        self._pending = {}
        self._lock = threading.Lock()
        self._proc = None
        self._reader_thread = None
        self._initialized = False
        self._open_files = set()
        self._diagnostics = {}

    def start(self):
        self._proc = subprocess.Popen(
            self.server_cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
        self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
        self._reader_thread.start()
        self._initialize()

    def _reader_loop(self):
        while self._proc and self._proc.poll() is None:
            try:
                msg = read_msg(self._proc.stdout)
            except Exception:
                break
            if msg is None:
                break
            # Server-to-client request (has both "method" and "id")
            # e.g. client/registerCapability, window/workDoneProgress/create
            if "method" in msg and "id" in msg:
                self._handle_server_request(msg)
                continue
            if "method" in msg and "id" not in msg:
                self._handle_notification(msg)
                continue
            msg_id = msg.get("id")
            if msg_id is not None:
                with self._lock:
                    ev = self._pending.pop(msg_id, None)
                if ev:
                    ev["result"] = msg
                    ev["event"].set()

    def _handle_server_request(self, msg):
        """Respond to server-to-client requests (e.g. client/registerCapability)."""
        rid = msg["id"]
        method = msg.get("method", "")
        with open("/tmp/lsp-query-debug.log", "a") as _dbg:
            _dbg.write(f"[srv-req] {method} id={rid}\n")
        response = {"jsonrpc": "2.0", "id": rid, "result": None}
        try:
            data = encode_msg(response)
            with self._lock:
                self._proc.stdin.write(data)
                self._proc.stdin.flush()
        except Exception as e:
            with open("/tmp/lsp-query-debug.log", "a") as _dbg:
                _dbg.write(f"[srv-req-err] {method}: {e}\n")

    def _handle_notification(self, msg):
        method = msg.get("method", "")
        with open("/tmp/lsp-query-debug.log", "a") as _dbg:
            _dbg.write(f"[notif] {method}\n")
            if method == "textDocument/publishDiagnostics":
                _dbg.write(f"  uri={msg.get('params',{}).get('uri','')}\n")
        if method == "textDocument/publishDiagnostics":
            params = msg.get("params", {})
            self._diagnostics[params.get("uri", "")] = params.get("diagnostics", [])

    def _send_request(self, method, params, timeout=30):
        with self._lock:
            self._id += 1
            rid = self._id
        msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
        ev = {"event": threading.Event(), "result": None}
        with self._lock:
            self._pending[rid] = ev
            self._proc.stdin.write(encode_msg(msg))
            self._proc.stdin.flush()
        ev["event"].wait(timeout=timeout)
        return ev["result"]

    def _send_notification(self, method, params):
        msg = {"jsonrpc": "2.0", "method": method, "params": params}
        with self._lock:
            self._proc.stdin.write(encode_msg(msg))
            self._proc.stdin.flush()

    def _initialize(self):
        result = self._send_request("initialize", {
            "processId": os.getpid(),
            "rootUri": uri(self.workspace),
            "rootPath": self.workspace,
            "capabilities": {
                "textDocument": {
                    "definition": {"dynamicRegistration": False},
                    "references": {"dynamicRegistration": False},
                    "hover": {"contentFormat": ["plaintext", "markdown"]},
                    "completion": {"completionItem": {"snippetSupport": False}},
                    "signatureHelp": {"signatureInformation": {"parameterInformation": {"labelOffsetSupport": True}}},
                    "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
                    "rename": {"prepareSupport": True},
                    "publishDiagnostics": {"relatedInformation": True},
                },
                "workspace": {
                    "symbol": {"dynamicRegistration": False},
                },
            },
        }, timeout=60)
        self._send_notification("initialized", {})
        self._initialized = True
        return result

    def _open_file(self, filepath):
        furi = uri(filepath)
        if furi in self._open_files:
            return furi
        try:
            text = Path(filepath).read_text(encoding="utf-8", errors="replace")
        except FileNotFoundError:
            return furi
        lang_id = get_lang_id(filepath, self.lang)
        self._send_notification("textDocument/didOpen", {
            "textDocument": {
                "uri": furi,
                "languageId": lang_id,
                "version": 1,
                "text": text,
            }
        })
        self._open_files.add(furi)
        # Wait for server to process the file. Roslyn-based servers (e.g. csharp-ls)
        # need time to load the project and compile. Wait until diagnostics arrive
        # for this file, or up to a timeout.
        deadline = time.time() + 30
        while time.time() < deadline:
            if furi in self._diagnostics:
                break
            time.sleep(0.5)
        else:
            # Even if no diagnostics, give a minimum settling time
            time.sleep(1)
        return furi

    def definition(self, filepath, line, col):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/definition", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
        })
        return self._format_locations(resp)

    def references(self, filepath, line, col):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/references", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
            "context": {"includeDeclaration": True},
        })
        return self._format_locations(resp)

    def hover(self, filepath, line, col):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/hover", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
        })
        if not resp or "result" not in resp:
            return "No hover info"
        result = resp["result"]
        if result is None:
            return "No hover info"
        contents = result.get("contents", "")
        if isinstance(contents, dict):
            return contents.get("value", str(contents))
        if isinstance(contents, list):
            parts = []
            for c in contents:
                parts.append(c.get("value", str(c)) if isinstance(c, dict) else str(c))
            return "\n".join(parts)
        return str(contents)

    def symbols(self, filepath):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/documentSymbol", {
            "textDocument": {"uri": furi},
        })
        if not resp or "result" not in resp:
            return "No symbols"
        return self._format_symbols(resp["result"], filepath)

    def workspace_symbols(self, query):
        resp = self._send_request("workspace/symbol", {"query": query})
        if not resp or "result" not in resp:
            return "No symbols found"
        result = resp["result"]
        if not result:
            return "No symbols found"
        lines = []
        for sym in result:
            loc = sym.get("location", {})
            fpath = path_from_uri(loc.get("uri", ""))
            rng = loc.get("range", {}).get("start", {})
            ln = rng.get("line", 0) + 1
            kind = _SYMBOL_KINDS.get(sym.get("kind", 0), "unknown")
            container = sym.get("containerName", "")
            prefix = f"{container}." if container else ""
            lines.append(f"  {kind:12s} {prefix}{sym['name']:30s} {fpath}:{ln}")
        return "\n".join(lines)

    def diagnostics(self, filepath):
        furi = self._open_file(filepath)
        time.sleep(1.0)
        diags = self._diagnostics.get(furi, [])
        if not diags:
            return "No diagnostics"
        lines = []
        for d in diags:
            rng = d.get("range", {}).get("start", {})
            ln = rng.get("line", 0) + 1
            col = rng.get("character", 0) + 1
            sev = {1: "error", 2: "warning", 3: "info", 4: "hint"}.get(d.get("severity", 3), "info")
            msg = d.get("message", "")
            rule = d.get("code", "")
            lines.append(f"  {sev:8s} L{ln}:{col:3d}  {msg}" + (f"  [{rule}]" if rule else ""))
        return "\n".join(lines)

    def completions(self, filepath, line, col):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/completion", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
        })
        if not resp or "result" not in resp:
            return "No completions"
        result = resp["result"]
        items = result.get("items", []) if isinstance(result, dict) else result
        if not items:
            return "No completions"
        lines = []
        for item in items[:20]:
            kind = _COMPLETION_KINDS.get(item.get("kind", 0), "")
            detail = item.get("detail", "")
            label = item.get("label", "")
            lines.append(f"  {kind:12s} {label:30s} {detail}")
        if len(items) > 20:
            lines.append(f"  ... and {len(items) - 20} more")
        return "\n".join(lines)

    def signature(self, filepath, line, col):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/signatureHelp", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
        })
        if not resp or "result" not in resp or resp["result"] is None:
            return "No signature info"
        result = resp["result"]
        sigs = result.get("signatures", [])
        if not sigs:
            return "No signature info"
        lines = []
        active = result.get("activeSignature", 0)
        for i, sig in enumerate(sigs):
            marker = ">> " if i == active else "   "
            label = sig.get("label", "")
            doc = sig.get("documentation", "")
            if isinstance(doc, dict):
                doc = doc.get("value", "")
            lines.append(f"{marker}{label}")
            if doc:
                lines.append(f"      {doc[:200]}")
        return "\n".join(lines)

    def rename(self, filepath, line, col, new_name):
        furi = self._open_file(filepath)
        resp = self._send_request("textDocument/rename", {
            "textDocument": {"uri": furi},
            "position": {"line": line, "character": col},
            "newName": new_name,
        })
        if not resp or "result" not in resp or resp["result"] is None:
            return "Rename not available"
        result = resp["result"]
        changes = result.get("changes", {})
        if not changes:
            doc_changes = result.get("documentChanges", [])
            if not doc_changes:
                return "No changes"
            lines = []
            for dc in doc_changes:
                fpath = path_from_uri(dc.get("textDocument", {}).get("uri", ""))
                edits = dc.get("edits", [])
                lines.append(f"  {fpath}: {len(edits)} edit(s)")
                for e in edits[:10]:
                    rng = e.get("range", {})
                    start = rng.get("start", {})
                    lines.append(f"    L{start.get('line',0)+1}:{start.get('character',0)+1} -> {e.get('newText','')!r}")
            return "\n".join(lines)
        lines = []
        for furi_key, edits in changes.items():
            fpath = path_from_uri(furi_key)
            lines.append(f"  {fpath}: {len(edits)} edit(s)")
            for e in edits[:10]:
                rng = e.get("range", {})
                start = rng.get("start", {})
                lines.append(f"    L{start.get('line',0)+1}:{start.get('character',0)+1} -> {e.get('newText','')!r}")
        return "\n".join(lines)

    def shutdown(self):
        if self._proc and self._proc.poll() is None:
            try:
                self._send_request("shutdown", {}, timeout=5)
                self._send_notification("exit", {})
                self._proc.wait(timeout=5)
            except Exception:
                self._proc.kill()
        self._initialized = False

    def _format_locations(self, resp):
        if not resp or "result" not in resp:
            return "Not found"
        result = resp["result"]
        if result is None:
            return "Not found"
        if isinstance(result, dict):
            result = [result]
        if not result:
            return "Not found"
        lines = []
        for loc in result:
            if "targetUri" in loc:
                fpath = path_from_uri(loc["targetUri"])
                rng = loc.get("targetSelectionRange", loc.get("targetRange", {})).get("start", {})
            else:
                fpath = path_from_uri(loc.get("uri", ""))
                rng = loc.get("range", {}).get("start", {})
            ln = rng.get("line", 0) + 1
            col = rng.get("character", 0) + 1
            try:
                fpath = os.path.relpath(fpath, self.workspace)
            except ValueError:
                pass
            lines.append(f"  {fpath}:{ln}:{col}")
        return "\n".join(lines)

    def _format_symbols(self, symbols, filepath, indent=0):
        if not symbols:
            return "No symbols"
        lines = []
        prefix = "  " * indent
        for sym in symbols:
            name = sym.get("name", "")
            kind = _SYMBOL_KINDS.get(sym.get("kind", 0), "unknown")
            rng = sym.get("selectionRange", sym.get("range", {}))
            start = rng.get("start", {})
            ln = start.get("line", 0) + 1
            detail = sym.get("detail", "")
            detail_str = f"  ({detail})" if detail else ""
            lines.append(f"{prefix}  {kind:12s} {name:30s} L{ln}{detail_str}")
            children = sym.get("children", [])
            if children:
                lines.append(self._format_symbols(children, filepath, indent + 1))
        return "\n".join(lines)


_SYMBOL_KINDS = {
    1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class",
    6: "method", 7: "property", 8: "field", 9: "constructor", 10: "enum",
    11: "interface", 12: "function", 13: "variable", 14: "constant",
    15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object",
    20: "key", 21: "null", 22: "enummember", 23: "struct", 24: "event",
    25: "operator", 26: "typeparam",
}

_COMPLETION_KINDS = {
    1: "text", 2: "method", 3: "function", 4: "constructor", 5: "field",
    6: "variable", 7: "class", 8: "interface", 9: "module", 10: "property",
    11: "unit", 12: "value", 13: "enum", 14: "keyword", 15: "snippet",
    16: "color", 17: "file", 18: "reference", 19: "folder", 20: "enummember",
    21: "constant", 22: "struct", 23: "event", 24: "operator", 25: "typeparam",
}


# ---------------------------------------------------------------------------
# Multi-language daemon (one LSPClient per language, single process)
# ---------------------------------------------------------------------------

SOCK_DIR = os.path.expanduser("~/.cache/lsp-query")
DEFAULT_TIMEOUT = 300


def _sock_path():
    return os.environ.get("LSP_SOCK", os.path.join(SOCK_DIR, "daemon.sock"))


def _workspace_root():
    ws = os.environ.get("LSP_WORKSPACE")
    if ws:
        return ws
    try:
        root = subprocess.check_output(
            ["git", "rev-parse", "--show-toplevel"],
            stderr=subprocess.DEVNULL, text=True
        ).strip()
        if root:
            return root
    except Exception:
        pass
    return os.getcwd()


def _idle_timeout():
    return int(os.environ.get("LSP_TIMEOUT", DEFAULT_TIMEOUT))


class MultiLangDaemon:
    """Manages multiple LSP clients, one per language. Lazy-starts servers on demand."""

    def __init__(self, workspace):
        self.workspace = workspace
        self._clients = {}  # lang -> LSPClient
        self._last_used = {}  # lang -> timestamp
        self._lock = threading.Lock()

    def get_client(self, lang):
        """Get or start an LSP client for the given language."""
        with self._lock:
            if lang in self._clients:
                self._last_used[lang] = time.time()
                return self._clients[lang]

            server_cmd = get_server_cmd(lang)
            if not server_cmd:
                cfg = LANGUAGES.get(lang, {})
                install_hint = cfg.get("install", "unknown")
                return None, f"Server not found for '{lang}'. Install with: {install_hint}"

            client = LSPClient(self.workspace, server_cmd, lang=lang)
            try:
                client.start()
            except Exception as e:
                return None, f"Failed to start {lang} server: {e}"

            self._clients[lang] = client
            self._last_used[lang] = time.time()
            return client

    def shutdown_lang(self, lang):
        with self._lock:
            client = self._clients.pop(lang, None)
            self._last_used.pop(lang, None)
        if client:
            client.shutdown()
            return True
        return False

    def shutdown_all(self):
        with self._lock:
            langs = list(self._clients.keys())
        for lang in langs:
            self.shutdown_lang(lang)

    def cleanup_idle(self, timeout):
        """Shut down servers that have been idle longer than timeout."""
        now = time.time()
        with self._lock:
            idle_langs = [
                lang for lang, ts in self._last_used.items()
                if now - ts > timeout
            ]
        for lang in idle_langs:
            self.shutdown_lang(lang)

    def active_servers(self):
        """Return list of (lang, uptime_seconds) for active servers."""
        now = time.time()
        with self._lock:
            return [
                (lang, now - self._last_used.get(lang, now))
                for lang in self._clients
            ]

    @property
    def has_clients(self):
        with self._lock:
            return len(self._clients) > 0


def _run_daemon():
    """Run the multi-language LSP daemon."""
    sock_path = _sock_path()
    os.makedirs(os.path.dirname(sock_path), exist_ok=True)

    if os.path.exists(sock_path):
        os.unlink(sock_path)

    workspace = _workspace_root()
    daemon = MultiLangDaemon(workspace)

    server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    server.bind(sock_path)
    server.listen(5)
    server.settimeout(1.0)

    idle_timeout = _idle_timeout()
    last_activity = time.time()

    def _shutdown_handler(signum, frame):
        daemon.shutdown_all()
        server.close()
        if os.path.exists(sock_path):
            os.unlink(sock_path)
        sys.exit(0)

    signal.signal(signal.SIGTERM, _shutdown_handler)
    signal.signal(signal.SIGINT, _shutdown_handler)

    pid_path = sock_path + ".pid"
    with open(pid_path, "w") as f:
        f.write(str(os.getpid()))

    try:
        while True:
            try:
                conn, _ = server.accept()
            except socket.timeout:
                # Periodic idle cleanup.
                daemon.cleanup_idle(idle_timeout)
                if time.time() - last_activity > idle_timeout and not daemon.has_clients:
                    break
                continue

            last_activity = time.time()
            try:
                data = b""
                while True:
                    chunk = conn.recv(65536)
                    if not chunk:
                        break
                    data += chunk
                    if b"\n\n" in data:
                        break

                request = json.loads(data.decode("utf-8").strip())
                result = _handle_request(daemon, request)
                response = json.dumps(result).encode("utf-8")
                conn.sendall(response)
            except Exception as e:
                try:
                    conn.sendall(json.dumps({"error": str(e)}).encode("utf-8"))
                except Exception:
                    pass
            finally:
                conn.close()
    finally:
        daemon.shutdown_all()
        server.close()
        if os.path.exists(sock_path):
            os.unlink(sock_path)
        if os.path.exists(pid_path):
            os.unlink(pid_path)


def _handle_request(daemon, req):
    cmd = req.get("command", "")

    # Commands that don't need a language.
    if cmd == "ping":
        return {"result": "pong"}
    if cmd == "shutdown":
        lang = req.get("language")
        if lang:
            ok = daemon.shutdown_lang(lang)
            return {"result": f"shutdown {lang}" if ok else f"{lang} not running"}
        daemon.shutdown_all()
        return {"result": "shutdown all"}
    if cmd == "servers":
        active = daemon.active_servers()
        if not active:
            return {"result": "No active servers"}
        lines = []
        for lang, idle in active:
            lines.append(f"  {lang:15s}  idle {idle:.0f}s")
        return {"result": "\n".join(lines)}
    if cmd == "languages":
        lines = []
        for lang, cfg in sorted(LANGUAGES.items()):
            exts = ", ".join(sorted(cfg["extensions"]))
            binary = cfg["server_cmd"][0]
            installed = "installed" if shutil.which(binary) else "not found"
            lines.append(f"  {lang:15s}  {exts:35s}  {binary} ({installed})")
        return {"result": "\n".join(lines)}

    # Commands that need a file -> language resolution.
    filepath = req.get("file")
    forced_lang = req.get("language") or os.environ.get("LSP_LANG")
    lang = forced_lang or (detect_language(filepath) if filepath else None)

    if not lang:
        ext = Path(filepath).suffix if filepath else "?"
        return {"error": f"Unsupported file type: {ext}. Use LSP_LANG=<language> to force. Supported: {', '.join(sorted(LANGUAGES.keys()))}"}

    result = daemon.get_client(lang)
    if isinstance(result, tuple):
        # Error case: (None, error_message)
        return {"error": result[1]}
    client = result

    line = req.get("line", 1) - 1
    col = req.get("col", 1) - 1

    if cmd == "definition":
        return {"result": client.definition(filepath, line, col)}
    elif cmd == "references":
        return {"result": client.references(filepath, line, col)}
    elif cmd == "hover":
        return {"result": client.hover(filepath, line, col)}
    elif cmd == "symbols":
        return {"result": client.symbols(filepath)}
    elif cmd == "diagnostics":
        return {"result": client.diagnostics(filepath)}
    elif cmd == "completions":
        return {"result": client.completions(filepath, line, col)}
    elif cmd == "signature":
        return {"result": client.signature(filepath, line, col)}
    elif cmd == "rename":
        new_name = req.get("new_name", "")
        return {"result": client.rename(filepath, line, col, new_name)}
    elif cmd == "workspace-symbols":
        query = req.get("query", "")
        return {"result": client.workspace_symbols(query)}
    else:
        return {"error": f"Unknown command: {cmd}"}


# ---------------------------------------------------------------------------
# Client: connect to daemon (start it if needed)
# ---------------------------------------------------------------------------

def _ensure_daemon():
    sock_path = _sock_path()
    pid_path = sock_path + ".pid"

    if os.path.exists(pid_path):
        try:
            pid = int(Path(pid_path).read_text().strip())
            os.kill(pid, 0)
            result = _send_to_daemon({"command": "ping"})
            if result and result.get("result") == "pong":
                return True
        except (OSError, ValueError, ConnectionRefusedError):
            pass

    for f in [sock_path, pid_path]:
        if os.path.exists(f):
            os.unlink(f)

    pid = os.fork()
    if pid == 0:
        os.setsid()
        devnull = os.open(os.devnull, os.O_RDWR)
        os.dup2(devnull, 0)
        os.dup2(devnull, 1)
        os.dup2(devnull, 2)
        os.close(devnull)
        _run_daemon()
        sys.exit(0)
    else:
        for _ in range(50):
            time.sleep(0.1)
            if os.path.exists(sock_path):
                try:
                    result = _send_to_daemon({"command": "ping"})
                    if result and result.get("result") == "pong":
                        return True
                except Exception:
                    continue
        print("Warning: daemon did not start in time", file=sys.stderr)
        return False


def _send_to_daemon(request):
    sock_path = _sock_path()
    conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    conn.settimeout(120)
    conn.connect(sock_path)
    try:
        data = json.dumps(request).encode("utf-8") + b"\n\n"
        conn.sendall(data)
        response = b""
        while True:
            chunk = conn.recv(65536)
            if not chunk:
                break
            response += chunk
        return json.loads(response.decode("utf-8"))
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

USAGE = """Usage: lsp-query <command> [args...]

Navigation:
  definition <file> <line> <col>          Go to definition
  references <file> <line> <col>          Find all references
  hover <file> <line> <col>               Type/docstring info
  symbols <file>                          List document symbols
  diagnostics <file>                      Show errors/warnings
  completions <file> <line> <col>         Code completions
  signature <file> <line> <col>           Function signature help
  rename <file> <line> <col> <new_name>   Preview rename
  workspace-symbols <query>               Search symbols across workspace

Management:
  languages                               Show supported languages + install status
  servers                                 List running language server daemons
  shutdown [language]                     Stop one or all daemons

Lines and columns are 1-indexed. Language is auto-detected from file extension.

Supported: python, typescript/js, rust, go, c/c++, bash, java, css, html, json

Environment:
  LSP_WORKSPACE   Workspace root (default: git root or cwd)
  LSP_SERVER      Override server command for all languages
  LSP_LANG        Force a specific language (bypass auto-detection)
  LSP_TIMEOUT     Daemon idle timeout in seconds (default: 300)
"""


def main():
    if len(sys.argv) < 2:
        print(USAGE)
        sys.exit(1)

    cmd = sys.argv[1]

    if cmd in ("-h", "--help", "help"):
        print(USAGE)
        return

    if cmd == "daemon":
        _run_daemon()
        return

    if cmd == "languages":
        _ensure_daemon()
        result = _send_to_daemon({"command": "languages"})
        print(result.get("result", result.get("error", "")))
        return

    if cmd == "servers":
        try:
            result = _send_to_daemon({"command": "servers"})
            print(result.get("result", result.get("error", "")))
        except Exception:
            print("No daemon running")
        return

    if cmd == "shutdown":
        lang = sys.argv[2] if len(sys.argv) > 2 else None
        try:
            result = _send_to_daemon({"command": "shutdown", "language": lang})
            print(result.get("result", result.get("error", "")))
        except Exception:
            print("No daemon running")
        return

    _ensure_daemon()

    request = {"command": cmd}

    if cmd in ("definition", "references", "hover", "completions", "signature"):
        if len(sys.argv) < 5:
            print(f"Usage: lsp-query {cmd} <file> <line> <col>")
            sys.exit(1)
        request["file"] = os.path.abspath(sys.argv[2])
        request["line"] = int(sys.argv[3])
        request["col"] = int(sys.argv[4])

    elif cmd == "rename":
        if len(sys.argv) < 6:
            print(f"Usage: lsp-query {cmd} <file> <line> <col> <new_name>")
            sys.exit(1)
        request["file"] = os.path.abspath(sys.argv[2])
        request["line"] = int(sys.argv[3])
        request["col"] = int(sys.argv[4])
        request["new_name"] = sys.argv[5]

    elif cmd in ("symbols", "diagnostics"):
        if len(sys.argv) < 3:
            print(f"Usage: lsp-query {cmd} <file>")
            sys.exit(1)
        request["file"] = os.path.abspath(sys.argv[2])

    elif cmd == "workspace-symbols":
        if len(sys.argv) < 3:
            print(f"Usage: lsp-query {cmd} <query>")
            sys.exit(1)
        request["query"] = sys.argv[2]

    else:
        print(f"Unknown command: {cmd}")
        print(USAGE)
        sys.exit(1)

    try:
        result = _send_to_daemon(request)
    except ConnectionRefusedError:
        print("Error: could not connect to LSP daemon", file=sys.stderr)
        sys.exit(1)

    if "error" in result:
        print(f"Error: {result['error']}", file=sys.stderr)
        sys.exit(1)

    print(result.get("result", ""))


if __name__ == "__main__":
    main()


path: scripts/setup.sh
content: #!/usr/bin/env bash
#
# csharp-lsp 원타임 셋업 스크립트
# 멱등성 보장 — 몇 번이든 재실행해도 안전합니다.
#
# 수행 항목:
#   1. .NET SDK 존재 확인 (없으면 안내 후 종료)
#   2. csharp-ls 설치 (이미 있으면 스킵)
#   3. ~/.dotnet/tools PATH 등록 (이미 있으면 스킵)
#   4. lsp-query 심볼릭 링크 생성 (이미 있으면 스킵)
#   5. puppeteer 캐시 디렉토리 생성 (데몬용)
#   6. 동작 검증 (선택)
#
# 사용법:
#   bash setup.sh
#   bash setup.sh --verify    # 셋업 후 간단한 동작 테스트까지 수행
#

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
LSP_QUERY="$SCRIPT_DIR/lsp-query.py"
CSHARP_LS_VERSION="0.20.0"
DOTNET_TOOLS="$HOME/.dotnet/tools"
SYMLINK_PATH="/usr/local/bin/lsp-query"
CACHE_DIR="$HOME/.cache/lsp-query"

GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m'

ok()   { echo -e "  ${GREEN}✓${NC} $1"; }
skip() { echo -e "  ${YELLOW}→${NC} $1 (이미 완료)"; }
fail() { echo -e "  ${RED}✗${NC} $1"; }
info() { echo -e "  $1"; }

echo ""
echo "═══════════════════════════════════════════"
echo "  csharp-lsp 셋업"
echo "═══════════════════════════════════════════"
echo ""

# ─────────────────────────────────────────────
# 1. .NET SDK 확인
# ─────────────────────────────────────────────
echo "[1/5] .NET SDK 확인"

if command -v dotnet &>/dev/null; then
    DOTNET_VER=$(dotnet --version 2>/dev/null || echo "unknown")
    ok ".NET SDK $DOTNET_VER"
else
    fail ".NET SDK가 설치되어 있지 않습니다."
    info ""
    info "설치 방법:"
    info "  Ubuntu/Debian:  sudo apt install dotnet-sdk-9.0"
    info "  macOS:          brew install dotnet-sdk"
    info "  기타:           https://dot.net/download"
    info ""
    exit 1
fi

# ─────────────────────────────────────────────
# 2. csharp-ls 설치
# ─────────────────────────────────────────────
echo "[2/5] csharp-ls 설치"

# PATH에 dotnet tools가 있는지 먼저 확인 (which가 찾을 수 있도록)
export PATH="$DOTNET_TOOLS:$PATH"

if command -v csharp-ls &>/dev/null; then
    CS_VER=$(csharp-ls --version 2>/dev/null || echo "installed")
    skip "csharp-ls $CS_VER"
else
    info "csharp-ls $CSHARP_LS_VERSION 설치 중..."
    if dotnet tool install --global csharp-ls --version "$CSHARP_LS_VERSION" 2>/dev/null; then
        ok "csharp-ls $CSHARP_LS_VERSION 설치 완료"
    elif dotnet tool list --global 2>/dev/null | grep -q csharp-ls; then
        # 다른 버전이 이미 있을 수 있음
        skip "csharp-ls (다른 버전이 이미 설치됨)"
    else
        fail "csharp-ls 설치 실패"
        info "수동 설치: dotnet tool install --global csharp-ls"
        exit 1
    fi
fi

# ─────────────────────────────────────────────
# 3. PATH 등록 (~/.dotnet/tools)
# ─────────────────────────────────────────────
echo "[3/5] PATH 등록"

path_registered=false

for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
    if [ -f "$rcfile" ]; then
        if grep -q '\.dotnet/tools' "$rcfile" 2>/dev/null; then
            skip "$rcfile 에 이미 등록됨"
            path_registered=true
        else
            echo 'export PATH="$HOME/.dotnet/tools:$PATH"' >> "$rcfile"
            ok "$rcfile 에 PATH 추가"
            path_registered=true
        fi
    fi
done

if [ "$path_registered" = false ]; then
    fail "shell RC 파일을 찾을 수 없습니다 (.bashrc 또는 .zshrc)"
    info "수동으로 추가: export PATH=\"\$HOME/.dotnet/tools:\$PATH\""
fi

# ─────────────────────────────────────────────
# 4. lsp-query 심볼릭 링크
# ─────────────────────────────────────────────
echo "[4/5] lsp-query 심볼릭 링크"

if [ ! -f "$LSP_QUERY" ]; then
    fail "lsp-query.py를 찾을 수 없습니다: $LSP_QUERY"
    exit 1
fi

chmod +x "$LSP_QUERY" 2>/dev/null || true

if [ -L "$SYMLINK_PATH" ] && [ "$(readlink -f "$SYMLINK_PATH")" = "$(readlink -f "$LSP_QUERY")" ]; then
    skip "$SYMLINK_PATH → $LSP_QUERY"
elif [ -w "$(dirname "$SYMLINK_PATH")" ] 2>/dev/null; then
    ln -sf "$LSP_QUERY" "$SYMLINK_PATH"
    ok "$SYMLINK_PATH → $LSP_QUERY"
else
    # sudo 필요
    if command -v sudo &>/dev/null; then
        info "심볼릭 링크 생성에 sudo가 필요합니다..."
        if [ -n "${SUDO_PASS:-}" ]; then
            echo "$SUDO_PASS" | sudo -S ln -sf "$LSP_QUERY" "$SYMLINK_PATH" 2>/dev/null
            ok "$SYMLINK_PATH → $LSP_QUERY (sudo)"
        elif sudo -n true 2>/dev/null; then
            sudo ln -sf "$LSP_QUERY" "$SYMLINK_PATH"
            ok "$SYMLINK_PATH → $LSP_QUERY (sudo)"
        else
            fail "sudo 권한 없이 $SYMLINK_PATH 에 링크를 만들 수 없습니다."
            info "수동 실행: sudo ln -sf $LSP_QUERY $SYMLINK_PATH"
            info "또는 다른 경로에 링크: ln -sf $LSP_QUERY ~/.local/bin/lsp-query"
        fi
    else
        fail "$SYMLINK_PATH 에 쓰기 권한이 없습니다."
        info "수동 실행: sudo ln -sf $LSP_QUERY $SYMLINK_PATH"
    fi
fi

# ─────────────────────────────────────────────
# 5. 캐시 디렉토리
# ─────────────────────────────────────────────
echo "[5/5] 캐시 디렉토리"

if [ -d "$CACHE_DIR" ]; then
    skip "$CACHE_DIR 존재"
else
    mkdir -p "$CACHE_DIR"
    ok "$CACHE_DIR 생성"
fi

echo ""
echo "═══════════════════════════════════════════"
echo -e "  ${GREEN}셋업 완료!${NC}"
echo "═══════════════════════════════════════════"
echo ""
echo "  사용법:"
echo "    LSP_WORKSPACE=/path/to/solution lsp-query hover file.cs 5 16"
echo "    LSP_WORKSPACE=/path/to/solution lsp-query references file.cs 10 20"
echo "    lsp-query servers    # 실행 중인 서버 확인"
echo "    lsp-query shutdown   # 데몬 종료"
echo ""

# ─────────────────────────────────────────────
# 검증 (--verify 옵션)
# ─────────────────────────────────────────────
if [[ "${1:-}" == "--verify" ]]; then
    echo "═══════════════════════════════════════════"
    echo "  동작 검증"
    echo "═══════════════════════════════════════════"
    echo ""

    TEST_DIR=$(mktemp -d /tmp/csharp-lsp-test.XXXXXX)
    trap "rm -rf $TEST_DIR" EXIT

    # 미니 C# 프로젝트 생성
    info "테스트 프로젝트 생성 중..."
    mkdir -p "$TEST_DIR/Verify"

    cat > "$TEST_DIR/Verify/Verify.csproj" << 'PROJEOF'
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>
</Project>
PROJEOF

    cat > "$TEST_DIR/Verify/Program.cs" << 'CSEOF'
namespace Verify;
public class Calculator
{
    public int Add(int a, int b) => a + b;
}
public class Program
{
    static void Main()
    {
        var calc = new Calculator();
        System.Console.WriteLine(calc.Add(1, 2));
    }
}
CSEOF

    dotnet restore "$TEST_DIR/Verify" -q 2>/dev/null || true

    info "LSP 쿼리 테스트 (첫 실행은 30초+ 소요)..."
    lsp-query shutdown 2>/dev/null || true
    rm -f "$CACHE_DIR/daemon.sock" "$CACHE_DIR/daemon.sock.pid"

    RESULT=$(LSP_WORKSPACE="$TEST_DIR/Verify" lsp-query hover "$TEST_DIR/Verify/Program.cs" 4 16 2>&1)

    if echo "$RESULT" | grep -q "Calculator.Add"; then
        ok "hover 테스트 통과: $RESULT"
        echo ""
        echo -e "  ${GREEN}모든 검증 완료!${NC}"
    else
        fail "hover 테스트 실패"
        info "결과: $RESULT"
        info "lsp-query shutdown 후 재시도해보세요."
    fi

    lsp-query shutdown 2>/dev/null || true
    echo ""
fi


path: tests/test.sh
content: #!/usr/bin/env bash
#
# csharp-lsp integration test suite
# Runs in a clean Docker container to verify install + functionality.
#
# Usage:
#   bash tests/test.sh              # from repo root
#   bash tests/test.sh --no-build   # skip Docker image build
#
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
IMAGE_NAME="csharp-lsp-test"

GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'

pass() { echo -e "  ${GREEN}✓${NC} $1"; PASSED=$((PASSED+1)); }
fail() { echo -e "  ${RED}✗${NC} $1"; FAILED=$((FAILED+1)); }

PASSED=0
FAILED=0

# ─────────────────────────────────────────────
# Build Docker image
# ─────────────────────────────────────────────
if [[ "${1:-}" != "--no-build" ]]; then
    echo "Building test image..."
    docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$SCRIPT_DIR" -q > /dev/null
    echo ""
fi

# ─────────────────────────────────────────────
# Run tests inside container
# ─────────────────────────────────────────────
echo "═══════════════════════════════════════════"
echo "  csharp-lsp test suite"
echo "═══════════════════════════════════════════"
echo ""

TEST_OUTPUT=$(docker run --rm \
    -v "$REPO_DIR:/skill/csharp-lsp:ro" \
    "$IMAGE_NAME" \
    bash -c '
        export PATH="$HOME/.dotnet/tools:$PATH"

        # Copy to writable location
        cp -r /skill/csharp-lsp /tmp/csharp-lsp
        chmod +x /tmp/csharp-lsp/scripts/*.sh /tmp/csharp-lsp/scripts/*.py

        # ═══ TEST 1: Setup ═══
        echo "TEST:setup:START"
        bash /tmp/csharp-lsp/scripts/setup.sh 2>&1
        if command -v csharp-ls &>/dev/null && [ -L /usr/local/bin/lsp-query ]; then
            echo "TEST:setup:PASS"
        else
            echo "TEST:setup:FAIL"
        fi

        # ═══ TEST 2: Idempotency ═══
        echo "TEST:idempotency:START"
        OUTPUT=$(bash /tmp/csharp-lsp/scripts/setup.sh 2>&1)
        # All steps should say "이미 완료" or similar skip message
        if echo "$OUTPUT" | grep -q "이미 완료"; then
            echo "TEST:idempotency:PASS"
        else
            echo "TEST:idempotency:FAIL"
        fi

        # ═══ Create test project ═══
        mkdir -p /tmp/test/Mini
        cat > /tmp/test/Mini/Mini.csproj << PROJ
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>
</Project>
PROJ
        cat > /tmp/test/Mini/Program.cs << CS
namespace Mini;

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public int Multiply(int a, int b) => a * b;
}

public class Program
{
    static void Main()
    {
        var calc = new Calculator();
        System.Console.WriteLine(calc.Add(1, 2));
    }
}
CS
        dotnet restore /tmp/test/Mini -q 2>&1

        export LSP_WORKSPACE=/tmp/test/Mini
        lsp-query shutdown 2>/dev/null || true
        rm -f ~/.cache/lsp-query/daemon.sock ~/.cache/lsp-query/daemon.sock.pid

        # ═══ TEST 3: Hover ═══
        echo "TEST:hover:START"
        RESULT=$(timeout 120 lsp-query hover /tmp/test/Mini/Program.cs 5 16 2>&1)
        if echo "$RESULT" | grep -q "Calculator.Add"; then
            echo "TEST:hover:PASS"
        else
            echo "TEST:hover:FAIL:$RESULT"
        fi

        # ═══ TEST 4: Symbols ═══
        echo "TEST:symbols:START"
        RESULT=$(timeout 30 lsp-query symbols /tmp/test/Mini/Program.cs 2>&1)
        if echo "$RESULT" | grep -q "Calculator" && echo "$RESULT" | grep -q "Program"; then
            echo "TEST:symbols:PASS"
        else
            echo "TEST:symbols:FAIL:$RESULT"
        fi

        # ═══ TEST 5: Definition ═══
        echo "TEST:definition:START"
        RESULT=$(timeout 30 lsp-query definition /tmp/test/Mini/Program.cs 14 35 2>&1)
        if echo "$RESULT" | grep -q "Program.cs"; then
            echo "TEST:definition:PASS"
        else
            echo "TEST:definition:FAIL:$RESULT"
        fi

        # ═══ TEST 6: References ═══
        echo "TEST:references:START"
        RESULT=$(timeout 30 lsp-query references /tmp/test/Mini/Program.cs 5 16 2>&1)
        if echo "$RESULT" | grep -q "Program.cs"; then
            echo "TEST:references:PASS"
        else
            echo "TEST:references:FAIL:$RESULT"
        fi

        lsp-query shutdown 2>/dev/null || true
    ' 2>&1)

# ─────────────────────────────────────────────
# Parse results
# ─────────────────────────────────────────────
echo "Tests:"

for test_name in setup idempotency hover symbols definition references; do
    if echo "$TEST_OUTPUT" | grep -q "TEST:${test_name}:PASS"; then
        pass "$test_name"
    else
        fail "$test_name"
        # Show failure detail if available
        DETAIL=$(echo "$TEST_OUTPUT" | grep "TEST:${test_name}:FAIL" | sed "s/TEST:${test_name}:FAIL://")
        if [ -n "$DETAIL" ]; then
            echo "       $DETAIL"
        fi
    fi
done

# ─────────────────────────────────────────────
# Summary
# ─────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
TOTAL=$((PASSED + FAILED))
if [ "$FAILED" -eq 0 ]; then
    echo -e "  ${GREEN}All $TOTAL tests passed!${NC}"
else
    echo -e "  ${RED}$FAILED/$TOTAL tests failed${NC}"
fi
echo "═══════════════════════════════════════════"

# Cleanup
docker rmi "$IMAGE_NAME" > /dev/null 2>&1 || true

exit "$FAILED"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)