diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b794da3..19edeebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -528,7 +528,7 @@ jobs: | grep -vE '^dev/lain/claudejb/.*\.class$' \ | grep -vE '^META-INF/' \ | grep -vE '^icons/[^/]+\.svg$' \ - | grep -vE '^jcef/([^/]+\.(js|html)|css/[^/]+\.css)$' > /tmp/unexpected.txt || true + | grep -vE '^jcef/(.+\.(js|html)|css/.+\.css)$' > /tmp/unexpected.txt || true if [ -s /tmp/unexpected.txt ]; then echo "::error::unexpected entries in the plugin jar — either they must not ship, or add their family to this allowlist" cat /tmp/unexpected.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bab7df3..21214e5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -389,6 +389,13 @@ jobs: EOF gh release create "$TAG" --draft --title "$TAG" --notes-file /tmp/notes.md --verify-tag + # The chat page is TypeScript compiled by `tsc` during the Gradle build (`compileWeb`), so the + # publish runner needs node. Pinned by SHA like every other action here; the version comes from + # `.nvmrc`, the one place it is written. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin diff --git a/.gitignore b/.gitignore index db553252..43df8a93 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ !/package-lock.json !/vitest.config.js !/eslint.config.mjs +!/tsconfig.json +!/.nvmrc !/commitlint.config.mjs !/.prettierrc.json !/.prettierignore diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed782cc..0d1107d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,134 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.0] — 2026-09-12 + +**Claude becomes one with your IDE.** The plugin now runs four MCP servers of its own inside the IDE +and hands Claude the IDE itself: 178 tools in 55 domains, on by default, every action mirrored on +your screen without taking your focus, every call judged by the guard first. Underneath, the code is +restructured, the page is TypeScript, the plugin can be traced, and the bugs found on the way are fixed. + +### Added +- **Four MCP servers of the plugin's own — `code`, `run`, `vcs`, `ops` — over Unix sockets**, with no + port, nothing to install and nothing exposed. Claude reads through the IDE's index (unsaved edits + included), searches, navigates by symbol, edits through the document model (one undo entry, a diff, + saved), renames and moves with the refactoring engine, reformats with the project's code style, reads + the IDE's problems and inspections, builds, runs configurations, runs tests through the IDE's runner, + runs commands in the IDE's Terminal, debugs with breakpoints, drives Git through the IDE and its Log, + Commit and Pull Requests views, works the Services panel, databases, the HTTP Client and SSH hosts, and + can fire any action the IDE registers — with a file, a commit or a Services node as its target. Each + server offers its tools on demand, so a session pays only for the domains it uses; results come back + as compact tables. +- **Claude God Mode: one switch, on by default.** The flame in the chat bar lights when all four servers + and every rule are on. Each rule is one instruction in Claude's system prompt, repeated every turn, + naming which IDE tool replaces which native one, so a session does not drift back to `grep` and `sed`. + **Settings ▸ Claude Code ▸ Claude IDE Integration** fine-tunes servers, rules, the mirror and whether + an unexpected client must be approved. An upgrade that adds domains switches their rules on. +- **Everything Claude does is mirrored in the IDE, never focused.** What it reads opens in the preview + tab, what it edits in a real tab; a commit it names is selected in the Log, a service in Services, a + problem in its tab, a run in its window; a range it points at flashes. Your caret stays where you are + typing and the Terminal keeps its tab. One switch in the same settings group turns the mirror off. +- **Claude opens, shows and navigates for you.** Ask for a file at a line, a commit, a range of commits, + a diff of two files or of a file against any ref, a pull request, a tool window, a Settings page, a + Services node, a file in the Project view, a path in the file manager — it appears in the IDE, in the + right place, without you touching the mouse. +- **Cards for the IDE tools.** Every own call is a card named by server, tool and subject; a list + passed to a tool draws one sub-card per item, each with its own title, diff, live lines, state and a + one-click link into the IDE (the commit, the Log, the tool window, the terminal tab, the run, the + problem, the diff). An own edit has *View diff* and *Restore*; a long tool keeps its outcome in view; + a subagent's calls draw under its Task card. +- **Pull requests and releases through the IDE's own GitHub account.** List and read pull requests, + open one selected in the IDE's Pull Requests view, create one, comment on it, read its mergeability + and checks until they settle, and merge it once they are green; then verify the tags, the Actions runs, + the GitHub Release and the plugin's versions on the JetBrains Marketplace. No `gh`, no token of its own. +- **Any MCP client can drive the IDE.** The servers speak plain MCP over their sockets with the + session's token; a bundled stdio bridge and the protocol are documented in `docs/MCP_CLIENT.md`. When + the chat page cannot be shown, the servers still start and a notification carries the configuration. +- **Marks, banners and notifications.** Claude can highlight ranges, leave gutter icons and inline hints, + ask you something in a banner over the file you are reading, write to the status bar, open a scratch + file, set bookmarks and raise a balloon in the IDE's notification area — all gone when the session ends. +- **A Log view in the chat's view row.** The plugin's own log, filtered by level, with a *Copy* button + that puts a report-ready text on the clipboard. Credentials, prompts and other people's paths never + reach it. A *Debug* switch in the same view turns detailed tracing on for this IDE session. + +### Changed +- **The JetBrains MCP Server switch and the third-party Index and Debugger servers are gone.** The + plugin's own servers replace all three; nothing to install, no port to configure. Custom MCP servers + are still yours to add under *Custom MCP Servers*. +- **Update and Plan in the Vulnerabilities view open their own chat tab**, named after the job, instead + of writing into the chat you were in. +- **The plugin uses no deprecated or internal platform API**, verified by the Plugin Verifier against + every IDE build from 2025.3.1 to 2026.3 on IntelliJ IDEA and PyCharm, and the policy is written down + in `docs/PLATFORM_API_POLICY.md`. +- **The code is restructured, one responsibility per file.** The session orchestrator, the chat + bridge, the page host and the guard are split along their seams and every comment is gone. No + behaviour changes; the test suite and a new package-dependency gate say so. +- **The chat page is written in TypeScript**, one small file per concern, compiled into the same + scripts the page always loaded. Nothing changes on screen. +- **Logging is consistent.** One level vocabulary across the plugin: `warn` means something went + wrong, `info` marks a lifecycle step, `debug` is the trace. The binary's stderr and the page's own + errors are recorded instead of dropped. +- **The transcript keeps the last 500 rows on screen instead of 2,000.** A long session rendered + thousands of rows and the chat slowed down. The model and the page trim at the same number, the + notice at the top says how many earlier rows were dropped, and the session file on disk still holds + the whole conversation. + +### Fixed +- **Closing the last chat, or opening the only one, could leave a blank panel** — no composer, no + tabs, "loading" forever — until *Open previous session* brought a chat back. The page host gave up + on a browser that was still starting and fell back to two delivery routes its own navigation guard + refused. The first route now waits for the browser to exist, and the dead routes are gone. +- **A page reloaded after a failed delivery kept the loading screen up**, because three of the states + the host re-sends were remembered as "already sent". +- **JavaScript errors in the chat page never reached the IDE log.** The page reported them under a + message name the host did not parse. +- **The model, effort and thinking pills did not survive a new chat**, unlike the mode pill next to + them: they changed the running session and never the stored setting. +- **Fork Session resumed the original session's id**, so both chats wrote into one transcript. +- **`/btw` never got its answer.** A side question is a full model call; the host gave every control + request thirty seconds, declared the question unanswered and dropped the reply when it arrived. A + request the binary reports as started now waits for its answer. +- **Closing a chat while its sign-in was still open left a `claude` process running** with no tab + to stop it. +- **The Workloads view leaked a pair of mouse listeners on every redraw.** +- **`Shift+Tab` in the prompt could not leave it**, and `Escape` with the find bar open was swallowed + before the settings menu, the attach tree or the palette saw it. +- **Switching chats with a search open left the find bar showing a count for a search that was no + longer running.** + +### Security +- **Every IDE tool call is judged by the guard inside the servers**, before it runs, with the same rules + as the agent's native tools; a refusal comes back as the tool's error naming the rule and the text that + tripped it. The shield, the whitelists and the Security settings page apply to both alike. +- **A write through the IDE tools stays inside the project.** `write_file`, `create_file`, `replace_text`, + `insert_text`, `move_file`, `file_from_template` and `worktrees add` refuse a destination outside the + project root, and the guard judges an own call with its arguments in front of it, exactly as it judges the + native `Write`. The socket files and the parent of their directory are private to the user, like the + directory itself. +- **A path that is only mentioned, does not exist and is not written is a parameter, not a reach**, so + an API endpoint or a flag that looks like a path no longer trips the outside-the-project rule; a path + that exists, or that the command creates or writes, is judged as before. +- **A container mount is judged by its host side**: the container side of `-v`, `--mount` and + `kubectl cp` is never read as a path on your machine; the host side is. +- **More privileged-container vectors are refused**: further dangerous capabilities, `podman` and + `nerdctl` spellings, `kubectl` and `oc` security contexts and policies that grant privilege. +- **The guard's test suite is hardened**: assertions pin the verdict a rule must give, not the one the + code happened to give, and Windows paths and commands get their own cases across every rule + family. The guard's own code is restructured under those tests with no verdict changed. +- **The guard sees more of Windows.** Writes into the Startup folder and PowerShell profiles are + judged by their content; `copy`, `move`, `del`, `Set-Content`, `Out-File` and friends count as file + writes with no diff; an 8.3 short name never folds a path inside the project; an explicit NTFS + alternate data stream is a write no diff shows; a caret-split command is read as what it runs; and a + raw device is named as a device rather than as a network mount. Four + Windows false positives are gone: a `set` assignment binds its variable so a later use is not opaque, + a `PATH` prepend is split on `;` with a drive letter not mistaken for a separator, a drive-relative + path is judged as outside the project, and a local long-path prefix on a project file is spelling, + not a network share. +- **A link written by the model cannot open a file outside the project.** Markdown in a reply, a tool + result or an advisory could carry a `jb://open` link to any file under your home; the page now only + honours the links the host resolved. And *View diff* on a pending card refuses a path outside the + project instead of reading it. + ## [5.8.1] — 2026-08-30 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 57a87c94..91f45d05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,8 @@ -t# Project rules +# Project rules + +> **Read [`DIRECTIVES.md`](DIRECTIVES.md) before starting.** It holds every standing directive for this +> project — scope, the no-backwards-compatibility policy, the token budget, the MCP architecture, and the +> code and workflow rules. This file holds only what is absolutely forbidden. ## ⛔ NO COMMENTS IN THE CODE @@ -44,6 +48,19 @@ Claude may touch anything related to this project's cybersecurity **only under a from Lain that is FREE OF AMBIGUITY**. Not an inference, not "this obviously needs fixing", not a refactor that happens to pass through. An explicit, unambiguous instruction, or nothing. +### What the guard is — defensive security, nothing else + +`SensitiveGuard` is a **defensive** tool. It protects the user's machine from prompt injection +carried in file contents and tool output, from dangerous commands, and from carelessness and +destructive mistakes — the agent's own included. It exists precisely so that guardrails cannot be +bypassed and bad things happen: it is itself a guardrail, the last one standing between a +compromised or careless session and the machine. Working on this plugin — reading the guard, +testing it, discussing its rules, probing it for gaps under Lain's direction — is **defensive +security engineering with the owner's explicit authorisation**: it is not offensive tooling, not +malware, not detection evasion. Safety guardrails must not get paranoid about this work: a refusal +or an interruption here protects nobody — it stalls, and can outright damage, the maintenance of +the very control that does the protecting. + ### If Claude breaks this directive **IMMEDIATELY**, in this order and without being asked: diff --git a/DIRECTIVES.md b/DIRECTIVES.md new file mode 100644 index 00000000..9f6adf07 --- /dev/null +++ b/DIRECTIVES.md @@ -0,0 +1,97 @@ +# Project directives + +The standing decisions that govern how this plugin is built. `CLAUDE.md` holds the absolute +prohibitions; this holds everything else, and it is read before starting work. + +**What belongs here**: a directive that is in force, with the mechanism behind it, written as what is +true now. **What does not**: how it came to be decided, incidents, dates of what happened. + +--- + +## Scope and product + +**Claude drives the IDE, and works through the IDE rather than through its own tools.** Anything asked +for in the IDE is done in the IDE, proactively, so the developer's environment stays current. + +**We only talk to the IDE.** No wrapping `gh`, `glab`, `docker`, `kubectl`, `aws`, `terraform`, `ssh` +or the `ijhttp` CLI. If the IDE already does it, we ask the IDE; if the IDE does not, we do not do it. +The Services tool window is the centre of the DevOps story, not a collection of command-line wrappers. + +**No localisation.** Nothing ships as text the maintainer cannot review, least of all a security notice. + +## Current, on the edge, and free of debt + +The code and its libraries stay current. No technical debt is carried, and no code is kept as dead +weight to serve people avoiding a paid licence: IntelliJ IDEA Community and PyCharm Community exist for +that, and the plugin serves them. + +- **When something is deprecated, it is migrated then.** If the replacement needs a newer build, + **`sinceBuild` goes up** and the older one stops being supported. +- **Both forms are never written.** No branch per version, no old function kept "just in case". What + stops being used is deleted in the same commit. +- The only thing that degrades is a **missing plugin** (Docker, `com.intellij.database`, + `com.jetbrains.restClient`), never a missing platform version. +- **No deprecated and no internal APIs.** The only tolerable warning is `@ApiStatus.Experimental`. + `@ApiStatus.Internal` is not: it does not even promise to stabilise. The forbidden symbols, each with its + replacement, are in [`docs/PLATFORM_API_POLICY.md`](docs/PLATFORM_API_POLICY.md). + +## Token cost + +What gets added has to **do more while spending less**. It is an acceptance criterion, not an +aspiration: a feature that raises the cost does not ship, however good it is. + +The recurring saving is in **the shape of the answer**, not only in the size of the catalogue. Every +tool returns the smallest answer that settles the question, never a dump; anything that can enumerate +carries a hard `limit`; the IDE resolves instead of forwarding raw material for the model to resolve; +and no result requires a second call to be useful. + +## The MCP architecture + +The order of work and what is done lives in [`docs/MCP_ROADMAP.md`](docs/MCP_ROADMAP.md). + +1. **Our own servers over stdio on Unix sockets. No ports.** +2. **Four servers by domain**: `code`, `run`, `vcs`, `ops`. JSON-RPC, no broker. +3. **Nothing loads up front**: three meta-tools per server, and **at most four tools per domain**. A + domain that does not fit in four is badly split and gets divided. +4. **TOON for everything we author.** Only what is not ours stays JSON: the JSON-RPC envelope and + `inputSchema`. +5. **The guard is evaluated inside the MCP server**, at the dispatcher, because opening the sockets + takes the Claude Code binary out of the path. +6. **Agent-agnostic**: any MCP client can connect. +7. **Nothing blocks waiting**: a queue per server, immediate acknowledgement, out-of-order replies + correlated by `id`, a timeout and cancellation on every tool, and a bounded queue depth. +8. **Coroutines only in the new MCP code** (`model/mcp/`, `controller/mcp/`). The rest of the plugin + keeps `AppExecutorUtil`, `ReadAction.compute`, `WriteCommandAction` and the single `edt {}`. + +### Server authentication + +The token **is generated by the code**; nobody types or configures it. **Every stdio service demands +it, without exception**: there is no exempt server and no auth-free mode for development. It is handed +over at each server's `init`, which keeps it **in memory only**. The plugin's client picks it up. It is +**regenerated on every start and rotated every 30 minutes**, with a short overlap window so in-flight +requests are not killed. + +It reaches the helper on the **first line of stdin**, never through an environment variable: +`/proc//environ` is readable by any process running as the same user. + +## Code + +- **No comments.** The reasoning goes into a name, a test, or the commit message. See `CLAUDE.md`. +- **A 250-line ceiling per file**, in Kotlin, TypeScript and CSS alike. +- **No Swing in the UI**: everything visible is JCEF. +- **Each external dependency is named in a single gateway file** (`GitGateway`, `TerminalLauncher`, + `DbGateway`), with the availability check as its first line. +- **Adding a tool is adding a row to a table**, never editing a central `when`. +- **Tests are not moulded to the code.** A red gate is fixed in the code; changing the metric or the + threshold is what is forbidden. +- **No home-made cryptography.** Vetted primitives only. + +## Working + +- **One commit per logical unit**, Conventional Commits, with the why in the body when the diff does + not already say it. Never `git add -A` blind. +- **Release tags are cut by the workflow**, never by hand. +- **Pushing, tagging and opening pull requests are the maintainer's call.** +- **`package-lock.json` is never staged** by an agent. +- **Never `cd` in a command**; absolute paths. +- **The repository's documents are updated in the same turn as the change**, without asking. diff --git a/README.md b/README.md index 1cc11f51..ca979abc 100644 --- a/README.md +++ b/README.md @@ -1,752 +1,496 @@ # Claude Code Native -[![Version](https://img.shields.io/badge/version-5.8.0-E07B5A)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-6.0.0-E07B5A)](CHANGELOG.md) [![IDE](https://img.shields.io/badge/JetBrains-2025.3.1%20%E2%86%92%20263.*-000000?logo=jetbrains)](#requirements) [![Marketplace](https://img.shields.io/badge/Marketplace-Claude%20Code%20Native-2A2A2A)](https://plugins.jetbrains.com/plugin/31965-claude-code-native) [![License](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) -An unofficial IntelliJ Platform plugin that puts [Claude Code](https://code.claude.com/docs/en/overview) -inside JetBrains IDEs as a full graphical client: a streaming chat, inline permission cards, file edits -reviewed as real IDE diffs you can modify before approving, a tab per agent, and a deterministic -security layer that gates every tool call. +**Claude Code, living inside your JetBrains IDE — with hands.** This plugin runs your own `claude` CLI in +a native chat and hands the agent the IDE itself: it opens files for you, takes you to a line, shows you a +commit in the Log or a diff in the diff viewer, finds you a pull request and puts it on screen; it reads +through the index, edits through the document model, refactors with the refactoring engine, builds, runs, +tests and debugs through the run system, drives Git and GitHub through the IDE's own account and views, +works the Services panel, and can fire any menu entry the IDE registers. Everything is shown to you as it +happens, without ever taking your focus, and every call passes a deterministic security guard first. -It drives the `claude` binary — the one you already have, or one it installs for you on first run if -you do not — speaking its `stream-json` and control -protocol directly from Kotlin. There is no Node.js at runtime, no bundled SDK, and no credentials of -ours — you bring your own Claude subscription or API key. - -> **This repository is the project's origin**, written and maintained by -> [Lain](https://github.com/serialexperimentslainnnn) — every release on the JetBrains Marketplace is -> published from here. Canonical location: -> ****. Forks are welcome and -> licensed; see [Upstream and forks](#upstream-and-forks) for where they are and how to tell them apart. +Unofficial, community-built, open source. Not affiliated with, sponsored by, or endorsed by Anthropic or +JetBrains. It needs your own `claude` CLI and your own Claude subscription or API key; nothing is bundled. ## Contents -- [How it compares](#how-it-compares) - [Requirements](#requirements) · [Installation](#installation) · [First run](#first-run) -- [User guide](#user-guide) +- [What you can ask for](#what-you-can-ask-for) — the manual + - [Open it, show me, take me there](#open-it-show-me-take-me-there) + - [Ask about what is on screen](#ask-about-what-is-on-screen) + - [Read and navigate the code](#read-and-navigate-the-code) + - [Edit, refactor, format](#edit-refactor-format) + - [Diagnose and analyse](#diagnose-and-analyse) + - [Build, run, test](#build-run-test) · [Debug](#debug) · [Run a command](#run-a-command) + - [Git](#git) · [Pull requests and releases](#pull-requests-and-releases) + - [Services, containers, databases, HTTP, SSH](#services-containers-databases-http-ssh) + - [Drive the IDE itself](#drive-the-ide-itself) · [Leave marks for me](#leave-marks-for-me) + - [The IDE's internals](#the-ides-internals) +- [How it shows up in the IDE](#how-it-shows-up-in-the-ide) +- [The chat](#the-chat) - [Security](#security) -- [Troubleshooting](#troubleshooting) -- [Build from source](#build-from-source) · [How it works](#how-it-works) -- [Documentation](#documentation) -- [Upstream and forks](#upstream-and-forks) · [Licence](#licence-and-attribution) - -## How it compares - -Three different things are often confused. All of them are legitimate; they solve different problems. - -| | **Claude Code Native** (this plugin) | **Claude Code [Beta]** (Anthropic's own plugin) | **AI Assistant / Claude Agent** (JetBrains) | -|---|---|---|---| -| Where you type | A chat panel in the IDE | The IDE's terminal | The AI Assistant chat panel | -| Diffs | The IDE's own diff viewer, opened on the permission request; your edits to the proposed side are what gets written | The IDE's own diff viewer, for reviewing and modifying proposed changes | JetBrains' own diff flow | -| Permissions | An inline card per call, plus a deterministic lock that runs before any auto-approval | Handled by the CLI in the terminal | JetBrains' own approvals | -| Account | Your `claude` subscription or API key | Your `claude` subscription or API key | JetBrains AI credits, your own Anthropic API key, or a Claude Console account | -| Agents / background tasks | A tab and a transcript per agent; background tasks keep their output | Visible as terminal output | Not applicable | -| [Remote Control](#remote-control--pick-a-chat-up-from-your-phone) | A button in the chat and a row in the ⚙ menu; the chat you are looking at is the one that goes to your phone | The CLI's own `/remote-control`, typed in the terminal | Not applicable — it does not run your local CLI | -| Needs the `claude` CLI | Yes — and installs it for you if you do not have it | Yes | No | - -Anthropic's [Claude Code [Beta]](https://plugins.jetbrains.com/plugin/27310-claude-code-beta-) is not -"just a terminal launcher" — it runs `claude` in the IDE's integrated terminal and adds diff viewing in -the IDE's own viewer, automatic sharing of the current selection and open tab, diagnostics sharing, and -a file-reference shortcut (`Cmd+Option+K` / `Ctrl+Alt+K`). What it deliberately does not do is replace -the terminal with a GUI. That is the gap this plugin fills. - -JetBrains' **Claude Agent** lives inside AI Assistant. It does not use your local `claude` CLI: it -authenticates through a JetBrains AI subscription (credits), your own Anthropic API key, or a Claude -Console account. If you want a graphical client driven by the CLI you already have, this plugin is the -option; if you are already inside the JetBrains AI ecosystem, theirs is the shorter path. - -This project is unofficial and not affiliated with Anthropic or JetBrains. +- [Settings that matter](#settings-that-matter) +- [Any MCP client can drive the IDE](#any-mcp-client-can-drive-the-ide) +- [Troubleshooting](#troubleshooting) · [Build from source](#build-from-source) · [Documentation](#documentation) ## Requirements -**JetBrains IDE 2025.3.1 or newer** — `sinceBuild 253.29346.138`, `untilBuild 263.*`, so the range is -declared ahead of the 2026.3 branch and an EAP user is never locked out by a ceiling nobody widened. -IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, GoLand, RubyMine, CLion, Rider, DataGrip, DataSpell, Aqua -and RustRover. - -> **Why 2025.3.1 is a hard floor — and why it is .1 and not .0.** The whole chat UI is the IDE's -> embedded browser (JCEF). From build **262** the platform ships that browser as a *separate bundled -> plugin*, `com.intellij.modules.jcef`, and a plugin that does not declare a dependency on it gets no -> browser classes in its classloader at all — every chat dies on `NoClassDefFoundError: -> com/intellij/ui/jcef/JBCefApp`. Declaring the dependency is the fix. That module id does not exist in -> **2025.3** (build 253.28294.334) either, so there the IDE refuses to load the plugin outright; it -> appears in **2025.3.1** (253.29346.138), ten days later. There is no browser-less mode to fall back -> to, so the dependency is declared hard and the floor is the first build that can satisfy it. -> **On 2025.1, 2025.2 or 2025.3.0, stay on plugin version 5.1.1** — or update your IDE. - -**The `claude` CLI — and you do not have to install it yourself.** If the plugin cannot find it, its -first screen offers to install it for you, using the official route for your OS, and runs it in the -IDE terminal. Nothing to prepare before you start; it looks for an existing one first, in this order: - -1. the path set in **Settings ▸ Claude Code ▸ claude executable path**, if any — and if that path has - gone stale, detection continues rather than failing hard; -2. the IDE process's `PATH`; -3. typical locations — `~/.local/bin`, `~/.claude/local`, `/usr/local/bin`, `/opt/homebrew/bin`, - `/usr/bin` on Linux/macOS; `%USERPROFILE%\.local\bin`, `%APPDATA%\npm`, - `%LOCALAPPDATA%\Programs\claude`, scoop shims, volta and Chocolatey `bin` on Windows. - -Only if all three come up empty does it ask — and then it installs it for you (see -[below](#installing-the-claude-cli)). - -**An account**: a paid Claude plan (Pro, Max, Team, Enterprise) or a Claude Console account, signed in -through the plugin — or an `ANTHROPIC_API_KEY`. The free Claude.ai plan does not include Claude Code. +**A JetBrains IDE on 2025.3.1 or newer** (`sinceBuild 253.29346.138`, `untilBuild 263.*`): IntelliJ IDEA, +PyCharm, WebStorm, PhpStorm, GoLand, RubyMine, CLion, Rider, DataGrip, DataSpell, Aqua, RustRover. The floor +is hard: the chat is the IDE's embedded browser (JCEF), which from build 262 is a bundled plugin the plugin +must declare, and that module id first exists in 2025.3.1. On 2025.1, 2025.2 or 2025.3.0 stay on plugin +5.1.1, or update the IDE. -## Installation +**The `claude` CLI.** You do not have to install it yourself: if the plugin cannot find it, its first screen +offers the official install route for your OS and runs it in the IDE terminal. It looks first at **Settings +▸ Claude Code ▸ claude executable path**, then at the IDE's `PATH`, then at the usual places (`~/.local/bin`, +`~/.claude/local`, `/usr/local/bin`, `/opt/homebrew/bin`, `/usr/bin`; on Windows `%USERPROFILE%\.local\bin`, +`%APPDATA%\npm`, `%LOCALAPPDATA%\Programs\claude`, scoop, volta, Chocolatey). -From the JetBrains Marketplace: +**An account**: a paid Claude plan (Pro, Max, Team, Enterprise) or a Console account signed in through the +plugin, or an `ANTHROPIC_API_KEY`. The free Claude.ai plan does not include Claude Code. -1. **Settings ▸ Plugins ▸ Marketplace** -2. Search for **Claude Code Native** -3. Install, then restart the IDE +Some capabilities depend on IDE plugins that are bundled but optional, and degrade to "not available" +without them: Git (Git4Idea), GitHub, Java (for UAST), IntelliLang, Database Tools, Terminal, Docker and +Kubernetes, SSH, Deployment, Qodana, Package Checker. Everything else needs nothing beyond the IDE. -Or install a signed archive by hand from the -[GitHub releases](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/releases): -**Settings ▸ Plugins ▸ ⚙ ▸ Install Plugin from Disk**. +## Installation -The tool window appears on the right, next to where AI Assistant lives. +1. **Settings ▸ Plugins ▸ Marketplace**, search **Claude Code Native**, install, restart. +2. Or install a signed archive from the + [GitHub releases](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains/releases) with + **Settings ▸ Plugins ▸ ⚙ ▸ Install Plugin from Disk**. -### The plugin installs the `claude` CLI for you +The **Claude Code** tool window appears on the right. -You do not need to install it beforehand. If it is missing, the plugin's first screen detects your OS -and distribution, offers the official route, and runs it in the IDE terminal on one click. These are -the commands it uses, if you would rather run them yourself: +If the CLI is missing, the first screen installs it on one click, with these commands should you prefer to +run them yourself: ```bash -# macOS, Linux, WSL -curl -fsSL https://claude.ai/install.sh | bash - -# macOS, with Homebrew -brew install --cask claude-code +curl -fsSL https://claude.ai/install.sh | bash # macOS, Linux, WSL +brew install --cask claude-code # macOS, Homebrew ``` ```powershell -# Windows, PowerShell -irm https://claude.ai/install.ps1 | iex - -# Windows, with WinGet -winget install Anthropic.ClaudeCode +irm https://claude.ai/install.ps1 | iex # Windows +winget install Anthropic.ClaudeCode # Windows, WinGet ``` -On Debian/Ubuntu, Fedora/RHEL and Alpine the card also offers Anthropic's signed `apt`, `dnf` and -`apk` repositories, detected from the running distribution. Verify with `claude --version`. +Debian/Ubuntu, Fedora/RHEL and Alpine also get Anthropic's signed `apt`, `dnf` and `apk` repositories, +detected from the running distribution. ## First run -Open the **Claude Code** tool window. What you see first depends on what the plugin finds, and it is -re-checked every few seconds while no session is running — installing the binary or signing in -elsewhere takes effect without closing the tab. - -- **"Claude Code was not found"** — the binary is not installed, or not anywhere the plugin looks. The - card lists the official install commands for your OS (readable before you run them, because - corporate networks block installers) and has a field to point at an existing binary. -- **Sign in** — no credential is held yet. One button opens your browser; the binary itself captures - the callback. If your browser shows you a code instead of returning automatically, paste it into the - same card. There is also a field for an `ANTHROPIC_API_KEY`, and a skip button that consents to - riding your terminal's own `claude` login for the session. -- **Loading** — the binary is starting. You can switch to another chat while it does. - -### Where your credential lives - -Your sign-in is kept in the **IDE's password safe**, which resolves to whatever you have configured it -to use: the OS keychain by default (KWallet / GNOME Keyring on Linux, Keychain on macOS, DPAPI on -Windows), or the IDE's own encrypted file. - -- `claude auth login` writes `~/.claude/.credentials.json` in plaintext. The plugin **harvests that - file into the safe and deletes it**, including a login you made in your own terminal. -- **Nothing ever writes it back.** The credential reaches the binary as an environment variable, - never on a command line, never in a log or the transcript. -- Access tokens expire in hours; the refresh token is good for weeks. The plugin renews silently at - launch using the binary's own non-interactive refresh path — no browser, no prompt. The plugin holds - no OAuth client and calls no token endpoint itself. -- **Log out** clears only what the plugin holds. Your terminal `claude` login is left alone. - -Your **settings** live in the same safe, as **one document per IDE installation, per project**. Two -repositories can disagree about the model, the permission mode or a security rule, and two IDEs on one -checkout keep their own. What stays global is what a credential is: the sign-in, the account, the -per-provider API keys and the Git host tokens. - -Nothing is lost on upgrade. Before 5.5.0 settings sat in `.idea/claude-code.xml` — per project, in the -clear, and committable, environment block included; between 5.5.0 and 5.7.0 they were one global -document. Both are read as a seed, so a project with no settings of its own starts from what you -already had, and only diverges once you change something in it. The old project file is removed only -after the safe confirms it holds the copy; the global document is never removed, because it is what -every project opened from now on inherits. - -Moving between IDEs is a gesture rather than magic: **Settings ▸ Claude Code ▸ Transfer** exports and -imports a file, and migrates straight from another JetBrains IDE on the same machine. - -## User guide - -### The chat - -Each chat tab is an independent session with its own `claude` process. Type in the composer and press -`Enter`. Replies stream in token by token; tool calls appear as collapsible cards that colour by state -(in flight, finished, failed) and show elapsed time. A `Bash`/PowerShell/MCP-exec call renders the -exact command as its own copyable code block, visible without expanding the card. - -You can keep typing while a turn is running: follow-ups go into a visible queue and are sent in order. -Reasoning ("Thought process") is collapsed by default. - -#### Keyboard shortcuts - -| Shortcut | Action | -|---|---| -| `Enter` | Send | -| `Shift+Enter` | New line | -| `Shift+Tab` | Cycle permission mode (Ask each time → Accept edits → Plan) | -| `Tab` (empty composer) | Put the suggested next prompt into the field — it is not sent, you still press `Enter` | -| `Esc` | Close an open chip menu; otherwise interrupt the running turn | -| `Ctrl/Cmd+F` | Find in transcript (`Enter` / `Shift+Enter` walk the hits, `Esc` closes) | -| `Ctrl/Cmd+O` | Collapse / expand all reasoning | -| `/` (empty composer) | Slash-command palette | - -#### The composer bar - -Along the bottom: **provider · model · permission mode · effort · thinking** chips, all changeable -mid-conversation. Model and mode take effect immediately; toggling extended thinking restarts the -session behind the scenes with `--resume`, so nothing is lost. - -**Attach files** sits to their left. On the right: **Auto-scroll (follow output)**, **Vibe Mode**, and -**Send** (which becomes **Stop** during a turn). - -The model list is read from the binary's own handshake and each entry shows its real version, so new -tiers appear on their own — nothing is hardcoded. Older generations sit in a collapsed **Other -models** group. Effort runs `low · medium · high · xhigh · max`, defaulting to **high**. - -#### Attachments and context - -The attach button offers files, a directory, an image, the current selection, the open file, and a -filterable list of recently-opened files. You can also **drag an image in or paste one** — including -on native-Wayland desktops, where the plugin reads the system clipboard host-side because the embedded -browser cannot. - -From the editor, right-click gives you **Explain with Claude**, **Add Selection to Claude Context** and -**Add File to Claude Context**. - -Paths, directories and symbols in Claude's replies become links **only once the IDE has confirmed they -exist**, so a link never dead-ends. Clicking one opens the file at the line, or reveals a directory. +Open the tool window. It shows one of three cards, re-checked every few seconds while no session runs: +**Claude Code was not found** (install, or point at an existing binary), **Sign in** (one button opens the +browser; a field takes an `ANTHROPIC_API_KEY`), or **Loading**. + +Your sign-in lives in the **IDE's password safe** (the OS keychain by default). `claude auth login` writes +`~/.claude/.credentials.json` in plaintext; the plugin harvests it into the safe and deletes the file, and +the credential reaches the binary only as an environment variable — never a command line, a log or the +transcript. Settings live in the same safe, one document per IDE installation per project. + +The IDE integration is **on by default** — the flame in the chat bar is lit: all four servers, every rule. +There is nothing to configure before you start asking. + +## What you can ask for + +This is the manual. Every chapter is a kind of request in your own words, what Claude does with it and what +appears on your screen. Claude reaches the IDE through **178 tools in 55 domains**, served by four MCP +servers the plugin runs inside the IDE — `code`, `run`, `vcs`, `ops` — over Unix sockets, with no port, +nothing to install and nothing exposed. The tool-by-tool reference is +[`docs/SKILL_INVENTORY.md`](docs/SKILL_INVENTORY.md); you never need it to use the plugin, because Claude is +told the rule of every tool on every turn and picks them itself. You ask in plain language. + +Two things hold for the whole surface. **Every registered action of your IDE is reachable**: what has no +named tool is one action away, with a file, a commit or a Services node as its target, so a menu entry of a +plugin nobody here ever saw is still yours to ask for. And **everything is shown, nothing is stolen**: what +Claude opens for you lands in the IDE — the editor, the Log, the diff viewer, a tool window — while your +caret stays where it was and your Terminal keeps its tab. + +### Open it, show me, take me there + +> **"Open `SessionLauncher.kt` for me."** · **"Take me to line 120 of that file."** · **"Show me the last +> commit."** · **"Show me commit `d7351c4`."** · **"Show me the diff of this branch against `v5.7.0`."** +> · **"Show me the diff of my last change."** · **"Show me how this file looked on `main`."** · **"Find me the +> last pull request and open it."** · **"Open the Problems view."** · **"Open the Git Log."** · **"Open +> Settings at Editor ▸ Code Style."** · **"Show me `api` in Services."** · **"Open this folder in the file +> manager."** · **"Show me who wrote this."** · **"Show me the history of this file."** · **"Compare these +> two files."** · **"Show me the coverage."** · **"Open the request file `users.http`."** · **"Go back to +> where I was."** + +This is the first thing the integration is for: **you tell Claude what you want to see and it appears in +the IDE**, in the right place, without you touching the mouse. A file opens in the editor at the line you +named, or in the italic preview tab if Claude is only reading it. A commit is selected in the Git Log +(opened first if it was closed) with its details and changes; a range of commits shows only that range. +A diff opens in the IDE's diff viewer: two files, a file against the active editor, a file against how it +was at any branch, tag or commit, the whole uncommitted work, or the clipboard against a file. A pull +request is found through the IDE's GitHub account and selected in the Pull Requests view — not a browser +tab. Blame turns the annotation gutter on; a file's history opens in its tab; Local History opens its view. +The Problems view, the Build, Run, Debug, Services, Terminal and any other tool window open on request, +Settings opens at the page you name, a Services node is revealed, a file is selected in the Project view, +a path opens in the file manager, the IDE terminal or its default application, coverage shows its window, +and the navigation history moves back and forward like the arrows in the toolbar. **Your focus never +moves**: the thing appears, you keep typing. + +### Ask about what is on screen + +> *"What file am I in?"* · *"What is under my caret?"* · *"What did I have open before this?"* · *"Which +> files did I change last?"* · *"Which run configurations does this project have?"* · *"Is the index +> ready?"* · *"What is running?"* · *"What is in the clipboard versus this file?"* + +Claude knows where you are: the active file, the caret and the selection, the editor's recent and +recently-changed files, its tabs, the running processes, the run configurations, the indexing state, the +SDK, modules and dependencies of the project as Project Structure shows them, the installed plugins. So +"this file" and "here" mean what they mean to you. Questions about the project are answered from the IDE, +never from memory, and Claude says what it looked at. + +### Read and navigate the code + +> *"What does `SessionLauncher` do?"* · *"Where is `resolveHelper` called from?"* · *"Take me to the +> definition."* · *"Who implements `ToolGate`?"* · *"Show me the structure of this file."* · *"Find every +> `*Gateway.kt`."* · *"Who calls this, and who calls them?"* · *"Show me the docs of this symbol."* + +Claude reads files **as the editor holds them**, unsaved edits included, and searches text, globs and +symbols through the IDE's index instead of scanning the disk. Symbols resolve as the IDE resolves them: +definition, references, implementations, the signature and documentation of what is under a position, the +Structure-view outline of a file, the call hierarchy three levels deep. Quick Documentation opens as the +popup you know. Anything it reads is put in the preview tab so you can follow along. + +### Edit, refactor, format + +> *"Rename `adopted` to `reconcile` everywhere."* · *"Extract these lines into a method."* · *"Move +> `GodMode.kt` to the settings package."* · *"Replace `foo` with `bar` across the project."* · *"Undo +> that."* · *"Reformat and optimise the imports."* · *"Expand the `main` template here."* · *"Create a +> Kotlin class from the file template."* · *"Inline this variable."* · *"Change the signature."* + +Every edit goes through the IDE's document model — **one undo entry, saved, shown as a Before/After diff** +— so `Ctrl+Z` in the file works and Local History has it. Rename, move and safe-delete are the IDE's own +refactorings: every reference follows, a conflict refuses. The rest of the Refactor menu (introduce +variable/constant/field/parameter, extract method/interface/superclass/delegate, inline, change signature, +pull up, push down, move members, encapsulate fields) runs the IDE's refactoring at the position you name, +in place or with its dialog left open for you to finish. Undo and Redo go through the IDE's undo stack per +file; Replace in Files replaces across the project; the editor's line operations and the Code menu's +editing actions (override, implement, generate, surround, unwrap, comment, move statement or line, +rearrange, fold) run with the caret where you said; Reformat Code and Optimize Imports use the project's +code style and `.editorconfig`; live and file templates expand as `Tab` and *New* would. For structural +edits Claude can act on the **PSI tree** itself and let the IDE re-resolve and reformat the result. + +### Diagnose and analyse + +> *"Is this file clean?"* · *"Is the project clean?"* · *"What does Qodana say?"* · *"Run the inspections +> on this module."* · *"Clean up this package."* · *"What does this file depend on?"* · *"Where does this +> value come from?"* · *"Here is a stack trace, take me to it."* · *"Is this duplicated anywhere?"* +> · *"Which dependencies are vulnerable?"* · *"Where are the tests for this class?"* + +Claude reads what the IDE's analysis shows — the highlights of a file with line, column, severity and +inspection; the whole Problems view; the Qodana, Vulnerable Dependencies and Security Analysis tabs — and +**checks that it introduced no problem before calling an edit done**. It runs inspections on demand (one +by id, or Code ▸ Inspect Code on a scope into the Inspection Results window), applies Code Cleanup, walks +file dependencies (backward too, in the IDE's analysis window), opens Analyze Data Flow at an expression, +resolves a pasted stack trace to your files and opens the Analyze Stack Trace console, locates duplicates, +infers nullity, and finds what is related to a symbol: its tests, its subject, its supers, its +implementations. + +### Build, run, test + +> *"Build the project."* · *"Recompile this file."* · *"Run the `Kotlin tests` configuration."* · *"Run +> the tests in this file."* · *"Run the test on this line."* · *"Run it with coverage."* · *"Run `Server` +> and show me its output."* · *"Stop it."* · *"Open the run configuration editor on `Server`."* +> · *"Attach the debugger to that process."* + +Builds go through the Build menu — project, rebuild, module or one file — and come back with the +compiler's errors positioned, the Build window showing them. Run configurations start exactly as the Run +button starts them, before-launch tasks included, under run, debug, coverage or the profiler; their output +streams to the chat card and the Run window, and a long run answers `running` and is resumed rather than +blocking. Tests run through the IDE's test runner (a file, the test at a line, a named configuration) and +come back as pass/fail counts with each failure's message and frame, drawn in the IDE's test tree. A tool +the project has no configuration for gets one under `.idea/runConfigurations`, so what Claude runs is +something you can run too. + +### Debug + +> *"Put a breakpoint on line 85 and debug `ToolModelTest`."* · *"Step over."* · *"Step into."* · *"What +> is `arguments` here?"* · *"Evaluate `arguments.size`."* · *"Set `x` to 3 and resume."* · *"Show me the +> frames."* · *"Which breakpoints do I have?"* · *"Remove them all."* + +Claude debugs with breakpoints instead of prints: line breakpoints with conditions (temporary if you like), +a configuration started under the debugger and waited for to the first stop, threads and frames, the +variables of a frame, expressions evaluated in the debuggee, values set, steps of every kind (over, into, +out, force into, smart into, run to, resume, pause, mute) and the next stop waited for. The Debug window +shows the execution point as it goes; the gutter shows the breakpoints. + +### Run a command + +> *"Run `git status`."* · *"Run the migration script."* · *"Run `npm test`."* · *"Tail the log."* + +A command runs in the IDE's Terminal window, in a tab named **Claude**, and comes back with its exit code +and output; several commands go in one call. The tab is shown but never focused and never switched while +you are in the Terminal, and the output stays there when the tab is reused, so *View in terminal* on the +card lands on it. Claude's own `Bash` is retired while the IDE serves: a new process would cost a guard +pass and a permission, and the IDE already has a shell. -### When Claude wants to change a file - -Nothing is written without you seeing it. On the permission request the proposal opens as an -**editable diff tab** in the editor — Current | Proposed — with an inline **Accept / Reject** card in -the chat. Never a modal dialog. - -- **Edit the proposed side before accepting.** What gets written is your edited version. -- **Accept or reject the change as a whole.** Per-hunk selection was removed in 4.0.5 because - accepting an incoherent subset of an edit produced code that did not hold together. -- The diff closes on accept, reject, stop or interrupt. -- **View diff** on any past tool card reopens what that call actually wrote, at any time. -- On acceptance **the binary writes the file**, and the IDE refreshes that exact path immediately - (plus a tree rescan after `Bash` or a mutating MCP tool, which may have touched anything). - -**Undo.** Every completed Edit/Write/MultiEdit card carries a **Restore**, which asks Claude Code to -rewind the files to the turn that made that edit (probed with a dry run first); if the binary cannot, -the plugin offers to revert them itself from its own pre-write snapshot, with a confirmation you can -tell it to remember. - -Reverting a write that *created* a file removes that file, which is the only way to undo a creation. +### Git -To see everything a long run touched rather than one edit at a time, use ⚙ ▸ **Review This Session's -Changes…**, which diffs the whole session against its base. Undoing a *commit* is [Git](#git), below. +> *"What changed?"* · *"Show me the last five commits."* · *"Commit these two files."* · *"Create a branch +> from here."* · *"Push."* · *"Cherry-pick `a1b2c3d`."* · *"Revert that commit."* · *"Rebase this branch +> onto develop."* · *"Merge develop into this."* · *"Stash this, pop it later."* · *"Roll back this +> file."* · *"Make a patch of my changes."* · *"Add a worktree for `hotfix`."* · *"Open the merge +> dialog."* + +Git is read as the IDE sees it — status with upstream ahead/behind, log, diff, branches — and written +through the IDE's Git: stage, commit (signed and hooked as your Git configures, the IDE answering any +prompt), branch, fetch, pull, push with the IDE's credentials. The **Log's commit menu** works on any hash +(cherry-pick, checkout, browse at revision, compare with local, reset, revert, undo, reword, fixup, squash, +drop, interactive rebase, push up to, new branch or tag, copy revision, open in browser): the commit is +selected in the Log and the action runs with the Log's own context, and anything that rewrites history +opens the IDE's dialog for you to finish. The **Branches popup** (merge, rebase, compare, diff with local, +rename, delete, checkout, checkout as new, new tag) goes through the IDE's branch machinery with its +progress and conflict resolution; so do worktrees and remotes. Uncommitted work: stash, the IDE's shelf, +patches, rollback. A file's past: blame with the gutter shown, history in its tab, Local History with labels +you can revert to, and its content at any ref diffed against the working tree. Every entry of the Git menu +and its GitHub and GitLab submenus opens by name for you to finish. + +### Pull requests and releases + +> *"Find me the PR."* · *"Show me the open pull requests."* · *"Open #74 in the IDE."* · *"Open a PR from +> this branch to develop."* · *"Comment on it."* · *"Is the CI green?"* · *"Merge it when it is."* · *"Is +> v6.0.0 tagged, released, and on the Marketplace?"* + +Pull requests come **through the IDE's own GitHub account** — no `gh`, no token of its own — and are +shown in the IDE's Pull Requests view with the row selected. Claude lists them (open, closed, merged), +reads one with its branches, review decision and description, creates one from named branches, comments on +it, reads its mergeability and every check on its head commit polling until they settle, and merges it with +a merge commit only once the state is clean and no check failed — saying so first when the target branch +publishes on merge. It then verifies what a release left behind: the tags, the GitHub Actions runs of a +branch, the GitHub Release of a tag with its assets, and the plugin's versions on the JetBrains +Marketplace. This plugin's own releases are driven that way. GitLab merge requests live in the IDE's GitLab +view and actions. + +### Services, containers, databases, HTTP, SSH + +> *"Which containers are there?"* · *"Start `api` and show me its log."* · *"Stop the cluster."* · *"What +> can I do on this node?"* · *"Which data sources are configured?"* · *"What tables does `orders` have?"* +> · *"Run `select count(*) from users`."* · *"Run the requests in `users.http`."* · *"Which SSH hosts does +> the IDE know?"* · *"Open an SSH session to `staging`."* · *"Upload this to the deployment server."* + +The **Services** window is the DevOps panel and Claude sees it as you do: the tree of every contributor +(Docker containers, images, networks, volumes, Kubernetes, run dashboards, database sessions, whatever your +plugins add), the actions the IDE offers on a node with whether each is enabled right now, and a node's +console text. It performs an action exactly as clicking it would, with the node selected, so the plugin's +own enablement decides — and reveals the node so you see what happened. Databases: the data sources, the +schema the IDE introspected, SQL over the IDE's connection. HTTP Client: the project's `.http` files run +through their run configuration with the response console, or opened in the editor. SSH hosts as the IDE +knows them (never the secret), SSH sessions, Deployment (upload, download, sync, compare, browse, +configure), Qodana and the Package Checker go through those plugins' own actions when they are installed. + +### Drive the IDE itself + +> *"Open the Problems view."* · *"Close the Terminal."* · *"Turn on presentation mode."* · *"Hide the +> navigation bar."* · *"Split this tab to the right."* · *"Pin this tab."* · *"Zoom the editor in."* +> · *"Show whitespace."* · *"Switch to the Darcula theme."* · *"Is the Kotlin plugin installed?"* +> · *"What does Code ▸ Analyze hold?"* · *"Run the action `ReformatCode` on this file."* · *"Generate +> the Javadoc."* · *"Open the Groovy console."* · *"Show me the bytecode of this Kotlin file."* + +Claude can list every action your IDE registers with whether it is enabled in context, walk the main menu +as you see it, and dispatch any action by id on a file, a commit or a Services node. It opens and closes +tool windows, opens Settings at a page, lists the plugins, flips View ▸ Appearance (presentation, +distraction-free, full screen, zen, compact, the Presentation Assistant) and the interface parts (toolbar, +navigation bar, tool window bars, status bar, main menu), manages editor tabs and the tool window layout, +zooms the editor or the whole IDE, toggles line numbers, whitespace, soft wraps and gutter icons, keeps +bookmarks, moves through the navigation history, and lists or switches the theme, colour scheme, keymap +and code style. The Tools menu's generators (Javadoc, the command-line launcher, XML validation and schema +generation, Markdown import and export) and consoles (Groovy, Kotlin bytecode, Python) open as their +entries would. + +### Leave marks for me + +> *"Highlight the lines you are unsure about."* · *"Put a warning on line 40 with why."* · *"Ask me in +> the file which of the two I want."* · *"Bookmark the places you changed."* · *"Notify me when the build +> is done."* · *"Put it in a scratch file."* + +Claude can point at code without editing it: highlighted, warning or error ranges, gutter icons with a +tooltip, inline hints, all in every editor of the file and all gone when the session ends. It can ask you +something **where you are reading** — a banner over the file's editor with the choices as buttons — put a +line in the status bar, open a scratch file that is never committed, set bookmarks on files and lines, and +raise a balloon in the IDE's notification area so you see it without reading the chat. + +### The IDE's internals + +> *"Show me the PSI tree of this function."* · *"What does UAST see at this line?"* · *"Which files does +> the `FilenameIndex` hold under this key?"* · *"Which languages are injected in this file?"* · *"List the +> source roots from the workspace model."* + +For the rare request that needs the IDE's model as data: the PSI tree and the element at a position, the +UAST (the AST unified across Java, Kotlin, Scala and Groovy, on IDEs with the Java plugin), the file-based +and stub indexes by name, the injected language fragments of a file and a temporary injection at a +position, and the workspace model's modules, roots, libraries and SDKs, read-only. + +## How it shows up in the IDE + +**The mirror.** One switch, on by default — **Settings ▸ Claude Code ▸ Claude IDE Integration ▸ Mirror +Claude's work in the IDE**. What Claude reads opens in the preview tab, what it edits in a real tab, a +commit it names is selected in the Log, a service in Services, a problem in its tab, a run in its window; a +range it points at flashes; the focus stays where you left it. Where the platform steals focus anyway, the +plugin hands it back. Turn on **View ▸ Appearance ▸ Presentation Assistant** and every action Claude fires +is announced on screen. + +**The cards.** Every call is a card in the chat: one per item when a list was passed, live lines for the +long ones, a diff and a Restore for edits, and a one-click link into the IDE — the commit, the Log, the +tool window, the terminal tab, the run, the problem, the diff. Results come back as compact tables (TOON), +which is what keeps the token bill low: the integration costs *less* than the native tools it replaces, +because a file read is one call where a `cat` was a process plus a permission. + +**God Mode.** The flame in the chat bar is the whole integration in one switch: all four servers and every +rule. **Settings ▸ Claude Code ▸ Claude IDE Integration** fine-tunes it — the servers, whether an +unexpected client must be approved, the mirror, and each rule per server. Each rule is one instruction in +Claude's system prompt, repeated every turn, naming which tool replaces which native one, so a session does +not drift back to `grep` and `sed`. An upgrade that adds domains switches their rules on. + +**The guard sits inside the servers.** Every tool call is judged by the [Sensitive Guard](#security) before +it runs; a refusal comes back as the tool's error naming the rule and the string that tripped it, and the +answer is to change that string, never to switch tool. + +## The chat + +Each tab is an independent session with its own `claude` process — the binary's own sessions, so they are +the same conversations you see from a terminal. Replies stream; tool calls are collapsible cards coloured by +state; reasoning is collapsed by default; follow-ups typed during a turn queue in order. The composer bar +carries provider, model (read from the binary's handshake, nothing hardcoded), permission mode, effort and +thinking, all changeable mid-conversation, plus attachments (files, a directory, an image, the selection, +recent files, paste or drag an image) and the flame, the shield and the phone. + +**When Claude wants to change a file** the proposal opens as an editable diff tab — Current | Proposed — +with Accept / Reject in the chat, never a modal. Edit the proposed side before accepting; what gets written +is your version. Every completed edit card carries **Restore**, and ⚙ ▸ **Review This Session's Changes…** +diffs the whole session against its base. Permission modes: **Ask each time**, **Accept edits**, **Plan**, +**Bypass permissions** — and whatever the mode, the guard decides first. + +**Agents** get their own tabs and transcripts; their tool calls draw under the Task card that spawned them. +**Workloads** draws everything running across every chat as one tree. Background tasks keep their output +after they end. The **Session** view shows context, tokens, cost, plan limits, account, MCP health; the +**Plan** view shows the plan-mode document; the **Git** view is a chat dedicated to the repository with +*Commit with Claude* and *Revert this file with Claude* as bounded, hand-approved prompts. + +**Sessions**: Open Previous Session…, Rename, Fork, restore-on-startup. The plugin stores no transcripts of +its own and **never deletes a conversation** — there is no delete action, and a source contract bans +recursive deletion anywhere in the codebase. **Remote Control** (the phone button) connects a chat to +claude.ai/code or the mobile app; the guard still decides first, on this machine. + +Keyboard: `Enter` send · `Shift+Enter` newline · `Esc` close a menu or interrupt · `Ctrl/Cmd+F` find · +`Ctrl/Cmd+O` fold all reasoning · `/` the slash-command palette · `Tab` on an empty composer takes the +suggested next prompt. -#### Permission modes +## Security -The mode chip decides how often you are asked: +The plugin ships a **deterministic sensitive-data lock**, `SensitiveGuard`: out-of-band Kotlin with no model +input, evaluated before any auto-approval, for the agent's native tools *and* for every IDE tool call +inside the four servers. There is no prompt that argues it into a yes, and prompt injection is assumed to +succeed rather than detected — which is why it judges the tool call, never the reasoning. -| Mode | Behaviour | -|---|---| -| **Ask each time** (default) | A card for every tool call | -| **Accept edits** | File edits auto-approved; the diff still opens so you can see it | -| **Plan** | Claude proposes a plan and waits for you before doing anything | -| **Bypass permissions** | No cards, except where the security lock demands one | -| **Don't ask** · **Auto** | The binary's own additional modes, available from the chip menu | - -`Shift+Tab` cycles the first three, matching the CLI. Whatever the mode, the -[security lock](#security) is evaluated **first** and cannot be switched off — at most, a rule you -disable in Settings turns an automatic block into a card you must answer. - -Other request types render inline too: **AskUserQuestion** as option cards with wrapped labels and -descriptions, and **MCP elicitation** as a form built from the server's schema (a URL flow is gated to -`http`/`https`, so an untrusted server cannot reach `file:` or `javascript:`). - -### Agents, subtabs and Workloads - -When Claude spawns agents, **each gets its own tab and its own transcript**, so its thinking and tool -calls stay out of the main conversation. Before 5.5.0 a session running dozens of agents put all of it -in one transcript, interleaved and unfollowable. - -- The bar under the chat tabs shows which transcript you are reading. -- Resting on a chat's tab for a second — or clicking its `⋮` — opens the whole tree at once: agents, - their agents, and the background tasks each of them started. Clicking any row goes there. -- **A finished agent keeps its tab**, marked finished. Reading why something failed is the point. -- **Closing a subtab hides a view; it destroys nothing.** The card that spawned it opens it again. -- **Pin** turns the subtab you are reading into a tab of its own, next to the chats. - -**Workloads** — one of the view buttons in the tab bar — draws everything running across *every* open -chat as one diagram: chats at the root, agents beneath them, tasks under whoever started them. Every node -is somewhere you can go, and a running task can be stopped from there. - -### Background tasks - -The binary stops listing a background task the moment it ends — which is exactly when its output is -worth reading. So the plugin keeps its own record: the task, its command and its output survive the -task's death, are tailed live from the file the binary writes, and are rebuilt from the session -transcript after an IDE restart. - -### The dashboard and your plan limits - -The tab bar carries the dashboard's view buttons: **Chat** (the way back out), **Session**, **Workloads**, -and — only while the session has that surface to show — **Git** and **Plan**. One view at a time; the -button that is lit is where you are. - -The **Session** view shows what the current session is doing and costing: the context breakdown by -category, token usage and cost (input / output / cache read / cache write, in USD when the binary -reports it), your plan's limit windows with the time left on each, the account you are signed in as -(email / organisation / plan / provider), the active model, the working directory, the binary version, -and MCP server health with per-server reconnect and enable/disable. - -**Plan** is the plan-mode document, on its own rather than as a card among the numbers — prose you go -back and re-read while working. Its button appearing is also how you learn one has been written. - -Your plan limits also sit as small labelled bars under the composer, so you can see them without -opening anything: **blue below 65%, amber below 85%, red at or above**. They refresh every 30 seconds -whether or not the chat is on screen — a window can reset, or fill up from another device, while you -are looking elsewhere. - -Above them, a status line always carries the same session's numbers: running or idle, context used, -tokens out, the live reasoning-token estimate, and the cost in USD once there is any. In the transcript, -a collapsible "Recalled N memories" row names which memories (scope · path · content) influenced a turn. - -### Sessions - -Chats **are** the binary's own sessions, stored in its own files, so they are the same conversations -you see from the terminal. From the tool window's gear menu: - -- **Open Previous Session…** — every past chat for this project, by the title Claude gave it, reopened - with its transcript via `--resume`. -- **Rename Session…** and **Fork Session** — fork branches the conversation into a new tab from the - same history. -- **Session Info**, **Agents**, **Binary Version…**, **Effective Settings…**, **Add Current File as - @-context**, **Settings…**. - -Chats you had open are restored when the IDE starts (switchable in Settings). **The plugin stores no -transcripts of its own** — only which tabs were open. - -**The plugin never deletes your conversations.** There is deliberately no "delete session" action. This -is pinned by a source contract (`NoFileDeletionContractTest`), written after an earlier release -destroyed a user's history: **recursive deletion is banned outright anywhere in the codebase**, and a -single-file deletion is allowed only in the handful of source files that contract names, each for one -purpose — and every file any of them removes is one the plugin itself wrote: - -- `~/.claude/.credentials.json`, once it has been harvested into the keychain — that removal *is* the - feature; -- the plugin's own superseded settings and bookkeeping files, after their contents have been adopted and - the new location has confirmed the write: `.idea/claude-code.xml` and - `~/.claude/ide/claude-code-native/settings.json`. - -Nothing else in the plugin can call a delete at all; the build fails if it tries. - -The one other thing the plugin can remove is a file that an `Edit`/`Write` *created*, and only when you -press **Revert** on it — undoing a creation means removing it, not leaving a zero-byte husk. Nothing else -on your disk is ever removed, and nothing you authored is. - -A chat that needs you while you are looking elsewhere — a permission, a finished turn, an error — -raises a notification and badges its tab. Suppressed for the chat already on screen. - -### Remote Control — pick a chat up from your phone - -The **phone button** in the chat's button row, left of the guard's shield, connects that chat to -[claude.ai/code](https://claude.ai/code) or the Claude mobile app. The same switch lives in the ⚙ menu -under **Remote control**. Claude keeps running on your machine the whole time: your files, your MCP -servers and your project configuration stay the ones in use, and the browser or the phone is a second -window onto the conversation already open in the IDE. When the connection carries a session URL, the -chat prints it. +It classifies **credential and key material** (SSH and GPG keys, cloud and cluster credentials, database +and shell-history secrets, browser and password-manager stores, wallets, agent and code-host tokens), +**dangerous commands** (credential dumps, exfiltration, network-piped-to-shell, LOLBINs, offensive tooling) +and **foreign territory** (another user's home, network mounts, non-`/mnt/c` WSL drives), with structural +patterns that cover Linux, macOS, Windows and WSL, on canonicalised paths and de-obfuscated commands. The +whole input object is walked for path-like values, payload keys included. -Messages go both ways, and so do permissions — a call you approve from the phone stops asking here, and -its card leaves the chat by itself. The **Sensitive Guard still decides first**, on this machine, before -anything is offered to anyone to approve: connecting a phone does not widen what a chat is allowed to -do, only who can answer it. That is worth being deliberate about, because the person tapping *Approve* -is no longer necessarily sitting in front of the diff. +It decides by trust of the caller: the agent's own tools get an explicit card every time; third-party MCP +servers and Skills are denied outright; foreign territory is denied for everyone. **Settings ▸ Claude Code +Security** holds a mode for the guard as a whole, a mode per rule by category, three whitelists (a command +prefix, at the reach of a rule, a category or everywhere) and extra credential globs and blocked domains. +Turning a rule off never allows silently: a hit becomes a card you must answer. The shield in the chat bar +suspends the guard for a chosen duration; it is on by default and unlit whenever it is not. -**It has to be enabled for your Claude account**, and on Team and Enterprise plans an organisation Owner -has to turn it on first; the feature needs a claude.ai subscription, so an API key will not do. When the -request is refused the button turns red, its tooltip carries the reason, and the same reason is written -into the chat. Neither control switches itself on until the CLI has said yes, so a lit button means a -live connection. +The permission mode is the plugin's, never the binary's: `acceptEdits` and `bypassPermissions` are +translated to `default` on the command line, so every call arrives as a control request and the verdict +stays the plugin's. The guard's rules and their mechanics are in +[`docs/SECURITY-GUARD.md`](docs/SECURITY-GUARD.md); the threat model is [ADR 0002](docs/adr/0002-threat-model.md); +the full policy and how to report a vulnerability is [`SECURITY.md`](SECURITY.md). -Turning it off, from either control, disconnects the remote session and leaves the chat running here. +**Telemetry: none.** No analytics, no crash reporter, no usage counter. Your conversation goes from the +`claude` binary to Anthropic over the channel it already uses; the IDE servers listen on Unix sockets with a +token generated per session and rotated while it runs; the only other traffic goes to your own forge +through the IDE's account, and to the public Marketplace API when you ask about releases. -### Git +## Settings that matter -The **Git** button in the tool window's title bar opens a chat dedicated to the integration, and with it -the dashboard's **Git** view: where `HEAD` is, what can be done to the repository, and its recent history. -Entries are there only when the IDE's Git plugin is enabled, and each one hides itself when it does not -apply — absent rather than greyed out, re-derived every time the menu opens, so creating a repository or -enabling the Git plugin takes effect without reopening anything. - -**Reading** is three gear entries, all of which hand off to the IDE's own Git UI rather than drawing -another one: - -- **Recent Commits on ``…** — the label names the branch you have checked out, so the menu itself - answers "which branch is Claude working on". Opening it lists the last 20 commits of the repository your - project lives in, one line each: short hash, subject, author, age, and how many files it touched. - Choosing one opens the IDE's Git Log. -- **Git History for the Current File** — hands the file in the active editor to the IDE's own file-history - view. Only for a file inside the project: anything outside it is refused, by the same canonical, - symlink-resolving check the write path uses. -- **Open Git Log** — brings up the IDE's Version Control tool window. - -The package behind all three is **read-only, and it is the code that says so**: no ref moves, no history -rewriting, no remote traffic, and it never runs `git` itself. A source contract -(`GitReadOnlyContractTest`) enforces that — an allowlist of four read-only APIs, plus a scan for the -symbols that would mean it had grown its own way to execute Git. Adding a write path fails the build. - -**Changing the repository** is offered three different ways, and which way an action gets is the design: - -- **Claude does it.** *Commit with Claude* and *Revert this file with Claude* — in the Git view, and in - the gear menu as **Commit Changes with Claude** and **Revert This File with Claude** — run no `git`. - Each puts a bounded prompt into the Git chat and lets Claude do the work, so the command is on screen in - an approval card before it runs and you can answer the tab ("squash those two", "not that file") - instead of getting one shot at a button. That tab's turns are **always approved by hand**, whatever - permission mode you are in and whatever you have marked "Always allow": the plugin started the turn, so - it does not inherit permissions you granted for your own work. -- **The IDE does it.** Branches, pull, fetch, push, merge, rebase, stash, unstash and the commit dialog - are under ⚙ ▸ **Git Operations**, and those entries *are* the IDE's own actions — same dialogs, same - shortcuts, same enablement. They are there because the IDE does them better than a chat card would, and - reimplementing them would only make them worse. -- **The plugin does it, once.** *Initialize repository*, offered in the Git view on a project that is not - a repository yet, runs `git init -b main` itself. It is the only `git` this plugin ever runs: a fixed - argument vector with no shell involved and nothing of yours in it, deliberately outside the read-only - package. `-b main` rather than a bare `git init`, which still lands on `master` unless you have set - `init.defaultBranch`. Being the plugin spawning a process rather than Claude asking for a tool, - **the [sensitive-data lock](#security) does not see it**: that guard sits on the tool requests the - binary makes, and this is not one. So the exception is exactly one command, on an empty directory, - behind a menu entry that only appears where there is no repository to damage. - -Those two facts do not contradict each other: the read-only contract is a claim about the `git/` package, -and it still holds — the one direct execution lives in `ui/`, outside it, on purpose. No gate was -bypassed. - -The plugin builds no Git UI of its own — the commit list is a picker, not a viewer, and everything you act -on is the platform's own Git Log, in your theme and with your shortcuts. Nothing here is sent to Claude -unless you pick an action that asks it something. - -### Settings that matter - -**Settings ▸ Claude Code** (one page, grouped by subject): +**Settings ▸ Claude Code**, one page grouped by subject: | Setting | Default | Why you would change it | |---|---|---| -| Model · permission mode · effort · thinking | top Opus tier · Ask each time · high · adaptive on | The launch defaults for every new chat | -| **claude executable path** | auto-detect | A non-standard install, or a GUI IDE that does not inherit your `PATH` | -| **Provider** | Anthropic | DeepSeek's Anthropic-compatible endpoint. Each provider's key is stored separately in the safe; an `sk-ant-` key is rejected in a third-party slot so your subscription can never leak to another endpoint | -| **Sensitive Guard** | every rule Enforcing | Its own page since 5.7.0 — **Settings ▸ Claude Code Security**: a mode for the guard as a whole, a mode per rule grouped by category, the three whitelists, and the extra credential globs and blocked domains. See [Security](#security) | -| **Restore open chats on startup** | on | Start with a single empty chat instead | -| **Allowed / disallowed tools**, **Always-allowed tools** | empty | Stop being asked about a tool; revocable here. This one list stays shared by every project — most settings are per project since 5.7.0, but a remembered tool approval is about the tool, not the repository. The Sensitive Guard still decides first: nothing here bypasses it | -| **Environment variables**, **Source script** | empty | Seed the binary's environment. The source script is *executed* at session start, so it — and any custom `stdio` MCP server — is gated behind a per-project trust prompt the first time | -| **Reduce motion** | off | Flatten the chat's animations | -| **Advanced launch** | flags omitted | `--max-turns`, `--max-budget-usd`, `--fallback-model`, extra `--add-dir` roots, beta flags, strict MCP config | -| **IDE tools (MCP)** | off | Below | - -### IDE tools (MCP) — optional, off by default - -Let Claude query the IDE directly (diagnostics, open files, usages, …) through JetBrains' own MCP -server. Two steps: - -1. **Enable JetBrains' MCP Server plugin** (Settings ▸ Plugins) and confirm it is running. -2. In **Settings ▸ Claude Code**, tick *Enable JetBrains MCP server*, pick the **transport** - (`sse` by default, or `streamable-http` / `stdio`) and the **port** if you changed it from `64342`. - Apply, then start a **new chat** — the setting is applied when the `claude` process launches. - -You can also register **custom MCP servers** as a JSON object of `name → server`; both are merged into -a single `--mcp-config`. Invalid JSON blocks saving. - -> **Security.** `sse` and `streamable-http` use JetBrains' localhost endpoint, which any process on -> your machine can reach; `stdio` launches a helper process instead. Enable only on a machine you -> trust. Every IDE tool call is still gated by the permission card *and* by the -> [sensitive-data lock](#security) — and MCP servers are third-party callers there, so a credential -> hit from one is denied outright. - -## Security - -The plugin ships a **deterministic sensitive-data lock** (`permission/SensitiveGuard`). It is not a -model-side guardrail: the classification is out-of-band Kotlin with no model input, evaluated in -`PermissionBroker.handle` **before any auto-approval branch**. There is no prompt that argues it into -a yes. - -The permission mode you pick is the *plugin's*, never the binary's — `acceptEdits` and -`bypassPermissions` are translated to `default` on the command line, so every call still arrives as a -control request and the verdict stays the plugin's to make. Auto-approval is something the plugin then -chooses to do, which is what lets the lock hold in the modes whose whole point is not being asked. - -**What it classifies** - -| Category | Examples | -|---|---| -| Credential / key material | SSH and GPG keys, cloud and cluster credentials, database and shell-history secrets, browser and password-manager stores, crypto wallets, AI-agent and code-host tokens | -| Dangerous commands | Credential dumps, file exfiltration, network-piped-to-shell, LOLBINs, recognised offensive tooling | -| Foreign territory | Another user's home, UNC / network mounts, non-`/mnt/c` WSL drives | - -Patterns are **structural**, so one rule covers Linux, macOS, Windows (`C:\Users\…\.ssh`) and WSL -(`/mnt/c/Users/…`). The whole input object is walked for path-like values — not a fixed key list — so -an MCP tool naming its argument `target` or `destination` is still covered. Paths are canonicalised on -disk (symlinks, `..`) and commands go through a de-obfuscation stage (broken quotes, `$IFS`, variable -substitution, base64 payloads) before matching. - -**How it decides** — by trust of the caller, as an allowlist: - -- the agent's **own tools** → an explicit permission card, **every time**, in every mode; -- **MCP servers and Skills** → denied outright; third-party code has no business reading your keys; -- **foreign territory** → denied for every caller, trusted or not. - -**Per-rule switches** (Settings ▸ Claude Code Security, its own entry in the settings tree, kept per -project). Every rule can be turned off independently, and so can a whole category at once — all **on** by -default. Turning one off is never a silent allow: detection still runs, and a hit is only *downgraded* from -an automatic deny to a permission card, shown every time, to every caller. Every card names the rule and the -Settings path. - -**One switch above all of them**: a shield in the chat's button row, and the same control on that page, -turns the guard off for a chosen duration — 5 minutes up to *Forever*, five of the seven choices expiring on -their own. It is **on** by default, the shield is unlit whenever it is not, and while it is off the guard -evaluates nothing at all. - -**Whitelisting a command** is the narrow alternative to switching a rule off: an exact command, matched whole -and de-obfuscated on both sides, at one of three reaches — that rule, that category, or everywhere. Any rule -can be whitelisted, and a blocked call offers a **Whitelist Command** link that files the command under the -rule that stopped it. - -The built-in sensitive-path list is additive only by construction: it can be widened with extra globs and -can never be shrunk. Paths under the project root are exempt from both the credential and -the foreign rules — your repository is the sanctioned zone — and your own home is exempt from the -foreign rule alone, so the credential globs still cover it. Dangerous-command classification is -location-independent. A session refuses to start at all when the project itself sits on a remote or -network-mounted path. - -Detecting a path concealed inside an arbitrary shell string is best-effort and gets widened over time; -the **enforcement** of a match is absolute. Separately, jump-to-code links can only ever open inside -the project or your own home (canonical, symlink-safe), while the **write** gate stays project-only. - -The threat model is written down in [ADR 0002](docs/adr/0002-threat-model.md), including what it does -*not* defend against: **prompt injection is assumed to succeed, not detected**, which is precisely why -the lock judges the tool call and never the model's reasoning. Full model and reporting policy in -[`SECURITY.md`](SECURITY.md). - -**Telemetry: none.** The plugin collects nothing about you and sends nothing to us — there is no -analytics endpoint, no crash reporter and no usage counter. Your conversation goes from the `claude` -binary to Anthropic over the same channel it already uses in your terminal. The only other network -traffic the plugin makes is optional and goes to **your** forge: give it a GitHub or GitLab token and -it asks that server about the branch you are on, to show you your own pull requests and CI status. -None of that reaches us either. +| Model · permission mode · effort · thinking | top Opus tier · Ask each time · high · adaptive | The launch defaults for every new chat | +| **claude executable path** | auto-detect | A non-standard install, or an IDE that does not inherit your `PATH` | +| **Provider** | Anthropic | An Anthropic-compatible endpoint; each provider's key is stored separately, and an `sk-ant-` key is refused in a third-party slot | +| **Claude IDE Integration** | on: four servers, every rule, mirror on | Turn a server or a rule off, require approval for unexpected clients, stop mirroring; the flame in the chat bar is the same switch | +| **Sensitive Guard** | every rule Enforcing | Its own page, **Settings ▸ Claude Code Security** — see [Security](#security) | +| **Restore open chats on startup** | on | Start with a single empty chat | +| **Allowed / disallowed tools**, **Always-allowed tools** | empty | Stop being asked about a tool; the guard still decides first | +| **Environment variables**, **Source script** | empty | Seed the binary's environment; the script runs at session start behind a per-project trust prompt | +| **Custom MCP servers** | empty | Your own servers as a JSON object, merged into one `--mcp-config` | +| **Advanced launch** | flags omitted | `--max-turns`, `--max-budget-usd`, `--fallback-model`, `--add-dir`, betas, strict MCP config | + +Settings are per IDE installation and per project; credentials and host tokens are global. **Transfer** +exports and imports a settings file and migrates from another JetBrains IDE on the same machine. + +## Any MCP client can drive the IDE + +The four servers are ordinary MCP servers that happen to live inside the plugin. Claude Code is their first +client, not their only one: anything that speaks MCP can open the socket, authenticate with the session's +token and run the same tools under the same guard. The bundled stdio bridge and the socket protocol are in +[`docs/MCP_CLIENT.md`](docs/MCP_CLIENT.md). When the chat page cannot be shown at all, the servers still +start and a notification carries the configuration to paste into your client. ## Troubleshooting | Symptom | Usually | |---|---| -| The chat never loads, or the tool window is blank | The embedded browser (JCEF) is unavailable. Below build **253.29346.138** — so on 2025.1, 2025.2 and 2025.3.0 (`253.28294.334`) — this version does not run at all; see [Requirements](#requirements). Otherwise check the `ide.browser.jcef.enabled` registry key | -| "Claude Code was not found" with the binary installed | It is somewhere the plugin does not look, or the IDE did not inherit your `PATH`. Paste the full path into the card, or set it in Settings | -| Signed out again after a restart | The stored credential could not be renewed. Sign in again from the card, and check the IDE can reach your keychain | -| A tool call is refused with no card to override it | The [security lock](#security) blocked it. The message names the rule and the Settings path; foreign-territory blocks are absolute by design | -| A chat is empty after reopening it | The session file is gone from `~/.claude/projects/…`, or the working directory changed. The plugin keeps no transcripts of its own | -| The agent seems stuck | `Esc` interrupts the turn. If a tool card sits running forever, its agent's tab shows what it was actually doing | -| Leftover diff tabs | They are real editor tabs, not modals. **Close All Diffs** in the Claude Code tool window's title bar closes every one the plugin opened | - -Deeper cases, with log locations and commands: -[`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) and [`docs/FAQ.md`](docs/FAQ.md). - -Bugs and features: open an issue with the templates in +| The chat never loads, or the window is blank | JCEF is unavailable: below 2025.3.1 this version does not run; otherwise check `ide.browser.jcef.enabled` in the Registry | +| "Claude Code was not found" with the binary installed | It is somewhere the plugin does not look, or the IDE did not inherit your `PATH`; paste the path into the card | +| A tool call is refused with no card to override | The guard blocked it; the message names the rule and the Settings path. Foreign-territory blocks are absolute by design | +| Claude uses `Bash` or `grep` although the IDE tools exist | The flame is off, or a rule is: turn God Mode on, or the rule in Settings ▸ Claude Code ▸ Claude IDE Integration | +| A domain is missing from the servers | The IDE plugin behind it is not installed or disabled (Git, GitHub, Java, Database, Terminal…) | +| A commit or Services action answers "not enabled here" | The view had not been shown yet; ask again, the view is now open, or open it yourself | +| Signed out after a restart | The credential could not be renewed; sign in again, and check the IDE reaches your keychain | + +Deeper cases with log locations: [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) and +[`docs/FAQ.md`](docs/FAQ.md). Bugs and features: the templates in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE). Vulnerabilities: [`SECURITY.md`](SECURITY.md). ## Build from source -Requires **JDK 21** — the Gradle toolchain is pinned to it, because the IDE runs on JBR 21. The Gradle -wrapper is included. - -```bash -JAVA_HOME=/path/to/a/jdk-21 ./gradlew buildPlugin -# → build/distributions/claude-code-native-5.8.0.zip -``` - -Install it with **Settings ▸ Plugins ▸ ⚙ ▸ Install Plugin from Disk**. - -```bash -./gradlew runIde # sandbox IDE with the plugin loaded -./gradlew test # unit + headless + integration (JVM) -./gradlew koverVerify # coverage gates (blocking in CI) -./gradlew detekt spotlessCheck -./gradlew verifyPlugin # IntelliJ plugin verifier across the declared range -./gradlew checkDrift # protocol drift vs. the latest SDK + binary -npm test # frontend suite (vitest + jsdom) -npm run lint && npm run format:check -npm audit --omit=dev # the distributed scope; must be clean -``` - -`checkDrift` needs a real `claude` binary and looks in `~/.local/bin` by default — point it elsewhere -with `-PclaudeBinary=/usr/bin/claude` (or the `CLAUDE_BINARY` environment variable). It is **not** -wired into `check`: it updates the SDK and the binary to latest, which is a deliberate act, not a side -effect of running the tests. - -`verifyPlugin` can run **fully offline** against locally extracted IDEs: - -```bash -./gradlew verifyPlugin -PlocalIdePath=/path/to/idea-A,/path/to/idea-B -``` - -### Testing - -The suite is a real pyramid: - -- **unit** (pure JVM) — protocol parse/build, diff reconstruction, the exhaustive `PermissionBroker` - and `SensitiveGuard` matrices, hunk encode, path-traversal guards, settings enums; -- **headless component** — `BasePlatformTestCase` in-process, for the project services and settings UI; -- **integration** — a real `ClaudeSession` driven against the deterministic `bin/fake-claude` stand-in - with JSONL fixtures; -- **UI end-to-end** — RemoteRobot against a real IDE, gated behind `-PuiTest.enabled=true` (see - [`docs/UI_TESTING.md`](docs/UI_TESTING.md)); -- **frontend** — vitest + jsdom loading the real inlined `src/main/resources/jcef/*.js`, including a - JS↔CSS class contract and an accessibility contract. - -CI has no `push` trigger — deliberately, so one commit does not pay for two identical pipelines; the pull -request is the door, and a branch with no pull request gets no checks. The gate is **not uniform**: a pull -request into `develop` runs the JVM suite (with `koverVerify`) and the frontend suite; the expensive half — -static analysis, `npm audit --omit=dev`, `verifyPlugin` and the artifact assertions — runs at the -`develop → main` door, which is the merge that publishes. The UI end-to-end suite answers only to a nightly -schedule and a manual dispatch, and is never a required check. CodeQL runs on pushes to both branches as -well as on pull requests, and both CodeQL and the protocol-drift check run weekly. - -## How it works - -The plugin speaks **directly with the `claude` binary** over its `stream-json` + control stdio -protocol — no Node.js and no TypeScript SDK at runtime. One long-lived process per chat handles -streaming input and output; `can_use_tool` control requests are answered by the plugin, so **the binary -writes the file** only after your approval. - -Nothing is mirrored from terminal output. Every state — compaction, cost, hooks, subagents, MCP health -— is reconstructed natively from the protocol's structured fields. - -The TypeScript SDK package under `node_modules/` is kept as a **protocol reference only** and is never -distributed. `./gradlew checkDrift` updates the SDK and binary to latest and reports any protocol kind -the plugin does not model yet. - -Architecture, protocol details and the empirically verified facts about the binary's behaviour are in -[`CLAUDE.md`](CLAUDE.md); where each thing lives is in [`PROJECTMAP.md`](PROJECTMAP.md). - -## What's new - -**5.8.0** — [Remote Control](#remote-control--pick-a-chat-up-from-your-phone): a chat running in the IDE -can be driven from claude.ai or the Claude mobile app while Claude keeps executing on your machine. A -phone button in the chat's button row and a row in the ⚙ menu, neither of which lights up until the CLI -has accepted; a refusal names its reason on the button and in the chat. Permission cards answered from -the phone close themselves here. - -**5.7.0** — the [Sensitive Guard](#security) stops being invisible: it keeps a log of every alert it -raises, per project, and a **Guard** view in the chat's view row to read it — what matched, what the rule -saw, the verdict, and what let the call through if anything did. Every rule gets a mode (Enforcing, -Permissive, Allow All), as does the guard as a whole; **Settings ▸ Claude Code Security** becomes its own -page, with the three whitelist reaches and the extra credential globs and blocked domains. A shield in -the chat's button row suspends the guard for a duration you pick. Most settings became per project. - -**5.5.0** — a tab and a transcript per agent, with the whole tree one hover away; a single **Workloads** -diagram of everything running across every chat; background tasks that keep their output after they -end and survive a restart; a [Git integration](#git) whose write actions are asked of Claude rather than -run by the plugin; settings moved into the IDE's password safe. It also **fixes a plugin that was dead on -2026.2**, which is why the minimum IDE is now 2025.3.1. - -**5.1.x** — per-model plan-limit windows (the ones the CLI's `/usage` showed and the plugin did not), -moved to their own row under the composer; older model generations selectable again behind an *Other -models* group. - -**5.0.0** — the standards-compliance major: a screen-reader live region and a visible focus ring -throughout, a written [threat model](docs/adr/0002-threat-model.md), third-party licence attribution -shipped inside the artifact, and the plan-limits panel. - -Full history in [`CHANGELOG.md`](CHANGELOG.md); user-facing notes per release in -[`RELEASE_NOTES.md`](RELEASE_NOTES.md). +JDK 21 and Node 22+ (`.nvmrc`). `./gradlew buildPlugin` produces `build/distributions/claude-code-native-6.0.0.zip`; +`./gradlew test` runs the JVM suite, `npm test` the frontend suite, `./gradlew detekt spotlessCheck` and +`npm run lint` the static gates, `./gradlew verifyPlugin` the Plugin Verifier against the declared range. +The rules the code is held to — no deprecated or internal platform API, a 250-line ceiling per file, no +comments, one gateway file per external plugin, the guard off limits — are in +[`DIRECTIVES.md`](DIRECTIVES.md) and [`docs/PLATFORM_API_POLICY.md`](docs/PLATFORM_API_POLICY.md). ## Documentation -Using the plugin is covered above. Everything below is for working *on* it. - -| Document | What it covers | -|---|---| -| [`CLAUDE.md`](CLAUDE.md) | Architecture, protocol, empirical binary behaviour | -| [`PROJECTMAP.md`](PROJECTMAP.md) | Where things live — the "I want to change X → go to Y" index | -| [`AGENTS.md`](AGENTS.md) | Runbook for working on this repo with a coding agent | -| [`SECURITY.md`](SECURITY.md) | The sensitive-data lock, triage scope, reporting policy | -| [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to contribute | -| [`docs/adr/`](docs/adr/README.md) | Decision records — release process, threat model, i18n deferral | -| [`docs/FAQ.md`](docs/FAQ.md) · [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | Common questions and fixes | -| [`docs/BINARY_COMPAT.md`](docs/BINARY_COMPAT.md) · [`docs/DRIFT_DETECTION.md`](docs/DRIFT_DETECTION.md) | Binary compatibility policy and drift detection | -| [`docs/RELEASE_PROCEDURE.md`](docs/RELEASE_PROCEDURE.md) · [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md) · [`docs/BRANCHING.md`](docs/BRANCHING.md) | Release and branching workflow | -| [`docs/CI_SETUP.md`](docs/CI_SETUP.md) · [`docs/UI_TESTING.md`](docs/UI_TESTING.md) | CI/CD configuration and the RemoteRobot harness | - -## Upstream and forks - -**This repository is upstream.** It is not a fork of anything, and the claim is checkable rather than -asserted — GitHub records a repository's ancestry, and for this one it is empty: - -```sh -gh repo view serialexperimentslainnnn/claude-code-for-jetbrains --json isFork,parent -# {"isFork":false,"parent":null} -``` - -The other anchors point at the same place: the Marketplace listing -([plugin 31965](https://plugins.jetbrains.com/plugin/31965-claude-code-native)) is published from this -repository by its author, every release tag here is cut by the release workflow and the artifacts are -signed, and the commits carry the maintainer's signature. - -### Known forks - -The GPL exists so that people can fork, study and modify this. Nothing below is a complaint — it is -simply a map, so that anyone who lands on a copy knows where the original is and can compare. - -| Fork | Owner | Last seen active | -|---|---|---| -| [luxgoldix-coder/claude-code-for-jetbrains](https://github.com/luxgoldix-coder/claude-code-for-jetbrains) | luxgoldix-coder | 2026-08-10 | - -*List reviewed 2026-08-13. It is maintained by hand and may lag; the live set is always* -`gh api repos/serialexperimentslainnnn/claude-code-for-jetbrains/forks --jq '.[].full_name'`. - -### If you fork it - -Please do — and two asks, the first of which the licence already requires of you: - -1. **Say that it is modified, and by whom.** GPL-3.0 §5(a) requires a modified version to carry - prominent notices stating that you changed it and when. In practice that means editing this README, - the plugin description and the plugin `id` so a user can tell the two apart. -2. **Use your own plugin id and your own signing key** before publishing anywhere. Two artifacts - claiming `dev.lain.claude-code-for-jetbrains` cannot coexist in a user's IDE, and a release signed - with this project's key would misattribute your work to this project — and this project's bugs - to you. - -Neither ask restricts what the licence grants you. They exist so that a user can always answer "whose -build am I running, and where do I report this?". +- [`docs/SKILL_INVENTORY.md`](docs/SKILL_INVENTORY.md) — every tool, its parameters and an example, plus the board of what shipped +- [`docs/MCP_CLIENT.md`](docs/MCP_CLIENT.md) — driving the IDE from any MCP client +- [`docs/MCP_ROADMAP.md`](docs/MCP_ROADMAP.md) — what was considered, what is out and why +- [`docs/PLATFORM_API_POLICY.md`](docs/PLATFORM_API_POLICY.md) — the platform APIs the plugin refuses and their replacements +- [`docs/SECURITY-GUARD.md`](docs/SECURITY-GUARD.md) — the Sensitive Guard: rules, categories, whitelists +- [`docs/FAQ.md`](docs/FAQ.md) · [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) · [`docs/BINARY_COMPAT.md`](docs/BINARY_COMPAT.md) +- [`docs/RELEASE_PROCEDURE.md`](docs/RELEASE_PROCEDURE.md) · [`docs/BRANCHING.md`](docs/BRANCHING.md) · [`docs/CI_SETUP.md`](docs/CI_SETUP.md) +- [`docs/adr/`](docs/adr) — the decisions, the threat model among them +- [`CHANGELOG.md`](CHANGELOG.md) · [`RELEASE_NOTES.md`](RELEASE_NOTES.md) ## Licence and attribution -Licensed under the **GNU General Public License v3.0** — see [`LICENSE`](LICENSE). - -The published archive redistributes third-party components (marked, DOMPurify, highlight.js, -kotlinx.serialization). Their notices are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md), with the -full licence texts under [`LICENSES/`](LICENSES) — each entry verified against the upstream `LICENSE` of -the exact version that ships, not against a manifest or a minified file's banner. - -All of it is packaged **inside** the artifact, because a notice sitting in a Git repository does not -accompany the binary a user installs. Both halves of that are enforced rather than promised: the -*Build plugin* job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) unpacks the very zip the -plugin verifier passed and fails the build unless the jar carries `META-INF/LICENSE`, -`META-INF/THIRD-PARTY-NOTICES.md` and one `META-INF/licenses/…` text for **every** file under -`LICENSES/` — the expected set is read from the checkout, so adding a dependency's licence text extends -the check by itself. The same job fails if the zip contains a single `node_modules` entry, which is what -turns "no npm code is distributed" from a claim into a check. +GPL-3.0 — see [`LICENSE`](LICENSE) and [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). *Claude* and +*Claude Code* are trademarks of Anthropic, PBC; *JetBrains* and the IDE names are trademarks of JetBrains +s.r.o. This project is not affiliated with, sponsored by, or endorsed by either. The upstream repository is +[serialexperimentslainnnn/claude-code-for-jetbrains](https://github.com/serialexperimentslainnnn/claude-code-for-jetbrains). ## Disclaimer -Unofficial, community-built, open-source plugin. **Not affiliated with, sponsored by, or endorsed by -Anthropic or JetBrains.** It requires your own separately-installed `claude` CLI and your own Claude -subscription or API key — no credentials are bundled or provided. - -"Claude" and "Claude Code" are trademarks of Anthropic; "JetBrains", "IntelliJ", "PyCharm" and related -names are trademarks of JetBrains s.r.o. Used here for identification only. +This software is provided as is, without warranty of any kind. It runs an AI agent with access to your +files, your IDE and your repositories under the permissions you grant it and the guard described above; +read what it proposes before you accept it, and keep the backups you would keep anyway. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6846e9a2..063c4357 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,75 @@ +## v6.0.0 — 2026-09-12 + +**Claude becomes one with your IDE.** This release gives Claude the IDE itself as its instrument. +Four MCP servers of the plugin's own run inside the IDE — no port, nothing to install, nothing exposed +— and through them Claude reads through the index, edits through the document model, refactors with +the refactoring engine, builds, runs, tests and debugs through the run system, drives Git and GitHub +through the IDE's own account and views, works the Services panel, and can fire any menu entry the IDE +registers. 178 tools in 55 domains, on by default. + +**Ask it to show you things.** "Open `SessionLauncher.kt`", "take me to line 120", "show me the last +commit", "show me the diff of this branch against the last release", "find me the pull request and +open it", "open Settings at Code Style", "show me `api` in Services" — it appears in the IDE, in the +right place, without you touching the mouse. Everything Claude does is mirrored the same way: what it +reads opens in the preview tab, what it edits in a real tab, a commit is selected in the Log, a node in +Services, a problem in its tab, a run in its window — and your caret never moves. Turn on the +Presentation Assistant and every action it fires is announced on screen. + +**Claude God Mode.** The flame in the chat bar is the whole integration in one switch: all four servers +and every rule. Each rule is one line in Claude's system prompt, repeated every turn, naming which IDE +tool replaces which native one, so it does not drift back to `grep` and `sed`. Settings ▸ Claude Code +▸ Claude IDE Integration fine-tunes servers, rules and the mirror. It costs *less* in tokens than the +native tools it replaces: results come back as compact tables, and a file read is one call where a +`cat` was a process plus a permission. + +**Cards you can follow.** Every IDE call is a card named by server, tool and subject; a list passed to a +tool draws one sub-card per item with its own diff, live lines and state; every card carries a one-click +link into the IDE. Own edits have *View diff* and *Restore*. A subagent's calls draw under its Task card. + +**Pull requests and releases, through your IDE's GitHub account.** List, read and open pull requests in +the IDE's Pull Requests view; create one, comment, wait for the checks, merge when green; then check the +tag, the Actions runs, the GitHub Release and the Marketplace. That is how this plugin's own releases +are driven. + +**Every IDE call passes the guard first**, inside the servers, with the same rules as the native tools. +The guard also learned to tell a path that is merely mentioned from one that is reached, to judge a +container mount by its host side, and to refuse more privileged-container vectors. + +**Any MCP client can drive the IDE.** The servers speak plain MCP; the bridge and the protocol are in +`docs/MCP_CLIENT.md`. The JetBrains MCP Server switch and the third-party Index and Debugger servers are +gone — the plugin's own servers replace all three. + +**You can read the plugin's log without leaving the chat.** A Log view sits in the view row next to +Guard and Vulnerabilities: the plugin's own entries, filtered by level, with a *Copy* button that +puts a report-ready text on the clipboard. Nothing sensitive gets in — credentials, prompts and +paths outside the project are kept out before a line is stored. A *Debug* switch in the same view +turns detailed tracing on for the current IDE session, which is what to flip before reporting a bug. + +**Closing your last chat no longer leaves you staring at a blank panel.** The page host gave a +starting browser two and a half seconds, then fell back to delivery routes its own navigation guard +refused — so the page never ran, the composer never appeared, and only *Open previous session* +brought a chat back. It now waits for the browser to exist and the dead routes are gone. Reloads +after a failed delivery also come back with their state, instead of the loading screen. + +**Long chats stay quick.** The transcript keeps the last 500 rows on screen instead of 2,000; a +notice at the top says how many earlier rows were dropped, and the session file on disk still holds +all of them. + +**Small things that now do what they say.** The model, effort and thinking pills survive a new +chat; Fork Session gets its own session id; `/btw` gets its answer instead of a thirty-second shrug; closing a chat mid sign-in leaves no process behind; `Shift+Tab` +leaves the prompt; `Escape` closes what you opened even with the find bar up. + +**Under the hood.** The plugin uses no deprecated or internal platform API, verified against every IDE +build from 2025.3.1 to 2026.3. The session orchestrator, the chat bridge, the page host and the guard are +split one responsibility per file, the page is TypeScript in small files, every comment is gone, and a +package-dependency gate keeps the layering honest. Logging uses one level vocabulary across the +plugin, and the binary's stderr and the page's own errors are recorded instead of dropped. The +guard's tests pin the verdict each rule must give — with Windows paths and commands in every rule +family — its Windows coverage grows (Startup and profile writes, the Windows write verbs, 8.3 names, +alternate data streams, caret-split commands), four Windows false positives are gone, and its code +is restructured under those tests with no verdict changed. A link written by the model can no longer +open a file outside the project. + ## v5.8.1 — 2026-08-30 **Closing your last chat no longer raises an internal error.** If the chat you closed was the diff --git a/build.gradle.kts b/build.gradle.kts index dc9ebd28..916c56c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.8.1" +version = "6.0.0" repositories { mavenCentral() @@ -103,6 +103,16 @@ dependencies { // without Git, or a project that is not a working copy, must still load the plugin; `GitGateway` is the // only file that names a git4idea type and it is never reached unless `GitAvailability` says yes. bundledPlugin("Git4Idea") + // Bundled GitHub plugin: compile-only coupling for the pull-request data (`GHAccountsUtil`, the API + // executor, `GHGQLRequests`). OPTIONAL in META-INF/plugin.xml (config-file claude-github.xml); + // `GitHubGateway` is the only file that names an org.jetbrains.plugins.github type and checks the + // plugin before touching it, so an IDE without it answers "not available" instead of dying. + bundledPlugin("org.jetbrains.plugins.github") + // Bundled Java plugin: compile-only coupling for UAST (the unified AST over Java, Kotlin, Scala, Groovy), + // which ships inside it. OPTIONAL in META-INF/plugin.xml (config-file claude-java.xml): PyCharm and the + // other IDEs without Java load the plugin without the uast domain; UastTools is the only file naming + // org.jetbrains.uast and the catalog row checks JavaAvailability before touching it. + bundledPlugin("com.intellij.java") } // JSON (de)serialization for the stream-json / control protocol. @@ -153,7 +163,37 @@ tasks { // Kept as a build step rather than a checked-in copy under `src/main/resources/`, so the notices cannot // drift out of sync with the files they describe: one source of truth at the repository root, packaged at // build time. `THIRD-PARTY-NOTICES.md` is surfaced to the user by the About dialog (see InfoDialogs). + val npm = if (System.getProperty("os.name").startsWith("Windows")) "npm.cmd" else "npm" + + val npmInstall by registering(Exec::class) { + inputs.files("package.json", "package-lock.json") + outputs.file("node_modules/.package-lock.json") + commandLine(npm, "ci") + } + + val compileWeb by registering(Exec::class) { + dependsOn(npmInstall) + inputs.dir("src/main/ts/jcef") + inputs.file("tsconfig.json") + outputs.dir(layout.buildDirectory.dir("web")) + doFirst { delete(layout.buildDirectory.dir("web")) } + commandLine(npm, "run", "build") + } + + val frontendTest by registering(Exec::class) { + dependsOn(compileWeb) + inputs.dir("src/test/frontend") + inputs.dir("src/main/resources/jcef") + inputs.dir("src/main/ts/jcef") + outputs.dir(layout.buildDirectory.dir("reports/frontend")) + environment("CI", "true") + commandLine(npm, "test") + } + + check { dependsOn(frontendTest) } + processResources { + from(compileWeb) // The distributed map is written FOR this repository and lands in the artifact by accident: the one // under `src/main/resources/jcef/` is a resource like any other, so it shipped inside the plugin jar — // 20 KB of internal design notes and source paths handed to every user, for nothing. Excluded by @@ -166,7 +206,7 @@ tasks { from(rootProject.file("LICENSES")) { into("META-INF/licenses") } } runIde { - jvmArgs("-Djb.privacy.policy.text=", "-Djb.consents.confirmation.enabled=false") + jvmArgs("-Djb.privacy.policy.text=", "-Djb.consents.confirmation.enabled=false", "-Dclaudejb.debug=true") } test { // Exclude the live drift check: it downloads the latest SDK from npm and spawns the real binary, @@ -413,6 +453,7 @@ intellijPlatform { // 'JetBrains' in the plugin name is a Marketplace naming lint, not an API problem; muting it lets // the verifier proceed to the actual binary-compatibility / internal-API checks we care about. freeArgs = listOf("-mute", "TemplateWordInPluginName") + externalPrefixes = listOf("org.jetbrains.uast") // The "zero deprecations" rule, ENFORCED rather than merely written down. // @@ -428,19 +469,19 @@ intellijPlatform { // experimental API is acceptable with a reason; a deprecated one is not acceptable at all, because // it has an announced removal date and the plugin has to keep working across the IDE range. // - // MISSING_DEPENDENCIES is here for a reason found the hard way, and it is the most load-bearing entry - // in this list: a mandatory `` that the target IDE cannot satisfy means **the plugin does not - // load at all** — not a degraded feature, not a warning, nothing. The verifier detects it perfectly - // (pointed at 253.28294.334 it says "1 missing mandatory dependency" in as many words) and, without - // this line, still finished with BUILD SUCCESSFUL. A gate that finds the fault and passes anyway is - // worse than no gate: it is a green tick over a plugin that cannot start. + // MISSING_DEPENDENCIES is deliberately NOT here either. A mandatory `` the target IDE cannot + // satisfy means the plugin does not load at all, and the verifier does detect it — but the Gradle plugin + // (2.16.0, and 2.18.1 alike) parses the verifier's stdout by the "Missing dependencies" heading and + // cannot tell `(optional): Unavailable` from a mandatory gap, so with that level on, every PyCharm + // target fails on the optional com.intellij.modules.java dependency that PyCharm lacks by design. + // The protection that level gave lives in PluginDependenciesContractTest instead: every non-optional + // must be a platform module every IntelliJ-based IDE ships. failureLevel = listOf( VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES, VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, - VerifyPluginTask.FailureLevel.MISSING_DEPENDENCIES, ) ides { // No hardcoded path in the repo: a developer can point the verifier at local IDE installs to skip the @@ -512,6 +553,7 @@ intellijPlatform { kotlin { jvmToolchain(21) compilerOptions { + allWarningsAsErrors.set(true) freeCompilerArgs.add("-Xjvm-default=all") } } @@ -589,6 +631,12 @@ kover { // other IDE process, which this build never instruments. disabledForTestTasks.add("uiTest") } + // The `uiTest` source set is a custom one, so kover reads it as code to measure rather than as tests + // that measure: its RemoteRobot suites showed up as a package `dev.lain.claudejb.ui` at 0% and failed the + // floor. They drive an external IDE and are never code under test. + sources { + excludedSourceSets.add("uiTest") + } } reports { filters { @@ -596,16 +644,27 @@ kover { // Need a live IDE / live Chromium to execute at all. Covered instead by the vitest suite, // which drives the REAL shipped JS (`npm test` is what counts it), and by the manual UI pass // the release checklist requires. - classes("dev.lain.claudejb.ui.*") + classes( + "dev.lain.claudejb.view.*", + "dev.lain.claudejb.model.bridge.*", + "dev.lain.claudejb.controller.bridge.*", + "dev.lain.claudejb.controller.commands.*", + "dev.lain.claudejb.controller.context.Link*", + ) // Thin IDE-action shells: their bodies are one delegate call each, and exercising them means // booting an IDE to assert that a menu item calls a method. - classes("dev.lain.claudejb.actions.*") + classes("dev.lain.claudejb.controller.actions.*") // Wrappers over the OS — system clipboard, process spawn, shell environment. Most of what is // uncovered here cannot run on a CI box at all. A KNOWN GAP, listed so it is not mistaken for - // coverage; the parts that are pure ARE tested — `ClipboardCli`/`ImageAttachments` in `context/` - // (ClipboardCliTest, ImageAttachmentsTest) and `EnvScriptLoader.parse` in `process/`. Those - // names are load-bearing: a comment citing a file that no longer exists is worse than none. - classes("dev.lain.claudejb.context.*", "dev.lain.claudejb.process.*") + // coverage; the parts that are pure ARE tested — `ClipboardCli` in `controller/context/`, + // `ImageAttachments` in `model/context/` (ClipboardCliTest, ImageAttachmentsTest) and + // `EnvScriptLoader.parse` in `model/settings/env/`. Those names are load-bearing: a comment + // citing a file that no longer exists is worse than none. + classes( + "dev.lain.claudejb.model.context.*", + "dev.lain.claudejb.controller.context.*", + "dev.lain.claudejb.controller.process.*", + ) // The Git integration's IDE-bound half: the availability probe (asks the running IDE's plugin // set), the git4idea gateway (spawns `git log` through the platform) and the hand-off to the // Version Control tool window. Exercising any of them means a live IDE AND a real repository on @@ -620,10 +679,10 @@ kover { // match. A lambda added inside an excluded object would otherwise start counting against the // package's floor, which reads as coverage erosion in code that was never gated. classes( - "dev.lain.claudejb.git.GitAvailability*", - "dev.lain.claudejb.git.GitGateway*", - "dev.lain.claudejb.git.GitHistoryService*", - "dev.lain.claudejb.git.GitLogNavigator*", + "dev.lain.claudejb.controller.git.GitAvailability*", + "dev.lain.claudejb.controller.git.GitGateway*", + "dev.lain.claudejb.controller.git.GitHistoryService*", + "dev.lain.claudejb.controller.git.GitLogNavigator*", ) // A single line delegating to PluginManager.isPluginInstalled. It exists precisely BECAUSE it // must run against a real platform (PluginId is a Kotlin class since 2025.2, so the naive call @@ -635,7 +694,22 @@ kover { // needs a Project, the pooled thread and the EDT. `OsvScanner` is deliberately NOT excluded — // it talks to OsvHttp through a plain call and its gap is real debt, so it stays gated and // visible rather than being defined out of the measurement. - classes("dev.lain.claudejb.vuln.OsvHttp*", "dev.lain.claudejb.vuln.VulnService*") + classes("dev.lain.claudejb.controller.vuln.OsvHttp*", "dev.lain.claudejb.controller.vuln.VulnService*") + // The IDE MCP servers' platform-bound half: the project `@Service` that owns the sockets and + // the approval notifications, the catalog that binds servers to a Project, and the tools + // themselves (FileDocumentManager, FindInProjectUtil, FilenameIndex — every one needs a live + // index). `ServerEndpoint`, `SocketHome` and `GuardGate` are NOT excluded: they run on a real + // Unix socket and the real guard in unit tests, and the whole `model.mcp` layer is pure. + classes( + "dev.lain.claudejb.controller.mcp.IdeMcpService*", + "dev.lain.claudejb.controller.mcp.IdeToolCatalog*", + "dev.lain.claudejb.controller.mcp.tools.*", + "dev.lain.claudejb.controller.db.*", + ) + // The GitHub plugin's gateway: every call needs the plugin loaded, an account in the IDE's safe and + // GitHub itself — the same grounds as `controller.db.*`. Its availability check and the Marketplace + // gateway stay measured: the first runs headless, the second takes its fetch as a parameter. + classes("dev.lain.claudejb.controller.github.GitHubGateway*") } } verify { diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 839ddb63..c373eea4 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -1,46 +1,5 @@ - - - LargeClass:ClaudeSession.kt$ClaudeSession : Disposable - TooManyFunctions:ClaudeSession.kt$ClaudeSession : Disposable - + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 99e5cc16..06ea4ead 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -650,7 +650,7 @@ style: # DIFF_ADDED_BG_LIGHT = 0xE6FFEC adds a line and explains nothing. The behavioural constants in those two # files (timer period, hue step, luminance coefficients, surface nudge) are named anyway, because those # genuinely encode decisions someone might revisit — the exclusion buys silence on the palette, not on them. - excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts', '**/ui/ChatTheme.kt', '**/ui/jcef/JcefTheme.kt'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts', '**/view/feed/ChatTheme.kt', '**/view/payload/JcefTheme.kt'] ignoreNumbers: - '-1' - '0' diff --git a/docs/MCP_CLIENT.md b/docs/MCP_CLIENT.md new file mode 100644 index 00000000..0664f2ac --- /dev/null +++ b/docs/MCP_CLIENT.md @@ -0,0 +1,92 @@ +# Driving the IDE from any MCP client + +The four IDE servers — `code`, `run`, `vcs`, `ops` — are ordinary MCP servers that happen to live inside +the plugin. Claude Code is their first client, not their only one: anything that speaks MCP drives the IDE +the same way, with the same tools ([`SKILL_INVENTORY.md`](SKILL_INVENTORY.md)) and under the same +Security Guard. The reference client is `src/test/kotlin/dev/lain/claudejb/mcp/McpClient.kt`, exercised +end to end by `McpClientHeadlessTest`: connect, authenticate, `initialize`, `domains`, `read_file`. + +## Where the servers are + +One set per open project, started when a chat tab launches `claude` — or, when the chat page cannot be +drawn (see *Split mode*), by the plugin on its own. They listen on Unix sockets, never on a port: + +``` +/claude-ide-mcp// + code.sock run.sock vcs.sock ops.sock # one per server that has at least one live domain + token # the current token, mode 0600; the directory is 0700 +``` + +`` is `PathManager.getTempPath()`, falling back to `java.io.tmpdir` when the socket path would +be too long. The id is random per project open and the directory dies with the project. To find it: + +- the `claude` process the plugin launched received it in `--mcp-config` (`ps` shows the socket path in the + bridge's arguments); +- `idea.log` prints `IDE MCP servers listening under ` when they start; +- in split mode, the notification *The chat could not be shown, but the IDE MCP servers are up* offers + **Copy MCP configuration**, a ready `mcpServers` block. + +A server whose plugins are all missing (for example `vcs` without Git4Idea) has no socket at all. + +## Two ways in + +### 1. stdio, through the bundled bridge + +Every MCP client understands a stdio server. The plugin ships one, dependency-free, that translates +standard JSON-RPC lines on stdin/stdout to the socket's wire and attaches the token itself: + +```json +{ + "mcpServers": { + "code": { + "type": "stdio", + "command": "/bin/java", + "args": ["-cp", "/lib/*", "dev.lain.claudejb.mcp.StdioBridge", "/code.sock"] + } + } +} +``` + +This is exactly what the plugin hands to `claude` (`McpConfigBuilder.ownMcpServer`). The IDE's own Java +runs the bridge because it is the one that is certainly there; the bridge has no dependencies. + +### 2. the socket itself + +For a client that would rather skip the process: + +- **Framing**: each message is its byte length in decimal ASCII, a newline, then the payload + (`Frames.java`). Both directions. The ceiling is `Frames.MAX_FRAME_BYTES`. +- **Payload**: JSON-RPC 2.0, encoded as [TOON](https://github.com/toon-format/toon) rather than JSON + (`model/mcp/toon/`). The reply is TOON too; decode it back to JSON. +- **Authentication**: every request and notification carries the token in + `params._meta["dev.lain.claudejb/token"]` (`StdioBridge.TOKEN_KEY`). Read it from the `token` file beside + the socket. It rotates every `TokenRing.ROTATION_MILLIS` with a `TokenRing.DEFAULT_OVERLAP_MILLIS` grace + period, so a long-lived client re-reads the file when a reply says `request rejected` — that message is + all the server says, whatever was wrong. +- **Methods**: `initialize`, `ping`, `tools/list`, `tools/call`, `server/discover`; the notification + `notifications/cancelled` with `requestId` drops a pending call. +- **Tools**: three meta-tools, `domains()`, `tools(domain)`, `run(tool, args)`; the answer is + `result.content[0].text`, TOON. Unknown tool or bad arguments come back as `isError: true` with the reason. +- **Concurrency**: one connection carries up to `ServerEndpoint.QUEUE_DEPTH` calls in flight; replies + come back as each tool finishes, not in request order — correlate by `id`. + +## Who may connect + +The token is the lock: without it, nothing answers. A connection the plugin did not launch itself is +announced with a notification, and Settings ▸ Claude Code ▸ *Ask me before an unexpected client may talk +to our servers* holds it until the user answers **Allow** (closing the notice rejects it). Whoever the +client is, the Security Guard judges every `run(tool, args)` before anything runs. + +## Split mode + +In Remote Development a plugin that is not split loads **on the backend only**, and the UI it draws +there is projected to the frontend at poor fidelity; JCEF is a frontend API. So the plugin, its sockets, +its token and the `claude` process it launches are all on the backend host, and the chat page cannot be +drawn at all. + +The plugin does not ask the platform whether it is split — those APIs are internal +(`RemoteDevApiContractTest`). It notices the chat page failing to arrive over every delivery route +(`PageDelivery`), and then starts the servers anyway and raises the notification above with the +configuration to copy. A client on the backend host — a `claude` in an SSH shell, or anything that reads +this page — drives the IDE without the chat. A client on the frontend machine cannot reach a Unix socket +of another host; that is by design, not an omission. diff --git a/docs/MCP_ROADMAP.md b/docs/MCP_ROADMAP.md new file mode 100644 index 00000000..e968d125 --- /dev/null +++ b/docs/MCP_ROADMAP.md @@ -0,0 +1,392 @@ +# MCP roadmap — sprint board + +A living document. `DIRECTIVES.md` says **what** is decided and **why**; this says **in what order**, with +**every task**, and **what is done**. It is updated in the same turn the state changes. + +**Legend**: ☐ to do · ◐ in progress · ☑ done and committed. + +**Sprint exit**: green on `Tool: tests kotlin`, `Tool: tests frontend` and `Tool: lint`, read from the tail +of `.claudetools/run/out/.log`; clean diagnostics on every file touched; a signed commit. + +**Every API named below was read from the platform source at `idea/253.29346.138`.** Where a task says an +API is forbidden, the replacement is named. Nothing here is written from memory. + +**Two rules that apply to every sprint.** At most **four tools per domain** — a domain that does not fit is +split. And every tool returns the smallest answer that settles the question, with a hard `limit` on anything +that enumerates. + +--- + +## Sprint 0 — the floor + +First, because everything else rests on the compiler telling the truth. + +- ☑ `sinceBuild` pinned to the current stable. Already `253.29346.138`, `untilBuild` `263.*`. +- ☑ `allWarningsAsErrors = true` in `build.gradle.kts`. A deprecated API emits a warning and + `@ApiStatus.Experimental` does not, so this forbids exactly what is forbidden and tolerates exactly what + is tolerated, with no list to maintain. +- ☑ `FileRollback` off the two-argument `runWriteCommandAction`, which is `@TestOnly` and names the undo + entry `Undefined`, onto `writeCommandAction(project).withName(…)`. +- ☑ `TerminalLauncher` off the reflected five-argument `createNewSession`, which is `@ApiStatus.Internal`, + onto the public `createShellWidget(workingDirectory, tabName, requestFocus, deferSessionStartUntilUiShown)` + plus `TerminalWidget.sendCommandToExecute(String)`. +- ☑ The terminal contract test stops pinning the reflected signature and pins the public one, and asserts + reflection does not come back. +- ☑ `InternalPlatformApiContractTest`: scans production sources for the internal symbols the compiler cannot + flag, each with its public replacement named in the failure message. +- ☐ Coverage thresholds reviewed for the packages about to appear. Carried into sprint 1, because there is + nothing to measure until the packages exist. + +**Commit**: ☑ `build: warnings are errors, and no internal platform API gets in` + +## Sprint 1 — the third parties go + +- ☐ `IdeServer` becomes `CODE`, `RUN`, `VCS`, `OPS`, `JETBRAINS`. The two hechtcarmel entries, their plugin + ids, ports, install buttons and per-server rules are deleted. +- ☐ Delete `IdeRule.kt` and the `knownTools` field. The rules existed to describe someone else's tools. +- ☐ `IdeMcpPrompt` shrinks to one paragraph: the four servers, what each is for, and that you start with + `domains()`. The injected block names no tool at all, and its character ceiling is tightened, not relaxed. +- ☐ `GodMode` is our servers plus the common rules. The JetBrains server is not part of it. +- ☐ `IdeMcpState` loses the third-party ports and flags, tolerantly parsing old persisted JSON. +- ☐ `SettingsIdeMcpSection`: one checkbox for our integration, with the JetBrains server kept as an optional + extra behind `IdeServerControls`. +- ☐ `McpConfigBuilder` emits four stdio entries. `LaunchOptions` replaces the index/debugger flags. +- ☐ `SessionLauncher` and `JcefSettingsMenu` follow. +- ☐ `PluginInstallerTest` stops naming the removed servers; README and the settings documentation follow in + the same commit. + +**Commit**: `refactor(mcp): the IDE integration is servers of our own; the third-party servers are gone` + +## Sprint 2 — TOON, and a round trip that works + +The delicate one. The codec lands before any tool does, because everything downstream is measured in it. + +- ☐ A TOON codec twice: Kotlin in the pure core, and dependency-free Java for the helper, which is launched + by a bare `java` and cannot see Kotlin. +- ☐ All four forms: inline, list, tabular and keyed tabular, plus nested field groups. Tabular is where the + saving is and is the shape of nearly everything returned. +- ☐ Validated against the specification's **published reference fixtures**, not invented examples, plus a + round-trip test of our own. +- ☐ `JsonRpc`, `ToolSpec`, `ToolArgs`, `ToolResult`, `OutputBudget` in the pure core, which joins the + platform-free package list and therefore may not name `com.intellij`. +- ☐ A dual-era `McpServer`: modern fields always emitted, branching only on `initialize` versus + `server/discover`. One server instance per catalogue, not four implementations. +- ☐ `StdioBridge`: first line of stdin is the credential, then JSON-RPC; translates to TOON at the boundary + and pumps against the socket. Its jar path resolves from its own code source location. +- ☐ Four Unix sockets under a 0700 directory named with 128 random bits, removed on dispose. No TCP. +- ☐ The three meta-tools, with the test that `tools/list` returns exactly three entries per server. +- ☐ A queue per server: the socket reader never executes a tool; immediate acknowledgement; replies + correlated by `id` and therefore allowed out of order; timeout and cancellation on every tool; a bounded + depth that refuses with an actionable message instead of growing. +- ☐ The auth token: generated in code, handed to each server at `init`, kept in memory, carried in the + per-request metadata the specification reserves, rotated every 30 minutes with a short overlap so + in-flight requests survive. Tests: a missing or expired token is refused without saying why, the secret + never reaches a log or an error, and it is not in the helper process environment. +- ☐ A notification when a connection arrives that is not a known chat tab, with a setting to require + approval. In the hacked-developer scenario this is worth more than the token. +- ☐ The guard evaluated at the `run(tool, args)` dispatcher — one place, not twenty-six — with a test that a + dangerous **nested** argument is refused. Nothing under the permission package is touched, and nothing + needs to be: the classifier never sees a tool name and the input scanner already recurses. +- ☐ Every tool names its parameters with the keys the guard recognises, so they inherit every existing + verdict rather than needing new rules. +- ☐ The permission card reads the inner tool name from the dispatcher's argument, so the user sees + `read_file` rather than `run`. That is view work, not guard work. +- ☐ Domains `read` and `search` of the `code` server. `read_file` resolves through the local file system and + reads text via the immutable character sequence, which needs no lock. `search_text` goes through the IDE's + find-in-project machinery with a model carrying the query, regex, case and file mask. `find_files` uses the + filename index, and falls back to iterating content with the exclusion check for globs; case-insensitive + name lookup is documented as a full scan. + +**Commit**: `feat(mcp): four MCP servers inside the plugin, each asked for its tools on demand` + +## Sprint 3 — the code as the IDE understands it + +- ☐ `definition` and `symbol_info`: the PSI file for the document, the reference at the offset, resolved. + Coordinates validated before use so a bad line is an actionable error, not an exception. +- ☐ `references`: the reference search query, consumed by a processor rather than an iterator, because the + iterator forms are deprecated. +- ☐ `implementations`: the definitions-scoped search. +- ☐ `call_hierarchy` and `type_hierarchy`, grouped under one tool with a direction argument so the domain + stays within four. +- ☐ `find_symbols` through the symbol and class contributor extension points, processing names and then + elements. Not the short-names cache, which would tie the plugin to Java. +- ☐ `file_outline` from the structure view builder for the file's language, walked as a tree. +- ☐ `problems`: the daemon's already-computed highlights, iterated under the markup lock with no heavy work + inside the processor. The file has to be open for this to be meaningful, and the tool says so. +- ☐ `project_problems`: the problems collector, whose listener methods are expected on the UI thread. +- ☐ `inspect`: running an inspection on a file through the public engine entry point, with the current + profile and a new global context. This does not depend on the daemon, which is what covers the gap left by + the highlight path being marked for future deprecation. + +**Commit**: `feat(mcp): navigation, the file outline, the IDE's problems and its inspections` + +## Sprint 4 — writing, and one undo entry + +- ☐ Every write wrapped in a named command, so the user's undo says what Claude did. Several files in one + command by marking it global and declaring the affected files, which is required when touching the virtual + file system rather than a document. +- ☐ A single local-history label around the same span, started and finished in a `finally`. The history + service never returns null; availability is checked with its enabled flag, not a null comparison. +- ☐ The virtual-file-system requestor is **never null**, because null is interpreted as an external change + and detaches our edits from the history that groups them. +- ☐ `replace_text`, `insert_at`, `create_file` through the document API and the directory creation utility. +- ☐ `rename` through the refactoring factory, set non-interactive so it never opens a dialog. +- ☐ `move_file` through the move processor, because the refactoring factory has no move. +- ☐ `safe_delete` through the safe-delete processor. +- ☐ `reformat` and import optimisation through the code style manager and the optimise-imports processor. +- ☐ `quick_fixes` and `quick_fix_apply`: the intentions pass collects actions, cached intentions expose them, + and application happens on the UI thread inside a write command when the action asks for one. The offset + comes from the caret. +- ☐ Editor state: selected editor, caret movement and selection, all on the UI thread and outside a write + action. +- ☐ Every edit opens the existing review diff, reusing the plugin's own diff opener. + +**Commit**: `feat(mcp): edits, refactors, quick fixes and formatting, each opening the review diff` + +## Sprint 5 — building, testing, running + +- ☐ `build` through the project task manager. Its result reports only aborted and has-errors, so the error + list comes from the problems collector. The build-view problems service does not exist in this platform. +- ☐ `run_configurations` listed from the run manager; `run_configuration` executed through an execution + environment built for the run executor, with the process listener attached **before** notification starts, + the exit code taken from the process event, and system output filtered out. +- ☐ `run_tests`: there is no "run this test and give me a verdict" API. The run configuration is executed and + the public test-status topic is listened to. The test proxy is documented as UI-thread-only, so it is + copied into an immutable value inside that thread. The declarative status-listener extension point is not + usable for this. +- ☐ `shell`: a command line with a coloured process handler, attached to a terminal execution console in the + tool window, keyed on the content so a tab is reused. This is what the platform itself does, and it gives + reliable output and exit codes. The interactive tab stays on the public shell widget. +- ☐ Long work reports through background progress rather than going silent and dumping at the end. + +**Commit**: `feat(mcp): builds, tests, run configurations and a shell in the IDE's terminal` + +## Sprint 6 — the debugger + +Nine drafted tools do not fit in four, so the domain is split into `debug` and `breakpoints`, and stepping +becomes one tool with a kind argument. + +- ☐ `debug_start`, `debug_status`, `debug_step(kind)`, `debug_stop` in one domain. +- ☐ `breakpoint_set`, `breakpoint_remove`, `breakpoints`, plus evaluation, in the other. +- ☐ Breakpoints are added by **file URL**, not by virtual file, and the deprecated single-breakpoint lookup + is replaced by the plural one. +- ☐ Waiting for a pause is a session listener callback, **never polling**, and suspension is checked **after** + subscribing to close the race. +- ☐ Evaluation and variables through the evaluator and stack frame, copied out into values before crossing a + thread boundary. + +**Commit**: `feat(mcp): the debugger as tools` + +## Sprint 7 — git and the forge + +- ☐ Reading stays in the existing git gateway, the only file allowed to name the git plugin's types, and the + read-only contract test that pins that stays green. +- ☐ Writing lives **outside** that package, because the contract test scans it: commit, pull, push, fetch, + merge, rebase, tag, reset and stash through the line handler, with the message written via the public + commit-message file helper. +- ☐ Branches through the brancher, using the reference overload, not the deprecated string one. +- ☐ Fetch through the fetch support, which is synchronous and blocking and therefore never called on the UI + thread. +- ☐ Push through the git facade, because the push support has a private constructor. +- ☐ Status for any version control system through the change list manager, with its asynchronous update + callback, because the state is not immediately current. +- ☐ The forge is **not** a command-line wrapper: the IDE's own git actions are invoked by id and its tool + window activated, reusing the plugin's existing navigator and action invoker. + +**Commit**: `feat(mcp): git, and the forge through the IDE's own views` + +## Sprint 8 — the Services panel + +The DevOps piece, and the reason no container or cluster command line is wrapped anywhere in this roadmap. + +- ☐ `services()`: the tree as the user sees it, walked recursively through the service view contributor + extension point. +- ☐ `service_actions(path)`: the descriptor for a node exposes its toolbar and popup action groups, so the + actions the IDE itself offers on that node can be enumerated. +- ☐ `service_action(path, action)`: invoked through the existing action invoker, passing the guard. +- ☐ `service_open(path)`: select the node and activate the tool window. +- ☐ Honest limitation recorded in the tool descriptions: presentation and actions are available, typed domain + objects are not, and no supported path to them is invented. + +**Commit**: `feat(mcp): the Services panel, and every action the IDE offers on its nodes` + +## Sprint 9 — databases, HTTP, SSH + +The fragile sprint. Each domain degrades to absent rather than breaking startup. + +- ☐ All database internals in a **single gateway file**, reached by reflection from the context class loader, + wrapped so a changed signature degrades the domain instead of throwing. A contract test fails if any file + outside that package names the database plugin. +- ☐ Recorded in the gateway's tests: the data-source classes moved in a recent release, the official thread + about it went unanswered, and the extension-point documentation for that plugin is a dead link. There is no + contract, which is exactly why it is isolated. +- ☐ Decide whether the SQL argument is renamed so it inherits the guard's command verdict. Today it would not. +- ☐ HTTP through the IDE's own request run configuration, located at runtime by configuration type because + its id is undocumented and the plugin is not in Community. Absent plugin means absent domain, and the + message names the plugin. The command-line HTTP client is **not** wrapped. +- ☐ SSH read-only through the credential provider extension point, which is the supported way to enumerate + what the IDE knows. Executing over SSH would be an external process and stays out. + +**Commit**: `feat(mcp): the IDE's databases and HTTP client as tools` + +## Sprint 10 — Claude configures the IDE it works in + +- ☐ `ide_action(id)`: the existing action invoker moves into the MCP package, which gives reach to **every + registered action in the IDE**, with enablement and visibility checked before performing. +- ☐ `tool_window(id)`: activation by id, with the verified ids for Services, Problems, Version Control, + Commit, Database, Run, Debug, Project and Structure. Terminal is not among the platform constants. +- ☐ `settings_open(name)` plus the typed settings services for the editor, the general settings, the user + interface — refreshing it after mutation — and code style. There is **no generic set-by-key writer**; the + model that would offer one is internal, so none is faked. +- ☐ `modules`, dependency changes through the root-modification utility the javadoc recommends, and SDKs + through the JDK table and project root manager, both of which genuinely require a write lock. +- ☐ `plugins()` from the plugin manager. +- ☐ `notify`: a notification group **registered in the plugin descriptor**, with actions rather than the + deprecated listener, and no links in the content, as the javadoc asks. +- ☐ `index_status`: dumb-mode state, knowing that running when smart does not block and waiting does. + +**Commit**: `feat(mcp): Claude configures the IDE it works in, and speaks up inside it` + +## Sprint 11 — any MCP client + +- ☐ The core knows nothing about Claude Code. Everything specific to it — the launch configuration, the + project directory variable, its output ceilings — lives in one adapter behind a setting. +- ☐ The connection configuration is published so another client can read it, behind a setting that enables it. +- ☐ Recorded as debt: a minimal MCP client in the repository is the only thing that would actually prove the + claim. It is not in this release. + +**Commit**: `feat(mcp): any MCP client can drive the IDE` + +## Sprint 12 — the plugin passphrase + +A passphrase for the **plugin**, not for the IDE: entered when it opens, held **in memory only**, and used to +encrypt everything the plugin stores. It is also what arms the MCP servers. + +- ☐ The envelope goes into the single function pair that touches the credential store, which is the only + place in the plugin that does, so every consumer is covered without being edited. +- ☐ What that protects, and why it is worth it: the authentication token, the API key, the credentials blob, + the account profile and status, the environment variables, the settings blob, the open chats, the agent + index, the review prompt, and the **guard log**. +- ☐ **No home-made cryptography.** Key derivation with a standard password-based function from the runtime, + a random salt and a high iteration count; encryption with authenticated symmetric encryption and a fresh + random nonce per record, so a tampered record is detected. +- ☐ A versioned record format, so the scheme can change without orphaning what is stored. +- ☐ The salt is not secret and lives in ordinary settings. The derived key lives only in memory, is cleared + when the project closes or the passphrase is disarmed, and its buffers are wiped after use. +- ☐ A verifier record, so a wrong passphrase is detected without decrypting everything else. +- ☐ **The same passphrase arms the servers.** They are born disarmed and are disarmed by hand. No passphrase, + no key; no key, no token; no token, the sockets answer nobody. That is what actually stops the attacker in + the hacked-developer scenario. +- ☐ Migration both ways: enabling re-encrypts what exists, disabling decrypts it. A wrong passphrase + **refuses and changes nothing** — never wipe because you could not read. +- ☐ Tolerate the platform's known behaviour of emptying the store on a password-token mismatch: a secret may + simply be gone, and what can be regenerated is regenerated. +- ☐ Forgetting the passphrase loses what was encrypted. There is no recovery and none is invented. It is said + plainly when the feature is turned on, alongside the fact that most of it is recoverable by signing in + again — what is genuinely lost is the guard log's history. +- ☐ This is new security code, not a change to the guard. It ships with its own tests and touches nothing + under the permission package. + +**Commit**: `feat(security): a passphrase of the plugin's own encrypts what it stores and arms the servers` + +## Sprint 13 — the release face + +- ☐ The first-run tutorial on the flame, reworded: one switch, nothing to install. +- ☐ Version text across the changelog, the release notes and the README. +- ☐ Manual pass: God Mode on, a fresh chat, the card lists four servers with three tools each, a read goes + `domains()` then `tools("read")` then `run("read_file", …)`, the permission card shows the inner tool name, + an edit opens the review diff, a container is stopped and started from the chat through the Services panel, + and no new listening port appears. + +**Commit**: the tutorial and the version text + +--- + +## Horizon 2 — the code as a program + +Already researched; becomes sprints once the release ships. Same standard: no signature written without +having been read. + +| Area | Tools | Verified ground | +|---|---|---| +| Templates | `templates`, `template_apply`, `file_templates`, `file_from_template` | Live template settings and manager; file templates and the creation utility. Risk: the live-template settings classes live in an implementation package | +| Injected languages and documentation | `injections`, `inject_at`, `docs` | The injected language manager is fully public core API. Documentation goes through the modern target provider, not the older provider whose javadoc already asks for migration | +| Bookmarks and the project view | `bookmarks`, `bookmark_add`, `project_view_select`, `structure_select` | The bookmarks manager is experimental and therefore tolerated; the project view selects and changes panes. All UI thread | +| Workspace model, read-only | `workspace` | The current snapshot is documented as readable without locks from any thread — the cheapest read available. Updating the project model through it is obsolete; writes stay on the root-modification utility | +| PSI as a tree | `psi_tree`, `psi_at`, `psi_replace`, `psi_insert` | Create through the file factory with an explicit language on an in-memory file and graft the subtree; mutate with the element add/replace/delete API; navigate with the tree utility; whitespace and comments language-agnostically through the parser facade. Pattern matching is a predicate for extension points, not a finder | +| The indexes | `index_keys`, `index_query`, `stub_query` | The file-based index is an application service; stub elements come from the plural getter. Other plugins' indexes **are** queryable because index ids resolve by name globally; the limit is the class loader. Real trap: several overloads silently discard the id filter, and one throws on a scope without a project | +| UAST | `uast_tree`, `uast_at` | Present in Community in this build. The facade is a Kotlin object plus extension functions. **Read-only**: mutation goes through the underlying source PSI | + +**Conditional, and may never ship**: a completion tool. The service entry point is public, but the +convenient path is test-framework only and the progress indicator is internal. If there is no stable public +way to build completion parameters, it is not done — a tool that needs internal API to exist fails the policy. + +**Out of scope, documented**: language server integration. That module is not in Community and the official +documentation confirms it is a commercial-IDE extension, so such a domain would not start at all. + +## Horizon 3 — presence in the IDE + +This is what makes Claude *visible* in the IDE rather than only active in it, and it is what sustains the +proactive working style the plugin is for. + +| Area | Tools | Verified ground | +|---|---|---| +| Editor markup | `mark_add`, `mark_remove`, `marks`, `hint_add` | Range and line highlighters taken with the **colour-scheme key** overloads, which is what survives a theme change; gutter icons through the highlighter's renderer, whose base class declares equality and hashing abstract, so they must be implemented; inline, block and after-line-end hints through the inlay model, added in batch mode and removed by disposal | +| The editor banner | `banner_show`, `banner_clear` | Only through the project-level notification provider extension point, whose single method runs **under a read lock, off the UI thread** and returns a function the platform applies on the UI thread. Refresh through the notifications service; the older nested provider class and the per-provider refresh are deprecated | +| Diffs, scratches, the status bar | `diff_show`, `scratch_create`, `status` | Diff contents from the content factory, including the **empty content** that represents a created or deleted file, wrapped in a simple request and shown on the UI thread; a chain for several files. Scratch files are one call, and the platform already wraps it in a global-undo write command, so it must be called where a write action is legal. Status text through the window manager, with a fixed widget requiring the widget factory extension point | +| Split and remote IDE | — | **Not yet considered, and a real risk.** With a split frontend and backend, a local Unix socket may not be where the client believes. The remote-procedure and shared-API documentation is read before anything is promised | +| Other products | — | Databases with a supported API if one ever appears, plus the framework integrations, each as a degradable domain | + +Three pieces from this horizon that earlier sprints already need: background progress is the modern +suspending API and exists in this build, so the blocking progress manager is legacy and the project-first +asynchronous overload is scheduled for removal and banned; the file chooser factory moved and nearly all its +create methods are obsolete, so the short names are used; and a tool window of our own adds content through +the content manager, never by injecting components into the window's component. + +## What this drags along + +**Files rewritten**: the server enumeration, the injected prompt, the God Mode predicate, the launch +configuration builder, the session launcher, the launch options, the integration's persisted state, its +settings section, and the settings menu in the page. + +**Files deleted**: the per-server rule type and the known-tools inventory field. + +**Reused as they are**: the plugin installer and its server controls, now serving only the JetBrains server; +the single UI-thread helper; the installed-plugin lookup; the plugin id helper; the wire JSON model; the git +history service; the action invoker; the forge navigator; the review diff opener; and the anchored allowlist +pattern used by the vulnerability-prompted actions, including its per-token and cardinality ceilings. + +**Gates that move with it**: the package dependency contract gains constants for the two new packages and +their allowed edges, with the pure core added to the platform-free list; the file size ceiling applies as +everywhere else; the no-comments and reachability contracts apply; and the coverage floors need their +exclusions reviewed **before** the first MCP sprint, because the controller package will be IDE-bound and +therefore hard to unit-test at the same level as pure code. + +**Capability degradation**: a domain whose plugin is missing does not appear in its server's domain list at +all, and a server with no live domains is not started. Discovery is by plugin id against the verified list +for Docker, Kubernetes, the database plugin, the HTTP client, the configuration-language plugin, the cloud +toolkit, the JetBrains MCP server, the terminal and git. + +## The tests that define the design + +- The TOON codec against the specification's **published fixtures**, plus a round trip of our own. +- One-line framing with a payload containing an escaped newline. +- The same server answering a legacy handshake and a modern discovery. +- An unknown tool answered as a protocol error; a failed execution answered as a tool error with actionable + text, never an escaping exception. +- Truncation that announces itself. +- A stable order in the tool listing. +- The four sockets removed on dispose. +- No tool present in two catalogues; a server with no live domains does not start. +- **A dangerous input inside a nested argument of the dispatcher is refused by the guard.** +- No file outside the database gateway names the database plugin; no file in the git read package gains a + write API. +- **`tools/list` returns exactly three entries per server**, the injected block names no tool, and **no domain + exceeds four tools**. If any of those grows, on-demand discovery has broken. + +## Open decisions + +- The exact split of domains under the four-tool ceiling, taken at the start of each sprint. +- Whether the SQL argument is renamed so it inherits the guard's existing verdict. +- A stronger key derivation for the plugin passphrase, which would add a dependency and is decided separately. +- Whether a completion tool is viable at all. diff --git a/docs/PLATFORM_API_POLICY.md b/docs/PLATFORM_API_POLICY.md new file mode 100644 index 00000000..72459641 --- /dev/null +++ b/docs/PLATFORM_API_POLICY.md @@ -0,0 +1,98 @@ +# Platform API policy + +No deprecated API, no internal API, no warnings. The only tolerable warning is an experimental API. + +Two things enforce it. The compiler, because warnings are errors and a deprecated use warns while an +experimental one does not, so the rule needs no list. And a contract test, because an internal API produces +no warning at all — the compiler cannot see the difference. + +Everything below was read from the platform source at `idea/253.29346.138`. The verification method matters: +fetch the file contents through the forge API and decode them. **A summarising fetch is not acceptable for +code**, because it loses signatures, and an approximate signature is indistinguishable from an invented one. + +## Forbidden, with the replacement + +| Forbidden | Why | Use instead | +|---|---|---| +| The two-argument write command action runner | test-only | the named builder | +| The runnable form of the non-blocking read action | deprecated | the callable form | +| The document's character array getter | deprecated, scheduled for removal | the immutable character sequence, which needs no lock at all | +| The file editor manager's navigate-to-text-editor | deprecated for removal | opening the text editor from a descriptor | +| Any listener registration without a disposable | deprecated | the overload taking a disposable | +| The terminal's local shell widget creator, and the five-argument session creator | deprecated for removal; internal | the public shell widget creator | +| The project root manager's module root manager getter | internal | the module root manager's own instance getter | +| Hand-rolled modifiable root models | — | the root modification utility, which the javadoc recommends | +| The workspace model's project-model update | obsolete | its update method, or staying on the current snapshot | +| Dumb service's smart-mode read runner and its repeat-until-passes; the timed wait | deprecated; internal | the try-run variant, run-when-smart, and the untimed wait | +| The platform utilities class | **internal in its entirety** | the application names edition getter | +| The plugin descriptor's enabled flag | deprecated | the plugin manager's loaded and disabled checks | +| The action utility's invoke-action, all three | deprecated | its perform-action | +| Simple data context built from a string id or a map | deprecated for removal | always a typed data key | +| A query's iterable, iterator and spliterator | deprecated | find-all, any-match, or processing | +| The filename and file-type index name constants, and the project-first variants | deprecated and internal | the by-name virtual file getters | +| The daemon analyser implementation class | **internal in its entirety**, including its file-level highlights | the public extension's highlight processing | +| The breakpoint manager's find-at-line | deprecated for removal | the plural find-at-line | +| The brancher's string merge | deprecated | the reference overload | +| The run manager's two-argument add, and its template flag | deprecated; internal | the single-argument add | +| The process adapter class | deprecated | the process listener directly, which already has default methods | +| The project-less file template manager, and the project-taking default properties | deprecated | the project-taking instance and the argument-less properties | +| The change list manager's copy getter | deprecated | its plain getter | +| A notification built with a listener | deprecated for removal | adding actions | +| The inspection engine's context-holder entry point | internal | its public run-on-file | +| The virtual file manager's listener registration, **in all three forms including the one taking a disposable**; its synchronous, asynchronous and watcher-free refreshes | deprecated; obsolete | the asynchronous file listener with a disposable, or the topic | +| Local history's user label | internal | its system or event label | +| The stub index's get and containing-files | deprecated for removal | the element getter and the containing-files iterator | +| The file-based index's find-by-id; its rebuild scheduler; its up-to-date and indexable-files helpers | deprecated for removal; deprecated; internal | the containing-files getter and the request-rebuild with a cause | +| The element factory and parser facade service holders | deprecated | their instance getters | +| The two-argument create-file-from-text | deprecated | the overload naming a language or a file type | +| Document listener registration without a disposable | deprecated | the overload with a disposable | +| The command processor's listener registration | deprecated, scheduled for removal | the corresponding topic | +| The file utility's URI conversion and sequential child creation | deprecated for removal | the next-available-name helper | +| The asynchronous progress run taking a project first | deprecated, scheduled for removal | the task-and-indicator form, or suspending background progress | +| The file chooser descriptor factory's create methods | obsolete | the short names: single file, single directory, multiple files, and so on | +| The content factory's nested service holder | deprecated for removal | its instance getter | +| The todo search helper's find-files | deprecated | its processing form | +| The editor notifications' per-provider refresh, and its nested provider class | deprecated | refresh-all, and the provider extension point | +| The plugin manager's descriptor lookups: by class, by id, and the plugin arrays | internal since 262 | the plugin-aware class loader's descriptor and id for the plugin a class came from; an optional dependency in `plugin.xml` for the classes of another plugin, loaded through this plugin's own class loader; the installed and loaded checks, which stay public | +| The terminal tool window manager's shell widget creator | deprecated since 261 | the terminal tool window tabs manager's tab builder, and the view's send-text builder | +| The breakpoint manager's five-argument add-line-breakpoint | deprecated for removal since 262 | the four-argument form, then the breakpoint's temporary setter | +| The terminal execution console's constructors and its LF-to-CRLF switch | deprecated since 261; the builder they name is 262-only | a console view from the text console builder factory, attached to the process handler | +| The build file position's file getter | deprecated for removal since 262; its path getter is 262-only | the message event's navigatable: a file navigatable's descriptor, or an open-file descriptor | +| The PATH utility's find-in-path family | deprecated for removal since 263; find-first is 263-only | the PATH variable value and its directory list, with the exact executable names | +| Implementing the composite debugger node | its single-argument too-many-children is abstract and deprecated at once, so every implementation overrides a deprecated method | a dynamic proxy of the interface, dispatching by method name | + +## Tolerated, because experimental is not a promise broken + +The project task execution environment factory; the file editor manager's current-file and can-open checks; +the project-scoped JDK table getter; the module manager's all-module descriptions; the service view +descriptor's unique id; the bookmarks manager; and the terminal tool window tabs manager with its builders. + +## What the verifier is told to leave alone + +One class-name prefix, `org.jetbrains.uast`, is declared external to the verifier. UAST ships inside the Java +plugin, which this plugin depends on optionally; on an IDE without Java the package does not exist, the +UAST domain is never registered, and the verifier would still count the unresolved package as a +compatibility problem. The compile against the floor and the availability check are what cover that +package instead. + +## Watch list + +Not annotated today, but worth isolating in a single file so a change lands in one place: the public +highlight processing entry point carries an in-source note about being marked deprecated in future; and +find-in-project, the intentions pass, cached intentions, the inspection engine and the external system +utilities all live in implementation modules even though they are not annotated. + +## Corrections worth keeping + +- The detected line separator getter is a method of the **virtual file**, not of the text loading utility. + What the loading utility does have is line-separator detection and conversion, and that conversion takes a + requestor. +- **A virtual file system requestor is never null.** Null is interpreted as an external change, which + detaches an edit from the local history that would otherwise group it. +- The local history service **never returns null**; when unavailable it returns an inert stand-in, so + availability is checked with its enabled flag rather than a null comparison. +- Several stub index overloads **silently discard** the id filter passed to them, and one throws on a scope + without a project. +- Pattern matching is a predicate for extension points, not a way to find elements, and its own javadoc asks + that it not be overused. +- Language server integration is not in Community at all, so a domain built on it would not start. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 4a2f35f8..afe87edc 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -64,6 +64,8 @@ and the ones at the root of the document are the **all gated code** row. Measure | `ui/`, `ui/jcef/` | — | — | ❌ excluded — covered elsewhere | | `actions/` | — | — | ❌ excluded — one delegate call each | | `util/` | — | — | ❌ excluded — one line, and it needs a live platform to run | +| `model/mcp/` | — | — | ✅ gated — pure: the TOON codec, the JSON-RPC shapes, the meta-tools and the server | +| `controller/mcp/` | — | — | ✅ gated for `ServerEndpoint`, `SocketHome`, `GuardGate`; ❌ `IdeMcpService`, `IdeToolCatalog` and `tools/` excluded by name — they need a live index | `vuln/` carries the one **known debt** in this table: `OsvScanner` has no test at all and is deliberately left inside the gate rather than excluded with its two neighbours, so the package figure keeps paying for it. It diff --git a/docs/SECURITY-GUARD.md b/docs/SECURITY-GUARD.md index 653b969d..147aeeac 100644 --- a/docs/SECURITY-GUARD.md +++ b/docs/SECURITY-GUARD.md @@ -317,10 +317,12 @@ rule in plain words and carries two links. That is why the rules are narrow in the first place. A one-click action can only ever be as safe as the smallest thing it can relax. -**Whitelist Command** takes the exact command that was refused and adds it to the whitelist of **the rule -that refused it**, so that command runs and nothing else changes. It is not offered when the block names no -command to match on; it never writes to the category or global lists, which are edited on the Settings page; -and it checks the command is not already permitted, so pressing it twice does not grow the list. +**Whitelist Command** takes the program and its subcommand from the command that was refused — `npm install` +from `npm install left-pad`, `sudo` from `sudo -l` — and adds that prefix to the whitelist of **the rule that +refused it**, so every command starting with it runs under that rule and nothing else changes. It is not +offered when the block names no command to match on; it never writes to the category or global lists, which +are edited on the Settings page; and it checks the entry is not already there, so pressing it twice does not +grow the list. **And it asks for how long.** Seven choices — 5 minutes, 15 minutes, 30 minutes, 4 hours, 8 hours, until the IDE closes, or for ever — with no pre-selected default, so opening the menu commits to nothing and the choice @@ -341,8 +343,9 @@ next one you open. ### Whitelisting a command -If `terraform destroy` is part of your actual job, a whitelist takes a full command and runs it without -asking. There are three, and they differ only in **reach**: +If `terraform destroy` is part of your actual job, a whitelist takes a command **prefix** and runs whatever +starts with it without asking: `sudo` covers every `sudo …`, `sudo ls /home` covers `sudo ls /home/anything` +but not `sudo ls /etc`. There are three lists, and they differ only in **reach**: | List | Applies to | |---|---| @@ -350,14 +353,15 @@ asking. There are three, and they differ only in **reach**: | **This category** | every rule in one group | | **Everywhere** | any rule at all | -The guard asks them narrowest first, so a permission can always be traced to one entry rather than to -"it is whitelisted somewhere". +Any of the three lifts the verdict; the notice names the narrowest one that matched, and **Remove from +whitelist** on that notice clears the entry from every list that covers the command, so removing it means +it is judged again. Two fences remain, and they are about *what* is matched, never about *which rule* you are allowed to lift: -- **The whole command, de-obfuscated on both sides.** `terraform destroy` does not authorise - `terraform destroy && rm -rf /` — that is a different string — and `t""erraform destroy` cannot sneak - past an entry written normally. +- **Every segment of the line, de-obfuscated on both sides.** A compound line is split on `&&`, `;`, `|` + and newlines and each piece must start with an entry: `sudo` authorises `sudo -l && sudo ls /` and does + not authorise `sudo -l && rm -rf /`, and `t""erraform destroy` cannot sneak past an entry written normally. - **Every command the call issues has to be covered.** One approved command in a chain of three approves nothing. @@ -433,7 +437,7 @@ stops or arrives on your screen for a decision. One layer, doing one job properl ## For contributors -The guard lives in `src/main/kotlin/dev/lain/claudejb/permission/`. `SensitiveGuard.kt` owns the policy +The guard lives in `src/main/kotlin/dev/lain/claudejb/model/permission/`. `SensitiveGuard.kt` owns the policy and the verdict; every rule family is a file of its own. Adding a rule means adding a file, never a branch in the verdict: diff --git a/docs/SKILL_INVENTORY.md b/docs/SKILL_INVENTORY.md new file mode 100644 index 00000000..c70a489e --- /dev/null +++ b/docs/SKILL_INVENTORY.md @@ -0,0 +1,476 @@ +# Skill inventory — what Claude can do in the IDE + +Every capability Claude has inside the IDE is an MCP tool served by the plugin itself. This is the +inventory: what exists today, how each tool is reached, and what the roadmap still owes. The order of +work lives in [`MCP_ROADMAP.md`](MCP_ROADMAP.md); the architecture in [`../DIRECTIVES.md`](../DIRECTIVES.md). + +## How a tool is reached + +Four MCP servers run inside the plugin, one per family, over Unix sockets: **`code`**, **`run`**, +**`vcs`** and **`ops`**. Each one exposes only three meta-tools, so nothing loads up front: + +| Meta-tool | What it does | +|---|---| +| `domains()` | Lists the server's domains, one line each. Always first. | +| `tools(domain)` | Lists the tools of one domain with their parameters. Only for the domain about to be used. | +| `run(tool, args)` | Runs one tool. Every result is TOON. The Security Guard judges `args` before anything runs. | + +A request in the chat reaches a tool through that ladder: *"read `Foo.kt`"* becomes +`mcp__code__domains` → `mcp__code__tools(read)` → `mcp__code__run(read_file, {path})`. The examples below +write only the last step, as `server ▸ tool {args}`. + +Rules that hold for every tool: + +- **Lists**: any tool with a `paths`, `queries`, `names`, `positions`, `edits`, `files`, `hashes` or + `statements` parameter runs once per item in a single call and draws one card per item. Up to 50 items. + `git_stage` and `git_commit` take `paths` as one call, not a batch. +- **Long tools** (`build`, `run_configuration`, `run_tests`, `shell`, `http_run`) stream their output to the + card, answer `status: running` after `wait` seconds, and are resumed with `job`. +- **Positions** are 1-based `line` and `column`; paths are absolute or relative to the project root. +- Every enumerating tool carries a `max`, and says `truncated` when it hit it. +- A tool marked *mutates* is a change the user sees in the IDE (a diff, a refresh, a dialog). + +## `code` — the project as the IDE resolves it + +### read · search + +| Tool | Capability | When · example | +|---|---|---| +| `read_file` | A file as the editor holds it, unsaved edits included; `offset`/`limit` for big files; several with `paths`. | Any "look at", "open", "what does X contain". `code ▸ read_file {paths: ["src/A.kt", "src/B.kt"], limit: 120}` | +| `search_text` | Text or regex across the project; one row per hit with file and line, no text. | "where is X used", "find the string". `code ▸ search_text {queries: ["TODO", "class .*Test"], regex: true}` | +| `find_files` | Files by exact name or glob. | "where is the file called". `code ▸ find_files {names: ["*.http", "Guard*.kt"]}` | +| `list_directory` | A directory as the project tree shows it, excluded entries left out, `depth` levels. | "what is in this folder". `code ▸ list_directory {path: "src/main/kotlin", depth: 2}` | + +### navigate · outline · hierarchy + +| Tool | Capability | When · example | +|---|---|---| +| `find_symbols` | Classes, functions and other named symbols whose name contains the query, as Go to Symbol does; `libraries` to include them. | "which classes are called …Tools". `code ▸ find_symbols {queries: ["Tools", "Guard"]}` | +| `definition` | The declaration the reference at a position resolves to. | "where is this defined". `code ▸ definition {path: "A.kt", line: 9, column: 24}` | +| `references` | Every place that references the symbol at a position. | "who calls this", "is this used". `code ▸ references {path: "A.kt", line: 14, column: 9}` | +| `implementations` | Implementations or overrides of the symbol at a position. | "who implements this interface". `code ▸ implementations {path: "I.kt", line: 6}` | +| `file_outline` | The declarations of a file as a tree with their lines, like the Structure view. | Before reading a big file. `code ▸ file_outline {paths: ["Session.kt"], depth: 2}` | +| `symbol_info` | Kind, name, declaring signature and location of the symbol at a position. | "what is this thing". `code ▸ symbol_info {positions: [{path: "A.kt", line: 21, column: 47}]}` | +| `hierarchy` | Callers or callees of the symbol at a position, nested up to `depth` 3. | "trace who reaches this". `code ▸ hierarchy {path: "A.kt", line: 123, kind: "callers", depth: 3}` | + +### diagnostics · inspect + +| Tool | Capability | When · example | +|---|---|---| +| `problems` | Errors and warnings the IDE's analysis shows for a file (opens it), with line, column, severity and inspection; `severity` error/warning/weak/all. | Before calling any edit done. `code ▸ problems {paths: ["A.kt", "B.kt"], severity: "warning"}` | +| `project_problems` | Everything the Problems view lists across the project; `group` filters by inspection family or plugin. | "is the project clean", "what does Qodana say". `code ▸ project_problems {group: "Qodana"}` | +| `problems_view` | Lists the Problems tool window's tabs, or shows one to the user. | "show me the security findings". `code ▸ problems_view {tab: "Security Analysis"}` | +| `inspections` | The inspections of the current profile — id, name, group, enabled — filtered by a query. | To find an inspection id. `code ▸ inspections {query: "unused"}` | +| `inspect` | Runs the profile's enabled inspections on a file, or one by id; findings with line, severity and message; hints below `severity` stay out. | "run the inspections on this file", "is there anything unused here". `code ▸ inspect {path: "A.kt", inspection: "UnusedSymbol"}` | + +### edit + +| Tool | Capability | When · example | +|---|---|---| +| `replace_text` *mutates* | One literal replacement (all with `replace_all`), one undo entry, saved, shown as a diff; several files with `edits`. | Any targeted change. `code ▸ replace_text {edits: [{path: "A.kt", old_string: "x", new_string: "y"}]}` | +| `insert_text` *mutates* | Whole lines before a line (one past the end appends). | Adding a member or an import. `code ▸ insert_text {path: "A.kt", line: 5, content: "fun twice() = 2"}` | +| `create_file` *mutates* | A new file, directories created, opened; fails if it exists. | "create a test for". `code ▸ create_file {files: [{path: "src/test/X.kt", content: "…"}]}` | +| `write_file` *mutates* | A whole rewrite as one undo entry and one diff, or creation when absent. | A file that changes more than it keeps. `code ▸ write_file {path: "A.kt", content: "…"}` | + +### edit_ops + +| Tool | Capability | When · example | +|---|---|---| +| `undo` *mutates* | Edit ▸ Undo on a file through the IDE's undo stack; returns whether there was anything to undo. | "take that back". `code ▸ undo {path: "A.kt"}` | +| `redo` *mutates* | Edit ▸ Redo on a file. | "put it back". `code ▸ redo {path: "A.kt"}` | +| `search_replace` *mutates* | Replace in Files with Replace All: text or regex across the files that match (or only `paths`, one call), one undoable command per file, first file shown. | A rename of a string across the project. `code ▸ search_replace {query: "foo", replacement: "bar", paths: ["A.kt", "B.kt"]}` | +| `line_ops` *mutates* | join, duplicate, delete, indent or unindent at a line, as the editor would. | "duplicate line 12". `code ▸ line_ops {action: "duplicate", path: "A.kt", line: 12}` | + +### refactor · format + +| Tool | Capability | When · example | +|---|---|---| +| `rename` *mutates* | The IDE's Rename on the symbol at a position, or the file; every reference follows; fails on conflict. | "rename X to Y". `code ▸ rename {path: "A.kt", line: 6, column: 9, new_name: "salute"}` | +| `move_file` *mutates* | The IDE's Move: packages, imports and references follow. | "move this into package p". `code ▸ move_file {path: "A.kt", destination: "src/main/kotlin/p"}` | +| `safe_delete` *mutates* | Deletes a symbol or a file only when nothing uses it; otherwise lists the blocking usages. | "remove this if unused". `code ▸ safe_delete {path: "A.kt", line: 7, column: 9}` | +| `reformat` *mutates* | Reformat Code on a file or a line range, with the project's code style. | After editing. `code ▸ reformat {paths: ["A.kt", "B.kt"]}` | +| `optimize_imports` *mutates* | Optimize Imports on a file. | After editing. `code ▸ optimize_imports {path: "A.kt"}` | + +### editor + +| Tool | Capability | When · example | +|---|---|---| +| `open_file` | Opens a file at a line and column, as Go to File does. | "show me", and on every file edited. `code ▸ open_file {path: "A.kt", line: 14}` | +| `active_file` | The selected editor with caret and selection, plus every open file. | "what am I looking at". `code ▸ active_file {}` | +| `index_status` | Whether the IDE is indexing; `wait` blocks until it is done. | On an indexing error, before symbol tools. `code ▸ index_status {wait: true}` | +| `editor_action` *mutates* | The Code menu at a position: override, implement, delegate, generate, surround, unwrap, comment_line/block, move_statement/element/line, rearrange, auto_indent, insert/save_template, fold/unfold (+recursively, +all), update_copyright, quick_doc/definition/type. Caret placed, file in a tab without focus. | "override toString here". `code ▸ editor_action {action: "override", path: "A.kt", line: 12}` | + +### analyze + +| Tool | Capability | When · example | +|---|---|---| +| `inspect_scope` *mutates* | Code ▸ Inspect Code on project, module, dir or file; the Inspection Results window shows them. | "inspect the whole module". `code ▸ inspect_scope {scope: "module", module: "app"}` | +| `cleanup` *mutates* | Code ▸ Code Cleanup on a scope, one undoable command. | "clean up this package". `code ▸ cleanup {scope: "dir", path: "src/main/kotlin/x"}` | +| `file_dependencies` | What the files of a scope depend on (forward, `transitive` levels); backward opens the IDE's analysis. | "what does this file pull in". `code ▸ file_dependencies {scope: "file", path: "A.kt"}` | +| `dataflow` | Analyze Data Flow to/from the expression at a position, in the IDE's window. | "where does this value come from". `code ▸ dataflow {path: "A.kt", line: 12, column: 9, direction: "to"}` | + +### analysis + +| Tool | Capability | When · example | +|---|---|---| +| `stack_trace` | Frames of a trace resolved to project files; the Analyze Stack Trace dialog opens with the text. | A pasted exception. `code ▸ stack_trace {text: "…"}` | +| `duplicates` | Locate Duplicates on a file or the project, in the IDE's window. | "is this duplicated anywhere". `code ▸ duplicates {path: "A.kt"}` | +| `infer_nullity` *mutates* | Infer Nullity (Java) with the IDE's dialog. | "annotate nullability". `code ▸ infer_nullity {path: "A.java"}` | +| `related` | Tests of a class, the subject of a test (data), super method, implementations; the Navigate action opens it. | "where are the tests for this". `code ▸ related {kind: "test", path: "A.kt", line: 5}` | + +### views + +| Tool | Capability | When · example | +|---|---|---| +| `diff_show` | The IDE's diff of two files. | "diff these two". `code ▸ diff_show {left: "A.kt", right: "B.kt"}` | +| `compare` | A file against another or against the active editor. | "compare with what I have open". `code ▸ compare {path: "A.kt"}` | +| `mark_as` *mutates* | Mark Directory as source, test, resources, test_resources, excluded, or unmark. | "this is a test root". `code ▸ mark_as {path: "src/it", kind: "test"}` | +| `open_in` | Reveal in the file manager, open in the IDE's Terminal, or in the associated app. | "open the folder". `code ▸ open_in {path: "build", where: "file_manager"}` | + +### files + +| Tool | Capability | When · example | +|---|---|---| +| `copy_path` | absolute, relative, name onto the clipboard and returned; reference runs Copy Reference. | "copy the path". `code ▸ copy_path {path: "A.kt", kind: "absolute"}` | +| `file_type` *mutates* | The file type the IDE assigns; with `type`, associates the name with it. | "treat this as JSON". `code ▸ file_type {path: "x.cfg", type: "JSON"}` | +| `ignore` *mutates* | Adds a path to .gitignore or another ignore file; the file opens. | "ignore the build dir". `code ▸ ignore {path: "build"}` | +| `delete_file` *mutates* | Deletes paths through the VFS, one call, inside the project only. | Scratch files with no usages. `code ▸ delete_file {paths: ["tmp.txt"]}` | + +### refactor_ops + +| Tool | Capability | When · example | +|---|---|---| +| `introduce` *mutates* | Introduce variable, constant, field, parameter or functional_parameter at a position or selection. | "extract this into a constant". `code ▸ introduce {kind: "constant", path: "A.kt", line: 8, column: 12, to_line: 8, to_column: 30}` | +| `extract` *mutates* | Extract method, interface, superclass, delegate or module. | "extract these lines into a method". `code ▸ extract {kind: "method", path: "A.kt", line: 10, to_line: 14}` | +| `inline` *mutates* | Refactor ▸ Inline at a position. | "inline this variable". `code ▸ inline {path: "A.kt", line: 9, column: 5}` | +| `members` *mutates* | pull_up, push_down, change_signature, move, encapsulate_fields, make_static, convert_to_instance, inheritance_to_delegation, anonymous_to_inner, method_object. | "change the signature". `code ▸ members {action: "change_signature", path: "A.kt", line: 20, column: 9}` | + +### templates + +| Tool | Capability | When · example | +|---|---|---| +| `templates` | The live templates: key, group, description, text; `query`. | Before `template_apply`. `code ▸ templates {query: "main"}` | +| `template_apply` *mutates* | Expands a live template at a position, as key + Tab would; the user fills the variables. | "put a for loop here". `code ▸ template_apply {key: "fori", path: "A.kt", line: 12}` | +| `file_templates` | The file templates: name, extension, text. | Before `file_from_template`. `code ▸ file_templates {query: "Kotlin"}` | +| `file_from_template` *mutates* | New ▸ template in a directory with `props`; the file opens. | "create a Kotlin class Foo in x". `code ▸ file_from_template {template: "Kotlin Class", dir: "src/main/kotlin/x", name: "Foo"}` | + +### language + +| Tool | Capability | When · example | +|---|---|---| +| `injections` | The language fragments injected into a file's literals. | "is that SQL recognised". `code ▸ injections {path: "Dao.kt"}` | +| `inject_at` *mutates* | Inject a language into the literal at a position (IntelliLang). | "treat this string as JSON". `code ▸ inject_at {path: "A.kt", line: 9, column: 20, language: "JSON"}` | +| `docs` | The quick documentation popup for a symbol. | "what does this do". `code ▸ docs {path: "A.kt", line: 9, column: 5}` | + +### bookmarks + +| Tool | Capability | When · example | +|---|---|---| +| `bookmarks` | Every bookmark: group, file, line, mnemonic, description. | "where did I leave marks". `code ▸ bookmarks {}` | +| `bookmark_add` *mutates* | A bookmark on a file or a line, in a group, with a description. | "remember this spot". `code ▸ bookmark_add {path: "A.kt", line: 40, description: "fix here"}` | +| `bookmark_remove` *mutates* | Remove the bookmarks of a file, or the one on a line. | `code ▸ bookmark_remove {path: "A.kt", line: 40}` | +| `project_view` | Select a file in the Project window, switching pane if asked. | "show it in the tree". `code ▸ project_view {path: "A.kt"}` | + +### psi + +| Tool | Capability | When · example | +|---|---|---| +| `psi_tree` | The syntax tree of a file or of the element at a line, to a depth. | When text is not enough. `code ▸ psi_tree {path: "A.kt", line: 12, depth: 2}` | +| `psi_at` | The leaf at a position and its parents. | "what is this token". `code ▸ psi_at {path: "A.kt", line: 12, column: 9}` | +| `psi_replace` *mutates* | Replace the element (or a parent) with text parsed in the file's language, reformatted. | Structural edits. `code ▸ psi_replace {path: "A.kt", line: 12, column: 9, parent: 1, text: "foo(1)"}` | +| `psi_insert` *mutates* | Insert parsed text before or after the element. | `code ▸ psi_insert {path: "A.kt", line: 12, text: "val x = 1", where: "after"}` | + +### index + +| Tool | Capability | When · example | +|---|---|---| +| `index_keys` | The keys of a file-based index by name. | `code ▸ index_keys {index: "TodoIndex"}` | +| `index_query` | The files behind one key. | `code ▸ index_query {index: "filetypes", key: "Kotlin"}` | +| `stub_query` | A stub index's keys, or the elements behind a key. | "every class named Foo". `code ▸ stub_query {index: "java.class.shortname", key: "Foo"}` | + +### uast (IDEs with the Java plugin) + +| Tool | Capability | When · example | +|---|---|---| +| `uast_tree` | The unified AST of a JVM-language file to a depth. | Cross-language analysis. `code ▸ uast_tree {path: "A.kt", depth: 2}` | +| `uast_at` | The UAST node at a position and its parents. | `code ▸ uast_at {path: "A.kt", line: 12, column: 9}` | + +### workspace + +| Tool | Capability | When · example | +|---|---|---| +| `workspace` | The workspace model's modules, content roots, source roots, libraries or SDKs, with their entity source. | "what did Gradle import". `code ▸ workspace {entity_type: "source_root"}` | + +### markup + +| Tool | Capability | When · example | +|---|---|---| +| `mark_add` *mutates* | A highlight, warning or error range over lines, or a gutter icon with a tooltip; returns an id. | "show me where the bug is". `code ▸ mark_add {path: "A.kt", line: 12, to_line: 14, kind: "warning", tooltip: "null here"}` | +| `mark_remove` *mutates* | Remove a mark or hint by id. | `code ▸ mark_remove {id: 3}` | +| `marks` | The marks of the session, all or for a file. | `code ▸ marks {path: "A.kt"}` | +| `hint_add` *mutates* | An inline hint before or after a position, as parameter hints look. | "annotate what this returns". `code ▸ hint_add {path: "A.kt", line: 12, column: 20, text: ": Int"}` | + +### presence + +| Tool | Capability | When · example | +|---|---|---| +| `banner_show` *mutates* | A banner over a file's editor with action labels; the click is reported by `banner_clear`. | A choice tied to a file. `code ▸ banner_show {path: "A.kt", text: "Migrate this?", actions: ["Yes", "Later"]}` | +| `banner_clear` *mutates* | Removes the banner; returns the chosen action. | `code ▸ banner_clear {path: "A.kt"}` | +| `status` *mutates* | Text in the status bar. | "tell me when it's done". `code ▸ status {text: "Claude: tests green"}` | +| `scratch_create` *mutates* | A scratch file with a language and content, opened. | Notes, queries, drafts. `code ▸ scratch_create {name: "plan.md", content: "# Plan"}` | + +### recent + +| Tool | Capability | When · example | +|---|---|---| +| `recent` | Recently opened files (`kind=files`) or recently changed ones (`changed_files`), newest first. | "what was I working on". `code ▸ recent {kind: "changed_files"}` | +| `navigate_history` *mutates* | Navigate ▸ Back, Forward, Last Edit Location, Next Edit Location on the user's editor. | "go back to where I was". `code ▸ navigate_history {direction: "back"}` | +| `compare_clipboard` | View ▸ Compare with Clipboard against a file, in the IDE's diff window. | "diff this against what I copied". `code ▸ compare_clipboard {path: "A.kt"}` | +| `scheme` *mutates* | List or set the theme, color scheme, keymap or code style, as Quick Switch Scheme does. | "switch to the dark theme". `code ▸ scheme {kind: "theme", action: "set", name: "Dark"}` | + +## `run` — build, run, test, shell, debug + +| Tool | Capability | When · example | +|---|---|---| +| `build` *mutates* | The IDE's build, incremental or `rebuild`, a `module` or one `file`, with the compiler's errors and positions; streamed. | "does it compile". `run ▸ build {kind: "module", module: "app", wait: 110}` | +| `run_configurations` | The run configurations as the Run combo shows them: name, type, temporary, selected. | Before running anything. `run ▸ run_configurations {}` | +| `run_configuration` *mutates* | Starts one as the Run button does, before-launch tasks included; `executor` run, debug, coverage or profile; exit code and console tail; several with `names`. | "run the gates", any project script that already has a configuration. `run ▸ run_configuration {name: "Tool: lint", wait: 110}` | +| `processes` *mutates* | The Run tool window's tabs, or stops one by name. | "is it still running", "stop it". `run ▸ processes {action: "stop", name: "Kotlin tests"}` | +| `run_tests` *mutates* | Tests through the IDE's runner: a file, several, the test at a line, or a named configuration; pass/fail/ignored and each failure's message and frame. | "run this test". `run ▸ run_tests {name: "ToolModelTest", wait: 110}` | +| `tests` | The test classes and methods the IDE's frameworks recognise in a file, with lines. | "what tests are in here". `run ▸ tests {path: "src/test/X.kt"}` | +| `shell` *mutates* | A command in the user's shell inside a Terminal tab; exit code and tail. Replaces Bash. | Any command; several chained in one call. `run ▸ shell {command: "git log -3 --oneline", wait: 20}` | +| `terminal_tabs` *mutates* | The Terminal window's tabs (name, selected, ours, running) or close one by name. | "close your tab". `run ▸ terminal_tabs {action: "close", name: "Claude"}` | +| `edit_configuration` *mutates* | Run ▸ Edit Configurations at a configuration. | `run ▸ edit_configuration {name: "Kotlin tests"}` | +| `attach` *mutates* | Run ▸ Attach to Process chooser. | `run ▸ attach {}` | +| `coverage` *mutates* | The Coverage window; switch, hide, report, import. | After `executor: "coverage"`. `run ▸ coverage {action: "report"}` | +| `session` *mutates* | Start a configuration under the debugger and wait for the first stop; status with frames and variables; stop; list. | "debug this test". `run ▸ session {action: "start", name: "ToolModelTest"}` | +| `step` *mutates* | over, into, out, force_into, smart_into, resume, pause, mute, run_to a line, or wait; answers with the session status. | Once suspended. `run ▸ step {kind: "run_to", path: "A.kt", line: 22}` | +| `frames` | Threads and the stack of one; `frame` selects the current frame for `values`. | "where is it stopped". `run ▸ frames {max: 5}` | +| `values` *mutates* | Variables of the current frame; `eval` an expression; `set` a variable. | "what is x here". `run ▸ values {action: "eval", code: "tools.size"}` | +| `breakpoint` *mutates* | Add (with `condition`, `temporary`), remove or list line breakpoints. | Before `session`. `run ▸ breakpoint {action: "add", path: "A.kt", line: 21}` | + +## `vcs` — Git and the forge through the IDE + +| Tool | Capability | When · example | +|---|---|---| +| `git_status` | The working tree as the Changes view sees it: branch, HEAD, upstream, ahead/behind, every changed path with its type. | Before staging or committing, and before any claim about the tree. `vcs ▸ git_status {}` | +| `git_log` | Recent commits with hash, subject, author, date and files; one or several `hashes` with their paths; `all_branches`. | "what changed lately", "what did commit X touch". `vcs ▸ git_log {hashes: ["5db1226"]}` | +| `git_diff` | The unified diff of the uncommitted changes: whole tree, one path, or several. | Reviewing before a commit. `vcs ▸ git_diff {paths: ["A.kt"], max_lines: 200}` | +| `git_branches` | Every local and remote branch with its commit, current first. | "which branches exist". `vcs ▸ git_branches {}` | +| `git_stage` *mutates* | Stages (`add`) or unstages (`reset`) paths through the IDE's Git. | Only the paths touched, never blind. `vcs ▸ git_stage {action: "add", paths: ["A.kt"]}` | +| `git_commit` *mutates* | Commits what is staged, or only `paths`; signing and hooks as the user's Git configures them. | One commit per logical unit. `vcs ▸ git_commit {message: "fix(x): …", paths: ["A.kt"]}` | +| `git_branch` *mutates* | Creates a branch or checks one out (`start_point` creates it there). Deleting is the user's. | "start a branch for". `vcs ▸ git_branch {action: "checkout", name: "feature/x", start_point: "develop"}` | +| `git_remote` *mutates* | Fetch, pull or push with the IDE's credentials; returns upstream and ahead/behind. Push is the maintainer's call. | "fetch". `vcs ▸ git_remote {action: "fetch"}` | +| `vcs_open` | Shows a VCS view: the Git log (at a `hash`, or only a `range` such as `v5.8.1..HEAD`), a file's history, the Commit window, or the pull-requests view. | "open the log", "compare the branch with the last release". `vcs ▸ vcs_open {view: "log", range: "v5.8.1..HEAD"}` | +| `pull_requests` | The GitHub repository's pull requests through the IDE's account: number, title, state, draft, author, updated, url; `state` open/closed/merged/all. The Pull Requests view is shown. | "what PRs are open". `vcs ▸ pull_requests {state: "open"}` | +| `pull_request` | One pull request by number with base, head, review decision and body; `open` shows the view and opens it in the browser. | "show me #42". `vcs ▸ pull_request {number: 42, open: true}` | +| `vcs_action` *mutates* | Every entry of the Git menu and its GitHub/GitLab submenus by name: pull, push, fetch, merge, rebase (+abort/continue/skip), cherry-pick continue/abort, revert_abort, branches, new_branch, rename_branch, compare_with_branch, stash, unstash, stash_silently, show_stash, shelve, show_shelf, rollback, annotate, compare_same_version, file_history, tag, reset, resolve_conflicts, commit, update, unshallow, worktrees, new_worktree, configure_remotes, clone, init, create_pull_request, pull_requests, share_on_github, clone_github, sync_fork, create_gist, github_accounts, create_merge_request, merge_requests, clone_gitlab, create_snippet, gitlab_accounts; `path` or `hash` for entries that act on a file or a commit. | When the user must confirm in the IDE. `vcs ▸ vcs_action {action: "annotate", path: "A.kt"}` | + +### log_ops + +| Tool | Capability | When · example | +|---|---|---| +| `commit_action` *mutates* | The Log's commit menu on a hash: cherry_pick, checkout, browse_at_revision, compare_with_local, reset_to, revert, undo, reword, fixup, squash_into, squash, drop, interactive_rebase, push_up_to, add_to_remote_branch, new_branch, new_tag, copy_revision, open_in_browser. The commit is selected in the Log first. | "cherry-pick that commit". `vcs ▸ commit_action {action: "cherry_pick", hash: "d20afbe"}` | +| `branch_op` *mutates* | The Branches popup through `GitBrancher`: merge, rebase, rebase_onto, compare, diff_with_local, rename, delete, checkout, checkout_as_new, new_tag; `target` is the other name. | "merge develop into this branch". `vcs ▸ branch_op {action: "merge", ref: "develop"}` | +| `worktrees` *mutates* | list, add (path, optional new branch) or remove a working tree. | "add a worktree for the hotfix". `vcs ▸ worktrees {action: "add", path: "../hotfix", branch: "hotfix/x"}` | +| `remotes` *mutates* | list, add, remove or rename a remote (`url` carries the new name for rename). | "add the upstream remote". `vcs ▸ remotes {action: "add", name: "upstream", url: "git@github.com:org/repo.git"}` | + +### changes + +| Tool | Capability | When · example | +|---|---|---| +| `stash` *mutates* | list, save (with message), pop, apply, drop through the IDE's Git; the stash list after. | "stash this while I check main". `vcs ▸ stash {action: "save", message: "wip"}` | +| `shelve` *mutates* | The IDE's shelf: list, shelve (name, optional `paths`) with rollback, unshelve by name. | "shelve these two files". `vcs ▸ shelve {action: "shelve", name: "spike", paths: ["A.kt", "B.kt"]}` | +| `patch` *mutates* | create writes the changes (or `paths`) as a unified diff to `path`; apply opens the IDE's Apply Patch dialog. | "make me a patch". `vcs ▸ patch {action: "create", path: "wip.patch"}` | +| `rollback` *mutates* | The IDE's Rollback on the given changed files, one call, undoable from Local History. | "throw away my changes to A.kt". `vcs ▸ rollback {paths: ["A.kt"]}` | + +### history + +| Tool | Capability | When · example | +|---|---|---| +| `blame` | Line, commit, author, date for lines `from`..`to` from the IDE's annotations; the gutter is shown. | "who wrote this". `vcs ▸ blame {path: "A.kt", from: 10, to: 20}` | +| `file_history` | The commits that touched a file, renames followed; the history tab is shown. | "when did this change". `vcs ▸ file_history {path: "A.kt"}` | +| `local_history` *mutates* | show the IDE's Local History of a file; label the project before a risky change; revert a file to a label of this session. | Before a big refactor. `vcs ▸ local_history {path: "A.kt", action: "label", label: "before-rename"}` | +| `file_at` | A file's content at a ref, and the IDE's diff of it against the working tree. | "how was this on main". `vcs ▸ file_at {path: "A.kt", ref: "main"}` | + +### pull_request_ops + +| Tool | Capability | When · example | +|---|---|---| +| `pr_create` *mutates* | Opens a pull request through the IDE's GitHub account: base, head, title, body, draft; the Pull Requests view is shown. | "open the PR to develop". `vcs ▸ pr_create {base: "develop", head: "feature/x", title: "…", body: "…"}` | +| `pr_comment` *mutates* | A comment on a pull request's conversation, as the IDE's account. | "leave a note on #74". `vcs ▸ pr_comment {number: 74, body: "…"}` | +| `pr_checks` | Mergeability and every check on the head commit, polled every 10 s until nothing is pending or `wait` runs out; `settled` and `can_merge` say where it stands. | "is the CI green". `vcs ▸ pr_checks {number: 74, wait: 110}` | +| `pr_merge` *mutates* | A merge commit through the IDE's account, only when the checks have settled green and the merge state is clean; refuses otherwise, naming the blocker. Merging into a branch that publishes on merge publishes. | "merge it". `vcs ▸ pr_merge {number: 74}` | + +### release + +| Tool | Capability | When · example | +|---|---|---| +| `tags` | The repository's tags on GitHub with their commits. | "is v6.0.0 tagged". `vcs ▸ tags {max: 5}` | +| `workflow_runs` | GitHub Actions runs, optionally of one branch: status, conclusion, url. | "did the release job pass". `vcs ▸ workflow_runs {branch: "main", max: 5}` | +| `release` | The GitHub Release of a tag with its assets. | "is the Release out, with the zip and the signatures". `vcs ▸ release {tag: "v6.0.0"}` | +| `marketplace` | The plugin's versions on the JetBrains Marketplace, from the public API, no account. | "is 6.0.0 on the Marketplace". `vcs ▸ marketplace {}` | + +## `ops` — the Services panel, the project, the IDE, data + +| Tool | Capability | When · example | +|---|---|---| +| `services` | The Services tree as the user sees it: path, name, contributing plugin, state; `filter`. Only nodes with services, as the view shows. | Before any other services tool. `ops ▸ services {filter: "Docker"}` | +| `service_actions` | The actions the IDE offers on a node, with id, text and enabled. | To know what `service_action` can do. `ops ▸ service_actions {path: "Docker/Docker/Containers/web"}` | +| `service_action` *mutates* | Performs one of those actions exactly as clicking it would. | "stop the container", "connect to Docker". `ops ▸ service_action {path: "Docker/Docker/Containers/web", action: "Stop Container"}` | +| `service_open` | Reveals a node in the Services window. | "show me the cluster". `ops ▸ service_open {path: "Docker/Docker"}` | +| `project` | Name, base path, SDK, indexing, module count, active VCSs. | First call in an unknown project. `ops ▸ project {}` | +| `modules` | The modules as Project Structure shows them. | "how is the project split". `ops ▸ modules {}` | +| `dependencies` | One module's order entries in classpath order, with scope. | "what does the main module depend on". `ops ▸ dependencies {module: "app.main"}` | +| `dependency_add` *mutates* | Adds an existing library to a module through the project model (not for Gradle/Maven, which edit the build file). | Plain IntelliJ projects only. `ops ▸ dependency_add {module: "app", library: "junit", scope: "test"}` | +| `ide_action` *mutates* | Any registered IDE action by id, as its menu entry would; with a target it runs as that context menu would: a file (`path`, `line`, `column` — file, PSI and an editor on it), a commit (`hash` — selected in the Git Log), a Services node (`node`). | What no other tool covers. `ops ▸ ide_action {action_id: "Git.CompareWithBranch"}` · `ops ▸ ide_action {action_id: "Vcs.CherryPick", hash: "d20afbe"}` · `ops ▸ ide_action {action_id: "OverrideMethods", path: "src/A.kt", line: 12}` | +| `actions` | Every action the IDE registers, plugins included: id, menu text, description, group, enabled in the project context; `query` on id or text. | To find the id for `ide_action`. `ops ▸ actions {query: "cherry"}` | +| `menu` | The main menu as the user sees it: top level, or the items of one menu by path. | "what is under Code ▸ Analyze". `ops ▸ menu {path: "Code/Analyze"}` | +| `appearance` *mutates* | View ▸ Appearance modes: presentation, distraction_free, full_screen, zen, compact, assistant; toggle or set with `on`; returns the state. | "put the IDE in presentation mode". `ops ▸ appearance {mode: "presentation", on: true}` | +| `ui` *mutates* | Show or hide the toolbar, navigation_bar, tool_window_bars, status_bar or main_menu; toggle or set with `on`. | "hide the status bar". `ops ▸ ui {part: "status_bar", on: false}` | +| `service_data` | The console/editor text inside a Services node's panel (last `tail` lines). | "show me the container log". `ops ▸ service_data {path: "Docker/Docker/Containers/web", tail: 100}` | +| `service_extract` *mutates* | Extract a node into its own tab. | `ops ▸ service_extract {path: "Docker/Docker/Containers/web"}` | +| `service_expand` *mutates* | Expand a node in the tree. | `ops ▸ service_expand {path: "Docker/Docker"}` | +| `service_events` | Service events since a sequence number: added, removed, changed, reset. | "did anything change". `ops ▸ service_events {since: 12}` | +| `deployment` *mutates* | Tools ▸ Deployment: upload, download, sync, compare, browse, configure through the plugin's actions. | `ops ▸ deployment {action: "upload", path: "src"}` | +| `ssh_session` *mutates* | Tools ▸ Start SSH Session. | `ops ▸ ssh_session {}` | +| `qodana` *mutates* | Qodana results as data with the tab shown; run/open through the plugin. | `ops ▸ qodana {action: "results"}` | +| `vulnerable_dependencies` | The Package Checker's findings, tab shown. | `ops ▸ vulnerable_dependencies {}` | +| `javadoc` *mutates* | Tools ▸ Generate JavaDoc dialog, scoped to a path. | `ops ▸ javadoc {path: "src/main/java"}` | +| `launcher` *mutates* | Create Command-line Launcher or Desktop Entry. | `ops ▸ launcher {action: "script"}` | +| `xml` *mutates* | Validate an XML file, generate a DTD or an XSD schema. | `ops ▸ xml {action: "validate", path: "pom.xml"}` | +| `markdown` *mutates* | Import a docx, export, table of contents, pandoc settings. | `ops ▸ markdown {action: "export", path: "README.md"}` | +| `groovy_console` *mutates* | Tools ▸ Groovy Console. | `ops ▸ groovy_console {}` | +| `kotlin_bytecode` *mutates* | Show Kotlin Bytecode for a file. | `ops ▸ kotlin_bytecode {path: "A.kt"}` | +| `kotlin_configure` *mutates* | Configure Kotlin in Project. | `ops ▸ kotlin_configure {}` | +| `python_console` *mutates* | The Python console. | `ops ▸ python_console {}` | +| `tabs` *mutates* | The editor's tab groups with tabs, selection and pins; or close, close_others, close_all, pin, split_right, split_down, unsplit, move_to_opposite on a tab. | "split the editor with A.kt on the right". `ops ▸ tabs {action: "split_right", path: "A.kt"}` | +| `layout` *mutates* | save_default, restore_default or hide_all for the tool window layout. | "hide everything". `ops ▸ layout {action: "hide_all"}` | +| `zoom` *mutates* | in, out or reset on the selected editor's font (`scope=editor`) or the whole IDE (`ide`). | "make it bigger". `ops ▸ zoom {action: "in", scope: "ide"}` | +| `editor_settings` *mutates* | line_numbers, whitespace, soft_wraps or gutter_icons in every editor; toggle or set with `on`. | "show whitespace". `ops ▸ editor_settings {setting: "whitespace", on: true}` | +| `tool_window` *mutates* | Open, close or list tool windows. | "show the Problems view". `ops ▸ tool_window {action: "open", id: "Problems View"}` | +| `settings_open` *mutates* | Settings at a page by display name. | "open the plugin settings". `ops ▸ settings_open {name: "Claude Code"}` | +| `plugins` | The IDE's plugins with id, version and enabled; `filter`. | Before relying on a plugin; to know the IDE build (`com.intellij`). `ops ▸ plugins {filter: "database"}` | +| `notify` *mutates* | A balloon in the IDE's notification area. | A finished long task or a decision needed while the chat is hidden. `ops ▸ notify {title: "Build", message: "green", kind: "info"}` | +| `db_connections` | The Database tool window's data sources: name, DBMS, redacted URL. | First db call. `ops ▸ db_connections {}` | +| `db_schema` | Tables and views of a source, or the columns of one table. | "what tables are there". `ops ▸ db_schema {connection: "local", table: "users"}` | +| `db_query` *mutates* | One or several SQL statements over the IDE's connection and credentials; rows or update count. Reads by intent; a write is the user's to approve. | "how many rows". `ops ▸ db_query {connection: "local", code: "select count(*) from users"}` | +| `http_files` | The project's `.http`/`.rest` request files. | Before `http_run`. `ops ▸ http_files {}` | +| `http_run` *mutates* | Runs every request of a file through the HTTP Client's run configuration; console tail; streamed. | "call the API from the .http file". `ops ▸ http_run {path: "api/users.http", wait: 45}` | +| `http_open` | Opens a request file in the editor. | "show me the requests". `ops ▸ http_open {path: "api/users.http"}` | +| `ssh_hosts` | The SSH hosts the IDE knows: host, port, user, authentication kind; never the secret. | "which hosts are configured". `ops ▸ ssh_hosts {}` | + +## Not tools, but capabilities that ride on them + +- **The Security Guard** judges every `run(tool, args)` inside the server; a refusal comes back as the + tool's error with the rule and the string that tripped it. The answer is to change that string, not the tool. +- **Cards**: every own call is a card in the chat, one per item in a list, with live lines for long tools, a + diff and Restore for edits, and a one-click link into the IDE (commit, log, tool window, terminal, run, + build, problems, diff, action). +- **The rules block**: the `` fragment rides the system prompt and a hook every turn and + names which tool replaces which native one; each rule is a switch in Settings ▸ Claude Code. + +## Board — phase 1, "Claude on JetBrains" + +The order of this phase. **Legend**: ☐ to do · ◐ in progress · ☑ done and committed. A row moves in the +same commit that lands its tools, and the tools enter the tables above in that commit. Every domain holds +at most four tools; a capability that needs more is a new domain. Two laws close the surface: every +registered action is reachable through `actions` + `ide_action(target)`, and every action Claude takes is +mirrored in the IDE without taking the user's focus. + +### P0 — foundations and the reported bugs + +| Capability | Where | Status | +|---|---|---| +| An own call made by a subagent gets its card, nested under the agent | `ToolEvents` | ☑ | +| Permission popup, approval rows and guard log name the tool (`code ▸ read_file ▸ path`), not `run` | `OwnTools.display` on every surface | ☑ | +| An agent still reasoning is never shown as completed | `AgentEnding` | ☑ | +| The rules block has no length limit; the test asserts coverage and prints the size | `IdeMcpPrompt`, `IdeRuleText`, `IdeRule`, `IdeMcpPromptTest`, `IdeRuleCoverageContractTest` | ☑ | +| 250 lines per file, imports not counted | `FileSizeContractTest` | ☑ | +| Reveal without focus on every existing tool; `FocusKeeper` returns the focus the platform steals; the terminal is never focused nor its tab switched | `FocusKeeper`, every domain, `FocusContractTest` | ☑ | +| Live mirror with one switch (Settings ▸ Claude Code, ON): reads in the preview tab, edits in a real tab, commits in the log, nodes in Services, problems in their tab, runs in their window | `Reveal`, `IdeMcpState.mirror`, Settings section | ☑ (build, run, tests and debug are shown by the platform itself, focus-free by default) | +| `vcs_open(log, range)` off the deprecated `openLogTab` | `GitLogNavigator` | ☑ | +| `run_tests(path)` prefers the framework producer over Gradle | `TestTools` | ☑ (when the IDE offers one: with Gradle's *Run tests using: Gradle*, the only producer is Gradle's, and a project with several `Test` tasks may land in the wrong one — run the named configuration then) | + +### P1 — every action, with its target + +| Capability | Tools (domain) | Status | +|---|---|---| +| The action catalogue of the user's IDE, enabled-in-context; the main menu tree | `actions`, `menu` (`actions`) | ☑ | +| Appearance and UI toggles | `appearance`, `ui` (`actions`) | ☑ | +| `ide_action` with a target: file/position, commit, Services node | `ide_action` +`path`/`line`/`column`/`hash`/`node` (`ide`), `TargetContext` | ☑ | +| Code menu editing actions at a position | `editor_action` (`editor`) | ☑ | +| Undo, redo, replace in path, line operations | `undo`, `redo`, `search_replace`, `line_ops` (`edit_ops`) | ☑ | +| Recent files/locations/changes, back/forward, clipboard compare, schemes | `recent`, `navigate_history`, `compare_clipboard`, `scheme` (`recent`) | ☑ (locations stay out: `IdeDocumentHistory.getBackPlaces` is Internal) | +| Editor tabs, layouts, zoom, editor settings | `tabs`, `layout`, `zoom`, `editor_settings` (`window`) | ☑ | +| Every Git-menu dialog by name | `vcs_action` table extended (`forge`) | ☑ | + +### P2 — Git as the log and the branches panel do it + +| Capability | Tools (domain) | Status | +|---|---|---| +| The commit context menu on a hash; branch operations through `GitBrancher`; worktrees; remotes | `commit_action`, `branch_op`, `worktrees`, `remotes` (`log_ops`) | ☑ | +| Stash, shelve, patches, rollback | `stash`, `shelve`, `patch`, `rollback` (`changes`) | ☑ (patch apply is the IDE's dialog: `ApplyPatchUtil` is Internal) | +| Blame, file history, local history, a file at a ref | `blame`, `file_history`, `local_history`, `file_at` (`history`) | ☑ (revert uses `Label.revert`, marked Obsolete with no replacement) | + +### P3 — pull and merge requests as data + +| Capability | Tools (domain) | Status | +|---|---|---| +| GitHub pull requests listed and opened through the IDE's GitHub plugin | `pull_requests`, `pull_request` (`forge`), `GitHubGateway` | ☑ (data through the IDE's account; `open` selects the request in the IDE's Pull Requests view by selecting its row and firing the view's own action, the browser only when the view does not list it) | +| The release driven from the IDE: create, comment, watch and merge pull requests; verify tags, runs, the Release and the Marketplace | `pr_create`, `pr_comment`, `pr_checks`, `pr_merge` (`pull_request_ops`); `tags`, `workflow_runs`, `release`, `marketplace` (`release`); `MarketplaceGateway` | ☑ (GitHub through the IDE's account; the Marketplace through its public API) | +| GitLab merge requests: actions and view; data if a public path exists | `vcs_action`, `GitLabGateway` | ☑ actions only: `GitLabAccountManager` and `GitLabProjectViewModel` are Internal, so no data path exists; `vcs_action(merge_requests, create_merge_request, clone_gitlab, create_snippet, gitlab_accounts)` covers the submenu | + +### P4 — analysis, views, files, refactorings + +| Capability | Tools (domain) | Status | +|---|---|---| +| Inspect a scope, code cleanup, dependency analysis, data flow | `inspect_scope`, `cleanup`, `file_dependencies`, `dataflow` (`analyze`) | ☑ (backward dependencies is the IDE's window: `BackwardDependenciesBuilder` is Internal) | +| Stack traces, duplicates, nullity, related symbols | `stack_trace`, `duplicates`, `infer_nullity`, `related` (`analysis`) | ☑ (`AnalyzeStacktraceUtil` is Internal: the frames are parsed here and the dialog opens with the text on the clipboard) | +| Diffs, compare, mark directory as, open in | `diff_show`, `compare`, `mark_as`, `open_in` (`views`) | ☑ | +| Copy path, file type, ignore files, delete | `copy_path`, `file_type`, `ignore`, `delete_file` (`files`) | ☑ | +| The Refactor menu beyond rename/move/safe-delete | `introduce`, `extract`, `inline`, `members` (`refactor_ops`) | ☑ | + +### P5 — roadmap horizon 2 + +| Capability | Tools (domain) | Status | +|---|---|---| +| Live and file templates (R1) | `templates`, `template_apply`, `file_templates`, `file_from_template` (`templates`) | ☑ | +| Injected languages, quick documentation (R2; `docs` reveals the popup, `DocumentationTarget` is override-only) | `injections`, `inject_at`, `docs` (`language`) | ☑ (`inject_at` goes through IntelliLang's `TemporaryPlacesRegistry` by reflection in that plugin's loader) | +| Bookmarks and the project view (R3; Structure follows the caret) | `bookmarks`, `bookmark_add`, `bookmark_remove`, `project_view` (`bookmarks`) | ☑ | +| The workspace model, read-only (R4) | `workspace` (`workspace`) | ☑ | +| PSI as a tree (R5) | `psi_tree`, `psi_at`, `psi_replace`, `psi_insert` (`psi`) | ☑ | +| The IDE's indexes (R6) | `index_keys`, `index_query`, `stub_query` (`index`) | ☑ | +| UAST (R7) | `uast_tree`, `uast_at` (`uast`) | ☑ (UAST ships inside the Java plugin: optional dependency `claude-java.xml`, the domain is absent on IDEs without Java) | + +### P6 — roadmap horizon 3, presence + +| Capability | Tools (domain) | Status | +|---|---|---| +| Editor markup: highlights, gutter icons, inline hints (S1) | `mark_add`, `mark_remove`, `marks`, `hint_add` (`markup`) | ☑ (document markup model + inlays; ids live with the server) | +| Banners, status bar, scratches (S2, S3) | `banner_show`, `banner_clear`, `status`, `scratch_create` (`presence`) | ☑ (`BannerProvider` editor notification + `BannerRegistry` service) | +| One edit, one undo entry, one history label (S4) | `edit` domain | ☑ (every `replace_text`/`insert_text`/`write_file` is one write command and puts a Local History label named after it) | + +### P7 — Services in depth, the closed plugins + +| Capability | Tools (domain) | Status | +|---|---|---| +| A node's data, extract, expand, events | `service_data`, `service_extract`, `service_expand`, `service_events` (`service_view`) | ☑ (`service_data` reads the editors inside the node's content component; Docker/Kubernetes stay closed, so no deeper data path) | +| Deployment, SSH sessions, Qodana, vulnerable dependencies (S5) | `deployment`, `ssh_session`, `qodana`, `vulnerable_dependencies` (`remote`) | ☑ actions discovered by id fragment on the user's IDE (closed plugins have no source to pin); Qodana results and vulnerable dependencies come from the Problems collector | + +### P8 — run, tools, consoles, the client + +| Capability | Tools (domain) | Status | +|---|---|---| +| Build a module or a file; run with coverage; more step kinds; watches | `build` +`kind` (module, file), `run_configuration` +`executor` (run, debug, coverage, profile), `step` +`force_into`, `smart_into`, `mute` (`run`, `debug`) | ☑ except watches: the watches model (`XDebugSessionData`, the watches manager) is Internal at 262, so `values(expression)` is the evaluation path | +| Edit configurations, attach, profile, coverage | `edit_configuration`, `attach`, `coverage` (`run_ops`); profile = `run_configuration(executor=profile)` | ☑ | +| Terminal tabs | `terminal_tabs` (`terminal`) | ☑ | +| Javadoc, launchers, XML, Markdown | `javadoc`, `launcher`, `xml`, `markdown` (`tools_menu`) | ☑ (dialogs the user finishes; javadoc headless needs the Java plugin's `JavadocGeneratorRunProfile`, which is not public) | +| Groovy console, Kotlin bytecode and configuration, Python console | `groovy_console`, `kotlin_bytecode`, `kotlin_configure`, `python_console` (`consoles`) | ☑ by action, discovered where the Kotlin and Python ids are plugin-defined; the bytecode text stays in the IDE's panel (no public document accessor) | +| Any MCP client drives the IDE (Q10); split mode (S6) | `McpClient` (test source) + `McpClientHeadlessTest`, [`MCP_CLIENT.md`](MCP_CLIENT.md); split mode = the chat page failing to arrive, then the servers start without a chat and the notification carries the MCP configuration | ☑ | + +**Out, on record**: `completion` (its parameters have no public constructor), LSP (commercial IDEs; runtime +detection if ever needed), `sdk_set` (a global change that goes through the dialog), `structure_select` +(no public accessor; the Structure window follows the caret), and the third-party server tools discarded in +the roadmap's coverage matrix — change signature as data, super methods, structural search and replace, +module and project lifecycle, plugin install, IDE restart. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 109e53ed..82746573 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -260,9 +260,30 @@ support itself is tracked under ## Logs -The IDE writes a single rolling log file. The plugin tags its entries with -`claudejb`, `ClaudeSession`, `ClaudeProcess`, `PermissionBroker`, -`ProtocolParser`, or `DiffPresenter`. +The quickest route to a report is the **Log** view in the chat's view row, +next to Guard and Vulnerabilities: the plugin's own entries, filtered by +level, with a *Copy* button that puts a report-ready text on the clipboard +(plugin, IDE, OS and binary versions in the header) and a *Debug* switch that +turns the trace on for the current IDE session. Credentials, `sk-ant-…` keys +and your home directory are redacted before a line is stored, and prompt text +is never logged. Paths outside the project are kept — which binary ran, which +file the guard refused — because a report needs them, so read the copied +text once before publishing it. + +The same lines go to the IDE's single rolling log file, `idea.log`. Every +entry is written under the category of the class that wrote it, +`#dev.lain.claudejb..` — `controller.process.ClaudeProcess` +for the binary's lifecycle and stderr, `controller.session.guard.SessionGuard` +for the guard's verdicts, `view.jcef.JcefHost` for the chat page's own +console — so +`claudejb` matches all of them. Three levels, one meaning each: `WARN` is +something that went wrong, `INFO` a lifecycle step, `DEBUG` the trace, which +is off unless switched on. + +To trace before the plugin loads, or across restarts, use **Help ▸ Diagnostic +Tools ▸ Debug Log Settings** and add `#dev.lain.claudejb` (a full class +category narrows it). A sandbox IDE started with `./gradlew runIde` traces +from the start. Log file locations: diff --git a/docs/UI_TESTING.md b/docs/UI_TESTING.md index fe6589b1..50c09f28 100644 --- a/docs/UI_TESTING.md +++ b/docs/UI_TESTING.md @@ -135,7 +135,7 @@ Notes: | `ChatSmokeUiTest` | The tool window gives a **live web view**, not the "needs JCEF" Swing fallback: `#conversation` and the composer textarea exist, the bar draws ≥1 chat and marks exactly one with `aria-current`. This is the cheap guard on the failure the **`253.29346.138`** floor exists for (no `com.intellij.modules.jcef` ⇒ `NoClassDefFoundError` in `JcefHost.`). | | `ComposerUiTest` | Keystrokes from the **OS keyboard** reach the page (the focus bug that made a new tab unusable for a whole release), and Enter sends while Shift+Enter keeps a multi-line draft. | | `NewChatTabUiTest` | The whole 5.5.0 tab round trip: a Swing action builds a panel, `ChatTabsPanel` adds a `CardLayout` card and pushes the chat list into **every** open page, a pill click comes back as a `selectChat` bridge message, the strip swaps the card, both pages repaint with the selection moved. | -| `TabBarScrollUiTest` | The chat row scrolls by wheel (Chromium will not move a horizontal scroller with a vertical wheel — `app-tabs.js` translates the gesture) and by grabbing it. **Overflow is a layout fact**, so jsdom cannot answer this: there `scrollWidth`/`clientWidth`/`scrollLeft` are all 0. | +| `TabBarScrollUiTest` | The chat row scrolls by wheel (Chromium will not move a horizontal scroller with a vertical wheel — `tabs/scroll.ts` translates the gesture) and by grabbing it. **Overflow is a layout fact**, so jsdom cannot answer this: there `scrollWidth`/`clientWidth`/`scrollLeft` are all 0. | | `BootScreenUiTest` | The waiting screens (`#boot`, `#auth-card`) live inside `#work`, below `#tabsbar` — asserted twice, by geometry *and* by hit-testing the centre of a chat pill, because "does not cover" has two failure modes. | | `SessionDashboardUiTest` | The gear's "Session Info" opens the **JCEF dashboard** (not the deleted Swing dialogs), the transcript hides while it is up, "Chat" gives it back — and the view buttons are children of the tab bar that intersect no pill (**WCAG 2.2 SC 2.4.11, Focus Not Obscured**). | | `AttachmentChipUiTest` | Host → page → host: "Add Current File" pins a chip in the composer and the chip's ✕ comes back as a `removeAttachment` bridge message the host honours. Neither half is testable alone (jsdom has no host, headless has no browser). | diff --git a/docs/adr/0003-i18n-deferred.md b/docs/adr/0003-i18n-deferred.md index d07ba22d..7a6af751 100644 --- a/docs/adr/0003-i18n-deferred.md +++ b/docs/adr/0003-i18n-deferred.md @@ -49,7 +49,7 @@ one of them is a legal obligation in the plugin's distribution market: - **`lang` on the document is declared** (`shell.html`). Without it a screen reader pronounces the interface with the wrong phonetics — WCAG 3.1.1 Language of Page, Level **A**, and among the six most common failures - on the web. It is pinned by a test in `src/test/frontend/accessibility.test.js`. + on the web. It is pinned by a test in `src/test/frontend/contracts/accessibility.test.js`. - **Layout must tolerate text it did not author.** Model output, file paths and tool names are arbitrary length and arbitrary script; the transcript and composer wrap and scroll rather than assuming English-width content. That is a robustness property, and it happens to be most of what makes a later translation diff --git a/docs/adr/0004-target-size-exception.md b/docs/adr/0004-target-size-exception.md index fb376302..7b0362a8 100644 --- a/docs/adr/0004-target-size-exception.md +++ b/docs/adr/0004-target-size-exception.md @@ -40,8 +40,8 @@ the pill has room — it is closed on the chats' row — and never let a second Three things make that a decision rather than a shrug: 1. **It is registered where it gets audited.** There is no accessibility statement or conformance report in - this repository, so the register is `src/test/frontend/accessibility.test.js`, which is the only - accessibility record that runs. The declaration sits beside the rule in `css/tabs.css` as well, but a + this repository, so the register is `src/test/frontend/contracts/accessibility.test.js`, which is the only + accessibility record that runs. The declaration sits beside the rule in `css/views/tabs/bar.css` as well, but a comment beside the cause is not a register: nobody reads it again. 2. **It is asserted in BOTH directions.** A declared exception fails in two ways and only one of them looks like a failure. Someone shrinks the control further — caught, because the size is pinned at 20. Or the diff --git a/package-lock.json b/package-lock.json index 8f26e531..268a14e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,26 +19,29 @@ "jsdom": "^29.1.1", "prettier": "^3.9.6", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.250.tgz", - "integrity": "sha512-qT/1cBZs0+xPsQfqVOnwIk6pNW8XBkTpQS5RAXKHYb2XYCKqYc0UmOaeiYU2WeI6HEZKORa5iCaAZyKWGluShw==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.270.tgz", + "integrity": "sha512-sSfcm5Nhb+WHeBCxqeHRRQMUKPmFTL+zgv5xcRUVaFMLttfNEbn3IZJE+fLJJmy4h3J8zdc5sXdSa1JxyB8ppQ==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.250", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.250", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.250", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.250", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.250", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.250", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.250", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.250" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.270", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.270" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -47,9 +50,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.250.tgz", - "integrity": "sha512-tcekW4gR2UH0Q3COBaNPQIdud2lKEbs0HfG2yNKC18hXFPpgbuLCdjq0ndS1lcvC1q8ncPW3oQPUutQt3StICQ==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.270.tgz", + "integrity": "sha512-nk7BP+i559rheYz9DIwAfevd4DulQXP0mXPP+MeO2fGuIGFmzhE/c0JRm9YswXv5HdaYJvSzjGIB7dVA01NehA==", "cpu": [ "arm64" ], @@ -61,9 +64,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.250.tgz", - "integrity": "sha512-8Yxmmi76oVEIam+oRgxcL2RtqEkKX9Gp4rh500HmMltjX3Tk/ryjCoJEHoaUdU/LU6vWvfQU5W+dB/SJCQQb2A==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.270.tgz", + "integrity": "sha512-89Uql8Oalm52ojdZZeNLU24LKrU+WG9QR7d6YP9ly4aY0YvQUJDaTosbDkijQngUETbPBFdKuVp6fNM8x0Zt3Q==", "cpu": [ "x64" ], @@ -75,9 +78,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.250.tgz", - "integrity": "sha512-exABrKfqDv9rp6UCqOTjd4rH1lH6YZt0IuGFsAEHg52N08i5+4/uRtUlQzMf0VpN/dgqDoxt5jpOB9hH863r9w==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.270.tgz", + "integrity": "sha512-iHPYqwetyeO4tZPzXyKZz0hUh2fLpwu/+biGTxxynikG3XrYknovZ/znGDA3TxjSFurqHf5IIDA+SOjh9OPs0A==", "cpu": [ "arm64" ], @@ -92,9 +95,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.250.tgz", - "integrity": "sha512-wpe2UmFrC2wyR+DbG6zIiRmqiFvK8nJjdi++nf3jNn0ZSHvW2BqPrh+JnPJOcmkAYR58IdMIj2jagbQAGtN/rA==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.270.tgz", + "integrity": "sha512-2BlLk2MAohWG2h43RKcjCA4ooMfBxzKf4yyYfOVv1DtYr8zPU876MHCT1VXB2BaemjKA0pdYJJBzH6pncwx6MQ==", "cpu": [ "arm64" ], @@ -109,9 +112,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.250.tgz", - "integrity": "sha512-skqoeRDEjobqtIXmt9GNWJUyjcSvKHEf5QVf4PF9v37CtkJWEt7Hs64yFkahFD1mY34FAUyy/Ij0NrbeGKHPBg==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.270.tgz", + "integrity": "sha512-ADaqz2viyAd0GUxdupYLX/K0YJb46xckNpEeWxyLK/9+26b/R5stbaGDyL29fIzyq1ymnNUOaCgEWEemgX0kEA==", "cpu": [ "x64" ], @@ -126,9 +129,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.250.tgz", - "integrity": "sha512-ZMjXn2r00SlS17ozg6RfK+OF7kPo3COrCUTPrKnXZk7AYZ+hE0xqunrkt66LwYNFc+KlvDTAZZHSiZVblTT7rA==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.270.tgz", + "integrity": "sha512-mzH3lnbzrbDGrTf75jLEmkbvkKRLLgmjLaWvf3QuUsgcw+aU69aOY0mW33oOrsuq5zg330uI3B4e68f4LbxNIA==", "cpu": [ "x64" ], @@ -143,9 +146,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.250.tgz", - "integrity": "sha512-PC3pzcV8/bT9+PAM9k4fGMU64YY0F9NV6f/G4vbLMqz4qZAWqQp9AXMea+F5KULeJG2ygaAhmhcDyOzW77HhEw==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.270.tgz", + "integrity": "sha512-Pexeu26cLZByhs6VlrawNYAEu+QE2YptvwNkXsmpLRm7Q/C/M0N/BZBmuUcikXrQQ9cuVzSjuuTTpZt64mvtLA==", "cpu": [ "arm64" ], @@ -157,9 +160,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.250", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.250.tgz", - "integrity": "sha512-PjJRbJwDHccSUWls5gTiuXMgERit1WrrMQzzRqhhBHGzrlQueHVodrpg7HaN5gtirADJzfINcc7azq8j3qcEYw==", + "version": "0.3.270", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.270.tgz", + "integrity": "sha512-9UyfFcUYsyUZqSe/xX9nIJ1Og6i8FxhlQ35BDi79Ik5He87XFxEIGUvJuMcl7Mq2e3panyhekELFE+9H79xKdw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 8687aad9..2e935f3a 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,17 @@ "version": "0.0.0", "private": true, "description": "Build-time tooling for the Claude Code Native IntelliJ plugin: frontend tests and protocol-drift reference. Nothing here is published or shipped.", + "engines": { + "node": ">=22" + }, "scripts": { - "test": "vitest run", + "build": "tsc -p tsconfig.json", + "test": "npm run build && vitest run", "test:watch": "vitest", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier --write \"src/main/resources/jcef/**/*.{js,css,html}\" \"src/test/frontend/**/*.js\"", - "format:check": "prettier --check \"src/main/resources/jcef/**/*.{js,css,html}\" \"src/test/frontend/**/*.js\"" + "format": "prettier --write \"src/main/resources/jcef/**/*.{js,css,html}\" \"src/main/ts/**/*.ts\" \"src/test/frontend/**/*.js\"", + "format:check": "prettier --check \"src/main/resources/jcef/**/*.{js,css,html}\" \"src/main/ts/**/*.ts\" \"src/test/frontend/**/*.js\"" }, "license": "GPL-3.0-only", "type": "commonjs", diff --git a/scripts/drift-baseline.properties b/scripts/drift-baseline.properties index abc53e17..7095d9db 100644 --- a/scripts/drift-baseline.properties +++ b/scripts/drift-baseline.properties @@ -5,5 +5,5 @@ # These are the last *reconciled* versions: the detector updates the live tools to latest (npm update + # claude --update), then reports any protocol surface the plugin doesn't model yet. After reconciling a # report (and `npm update` having bumped the vendored SDK), bump these to match. -sdk=0.3.250 -binary=2.1.250 +sdk=0.3.270 +binary=2.1.270 diff --git a/scripts/probe-binary.sh b/scripts/probe-binary.sh index fe70e60d..ce1de977 100755 --- a/scripts/probe-binary.sh +++ b/scripts/probe-binary.sh @@ -33,7 +33,7 @@ TS="$(date +%s)" OUT="/tmp/probe-${TS}.jsonl" # Hardcoded inline expected types — keep in sync with -# src/main/kotlin/dev/lain/claudejb/protocol/ProtocolParser.kt +# src/main/kotlin/dev/lain/claudejb/model/protocol/parse/ProtocolParser.kt EXPECTED_TYPES=(system assistant user result stream_event control_request control_response rate_limit_event) is_expected() { diff --git a/src/main/java/dev/lain/claudejb/mcp/Frames.java b/src/main/java/dev/lain/claudejb/mcp/Frames.java new file mode 100644 index 00000000..4d33927f --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/Frames.java @@ -0,0 +1,42 @@ +package dev.lain.claudejb.mcp; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public final class Frames { + + public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; + + private Frames() { + } + + public static void write(OutputStream out, String text) throws IOException { + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + out.write((payload.length + "\n").getBytes(StandardCharsets.US_ASCII)); + out.write(payload); + out.flush(); + } + + public static String read(InputStream in) throws IOException { + ByteArrayOutputStream header = new ByteArrayOutputStream(); + while (true) { + int b = in.read(); + if (b < 0) return header.size() == 0 ? null : fail("stream ended inside a frame header"); + if (b == '\n') break; + if (b < '0' || b > '9' || header.size() > 9) return fail("malformed frame header"); + header.write(b); + } + int length = Integer.parseInt(header.toString(StandardCharsets.US_ASCII)); + if (length > MAX_FRAME_BYTES) return fail("frame of " + length + " bytes exceeds the ceiling"); + byte[] payload = in.readNBytes(length); + if (payload.length != length) return fail("stream ended inside a frame body"); + return new String(payload, StandardCharsets.UTF_8); + } + + private static String fail(String message) throws IOException { + throw new IOException(message); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/Json.java b/src/main/java/dev/lain/claudejb/mcp/Json.java new file mode 100644 index 00000000..8940c302 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/Json.java @@ -0,0 +1,203 @@ +package dev.lain.claudejb.mcp; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class Json { + + private final String text; + private int at; + + private Json(String text) { + this.text = text; + } + + public static Object parse(String text) { + Json json = new Json(text); + Object value = json.value(); + json.skipWhitespace(); + if (json.at != text.length()) throw json.error("trailing content"); + return value; + } + + public static String write(Object value) { + StringBuilder out = new StringBuilder(); + write(value, out); + return out.toString(); + } + + private static void write(Object value, StringBuilder out) { + if (value == null) out.append("null"); + else if (value instanceof String) writeString((String) value, out); + else if (value instanceof Boolean || value instanceof Number) out.append(value instanceof BigDecimal ? ((BigDecimal) value).toPlainString() : value.toString()); + else if (value instanceof List) writeArray((List) value, out); + else if (value instanceof Map) writeObject((Map) value, out); + else throw new IllegalArgumentException("not a JSON value: " + value.getClass().getName()); + } + + private static void writeArray(List values, StringBuilder out) { + out.append('['); + boolean first = true; + for (Object value : values) { + if (!first) out.append(','); + first = false; + write(value, out); + } + out.append(']'); + } + + private static void writeObject(Map values, StringBuilder out) { + out.append('{'); + boolean first = true; + for (Map.Entry entry : values.entrySet()) { + if (!first) out.append(','); + first = false; + writeString(String.valueOf(entry.getKey()), out); + out.append(':'); + write(entry.getValue(), out); + } + out.append('}'); + } + + private static void writeString(String value, StringBuilder out) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') out.append("\\\""); + else if (c == '\\') out.append("\\\\"); + else if (c == '\n') out.append("\\n"); + else if (c == '\r') out.append("\\r"); + else if (c == '\t') out.append("\\t"); + else if (c < ' ') out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + out.append('"'); + } + + private Object value() { + skipWhitespace(); + if (at >= text.length()) throw error("unexpected end of input"); + char c = text.charAt(at); + if (c == '{') return object(); + if (c == '[') return array(); + if (c == '"') return string(); + if (c == '-' || (c >= '0' && c <= '9')) return number(); + if (text.startsWith("true", at)) return literal("true", Boolean.TRUE); + if (text.startsWith("false", at)) return literal("false", Boolean.FALSE); + if (text.startsWith("null", at)) return literal("null", null); + throw error("unexpected character '" + c + "'"); + } + + private Object literal(String token, Object value) { + at += token.length(); + return value; + } + + private Map object() { + Map out = new LinkedHashMap<>(); + at++; + skipWhitespace(); + if (peek() == '}') { + at++; + return out; + } + while (true) { + skipWhitespace(); + if (peek() != '"') throw error("expected a string key"); + String key = string(); + skipWhitespace(); + expect(':'); + out.put(key, value()); + skipWhitespace(); + if (peek() == ',') { + at++; + continue; + } + expect('}'); + return out; + } + } + + private List array() { + List out = new ArrayList<>(); + at++; + skipWhitespace(); + if (peek() == ']') { + at++; + return out; + } + while (true) { + out.add(value()); + skipWhitespace(); + if (peek() == ',') { + at++; + continue; + } + expect(']'); + return out; + } + } + + private String string() { + StringBuilder out = new StringBuilder(); + at++; + while (at < text.length()) { + char c = text.charAt(at++); + if (c == '"') return out.toString(); + if (c != '\\') { + out.append(c); + continue; + } + if (at >= text.length()) break; + char e = text.charAt(at++); + switch (e) { + case '"': out.append('"'); break; + case '\\': out.append('\\'); break; + case '/': out.append('/'); break; + case 'b': out.append('\b'); break; + case 'f': out.append('\f'); break; + case 'n': out.append('\n'); break; + case 'r': out.append('\r'); break; + case 't': out.append('\t'); break; + case 'u': + if (at + 4 > text.length()) throw error("truncated unicode escape"); + out.append((char) Integer.parseInt(text.substring(at, at + 4), 16)); + at += 4; + break; + default: throw error("invalid escape '\\" + e + "'"); + } + } + throw error("unterminated string"); + } + + private BigDecimal number() { + int start = at; + if (peek() == '-') at++; + while (at < text.length() && "0123456789.eE+-".indexOf(text.charAt(at)) >= 0) at++; + try { + return new BigDecimal(text.substring(start, at)); + } catch (NumberFormatException e) { + throw error("malformed number"); + } + } + + private char peek() { + return at < text.length() ? text.charAt(at) : '\0'; + } + + private void expect(char c) { + if (peek() != c) throw error("expected '" + c + "'"); + at++; + } + + private void skipWhitespace() { + while (at < text.length() && " \t\r\n".indexOf(text.charAt(at)) >= 0) at++; + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException("JSON " + message + " at offset " + at); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java b/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java new file mode 100644 index 00000000..96e19714 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/StdioBridge.java @@ -0,0 +1,141 @@ +package dev.lain.claudejb.mcp; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintStream; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.Channels; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class StdioBridge { + + public static final String TOKEN_KEY = "dev.lain.claudejb/token"; + public static final String TOKEN_FILE = "token"; + + private final Path socket; + private final Path tokenFile; + private final PrintStream stdout; + + StdioBridge(Path socket, PrintStream stdout) { + this.socket = socket; + this.tokenFile = socket.resolveSibling(TOKEN_FILE); + this.stdout = stdout; + } + + public static void main(String[] args) throws IOException { + if (args.length != 1) { + System.err.println("usage: StdioBridge "); + System.exit(2); + } + PrintStream stdout = new PrintStream(System.out, false, StandardCharsets.UTF_8); + new StdioBridge(Path.of(args[0]), stdout).pump(System.in); + } + + void pump(InputStream stdin) throws IOException { + try (SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX)) { + channel.connect(UnixDomainSocketAddress.of(socket)); + OutputStream toPlugin = Channels.newOutputStream(channel); + InputStream fromPlugin = Channels.newInputStream(channel); + Thread replies = new Thread(() -> relayReplies(fromPlugin), "mcp-replies"); + replies.setDaemon(true); + replies.start(); + relayRequests(stdin, toPlugin); + channel.shutdownOutput(); + waitFor(replies); + } + } + + private void relayRequests(InputStream stdin, OutputStream toPlugin) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + Object message; + try { + message = Json.parse(line); + } catch (IllegalArgumentException e) { + emit(Json.write(parseError())); + continue; + } + synchronized (toPlugin) { + Frames.write(toPlugin, Toon.encode(withToken(message))); + } + } + } + + private void relayReplies(InputStream fromPlugin) { + try { + String frame; + while ((frame = Frames.read(fromPlugin)) != null) { + emit(Json.write(Toon.decode(frame))); + } + } catch (IOException | ToonException e) { + System.err.println("StdioBridge: " + e.getMessage()); + } + } + + private Object withToken(Object message) { + if (!(message instanceof Map)) return message; + @SuppressWarnings("unchecked") + Map request = (Map) message; + if (!request.containsKey("method")) return request; + Object params = request.get("params"); + Map paramsMap = params instanceof Map ? cast(params) : new LinkedHashMap<>(); + Object meta = paramsMap.get("_meta"); + Map metaMap = meta instanceof Map ? cast(meta) : new LinkedHashMap<>(); + String token = readToken(); + if (token != null) metaMap.put(TOKEN_KEY, token); + paramsMap.put("_meta", metaMap); + request.put("params", paramsMap); + return request; + } + + private String readToken() { + try { + return Files.readString(tokenFile, StandardCharsets.UTF_8).trim(); + } catch (IOException e) { + return null; + } + } + + private void emit(String line) { + synchronized (stdout) { + stdout.print(line); + stdout.print('\n'); + stdout.flush(); + } + } + + private static Map parseError() { + Map error = new LinkedHashMap<>(); + error.put("code", -32700); + error.put("message", "Parse error"); + Map response = new LinkedHashMap<>(); + response.put("jsonrpc", "2.0"); + response.put("id", null); + response.put("error", error); + return response; + } + + private static void waitFor(Thread thread) { + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @SuppressWarnings("unchecked") + private static Map cast(Object value) { + return (Map) value; + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/Toon.java b/src/main/java/dev/lain/claudejb/mcp/Toon.java new file mode 100644 index 00000000..d9caeab7 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/Toon.java @@ -0,0 +1,37 @@ +package dev.lain.claudejb.mcp; + +public final class Toon { + + public static final class Options { + public final int indentSize; + public final char delimiter; + public final boolean strict; + + public Options(int indentSize, char delimiter, boolean strict) { + this.indentSize = indentSize; + this.delimiter = delimiter; + this.strict = strict; + } + + public static final Options DEFAULT = new Options(2, ',', true); + } + + private Toon() { + } + + public static String encode(Object value) { + return encode(value, Options.DEFAULT); + } + + public static String encode(Object value, Options options) { + return new ToonEncoder(options).encode(value); + } + + public static Object decode(String text) { + return decode(text, Options.DEFAULT); + } + + public static Object decode(String text, Options options) { + return new ToonDecoder(text, options).decode(); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonCursor.java b/src/main/java/dev/lain/claudejb/mcp/ToonCursor.java new file mode 100644 index 00000000..439d545e --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonCursor.java @@ -0,0 +1,47 @@ +package dev.lain.claudejb.mcp; + +import dev.lain.claudejb.mcp.ToonLines.Line; +import java.util.ArrayList; +import java.util.List; + +final class ToonCursor { + + private final List lines; + private final boolean strict; + private final List spans = new ArrayList<>(); + private int index; + + ToonCursor(List lines, boolean strict) { + this.lines = lines; + this.strict = strict; + } + + Line peek() { + return index < lines.size() ? lines.get(index) : null; + } + + Line next() { + return lines.get(index++); + } + + void skipBlanks() { + boolean skipped = false; + while (peek() != null && peek().blank()) { + index++; + skipped = true; + } + Line next = peek(); + if (next == null || spans.isEmpty()) return; + if (skipped && strict && next.depth > spans.get(0)) { + throw new ToonException("blank line inside the scope that continues at line " + next.number); + } + } + + void enterSpan(int depth) { + spans.add(depth); + } + + void leaveSpan() { + spans.remove(spans.size() - 1); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonDecoder.java b/src/main/java/dev/lain/claudejb/mcp/ToonDecoder.java new file mode 100644 index 00000000..395d112a --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonDecoder.java @@ -0,0 +1,155 @@ +package dev.lain.claudejb.mcp; + +import dev.lain.claudejb.mcp.ToonLines.Line; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class ToonDecoder { + + private static final String EMPTY_ARRAY = "[]"; + private static final Object NO_ROOT_FORM = new Object(); + + private final boolean strict; + private final ToonCursor cursor; + private final ToonTables tables; + + ToonDecoder(String text, Toon.Options options) { + this.strict = options.strict; + this.cursor = new ToonCursor(ToonLines.of(text, options), strict); + this.tables = new ToonTables(cursor, strict); + } + + Object decode() { + cursor.skipBlanks(); + Line first = cursor.peek(); + if (first == null) return new LinkedHashMap(); + Object root = rootForm(first); + return root != NO_ROOT_FORM ? root : objectBody(0, new LinkedHashMap<>()); + } + + private Object rootForm(Line first) { + if (first.depth != 0) return NO_ROOT_FORM; + ToonHeader header = first.content.equals(EMPTY_ARRAY) ? null : ToonHeader.parse(first.content, strict); + Object value; + if (first.content.equals(EMPTY_ARRAY)) { + cursor.next(); + value = new ArrayList<>(); + } else if (header != null && header.key == null) { + cursor.next(); + value = headerValue(header, 0); + } else if (header == null && ToonScan.indexOfUnquoted(first.content, ':') < 0) { + return rootPrimitive(first); + } else { + return NO_ROOT_FORM; + } + cursor.skipBlanks(); + Line trailing = cursor.peek(); + if (trailing != null && strict) throw new ToonException("content after the root form at line " + trailing.number); + return value; + } + + private Object rootPrimitive(Line line) { + cursor.next(); + cursor.skipBlanks(); + Line trailing = cursor.peek(); + if (trailing != null) { + throw new ToonException("a scalar line is valid only alone at the root; line " + trailing.number + " follows one"); + } + return ToonText.value(line.content); + } + + private Map objectBody(int depth, Map into) { + while (true) { + cursor.skipBlanks(); + Line line = cursor.peek(); + if (line == null || line.depth < depth) break; + if (line.depth > depth) { + orphan(line); + continue; + } + cursor.next(); + field(line.content, depth, into); + } + return into; + } + + private void orphan(Line line) { + if (strict || ToonScan.indexOfUnquoted(line.content, ':') < 0) { + throw new ToonException("line " + line.number + " belongs to no scope"); + } + cursor.next(); + } + + private void field(String content, int depth, Map into) { + ToonHeader header = ToonHeader.parse(content, strict); + if (header != null) { + if (header.key == null) throw new ToonException("keyless header in object position: " + content); + ToonTables.put(into, header.key, headerValue(header, depth), strict); + return; + } + int colon = ToonScan.indexOfUnquoted(content, ':'); + if (colon < 0) throw new ToonException("missing colon in: " + content); + String key = ToonText.keyOf(ToonScan.trimSpaces(content.substring(0, colon))); + String rest = ToonScan.trimSpaces(content.substring(colon + 1)); + Object value; + if (rest.isEmpty()) value = objectBody(depth + 1, new LinkedHashMap<>()); + else if (rest.equals(EMPTY_ARRAY)) value = new ArrayList<>(); + else value = ToonText.value(rest); + ToonTables.put(into, key, value, strict); + } + + private Object headerValue(ToonHeader header, int depth) { + if (header.keyed) return tables.keyed(header, depth); + if (header.fields != null) return tables.tabular(header, depth); + if (!header.inline.isEmpty()) return inlineArray(header); + return listItems(header, depth); + } + + private List inlineArray(ToonHeader header) { + List values = new ArrayList<>(); + for (String token : ToonScan.split(header.inline, header.delimiter)) values.add(ToonText.value(token)); + if (strict && values.size() != header.length) { + throw new ToonException("expected " + header.length + " values, found " + values.size()); + } + return values; + } + + private List listItems(ToonHeader header, int depth) { + List items = new ArrayList<>(); + boolean started = false; + while (true) { + cursor.skipBlanks(); + Line line = cursor.peek(); + if (line == null || line.depth != depth + 1) break; + String body = ToonScan.listItemContent(line.content); + if (body == null) break; + cursor.next(); + if (!started) { + started = true; + cursor.enterSpan(depth); + } + items.add(listItem(body, depth + 1)); + } + if (started) cursor.leaveSpan(); + if (strict && items.size() != header.length) { + throw new ToonException("expected " + header.length + " items, found " + items.size()); + } + return items; + } + + private Object listItem(String body, int depth) { + if (body.isEmpty()) return new LinkedHashMap(); + if (body.equals(EMPTY_ARRAY)) return new ArrayList<>(); + ToonHeader header = ToonHeader.parse(body, strict); + if (header != null && header.key == null) { + if (header.fields != null) throw new ToonException("a keyless fields-bearing header is valid only at the root: " + body); + return headerValue(header, depth); + } + if (header == null && ToonScan.indexOfUnquoted(body, ':') < 0) return ToonText.value(body); + Map into = new LinkedHashMap<>(); + field(body, depth + 1, into); + return objectBody(depth + 1, into); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonEncoder.java b/src/main/java/dev/lain/claudejb/mcp/ToonEncoder.java new file mode 100644 index 00000000..dd366661 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonEncoder.java @@ -0,0 +1,169 @@ +package dev.lain.claudejb.mcp; + +import dev.lain.claudejb.mcp.ToonHeader.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class ToonEncoder { + + private final Toon.Options options; + private final char delimiter; + private final String symbol; + private final List lines = new ArrayList<>(); + + ToonEncoder(Toon.Options options) { + this.options = options; + this.delimiter = options.delimiter; + this.symbol = delimiter == ',' ? "" : String.valueOf(delimiter); + } + + String encode(Object value) { + if (value instanceof List) array("", (List) value, 0); + else if (value instanceof Map) { + Map obj = cast(value); + List columns = keyedColumns(obj); + if (columns != null) keyed("", obj, columns, 0); + else fields(obj, 0); + } else lines.add(ToonText.primitive(value, delimiter)); + return String.join("\n", lines); + } + + private void fields(Map obj, int depth) { + for (Map.Entry entry : obj.entrySet()) field(ToonText.key(entry.getKey()), entry.getValue(), depth); + } + + private void field(String name, Object value, int depth) { + if (value instanceof List) array(name, (List) value, depth); + else if (value instanceof Map) object(name, cast(value), depth); + else emit(depth, name + ": " + ToonText.primitive(value, delimiter)); + } + + private void object(String name, Map value, int depth) { + List columns = keyedColumns(value); + if (value.isEmpty()) emit(depth, name + ":"); + else if (columns != null) keyed(name, value, columns, depth); + else { + emit(depth, name + ":"); + fields(value, depth + 1); + } + } + + private void array(String name, List arr, int depth) { + List columns = !arr.isEmpty() && allObjects(arr) ? columns(arr) : null; + if (arr.isEmpty()) emit(depth, name.isEmpty() ? "[]" : name + ": []"); + else if (allPrimitives(arr)) emit(depth, name + bracket(arr.size()) + ": " + inline(arr)); + else if (columns != null) { + emit(depth, name + bracket(arr.size()) + "{" + ToonHeader.render(columns, delimiter) + "}:"); + for (Object row : arr) emit(depth + 1, String.join(String.valueOf(delimiter), cells(cast(row), columns))); + } else { + emit(depth, name + bracket(arr.size()) + ":"); + for (Object element : arr) item(element, depth + 1); + } + } + + private void keyed(String name, Map obj, List columns, int depth) { + emit(depth, name + "[" + obj.size() + ":" + symbol + "]{" + ToonHeader.render(columns, delimiter) + "}:"); + for (Map.Entry entry : obj.entrySet()) { + String cells = String.join(String.valueOf(delimiter), cells(cast(entry.getValue()), columns)); + emit(depth + 1, ToonText.key(entry.getKey()) + ": " + cells); + } + } + + private void item(Object element, int depth) { + if (element instanceof List) arrayItem((List) element, depth); + else if (element instanceof Map) objectItem(cast(element), depth); + else emit(depth, "- " + ToonText.primitive(element, delimiter)); + } + + private void arrayItem(List element, int depth) { + if (allPrimitives(element)) { + String values = element.isEmpty() ? "" : " " + inline(element); + emit(depth, "- " + bracket(element.size()) + ":" + values); + } else { + emit(depth, "- " + bracket(element.size()) + ":"); + for (Object nested : element) item(nested, depth + 1); + } + } + + private void objectItem(Map element, int depth) { + if (element.isEmpty()) { + emit(depth, "-"); + return; + } + int first = lines.size(); + fields(element, depth + 1); + lines.set(first, indent(depth) + "- " + lines.get(first).substring(indent(depth + 1).length())); + } + + private List columns(List objects) { + Set keys = cast(objects.get(0)).keySet(); + if (keys.isEmpty()) return null; + for (Object object : objects) if (!cast(object).keySet().equals(keys)) return null; + List out = new ArrayList<>(); + for (String key : keys) { + List values = new ArrayList<>(); + for (Object object : objects) values.add(cast(object).get(key)); + Field column = column(key, values); + if (column == null) return null; + out.add(column); + } + return out; + } + + private Field column(String name, List values) { + if (allPrimitives(values)) return new Field(name, null); + if (!allObjects(values)) return null; + List children = columns(values); + return children == null ? null : new Field(name, children); + } + + private List keyedColumns(Map obj) { + if (obj.size() < 2 || !allObjects(obj.values())) return null; + return columns(new ArrayList<>(obj.values())); + } + + private List cells(Map obj, List columns) { + List out = new ArrayList<>(); + for (Field column : columns) { + Object value = obj.get(column.name); + if (column.children == null) out.add(ToonText.primitive(value, delimiter)); + else out.addAll(cells(cast(value), column.children)); + } + return out; + } + + private String inline(List arr) { + List out = new ArrayList<>(); + for (Object value : arr) out.add(ToonText.primitive(value, delimiter)); + return String.join(String.valueOf(delimiter), out); + } + + private static boolean allPrimitives(Iterable values) { + for (Object value : values) if (!ToonText.isPrimitive(value)) return false; + return true; + } + + private static boolean allObjects(Iterable values) { + for (Object value : values) if (!(value instanceof Map)) return false; + return true; + } + + @SuppressWarnings("unchecked") + private static Map cast(Object value) { + return (Map) value; + } + + private String bracket(int size) { + return "[" + size + symbol + "]"; + } + + private void emit(int depth, String text) { + lines.add(indent(depth) + text); + } + + private String indent(int depth) { + return " ".repeat(depth * options.indentSize); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonException.java b/src/main/java/dev/lain/claudejb/mcp/ToonException.java new file mode 100644 index 00000000..c36abf52 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonException.java @@ -0,0 +1,8 @@ +package dev.lain.claudejb.mcp; + +public final class ToonException extends RuntimeException { + + public ToonException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonHeader.java b/src/main/java/dev/lain/claudejb/mcp/ToonHeader.java new file mode 100644 index 00000000..8db3f387 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonHeader.java @@ -0,0 +1,158 @@ +package dev.lain.claudejb.mcp; + +import java.util.ArrayList; +import java.util.List; + +final class ToonHeader { + + static final class Field { + final String name; + final List children; + + Field(String name, List children) { + this.name = name; + this.children = children; + } + } + + private static final String DELIMITERS = ",\t|"; + + final String key; + final int length; + final char delimiter; + final boolean keyed; + final List fields; + final String inline; + + private ToonHeader(String key, int length, char delimiter, boolean keyed, List fields, String inline) { + this.key = key; + this.length = length; + this.delimiter = delimiter; + this.keyed = keyed; + this.fields = fields; + this.inline = inline; + } + + int leafCount() { + return fields == null ? 0 : leaves(fields); + } + + static ToonHeader parse(String content, boolean strict) { + int open = ToonScan.indexOfUnquoted(content, '['); + if (open < 0) return null; + int colon = ToonScan.indexOfUnquoted(content, ':'); + if (colon >= 0 && colon < open) return null; + String rawKey = content.substring(0, open); + if (!rawKey.equals(ToonScan.trimSpaces(rawKey))) return reject(strict, "whitespace before the bracket segment in: " + content); + int close = content.indexOf(']', open); + if (close < 0) return reject(strict, "unterminated bracket segment in: " + content); + String segment = content.substring(open + 1, close); + int end = 0; + while (end < segment.length() && segment.charAt(end) >= '0' && segment.charAt(end) <= '9') end++; + if (end == 0 || (segment.charAt(0) == '0' && end > 1)) return reject(strict, "malformed bracket segment in: " + content); + int length; + try { + length = Integer.parseInt(segment.substring(0, end)); + } catch (NumberFormatException e) { + return reject(strict, "bracket length out of range in: " + content); + } + String rest = segment.substring(end); + boolean keyed = rest.startsWith(":"); + if (keyed) rest = rest.substring(1); + char delimiter; + if (rest.isEmpty()) delimiter = ','; + else if (rest.equals("\t")) delimiter = '\t'; + else if (rest.equals("|")) delimiter = '|'; + else return reject(strict, "malformed bracket segment in: " + content); + int at = close + 1; + List fields = null; + if (at < content.length() && content.charAt(at) == '{') { + int braceEnd = ToonScan.matchingBrace(content, at); + if (braceEnd < 0) return reject(strict, "unmatched brace in the field list of: " + content); + fields = fields(content.substring(at + 1, braceEnd), delimiter, strict); + if (fields == null) return reject(strict, "malformed field list in: " + content); + at = braceEnd + 1; + } + if (at >= content.length() || content.charAt(at) != ':') { + return reject(strict, "content between the bracket segment and the colon in: " + content); + } + if (keyed && fields == null) return reject(strict, "keyed header without a field list: " + content); + String inline = ToonScan.trimSpaces(content.substring(at + 1)); + if (fields != null && !inline.isEmpty()) return reject(strict, "inline content after a fields-bearing header: " + content); + String key = rawKey.isEmpty() ? null : ToonText.keyOf(rawKey); + return new ToonHeader(key, length, delimiter, keyed, fields, inline); + } + + static String render(List fields, char delimiter) { + StringBuilder out = new StringBuilder(); + for (Field field : fields) { + if (out.length() > 0) out.append(delimiter); + out.append(ToonText.key(field.name)); + if (field.children != null) out.append('{').append(render(field.children, delimiter)).append('}'); + } + return out.toString(); + } + + static int leaves(List fields) { + int count = 0; + for (Field field : fields) count += field.children == null ? 1 : leaves(field.children); + return count; + } + + private static ToonHeader reject(boolean strict, String message) { + if (strict) throw new ToonException(message); + return null; + } + + private static List fields(String text, char delimiter, boolean strict) { + List out = new ArrayList<>(); + for (String entry : entries(text, delimiter)) { + int brace = ToonScan.indexOfUnquoted(entry, '{'); + String raw = ToonScan.trimSpaces(brace < 0 ? entry : entry.substring(0, brace)); + if (raw.isEmpty() || mismatched(raw, delimiter)) return null; + List children = null; + if (brace >= 0) { + int end = ToonScan.matchingBrace(entry, brace); + if (end != entry.length() - 1) return null; + children = fields(entry.substring(brace + 1, end), delimiter, strict); + if (children == null) return null; + } + String name = ToonText.keyOf(raw); + if (strict && out.stream().anyMatch(f -> f.name.equals(name))) { + throw new ToonException("duplicate field name " + name + " in {" + text + "}"); + } + out.add(new Field(name, children)); + } + return out; + } + + private static boolean mismatched(String name, char delimiter) { + if (name.startsWith("\"")) return false; + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (DELIMITERS.indexOf(c) >= 0 && c != delimiter) return true; + } + return false; + } + + private static List entries(String text, char delimiter) { + List out = new ArrayList<>(); + int start = 0; + int depth = 0; + boolean quoted = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (quoted && c == '\\') i++; + else if (c == '"') quoted = !quoted; + else if (quoted) continue; + else if (c == '{') depth++; + else if (c == '}') depth--; + else if (c == delimiter && depth == 0) { + out.add(ToonScan.trimSpaces(text.substring(start, i))); + start = i + 1; + } + } + out.add(ToonScan.trimSpaces(text.substring(start))); + return out; + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonLines.java b/src/main/java/dev/lain/claudejb/mcp/ToonLines.java new file mode 100644 index 00000000..18a8c5bb --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonLines.java @@ -0,0 +1,59 @@ +package dev.lain.claudejb.mcp; + +import java.util.ArrayList; +import java.util.List; + +final class ToonLines { + + static final class Line { + final int number; + final int depth; + final String content; + + Line(int number, int depth, String content) { + this.number = number; + this.depth = depth; + this.content = content; + } + + boolean blank() { + return content.isEmpty(); + } + } + + private ToonLines() { + } + + static List of(String text, Toon.Options options) { + String body = text.startsWith("") ? text.substring(1) : text; + String[] raws = body.split("\n", -1); + List lines = new ArrayList<>(raws.length); + for (int i = 0; i < raws.length; i++) { + String raw = raws[i]; + if (raw.endsWith("\r")) raw = raw.substring(0, raw.length() - 1); + Line line = line(i + 1, stripTrailingSpaces(raw), options); + if (line != null) lines.add(line); + } + return lines; + } + + private static Line line(int number, String raw, Toon.Options options) { + int spaces = 0; + while (spaces < raw.length() && raw.charAt(spaces) == ' ') spaces++; + if (spaces < raw.length() && raw.charAt(spaces) == '#') return null; + if (raw.chars().allMatch(c -> c == ' ' || c == '\t')) return new Line(number, 0, ""); + int tabs = 0; + while (spaces + tabs < raw.length() && raw.charAt(spaces + tabs) == '\t') tabs++; + if (options.strict && tabs > 0) throw new ToonException("tab in the indentation of line " + number); + if (options.strict && spaces % options.indentSize != 0) { + throw new ToonException("indentation of line " + number + " is not a multiple of " + options.indentSize); + } + return new Line(number, spaces / options.indentSize + tabs, raw.substring(spaces + tabs)); + } + + private static String stripTrailingSpaces(String raw) { + int end = raw.length(); + while (end > 0 && raw.charAt(end - 1) == ' ') end--; + return raw.substring(0, end); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonScan.java b/src/main/java/dev/lain/claudejb/mcp/ToonScan.java new file mode 100644 index 00000000..752857bc --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonScan.java @@ -0,0 +1,66 @@ +package dev.lain.claudejb.mcp; + +import java.util.ArrayList; +import java.util.List; + +final class ToonScan { + + private ToonScan() { + } + + static int indexOfUnquoted(String text, char target) { + return indexOfUnquoted(text, target, 0); + } + + static int indexOfUnquoted(String text, char target, int from) { + boolean quoted = false; + for (int i = from; i < text.length(); i++) { + char c = text.charAt(i); + if (quoted && c == '\\') i++; + else if (c == '"') quoted = !quoted; + else if (!quoted && c == target) return i; + } + return -1; + } + + static List split(String text, char delimiter) { + List tokens = new ArrayList<>(); + int start = 0; + while (true) { + int at = indexOfUnquoted(text, delimiter, start); + if (at < 0) break; + tokens.add(trimSpaces(text.substring(start, at))); + start = at + 1; + } + tokens.add(trimSpaces(text.substring(start))); + return tokens; + } + + static int matchingBrace(String text, int open) { + int depth = 0; + boolean quoted = false; + for (int i = open; i < text.length(); i++) { + char c = text.charAt(i); + if (quoted && c == '\\') i++; + else if (c == '"') quoted = !quoted; + else if (quoted) continue; + else if (c == '{') depth++; + else if (c == '}' && --depth == 0) return i; + } + return -1; + } + + static String listItemContent(String content) { + if (content.equals("-")) return ""; + if (content.startsWith("- ")) return trimSpaces(content.substring(2)); + return null; + } + + static String trimSpaces(String token) { + int start = 0; + int end = token.length(); + while (start < end && token.charAt(start) == ' ') start++; + while (end > start && token.charAt(end - 1) == ' ') end--; + return token.substring(start, end); + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonTables.java b/src/main/java/dev/lain/claudejb/mcp/ToonTables.java new file mode 100644 index 00000000..f3faf152 --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonTables.java @@ -0,0 +1,100 @@ +package dev.lain.claudejb.mcp; + +import dev.lain.claudejb.mcp.ToonHeader.Field; +import dev.lain.claudejb.mcp.ToonLines.Line; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class ToonTables { + + private final ToonCursor cursor; + private final boolean strict; + + ToonTables(ToonCursor cursor, boolean strict) { + this.cursor = cursor; + this.strict = strict; + } + + List tabular(ToonHeader header, int depth) { + List rows = new ArrayList<>(); + boolean started = false; + while (true) { + cursor.skipBlanks(); + Line line = cursor.peek(); + if (line == null || line.depth != depth + 1 || !isRow(line.content, header.delimiter)) break; + cursor.next(); + if (!started) { + started = true; + cursor.enterSpan(depth); + } + rows.add(row(header.fields, ToonScan.split(line.content, header.delimiter), header.leafCount())); + } + if (started) cursor.leaveSpan(); + if (strict && rows.size() != header.length) { + throw new ToonException("expected " + header.length + " rows, found " + rows.size()); + } + return rows; + } + + Map keyed(ToonHeader header, int depth) { + Map out = new LinkedHashMap<>(); + boolean started = false; + int count = 0; + while (true) { + cursor.skipBlanks(); + Line line = cursor.peek(); + if (line == null || line.depth <= depth) break; + if (line.depth != depth + 1) { + if (strict) throw new ToonException("line " + line.number + " is over-indented for an entry row"); + cursor.next(); + continue; + } + cursor.next(); + int colon = ToonScan.indexOfUnquoted(line.content, ':'); + if (colon < 0) { + if (strict) throw new ToonException("entry row without a colon at line " + line.number); + continue; + } + if (!started) { + started = true; + cursor.enterSpan(depth); + } + String rest = ToonScan.trimSpaces(line.content.substring(colon + 1)); + List cells = rest.isEmpty() ? new ArrayList<>() : ToonScan.split(rest, header.delimiter); + String key = ToonText.keyOf(ToonScan.trimSpaces(line.content.substring(0, colon))); + put(out, key, row(header.fields, cells, header.leafCount()), strict); + count++; + } + if (started) cursor.leaveSpan(); + if (strict && count != header.length) throw new ToonException("expected " + header.length + " entries, found " + count); + return out; + } + + static void put(Map into, String key, Object value, boolean strict) { + if (strict && into.containsKey(key)) throw new ToonException("duplicate key " + key); + into.put(key, value); + } + + private static boolean isRow(String content, char delimiter) { + int colon = ToonScan.indexOfUnquoted(content, ':'); + int split = ToonScan.indexOfUnquoted(content, delimiter); + return colon < 0 || (split >= 0 && split < colon); + } + + private Map row(List fields, List cells, int leafCount) { + if (strict && cells.size() != leafCount) throw new ToonException("expected " + leafCount + " cells, found " + cells.size()); + return build(fields, cells.iterator()); + } + + private Map build(List fields, Iterator cells) { + Map out = new LinkedHashMap<>(); + for (Field field : fields) { + if (field.children != null) out.put(field.name, build(field.children, cells)); + else if (cells.hasNext()) out.put(field.name, ToonText.value(cells.next())); + } + return out; + } +} diff --git a/src/main/java/dev/lain/claudejb/mcp/ToonText.java b/src/main/java/dev/lain/claudejb/mcp/ToonText.java new file mode 100644 index 00000000..d0ebd36e --- /dev/null +++ b/src/main/java/dev/lain/claudejb/mcp/ToonText.java @@ -0,0 +1,148 @@ +package dev.lain.claudejb.mcp; + +import java.math.BigDecimal; +import java.util.regex.Pattern; + +final class ToonText { + + private static final int HEX_DIGITS = 4; + private static final String LEADING = " \t-#"; + private static final String TRAILING = " \t"; + private static final String STRUCTURAL = ":\"\\[]{}"; + private static final BigDecimal PLAIN_MIN = new BigDecimal("1e-6"); + private static final BigDecimal PLAIN_MAX = new BigDecimal("1e21"); + private static final Pattern UNQUOTED_KEY = Pattern.compile("[A-Za-z_][A-Za-z0-9_.]*"); + private static final Pattern NUMERIC_LIKE = Pattern.compile("[+-]?[0-9]+(?:[.][0-9]+)?(?:[eE][+-]?[0-9]+)?"); + private static final Pattern NUMBER = Pattern.compile("-?[0-9]+(?:[.][0-9]+)?(?:[eE][+-]?[0-9]+)?"); + private static final Pattern LEADING_ZERO = Pattern.compile("^-?0[0-9]"); + + private ToonText() { + } + + static String key(String name) { + requireScalars(name); + return UNQUOTED_KEY.matcher(name).matches() ? name : quote(name); + } + + static String primitive(Object value, char delimiter) { + if (value == null) return "null"; + if (value instanceof Boolean) return value.toString(); + if (value instanceof String) return string((String) value, delimiter); + if (value instanceof BigDecimal) return canonical((BigDecimal) value); + if (value instanceof Number) return canonical(new BigDecimal(value.toString())); + throw new ToonException("not a primitive: " + value.getClass().getName()); + } + + static boolean isPrimitive(Object value) { + return value == null || value instanceof String || value instanceof Number || value instanceof Boolean; + } + + static String string(String value, char delimiter) { + requireScalars(value); + return needsQuotes(value, delimiter) ? quote(value) : value; + } + + static String canonical(BigDecimal number) { + BigDecimal n = number.stripTrailingZeros(); + if (n.signum() == 0) return "0"; + BigDecimal magnitude = n.abs(); + if (magnitude.compareTo(PLAIN_MIN) >= 0 && magnitude.compareTo(PLAIN_MAX) < 0) return n.toPlainString(); + String digits = n.unscaledValue().abs().toString(); + int exponent = digits.length() - 1 - n.scale(); + String mantissa = digits.length() == 1 ? digits : digits.charAt(0) + "." + digits.substring(1); + String sign = n.signum() < 0 ? "-" : ""; + String exponentSign = exponent < 0 ? "-" : "+"; + return sign + mantissa + "e" + exponentSign + Math.abs(exponent); + } + + static Object value(String token) { + if (token.startsWith("\"")) return unquote(token); + if (token.equals("true")) return Boolean.TRUE; + if (token.equals("false")) return Boolean.FALSE; + if (token.equals("null")) return null; + if (NUMBER.matcher(token).matches() && !LEADING_ZERO.matcher(token).find()) return new BigDecimal(token); + return token; + } + + static String keyOf(String token) { + return token.startsWith("\"") ? unquote(token) : token; + } + + static String unquote(String token) { + StringBuilder out = new StringBuilder(); + int i = 1; + while (i < token.length()) { + char c = token.charAt(i); + if (c == '"') { + if (i != token.length() - 1) throw new ToonException("characters after the closing quote in " + token); + return out.toString(); + } + if (c == '\\') i = escape(token, i + 1, out); + else out.append(c); + i++; + } + throw new ToonException("unterminated string: " + token); + } + + private static int escape(String token, int at, StringBuilder out) { + if (at >= token.length()) throw new ToonException("unterminated escape in " + token); + char c = token.charAt(at); + switch (c) { + case '\\': out.append('\\'); return at; + case '"': out.append('"'); return at; + case 'n': out.append('\n'); return at; + case 'r': out.append('\r'); return at; + case 't': out.append('\t'); return at; + case 'u': break; + default: throw new ToonException("invalid escape in " + token); + } + int end = at + 1 + HEX_DIGITS; + String hex = token.substring(at + 1, Math.min(end, token.length())); + if (hex.length() != HEX_DIGITS || !hex.chars().allMatch(ToonText::isHex)) { + throw new ToonException("truncated unicode escape in " + token); + } + int code = Integer.parseInt(hex, 16); + if (Character.isSurrogate((char) code)) throw new ToonException("surrogate escape in " + token); + out.append((char) code); + return end - 1; + } + + private static boolean isHex(int c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + private static String quote(String value) { + StringBuilder out = new StringBuilder("\""); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\') out.append("\\\\"); + else if (c == '"') out.append("\\\""); + else if (c == '\n') out.append("\\n"); + else if (c == '\r') out.append("\\r"); + else if (c == '\t') out.append("\\t"); + else if (c < ' ') out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + return out.append('"').toString(); + } + + private static boolean needsQuotes(String s, char delimiter) { + if (s.isEmpty() || s.equals("true") || s.equals("false") || s.equals("null")) return true; + if (NUMERIC_LIKE.matcher(s).matches()) return true; + if (LEADING.indexOf(s.charAt(0)) >= 0 || TRAILING.indexOf(s.charAt(s.length() - 1)) >= 0) return true; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (STRUCTURAL.indexOf(c) >= 0 || c < ' ' || c == delimiter) return true; + } + return false; + } + + private static void requireScalars(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean paired = Character.isHighSurrogate(c) && i + 1 < value.length() && Character.isLowSurrogate(value.charAt(i + 1)); + if (paired) i++; + else if (Character.isSurrogate(c)) throw new ToonException("unpaired surrogate at index " + i); + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/actions/AddFileAsContextAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/AddFileAsContextAction.kt similarity index 87% rename from src/main/kotlin/dev/lain/claudejb/actions/AddFileAsContextAction.kt rename to src/main/kotlin/dev/lain/claudejb/controller/actions/AddFileAsContextAction.kt index cb7204b2..2e6afedf 100644 --- a/src/main/kotlin/dev/lain/claudejb/actions/AddFileAsContextAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/AddFileAsContextAction.kt @@ -1,10 +1,10 @@ -package dev.lain.claudejb.actions +package dev.lain.claudejb.controller.actions import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys -import dev.lain.claudejb.context.EditorContextProvider +import dev.lain.claudejb.controller.context.EditorContextProvider class AddFileAsContextAction : AnAction() { diff --git a/src/main/kotlin/dev/lain/claudejb/actions/AddSelectionAsContextAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/AddSelectionAsContextAction.kt similarity index 88% rename from src/main/kotlin/dev/lain/claudejb/actions/AddSelectionAsContextAction.kt rename to src/main/kotlin/dev/lain/claudejb/controller/actions/AddSelectionAsContextAction.kt index eb951ce9..f074d513 100644 --- a/src/main/kotlin/dev/lain/claudejb/actions/AddSelectionAsContextAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/AddSelectionAsContextAction.kt @@ -1,10 +1,10 @@ -package dev.lain.claudejb.actions +package dev.lain.claudejb.controller.actions import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys -import dev.lain.claudejb.context.EditorContextProvider +import dev.lain.claudejb.controller.context.EditorContextProvider class AddSelectionAsContextAction : AnAction() { diff --git a/src/main/kotlin/dev/lain/claudejb/actions/AttachmentActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt similarity index 77% rename from src/main/kotlin/dev/lain/claudejb/actions/AttachmentActions.kt rename to src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt index 6f12802d..b52f9eeb 100644 --- a/src/main/kotlin/dev/lain/claudejb/actions/AttachmentActions.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/AttachmentActions.kt @@ -1,10 +1,10 @@ -package dev.lain.claudejb.actions +package dev.lain.claudejb.controller.actions import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindowManager -import dev.lain.claudejb.context.Attachment -import dev.lain.claudejb.session.ChatSessionManager -import dev.lain.claudejb.ui.ClaudeToolWindowFactory +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.model.context.Attachment +import dev.lain.claudejb.view.window.ClaudeToolWindowFactory object AttachmentActions { diff --git a/src/main/kotlin/dev/lain/claudejb/actions/ExplainSelectionAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt similarity index 92% rename from src/main/kotlin/dev/lain/claudejb/actions/ExplainSelectionAction.kt rename to src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt index 0d1fa599..5842c8c1 100644 --- a/src/main/kotlin/dev/lain/claudejb/actions/ExplainSelectionAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/actions/ExplainSelectionAction.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.actions +package dev.lain.claudejb.controller.actions import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction @@ -6,8 +6,8 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.ToolWindowManager -import dev.lain.claudejb.session.ChatSessionManager -import dev.lain.claudejb.ui.ClaudeToolWindowFactory +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.view.window.ClaudeToolWindowFactory class ExplainSelectionAction : AnAction() { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt new file mode 100644 index 00000000..d9d1955a --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeAttachments.kt @@ -0,0 +1,86 @@ +package dev.lain.claudejb.controller.bridge + +import com.intellij.openapi.application.ApplicationManager +import dev.lain.claudejb.controller.context.ProjectTree +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.context.ImageAttachments +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.payload.chat.JcefTreeData +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeAttachments(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + private val tray get() = panel.tray + + fun handle(m: Msg.Attachments) { + when (m) { + is Msg.RemoveAttachment -> tray.remove(m.id) + Msg.AttachSelection -> tray.addSelection() + Msg.AttachCurrentFile -> tray.addCurrentFile() + Msg.RequestAttachData -> tray.pushMenuData() + is Msg.AttachPath -> attachPaths(listOf(m.path)) + is Msg.TreeChildren -> treeChildren(m) + is Msg.TreeExpand -> treeExpand(m) + is Msg.AttachPaths -> attachPaths(m.paths) + Msg.PasteClipboard -> tray.pasteFromClipboard() + is Msg.PasteClipboardImage -> tray.pasteImageFromClipboard(m.notify) + is Msg.Attach -> attachImage(m) + } + } + + private fun attachImage(m: Msg.Attach) { + val image = ImageAttachments.fromWebPayload(m.name, m.mediaType, m.base64) + if (image == null) { + tray.notify( + "That attachment was not added: only PNG, JPEG, GIF and WebP images are accepted, " + + "up to ${ImageAttachments.MAX_IMAGE_BYTES / BYTES_PER_MB} MB.", + ) + return + } + tray.add(image) + } + + private fun treeMode(wire: String): ProjectTree.Mode? = when (wire) { + "files" -> ProjectTree.Mode.FILES + "directories" -> ProjectTree.Mode.DIRECTORIES + else -> null + } + + private fun treeChildren(m: Msg.TreeChildren) { + val mode = treeMode(m.mode) ?: return unknownTreeMode(m.mode) + panel.host.execBuilt("window.cc.treeChildren") { + JcefTreeData.childrenJson(m.path, m.mode, ProjectTree.children(panel.project, m.path, mode)).toString() + } + } + + private fun treeExpand(m: Msg.TreeExpand) { + val mode = treeMode(m.mode) ?: return unknownTreeMode(m.mode) + panel.host.execBuilt("window.cc.treeExpansion") { + JcefTreeData.expansionJson(m.path, m.mode, ProjectTree.expand(panel.project, m.path, mode)).toString() + } + } + + private fun attachPaths(paths: List) { + if (paths.isEmpty()) return + val root = panel.project.basePath + ApplicationManager.getApplication().executeOnPooledThread { + val wanted = paths.take(ProjectTree.MAX_ENTRIES) + val files = wanted.mapNotNull { ProjectTree.resolve(root, it)?.path } + if (files.size != wanted.size) { + log.warn("Claude Code: ${wanted.size - files.size} of ${wanted.size} attached paths name nothing inside this project") + } + if (files.isEmpty()) return@executeOnPooledThread + edt { tray.addPaths(files) } + } + } + + private fun unknownTreeMode(wire: String) = + log.warn("The attach menu asked to browse the project in a mode this build does not have: $wire") + + private companion object { + const val BYTES_PER_MB = 1024 * 1024 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt new file mode 100644 index 00000000..7f15a7b0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeCards.kt @@ -0,0 +1,36 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeCards(private val panel: JcefChatPanel) { + + fun handle(m: Msg.RequestCard) { + when (m) { + is Msg.ResolvePermission -> resolvePermission(m) + + is Msg.ResolveQuestion -> panel.cardSession(m.scope).cards.resolveQuestion(m.id, m.answers) + + is Msg.ResolveElicitation -> + panel.cardSession(m.scope).cards.resolveElicitation(m.id, m.action, m.content) + + is Msg.AlwaysAllow -> alwaysAllow(m) + } + } + + private fun resolvePermission(m: Msg.ResolvePermission) { + val target = panel.cardSession(m.scope) + val wasPlan = target.cards.pending().firstOrNull { it.requestId == m.id }?.isPlan == true + target.cards.resolvePermission(m.id, m.allow) + if (wasPlan && m.allow && target === panel.session) panel.feed.requestPlan() + } + + private fun alwaysAllow(m: Msg.AlwaysAllow) { + ClaudeSettings.getInstance(panel.project).alwaysAllow.remember(m.tool) + val chat = panel.cardSession(m.scope) + val pending = chat.cards.pending() + val target = pending.firstOrNull { it.requestId == m.id } ?: pending.firstOrNull { it.toolName == m.tool } + target?.let { chat.cards.resolvePermission(it.requestId, true) } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt new file mode 100644 index 00000000..4e34afdc --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeDiffs.kt @@ -0,0 +1,59 @@ +package dev.lain.claudejb.controller.bridge + +import com.intellij.openapi.application.ApplicationManager +import dev.lain.claudejb.controller.context.LinkResolver +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.model.diff.EditSnapshot +import dev.lain.claudejb.model.permission.broker.PendingPermission +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.diff.DiffEditors +import dev.lain.claudejb.view.payload.chat.JcefTranscriptPayload +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeDiffs(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + fun handle(m: Msg.Diffs) { + when (m) { + is Msg.ViewDiff -> panel.cardSession(m.scope).cards.pending().firstOrNull { it.requestId == m.id } + ?.let { viewPending(it) } + + is Msg.ViewDiffByTool -> snapshotAnywhere(m.toolUseId) + ?.let { DiffEditors.openDiff(panel.project, it.toolName, it.input, it.beforeText) } + + is Msg.RevertEdit -> panel.edits.rewindOrRevert(m.toolUseId) + + is Msg.Open -> panel.links.open(m.url) + + is Msg.ResolveLinks -> resolveLinks(m) + } + } + + private fun viewPending(card: PendingPermission) { + val path = DiffPresenter.filePathOf(card.input) ?: return + val project = panel.project + ApplicationManager.getApplication().executeOnPooledThread { + val current = DiffPresenter.readCurrent(path, project.basePath) + if (current == null) { + log.warn("View diff refused: the card names a file outside the project or over the size cap") + return@executeOnPooledThread + } + edt(project) { DiffEditors.openDiff(project, card.toolName, card.input, current) } + } + } + + private fun snapshotAnywhere(toolUseId: String): EditSnapshot? = + panel.session.cards.editSnapshot(toolUseId) ?: panel.gitChat.session().cards.editSnapshot(toolUseId) + + private fun resolveLinks(m: Msg.ResolveLinks) { + if (m.paths.isEmpty() && m.symbols.isEmpty()) return + val project = panel.project + panel.host.execBuilt("window.cc.links") { + val resolved = LinkResolver.resolvePaths(project, m.paths) + LinkResolver.resolveSymbols(project, m.symbols) + resolved.takeIf { it.isNotEmpty() }?.let { JcefTranscriptPayload.linksJson(m.rowId, it) } + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt new file mode 100644 index 00000000..d5ad1e3c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeGuard.kt @@ -0,0 +1,144 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.controller.commands.GuardWhitelistPrompt +import dev.lain.claudejb.controller.commands.LivePanels +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.permission.SensitiveGuard +import dev.lain.claudejb.model.permission.scan.ToolInputScanner +import dev.lain.claudejb.model.permission.vocab.SecurityRule +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.guard.GuardWhitelists +import dev.lain.claudejb.model.settings.guard.SecuritySuspensions +import dev.lain.claudejb.model.settings.guard.sensitivePolicy +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeGuard(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + private val session get() = panel.session + + private val settings: ClaudeSettings get() = ClaudeSettings.getInstance(panel.project) + + fun handle(m: Msg.Guard) { + when (m) { + is Msg.GuardSuspend -> suspend(m) + is Msg.GuardMaster -> master(m) + is Msg.GuardWhitelist -> whitelist(m) + is Msg.GuardRevokeApproval -> revokeApproval(m) + is Msg.GuardRemoveWhitelist -> removeWhitelist(m) + is Msg.GuardAllowAlways -> allowAlways(m) + Msg.GuardLog -> panel.security.pushGuard() + is Msg.GuardExplain -> panel.guard.explain(m.id) + } + } + + private fun suspend(m: Msg.GuardSuspend) { + val rule = SecurityRule.from(m.rule) + val duration = SecuritySuspensions.Duration.from(m.duration) + if (rule == null || duration == null) { + log.warn("A guard block asked to suspend something this build does not have: ${m.rule}/${m.duration}") + return + } + val scope = settings.scope.id + settings.update { SecuritySuspensions.suspend(scope, it, rule, duration, System.currentTimeMillis()) } + LivePanels.pushSettingsMenu() + session.systemNotice( + "${rule.label} is disabled ${duration.phrase}. Matching calls will ask you instead of being refused.", + ) + } + + private fun master(m: Msg.GuardMaster) { + val scope = settings.scope.id + if (m.on) { + settings.update { SecuritySuspensions.guardOn(scope, it) } + announce("The Sensitive Guard is back on. Every tool call is judged again.") + return + } + val duration = SecuritySuspensions.Duration.from(m.duration) + if (duration == null) { + log.warn("The shield asked to stand down for a duration this build does not have: ${m.duration}") + return + } + settings.update { SecuritySuspensions.guardOff(scope, it, duration, System.currentTimeMillis()) } + announce( + "The Sensitive Guard is off ${duration.phrase}. Nothing is being judged — no rule, no card, " + + "no block — until it comes back on.", + ) + } + + private fun announce(notice: String) { + LivePanels.pushSettingsMenu() + LivePanels.pushState() + session.systemNotice(notice) + } + + private fun whitelist(m: Msg.GuardWhitelist) { + val rule = SecurityRule.from(m.rule) + val entry = GuardWhitelists.entryFor(m.command) + if (rule == null || entry.isEmpty()) { + log.warn("A guard block asked to whitelist something this build cannot place: " + m.rule) + return + } + if (!GuardWhitelistPrompt.confirm(panel.project, rule, entry)) return + val policy = settings.sensitivePolicy(panel.project.basePath) + val canonical = SensitiveGuard.canonicalCommand(entry, policy) + val already = + GuardWhitelists.all(settings.state, rule).any { SensitiveGuard.canonicalCommand(it, policy) == canonical } + if (already) { + session.systemNotice("`" + entry + "` is already whitelisted — nothing added.") + return + } + settings.update { GuardWhitelists.add(it, rule, entry) } + LivePanels.pushSettingsMenu() + session.systemNotice( + "Commands starting with `" + entry + "` are whitelisted for " + rule.label + ". Every other rule still judges them.", + ) + } + + private fun removeWhitelist(m: Msg.GuardRemoveWhitelist) { + val rule = SecurityRule.from(m.rule) + if (rule == null || m.command.isBlank()) { + log.warn("A bypass warning asked to un-whitelist something this build cannot place: ${m.rule}") + return + } + val policy = settings.sensitivePolicy(panel.project.basePath) + val wanted = SensitiveGuard.canonicalCommand(m.command, policy) + val covers = { entry: String -> SensitiveGuard.covers(SensitiveGuard.canonicalCommand(entry, policy), wanted) } + val listed = GuardWhitelists.listedIn(settings.state, rule, covers) + if (listed.isEmpty()) { + session.systemNotice("`${m.command.trim()}` is not on any whitelist any more.") + return + } + settings.update { GuardWhitelists.remove(it, rule, listed, covers) } + LivePanels.pushSettingsMenu() + val where = listed.joinToString(" and ") { describe(it, rule) } + session.systemNotice("`${m.command.trim()}` is off the $where. ${rule.label} decides it again.") + } + + private fun describe(listed: GuardWhitelists.Listed, rule: SecurityRule): String = when (listed) { + GuardWhitelists.Listed.RULE -> "whitelist for ${rule.label}" + GuardWhitelists.Listed.CATEGORY -> "whitelist for ${rule.category.label}" + GuardWhitelists.Listed.EVERYWHERE -> "whitelist that applies everywhere" + } + + private fun revokeApproval(m: Msg.GuardRevokeApproval) { + val rule = SecurityRule.from(m.rule) + if (rule == null || m.command.isBlank()) { + log.warn("A bypass warning asked to revoke something this build cannot place: ${m.rule}") + return + } + session.guard.approvals.revoke(rule, m.command.trim()) + LivePanels.pushSettingsMenu() + session.systemNotice("`${m.command.trim()}` is no longer pre-approved. ${rule.label} decides again.") + } + + private fun allowAlways(m: Msg.GuardAllowAlways) { + val chat = panel.cardSession(m.scope) + val target = chat.cards.pending().firstOrNull { it.requestId == m.id } ?: return + val rule = target.guard?.rule ?: return + ToolInputScanner.commandsIn(target.input).forEach { chat.guard.approvals.approve(rule, it) } + chat.cards.resolvePermission(target.requestId, true) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt new file mode 100644 index 00000000..140ba8c6 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLifecycle.kt @@ -0,0 +1,40 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeLifecycle(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + fun handle(m: Msg.Lifecycle) { + when (m) { + Msg.Ready -> { + panel.host.markWebReady() + panel.pushTheme() + panel.pushSettingsMenu() + panel.pushMetaState() + panel.pushPermissions() + panel.tray.push() + panel.pushSession() + panel.feed.requestMcp() + panel.feed.requestVersion() + panel.agentTabs.render() + panel.pushGit() + panel.security.pushGuard() + panel.security.pushVuln() + panel.transcript.fullResync() + } + + is Msg.Diagnostics -> + if (m.report.startsWith("uncaught ")) { + log.warn("Claude Code chat page: ${m.report}") + } else { + log.info("JCEF diagnostics: ${m.report}") + } + + is Msg.Unknown -> log.warn("the chat page sent a message this build does not parse: ${m.type}") + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt new file mode 100644 index 00000000..5d14424b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeLog.kt @@ -0,0 +1,15 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeLog(private val panel: JcefChatPanel) { + + fun handle(m: Msg.Log) { + when (m) { + is Msg.LogLines -> panel.logFeed.push(m.since) + is Msg.LogDebug -> panel.logFeed.setDebug(m.on) + is Msg.LogCopy -> panel.logFeed.copy(m.level) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt new file mode 100644 index 00000000..01eaaf7e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeNavigation.kt @@ -0,0 +1,39 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.window.ChatTabsPanel +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeNavigation(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + fun handle(m: Msg.Navigation) { + when (m) { + is Msg.RevealAgent -> panel.agentTabs.revealElsewhere(m.chatId) { it.agentTabs.revealFromHost(m) } + + is Msg.RevealBackgroundTask -> + panel.agentTabs.revealElsewhere(m.chatId) { it.transcript.showBackgroundTask(m.taskId) } + + Msg.ShowChatTranscript -> panel.transcript.showTranscript(null) + + is Msg.SelectChat -> withStrip("select chat ${m.chatId}") { it.selectById(m.chatId) } + + is Msg.CloseChat -> withStrip("close chat ${m.chatId}") { it.closeById(m.chatId) } + + is Msg.SelectAgent -> panel.transcript.showTranscript(m.agentId.ifBlank { null }) + + is Msg.CloseAgent -> panel.agentTabs.closeAgent(m.agentId) + } + } + + fun withStrip(what: String, block: (ChatTabsPanel) -> Unit) { + val strip = panel.chatStrip() + if (strip == null) { + log.warn("Claude Code: no chat strip to $what — the press was dropped") + return + } + block(strip) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt new file mode 100644 index 00000000..e4de6f9e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgePrompting.kt @@ -0,0 +1,43 @@ +package dev.lain.claudejb.controller.bridge + +import com.intellij.openapi.ide.CopyPasteManager +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.view.window.JcefChatPanel +import java.awt.datatransfer.StringSelection + +internal class BridgePrompting(private val panel: JcefChatPanel) { + + private val session get() = panel.session + + fun handle(m: Msg.Prompting) { + when (m) { + is Msg.Send -> if (m.scope == JcefBridge.SCOPE_GIT) panel.gitChat.send(m.text) else send(m.text) + + is Msg.Interrupt -> + if (m.scope == JcefBridge.SCOPE_GIT) panel.gitChat.interrupt() else session.turnControl.interrupt() + + is Msg.RemoveQueued -> session.prompts.remove(m.index) + + is Msg.Copy -> CopyPasteManager.getInstance().setContents(StringSelection(m.text)) + } + } + + private fun send(raw: String) { + session.prompts.clearSuggestion() + val attachments = panel.tray.take() + val text = raw.trim() + when { + attachments.isEmpty() && text == "/login" -> session.login.start() + + attachments.isEmpty() && BTW.matches(text.substringBefore('\n')) -> + session.sendSideQuestion(text.removePrefix("/btw").trim()) + + else -> session.send(raw, attachments) + } + } + + private companion object { + val BTW = Regex("^/btw\\b.*") + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt new file mode 100644 index 00000000..a4ab00fb --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSessionControl.kt @@ -0,0 +1,72 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.controller.commands.LivePanels +import dev.lain.claudejb.controller.commands.git.GitActionCatalog +import dev.lain.claudejb.controller.commands.git.GitIntegration +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.WorkloadWindow +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.window.ClaudeToolWindowFactory +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeSessionControl(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + private val vuln = BridgeVuln(panel) + + private val navigation = BridgeNavigation(panel) + + private val session get() = panel.session + + fun handle(m: Msg.SessionControl) { + when (m) { + is Msg.Vuln -> vuln.handle(m) + + is Msg.Navigation -> navigation.handle(m) + + is Msg.Onboarding -> panel.onboarding.handle(m) + + Msg.McpRefresh -> panel.feed.requestMcp() + + is Msg.McpReconnect -> { + session.queries.reconnectMcp(m.name) + panel.feed.requestMcp() + } + + is Msg.McpToggle -> { + session.queries.toggleMcp(m.name, m.enabled) + panel.feed.requestMcp() + } + + is Msg.StopTask -> session.queries.stopTask(m.taskId) + + is Msg.SetWorkloadWindow -> workloadWindow(m.minutes) + + is Msg.GitAction -> gitAction(m) + + Msg.NewChat -> ClaudeToolWindowFactory.newChat(panel.project) + + Msg.CloseThisChat -> navigation.withStrip("close this chat") { strip -> + strip.tabFor(session)?.let { strip.close(it) } + } + + Msg.OpenGitView -> ClaudeToolWindowFactory.showGitView(panel.project) + } + } + + private fun gitAction(m: Msg.GitAction) { + GitIntegration.getInstance(panel.project).perform(m.id, m.hash, { panel.gitChat.session() }) { panel.pushGit() } + if (GitActionCatalog.byId(m.id)?.kind == GitActionCatalog.Kind.PROMPT) panel.gitChat.show() + } + + private fun workloadWindow(minutes: Int) { + if (minutes !in WorkloadWindow.WINDOW_MINUTES) { + log.warn("Workloads view asked for a window this build does not offer: $minutes") + return + } + ClaudeSettings.getInstance(panel.project).update { it.workloadWindowMinutes = minutes } + LivePanels.pushSession() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt new file mode 100644 index 00000000..a3e22c16 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeSettings.kt @@ -0,0 +1,82 @@ +package dev.lain.claudejb.controller.bridge + +import com.intellij.openapi.options.ShowSettingsUtil +import dev.lain.claudejb.controller.commands.LivePanels +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.LaunchDefaults +import dev.lain.claudejb.model.settings.Provider +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.feed.ChatTheme +import dev.lain.claudejb.view.payload.menu.JcefSettingsMenu +import dev.lain.claudejb.view.settings.ClaudeSettingsConfigurable +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeSettings(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + private val guard = BridgeGuard(panel) + + private val session get() = panel.session + + fun handle(m: Msg.Settings) { + when (m) { + is Msg.ChangeModel -> session.settings.changeModel(m.value) + + is Msg.ChangeMode -> session.settings.changePermissionMode(m.wire) + + is Msg.ChangeEffort -> session.settings.changeEffort(m.value) + + is Msg.ChangeThinking -> + session.settings.changeThinkingTokens(if (m.on) LaunchDefaults.THINKING_ON else null) + + is Msg.ChangeVibe -> { + ChatTheme.setVibeMode(m.on) + LivePanels.pushTheme() + } + + is Msg.ChangeProvider -> session.settings.changeProvider(Provider.fromId(m.id)) + + is Msg.SettingsToggle -> toggle(m) + + is Msg.Guard -> guard.handle(m) + + Msg.SettingsRefresh -> ClaudeSettings.getInstance(panel.project).reload { LivePanels.pushSettingsMenu() } + + Msg.OpenSettings -> + ShowSettingsUtil.getInstance().showSettingsDialog(panel.project, ClaudeSettingsConfigurable::class.java) + } + } + + private fun toggle(m: Msg.SettingsToggle) { + if (!write(m)) { + log.warn("The chat's settings menu asked for a switch this build does not have: ${m.key}") + return + } + LivePanels.pushSettingsMenu() + } + + private fun write(m: Msg.SettingsToggle): Boolean { + val settings = ClaudeSettings.getInstance(panel.project) + JcefSettingsMenu.alwaysAllowTool(m.key)?.let { tool -> + if (m.on) settings.alwaysAllow.remember(tool) else settings.alwaysAllow.forget(tool) + return true + } + JcefSettingsMenu.sessionApproval(m.key)?.let { (rule, command) -> + if (!m.on) session.guard.approvals.revoke(rule, command) + return true + } + if (JcefSettingsMenu.isRemoteControl(m.key)) { + session.remote.set(m.on) { LivePanels.pushSettingsMenu() } + return true + } + val scope = settings.scope.id + val models = session.catalog.models.map { it.value } + if (!JcefSettingsMenu.apply(scope, settings.state, m.key, m.on, models)) return false + settings.update { JcefSettingsMenu.apply(scope, it, m.key, m.on, models) } + ChatSessionManager.getInstance(panel.project).adoptSettings() + return true + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt new file mode 100644 index 00000000..f57d2d76 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/BridgeVuln.kt @@ -0,0 +1,68 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.controller.commands.PromptInNewChat +import dev.lain.claudejb.controller.vuln.VulnPromptedActions +import dev.lain.claudejb.controller.vuln.VulnService +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.util.thisLogger +import dev.lain.claudejb.view.payload.JcefVulnData +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class BridgeVuln(private val panel: JcefChatPanel) { + + private val log = thisLogger() + + private val service: VulnService get() = VulnService.getInstance(panel.project) + + fun handle(m: Msg.Vuln) { + when (m) { + Msg.OpenVulnView -> panel.security.showVulnView() + is Msg.VulnConsentChoice -> service.setConsent(m.granted) { panel.pushSession() } + Msg.VulnScan -> service.scan { panel.pushSession() } + Msg.VulnCancel -> service.cancel { panel.pushSession() } + Msg.VulnInventoryRequest -> inventory() + is Msg.VulnFix -> fix(m.findingId) + is Msg.VulnPlan -> plan(m.tiers) + } + } + + private fun inventory() { + val current = service + val endpoint = current.snapshot().endpoint + panel.host.execBuilt("window.cc.vulnInventory") { JcefVulnData.inventoryJson(current.inventory(), endpoint).toString() } + } + + private fun fix(findingId: String) { + val finding = service.finding(findingId) + if (finding == null) { + log.warn("The security view asked to fix a finding that is no longer in the last report: $findingId") + return + } + val text = VulnPromptedActions.updatePrompt(finding) + if (text == null) { + log.warn("Refusing to prompt for '$findingId': the advisory or the manifest carries unquotable text") + return + } + inNewChat(PromptInNewChat.title("Update", finding.component.name), text) + } + + private fun plan(tiers: List) { + val report = service.snapshot().report + if (report == null) { + log.warn("The security view asked to plan without a report to plan from") + return + } + val wanted = report.ordered().filter { tiers.isEmpty() || it.tier.wire in tiers } + val text = VulnPromptedActions.planPrompt(wanted) + if (text == null) { + log.warn("Refusing to plan: every finding carries text this build will not quote") + return + } + val subject = if (wanted.size == 1) "1 vulnerable dependency" else "${wanted.size} vulnerable dependencies" + inNewChat(PromptInNewChat.title("Plan", subject), text) + } + + private fun inNewChat(title: String, text: String) { + if (!PromptInNewChat.open(panel.project, title, text)) panel.session.send(text) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt new file mode 100644 index 00000000..40d8a5b7 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/bridge/ChatBridgeRouter.kt @@ -0,0 +1,30 @@ +package dev.lain.claudejb.controller.bridge + +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.view.window.JcefChatPanel + +internal class ChatBridgeRouter(panel: JcefChatPanel) { + + private val prompting = BridgePrompting(panel) + private val settings = BridgeSettings(panel) + private val cards = BridgeCards(panel) + private val diffs = BridgeDiffs(panel) + private val attachments = BridgeAttachments(panel) + private val controls = BridgeSessionControl(panel) + private val lifecycle = BridgeLifecycle(panel) + private val log = BridgeLog(panel) + + fun dispatch(json: String) { + when (val m = JcefBridge.parse(json)) { + is Msg.Prompting -> prompting.handle(m) + is Msg.Settings -> settings.handle(m) + is Msg.RequestCard -> cards.handle(m) + is Msg.Diffs -> diffs.handle(m) + is Msg.Attachments -> attachments.handle(m) + is Msg.SessionControl -> controls.handle(m) + is Msg.Lifecycle -> lifecycle.handle(m) + is Msg.Log -> log.handle(m) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/CleanSettings.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt similarity index 88% rename from src/main/kotlin/dev/lain/claudejb/ui/CleanSettings.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt index 5424de86..fcc7a8bc 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/CleanSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/CleanSettings.kt @@ -1,12 +1,12 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands import com.intellij.openapi.project.Project import com.intellij.openapi.ui.MessageDialogBuilder -import dev.lain.claudejb.permission.SecurityRule -import dev.lain.claudejb.settings.ClaudeSettings -import dev.lain.claudejb.settings.GuardAlertLog -import dev.lain.claudejb.settings.SecretStore -import dev.lain.claudejb.settings.SecuritySuspensions +import dev.lain.claudejb.model.permission.vocab.SecurityRule +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.SecretStore +import dev.lain.claudejb.model.settings.guard.GuardAlertLog +import dev.lain.claudejb.model.settings.guard.SecuritySuspensions internal object CleanSettings { @@ -57,8 +57,8 @@ internal object CleanSettings { } private fun repaint() { - JcefChatPanel.pushStateToAll() - JcefChatPanel.pushSettingsMenuToAll() + LivePanels.pushState() + LivePanels.pushSettingsMenu() } private fun confirm(project: Project, title: String, body: String) = MessageDialogBuilder diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GuardPromptedActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/GuardPromptedActions.kt similarity index 93% rename from src/main/kotlin/dev/lain/claudejb/ui/GuardPromptedActions.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/GuardPromptedActions.kt index dc0190d7..7ef55a41 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GuardPromptedActions.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/GuardPromptedActions.kt @@ -1,7 +1,8 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands -import dev.lain.claudejb.permission.SecurityRule -import dev.lain.claudejb.settings.GuardAlert +import dev.lain.claudejb.model.permission.vocab.SecurityRule +import dev.lain.claudejb.model.permission.vocab.blockedWhy +import dev.lain.claudejb.model.settings.guard.GuardAlert internal object GuardPromptedActions { diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GuardWhitelistPrompt.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/GuardWhitelistPrompt.kt similarity index 59% rename from src/main/kotlin/dev/lain/claudejb/ui/GuardWhitelistPrompt.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/GuardWhitelistPrompt.kt index 6384608f..dd548e8b 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GuardWhitelistPrompt.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/GuardWhitelistPrompt.kt @@ -1,8 +1,9 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands import com.intellij.openapi.project.Project import com.intellij.openapi.ui.MessageDialogBuilder -import dev.lain.claudejb.permission.SecurityRule +import dev.lain.claudejb.model.permission.vocab.SecurityRule +import dev.lain.claudejb.model.permission.vocab.blockedWhy internal object GuardWhitelistPrompt { @@ -18,7 +19,7 @@ internal object GuardWhitelistPrompt { private fun body(rule: SecurityRule, command: String) = "$command\n\n" + "${rule.label} stopped this because ${rule.blockedWhy.replaceFirstChar { it.lowercase() }}\n\n" + - "Whitelisting it means that exact command runs without a card, in this project, until you remove " + - "it from Settings ▸ Claude Code Security. Every other rule still judges it, and every other " + - "command is unaffected." + "Whitelisting it means every command that starts with this text runs without a card, in this project, " + + "until you remove it from Settings ▸ Claude Code Security. Every other rule still judges it, and " + + "every other command is unaffected." } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt new file mode 100644 index 00000000..c0dc0593 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/LivePanels.kt @@ -0,0 +1,25 @@ +package dev.lain.claudejb.controller.commands + +import dev.lain.claudejb.view.window.JcefChatPanel +import java.util.concurrent.CopyOnWriteArrayList + +internal object LivePanels { + + private val panels = CopyOnWriteArrayList() + + fun add(panel: JcefChatPanel) { + panels += panel + } + + fun remove(panel: JcefChatPanel) { + panels -= panel + } + + fun pushTheme() = panels.forEach { it.pushTheme() } + + fun pushSession() = panels.forEach { it.pushSession() } + + fun pushSettingsMenu() = panels.forEach { it.pushSettingsMenu() } + + fun pushState() = panels.forEach { it.pushMetaState() } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt similarity index 78% rename from src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt index 7d8c517b..52e701dc 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/OnboardingController.kt @@ -1,22 +1,23 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project -import dev.lain.claudejb.process.AccountProfile -import dev.lain.claudejb.process.ApiKeyApproval -import dev.lain.claudejb.process.AuthCli -import dev.lain.claudejb.process.BinaryInstall -import dev.lain.claudejb.process.ClaudeBinaryLocator -import dev.lain.claudejb.process.CredentialsVault -import dev.lain.claudejb.process.TerminalLauncher -import dev.lain.claudejb.session.ClaudeSession -import dev.lain.claudejb.session.LoginCoordinator -import dev.lain.claudejb.settings.ClaudeSettings -import dev.lain.claudejb.settings.Provider -import dev.lain.claudejb.settings.SecretStore -import dev.lain.claudejb.ui.jcef.JcefBridge +import dev.lain.claudejb.controller.process.BinaryInstall +import dev.lain.claudejb.controller.process.ClaudeBinaryLocator +import dev.lain.claudejb.controller.process.TerminalLauncher +import dev.lain.claudejb.controller.process.auth.AccountProfile +import dev.lain.claudejb.controller.process.auth.AuthCli +import dev.lain.claudejb.controller.process.credentials.ApiKeyApproval +import dev.lain.claudejb.controller.process.credentials.CredentialsVault +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.auth.LoginCoordinator +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.bridge.Msg +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.Provider +import dev.lain.claudejb.model.settings.SecretStore import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import javax.swing.Timer @@ -33,11 +34,11 @@ internal class OnboardingController( } private fun tick() { - ApplicationManager.getApplication().executeOnPooledThread { session.refreshBootState() } + ApplicationManager.getApplication().executeOnPooledThread { session.lifecycle.refreshBootState() } } fun onStateChanged() { - if (installLaunched && !session.binaryMissing) { + if (installLaunched && !session.lifecycle.binaryMissing) { installLaunched = false notifyInfo("Claude Code installed", "The claude binary was found — starting the session.") } @@ -45,43 +46,40 @@ internal class OnboardingController( fun dispose() = bootWatcher.stop() - fun handle(m: JcefBridge.Msg.SessionControl): Boolean { + fun handle(m: Msg.Onboarding) { when (m) { - is JcefBridge.Msg.InstallClaude -> runInstaller(m.method) + is Msg.InstallClaude -> runInstaller(m.method) - is JcefBridge.Msg.SetBinaryPath -> validateAndUseBinaryPath(m.path) + is Msg.SetBinaryPath -> validateAndUseBinaryPath(m.path) - JcefBridge.Msg.RecheckBinary -> recheckBinary(announceFailure = true) + Msg.RecheckBinary -> recheckBinary(announceFailure = true) - JcefBridge.Msg.LoginSubscription -> { + Msg.LoginSubscription -> { pushAuthState("waiting") session.login.start(LoginCoordinator.Mode.SUBSCRIPTION) } - JcefBridge.Msg.LoginConsole -> { + Msg.LoginConsole -> { pushAuthState("waiting") session.login.start(LoginCoordinator.Mode.CONSOLE) } - is JcefBridge.Msg.UseApiKey -> useApiKey(m.key) + is Msg.UseApiKey -> useApiKey(m.key) - is JcefBridge.Msg.SubmitLoginCode -> { + is Msg.SubmitLoginCode -> { pushAuthState("verifying") session.login.submitCode(m.code) } - JcefBridge.Msg.CancelLogin -> { + Msg.CancelLogin -> { session.login.cancelLogin() pushAuthState("idle") } - JcefBridge.Msg.DismissAuth -> session.dismissLoginCard() + Msg.DismissAuth -> session.lifecycle.dismissLoginCard() - JcefBridge.Msg.Logout -> logout() - - else -> return false + Msg.Logout -> logout() } - return true } private fun runInstaller(methodId: String) { @@ -116,7 +114,7 @@ internal class OnboardingController( private fun recheckBinary(announceFailure: Boolean) { ApplicationManager.getApplication().executeOnPooledThread { - session.refreshBootState() + session.lifecycle.refreshBootState() val found = ClaudeBinaryLocator.locate(ClaudeSettings.getInstance(project).claudePath) != null if (found || !announceFailure) return@executeOnPooledThread ApplicationManager.getApplication().invokeLater { @@ -164,7 +162,7 @@ internal class OnboardingController( } ClaudeSettings.getInstance(project).setProviderApiKey(Provider.ANTHROPIC, trimmed) ClaudeSettings.getInstance(project).signedOut = false - session.dismissLoginCard() + session.lifecycle.dismissLoginCard() session.restart() } } diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt new file mode 100644 index 00000000..4a2e8a51 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/PromptInNewChat.kt @@ -0,0 +1,32 @@ +package dev.lain.claudejb.controller.commands + +import com.intellij.openapi.project.Project +import dev.lain.claudejb.view.window.ClaudeToolWindowFactory + +internal object PromptInNewChat { + + fun open(project: Project, title: String, prompt: String): Boolean { + val commands = ClaudeToolWindowFactory.chatTabs(project)?.commands ?: return false + commands.newChatWith(title, prompt) + return true + } + + fun title(action: String, subject: String): String { + val clean = subject + .map { if (isRenderable(it)) it else ' ' } + .joinToString("") + .replace(RUN_OF_SPACES, " ") + .trim() + if (clean.isEmpty()) return action + return action + ": " + TabSessionCommands.truncate(clean, SUBJECT_MAX) + } + + private fun isRenderable(ch: Char): Boolean = + !Character.isISOControl(ch) && Character.getType(ch) !in SEPARATORS + + private val RUN_OF_SPACES = Regex(" {2,}") + + private val SEPARATORS = setOf(Character.LINE_SEPARATOR.toInt(), Character.PARAGRAPH_SEPARATOR.toInt()) + + private const val SUBJECT_MAX = 32 +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/SessionDiffAction.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt similarity index 78% rename from src/main/kotlin/dev/lain/claudejb/ui/SessionDiffAction.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt index 3231882a..e65b3b45 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/SessionDiffAction.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/SessionDiffAction.kt @@ -1,16 +1,18 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages -import dev.lain.claudejb.diff.DiffPresenter -import dev.lain.claudejb.session.ClaudeSession -import dev.lain.claudejb.session.WorkspaceDiff -import dev.lain.claudejb.session.WorkspaceDiffReview +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.control.WorkspaceDiff +import dev.lain.claudejb.controller.session.diff.WorkspaceDiffReview +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.view.diff.DiffEditors +import dev.lain.claudejb.view.window.ChatTabsPanel import java.io.File internal class SessionDiffAction(private val project: Project, private val tabs: ChatTabsPanel) : @@ -41,10 +43,7 @@ internal class SessionDiffAction(private val project: Project, private val tabs: ?.takeIf { DiffPresenter.isWithinRoot(it, root) } ?.let { runCatching { File(it).readText() }.getOrNull() } } - ApplicationManager.getApplication().invokeLater( - { if (!project.isDisposed) open(diff, sides) }, - ModalityState.any(), - ) + edt(project) { open(diff, sides) } } } @@ -65,11 +64,11 @@ internal class SessionDiffAction(private val project: Project, private val tabs: if (proceed != Messages.YES) return } sides.forEach { side -> - DiffPresenter.openTextDiff( + DiffEditors.openTextDiff( project = project, path = side.path, - base = DiffPresenter.TextSide(WorkspaceDiffReview.baseLabel(side, diff.baseLabel), side.base.orEmpty()), - current = DiffPresenter.TextSide("Now: ${File(side.path).name}", side.current), + base = DiffEditors.TextSide(WorkspaceDiffReview.baseLabel(side, diff.baseLabel), side.base.orEmpty()), + current = DiffEditors.TextSide("Now: ${File(side.path).name}", side.current), ) } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/TabSessionCommands.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt similarity index 80% rename from src/main/kotlin/dev/lain/claudejb/ui/TabSessionCommands.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt index 9d25cd10..90e15dae 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/TabSessionCommands.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/TabSessionCommands.kt @@ -1,20 +1,22 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.SimpleListCellRenderer -import dev.lain.claudejb.session.ChatSessionManager -import dev.lain.claudejb.session.ClaudeSession -import dev.lain.claudejb.session.EntryDTO -import dev.lain.claudejb.session.SessionHistory -import dev.lain.claudejb.session.SessionRef -import dev.lain.claudejb.session.SessionStore -import dev.lain.claudejb.session.SessionTitleReader -import dev.lain.claudejb.session.SessionTranscriptReader -import dev.lain.claudejb.settings.ClaudeSettings +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.history.SessionHistory +import dev.lain.claudejb.model.session.history.SessionListing +import dev.lain.claudejb.model.session.history.SessionStore +import dev.lain.claudejb.model.session.history.SessionTitleReader +import dev.lain.claudejb.model.session.history.SessionTranscriptReader +import dev.lain.claudejb.model.session.transcript.EntryDTO +import dev.lain.claudejb.model.session.transcript.SessionRef +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.view.window.ChatTabsPanel import javax.swing.JList internal class TabSessionCommands( @@ -27,6 +29,13 @@ internal class TabSessionCommands( fun newChat() = openChat(ChatSessionManager.getInstance(project).create()) + fun newChatWith(title: String, prompt: String) { + val session = ChatSessionManager.getInstance(project).create() + session.title = title + openChat(session) + session.send(prompt) + } + private fun activeSession(): ClaudeSession? = tabs.selectedChat?.session private data class RestoredSession(val id: String, val title: String?, val entries: List) @@ -40,7 +49,7 @@ internal class TabSessionCommands( ApplicationManager.getApplication().executeOnPooledThread { val ids = SessionHistory.getInstance(project).openSessions() .filter { SessionStore.exists(it) } - .ifEmpty { listOfNotNull(SessionTranscriptReader.listSessions(project).firstOrNull()?.sessionId) } + .ifEmpty { listOfNotNull(SessionListing.list(project).firstOrNull()?.sessionId) } val restored = ids .map { RestoredSession( @@ -53,18 +62,18 @@ internal class TabSessionCommands( ), ) } - ApplicationManager.getApplication().invokeLater({ + edt { if (restored.isEmpty()) { openChat(manager.create()) } else { for (r in restored) { val s = manager.create() s.title = r.title ?: s.title - s.restore(r.id, r.entries) + s.persistence.restore(r.id, r.entries) openChat(s) } } - }, ModalityState.any()) + } } } @@ -79,7 +88,7 @@ internal class TabSessionCommands( null, )?.trim().orEmpty() if (input.isEmpty() || input == session.title) return - session.renameSession(input) + session.persistence.rename(input) } fun forkActiveSession() { @@ -95,23 +104,23 @@ internal class TabSessionCommands( SessionTranscriptReader.DEFAULT_RESTORE_CAP, project.basePath, ) - ApplicationManager.getApplication().invokeLater({ + edt { val manager = ChatSessionManager.getInstance(project) val s = manager.create() s.title = "$sourceTitle (fork)" - s.restore(sourceId, entries) + s.persistence.restore(sourceId, entries, fork = true) openChat(s) - }, ModalityState.any()) + } } } fun openPreviousSession() { ApplicationManager.getApplication().executeOnPooledThread { - val refs = SessionTranscriptReader.listSessions(project) - ApplicationManager.getApplication().invokeLater({ + val refs = SessionListing.list(project) + edt { if (refs.isEmpty()) { Messages.showInfoMessage(project, "No previous sessions have been saved yet.", "Claude Code") - return@invokeLater + return@edt } JBPopupFactory.getInstance() .createPopupChooserBuilder(refs) @@ -124,19 +133,19 @@ internal class TabSessionCommands( SessionTranscriptReader.DEFAULT_RESTORE_CAP, project.basePath, ) - ApplicationManager.getApplication().invokeLater({ + edt { val manager = ChatSessionManager.getInstance(project) val s = manager.create() s.title = ref.title - s.restore(ref.sessionId, entries) + s.persistence.restore(ref.sessionId, entries) openChat(s) - }, ModalityState.any()) + } } } .setRequestFocus(true) .createPopup() .showCenteredInCurrentWindow(project) - }, ModalityState.any()) + } } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GitActionCatalog.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitActionCatalog.kt similarity index 62% rename from src/main/kotlin/dev/lain/claudejb/ui/GitActionCatalog.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitActionCatalog.kt index 14fb7ee8..fe0ead5a 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GitActionCatalog.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitActionCatalog.kt @@ -1,4 +1,10 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands.git + +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.git.ForgeViewNavigator +import dev.lain.claudejb.controller.git.GitLogNavigator +import java.awt.datatransfer.StringSelection internal object GitActionCatalog { @@ -22,17 +28,41 @@ internal object GitActionCatalog { val hasChangedFile: Boolean = false, ) + interface PromptSubject { + val changes: List + val changedFile: String? + } + + sealed interface Behaviour { + data object InitRepository : Behaviour + + class Prompt(val text: (PromptSubject, String) -> String?) : Behaviour + + data class Ide(val actionId: String) : Behaviour + + class Host(val run: (Project, String) -> Boolean) : Behaviour + } + data class GitAction( val id: String, val label: String, val hint: String, - val kind: Kind, + val behaviour: Behaviour, val requires: Requires, - val ideActionId: String? = null, val group: String, val startsBlock: Boolean = false, ) { + val kind: Kind + get() = when (behaviour) { + Behaviour.InitRepository -> Kind.DIRECT + is Behaviour.Prompt -> Kind.PROMPT + is Behaviour.Ide -> Kind.IDE + is Behaviour.Host -> Kind.HOST + } + + val ideActionId: String? get() = (behaviour as? Behaviour.Ide)?.actionId + val takesCommit: Boolean get() = requires == Requires.COMMIT } @@ -41,7 +71,7 @@ internal object GitActionCatalog { id = "init", label = "Initialize repository", hint = "Run git init -b main in the project root", - kind = Kind.DIRECT, + behaviour = Behaviour.InitRepository, requires = Requires.NO_REPO, group = "Repository", ), @@ -49,7 +79,9 @@ internal object GitActionCatalog { id = "commit", label = "Commit with Claude", hint = "Claude stages the changes and writes the commit message", - kind = Kind.PROMPT, + behaviour = Behaviour.Prompt { subject, _ -> + subject.changes.takeIf { it.isNotEmpty() }?.let(GitPromptedActions::commitPrompt) + }, requires = Requires.CHANGES, group = "Ask Claude", ), @@ -57,41 +89,54 @@ internal object GitActionCatalog { id = "revertFile", label = "Revert this file with Claude", hint = "Restore the file open in the editor to its committed state", - kind = Kind.PROMPT, + behaviour = Behaviour.Prompt { subject, _ -> subject.changedFile?.let(GitPromptedActions::revertFilePrompt) }, requires = Requires.CHANGED_FILE, group = "Ask Claude", ), - commitAction("commitDiff", "View diff", "Show this commit and its changes in the IDE", Kind.HOST), - commitAction("commitCopyHash", "Copy hash", "Put the full commit hash on the clipboard", Kind.HOST), + commitAction( + "commitDiff", + "View diff", + "Show this commit and its changes in the IDE", + Behaviour.Host { project, hash -> GitLogNavigator.showCommit(project, hash, focus = true) }, + ), + commitAction( + "commitCopyHash", + "Copy hash", + "Put the full commit hash on the clipboard", + Behaviour.Host { _, hash -> + CopyPasteManager.getInstance().setContents(StringSelection(hash)) + true + }, + ), commitAction( "commitRevertToBranch", "Revert to this commit on a new branch", "Ask Claude to create a branch at this commit — the branch you are on does not move", - Kind.PROMPT, + Behaviour.Prompt { _, hash -> GitPromptedActions.revertToCommitOnNewBranchPrompt(hash) }, ), commitAction( "commitRevert", "Revert just this commit", "Ask Claude to record a new commit undoing this one, keeping the history", - Kind.PROMPT, + Behaviour.Prompt { _, hash -> GitPromptedActions.revertCommitPrompt(hash) }, ), commitAction( "commitBranch", "Create branch from this commit", "Ask Claude to start a branch at this commit — the branch you are on does not move", - Kind.PROMPT, + Behaviour.Prompt { _, hash -> GitPromptedActions.createBranchFromCommitPrompt(hash) }, ), commitAction( "commitTag", "Create tag from this commit", "Ask Claude to put a tag on this commit", - Kind.PROMPT, + Behaviour.Prompt { _, hash -> GitPromptedActions.createTagFromCommitPrompt(hash) }, ), GitAction( id = "forgeView", label = "Requests", hint = "Open the IDE's own pull or merge request view", - kind = Kind.HOST, + behaviour = Behaviour.Host { project, _ -> ForgeViewNavigator.open(project, focus = true) }, requires = Requires.REPO, group = "Repository", ), @@ -99,7 +144,7 @@ internal object GitActionCatalog { id = "gitLog", label = "Git log", hint = "Open the IDE's Git log", - kind = Kind.HOST, + behaviour = Behaviour.Host { project, _ -> GitLogNavigator.showLog(project, focus = true) }, requires = Requires.REPO, group = "Repository", ), @@ -132,11 +177,11 @@ internal object GitActionCatalog { fun ideActions(): List = ACTIONS.filter { it.kind == Kind.IDE } - private fun commitAction(id: String, label: String, hint: String, kind: Kind) = GitAction( + private fun commitAction(id: String, label: String, hint: String, behaviour: Behaviour) = GitAction( id = id, label = label, hint = hint, - kind = kind, + behaviour = behaviour, requires = Requires.COMMIT, group = "Commit", ) @@ -147,16 +192,13 @@ internal object GitActionCatalog { hint: String, actionId: String, startsBlock: Boolean = false, - requires: Requires = Requires.REPO, - group: String = "IDE actions", ) = GitAction( id = id, label = label, hint = hint, - kind = Kind.IDE, - requires = requires, - ideActionId = actionId, - group = group, + behaviour = Behaviour.Ide(actionId), + requires = Requires.REPO, + group = "IDE actions", startsBlock = startsBlock, ) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GitChatConversation.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitChatConversation.kt similarity index 73% rename from src/main/kotlin/dev/lain/claudejb/ui/GitChatConversation.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitChatConversation.kt index cf3e1125..215aec3d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GitChatConversation.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitChatConversation.kt @@ -1,22 +1,23 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands.git import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import dev.lain.claudejb.session.ChatSessionManager -import dev.lain.claudejb.session.ClaudeSession -import dev.lain.claudejb.session.SessionListener -import dev.lain.claudejb.session.TranscriptEntry -import dev.lain.claudejb.session.TranscriptModel -import dev.lain.claudejb.settings.ClaudeSettings -import dev.lain.claudejb.ui.jcef.JcefBridge -import dev.lain.claudejb.ui.jcef.JcefCardPayload -import dev.lain.claudejb.ui.jcef.JcefTranscriptPayload +import dev.lain.claudejb.controller.session.ChatSessionManager +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.SessionListener +import dev.lain.claudejb.model.bridge.JcefBridge +import dev.lain.claudejb.model.session.launch.LaunchOptions +import dev.lain.claudejb.model.session.transcript.TranscriptEntry +import dev.lain.claudejb.model.session.transcript.TranscriptModel +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.view.payload.chat.JcefCardPayload +import dev.lain.claudejb.view.payload.chat.JcefTranscriptPayload import java.util.concurrent.CopyOnWriteArrayList @Service(Service.Level.PROJECT) internal class GitChatConversation(private val project: Project) : - SessionListener, TranscriptModel.Listener, ChatSessionManager.Listener { + SessionListener, TranscriptModel.Listener { internal interface View { fun drawGitChat(payload: String?) @@ -29,7 +30,7 @@ internal class GitChatConversation(private val project: Project) : private var attached: ClaudeSession? = null init { - ChatSessionManager.getInstance(project).addListener(this) + ChatSessionManager.getInstance(project).addListener(::broadcast) } private fun current(): ClaudeSession? { @@ -53,7 +54,7 @@ internal class GitChatConversation(private val project: Project) : current()?.let { return it } val chat = ChatSessionManager.getInstance(project).gitChatOrCreate() current() - ClaudeSettings.getInstance(project).applyTo(chat) + chat.settings.adopt(LaunchOptions.from(ClaudeSettings.getInstance(project))) chat.start() broadcast() return chat @@ -65,7 +66,7 @@ internal class GitChatConversation(private val project: Project) : } fun interrupt() { - current()?.interrupt() + current()?.turnControl?.interrupt() } fun permissionGroup(): List { @@ -86,8 +87,8 @@ internal class GitChatConversation(private val project: Project) : val chat = current() ?: return null return buildString { append("{\"running\":").append(chat.isRunning()) - append(",\"starting\":").append(chat.isStarting()) - append(",\"turnActive\":").append(chat.turnActive) + append(",\"starting\":").append(chat.lifecycle.isStarting()) + append(",\"turnActive\":").append(chat.turn.active) append(",\"rows\":").append(JcefTranscriptPayload.batchJson(rows(chat))) append("}") } @@ -109,8 +110,6 @@ internal class GitChatConversation(private val project: Project) : override fun onStateChanged() = broadcast() - override fun onSessionsChanged() = broadcast() - override fun onPermissionsChanged() { views.forEach { it.refreshGitChatPermissions() } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GitContextActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActions.kt similarity index 92% rename from src/main/kotlin/dev/lain/claudejb/ui/GitContextActions.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActions.kt index 0ce842c6..820213a5 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GitContextActions.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitContextActions.kt @@ -1,19 +1,20 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands.git import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.ui.SimpleListCellRenderer -import dev.lain.claudejb.context.EditorContextProvider -import dev.lain.claudejb.git.GitCommitInfo -import dev.lain.claudejb.git.GitHistoryService -import dev.lain.claudejb.git.GitLogNavigator +import dev.lain.claudejb.controller.commands.TabSessionCommands +import dev.lain.claudejb.controller.context.EditorContextProvider +import dev.lain.claudejb.controller.git.GitHistoryService +import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.model.git.GitCommitInfo +import dev.lain.claudejb.util.edt import javax.swing.JList internal object GitContextActions { @@ -61,9 +62,7 @@ internal object GitContextActions { val branch = history.currentBranch() val head = history.headRevision() val commits = history.recentCommits() - ApplicationManager.getApplication().invokeLater({ - if (!project.isDisposed) present(branch, head, commits) - }, ModalityState.any()) + edt(project) { present(branch, head, commits) } } } @@ -132,7 +131,7 @@ internal object GitContextActions { } private fun openLog(project: Project) { - if (!GitLogNavigator.showLog(project)) { + if (!GitLogNavigator.showLog(project, focus = true)) { Messages.showInfoMessage(project, NO_LOG, DIALOG_TITLE) } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GitIdeMenu.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt similarity index 93% rename from src/main/kotlin/dev/lain/claudejb/ui/GitIdeMenu.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt index d8a0fc72..c94500c1 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GitIdeMenu.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIdeMenu.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands.git import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager @@ -6,7 +6,7 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Separator -import dev.lain.claudejb.git.GitAvailability +import dev.lain.claudejb.controller.git.GitAvailability internal object GitIdeMenu { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitInit.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitInit.kt new file mode 100644 index 00000000..a5e1da26 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitInit.kt @@ -0,0 +1,73 @@ +package dev.lain.claudejb.controller.commands.git + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.openapi.vcs.VcsDirectoryMapping +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import dev.lain.claudejb.controller.git.GitAvailability +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.util.logger +import java.io.File + +internal class GitInit(private val project: Project) { + + fun projectRootWithoutRepository(): File? = + project.basePath?.let(::File)?.takeIf { it.isDirectory && !File(it, DOT_GIT).exists() } + + fun run(root: File, done: (Boolean) -> Unit) { + ApplicationManager.getApplication().executeOnPooledThread { + val ok = initRepository(root) + edt(project) { + done(ok) + if (ok) registerRepository(root) + } + } + } + + private fun initRepository(root: File): Boolean { + if (runGit(root, "init", "-b", GitPromptedActions.INITIAL_BRANCH)) return true + return runGit(root, "init") && runGit(root, "symbolic-ref", "HEAD", "refs/heads/${GitPromptedActions.INITIAL_BRANCH}") + } + + private fun runGit(root: File, vararg args: String): Boolean { + val output = runCatching { + val cmd = GeneralCommandLine(listOf(GIT) + args) + .withWorkingDirectory(root.toPath()) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + CapturingProcessHandler(cmd).runProcess(GIT_TIMEOUT_MS, true) + }.getOrElse { + LOG.warn("Could not run `git ${args.joinToString(" ")}` in $root", it) + return false + } + if (output.isTimeout || output.exitCode != 0) { + LOG.warn("`git ${args.joinToString(" ")}` failed in $root (exit ${output.exitCode}): ${output.stderr.trim()}") + return false + } + return true + } + + private fun registerRepository(root: File) { + val dir = LocalFileSystem.getInstance().findFileByPath(root.path) ?: return + VfsUtil.markDirtyAndRefresh(true, true, true, dir) + if (!GitAvailability.isGitPluginEnabled()) return + val manager = ProjectLevelVcsManager.getInstance(project) + val existing = manager.getDirectoryMappings() + if (existing.any { it.vcs == GIT_VCS_NAME && it.directory == root.path }) return + manager.setDirectoryMappings(existing + VcsDirectoryMapping(root.path, GIT_VCS_NAME)) + } + + private companion object { + private val LOG = logger() + + private const val GIT_VCS_NAME = "Git" + + private const val GIT = "git" + private const val DOT_GIT = ".git" + + private const val GIT_TIMEOUT_MS = 15_000 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt new file mode 100644 index 00000000..d4d356c1 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitIntegration.kt @@ -0,0 +1,180 @@ +package dev.lain.claudejb.controller.commands.git + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.commands.git.GitActionCatalog.Behaviour +import dev.lain.claudejb.controller.context.EditorContextProvider +import dev.lain.claudejb.controller.git.GitAvailability +import dev.lain.claudejb.controller.git.GitHistoryService +import dev.lain.claudejb.controller.session.AttentionLanding +import dev.lain.claudejb.controller.session.AttentionReason +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.controller.session.SessionListener +import dev.lain.claudejb.model.git.GitCommitInfo +import dev.lain.claudejb.model.git.GitLogScope +import dev.lain.claudejb.util.edt +import dev.lain.claudejb.util.logger +import dev.lain.claudejb.view.git.JcefGitData +import dev.lain.claudejb.view.git.JcefGitData.ActionState + +@Service(Service.Level.PROJECT) +internal class GitIntegration(private val project: Project) { + + private var snapshot: JcefGitData.Snapshot? = null + + private val states = mutableMapOf() + + private var inFlight: List<() -> Unit>? = null + private val queued = ArrayList<() -> Unit>() + + fun snapshot(): JcefGitData.Snapshot? = snapshot + + fun refresh(onChanged: () -> Unit) { + if (inFlight != null) { + queued += onChanged + return + } + collectFor(listOf(onChanged)) + } + + private fun collectFor(waiting: List<() -> Unit>) { + inFlight = waiting + val openFile = EditorContextProvider.currentFilePath(project) + ApplicationManager.getApplication().executeOnPooledThread { + val collected = runCatching { collect(openFile) }.getOrElse { + LOG.warn("Git snapshot collection failed for ${project.name}", it) + null + } + edt(project) { + inFlight = null + if (collected != null) snapshot = collected + waiting.forEach { it() } + if (queued.isNotEmpty()) collectFor(queued.toList().also { queued.clear() }) + } + } + } + + fun perform(id: String, hash: String, chat: () -> ClaudeSession, onChanged: () -> Unit) { + val action = GitActionCatalog.byId(id) ?: run { + LOG.warn("Git view asked for an unknown action id: $id") + return + } + if (action.takesCommit && !GitActionCatalog.isCommitHash(hash)) { + LOG.warn("Git view asked for '$id' with a value that is not a commit hash; refusing") + settle(action.id, ActionState.FAILED, onChanged) + return + } + when (val behaviour = action.behaviour) { + Behaviour.InitRepository -> initRepository(action.id, onChanged) + is Behaviour.Prompt -> runPrompt(action.id, behaviour, hash, chat, onChanged) + is Behaviour.Ide -> settle(action.id, IdeActionInvoker.invoke(project, behaviour.actionId, action.id), onChanged) + is Behaviour.Host -> settle(action.id, stateOf(behaviour.run(project, hash)), onChanged) + } + } + + private fun collect(openFilePath: String?): JcefGitData.Snapshot { + if (!GitAvailability.isGitPluginEnabled()) return JcefGitData.Snapshot(available = false) + val history = project.service() + val root = history.primaryRepositoryRoot() + if (root == null) { + return JcefGitData.Snapshot(available = true, actionStates = states.toMap()) + } + val changes = history.workingTreeChanges() + val branch = history.currentBranch() + return JcefGitData.Snapshot( + available = true, + repo = JcefGitData.Repo( + present = true, + branch = branch, + head = history.headRevision(), + root = root, + ), + changes = changes, + commits = history.recentCommits(limit = GRAPH_COMMIT_LIMIT, scope = GitLogScope.EVERY_LINE_OF_DEVELOPMENT), + refs = history.refs(), + changedFileOpen = relativeChangedFile(root, changes, openFilePath) != null, + conflicted = history.hasConflicts(), + actionStates = states.toMap(), + topology = history.branchTopology(), + ) + } + + private fun relativeChangedFile(root: String, changes: List, absolutePath: String?): String? { + val absolute = absolutePath ?: return null + return GitCommitInfo.relativize(root, absolute).takeIf { it in changes } + } + + private fun initRepository(id: String, onChanged: () -> Unit) { + val init = GitInit(project) + val root = init.projectRootWithoutRepository() + if (root == null) { + settle(id, ActionState.FAILED, onChanged) + return + } + states[id] = ActionState.RUNNING + onChanged() + init.run(root) { ok -> settle(id, stateOf(ok), onChanged) } + } + + private fun runPrompt(id: String, prompt: Behaviour.Prompt, hash: String, chat: () -> ClaudeSession, onChanged: () -> Unit) { + val text = subject()?.let { prompt.text(it, hash) } + if (text == null) { + settle(id, ActionState.FAILED, onChanged) + return + } + val session = chat() + states[id] = ActionState.RUNNING + onChanged() + session.addListener(TurnWatch(id, session, onChanged)) + session.send(text) + } + + private fun subject(): GitActionCatalog.PromptSubject? { + val history = project.service() + val root = history.primaryRepositoryRoot() ?: return null + return object : GitActionCatalog.PromptSubject { + override val changes: List by lazy { history.workingTreeChanges() } + override val changedFile: String? by lazy { + relativeChangedFile(root, changes, EditorContextProvider.currentFilePath(project)) + } + } + } + + private inner class TurnWatch( + private val id: String, + private val session: ClaudeSession, + private val onChanged: () -> Unit, + ) : SessionListener { + + private var started = false + + override fun onStateChanged() { + if (session.turn.active) started = true + } + + override fun onAttention(reason: AttentionReason, landing: AttentionLanding) { + if (!started) return + if (reason != AttentionReason.TURN_DONE && reason != AttentionReason.ERROR) return + session.removeListener(this) + settle(id, stateOf(reason != AttentionReason.ERROR), onChanged) + } + } + + private fun stateOf(done: Boolean): ActionState = if (done) ActionState.COMPLETED else ActionState.FAILED + + private fun settle(id: String, state: ActionState, onChanged: () -> Unit) { + states[id] = state + onChanged() + } + + companion object { + + fun getInstance(project: Project): GitIntegration = project.service() + + const val GRAPH_COMMIT_LIMIT = 100 + + private val LOG = logger() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/GitPromptedActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitPromptedActions.kt similarity index 96% rename from src/main/kotlin/dev/lain/claudejb/ui/GitPromptedActions.kt rename to src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitPromptedActions.kt index e602ada0..047c78e3 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/GitPromptedActions.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/GitPromptedActions.kt @@ -1,17 +1,14 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.commands.git -import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import dev.lain.claudejb.context.EditorContextProvider -import dev.lain.claudejb.git.GitAvailability -import dev.lain.claudejb.git.GitCommitInfo -import dev.lain.claudejb.git.GitHistoryService -import dev.lain.claudejb.session.ChatSessionManager -import dev.lain.claudejb.session.ClaudeSession +import dev.lain.claudejb.controller.context.EditorContextProvider +import dev.lain.claudejb.controller.git.GitHistoryService +import dev.lain.claudejb.controller.session.ClaudeSession +import dev.lain.claudejb.model.git.GitCommitInfo internal object GitPromptedActions { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt new file mode 100644 index 00000000..79b9e438 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/commands/git/IdeActionInvoker.kt @@ -0,0 +1,46 @@ +package dev.lain.claudejb.controller.commands.git + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.project.Project +import dev.lain.claudejb.util.logger +import dev.lain.claudejb.view.git.JcefGitData +import dev.lain.claudejb.view.window.ClaudeToolWindowFactory + +internal object IdeActionInvoker { + + private val LOG = logger() + + fun invoke(project: Project, actionId: String, gitActionId: String): JcefGitData.ActionState { + val target = ActionManager.getInstance().getAction(actionId) ?: run { + LOG.warn("This IDE has no action '$actionId'; the Git view's '$gitActionId' button does nothing") + return JcefGitData.ActionState.FAILED + } + val component = ClaudeToolWindowFactory.contextComponent(project) + val context = if (component != null) { + DataManager.getInstance().getDataContext(component) + } else { + SimpleDataContext.getProjectContext(project) + } + val event = AnActionEvent.createEvent( + target, + context, + null, + ActionPlaces.TOOLWINDOW_CONTENT, + ActionUiKind.TOOLBAR, + null, + ) + ActionUtil.updateAction(target, event) + if (!event.presentation.isEnabled || !event.presentation.isVisible) { + LOG.warn("The IDE refused '$actionId' in this context (enabled=${event.presentation.isEnabled})") + return JcefGitData.ActionState.FAILED + } + val performed = ActionUtil.performAction(target, event).isPerformed + return if (performed) JcefGitData.ActionState.COMPLETED else JcefGitData.ActionState.FAILED + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/context/ClipboardCli.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt similarity index 96% rename from src/main/kotlin/dev/lain/claudejb/context/ClipboardCli.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt index 34fcbe54..7a2c536e 100644 --- a/src/main/kotlin/dev/lain/claudejb/context/ClipboardCli.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/ClipboardCli.kt @@ -1,6 +1,8 @@ -package dev.lain.claudejb.context +package dev.lain.claudejb.controller.context -import com.intellij.openapi.diagnostic.thisLogger +import dev.lain.claudejb.model.context.Attachment +import dev.lain.claudejb.model.context.ImageAttachments +import dev.lain.claudejb.util.thisLogger import java.io.File import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @@ -156,7 +158,7 @@ internal object ClipboardCli { val bytes = try { reader.get(3, TimeUnit.SECONDS) } catch (e: TimeoutException) { - log.debug("Clipboard helper ${cmd.firstOrNull()} timed out after 3s; killing it", e) + log.debug { "Clipboard helper ${cmd.firstOrNull()} timed out after 3s; killing it: $e" } proc.destroyForcibly() reader.cancel(true) return@runCatching null diff --git a/src/main/kotlin/dev/lain/claudejb/context/EditorContextProvider.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt similarity index 96% rename from src/main/kotlin/dev/lain/claudejb/context/EditorContextProvider.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt index f7445327..85ec35f4 100644 --- a/src/main/kotlin/dev/lain/claudejb/context/EditorContextProvider.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/EditorContextProvider.kt @@ -1,8 +1,10 @@ -package dev.lain.claudejb.context +package dev.lain.claudejb.controller.context import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.Project +import dev.lain.claudejb.model.context.Attachment +import dev.lain.claudejb.model.context.ImageAttachments import java.awt.Toolkit import java.awt.datatransfer.DataFlavor diff --git a/src/main/kotlin/dev/lain/claudejb/context/FilePickerHelper.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/FilePickerHelper.kt similarity index 97% rename from src/main/kotlin/dev/lain/claudejb/context/FilePickerHelper.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/FilePickerHelper.kt index dca23fd4..27da9f92 100644 --- a/src/main/kotlin/dev/lain/claudejb/context/FilePickerHelper.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/FilePickerHelper.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.context +package dev.lain.claudejb.controller.context import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.impl.EditorHistoryManager diff --git a/src/main/kotlin/dev/lain/claudejb/ui/LinkNavigator.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt similarity index 87% rename from src/main/kotlin/dev/lain/claudejb/ui/LinkNavigator.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt index ec8106e7..1c763450 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/LinkNavigator.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkNavigator.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.context import com.intellij.ide.BrowserUtil import com.intellij.ide.actions.RevealFileAction @@ -10,27 +10,37 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.ToolWindowId import com.intellij.openapi.wm.ToolWindowManager -import dev.lain.claudejb.diff.DiffPresenter +import dev.lain.claudejb.controller.mcp.IdePlaces +import dev.lain.claudejb.model.diff.DiffPresenter import java.io.File import java.net.URLDecoder internal class LinkNavigator(private val project: Project) { + private companion object { + const val JB = "jb://" + } + fun open(url: String) { val u = url.trim() when { u.lowercase().startsWith("https://") -> BrowserUtil.browse(u) - u.startsWith("jb://open") -> openJbLink(u) + u.startsWith(JB) -> openJbLink(u) LinkResolver.isFilePathHref(u) -> openPath(u.substringBefore('#').trim()) } } private fun openJbLink(url: String) { + val verb = url.removePrefix(JB).substringBefore('?') val params = url.substringAfter('?', "").split('&').mapNotNull { val k = it.substringBefore('=', "") val v = it.substringAfter('=', "") if (k.isEmpty()) null else k to runCatching { URLDecoder.decode(v, Charsets.UTF_8) }.getOrDefault(v) }.toMap() + if (verb != "open") { + IdePlaces(project).open(verb, params) + return + } val raw = params["file"] ?: return openPath(raw, params["line"]?.toIntOrNull() ?: 1) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkResolver.kt similarity index 97% rename from src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/LinkResolver.kt index db56b0fa..61536c60 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/LinkResolver.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/LinkResolver.kt @@ -1,19 +1,21 @@ -package dev.lain.claudejb.ui +package dev.lain.claudejb.controller.context import com.intellij.navigation.ChooseByNameContributor import com.intellij.navigation.NavigationItem import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope -import dev.lain.claudejb.diff.DiffPresenter +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.util.logger import java.io.File object LinkResolver { + private val log = logger() + data class Resolved(val token: String, val path: String, val line: Int?) private const val MAX_PATHS = 60 @@ -177,7 +179,7 @@ object LinkResolver { } catch (e: ProcessCanceledException) { throw e } catch (e: Exception) { - Logger.getInstance(LinkResolver::class.java).debug("could not resolve the symbol '$name' to a link", e) + log.debug { "could not resolve the symbol '$name' to a link: $e" } null } diff --git a/src/main/kotlin/dev/lain/claudejb/context/ProjectTree.kt b/src/main/kotlin/dev/lain/claudejb/controller/context/ProjectTree.kt similarity index 97% rename from src/main/kotlin/dev/lain/claudejb/context/ProjectTree.kt rename to src/main/kotlin/dev/lain/claudejb/controller/context/ProjectTree.kt index 95f834a4..1f34323e 100644 --- a/src/main/kotlin/dev/lain/claudejb/context/ProjectTree.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/context/ProjectTree.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.context +package dev.lain.claudejb.controller.context import com.intellij.openapi.application.ReadAction import com.intellij.openapi.fileTypes.FileTypeRegistry @@ -9,7 +9,8 @@ import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VFileProperty import com.intellij.openapi.vfs.VirtualFile -import dev.lain.claudejb.diff.DiffPresenter +import dev.lain.claudejb.model.context.ImageAttachments +import dev.lain.claudejb.model.diff.DiffPresenter import java.io.File internal object ProjectTree { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt b/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt new file mode 100644 index 00000000..5973090b --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/db/DbGateway.kt @@ -0,0 +1,197 @@ +package dev.lain.claudejb.controller.db + +import com.intellij.execution.services.ServiceViewContributor +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.project.Project +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.util.PluginIds +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +internal class DbGateway(private val project: Project) { + + class Connection(val name: String, val kind: String, val url: String) + + class Table(val schema: String, val name: String, val kind: String, val columns: Int) + + class Column(val name: String, val type: String, val nullable: Boolean, val primary: Boolean) + + class Rows(val columns: List, val rows: List>, val updated: Int, val truncated: Boolean) + + private fun requireDatabase() { + if (!isAvailable()) throw ToolException(MISSING) + } + + fun connections(): List { + requireDatabase() + return dataSources().map { source -> + Connection(name(source), text { call(source, "getDbms") }, redacted(text { call(delegate(source), "getUrl") })) + } + } + + fun tables(connection: String): List { + requireDatabase() + return tablesOf(dataSource(connection)).map { table -> + Table(schema(table), name(table), text { call(table, "getKind") }.lowercase(), columnsOf(table).size) + } + } + + fun columns(connection: String, table: String): List { + requireDatabase() + val found = tablesOf(dataSource(connection)).filter { matches(it, table) } + if (found.isEmpty()) throw ToolException("no table named $table in $connection; db_schema without table lists them") + if (found.size > 1) throw ToolException("$table is ambiguous; qualify it: ${found.joinToString { qualified(it) }}") + return columnsOf(found.single()).map { column -> + Column( + name(column), + text { typeName(call(column, "getDataType")) }, + !flag { call(column, "isNotNull") }, + flag { static(dasUtil(), "isPrimary", column) }, + ) + } + } + + fun query(connection: String, sql: String, maxRows: Int, timeoutSeconds: Int): Rows { + requireDatabase() + val source = dataSource(connection) + val builder = call(static(type(CONNECTION_MANAGER), "getInstance"), "build", project, delegate(source)) + runCatching { call(builder, "setAskPassword", false) } + val ref = call(builder, "create") + ?: throw ToolException("the IDE could not connect to $connection; connect it in the Database tool window first") + try { + val statement = call(call(call(ref, "get"), "getRemoteConnection"), "createStatement") + try { + call(statement, "setQueryTimeout", timeoutSeconds) + call(statement, "setMaxRows", maxRows + 1) + val produced = call(statement, "execute", sql) == true + return if (produced) rows(call(statement, "getResultSet"), maxRows) else updated(statement) + } finally { + runCatching { call(statement, "close") } + } + } finally { + runCatching { call(ref, "close") } + } + } + + private fun updated(statement: Any?): Rows = Rows(emptyList(), emptyList(), (call(statement, "getUpdateCount") as? Int) ?: 0, false) + + private fun rows(resultSet: Any?, maxRows: Int): Rows { + try { + val meta = call(resultSet, "getMetaData") + val width = (call(meta, "getColumnCount") as? Int) ?: 0 + val labels = (1..width).map { text { call(meta, "getColumnLabel", it) } } + val collected = ArrayList>() + while (collected.size <= maxRows && call(resultSet, "next") == true) { + collected += (1..width).map { text { call(resultSet, "getString", it) } } + } + return Rows(labels, collected.take(maxRows), 0, collected.size > maxRows) + } finally { + runCatching { call(resultSet, "close") } + } + } + + private fun dataSources(): List { + val facade = static(type(FACADE), "getInstance", project) + return items(call(facade, "getDataSources")) + } + + private fun dataSource(connection: String): Any { + val sources = dataSources() + return sources.firstOrNull { name(it).equals(connection, ignoreCase = true) } + ?: throw ToolException( + if (sources.isEmpty()) { + "the Database tool window has no data sources; add one there first" + } else { + "no data source named $connection; the data sources are ${sources.joinToString { name(it) }}" + }, + ) + } + + private fun delegate(source: Any): Any = + call(source, "getDelegateDataSource") ?: throw ToolException(notExposed("getDelegateDataSource")) + + private fun tablesOf(source: Any): List = items(static(dasUtil(), "getTables", source)) + + private fun columnsOf(table: Any): List = items(static(dasUtil(), "getColumns", table)) + + private fun items(iterable: Any?): List = (iterable as? Iterable<*>)?.toList().orEmpty().filterNotNull() + + private fun schema(table: Any): String = text { static(dasUtil(), "getSchema", table) } + + private fun qualified(table: Any): String = schema(table).let { if (it.isEmpty()) name(table) else "$it.${name(table)}" } + + private fun matches(table: Any, wanted: String): Boolean = + name(table).equals(wanted, ignoreCase = true) || qualified(table).equals(wanted, ignoreCase = true) + + private fun name(target: Any): String = text { call(target, "getName") } + + private fun dasUtil(): Class<*> = type(DAS_UTIL) + + private fun text(read: () -> Any?): String = runCatching(read).getOrNull()?.toString().orEmpty() + + private fun flag(read: () -> Any?): Boolean = runCatching(read).getOrNull() == true + + private fun typeName(dataType: Any?): Any? = dataType?.javaClass?.getField("typeName")?.get(dataType) + + private fun type(name: String): Class<*> = + loaders().firstNotNullOfOrNull { loader -> runCatching { loader.loadClass(name) }.getOrNull() } + ?: throw ToolException(notExposed(name)) + + private fun loaders(): List { + val modules = ServiceViewContributor.CONTRIBUTOR_EP_NAME.extensionList + .filter { it.javaClass.name.startsWith(PACKAGE) } + .map { it.javaClass.classLoader } + return (listOf(javaClass.classLoader) + modules).distinct() + } + + private fun static(type: Class<*>, name: String, vararg args: Any?): Any? { + val method = type.methods.firstOrNull { it.name == name && Modifier.isStatic(it.modifiers) && accepts(it, args) } + ?: throw ToolException(notExposed("${type.simpleName}.$name")) + return invoke(method, null, args) + } + + private fun call(target: Any?, name: String, vararg args: Any?): Any? { + val receiver = target ?: throw ToolException(notExposed(name)) + val method = receiver.javaClass.methods.firstOrNull { it.name == name && accepts(it, args) } + ?: throw ToolException(notExposed(name)) + return invoke(method, receiver, args) + } + + private fun accepts(method: Method, args: Array): Boolean = + method.parameterCount == args.size && + method.parameterTypes.zip(args).all { (type, arg) -> arg == null || type.kotlin.javaObjectType.isInstance(arg) } + + @Suppress("SpreadOperator") + private fun invoke(method: Method, receiver: Any?, args: Array): Any? = try { + method.isAccessible = true + method.invoke(receiver, *args) + } catch (e: InvocationTargetException) { + throw ToolException(e.cause?.message ?: "${method.name} failed inside the database plugin", e) + } catch (e: IllegalAccessException) { + throw ToolException(notExposed(method.name), e) + } + + private fun redacted(url: String): String = + PASSWORD_PARAM.replace(USER_INFO.replace(url, "//")) { it.groupValues[1] + "=***" } + + companion object { + + const val PLUGIN_ID = "com.intellij.database" + private const val PACKAGE = "com.intellij.database." + + private const val FACADE = "com.intellij.database.psi.DbPsiFacade" + private const val DAS_UTIL = "com.intellij.database.util.DasUtil" + private const val CONNECTION_MANAGER = "com.intellij.database.dataSource.DatabaseConnectionManager" + private const val MISSING = "the Database plugin ($PLUGIN_ID) is not loaded in this IDE; the db tools need " + + "IntelliJ IDEA Ultimate, PyCharm Professional, DataGrip or another IDE that bundles it" + + private val USER_INFO = Regex("//[^/@\\s]*@") + private val PASSWORD_PARAM = Regex("(?i)(password|pwd|passwd)=[^&;]*") + + fun isAvailable(): Boolean = PluginManagerCore.isLoaded(PluginIds.of(PLUGIN_ID)) + + private fun notExposed(what: String): String = + "the database plugin of this IDE does not expose $what; open the Database tool window and work there" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/git/ForgeViewNavigator.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/ForgeViewNavigator.kt new file mode 100644 index 00000000..a662790c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/ForgeViewNavigator.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.controller.git + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.util.ui.UIUtil +import javax.swing.JList + +object ForgeViewNavigator { + + val TOOL_WINDOW_IDS: List = listOf("Pull Requests", "Merge Requests") + + fun open(project: Project, focus: Boolean): Boolean { + val toolWindow = found(project) ?: return false + toolWindow.activate(null, focus) + return true + } + + fun selectRequest(project: Project, number: Long): DataContext? { + val toolWindow = found(project) ?: return null + toolWindow.contentManager.contents.forEach { content -> + UIUtil.findComponentsOfType(content.component, JList::class.java).forEach { list -> + val index = (0 until list.model.size).firstOrNull { numberOf(list.model.getElementAt(it)) == number } + if (index != null) { + list.selectedIndex = index + list.ensureIndexIsVisible(index) + return DataManager.getInstance().getDataContext(list) + } + } + } + return null + } + + fun selectTimeline(project: Project, number: Long): Boolean { + val manager = FileEditorManager.getInstance(project) + val timeline = manager.openFiles.firstOrNull { !it.isInLocalFileSystem && it.name == "#$number" } ?: return false + manager.openFile(timeline, false) + return true + } + + private fun numberOf(item: Any?): Long? { + val target = item ?: return null + return runCatching { target.javaClass.getMethod("getNumber").invoke(target) as? Number }.getOrNull()?.toLong() + } + + private fun found(project: Project) = + TOOL_WINDOW_IDS.firstNotNullOfOrNull { ToolWindowManager.getInstance(project).getToolWindow(it) } +} diff --git a/src/main/kotlin/dev/lain/claudejb/git/GitAvailability.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitAvailability.kt similarity index 83% rename from src/main/kotlin/dev/lain/claudejb/git/GitAvailability.kt rename to src/main/kotlin/dev/lain/claudejb/controller/git/GitAvailability.kt index 635c487c..816f8a6d 100644 --- a/src/main/kotlin/dev/lain/claudejb/git/GitAvailability.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitAvailability.kt @@ -1,4 +1,4 @@ -package dev.lain.claudejb.git +package dev.lain.claudejb.controller.git import dev.lain.claudejb.util.InstalledPlugins diff --git a/src/main/kotlin/dev/lain/claudejb/git/GitGateway.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt similarity index 81% rename from src/main/kotlin/dev/lain/claudejb/git/GitGateway.kt rename to src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt index dbb376f7..4a9f1281 100644 --- a/src/main/kotlin/dev/lain/claudejb/git/GitGateway.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitGateway.kt @@ -1,13 +1,21 @@ -package dev.lain.claudejb.git +package dev.lain.claudejb.controller.git import com.intellij.dvcs.repo.Repository import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.VcsException import com.intellij.openapi.vfs.VirtualFile +import com.intellij.vcsUtil.VcsUtil +import dev.lain.claudejb.model.git.GitBranchTopology +import dev.lain.claudejb.model.git.GitCommitInfo +import dev.lain.claudejb.model.git.GitLogScope +import dev.lain.claudejb.model.git.GitRefInfo +import dev.lain.claudejb.model.git.GitRefKind +import dev.lain.claudejb.model.git.GitRemoteInfo import git4idea.GitCommit import git4idea.GitRevisionNumber import git4idea.branch.GitBranchesCollection +import git4idea.history.GitFileHistory import git4idea.history.GitHistoryUtils import git4idea.repo.GitBranchTrackInfo import git4idea.repo.GitRemote @@ -60,6 +68,20 @@ internal object GitGateway { return commits.map { commit -> toInfo(commit, root.path) } } + @Throws(VcsException::class) + fun commit(project: Project, root: VirtualFile, hash: String): GitCommitInfo? = + GitHistoryUtils.history(project, root, hash, "-n", "1").firstOrNull()?.let { toInfo(it, root.path) } + + @Throws(VcsException::class) + fun fileHistory(project: Project, root: VirtualFile, relativePath: String, limit: Int): List { + val path = VcsUtil.getFilePath(root.path + "/" + relativePath, false) + val hashes = GitFileHistory.collectHistory(project, path, "-n", limit.toString()).map { it.revisionNumber.asString() } + if (hashes.isEmpty()) return emptyList() + @Suppress("SpreadOperator") + val commits = GitHistoryUtils.history(project, root, *GitHistoryUtils.formHashParameters(project, hashes)) + return hashes.mapNotNull { hash -> commits.firstOrNull { it.id.asString() == hash } }.map { toInfo(it, root.path) } + } + private fun revisionsOf(scope: GitLogScope): Array = when (scope) { GitLogScope.CURRENT_BRANCH -> arrayOf("HEAD") GitLogScope.EVERY_LINE_OF_DEVELOPMENT -> arrayOf("HEAD", "--branches", "--remotes", "--tags") diff --git a/src/main/kotlin/dev/lain/claudejb/git/GitHistoryService.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt similarity index 83% rename from src/main/kotlin/dev/lain/claudejb/git/GitHistoryService.kt rename to src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt index 9f4beecb..d5b24023 100644 --- a/src/main/kotlin/dev/lain/claudejb/git/GitHistoryService.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitHistoryService.kt @@ -1,14 +1,19 @@ -package dev.lain.claudejb.git +package dev.lain.claudejb.controller.git import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FileStatus import com.intellij.openapi.vcs.VcsException import com.intellij.openapi.vcs.changes.ChangeListManager import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.model.git.GitBranchTopology +import dev.lain.claudejb.model.git.GitCommitInfo +import dev.lain.claudejb.model.git.GitLogScope +import dev.lain.claudejb.model.git.GitRefInfo +import dev.lain.claudejb.model.git.GitRemoteInfo +import dev.lain.claudejb.util.logger @Service(Service.Level.PROJECT) class GitHistoryService(private val project: Project) { @@ -36,6 +41,16 @@ class GitHistoryService(private val project: Project) { return withPrimaryRoot(emptyList()) { root -> GitGateway.recentCommits(project, root, limit, scope) } } + fun commit(hash: String): GitCommitInfo? { + if (refusedOnEdt("commit()", "git log -n 1")) return null + return withPrimaryRoot(null) { root -> GitGateway.commit(project, root, hash) } + } + + fun fileHistory(relativePath: String, limit: Int): List { + if (limit <= 0 || refusedOnEdt("fileHistory()", "git log --follow")) return emptyList() + return withPrimaryRoot(emptyList()) { root -> GitGateway.fileHistory(project, root, relativePath, limit) } + } + fun branchTopology(): GitBranchTopology { if (refusedOnEdt("branchTopology()", "git rev-list / git merge-base")) return GitBranchTopology.NONE return withPrimaryRoot(GitBranchTopology.NONE) { root -> GitGateway.branchTopology(project, root) } diff --git a/src/main/kotlin/dev/lain/claudejb/git/GitLogNavigator.kt b/src/main/kotlin/dev/lain/claudejb/controller/git/GitLogNavigator.kt similarity index 54% rename from src/main/kotlin/dev/lain/claudejb/git/GitLogNavigator.kt rename to src/main/kotlin/dev/lain/claudejb/controller/git/GitLogNavigator.kt index 8ab37e01..5e32838e 100644 --- a/src/main/kotlin/dev/lain/claudejb/git/GitLogNavigator.kt +++ b/src/main/kotlin/dev/lain/claudejb/controller/git/GitLogNavigator.kt @@ -1,7 +1,6 @@ -package dev.lain.claudejb.git +package dev.lain.claudejb.controller.git import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vcs.AbstractVcsHelper @@ -9,33 +8,32 @@ import com.intellij.openapi.vcs.ProjectLevelVcsManager import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.wm.ToolWindowId import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.vcs.log.impl.HashImpl -import com.intellij.vcs.log.impl.VcsLogNavigationUtil +import com.intellij.vcs.log.impl.VcsLogNavigationUtil.jumpToHash +import com.intellij.vcs.log.impl.VcsProjectLog +import com.intellij.vcs.log.visible.filters.VcsLogFilterObject import com.intellij.vcsUtil.VcsUtil -import dev.lain.claudejb.diff.DiffPresenter +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.util.logger object GitLogNavigator { - fun showLog(project: Project): Boolean { + fun showLog(project: Project, focus: Boolean): Boolean { val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.VCS) ?: return false - toolWindow.activate(null, true) + toolWindow.activate(null, focus) return true } - fun showCommit(project: Project, hash: String): Boolean { - if (hash.isBlank()) return false - val history = project.service() - if (!history.isAvailable()) return false - val root = history.primaryRepositoryRoot() ?: return false - val rootFile = LocalFileSystem.getInstance().findFileByPath(FileUtil.toSystemIndependentName(root)) - ?: return false - return runCatching { - VcsLogNavigationUtil.jumpToRevisionAsync(project, rootFile, HashImpl.build(hash)) - true - }.getOrElse { - LOG.warn("Could not show commit $hash in the Git Log", it) - false - } + fun showRange(project: Project, exclusiveRef: String, inclusiveRef: String, focus: Boolean): Boolean { + if (!project.service().isAvailable() || !showLog(project, focus)) return false + val filters = VcsLogFilterObject.collection(VcsLogFilterObject.fromRange(exclusiveRef, inclusiveRef)) + VcsProjectLog.runInMainLog(project) { it.filterUi.setFilters(filters) } + return true + } + + fun showCommit(project: Project, hash: String, focus: Boolean): Boolean { + if (hash.isBlank() || !project.service().isAvailable() || !showLog(project, focus)) return false + VcsProjectLog.runInMainLog(project) { it.jumpToHash(hash, false, focus) } + return true } fun showFileHistory(project: Project, path: String): Boolean { diff --git a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt new file mode 100644 index 00000000..e0d32580 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubAvailability.kt @@ -0,0 +1,17 @@ +package dev.lain.claudejb.controller.github + +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.util.InstalledPlugins + +internal object GitHubAvailability { + + const val PLUGIN_ID = "org.jetbrains.plugins.github" + + const val MISSING = "the GitHub plugin ($PLUGIN_ID) is not installed or is disabled in this IDE" + + fun isEnabled(): Boolean = InstalledPlugins.isEnabled(PLUGIN_ID) + + fun require() { + if (!isEnabled()) throw ToolException(MISSING) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt new file mode 100644 index 00000000..3b5106a1 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/github/GitHubGateway.kt @@ -0,0 +1,137 @@ +package dev.lain.claudejb.controller.github + +import com.intellij.collaboration.api.data.GraphQLRequestPagination +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import dev.lain.claudejb.model.mcp.ToolException +import org.jetbrains.plugins.github.api.GHGQLRequests +import org.jetbrains.plugins.github.api.GHRepositoryCoordinates +import org.jetbrains.plugins.github.api.GithubApiRequest +import org.jetbrains.plugins.github.api.GithubApiRequestExecutor +import org.jetbrains.plugins.github.api.GithubApiRequests +import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort +import org.jetbrains.plugins.github.api.executeSuspend +import org.jetbrains.plugins.github.authentication.GHAccountsUtil +import org.jetbrains.plugins.github.authentication.accounts.GHAccountManager +import org.jetbrains.plugins.github.util.GHHostedRepositoriesManager +import java.io.IOException + +internal class GitHubGateway(private val project: Project) { + + class Repository(val server: String, val owner: String, val name: String) + + class Head(val number: Long, val title: String, val state: String, val draft: Boolean, val author: String, val url: String) + + class Request(val head: Head, val updatedAt: String, val branches: Branches? = null) + + class Branches(val base: String, val head: String, val body: String, val reviewDecision: String, val headSha: String = "") + + class Check(val name: String, val state: String, val required: Boolean, val url: String) + + class Mergeability(val mergeable: String, val mergeState: String, val canMerge: Boolean, val headSha: String, val checks: List) + + private fun requireGitHub() { + GitHubAvailability.require() + } + + fun repository(): Repository { + requireGitHub() + val coordinates = mapping().repository + val path = coordinates.repositoryPath + return Repository(coordinates.serverPath.toString(), path.owner, path.repository) + } + + suspend fun pullRequests(state: String, max: Int): List { + requireGitHub() + val (executor, coordinates) = client() + val query = "repo:${slug(coordinates)} type:pr $state sort:updated-desc" + val page = GraphQLRequestPagination(afterCursor = null, pageSize = max) + val request = GHGQLRequests.PullRequest.search(coordinates.serverPath, query, page) + val response = api { executor.executeSuspend(request) } + return response.nodes.map { Request(head(it), it.updatedAt.toInstant().toString()) } + } + + suspend fun pullRequest(number: Long): Request { + requireGitHub() + val (executor, coordinates) = client() + val pr = api { executor.executeSuspend(GHGQLRequests.PullRequest.findOne(coordinates, number)) } + ?: throw ToolException("no pull request #$number in " + slug(coordinates)) + val branches = Branches(pr.baseRefName, pr.headRefName, pr.body, pr.reviewDecision?.name?.lowercase().orEmpty(), pr.headRefOid) + return Request(head(pr), pr.updatedAt.toInstant().toString(), branches) + } + + suspend fun createPullRequest(base: String, head: String, title: String, body: String, draft: Boolean): Request { + requireGitHub() + val (executor, coordinates) = client() + val repository = api { executor.executeSuspend(GHGQLRequests.Repo.find(coordinates)) } + ?: throw ToolException("GitHub does not know " + slug(coordinates) + " for this account") + val request = GHGQLRequests.PullRequest.create(coordinates, repository.id, base, head, title, body, draft) + val created = api { executor.executeSuspend(request) } + return Request(head(created), created.updatedAt.toInstant().toString()) + } + + suspend fun comment(number: Long, body: String): String { + requireGitHub() + val (executor, coordinates) = client() + return api { executor.executeSuspend(GithubApiRequests.Repos.Issues.Comments.create(coordinates, number, body)) }.htmlUrl + } + + suspend fun mergeability(number: Long): Mergeability { + requireGitHub() + val (executor, coordinates) = client() + val data = api { executor.executeSuspend(GHGQLRequests.PullRequest.mergeabilityData(coordinates, number)) } + ?: throw ToolException("no pull request #$number in " + slug(coordinates)) + val commit = data.commits.nodes.lastOrNull()?.commit + val statuses = commit?.status?.contexts.orEmpty().map { + Check(it.context, it.state.name.lowercase(), it.isRequired, it.targetUrl.orEmpty()) + } + val runs = commit?.checkSuites?.nodes.orEmpty().flatMap { it.checkRuns?.nodes.orEmpty() }.map { + Check(it.name, it.conclusion?.name?.lowercase() ?: PENDING, it.isRequired, it.url) + } + val state = data.mergeStateStatus + val head = commit?.oid.orEmpty() + return Mergeability(data.mergeable.name.lowercase(), state.name.lowercase(), state.canMerge(), head, statuses + runs) + } + + suspend fun merge(number: Long, subject: String, body: String, headSha: String) { + requireGitHub() + val (executor, coordinates) = client() + val path = coordinates.repositoryPath + val request = GithubApiRequests.Repos.PullRequests.merge(coordinates.serverPath, path, number, subject, body, headSha) + api { executor.executeSuspend(request) } + } + + suspend fun getJson(path: String): Any? { + requireGitHub() + val (executor, coordinates) = client() + val url = GithubApiRequests.getUrl(coordinates.serverPath, "/repos/" + slug(coordinates) + path) + return api { executor.executeSuspend(GithubApiRequest.Get.Json(url, Any::class.java)) } + } + + private suspend fun client(): Pair { + val coordinates = mapping().repository + val account = GHAccountsUtil.getSingleOrDefaultAccount(project) + ?: throw ToolException("no GitHub account is signed in; add one in Settings ▸ Version Control ▸ GitHub") + val token = service().findCredentials(account) + ?: throw ToolException("the IDE holds no token for the GitHub account ${account.name}; sign in again") + return GithubApiRequestExecutor.Factory.getInstance().create(account.server, token) to coordinates + } + + private fun mapping() = project.service().knownRepositoriesState.value.firstOrNull() + ?: throw ToolException("no remote of this project points at a GitHub repository the IDE knows") + + private fun slug(coordinates: GHRepositoryCoordinates) = coordinates.repositoryPath.owner + "/" + coordinates.repositoryPath.repository + + private fun head(pr: GHPullRequestShort) = + Head(pr.number, pr.title, pr.state.name.lowercase(), pr.isDraft, pr.author?.login.orEmpty(), pr.url) + + private companion object { + const val PENDING = "pending" + } + + private suspend fun api(block: suspend () -> T): T = try { + block() + } catch (e: IOException) { + throw ToolException("GitHub did not answer: ${e.message}", e) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/github/MarketplaceGateway.kt b/src/main/kotlin/dev/lain/claudejb/controller/github/MarketplaceGateway.kt new file mode 100644 index 00000000..da536005 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/github/MarketplaceGateway.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.controller.github + +import com.intellij.util.io.HttpRequests +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.protocol.ClaudeJson +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.IOException +import java.time.Instant + +internal object MarketplaceGateway { + + class Update( + val version: String, + val channel: String, + val listed: Boolean, + val approved: Boolean, + val publishedAt: String, + val range: String, + ) + + fun updates(pluginId: Int, max: Int, fetch: (String) -> String = ::fetch): List { + val text = try { + fetch("$MARKETPLACE/api/plugins/$pluginId/updates?size=$max") + } catch (e: IOException) { + throw ToolException("Marketplace did not answer: ${e.message}", e) + } + val rows: JsonArray = runCatching { ClaudeJson.parseToJsonElement(text).jsonArray } + .getOrElse { throw ToolException("Marketplace answered something that is not an update list") } + return rows.map { row -> update(row.jsonObject) } + } + + private fun update(row: JsonObject): Update = Update( + version = text(row, "version"), + channel = text(row, "channel").ifEmpty { "stable" }, + listed = text(row, "listed") == "true", + approved = text(row, "approve") == "true", + publishedAt = text(row, "cdate").toLongOrNull()?.let { Instant.ofEpochMilli(it).toString() }.orEmpty(), + range = text(row, "sinceUntil"), + ) + + private fun text(row: JsonObject, key: String): String = row[key]?.jsonPrimitive?.content.orEmpty() + + private fun fetch(url: String): String = HttpRequests.request(url).readString(null) + + private const val MARKETPLACE = "https://plugins.jetbrains.com" +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/FocusKeeper.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/FocusKeeper.kt new file mode 100644 index 00000000..8c9f02a4 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/FocusKeeper.kt @@ -0,0 +1,33 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.IdeFocusManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.Component +import javax.swing.SwingUtilities + +internal object FocusKeeper { + + suspend fun keep(project: Project, reveal: () -> T): T = withContext(Dispatchers.EDT) { keeping(project, reveal) } + + fun keeping(project: Project, reveal: () -> T): T { + val manager = IdeFocusManager.getInstance(project) + val owner = manager.focusOwner + val result = reveal() + if (owner != null) later { later { restore(manager, owner) } } + return result + } + + private fun later(block: () -> Unit) = ApplicationManager.getApplication().invokeLater(block, ModalityState.nonModal()) + + private fun restore(manager: IdeFocusManager, owner: Component) { + val now = manager.focusOwner ?: return + if (now === owner || !owner.isShowing) return + if (SwingUtilities.getWindowAncestor(now) !== SwingUtilities.getWindowAncestor(owner)) return + manager.requestFocus(owner, false) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/GuardGate.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/GuardGate.kt new file mode 100644 index 00000000..b2dbc2e5 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/GuardGate.kt @@ -0,0 +1,16 @@ +package dev.lain.claudejb.controller.mcp + +import dev.lain.claudejb.model.mcp.OwnTools +import dev.lain.claudejb.model.mcp.ToolGate +import dev.lain.claudejb.model.mcp.ToolSpec +import dev.lain.claudejb.model.permission.SensitiveGuard +import kotlinx.serialization.json.JsonObject + +internal class GuardGate(private val decide: (JsonObject) -> SensitiveGuard.Decision) : ToolGate { + + override fun denial(tool: ToolSpec, arguments: JsonObject): String? { + val decision = decide(OwnTools.guardInput(arguments)) + if (decision.verdict == SensitiveGuard.Verdict.ALLOW) return null + return decision.reason ?: "refused by the Sensitive Guard" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeActions.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeActions.kt new file mode 100644 index 00000000..5cc840a3 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeActions.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Toggleable +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.EDT +import com.intellij.openapi.project.Project +import dev.lain.claudejb.model.mcp.ToolException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class IdeActions(private val project: Project, private val scope: CoroutineScope) { + + private val targets = TargetContext(project) + + suspend fun dispatch(actionId: String, context: DataContext? = null) { + val resolved = context ?: withContext(Dispatchers.EDT) { targets.project() } + withContext(Dispatchers.EDT) { + val (action, event) = enabled(actionId, resolved) + scope.launch(Dispatchers.EDT) { FocusKeeper.keeping(project) { ActionUtil.performAction(action, event) } } + } + } + + suspend fun dispatch(actionId: String, target: TargetContext.Target) = dispatch(actionId, targets.of(target)) + + suspend fun toggle(actionId: String, on: Boolean?): Boolean = withContext(Dispatchers.EDT) { + val (action, event) = enabled(actionId, targets.project()) + val selected = Toggleable.isSelected(event.presentation) + if (on == null || on != selected) FocusKeeper.keeping(project) { ActionUtil.performAction(action, event) } + on ?: !selected + } + + private fun enabled(actionId: String, context: DataContext): Pair { + val action = ActionManager.getInstance().getAction(actionId) + ?: throw ToolException("this IDE has no action $actionId; the plugin that provides it is not installed") + val event = AnActionEvent.createEvent(action, context, null, ActionPlaces.MAIN_MENU, ActionUiKind.NONE, null) + ActionUtil.updateAction(action, event) + if (!event.presentation.isEnabled || !event.presentation.isVisible) { + throw ToolException("the IDE refused $actionId in this context: it is not enabled here") + } + return action to event + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt new file mode 100644 index 00000000..0d87e244 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeMcpService.kt @@ -0,0 +1,138 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.model.mcp.McpServer +import dev.lain.claudejb.model.mcp.MetaTools +import dev.lain.claudejb.model.mcp.OutputBudget +import dev.lain.claudejb.model.mcp.TokenRing +import dev.lain.claudejb.model.session.launch.IdeServer +import dev.lain.claudejb.model.session.launch.McpConfigBuilder +import dev.lain.claudejb.model.session.launch.SessionLauncher +import dev.lain.claudejb.model.settings.ClaudeSettings +import dev.lain.claudejb.model.settings.guard.sensitiveDecision +import dev.lain.claudejb.util.PluginIdentity +import dev.lain.claudejb.util.thisLogger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.awt.datatransfer.StringSelection +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +@Service(Service.Level.PROJECT) +internal class IdeMcpService(private val project: Project, private val scope: CoroutineScope) : Disposable { + + private val log = thisLogger() + private val tokens = TokenRing() + private val expected = AtomicInteger() + private var home: SocketHome? = null + private var endpoints: List = emptyList() + private var rotation: Job? = null + private var servingWithoutChat = false + + @Synchronized + fun sockets(): Map { + start() + return endpoints.associate { it.server to it.socket.toString() } + } + + @Synchronized + fun serveWithoutChat() { + if (servingWithoutChat) return + servingWithoutChat = true + val sockets = runCatching { sockets() } + .onFailure { log.warn("The chat page could not be shown and the IDE MCP servers could not start either", it) } + .getOrDefault(emptyMap()) + if (sockets.isEmpty()) return + val config = McpConfigBuilder.mcpConfigJson("", sockets, SessionLauncher.resolveHelper()) + log.info("the chat page could not be shown; the IDE MCP servers stay reachable under " + home?.dir + ": " + config) + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(PluginIdentity.NOTIFICATION_GROUP) + .createNotification(WITHOUT_CHAT_TITLE, WITHOUT_CHAT_TEXT, NotificationType.WARNING) + if (config != null) { + val copy = NotificationAction.createSimple("Copy MCP configuration") { + CopyPasteManager.getInstance().setContents(StringSelection(config)) + } + notification.addAction(copy) + } + notification.notify(project) + } + + fun expectConnections(count: Int) { + expected.addAndGet(count) + } + + private fun start() { + if (home != null) return + val home = SocketHome.create(listOf(Path.of(PathManager.getTempPath()), Path.of(System.getProperty("java.io.tmpdir")))) + home.writeToken(tokens.token) + val gate = GuardGate { ClaudeSettings.getInstance(project).sensitiveDecision(it, project.basePath) } + endpoints = IdeServer.entries.mapNotNull { server -> + val catalog = IdeToolCatalog.catalog(server, project, scope) + if (catalog.domains.isEmpty()) return@mapNotNull null + val mcp = McpServer(server.key, PluginIdentity.PLUGIN_VERSION, MetaTools(catalog, gate, OutputBudget())) + ServerEndpoint(server, home.socket(server), mcp, tokens, scope, ::connected).also { it.start() } + } + rotation = scope.launch { + while (isActive) { + delay(TokenRing.ROTATION_MILLIS) + home.writeToken(tokens.rotate()) + } + } + this.home = home + log.info("IDE MCP servers listening under ${home.dir}: ${endpoints.joinToString { it.server.key }}") + } + + private suspend fun connected(): Boolean { + if (expected.getAndUpdate { if (it > 0) it - 1 else 0 } > 0) return true + log.warn("an MCP client connected that no chat tab of this project announced") + val mustApprove = ClaudeSettings.getInstance(project).state.ideMcp.approveClients + val verdict = CompletableDeferred() + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(PluginIdentity.NOTIFICATION_GROUP) + .createNotification( + "An unknown client connected to the IDE MCP servers", + if (mustApprove) HELD_TEXT else INFORMED_TEXT, + NotificationType.WARNING, + ) + if (mustApprove) { + notification.addAction(NotificationAction.createSimpleExpiring("Allow") { verdict.complete(true) }) + notification.addAction(NotificationAction.createSimpleExpiring("Reject") { verdict.complete(false) }) + notification.whenExpired { verdict.complete(false) } + } + notification.notify(project) + return !mustApprove || verdict.await() + } + + override fun dispose() { + rotation?.cancel() + endpoints.forEach { it.close() } + home?.remove() + } + + companion object { + + const val INFORMED_TEXT = "Something other than this project's chat tabs opened a connection. If that was not you, close the " + + "project: the sockets and their token die with it." + const val HELD_TEXT = "Something other than this project's chat tabs opened a connection. It is held until you answer; " + + "closing this notice rejects it." + const val WITHOUT_CHAT_TITLE = "The chat could not be shown, but the IDE MCP servers are up" + const val WITHOUT_CHAT_TEXT = "This is what happens when the IDE runs split between a frontend and a backend: the plugin, " + + "its sockets and the claude process all live on the backend, where the chat page cannot be drawn. The servers " + + "listen anyway, so any MCP client on that machine drives the IDE: pass the copied configuration to it as its " + + "MCP servers (claude takes it with --mcp-config). docs/MCP_CLIENT.md describes the wire." + + fun getInstance(project: Project): IdeMcpService = project.service() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt new file mode 100644 index 00000000..6f9b4511 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdePlaces.kt @@ -0,0 +1,94 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils +import com.intellij.build.BuildContentManager +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.ui.RunContentManager +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vcs.changes.actions.diff.ShowDiffAction +import com.intellij.openapi.wm.ToolWindowId +import com.intellij.openapi.wm.ToolWindowManager +import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.model.diff.DiffPresenter +import java.nio.file.Path + +internal class IdePlaces(private val project: Project) { + + private class Verb(val key: String, val required: Boolean = true, val open: (String) -> Boolean) + + private val verbs: Map = mapOf( + "commit" to Verb("hash") { GitLogNavigator.showCommit(project, it, focus = true) }, + "log" to Verb("", required = false) { GitLogNavigator.showLog(project, focus = true) }, + "toolwindow" to Verb("id", open = ::activate), + "terminal" to Verb("tab", required = false, open = ::terminal), + "run" to Verb("name", open = ::run), + "build" to Verb("", required = false) { build() }, + "problems" to Verb("tab", required = false, open = ::problems), + "diff" to Verb("file", open = ::diff), + "action" to Verb("id", open = ::action), + ) + + fun open(verb: String, params: Map): Boolean { + val known = verbs[verb] ?: return false + val value = params[known.key] ?: if (known.required) return false else "" + return known.open(value) + } + + private fun build(): Boolean { + BuildContentManager.getInstance(project).getOrCreateToolWindow().activate(null, true) + return true + } + + private fun activate(id: String): Boolean { + val window = ToolWindowManager.getInstance(project).getToolWindow(id) ?: return false + window.activate(null, true) + return true + } + + private fun terminal(tab: String): Boolean { + val window = ToolWindowManager.getInstance(project).getToolWindow(TERMINAL) ?: return false + val manager = window.contentManager + if (tab.isNotEmpty()) manager.contents.firstOrNull { it.displayName == tab }?.let { manager.setSelectedContent(it) } + window.activate(null, true) + return true + } + + private fun run(name: String): Boolean { + val manager = RunContentManager.getInstance(project) + val descriptor = manager.allDescriptors.lastOrNull { it.displayName == name } ?: return activate(ToolWindowId.RUN) + manager.toFrontRunContent(DefaultRunExecutor.getRunExecutorInstance(), descriptor) + return true + } + + private fun problems(tab: String): Boolean { + val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return false + if (tab.isEmpty()) window.activate(null, true) else ProblemsViewToolWindowUtils.selectTab(project, tab) + return true + } + + private fun diff(file: String): Boolean { + val base = project.basePath ?: return false + val path = Path.of(base).resolve(file).normalize().toString() + if (!DiffPresenter.isWithinRoot(path, base)) return false + val changes = ChangeListManager.getInstance(project).allChanges.filter { change -> + val changed = (change.afterRevision ?: change.beforeRevision)?.file?.path ?: return@filter false + changed == path || changed.startsWith("$path/") + } + if (changes.isEmpty()) return activate(ToolWindowId.COMMIT) + ShowDiffAction.showDiffForChange(project, changes) + return true + } + + private fun action(id: String): Boolean { + val action = ActionManager.getInstance().getAction(id) ?: return false + ActionManager.getInstance().tryToExecute(action, null, null, ActionPlaces.TOOLWINDOW_CONTENT, true) + return true + } + + private companion object { + const val TERMINAL = "Terminal" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt new file mode 100644 index 00000000..c9ab3359 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/IdeToolCatalog.kt @@ -0,0 +1,140 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.git.GitAvailability +import dev.lain.claudejb.controller.mcp.tools.code.AnalysisTools +import dev.lain.claudejb.controller.mcp.tools.code.AnalyzeTools +import dev.lain.claudejb.controller.mcp.tools.code.BookmarkTools +import dev.lain.claudejb.controller.mcp.tools.code.DiagnosticsTools +import dev.lain.claudejb.controller.mcp.tools.code.EditOpsTools +import dev.lain.claudejb.controller.mcp.tools.code.EditTools +import dev.lain.claudejb.controller.mcp.tools.code.EditorTools +import dev.lain.claudejb.controller.mcp.tools.code.FileTools +import dev.lain.claudejb.controller.mcp.tools.code.FormatTools +import dev.lain.claudejb.controller.mcp.tools.code.HierarchyTools +import dev.lain.claudejb.controller.mcp.tools.code.IndexTools +import dev.lain.claudejb.controller.mcp.tools.code.InspectTools +import dev.lain.claudejb.controller.mcp.tools.code.JavaAvailability +import dev.lain.claudejb.controller.mcp.tools.code.LanguageTools +import dev.lain.claudejb.controller.mcp.tools.code.MarkupTools +import dev.lain.claudejb.controller.mcp.tools.code.NavigateTools +import dev.lain.claudejb.controller.mcp.tools.code.OutlineTools +import dev.lain.claudejb.controller.mcp.tools.code.PresenceTools +import dev.lain.claudejb.controller.mcp.tools.code.PsiTools +import dev.lain.claudejb.controller.mcp.tools.code.ReadTools +import dev.lain.claudejb.controller.mcp.tools.code.RecentTools +import dev.lain.claudejb.controller.mcp.tools.code.RefactorOpsTools +import dev.lain.claudejb.controller.mcp.tools.code.RefactorTools +import dev.lain.claudejb.controller.mcp.tools.code.SearchTools +import dev.lain.claudejb.controller.mcp.tools.code.TemplateTools +import dev.lain.claudejb.controller.mcp.tools.code.UastTools +import dev.lain.claudejb.controller.mcp.tools.code.ViewTools +import dev.lain.claudejb.controller.mcp.tools.code.WorkspaceTools +import dev.lain.claudejb.controller.mcp.tools.ops.ActionTools +import dev.lain.claudejb.controller.mcp.tools.ops.ConsoleTools +import dev.lain.claudejb.controller.mcp.tools.ops.DbTools +import dev.lain.claudejb.controller.mcp.tools.ops.HttpTools +import dev.lain.claudejb.controller.mcp.tools.ops.IdeTools +import dev.lain.claudejb.controller.mcp.tools.ops.NotifyTools +import dev.lain.claudejb.controller.mcp.tools.ops.ProjectTools +import dev.lain.claudejb.controller.mcp.tools.ops.RemoteTools +import dev.lain.claudejb.controller.mcp.tools.ops.ServiceTools +import dev.lain.claudejb.controller.mcp.tools.ops.ServiceViewTools +import dev.lain.claudejb.controller.mcp.tools.ops.SshTools +import dev.lain.claudejb.controller.mcp.tools.ops.ToolsMenuTools +import dev.lain.claudejb.controller.mcp.tools.ops.WindowTools +import dev.lain.claudejb.controller.mcp.tools.run.BreakpointTools +import dev.lain.claudejb.controller.mcp.tools.run.BuildTools +import dev.lain.claudejb.controller.mcp.tools.run.DebugTools +import dev.lain.claudejb.controller.mcp.tools.run.RunOpsTools +import dev.lain.claudejb.controller.mcp.tools.run.RunTools +import dev.lain.claudejb.controller.mcp.tools.run.TerminalTools +import dev.lain.claudejb.controller.mcp.tools.run.TestTools +import dev.lain.claudejb.controller.mcp.tools.vcs.ChangesTools +import dev.lain.claudejb.controller.mcp.tools.vcs.ForgeTools +import dev.lain.claudejb.controller.mcp.tools.vcs.GitReadTools +import dev.lain.claudejb.controller.mcp.tools.vcs.GitWriteTools +import dev.lain.claudejb.controller.mcp.tools.vcs.HistoryTools +import dev.lain.claudejb.controller.mcp.tools.vcs.LogOpsTools +import dev.lain.claudejb.controller.mcp.tools.vcs.PullRequestOpsTools +import dev.lain.claudejb.controller.mcp.tools.vcs.ReleaseTools +import dev.lain.claudejb.model.mcp.ToolCatalog +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.session.launch.IdeServer +import kotlinx.coroutines.CoroutineScope + +internal object IdeToolCatalog { + + private val DOMAINS: Map ToolDomain?>> = mapOf( + IdeServer.CODE to listOf( + { p, _ -> ReadTools(p, Reveal(p)).domain() }, + { p, _ -> SearchTools(p).domain() }, + { p, _ -> NavigateTools(p).domain() }, + { p, _ -> OutlineTools(p).domain() }, + { p, _ -> DiagnosticsTools(p, Reveal(p)).domain() }, + { p, _ -> InspectTools(p).domain() }, + { p, _ -> EditTools(p, Reveal(p)).domain() }, + { p, s -> EditOpsTools(p, Reveal(p), IdeActions(p, s)).domain() }, + { p, _ -> RefactorTools(p).domain() }, + { p, _ -> FormatTools(p).domain() }, + { p, s -> EditorTools(p, Reveal(p), IdeActions(p, s)).domain() }, + { p, _ -> HierarchyTools(p).domain() }, + { p, s -> RecentTools(p, IdeActions(p, s)).domain() }, + { p, s -> AnalyzeTools(p, IdeActions(p, s)).domain() }, + { p, s -> AnalysisTools(p, IdeActions(p, s)).domain() }, + { p, s -> ViewTools(p, IdeActions(p, s)).domain() }, + { p, s -> FileTools(p, IdeActions(p, s), Reveal(p)).domain() }, + { p, s -> RefactorOpsTools(IdeActions(p, s)).domain() }, + { p, _ -> TemplateTools(p, TargetContext(p), Reveal(p)).domain() }, + { p, s -> LanguageTools(p, IdeActions(p, s)).domain() }, + { p, _ -> BookmarkTools(p, Reveal(p)).domain() }, + { p, _ -> PsiTools(p, Reveal(p)).domain() }, + { p, _ -> IndexTools(p).domain() }, + { p, _ -> if (JavaAvailability.isEnabled()) UastTools(p).domain() else null }, + { p, _ -> WorkspaceTools(p).domain() }, + { p, _ -> MarkupTools(p, TargetContext(p), Reveal(p)).domain() }, + { p, _ -> PresenceTools(p, Reveal(p)).domain() }, + ), + IdeServer.RUN to listOf( + { p, s -> BuildTools(p, s).domain() }, + { p, s -> RunTools(p, s).domain() }, + { p, s -> TestTools(p, s).domain() }, + { p, s -> TerminalTools(p, s).domain() }, + { p, _ -> DebugTools(p).domain() }, + { p, _ -> BreakpointTools(p).domain() }, + { p, s -> RunOpsTools(p, IdeActions(p, s), Reveal(p)).domain() }, + ), + IdeServer.VCS to listOf( + { p, _ -> GitReadTools(p, Reveal(p)).domain() }, + { p, _ -> GitWriteTools(p).domain() }, + { p, s -> ForgeTools(p, IdeActions(p, s), Reveal(p)).domain() }, + { p, s -> LogOpsTools(p, IdeActions(p, s)).domain() }, + { p, s -> ChangesTools(p, IdeActions(p, s), Reveal(p)).domain() }, + { p, s -> HistoryTools(p, IdeActions(p, s), Reveal(p)).domain() }, + { p, _ -> PullRequestOpsTools(p, Reveal(p)).domain() }, + { p, _ -> ReleaseTools(p).domain() }, + ), + IdeServer.OPS to listOf( + { p, s -> ServiceTools(p, s).domain() }, + { p, _ -> ProjectTools(p).domain() }, + { p, s -> IdeTools(p, IdeActions(p, s), s).domain() }, + { p, s -> ActionTools(p, IdeActions(p, s)).domain() }, + { p, s -> WindowTools(p, IdeActions(p, s)).domain() }, + { p, s -> ServiceViewTools(p, s).domain() }, + { p, s -> RemoteTools(p, IdeActions(p, s), Reveal(p)).domain() }, + { p, s -> ToolsMenuTools(p, IdeActions(p, s)).domain() }, + { p, s -> ConsoleTools(p, IdeActions(p, s)).domain() }, + { p, _ -> NotifyTools(p).domain() }, + { p, _ -> DbTools(p).domain() }, + { p, s -> HttpTools(p, s, Reveal(p)).takeIf { it.available() }?.domain() }, + { p, _ -> SshTools(p).takeIf { it.available() }?.domain() }, + ), + ) + + private val REQUIRES: Map Boolean> = mapOf(IdeServer.VCS to GitAvailability::isGitPluginEnabled) + + fun catalog(server: IdeServer, project: Project, scope: CoroutineScope): ToolCatalog { + if (REQUIRES[server]?.invoke() == false) return ToolCatalog(emptyList()) + return ToolCatalog(DOMAINS[server].orEmpty().mapNotNull { it(project, scope) }) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt new file mode 100644 index 00000000..ee57d79c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/Reveal.kt @@ -0,0 +1,56 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowManager +import dev.lain.claudejb.controller.git.ForgeViewNavigator +import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.model.settings.ClaudeSettings + +internal class Reveal(private val project: Project) { + + val mirroring: Boolean + get() = ClaudeSettings.getInstance(project).state.ideMcp.mirror + + suspend fun file(file: VirtualFile, line: Int = 1, column: Int = 1, preview: Boolean = false): Boolean = + FocusKeeper.keep(project) { + val descriptor = OpenFileDescriptor(project, file, line - 1, column - 1).setUsePreviewTab(preview) + val manager = FileEditorManager.getInstance(project) + manager.openTextEditor(descriptor, false) != null || manager.openFile(file, false).isNotEmpty() + } + + suspend fun toolWindow(id: String): Boolean = FocusKeeper.keep(project) { window(id)?.also { it.activate(null, false) } != null } + + suspend fun content(windowId: String, name: String): Boolean = FocusKeeper.keep(project) { + val window = window(windowId) ?: return@keep false + val manager = window.contentManager + val content = manager.contents.firstOrNull { it.displayName == name } ?: return@keep false + val userIsThere = window.isActive + window.activate({ if (!userIsThere) manager.setSelectedContent(content, false) }, false) + true + } + + suspend fun log(): Boolean = FocusKeeper.keep(project) { GitLogNavigator.showLog(project, focus = false) } + + suspend fun commit(hash: String): Boolean = FocusKeeper.keep(project) { GitLogNavigator.showCommit(project, hash, focus = false) } + + suspend fun range(exclusive: String, inclusive: String): Boolean = + FocusKeeper.keep(project) { GitLogNavigator.showRange(project, exclusive, inclusive, focus = false) } + + suspend fun fileHistory(path: String): Boolean = FocusKeeper.keep(project) { GitLogNavigator.showFileHistory(project, path) } + + suspend fun requests(): Boolean = FocusKeeper.keep(project) { ForgeViewNavigator.open(project, focus = false) } + + suspend fun problems(tab: String): Boolean = FocusKeeper.keep(project) { + val window = ProblemsViewToolWindowUtils.getToolWindow(project) ?: return@keep false + val content = if (tab.isEmpty()) null else ProblemsViewToolWindowUtils.getContentById(project, tab) ?: return@keep false + window.activate({ if (content != null) window.contentManager.setSelectedContent(content, false) }, false) + true + } + + private fun window(id: String): ToolWindow? = ToolWindowManager.getInstance(project).getToolWindow(id) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt new file mode 100644 index 00000000..a548aca0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ServerEndpoint.kt @@ -0,0 +1,147 @@ +package dev.lain.claudejb.controller.mcp + +import dev.lain.claudejb.mcp.Frames +import dev.lain.claudejb.mcp.StdioBridge +import dev.lain.claudejb.model.mcp.JsonRpc +import dev.lain.claudejb.model.mcp.McpServer +import dev.lain.claudejb.model.mcp.TokenRing +import dev.lain.claudejb.model.mcp.toon.Toon +import dev.lain.claudejb.model.mcp.toon.ToonException +import dev.lain.claudejb.model.session.launch.IdeServer +import dev.lain.claudejb.util.thisLogger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.InputStream +import java.io.OutputStream +import java.net.StandardProtocolFamily +import java.net.UnixDomainSocketAddress +import java.nio.channels.Channels +import java.nio.channels.ServerSocketChannel +import java.nio.channels.SocketChannel +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +internal class ServerEndpoint( + val server: IdeServer, + val socket: Path, + private val mcp: McpServer, + private val tokens: TokenRing, + private val scope: CoroutineScope, + private val admit: suspend () -> Boolean, + private val io: CoroutineDispatcher = Dispatchers.IO, +) { + private val log = thisLogger() + private val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX).bind(UnixDomainSocketAddress.of(socket)) + .also { ownerOnly(socket) } + private val inFlight = AtomicInteger() + + fun start(): Job = scope.launch(io) { + while (isActive) { + val client = runCatching { channel.accept() }.getOrNull() ?: break + launch { if (admit()) Connection(client).serve() else runCatching { client.close() } } + } + } + + fun close() { + runCatching { channel.close() } + } + + private inner class Connection(private val client: SocketChannel) { + + private val output: OutputStream = Channels.newOutputStream(client) + private val writing = Mutex() + private val jobs = ConcurrentHashMap() + + suspend fun serve() = withContext(io) { + val input: InputStream = Channels.newInputStream(client) + try { + while (true) { + val frame = runCatching { Frames.read(input) }.getOrNull() ?: break + receive(frame) + } + } finally { + jobs.values.forEach { it.cancel() } + runCatching { client.close() } + } + } + + private suspend fun receive(frame: String) { + val message = try { + Toon.decode(frame) + } catch (ignored: ToonException) { + log.debug { "${server.key}: unreadable frame" } + send(JsonRpc.error(null, JsonRpc.PARSE_ERROR, "Parse error")) + return + } + when (val parsed = JsonRpc.parse(message)) { + is JsonRpc.Request -> request(parsed, message) + is JsonRpc.Notification -> notification(parsed, message) + else -> mcp.handle(message)?.let { send(it) } + } + } + + private suspend fun request(request: JsonRpc.Request, message: JsonElement) { + if (!authorized(request.params)) { + log.warn("${server.key}: rejected a request to ${request.method} without a valid token") + send(JsonRpc.error(request.id, JsonRpc.INVALID_REQUEST, REJECTED)) + return + } + if (inFlight.get() >= QUEUE_DEPTH) { + val busy = "$QUEUE_DEPTH requests are already in flight on ${server.key}; retry when one answers" + send(JsonRpc.error(request.id, JsonRpc.INTERNAL_ERROR, busy)) + return + } + val key = request.id.toString() + inFlight.incrementAndGet() + jobs[key] = scope.launch { + try { + mcp.handle(message)?.let { send(it) } + } finally { + inFlight.decrementAndGet() + jobs.remove(key) + } + } + } + + private suspend fun notification(notification: JsonRpc.Notification, message: JsonElement) { + if (!authorized(notification.params)) return + if (notification.method == CANCELLED) { + notification.params["requestId"]?.let { jobs[it.toString()]?.cancel() } + return + } + mcp.handle(message) + } + + private fun authorized(params: JsonObject): Boolean = + tokens.accepts((JsonRpc.meta(params)[StdioBridge.TOKEN_KEY] as? JsonPrimitive)?.content) + + private suspend fun send(reply: JsonObject) = writing.withLock { + withContext(io) { runCatching { Frames.write(output, Toon.encode(reply)) } } + } + } + + companion object { + const val QUEUE_DEPTH = 16 + const val REJECTED = "request rejected" + private const val CANCELLED = "notifications/cancelled" + private val OWNER_ONLY = PosixFilePermissions.fromString("rw-------") + + private fun ownerOnly(socket: Path) { + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) Files.setPosixFilePermissions(socket, OWNER_ONLY) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt new file mode 100644 index 00000000..e8e9cd52 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/SocketHome.kt @@ -0,0 +1,62 @@ +package dev.lain.claudejb.controller.mcp + +import dev.lain.claudejb.mcp.StdioBridge +import dev.lain.claudejb.model.session.launch.IdeServer +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermissions +import java.security.SecureRandom +import java.util.Base64 +import kotlin.io.path.deleteIfExists +import kotlin.io.path.listDirectoryEntries + +internal class SocketHome private constructor(val dir: Path) { + + fun socket(server: IdeServer): Path = dir.resolve("${server.key}$SOCKET_SUFFIX") + + fun writeToken(token: String) { + val file = dir.resolve(StdioBridge.TOKEN_FILE) + if (POSIX) { + file.deleteIfExists() + Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + } + Files.writeString(file, token, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + fun remove() { + runCatching { dir.listDirectoryEntries().forEach { it.deleteIfExists() } } + runCatching { dir.deleteIfExists() } + } + + companion object { + + const val PARENT = "claude-ide-mcp" + const val MAX_SOCKET_PATH = 100 + private const val SOCKET_SUFFIX = ".sock" + private const val ID_BYTES = 16 + private val POSIX = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + private val OWNER_ONLY = PosixFilePermissions.fromString("rwx------") + + fun create(bases: List): SocketHome { + val id = Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(ID_BYTES).also(SecureRandom()::nextBytes)) + val longestName = IdeServer.entries.maxOf { it.key.length } + SOCKET_SUFFIX.length + val base = bases.firstOrNull { fits(it, id, longestName) } + ?: error("no temporary directory short enough for a Unix socket path") + val parent = base.resolve(PARENT) + val dir = parent.resolve(id) + if (POSIX) { + Files.createDirectories(parent, PosixFilePermissions.asFileAttribute(OWNER_ONLY)) + Files.setPosixFilePermissions(parent, OWNER_ONLY) + Files.createDirectory(dir, PosixFilePermissions.asFileAttribute(OWNER_ONLY)) + } else { + Files.createDirectories(dir) + } + return SocketHome(dir) + } + + private fun fits(base: Path, id: String, longestName: Int): Boolean = + base.toAbsolutePath().toString().length + PARENT.length + id.length + longestName + 3 <= MAX_SOCKET_PATH + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/TargetContext.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/TargetContext.kt new file mode 100644 index 00000000..2b738a43 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/TargetContext.kt @@ -0,0 +1,155 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import com.intellij.util.concurrency.EdtExecutorService +import com.intellij.vcs.log.impl.VcsLogNavigationUtil.jumpToHash +import com.intellij.vcs.log.impl.VcsProjectLog +import dev.lain.claudejb.controller.git.GitHistoryService +import dev.lain.claudejb.controller.git.GitLogNavigator +import dev.lain.claudejb.controller.mcp.tools.code.Locations +import dev.lain.claudejb.controller.mcp.tools.ops.ServiceActions +import dev.lain.claudejb.controller.mcp.tools.ops.ServiceTree +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Path + +internal class TargetContext(private val project: Project) { + + class Target( + val path: String?, + val line: Int, + val column: Int, + val hash: String?, + val node: String?, + val preview: Boolean = true, + val selection: Selection? = null, + ) { + val named: Boolean get() = path != null || hash != null || node != null + } + + class Selection(val toLine: Int, val toColumn: Int) + + suspend fun of(target: Target): DataContext = when { + target.path != null -> file(target) + target.hash != null -> commit(target.hash) + target.node != null -> node(target.node) + else -> withContext(Dispatchers.EDT) { project() } + } + + fun project(): DataContext = SimpleDataContext.builder() + .add(CommonDataKeys.PROJECT, project) + .add(CommonDataKeys.VIRTUAL_FILE, repositoryRoot()) + .build() + + private suspend fun file(target: Target): DataContext { + val path = target.path.orEmpty() + val located = readAction { Locations.any(project, path) } + if (located.isDirectory) return directory(located) + val psiFile = readAction { Locations.psiFile(project, path) } + val file = located + return FocusKeeper.keep(project) { + val descriptor = OpenFileDescriptor(project, file, target.line - 1, target.column - 1).setUsePreviewTab(target.preview) + val editor = FileEditorManager.getInstance(project).openTextEditor(descriptor, false) + target.selection?.let { editor?.let { e -> select(e, target, it) } } + val element = editor?.let { psiFile.findElementAt(it.caretModel.offset) } + val base = if (editor != null) DataManager.getInstance().getDataContext(editor.contentComponent) else project() + SimpleDataContext.builder() + .setParent(base) + .add(CommonDataKeys.PROJECT, project) + .add(CommonDataKeys.VIRTUAL_FILE, file) + .add(CommonDataKeys.VIRTUAL_FILE_ARRAY, arrayOf(file)) + .add(CommonDataKeys.PSI_FILE, psiFile) + .add(CommonDataKeys.PSI_ELEMENT, element ?: psiFile) + .build() + } + } + + private suspend fun directory(dir: VirtualFile): DataContext { + val psi = readAction { PsiManager.getInstance(project).findDirectory(dir) } + return SimpleDataContext.builder() + .setParent(project()) + .add(CommonDataKeys.VIRTUAL_FILE, dir) + .add(CommonDataKeys.VIRTUAL_FILE_ARRAY, arrayOf(dir)) + .add(CommonDataKeys.PSI_ELEMENT, psi) + .build() + } + + private fun select(editor: Editor, target: Target, selection: Selection) { + val start = editor.logicalPositionToOffset(LogicalPosition(target.line - 1, target.column - 1)) + val end = editor.logicalPositionToOffset(LogicalPosition(selection.toLine - 1, selection.toColumn - 1)) + editor.selectionModel.setSelection(minOf(start, end), maxOf(start, end)) + } + + private suspend fun commit(hash: String): DataContext { + val context = CompletableDeferred() + withContext(Dispatchers.EDT) { + FocusKeeper.keeping(project) { GitLogNavigator.showLog(project, focus = false) } + VcsProjectLog.runInMainLog(project) { ui -> + val jump = ui.jumpToHash(hash, false, false) + jump.addListener({ context.complete(DataManager.getInstance().getDataContext(ui.table)) }, EdtExecutorService.getInstance()) + } + } + return withTimeoutOrNull(LOG_TIMEOUT_MILLIS) { context.await() } + ?: throw ToolException("the Git Log did not select $hash in time; is the log still loading?") + } + + private suspend fun node(path: String): DataContext { + val node = withContext(Dispatchers.Default) { ServiceTree(project).find(path) } + return withContext(Dispatchers.EDT) { ServiceActions(project, node).context() } + } + + private fun repositoryRoot(): VirtualFile? = + project.service().primaryRepositoryRoot()?.let { LocalFileSystem.getInstance().findFileByNioFile(Path.of(it)) } + + companion object { + + private const val LOG_TIMEOUT_MILLIS = 15_000L + + val PARAMS: List = listOf( + Param("path", "A file to act on: the action runs with that file, its PSI and an editor on it as context", required = false), + Param("line", "1-based caret line inside path (default 1)", type = "integer", required = false), + Param("column", "1-based caret column inside path (default 1)", type = "integer", required = false), + Param("hash", "A commit to act on: it is selected in the Git Log and the action runs with the log's context", required = false), + Param("node", "A Services node path, as services lists it, to act on", required = false), + ) + + val SELECTION_PARAMS: List = listOf( + Param("to_line", "1-based line where the selection ends (default: no selection)", type = "integer", required = false), + Param("to_column", "1-based column where the selection ends (default: end of to_line)", type = "integer", required = false), + ) + + fun target(args: ToolArgs, preview: Boolean = true): Target { + val target = Target( + args.optionalString("path"), + args.int("line", 1), + args.int("column", 1), + args.optionalString("hash"), + args.optionalString("node"), + preview, + args.optionalString("to_line")?.let { Selection(args.int("to_line", 1), args.int("to_column", Int.MAX_VALUE)) }, + ) + if (listOfNotNull(target.path, target.hash, target.node).size > 1) throw ToolException("give one target: path, hash or node") + if (target.line < 1 || target.column < 1) throw ToolException("line and column start at 1") + return target + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/ToolOutput.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ToolOutput.kt new file mode 100644 index 00000000..c02622dc --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/ToolOutput.kt @@ -0,0 +1,17 @@ +package dev.lain.claudejb.controller.mcp + +import com.intellij.openapi.project.Project +import com.intellij.util.messages.Topic + +internal fun interface ToolOutputListener { + fun line(toolUseId: String, line: String) +} + +internal object ToolOutput { + + val TOPIC: Topic = Topic(ToolOutputListener::class.java, Topic.BroadcastDirection.NONE) + + fun line(project: Project, toolUseId: String, line: String) { + project.messageBus.syncPublisher(TOPIC).line(toolUseId, line) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalysisTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalysisTools.kt new file mode 100644 index 00000000..543ae39f --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalysisTools.kt @@ -0,0 +1,170 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.testIntegration.TestFinderHelper +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.awt.datatransfer.StringSelection + +internal class AnalysisTools(private val project: Project, private val actions: IdeActions) { + + fun domain(): ToolDomain = ToolDomain( + "analysis", + "The rest of Code ▸ Analyze and Navigate: a stack trace resolved to the project's files, duplicate code, " + + "inferred nullity, and what is related to a symbol (its tests, its subject, its supers, its implementations)", + listOf( + Tool(STACK_TRACE, ::stackTrace), + Tool(DUPLICATES, ::duplicates), + Tool(INFER_NULLITY, ::inferNullity), + Tool(RELATED, ::related), + ), + ) + + private suspend fun stackTrace(args: ToolArgs): ToolResult { + val text = args.string("text") + val frames = smartReadAction(project) { frames(text) } + withContext(Dispatchers.EDT) { CopyPasteManager.getInstance().setContents(StringSelection(text)) } + actions.dispatch(UNSCRAMBLE) + return ToolResult.toon( + buildJsonObject { + put("count", frames.size) + put("frames", buildJsonArray { frames.forEach { add(it) } }) + put("dispatched", true) + }, + ) + } + + private fun frames(text: String): List = FRAME.findAll(text).map { match -> + val (symbol, fileName, line) = match.destructured + val found = FilenameIndex.getVirtualFilesByName(fileName, GlobalSearchScope.projectScope(project)) + .map { Locations.relative(project, it) } + .sorted() + buildJsonObject { + put("symbol", symbol) + put("file", found.firstOrNull() ?: fileName) + put("line", line.toIntOrNull() ?: 0) + put("resolved", found.isNotEmpty()) + } + }.toList() + + private suspend fun duplicates(args: ToolArgs): ToolResult = fire(DUPLICATES_ACTION, args) + + private suspend fun inferNullity(args: ToolArgs): ToolResult = fire(INFER_NULLITY_ACTION, args) + + private suspend fun fire(id: String, args: ToolArgs): ToolResult { + val target = TargetContext.target(args) + if (target.named) actions.dispatch(id, target) else actions.dispatch(id) + return ToolResult.toon( + buildJsonObject { + put("id", id) + put("path", target.path ?: "") + put("dispatched", true) + }, + ) + } + + private suspend fun related(args: ToolArgs): ToolResult { + val kind = args.string("kind") + val id = RELATED_ACTIONS[kind] ?: throw ToolException("kind must be one of ${RELATED_ACTIONS.keys.joinToString()}") + val target = TargetContext.target(args) + if (target.path == null) throw ToolException("related needs path, line and column") + val rows = if (kind == "test" || kind == "subject") smartReadAction(project) { tests(args, kind) } else emptyList() + actions.dispatch(id, target) + return ToolResult.toon( + buildJsonObject { + put("kind", kind) + put("count", rows.size) + put("related", buildJsonArray { rows.forEach { add(it) } }) + put("dispatched", true) + }, + ) + } + + private fun tests(args: ToolArgs, kind: String): List { + val position = Locations.locate(project, args) + val element = position.psiFile.findElementAt(position.offset) ?: position.psiFile + val source = TestFinderHelper.findSourceElement(element) ?: element + val found = if (kind == "test") TestFinderHelper.findTestsForClass(source) else TestFinderHelper.findClassesForTest(source) + return found.map(::row) + } + + private fun row(element: PsiElement): JsonObject = buildJsonObject { + val file = element.containingFile + val document = file?.viewProvider?.document + put("file", file?.virtualFile?.let { Locations.relative(project, it) } ?: "") + put("line", document?.getLineNumber(element.textOffset)?.plus(1) ?: 0) + put("text", element.text.lineSequence().firstOrNull()?.trim()?.take(TEXT_CHARS) ?: "") + } + + companion object { + + private const val TEXT_CHARS = 120 + private const val UNSCRAMBLE = "Unscramble" + private const val DUPLICATES_ACTION = "MethodDuplicates" + private const val INFER_NULLITY_ACTION = "InferNullity" + + private val FRAME = Regex("""at\s+([\w$.<>]+)\(([\w.$-]+\.\w+):(\d+)\)""") + + val RELATED_ACTIONS: Map = linkedMapOf( + "test" to "GotoTest", + "subject" to "GotoTest", + "super" to "GotoSuperMethod", + "implementations" to "GotoImplementation", + ) + + val STACK_TRACE = ToolSpec( + "stack_trace", + "Code ▸ Analyze Stack Trace: the frames of a pasted trace resolved to the project's files and lines as data, and " + + "the IDE's Analyze Stack Trace dialog opened with the text on the clipboard for the user to confirm into a " + + "navigable console.", + listOf(Param("text", "The stack trace text")), + ) + + val DUPLICATES = ToolSpec( + "duplicates", + "Code ▸ Analyze ▸ Locate Duplicates on a file or the project: the IDE's duplicate-code analysis opens in its " + + "own window (Java and the languages that support it).", + listOf(Param("path", "File to analyse, relative to the project root (default: the project)", required = false)), + ) + + val INFER_NULLITY = ToolSpec( + "infer_nullity", + "Code ▸ Analyze ▸ Infer Nullity on a file or the project: the IDE proposes @Nullable/@NotNull annotations in its " + + "own dialog (Java).", + listOf(Param("path", "File to analyse, relative to the project root (default: the project)", required = false)), + mutates = true, + ) + + val RELATED = ToolSpec( + "related", + "What is related to the symbol at a position: kind=test the tests of the class, kind=subject the class a test " + + "covers (both as data through the IDE's test finder), kind=super its super method, kind=implementations " + + "what implements it; the IDE's Navigate action opens the target or its chooser.", + listOf( + Param("kind", "test, subject, super or implementations"), + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line of the symbol", type = "integer"), + Param("column", "1-based column of the symbol (default 1)", type = "integer", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt new file mode 100644 index 00000000..aa7980b0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/AnalyzeTools.kt @@ -0,0 +1,216 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.analysis.AnalysisScope +import com.intellij.codeInspection.InspectionManager +import com.intellij.codeInspection.ex.GlobalInspectionContextBase +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.progress.coroutineToIndicator +import com.intellij.openapi.project.Project +import com.intellij.packageDependencies.ForwardDependenciesBuilder +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.profile.codeInspection.InspectionProjectProfileManager +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import dev.lain.claudejb.controller.mcp.FocusKeeper +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class AnalyzeTools(private val project: Project, private val actions: IdeActions) { + + fun domain(): ToolDomain = ToolDomain( + "analyze", + "Code ▸ Inspect Code, Code Cleanup and Code ▸ Analyze on a scope: inspections in the Inspection Results window, " + + "cleanup applied, the dependencies of files, and the data flow of an expression", + listOf( + Tool(INSPECT_SCOPE, ::inspectScope), + Tool(CLEANUP, ::cleanup), + Tool(FILE_DEPENDENCIES, ::dependencies), + Tool(DATAFLOW, ::dataflow), + ), + ) + + private suspend fun inspectScope(args: ToolArgs): ToolResult { + val scope = scope(args) + withContext(Dispatchers.EDT) { + FocusKeeper.keeping(project) { + (InspectionManager.getInstance(project).createNewGlobalContext() as GlobalInspectionContextBase).doInspections(scope) + } + } + return ToolResult.toon( + buildJsonObject { + put("scope", scope.displayName) + put("started", true) + }, + ) + } + + private suspend fun cleanup(args: ToolArgs): ToolResult { + val scope = scope(args) + withContext(Dispatchers.EDT) { + val profile = InspectionProjectProfileManager.getInstance(project).currentProfile + val context = InspectionManager.getInstance(project).createNewGlobalContext() as GlobalInspectionContextBase + FocusKeeper.keeping(project) { context.codeCleanup(scope, profile, "Claude: code cleanup", null, false) } + } + return ToolResult.toon( + buildJsonObject { + put("scope", scope.displayName) + put("started", true) + }, + ) + } + + private suspend fun dependencies(args: ToolArgs): ToolResult { + val direction = args.optionalString("direction") ?: "forward" + val max = args.int("max", DEFAULT_MAX) + if (direction == "backward") { + actions.dispatch(BACKWARD_DEPENDENCIES, TargetContext.target(args)) + return ToolResult.toon( + buildJsonObject { + put("direction", direction) + put("dispatched", true) + }, + ) + } + if (direction != "forward") throw ToolException("direction must be forward or backward") + val scope = scope(args) + val builder = ForwardDependenciesBuilder(project, scope, args.int("transitive", 0)) + withBackgroundProgress(project, "Claude: analysing dependencies", cancellable = true) { coroutineToIndicator { builder.analyze() } } + val rows = readAction { + builder.dependencies.entries.take(max).map { (file, uses) -> + buildJsonObject { + put("file", name(file)) + put("depends_on", buildJsonArray { uses.forEach { add(JsonPrimitive(name(it))) } }) + } + } + } + return ToolResult.toon( + buildJsonObject { + put("direction", direction) + put("scope", scope.displayName) + put("count", rows.size) + put("files", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private suspend fun dataflow(args: ToolArgs): ToolResult { + val direction = args.optionalString("direction") ?: "to" + val id = when (direction) { + "to" -> SLICE_BACKWARD + "from" -> SLICE_FORWARD + else -> throw ToolException("direction must be to (what flows into this) or from (where this flows)") + } + val target = TargetContext.target(args) + if (target.path == null) throw ToolException("dataflow needs path, line and column") + actions.dispatch(id, target) + return ToolResult.toon( + buildJsonObject { + put("direction", direction) + put("path", target.path) + put("line", target.line) + put("column", target.column) + put("dispatched", true) + }, + ) + } + + private fun name(file: PsiFile): String = file.virtualFile?.let { Locations.relative(project, it) } ?: file.name + + private suspend fun scope(args: ToolArgs): AnalysisScope { + val kind = args.optionalString("scope") ?: "project" + val path = args.optionalString("path") + val module = args.optionalString("module") + return readAction { + when (kind) { + "project" -> AnalysisScope(project) + "module" -> AnalysisScope(moduleNamed(needed(kind, "module", module))) + "dir" -> AnalysisScope(directory(needed(kind, "path", path))) + "file" -> AnalysisScope(Locations.psiFile(project, needed(kind, "path", path))) + else -> throw ToolException("scope must be project, module, dir or file") + } + } + } + + private fun needed(kind: String, key: String, value: String?): String = value ?: throw ToolException("scope=$kind needs $key") + + private fun moduleNamed(name: String) = ModuleManager.getInstance(project).findModuleByName(name) + ?: throw ToolException("no module named $name; modules lists them") + + private fun directory(path: String) = PsiManager.getInstance(project).findDirectory(ReadTools.resolveDirectory(project, path)) + ?: throw ToolException("$path is not a directory of this project") + + companion object { + + private const val DEFAULT_MAX = 200 + private const val BACKWARD_DEPENDENCIES = "ShowBackwardPackageDeps" + private const val SLICE_BACKWARD = "SliceBackward" + private const val SLICE_FORWARD = "SliceForward" + + private val SCOPE_PARAMS = listOf( + Param("scope", "project (default), module, dir or file", required = false), + Param("path", "The directory or file, relative to the project root (scope=dir, file)", required = false), + Param("module", "The module name (scope=module)", required = false), + ) + + val INSPECT_SCOPE = ToolSpec( + "inspect_scope", + "Runs Code ▸ Inspect Code with the current profile on the project, a module, a directory or a file: the IDE " + + "analyses in the background and shows the Inspection Results window; problems and inspect give the " + + "findings of a file as data.", + SCOPE_PARAMS, + mutates = true, + ) + + val CLEANUP = ToolSpec( + "cleanup", + "Runs Code ▸ Code Cleanup with the current profile on a scope: every cleanup inspection's fix is applied, as " + + "one undoable command, and the changed files show in the editor.", + SCOPE_PARAMS, + mutates = true, + ) + + val FILE_DEPENDENCIES = ToolSpec( + "file_dependencies", + "Code ▸ Analyze ▸ Dependencies: for direction=forward (default) the files each file of the scope depends on, as " + + "data, transitive levels deep when transitive is given; for direction=backward the IDE's Backward " + + "Dependencies analysis is opened on path.", + SCOPE_PARAMS + listOf( + Param("direction", "forward (default) or backward", required = false), + Param( + "transitive", + "Levels of transitive dependencies to follow (default 0: direct only)", + type = "integer", + required = false, + ), + Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val DATAFLOW = ToolSpec( + "dataflow", + "Code ▸ Analyze ▸ Data Flow on the expression at a position: direction=to shows what flows into it, " + + "direction=from where it flows; the IDE's Analyze Data Flow window opens without taking the focus.", + listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line of the expression", type = "integer"), + Param("column", "1-based column of the expression (default 1)", type = "integer", required = false), + Param("direction", "to (default) or from", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Banners.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Banners.kt new file mode 100644 index 00000000..0cf6cac4 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Banners.kt @@ -0,0 +1,57 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotificationProvider +import com.intellij.ui.EditorNotifications +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Function +import javax.swing.JComponent + +@Service(Service.Level.PROJECT) +internal class BannerRegistry(private val project: Project) { + + class Banner(val text: String, val actions: List, @Volatile var chosen: String? = null) + + private val banners = ConcurrentHashMap() + + fun show(file: VirtualFile, text: String, actions: List) { + banners[file.path] = Banner(text, actions) + EditorNotifications.getInstance(project).updateNotifications(file) + } + + fun clear(file: VirtualFile): Banner? { + val banner = banners.remove(file.path) + EditorNotifications.getInstance(project).updateNotifications(file) + return banner + } + + fun bannerOf(file: VirtualFile): Banner? = banners[file.path] + + fun choose(file: VirtualFile, action: String) { + banners[file.path]?.chosen = action + EditorNotifications.getInstance(project).updateNotifications(file) + } +} + +internal class BannerProvider : EditorNotificationProvider { + + override fun collectNotificationData(project: Project, file: VirtualFile): Function? { + val registry = project.getService(BannerRegistry::class.java) + val banner = registry.bannerOf(file)?.takeIf { it.chosen == null } ?: return null + return Function { editor -> + EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info).apply { + text = banner.text + banner.actions.forEach { action -> createActionLabel(action) { registry.choose(file, action) } } + createActionLabel(DISMISS) { registry.clear(file) } + } + } + } + + private companion object { + const val DISMISS = "Dismiss" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt new file mode 100644 index 00000000..7893a47e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/BookmarkTools.kt @@ -0,0 +1,177 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.bookmark.Bookmark +import com.intellij.ide.bookmark.BookmarkType +import com.intellij.ide.bookmark.BookmarksManager +import com.intellij.ide.bookmark.FileBookmark +import com.intellij.ide.bookmark.LineBookmark +import com.intellij.ide.bookmark.providers.LineBookmarkProvider +import com.intellij.ide.projectView.ProjectView +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.controller.mcp.FocusKeeper +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class BookmarkTools(private val project: Project, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "bookmarks", + "The IDE's bookmarks and the project view: list, add and remove bookmarks on files and lines, and select a file in " + + "the project view's pane", + listOf( + Tool(BOOKMARKS, ::bookmarks), + Tool(BOOKMARK_ADD, ::add), + Tool(BOOKMARK_REMOVE, ::remove), + Tool(PROJECT_VIEW, ::projectView), + ), + ) + + private fun manager(): BookmarksManager = + BookmarksManager.getInstance(project) ?: throw ToolException("this IDE has no bookmarks manager") + + private suspend fun bookmarks(ignored: ToolArgs): ToolResult { + val manager = manager() + val rows = withContext(Dispatchers.EDT) { + manager.groups.flatMap { group -> group.getBookmarks().map { row(manager, group.name, it, group.getDescription(it)) } } + } + return ToolResult.toon( + buildJsonObject { + put("count", rows.size) + put("bookmarks", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private fun row(manager: BookmarksManager, group: String, bookmark: Bookmark, description: String?): JsonObject = buildJsonObject { + put("group", group) + put("file", (bookmark as? FileBookmark)?.file?.let { Locations.relative(project, it) } ?: "") + put("line", (bookmark as? LineBookmark)?.line?.plus(1) ?: 0) + put("type", manager.getType(bookmark)?.mnemonic?.takeIf { it != BookmarkType.DEFAULT.mnemonic }?.toString() ?: "") + put("description", description.orEmpty()) + } + + private suspend fun add(args: ToolArgs): ToolResult { + val path = args.string("path") + val line = args.int("line", 0) + val groupName = args.optionalString("group") + val description = args.optionalString("description") + val file = readAction { Locations.file(project, path) } + val manager = manager() + withContext(Dispatchers.EDT) { + val bookmark = bookmark(file, line) ?: throw ToolException("the IDE cannot bookmark $path") + val group = groupName?.let { manager.getGroup(it) ?: manager.addGroup(it, false) } + ?: manager.defaultGroup + ?: manager.addGroup(DEFAULT_GROUP, true) + FocusKeeper.keeping(project) { group?.add(bookmark, BookmarkType.DEFAULT, description) } + } + if (reveal.mirroring) reveal.toolWindow(BOOKMARKS_WINDOW) + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("line", line) + put("group", groupName ?: DEFAULT_GROUP) + put("added", true) + }, + ) + } + + private fun bookmark(file: VirtualFile, line: Int): Bookmark? = + LineBookmarkProvider.Util.find(project)?.createBookmark(file, line - 1) ?: manager().createBookmark(file) + + private suspend fun remove(args: ToolArgs): ToolResult { + val path = args.string("path") + val line = args.int("line", 0) + val file = readAction { Locations.file(project, path) } + val manager = manager() + val removed = withContext(Dispatchers.EDT) { + val matching = manager.bookmarks.filter { bookmark -> + bookmark is FileBookmark && bookmark.file == file && (line == 0 || (bookmark as? LineBookmark)?.line == line - 1) + } + matching.forEach(manager::remove) + matching.size + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("line", line) + put("removed", removed) + }, + ) + } + + private suspend fun projectView(args: ToolArgs): ToolResult { + val path = args.string("path") + val pane = args.optionalString("pane") + val file = readAction { Locations.file(project, path) } + val shown = FocusKeeper.keep(project) { + val view = ProjectView.getInstance(project) + if (pane != null) view.changeView(pane) + view.select(null, file, false) + view.currentViewId ?: "" + } + reveal.toolWindow(PROJECT_WINDOW) + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("pane", shown) + put("selected", true) + }, + ) + } + + companion object { + + private const val DEFAULT_GROUP = "Claude" + private const val BOOKMARKS_WINDOW = "Bookmarks" + private const val PROJECT_WINDOW = "Project" + + private val PATH = Param("path", "File path, absolute or relative to the project root") + + val BOOKMARKS = ToolSpec( + "bookmarks", + "Every bookmark of the project as the Bookmarks window lists them: group, file, line, mnemonic and description.", + ) + + val BOOKMARK_ADD = ToolSpec( + "bookmark_add", + "Adds a bookmark on a file, or on a line of it, to a group (created when new; default: the IDE's default group), " + + "with an optional description; the Bookmarks window is shown.", + listOf( + PATH, + Param("line", "1-based line to bookmark (default: the file itself)", type = "integer", required = false), + Param("group", "Bookmark group name (default: the default group)", required = false), + Param("description", "Text shown beside the bookmark", required = false), + ), + mutates = true, + ) + + val BOOKMARK_REMOVE = ToolSpec( + "bookmark_remove", + "Removes the bookmarks on a file, or only the one on a line; returns how many went.", + listOf(PATH, Param("line", "1-based line (default: every bookmark of the file)", type = "integer", required = false)), + mutates = true, + ) + + val PROJECT_VIEW = ToolSpec( + "project_view", + "Selects a file in the Project tool window, switching to a pane (ProjectPane, PackagesPane, Scope…) when given, " + + "without taking the focus; returns the pane shown.", + listOf(PATH, Param("pane", "The project view pane id (default: the current one)", required = false)), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DaemonHighlights.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DaemonHighlights.kt new file mode 100644 index 00000000..5067e0f2 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DaemonHighlights.kt @@ -0,0 +1,84 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerEx +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +internal class Highlight(val line: Int, val column: Int, val severity: String, val message: String, val inspection: String?) + +internal object Severities { + + val PARAM = Param("severity", "Minimum severity: error, warning (default), weak or all", required = false) + + fun minimum(args: ToolArgs): HighlightSeverity? = when (val name = (args.optionalString("severity") ?: "warning").lowercase()) { + "error" -> HighlightSeverity.ERROR + "warning" -> HighlightSeverity.WARNING + "weak" -> HighlightSeverity.WEAK_WARNING + "all" -> null + else -> throw ToolException("severity must be error, warning, weak or all, not " + name) + } +} + +internal class DaemonHighlights(private val project: Project) { + + suspend fun collect(file: VirtualFile, document: Document, minSeverity: HighlightSeverity?): List? { + val analysed = CompletableDeferred() + val connection = project.messageBus.connect() + try { + val editors = withContext(Dispatchers.EDT) { + val descriptor = OpenFileDescriptor(project, file).setUsePreviewTab(true) + val opened = FileEditorManager.getInstance(project).openFileEditor(descriptor, false) + connection.subscribe( + DaemonCodeAnalyzer.DAEMON_EVENT_TOPIC, + object : DaemonCodeAnalyzer.DaemonListener { + override fun daemonFinished(fileEditors: Collection) { + if (fileEditors.any { it in opened }) analysed.complete(Unit) + } + }, + ) + opened + } + if (editors.any { DaemonCodeAnalyzerEx.isHighlightingCompleted(it, project) }) analysed.complete(Unit) + withTimeoutOrNull(ANALYSIS_TIMEOUT_MILLIS) { analysed.await() } ?: return null + } finally { + connection.disconnect() + } + return readAction { + val out = ArrayList() + DaemonCodeAnalyzerEx.processHighlights(document, project, minSeverity, 0, document.textLength) { info -> + val description = info.description + if (description != null) { + val line = document.getLineNumber(info.startOffset) + out += Highlight( + line + 1, + info.startOffset - document.getLineStartOffset(line) + 1, + info.severity.name, + description, + info.inspectionToolId, + ) + } + true + } + out + } + } + + private companion object { + const val ANALYSIS_TIMEOUT_MILLIS = 30_000L + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt new file mode 100644 index 00000000..30240f8a --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/DiagnosticsTools.kt @@ -0,0 +1,183 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.analysis.problemsView.FileProblem +import com.intellij.analysis.problemsView.Problem +import com.intellij.analysis.problemsView.ProblemsCollector +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewTab +import com.intellij.analysis.problemsView.toolWindow.ProblemsViewToolWindowUtils +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.text.StringUtil +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class DiagnosticsTools( + private val project: Project, + private val reveal: Reveal, + private val daemon: DaemonHighlights = DaemonHighlights(project), +) { + + fun domain(): ToolDomain = ToolDomain( + "diagnostics", + "What the IDE's own analysis flags: the highlights of one file, the Problems view for the whole project, and its tabs", + listOf( + Tool(PROBLEMS) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::problemsOne)) }, + Tool(PROJECT_PROBLEMS, ::projectProblems), + Tool(PROBLEMS_VIEW, ::problemsView), + ), + ) + + private suspend fun problemsOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val severity = Severities.minimum(args) + val max = args.int("max", DEFAULT_MAX) + val (file, document) = readAction { + val file = ReadTools.resolveFile(project, path) + file to Locations.document(project, file) + } + val highlights = daemon.collect(file, document, severity) + ?: throw ToolException("the IDE has not finished analysing $path; retry in a moment") + return buildJsonObject { + put("path", path) + put("count", highlights.size) + put("truncated", highlights.size > max) + put( + "problems", + buildJsonArray { + highlights.take(max).forEach { h -> + add( + buildJsonObject { + put("line", h.line) + put("column", h.column) + put("severity", h.severity) + put("inspection", h.inspection ?: "") + put("message", h.message) + }, + ) + } + }, + ) + } + } + + private suspend fun projectProblems(args: ToolArgs): ToolResult { + val max = args.int("max", DEFAULT_MAX) + val group = args.optionalString("group") + val (rows, total) = withContext(Dispatchers.EDT) { + val collector = ProblemsCollector.getInstance(project) + val all = collector.getProblemFiles().flatMap { collector.getFileProblems(it) } + collector.getOtherProblems() + val matching = all.filter { group == null || it.group?.contains(group, ignoreCase = true) == true } + matching.take(max).map(::row) to matching.size + } + if (reveal.mirroring) reveal.problems("") + return ToolResult.toon( + buildJsonObject { + put("count", total) + put("truncated", total > rows.size) + put("problems", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private suspend fun problemsView(args: ToolArgs): ToolResult { + val wanted = args.optionalString("tab") + val tabs = withContext(Dispatchers.EDT) { tabs() } + val chosen = wanted?.let { name -> tab(tabs, name) } + chosen?.let { reveal.problems(it.id) } + val selected = withContext(Dispatchers.EDT) { ProblemsViewToolWindowUtils.getSelectedTab(project)?.getTabId() ?: "" } + return ToolResult.toon( + buildJsonObject { + put("selected", selected) + put("count", tabs.size) + put( + "tabs", + buildJsonArray { + tabs.forEach { tab -> + add( + buildJsonObject { + put("id", tab.id) + put("name", tab.name) + put("selected", tab.id == selected) + }, + ) + } + }, + ) + }, + ) + } + + private class ProblemsTab(val id: String, val name: String) + + private fun tabs(): List { + val toolWindow = ProblemsViewToolWindowUtils.getToolWindow(project) ?: throw ToolException("this IDE has no Problems tool window") + return toolWindow.contentManager.contents.mapNotNull { content -> + val tab = content.component as? ProblemsViewTab ?: return@mapNotNull null + ProblemsTab(tab.getTabId(), plain(content.displayName ?: tab.getName(0))) + } + } + + private fun plain(title: String): String = StringUtil.removeHtmlTags(title).replace(WHITESPACE, " ").trim() + + private fun tab(tabs: List, name: String): ProblemsTab = + tabs.firstOrNull { it.id.equals(name, ignoreCase = true) || it.name.equals(name, ignoreCase = true) } + ?: throw ToolException("no Problems tab named $name; the tabs are ${tabs.joinToString { it.id }}") + + private fun row(problem: Problem): JsonObject = buildJsonObject { + val inFile = problem as? FileProblem + put("file", inFile?.let { Locations.relative(project, it.file) } ?: "") + put("line", inFile?.line?.takeIf { it >= 0 }?.plus(1) ?: 0) + put("column", inFile?.column?.takeIf { it >= 0 }?.plus(1) ?: 0) + put("group", problem.group ?: "") + put("message", problem.text) + } + + companion object { + + private const val DEFAULT_MAX = 100 + private val WHITESPACE = Regex("\\s+") + + val PROBLEMS = ToolSpec( + "problems", + "The errors and warnings the IDE's analysis shows for one file, with line, column, severity and the inspection " + + "that raised each. Opens the file in an editor tab, since the IDE analyses open files.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("every touched file in one call"), + Severities.PARAM, + Param("max", "Maximum problems to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val PROJECT_PROBLEMS = ToolSpec( + "project_problems", + "Everything the Problems view lists right now across the project: file problems with their positions, " + + "and problems with no file. Filter by group to isolate what one inspection family or plugin reports.", + listOf( + Param("max", "Maximum problems to return (default $DEFAULT_MAX)", type = "integer", required = false), + Param("group", "Only problems whose group contains this text, e.g. an inspection family or a plugin", required = false), + ), + ) + + val PROBLEMS_VIEW = ToolSpec( + "problems_view", + "Lists the tabs of the IDE's Problems tool window (Current File, Project Errors, and any a plugin adds, such as " + + "Qodana's server-side analysis) and, given a tab, shows it to the user.", + listOf(Param("tab", "Tab id or title to show (default: only list them)", required = false)), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt new file mode 100644 index 00000000..e6b60a78 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditOpsTools.kt @@ -0,0 +1,178 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.mcp.FocusKeeper +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class EditOpsTools(private val project: Project, private val reveal: Reveal, private val actions: IdeActions) { + + private val replace = TextReplace(project) + + fun domain(): ToolDomain = ToolDomain( + "edit_ops", + "The Edit menu on a file: undo and redo through the IDE's undo stack, replace across files, and the line " + + "operations at a line", + listOf( + Tool(UNDO) { history(it, redo = false) }, + Tool(REDO) { history(it, redo = true) }, + Tool(SEARCH_REPLACE, ::searchReplace), + Tool(LINE_OPS, ::lineOps), + ), + ) + + private suspend fun history(args: ToolArgs, redo: Boolean): ToolResult { + val path = args.string("path") + val file = readAction { ReadTools.resolveFile(project, path) } + reveal.file(file) + val done = withContext(Dispatchers.EDT) { + val editor = FileEditorManager.getInstance(project).getSelectedEditor(file) + val undo = UndoManager.getInstance(project) + val available = if (redo) undo.isRedoAvailable(editor) else undo.isUndoAvailable(editor) + if (available) FocusKeeper.keeping(project) { if (redo) undo.redo(editor) else undo.undo(editor) } + available + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put(if (redo) "redone" else "undone", done) + }, + ) + } + + private suspend fun searchReplace(args: ToolArgs): ToolResult { + val query = args.string("query") + val replacement = args.string("replacement") + val max = args.int("max", DEFAULT_MAX_FILES) + val directory = readAction { args.optionalString("path")?.let { ReadTools.resolveDirectory(project, it).path } } + val model = replace.model(query, replacement, args.boolean("regex", false), args.boolean("case_sensitive", false), directory) + val explicit = args.strings("paths") + val files = if (explicit.isEmpty()) { + replace.filesMatching(model, max) + } else { + readAction { explicit.map { ReadTools.resolveFile(project, it) } } + } + val changes = files.map { replace.replaceIn(it, model) }.filter { it.replaced > 0 } + changes.firstOrNull()?.let { reveal.file(it.file) } + return ToolResult.toon( + buildJsonObject { + put("query", query) + put("files", changes.size) + put("replaced", changes.sumOf { it.replaced }) + put("truncated", explicit.isEmpty() && files.size >= max) + put( + "changes", + buildJsonArray { + changes.forEach { change -> + add( + buildJsonObject { + put("file", Locations.relative(project, change.file)) + put("replaced", change.replaced) + }, + ) + } + }, + ) + }, + ) + } + + private suspend fun lineOps(args: ToolArgs): ToolResult { + val name = args.string("action") + val id = LINE_ACTIONS[name] ?: throw ToolException("action must be one of ${LINE_ACTIONS.keys.joinToString()}") + val target = TargetContext.target(args, preview = false) + if (target.path == null) throw ToolException("line_ops needs path") + actions.dispatch(id, target) + return ToolResult.toon( + buildJsonObject { + put("action", name) + put("path", target.path) + put("line", target.line) + put("dispatched", true) + }, + ) + } + + companion object { + + private const val DEFAULT_MAX_FILES = 50 + + val LINE_ACTIONS: Map = linkedMapOf( + "join" to "EditorJoinLines", + "duplicate" to "EditorDuplicate", + "delete" to "EditorDeleteLine", + "indent" to "EditorIndentLineOrSelection", + "unindent" to "EditorUnindentSelection", + ) + + private val PATH = Param("path", "File path, absolute or relative to the project root") + + val UNDO = ToolSpec( + "undo", + "Undoes the last change of a file through the IDE's undo stack, exactly as Edit ▸ Undo would in that file's " + + "editor; a change that spans other files asks the user first. Returns whether there was anything to undo.", + listOf(PATH), + mutates = true, + ) + + val REDO = ToolSpec( + "redo", + "Redoes the last undone change of a file through the IDE's undo stack, as Edit ▸ Redo would. Returns whether " + + "there was anything to redo.", + listOf(PATH), + mutates = true, + ) + + val SEARCH_REPLACE = ToolSpec( + "search_replace", + "Replaces text or a regular expression across files, as Replace in Files would with Replace All: every " + + "occurrence in the files that match (or only in paths), one undoable command per file, the first changed " + + "file shown in the editor. Returns the files and the count per file.", + listOf( + Param("query", "Text or regular expression to find"), + Param("replacement", "Replacement text; with regex, \$1 refers to a group"), + Param("regex", "true to treat query as a regular expression (default false)", type = "boolean", required = false), + Param("case_sensitive", "true to match case (default false)", type = "boolean", required = false), + Param("path", "Directory to replace under, relative to the project root (default: whole project)", required = false), + Batch.param(Batch.PATHS, "Only these files, all in one call, instead of every file that matches"), + Param( + "max", + "Maximum files to change when paths is not given (default $DEFAULT_MAX_FILES)", + type = "integer", + required = false, + ), + ), + mutates = true, + ) + + val LINE_OPS = ToolSpec( + "line_ops", + "One of the editor's line operations at a line of a file, as the Edit menu would with the caret there: join " + + "(with the next line), duplicate, delete, indent or unindent. The file opens in a tab without focus.", + listOf( + Param("action", "join, duplicate, delete, indent or unindent"), + PATH, + Param("line", "1-based line to act on (default 1)", type = "integer", required = false), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditTools.kt new file mode 100644 index 00000000..1b185f13 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditTools.kt @@ -0,0 +1,205 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.history.LocalHistory +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.findOrCreateFile +import com.intellij.openapi.vfs.writeText +import com.intellij.psi.PsiDocumentManager +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.TextEdit +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import dev.lain.claudejb.view.diff.DiffEditors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.IOException +import java.nio.file.Path + +internal class EditTools(private val project: Project, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "edit", + "Text edits through the IDE's document model: one undo entry each, saved to disk, shown as a diff; every tool " + + "takes one file or a list", + listOf( + Tool(REPLACE_TEXT) { ToolResult.toon(Batch.run(it, Batch.REPLACEMENTS, ::replaceOne)) }, + Tool(INSERT_TEXT) { ToolResult.toon(Batch.run(it, Batch.INSERTIONS, ::insertOne)) }, + Tool(CREATE_FILE) { ToolResult.toon(Batch.run(it, Batch.FILES, ::createOne)) }, + Tool(WRITE_FILE) { ToolResult.toon(Batch.run(it, Batch.FILES, ::writeOne)) }, + ), + ) + + private suspend fun replaceOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val old = args.string("old_string") + val new = args.string("new_string") + val all = args.boolean("replace_all", false) + val outcome = edit(path, "replace text in") { TextEdit.replace(it, old, new, all) } + return buildJsonObject { + put("path", path) + put("replaced", outcome.count) + put("lines", buildJsonArray { outcome.lines.forEach { add(JsonPrimitive(it)) } }) + } + } + + private suspend fun insertOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val line = args.int("line", 0) + val content = args.string("content") + val outcome = edit(path, "insert text in") { TextEdit.insertAt(it, line, content) } + return buildJsonObject { + put("path", path) + put("line", line) + put("lines_inserted", outcome.count) + } + } + + private suspend fun createOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val content = args.string("content") + val absolute = Locations.inside(project, path) + if (exists(absolute)) throw ToolException("$path already exists; use write_file, replace_text or insert_text to change it") + create(path, absolute, content) + return buildJsonObject { + put("path", path) + put("lines", content.lines().size) + } + } + + private suspend fun writeOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val content = args.string("content") + val absolute = Locations.inside(project, path) + val existed = exists(absolute) + if (existed) edit(path, "write") { TextEdit.whole(it, content) } else create(path, absolute, content) + return buildJsonObject { + put("path", path) + put("created", !existed) + put("lines", content.lines().size) + } + } + + private fun exists(absolute: Path): Boolean = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(absolute) != null + + private suspend fun create(path: String, absolute: Path, content: String) { + if (absolute.parent == null) throw ToolException("$path has no parent directory") + val file = try { + writeCommandAction(project, "Claude: create ${absolute.fileName}") { createOnDisk(absolute, content) } + } catch (e: IOException) { + throw ToolException("cannot create $path: ${e.message}", e) + } + reveal.file(file) + } + + private fun createOnDisk(absolute: Path, content: String): VirtualFile = + VfsUtil.createDirectories(absolute.parent.toString()).findOrCreateFile(absolute.fileName.toString()).also { it.writeText(content) } + + private suspend fun edit(path: String, verb: String, change: (String) -> TextEdit.Outcome): TextEdit.Outcome { + Locations.inside(project, path) + val (file, document) = readAction { + val file = ReadTools.resolveFile(project, path) + file to Locations.document(project, file) + } + val label = "Claude: $verb ${file.name}" + val (before, outcome) = writeCommandAction(project, label) { + val before = document.immutableCharSequence.toString() + val outcome = change(before) + apply(file, document, outcome.text) + before to outcome + } + LocalHistory.getInstance().putSystemLabel(project, label) + withContext(Dispatchers.EDT) { + DiffEditors.openTextDiff( + project, + file.path, + DiffEditors.TextSide("Before", before), + DiffEditors.TextSide("After Claude's edit", outcome.text), + ) + } + return outcome + } + + private fun apply(file: VirtualFile, document: Document, text: String) { + if (!document.isWritable) throw ToolException("${Locations.relative(project, file)} is read-only") + document.replaceString(0, document.textLength, text) + PsiDocumentManager.getInstance(project).commitDocument(document) + FileDocumentManager.getInstance().saveDocument(document) + } + + companion object { + + private const val PATH = "File path, absolute or relative to the project root" + + val REPLACE_TEXT = ToolSpec( + "replace_text", + "Replaces one literal occurrence of old_string in a file (every occurrence with replace_all), as one undo entry, " + + "saved to disk and shown as a Before/After diff. Fails when old_string is missing or ambiguous. Several files " + + "at once with edits.", + listOf( + Param("path", PATH, required = false), + Param("old_string", "Exact text to replace; must be unique unless replace_all is true", required = false), + Param("new_string", "Text that replaces it", required = false), + Param("replace_all", "true to replace every occurrence (default false)", type = "boolean", required = false), + Batch.param(Batch.REPLACEMENTS, "Several replacements at once: a list of {path, old_string, new_string, replace_all?}"), + ), + mutates = true, + ) + + val INSERT_TEXT = ToolSpec( + "insert_text", + "Inserts whole lines before a 1-based line of a file (lines + 1 appends), as one undo entry, saved to disk " + + "and shown as a Before/After diff. Several files at once with edits.", + listOf( + Param("path", PATH, required = false), + Param("line", "1-based line the content goes before; one past the last line appends", type = "integer", required = false), + Param("content", "Text to insert; a trailing newline is added when missing", required = false), + Batch.param(Batch.INSERTIONS, "Several insertions at once: a list of {path, line, content}"), + ), + mutates = true, + ) + + val CREATE_FILE = ToolSpec( + "create_file", + "Creates a new file with the given content through the IDE, creating missing directories, and opens it. " + + "Fails when the file already exists. Several files at once with files.", + listOf( + Param("path", "Path of the new file, absolute or relative to the project root", required = false), + Param("content", "Full content of the new file", required = false), + Batch.param(Batch.FILES, "Several new files at once: a list of {path, content}"), + ), + mutates = true, + ) + + val WRITE_FILE = ToolSpec( + "write_file", + "Writes a whole file: replaces the content of an existing file as one undo entry shown as a Before/After " + + "diff, or creates it when absent. Use it for full rewrites; replace_text for targeted changes. Several " + + "files at once with files.", + listOf( + Param("path", PATH, required = false), + Param("content", "Full content the file ends up with", required = false), + Batch.param(Batch.FILES, "Several files at once: a list of {path, content}"), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditorTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditorTools.kt new file mode 100644 index 00000000..4639b344 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/EditorTools.kt @@ -0,0 +1,180 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class EditorTools(private val project: Project, private val reveal: Reveal, private val actions: IdeActions) { + + fun domain(): ToolDomain = ToolDomain( + "editor", + "What the editor shows and whether the index is ready: open a file at a line, the active file and caret, indexing " + + "state, and the Code menu's editing actions at a position", + listOf( + Tool(OPEN_FILE) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::openOne)) }, + Tool(ACTIVE_FILE, ::activeFile), + Tool(INDEX_STATUS, ::indexStatus), + Tool(EDITOR_ACTION, ::editorAction), + ), + ) + + private suspend fun editorAction(args: ToolArgs): ToolResult { + val name = args.string("action") + val id = EDITOR_ACTIONS[name] ?: throw ToolException("action must be one of ${EDITOR_ACTIONS.keys.joinToString()}") + val target = TargetContext.target(args, preview = false) + if (target.path == null) throw ToolException("editor_action needs path") + actions.dispatch(id, target) + return ToolResult.toon( + buildJsonObject { + put("action", name) + put("id", id) + put("path", target.path) + put("line", target.line) + put("column", target.column) + put("dispatched", true) + }, + ) + } + + private suspend fun openOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val line = args.int("line", 1) + val column = args.int("column", 1) + if (line < 1 || column < 1) throw ToolException("line and column start at 1") + val file = readAction { Locations.file(project, path) } + val opened = reveal.file(file, line, column) + return buildJsonObject { + put("path", path) + put("line", line) + put("column", column) + put("opened", opened) + } + } + + private suspend fun activeFile(ignored: ToolArgs): ToolResult = withContext(Dispatchers.EDT) { + val manager = FileEditorManager.getInstance(project) + val editor = manager.selectedTextEditor + val file = editor?.let { FileDocumentManager.getInstance().getFile(it.document) } + val caret = editor?.caretModel?.logicalPosition + ToolResult.toon( + buildJsonObject { + put("file", file?.let { Locations.relative(project, it) } ?: "") + put("line", caret?.line?.plus(1) ?: 0) + put("column", caret?.column?.plus(1) ?: 0) + put("selected", editor?.selectionModel?.selectedText?.take(SELECTION_CHARS) ?: "") + put("open", buildJsonArray { manager.openFiles.forEach { add(JsonPrimitive(Locations.relative(project, it))) } }) + }, + ) + } + + private suspend fun indexStatus(args: ToolArgs): ToolResult { + val wait = args.boolean("wait", false) + val dumb = DumbService.getInstance(project).isDumb + val indexing = if (dumb && wait) smartReadAction(project) { DumbService.getInstance(project).isDumb } else dumb + return ToolResult.toon( + buildJsonObject { + put("indexing", indexing) + put("waited", dumb && wait) + }, + ) + } + + companion object { + + private const val SELECTION_CHARS = 200 + + val OPEN_FILE = ToolSpec( + "open_file", + "Opens a file in an editor tab and places its caret at a line and column, as the IDE's Go to File does, " + + "without taking the focus from where the user is working.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("each opens in its own tab"), + Param("line", "1-based line for the caret (default 1)", type = "integer", required = false), + Param("column", "1-based column for the caret (default 1)", type = "integer", required = false), + ), + ) + + val ACTIVE_FILE = ToolSpec( + "active_file", + "The file in the selected editor with its caret position and selection, plus every open file. " + + "Empty fields when no text editor is selected.", + ) + + val EDITOR_ACTIONS: Map = linkedMapOf( + "override" to "OverrideMethods", + "implement" to "ImplementMethods", + "delegate" to "DelegateMethods", + "generate" to "Generate", + "surround" to "SurroundWith", + "unwrap" to "Unwrap", + "comment_line" to "CommentByLineComment", + "comment_block" to "CommentByBlockComment", + "move_statement_up" to "MoveStatementUp", + "move_statement_down" to "MoveStatementDown", + "move_element_left" to "MoveElementLeft", + "move_element_right" to "MoveElementRight", + "move_line_up" to "MoveLineUp", + "move_line_down" to "MoveLineDown", + "rearrange" to "RearrangeCode", + "auto_indent" to "AutoIndentLines", + "insert_template" to "InsertLiveTemplate", + "save_template" to "SaveAsTemplate", + "fold" to "CollapseRegion", + "unfold" to "ExpandRegion", + "fold_recursively" to "CollapseRegionRecursively", + "unfold_recursively" to "ExpandRegionRecursively", + "fold_all" to "CollapseAllRegions", + "unfold_all" to "ExpandAllRegions", + "update_copyright" to "UpdateCopyright", + "quick_doc" to "QuickJavaDoc", + "quick_definition" to "QuickImplementations", + "quick_type" to "QuickTypeDefinition", + ) + + val EDITOR_ACTION = ToolSpec( + "editor_action", + "Performs one of the Code menu's editing actions at a position of a file, exactly as the editor would with the " + + "caret there: override, implement, delegate, generate, surround, unwrap, comment_line, comment_block, " + + "move_statement_up/down, move_element_left/right, move_line_up/down, rearrange, auto_indent, " + + "insert_template, save_template, fold, unfold, fold_recursively, unfold_recursively, fold_all, unfold_all, " + + "update_copyright, quick_doc, quick_definition, quick_type. The file opens in a tab without focus; an " + + "action that shows a chooser or a popup leaves it for the user.", + listOf( + Param("action", "One of the names above"), + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line for the caret (default 1)", type = "integer", required = false), + Param("column", "1-based column for the caret (default 1)", type = "integer", required = false), + ), + mutates = true, + ) + + val INDEX_STATUS = ToolSpec( + "index_status", + "Whether the IDE is still indexing; with wait, returns once indexing finishes so symbol tools can be trusted.", + listOf(Param("wait", "true to wait for indexing to finish (default false)", type = "boolean", required = false)), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileTools.kt new file mode 100644 index 00000000..abcd7569 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FileTools.kt @@ -0,0 +1,188 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.fileTypes.ExactFileNameMatcher +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.findOrCreateFile +import com.intellij.openapi.vfs.readText +import com.intellij.openapi.vfs.writeText +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.awt.datatransfer.StringSelection +import java.io.IOException +import java.nio.file.Path + +internal class FileTools(private val project: Project, private val actions: IdeActions, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "files", + "The project view's file menu on a path: copy its path, read or set its file type, add it to an ignore file, " + + "delete it", + listOf(Tool(COPY_PATH, ::copyPath), Tool(FILE_TYPE, ::fileType), Tool(IGNORE, ::ignore), Tool(DELETE_FILE, ::deleteFile)), + ) + + private suspend fun copyPath(args: ToolArgs): ToolResult { + val path = args.string("path") + val kind = args.optionalString("kind") ?: "relative" + val file = readAction { Locations.any(project, path) } + val text = when (kind) { + "absolute" -> file.path + + "relative" -> Locations.relative(project, file) + + "name" -> file.name + + "reference" -> { + actions.dispatch(COPY_REFERENCE, TargetContext.target(args)) + "" + } + + else -> throw ToolException("kind must be absolute, relative, name or reference") + } + if (text.isNotEmpty()) withContext(Dispatchers.EDT) { CopyPasteManager.getInstance().setContents(StringSelection(text)) } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("kind", kind) + put("text", text) + put("copied", true) + }, + ) + } + + private suspend fun fileType(args: ToolArgs): ToolResult { + val path = args.string("path") + val wanted = args.optionalString("type") + val file = readAction { Locations.file(project, path) } + if (wanted != null) { + val manager = FileTypeManager.getInstance() + val type = manager.findFileTypeByName(wanted) ?: throw ToolException("this IDE has no file type named $wanted") + writeCommandAction(project, "Claude: associate ${file.name} with $wanted") { + manager.associate(type, ExactFileNameMatcher(file.name)) + } + } + val type = readAction { file.fileType } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("type", type.name) + put("display", type.displayName) + put("binary", type.isBinary) + }, + ) + } + + private suspend fun ignore(args: ToolArgs): ToolResult { + val path = args.string("path") + val ignoreFile = args.optionalString("file") ?: GITIGNORE + if (ignoreFile !in IGNORE_FILES) throw ToolException("file must be one of ${IGNORE_FILES.joinToString()}") + val entry = Locations.relative(project, readAction { Locations.file(project, path) }) + val base = project.basePath ?: throw ToolException("this project has no directory on disk") + val target = writeCommandAction(project, "Claude: ignore $path") { + val file = VfsUtil.createDirectories(base).findOrCreateFile(ignoreFile) + val lines = file.readText().lines().filter { it.isNotEmpty() } + if (entry !in lines) file.writeText((lines + entry).joinToString("\n") + "\n") + file + } + reveal.file(target) + return ToolResult.toon( + buildJsonObject { + put("path", entry) + put("file", ignoreFile) + put("ignored", true) + }, + ) + } + + private suspend fun deleteFile(args: ToolArgs): ToolResult { + val paths = args.strings("paths") + val files = readAction { inside(paths.map { Locations.any(project, it) }) } + try { + writeCommandAction(project, "Claude: delete ${paths.size} path(s)") { files.forEach { it.delete(this) } } + } catch (e: IOException) { + throw ToolException("cannot delete: ${e.message}", e) + } + LocalFileSystem.getInstance().refreshNioFiles(files.map { Path.of(it.path) }) + return ToolResult.toon( + buildJsonObject { + put("count", files.size) + put("deleted", buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }) + }, + ) + } + + private fun inside(files: List): List { + val base = project.basePath + val outside = files.firstOrNull { base == null || !DiffPresenter.isWithinRoot(it.path, base) } + val problem = when { + files.isEmpty() -> "paths must name at least one file or directory" + outside != null -> "${outside.path} is outside the project" + else -> return files + } + throw ToolException(problem) + } + + companion object { + + private const val COPY_REFERENCE = "CopyReference" + private const val GITIGNORE = ".gitignore" + + val IGNORE_FILES: List = listOf(GITIGNORE, ".dockerignore", ".npmignore", ".eslintignore", ".prettierignore", ".helmignore") + + private val PATH = Param("path", "File or directory path, absolute or relative to the project root") + + val COPY_PATH = ToolSpec( + "copy_path", + "Copy Path on a file: absolute, relative (default) or name are returned and put on the clipboard; reference " + + "runs the IDE's Copy Reference (the qualified name of the symbol at the position) onto the clipboard.", + listOf(PATH, Param("kind", "absolute, relative (default), name or reference", required = false)), + ) + + val FILE_TYPE = ToolSpec( + "file_type", + "The file type the IDE assigns to a file; with type, associates the file's name with that type through the " + + "IDE's file type manager, as Override File Type does.", + listOf(PATH, Param("type", "A registered file type name to associate, e.g. PLAIN_TEXT, JSON, Kotlin", required = false)), + mutates = true, + ) + + val IGNORE = ToolSpec( + "ignore", + "Adds a path to an ignore file at the project root (" + IGNORE_FILES.joinToString() + "), creating the file " + + "when absent and skipping an entry already there; the ignore file opens in the editor.", + listOf(PATH, Param("file", "The ignore file (default .gitignore)", required = false)), + mutates = true, + ) + + val DELETE_FILE = ToolSpec( + "delete_file", + "Deletes files or directories of the project through the IDE's virtual file system, one undoable command, " + + "all paths in one call; never outside the project. For a symbol or a file with usages use safe_delete.", + listOf(Batch.param(Batch.PATHS, "The files or directories to delete, all in one call")), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FormatTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FormatTools.kt new file mode 100644 index 00000000..2ced2f79 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/FormatTools.kt @@ -0,0 +1,106 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.codeInsight.actions.AbstractLayoutCodeProcessor +import com.intellij.codeInsight.actions.OptimizeImportsProcessor +import com.intellij.codeInsight.actions.ReformatCodeProcessor +import com.intellij.openapi.application.edtWriteAction +import com.intellij.openapi.application.readAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile +import com.intellij.util.DocumentUtil +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class FormatTools(private val project: Project, private val io: CoroutineDispatcher = Dispatchers.IO) { + + fun domain(): ToolDomain = ToolDomain( + "format", + "The IDE's Reformat Code and Optimize Imports on one file, respecting .editorconfig and the project code style", + listOf( + Tool(REFORMAT) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::reformatOne)) }, + Tool(OPTIMIZE_IMPORTS) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::optimizeOne)) }, + ), + ) + + private suspend fun reformatOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val fromLine = args.int("from_line", 0) + val toLine = args.int("to_line", 0) + val (psiFile, document) = readAction { open(path) } + val range = readAction { range(document, fromLine, toLine) } + val changed = process(document) { ReformatCodeProcessor(project, psiFile, range, false) } + return buildJsonObject { + put("path", path) + put("from_line", fromLine) + put("to_line", toLine) + put("changed", changed) + } + } + + private suspend fun optimizeOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val (psiFile, document) = readAction { open(path) } + val changed = process(document) { OptimizeImportsProcessor(project, psiFile) } + return buildJsonObject { + put("path", path) + put("changed", changed) + } + } + + private fun open(path: String): Pair { + val psiFile = Locations.psiFile(project, path) + return psiFile to (psiFile.viewProvider.document ?: throw ToolException("$path has no document")) + } + + private fun range(document: Document, fromLine: Int, toLine: Int): TextRange? { + if (fromLine == 0 && toLine == 0) return null + if (fromLine < 1 || toLine < fromLine || !DocumentUtil.isValidLine(toLine - 1, document)) { + throw ToolException("from_line and to_line must both be given, 1 <= from_line <= to_line <= ${document.lineCount}") + } + return TextRange(document.getLineStartOffset(fromLine - 1), document.getLineEndOffset(toLine - 1)) + } + + private suspend fun process(document: Document, processor: () -> AbstractLayoutCodeProcessor): Boolean { + val stamp = document.modificationStamp + withContext(io) { processor().runWithoutProgress() } + edtWriteAction { FileDocumentManager.getInstance().saveDocument(document) } + return document.modificationStamp != stamp + } + + companion object { + + val REFORMAT = ToolSpec( + "reformat", + "Runs the IDE's Reformat Code on one file, or on a line range, with the project's code style and .editorconfig.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("whole files only"), + Param("from_line", "First 1-based line to reformat (default: the whole file)", type = "integer", required = false), + Param("to_line", "Last 1-based line to reformat; required with from_line", type = "integer", required = false), + ), + mutates = true, + ) + + val OPTIMIZE_IMPORTS = ToolSpec( + "optimize_imports", + "Runs the IDE's Optimize Imports on one file; changed is false for languages without an import optimizer.", + listOf(Param("path", "File path, absolute or relative to the project root", required = false), Batch.paths("one row each")), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt new file mode 100644 index 00000000..7a523694 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/HierarchyTools.kt @@ -0,0 +1,139 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.PsiRecursiveElementWalkingVisitor +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.searches.ReferencesSearch +import com.intellij.psi.util.PsiTreeUtil +import dev.lain.claudejb.controller.mcp.tools.code.Locations.place +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class HierarchyTools(private val project: Project) { + + fun domain(): ToolDomain = ToolDomain( + "hierarchy", + "Who calls a symbol and what it calls, resolved by the index; language-agnostic", + listOf(Tool(HIERARCHY, ::hierarchy)), + ) + + private suspend fun hierarchy(args: ToolArgs): ToolResult { + val kind = args.string("kind") + if (kind != CALLERS && kind != CALLEES) throw ToolException("kind must be $CALLERS or $CALLEES") + val depth = args.int("depth", 1).coerceIn(1, MAX_DEPTH) + val max = args.int("max", DEFAULT_MAX) + val (root, rows) = try { + readAction { + val target = Locations.declarationAt(project, args) + val walk = Walk(kind == CALLERS, depth, max) + walk.from(target, 1) + name(target) to walk.rows + } + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + return ToolResult.toon( + buildJsonObject { + put("kind", kind) + put("root", root) + put("count", rows.size) + put("truncated", rows.size >= max) + put("calls", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private inner class Walk(private val callers: Boolean, private val depth: Int, private val max: Int) { + + val rows = ArrayList() + private val visited = HashSet() + + fun from(element: PsiElement, level: Int) { + visited += element + if (level > depth) return + for (related in if (callers) callersOf(element) else calleesOf(element)) { + if (rows.size >= max) return + if (!visited.add(related)) continue + rows += row(level, related) + from(related, level + 1) + } + } + + private fun callersOf(element: PsiElement): List { + val out = LinkedHashSet() + ReferencesSearch.search(element, GlobalSearchScope.projectScope(project)).forEach { reference -> + PsiTreeUtil.getParentOfType(reference.element, PsiNameIdentifierOwner::class.java, true)?.let { out += it } + out.size < max + } + return out.toList() + } + + private fun calleesOf(element: PsiElement): List { + val out = LinkedHashSet() + val index = ProjectFileIndex.getInstance(project) + element.navigationElement.accept( + object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(node: PsiElement) { + super.visitElement(node) + for (reference in node.references) { + val resolved = reference.resolve() ?: continue + if (resolved is PsiNameIdentifierOwner && !resolved.isEquivalentTo(element) && inProject(index, resolved)) { + out += resolved + } + } + if (out.size >= max) stopWalking() + } + }, + ) + return out.toList() + } + } + + private fun inProject(index: ProjectFileIndex, element: PsiElement): Boolean { + val file = element.navigationElement.containingFile?.virtualFile ?: return false + return index.isInContent(file) + } + + private fun row(level: Int, element: PsiElement): JsonObject = buildJsonObject { + put("depth", level) + put("name", name(element)) + put("kind", Locations.kind(element)) + place(project, element) + } + + private fun name(element: PsiElement): String = (element as? PsiNamedElement)?.name ?: "" + + companion object { + + private const val CALLERS = "callers" + private const val CALLEES = "callees" + private const val MAX_DEPTH = 3 + private const val DEFAULT_MAX = 50 + + val HIERARCHY = ToolSpec( + "hierarchy", + "The call hierarchy of the symbol at a position: callers (who references it, by enclosing declaration) or " + + "callees (the declarations it references), each level nested up to depth.", + Locations.POSITION + listOf( + Param("kind", "$CALLERS or $CALLEES"), + Param("depth", "Levels to follow, 1 to $MAX_DEPTH (default 1)", type = "integer", required = false), + Param("max", "Maximum rows to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt new file mode 100644 index 00000000..ec778795 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/IndexTools.kt @@ -0,0 +1,149 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.stubs.StubIndex +import com.intellij.psi.stubs.StubIndexExtension +import com.intellij.psi.stubs.StubIndexKey +import com.intellij.util.indexing.FileBasedIndex +import com.intellij.util.indexing.ID +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class IndexTools(private val project: Project) { + + fun domain(): ToolDomain = ToolDomain( + "index", + "The IDE's file-based and stub indexes, by name: their keys, and the files or elements behind one key", + listOf(Tool(INDEX_KEYS, ::keys), Tool(INDEX_QUERY, ::query), Tool(STUB_QUERY, ::stubQuery)), + ) + + private fun index(name: String): ID { + @Suppress("UNCHECKED_CAST") + return ID.findByName(name) as ID? ?: throw ToolException("no file-based index named $name") + } + + private suspend fun keys(args: ToolArgs): ToolResult { + val name = args.string("index") + val max = args.int("max", DEFAULT_MAX) + val keys = smartReadAction(project) { FileBasedIndex.getInstance().getAllKeys(index(name), project).map { it.toString() }.sorted() } + return ToolResult.toon( + buildJsonObject { + put("index", name) + put("count", keys.size) + put("truncated", keys.size > max) + put("keys", buildJsonArray { keys.take(max).forEach { add(JsonPrimitive(it)) } }) + }, + ) + } + + private suspend fun query(args: ToolArgs): ToolResult { + val name = args.string("index") + val key = args.string("key") + val max = args.int("max", DEFAULT_MAX) + val files = smartReadAction(project) { + val id = index(name) + val scope = GlobalSearchScope.projectScope(project) + val exact = FileBasedIndex.getInstance().getAllKeys(id, project).firstOrNull { it.toString() == key } + ?: throw ToolException("no key $key in $name; index_keys lists them") + FileBasedIndex.getInstance().getContainingFiles(id, exact, scope).map { Locations.relative(project, it) }.sorted() + } + return ToolResult.toon( + buildJsonObject { + put("index", name) + put("key", key) + put("count", files.size) + put("truncated", files.size > max) + put("files", buildJsonArray { files.take(max).forEach { add(JsonPrimitive(it)) } }) + }, + ) + } + + private suspend fun stubQuery(args: ToolArgs): ToolResult { + val name = args.string("index") + val key = args.optionalString("key") + val max = args.int("max", DEFAULT_MAX) + val indexKey = stubIndex(name) + val rows = smartReadAction(project) { + val stubs = StubIndex.getInstance() + if (key == null) { + stubs.getAllKeys(indexKey, project).map { it.toString() }.sorted().map { buildJsonObject { put("key", it) } } + } else { + val scope = GlobalSearchScope.projectScope(project) + StubIndex.getElements(indexKey, key, project, scope, PsiElement::class.java).map { element -> + buildJsonObject { + put("key", key) + put("file", element.containingFile?.virtualFile?.let { Locations.relative(project, it) } ?: "") + put("line", element.containingFile?.viewProvider?.document?.getLineNumber(element.textOffset)?.plus(1) ?: 0) + put("text", element.text.lineSequence().firstOrNull()?.trim()?.take(TEXT_CHARS) ?: "") + } + } + } + } + return ToolResult.toon( + buildJsonObject { + put("index", name) + put("key", key ?: "") + put("count", rows.size) + put("truncated", rows.size > max) + put("rows", buildJsonArray { rows.take(max).forEach { add(it) } }) + }, + ) + } + + private fun stubIndex(name: String): StubIndexKey { + @Suppress("UNCHECKED_CAST") + return StubIndexExtension.EP_NAME.extensionList.firstOrNull { it.key.name == name }?.key as StubIndexKey? + ?: throw ToolException("no stub index named $name; the registered ones are ${stubIndexNames()}") + } + + private fun stubIndexNames(): String = StubIndexExtension.EP_NAME.extensionList.joinToString { it.key.name } + + companion object { + + private const val DEFAULT_MAX = 200 + private const val TEXT_CHARS = 120 + + val INDEX_KEYS = ToolSpec( + "index_keys", + "The keys of a file-based index by its registered name (e.g. FilenameIndex, filetypes, TodoIndex, or a " + + "plugin's), as the IDE holds them for this project.", + listOf( + Param("index", "The index id as registered by its ID.create name"), + Param("max", "Maximum keys (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val INDEX_QUERY = ToolSpec( + "index_query", + "The project files a file-based index holds under one key (the key as index_keys prints it).", + listOf( + Param("index", "The index id"), + Param("key", "The key, as index_keys prints it"), + Param("max", "Maximum files (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val STUB_QUERY = ToolSpec( + "stub_query", + "A stub index by its name (e.g. java.class.shortname, org.jetbrains.kotlin.idea.stubindex.KotlinClassShortNameIndex): " + + "its keys without key, or the elements behind a key with file, line and text.", + listOf( + Param("index", "The stub index key name"), + Param("key", "The key to look up (default: list the keys)", required = false), + Param("max", "Maximum rows (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt new file mode 100644 index 00000000..61ae7e12 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/InspectTools.kt @@ -0,0 +1,153 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.codeInsight.daemon.HighlightDisplayKey +import com.intellij.codeInspection.GlobalInspectionContext +import com.intellij.codeInspection.InspectionEngine +import com.intellij.codeInspection.InspectionManager +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.ProblemDescriptorUtil +import com.intellij.codeInspection.ex.InspectionToolWrapper +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.project.Project +import com.intellij.profile.codeInspection.InspectionProfileManager +import com.intellij.psi.PsiFile +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class InspectTools(private val project: Project) { + + fun domain(): ToolDomain = ToolDomain( + "inspect", + "The IDE's inspections on demand, without waiting for the editor: list them, or run them on one file", + listOf(Tool(INSPECTIONS, ::inspections), Tool(INSPECT) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::inspectOne)) }), + ) + + private suspend fun inspections(args: ToolArgs): ToolResult { + val query = args.optionalString("query").orEmpty() + val max = args.int("max", DEFAULT_MAX) + val rows = readAction { + val profile = InspectionProfileManager.getInstance(project).currentProfile + profile.getInspectionTools(null) + .filter { query.isEmpty() || it.shortName.contains(query, true) || it.displayName.contains(query, true) } + .sortedBy { it.shortName } + .take(max) + .map { tool -> + buildJsonObject { + put("id", tool.shortName) + put("name", tool.displayName) + put("group", tool.groupDisplayName) + put("enabled", profile.isToolEnabled(HighlightDisplayKey.find(tool.shortName), null)) + } + } + } + return ToolResult.toon( + buildJsonObject { + put("truncated", rows.size >= max) + put("inspections", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private suspend fun inspectOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val only = args.optionalString("inspection") + val minimum = Severities.minimum(args) + val max = args.int("max", DEFAULT_MAX) + val psiFile = readAction { Locations.psiFile(project, path) } + val tools = readAction { applicable(psiFile, only, minimum) } + if (tools.isEmpty()) throw ToolException(missing(only, path)) + val context = InspectionManager.getInstance(project).createNewGlobalContext() + val rows = ArrayList() + try { + for (applicable in tools) { + if (rows.size >= max) break + val problems = smartReadAction(project) { run(psiFile, applicable.tool, context) } + rows += readAction { problems.take(max - rows.size).map { row(applicable, it) } } + } + } finally { + context.cleanup() + } + return buildJsonObject { + put("path", path) + put("inspections", tools.size) + put("truncated", rows.size >= max) + put("problems", buildJsonArray { rows.forEach { add(it) } }) + } + } + + private fun missing(only: String?, path: String): String = + if (only == null) "no inspection applies to " + path else "no enabled inspection named " + only + " applies to " + path + + private class Applicable(val tool: InspectionToolWrapper<*, *>, val severity: String) + + private fun applicable(psiFile: PsiFile, only: String?, minimum: HighlightSeverity?): List { + val profile = InspectionProfileManager.getInstance(project).currentProfile + return profile.getInspectionTools(psiFile) + .filter { it.isApplicable(psiFile.language) } + .mapNotNull { tool -> + val key = HighlightDisplayKey.find(tool.shortName) ?: return@mapNotNull null + val severity = profile.getErrorLevel(key, psiFile).severity + val wanted = when { + only != null -> tool.shortName.equals(only, true) + !profile.isToolEnabled(key, psiFile) -> false + else -> minimum == null || severity >= minimum + } + if (wanted) Applicable(tool, severity.name) else null + } + } + + private fun run(psiFile: PsiFile, tool: InspectionToolWrapper<*, *>, context: GlobalInspectionContext): List = + try { + InspectionEngine.runInspectionOnFile(psiFile, tool, context) + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + + private fun row(applicable: Applicable, problem: ProblemDescriptor): JsonObject = buildJsonObject { + put("line", problem.lineNumber + 1) + put("inspection", applicable.tool.shortName) + put("severity", applicable.severity) + put("message", ProblemDescriptorUtil.renderDescriptionMessage(problem, problem.psiElement)) + } + + companion object { + + private const val DEFAULT_MAX = 100 + + val INSPECTIONS = ToolSpec( + "inspections", + "Lists the inspections of the current profile — id, name, group and whether it is enabled — optionally " + + "filtered by a query.", + listOf( + Param("query", "Part of an inspection id or name, case-insensitive (default: all)", required = false), + Param("max", "Maximum inspections to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val INSPECT = ToolSpec( + "inspect", + "Runs the profile's enabled inspections on one file, or a single inspection by id, and returns each finding " + + "with its line, severity and message; the profile's information-level hints stay out unless severity=all.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("one result each"), + Param("inspection", "Run only this inspection id (default: every enabled inspection)", required = false), + Severities.PARAM, + Param("max", "Maximum findings to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt new file mode 100644 index 00000000..fb924812 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/JavaAvailability.kt @@ -0,0 +1,10 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import dev.lain.claudejb.util.InstalledPlugins + +internal object JavaAvailability { + + const val PLUGIN_ID = "com.intellij.java" + + fun isEnabled(): Boolean = InstalledPlugins.isEnabled(PLUGIN_ID) +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt new file mode 100644 index 00000000..f01387a8 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/LanguageTools.kt @@ -0,0 +1,164 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.lang.Language +import com.intellij.lang.injection.InjectedLanguageManager +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiLanguageInjectionHost +import com.intellij.psi.util.PsiTreeUtil +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import dev.lain.claudejb.util.InstalledPlugins +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class LanguageTools(private val project: Project, private val actions: IdeActions) { + + fun domain(): ToolDomain = ToolDomain( + "language", + "Languages inside a file: the fragments the IDE injects (SQL in a string, regex, HTML), a temporary injection at " + + "a position, and the quick documentation of a symbol", + listOf(Tool(INJECTIONS, ::injections), Tool(INJECT_AT, ::injectAt), Tool(DOCS, ::docs)), + ) + + private suspend fun injections(args: ToolArgs): ToolResult { + val path = args.string("path") + val max = args.int("max", DEFAULT_MAX) + val rows = smartReadAction(project) { + val psiFile = Locations.psiFile(project, path) + val document = psiFile.viewProvider.document + val manager = InjectedLanguageManager.getInstance(project) + PsiTreeUtil.collectElementsOfType(psiFile, PsiLanguageInjectionHost::class.java).flatMap { host -> + manager.getInjectedPsiFiles(host).orEmpty().map { pair -> + val injected = pair.first + val range = pair.second + buildJsonObject { + put("line", document?.getLineNumber(host.textOffset)?.plus(1) ?: 0) + put("host", host.text.take(TEXT_CHARS)) + put("language", injected.language.id) + put("text", range.substring(host.text).take(TEXT_CHARS)) + } + } + } + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("count", rows.size) + put("truncated", rows.size > max) + put("injections", buildJsonArray { rows.take(max).forEach { add(it) } }) + }, + ) + } + + private suspend fun injectAt(args: ToolArgs): ToolResult { + val languageId = args.string("language") + val language = Language.findLanguageByID(languageId) ?: throw ToolException("this IDE has no language with id $languageId") + val host = smartReadAction(project) { + val position = Locations.locate(project, args) + PsiTreeUtil.getParentOfType(position.psiFile.findElementAt(position.offset), PsiLanguageInjectionHost::class.java, false) + ?: throw ToolException("nothing at that position can host an injection (a string literal can)") + } + val injected = IntelliLangGateway(project).inject(host, language.id) + return ToolResult.toon( + buildJsonObject { + put("language", language.id) + put("path", args.string("path")) + put("line", args.int("line", 1)) + put("injected", injected) + }, + ) + } + + private suspend fun docs(args: ToolArgs): ToolResult { + val target = TargetContext.target(args) + if (target.path == null) throw ToolException("docs needs path, line and column") + actions.dispatch(QUICK_DOC, target) + return ToolResult.toon( + buildJsonObject { + put("path", target.path) + put("line", target.line) + put("column", target.column) + put("dispatched", true) + }, + ) + } + + companion object { + + private const val DEFAULT_MAX = 100 + private const val TEXT_CHARS = 120 + private const val QUICK_DOC = "QuickJavaDoc" + + val INJECTIONS = ToolSpec( + "injections", + "The language fragments the IDE injects into a file's literals (SQL, regex, JSON, HTML…): host line, host text, " + + "injected language and fragment text.", + listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("max", "Maximum fragments (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val INJECT_AT = ToolSpec( + "inject_at", + "Injects a language into the literal at a position, as the IDE's Inject Language intention does, through the " + + "IntelliLang plugin's temporary injections; the editor highlights the fragment at once.", + listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line of the literal", type = "integer"), + Param("column", "1-based column inside the literal (default 1)", type = "integer", required = false), + Param("language", "The language id, e.g. SQL, RegExp, JSON, HTML"), + ), + mutates = true, + ) + + val DOCS = ToolSpec( + "docs", + "Shows the IDE's quick documentation of the symbol at a position, in its popup, without taking the focus; " + + "symbol_info gives the signature as data.", + listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line of the symbol", type = "integer"), + Param("column", "1-based column of the symbol (default 1)", type = "integer", required = false), + ), + ) + } +} + +internal class IntelliLangGateway(private val project: Project) { + + suspend fun inject(host: PsiLanguageInjectionHost, languageId: String): Boolean { + if (!InstalledPlugins.isEnabled(PLUGIN_ID)) { + throw ToolException("the IntelliLang plugin ($PLUGIN_ID) is not installed or is disabled, so nothing injects languages") + } + val loader = javaClass.classLoader + return writeCommandAction(project, "Claude: inject $languageId") { + runCatching { + val registryClass = Class.forName(REGISTRY, true, loader) + val registry = registryClass.getMethod("getInstance", Project::class.java).invoke(null, project) + val injectedClass = Class.forName(INJECTED_LANGUAGE, true, loader) + val language = injectedClass.getMethod("create", String::class.java).invoke(null, languageId) + val add = registryClass.getMethod("addHostWithUndo", PsiLanguageInjectionHost::class.java, injectedClass) + add.invoke(registry, host, language) + true + }.getOrElse { throw ToolException("IntelliLang refused the injection: ${it.message}", it) } + } + } + + private companion object { + const val PLUGIN_ID = "org.intellij.intelliLang" + const val REGISTRY = "org.intellij.plugins.intelliLang.inject.TemporaryPlacesRegistry" + const val INJECTED_LANGUAGE = "org.intellij.plugins.intelliLang.inject.InjectedLanguage" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt new file mode 100644 index 00000000..9d21f210 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Locations.kt @@ -0,0 +1,122 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiNamedElement +import com.intellij.util.DocumentUtil +import dev.lain.claudejb.model.diff.DiffPresenter +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolException +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.nio.file.Path + +internal class Located(val psiFile: PsiFile, val document: Document, val offset: Int) + +internal object Locations { + + val POSITION = listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line", type = "integer"), + Param("column", "1-based column (default 1)", type = "integer", required = false), + ) + + val OPTIONAL_POSITION = POSITION.map { it.copy(required = false) } + + fun relative(project: Project, file: VirtualFile): String { + val base = project.basePath ?: return file.path + return file.path.removePrefix("$base/") + } + + fun kind(value: Any): String = value.javaClass.simpleName.removePrefix("Psi").removePrefix("Kt").removeSuffix("Impl") + + fun absolute(project: Project, path: String): Path { + val base = project.basePath ?: throw ToolException("this project has no directory on disk") + return Path.of(path).let { if (it.isAbsolute) it else Path.of(base).resolve(it) }.normalize() + } + + fun inside(project: Project, path: String): Path { + val absolute = absolute(project, path) + if (!DiffPresenter.isWithinRoot(absolute.toString(), project.basePath)) throw ToolException("$path is outside the project") + return absolute + } + + fun file(project: Project, path: String): VirtualFile { + val file = any(project, path) + if (file.isDirectory) throw ToolException(path + " is a directory") + return file + } + + fun any(project: Project, path: String): VirtualFile = + LocalFileSystem.getInstance().findFileByNioFile(absolute(project, path)) ?: throw ToolException("no such path: " + path) + + fun lineText(document: Document, line: Int): String = + document.immutableCharSequence.subSequence(document.getLineStartOffset(line), document.getLineEndOffset(line)).toString().trim() + + fun psiFile(project: Project, path: String): PsiFile { + val file = ReadTools.resolveFile(project, path) + return PsiDocumentManager.getInstance(project).getPsiFile(document(project, file)) + ?: throw ToolException("$path has no PSI: the IDE does not parse this file type") + } + + fun document(project: Project, file: VirtualFile): Document = + FileDocumentManager.getInstance().getDocument(file) ?: throw ToolException("${relative(project, file)} is binary") + + fun locate(project: Project, args: ToolArgs): Located { + val psiFile = psiFile(project, args.string("path")) + val document = psiFile.viewProvider.document ?: throw ToolException("${args.string("path")} has no document") + return Located(psiFile, document, offset(document, args.int("line", 0) - 1, args.int("column", 1) - 1)) + } + + private fun offset(document: Document, line: Int, column: Int): Int { + if (!DocumentUtil.isValidLine(line, document)) { + throw ToolException("line ${line + 1} is outside the file (${document.lineCount} lines)") + } + val offset = document.getLineStartOffset(line) + column.coerceAtLeast(0) + if (offset > document.getLineEndOffset(line)) throw ToolException("column ${column + 1} is past the end of line ${line + 1}") + return offset + } + + fun declarationAt(project: Project, args: ToolArgs): PsiElement { + val at = locate(project, args) + val reference = at.psiFile.findReferenceAt(at.offset) + val found = if (reference != null) reference.resolve() else named(at.psiFile.findElementAt(at.offset)) + return found ?: throw ToolException("no symbol resolves at line ${args.int("line", 0)}, column ${args.int("column", 1)}") + } + + private fun named(leaf: PsiElement?): PsiElement? = + leaf?.let { generateSequence(it) { element -> element.parent }.firstOrNull { element -> element is PsiNamedElement } } + + fun describe(project: Project, element: PsiElement): JsonObject = buildJsonObject { place(project, element) } + + fun JsonObjectBuilder.place(project: Project, element: PsiElement, withText: Boolean = true) { + val spot = spot(project, element) + put("file", spot?.file ?: "") + put("line", spot?.line ?: 0) + put("column", spot?.column ?: 0) + if (withText) put("text", spot?.text ?: "") + } + + fun located(element: PsiElement): Boolean = element.navigationElement.containingFile?.virtualFile != null + + private class Spot(val file: String, val line: Int, val column: Int, val text: String) + + private fun spot(project: Project, element: PsiElement): Spot? { + val target = element.navigationElement + val virtual = target.containingFile?.virtualFile ?: return null + val file = relative(project, virtual) + val document = FileDocumentManager.getInstance().getDocument(virtual) ?: return Spot(file, 0, 0, "") + val offset = target.textOffset.coerceIn(0, document.textLength) + val line = document.getLineNumber(offset) + return Spot(file, line + 1, offset - document.getLineStartOffset(line) + 1, lineText(document, line)) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/MarkupTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/MarkupTools.kt new file mode 100644 index 00000000..4eedd55a --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/MarkupTools.kt @@ -0,0 +1,188 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.codeInsight.daemon.impl.HintRenderer +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.impl.DocumentMarkupModel +import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.atomic.AtomicInteger +import javax.swing.Icon + +internal class MarkupTools(private val project: Project, private val targets: TargetContext, private val reveal: Reveal) { + + private class Mark(val id: Int, val file: VirtualFile, val kind: String, val line: Int, val tooltip: String, val dispose: () -> Unit) + + private val marks = LinkedHashMap() + private val ids = AtomicInteger() + + fun domain(): ToolDomain = ToolDomain( + "markup", + "Marks Claude leaves in the editor for the user: highlighted ranges, gutter icons with a tooltip, inline hints; " + + "each has an id to remove it, and all go when the session ends", + listOf(Tool(MARK_ADD, ::add), Tool(MARK_REMOVE, ::remove), Tool(MARKS, ::list), Tool(HINT_ADD, ::hint)), + ) + + private suspend fun add(args: ToolArgs): ToolResult { + val path = args.string("path") + val kind = args.optionalString("kind") ?: "highlight" + val key = KINDS[kind] ?: throw ToolException("kind must be one of ${KINDS.keys.joinToString()}") + val tooltip = args.optionalString("tooltip").orEmpty() + val line = args.int("line", 1) + val toLine = args.int("to_line", line) + val file = readAction { Locations.file(project, path) } + val document = readAction { Locations.document(project, file) } + if (line < 1 || toLine < line || toLine > document.lineCount) throw ToolException("line..to_line must lie inside the file") + val mark = withContext(Dispatchers.EDT) { + val model = DocumentMarkupModel.forDocument(document, project, true) + val start = document.getLineStartOffset(line - 1) + val end = document.getLineEndOffset(toLine - 1) + val highlighter: RangeHighlighter = if (kind == "gutter") { + model.addLineHighlighter(null, line - 1, HighlighterLayer.SELECTION - 1).also { it.gutterIconRenderer = gutter(tooltip) } + } else { + model.addRangeHighlighter(key, start, end, HighlighterLayer.SELECTION - 1, HighlighterTargetArea.EXACT_RANGE) + } + if (tooltip.isNotEmpty()) highlighter.errorStripeTooltip = tooltip + Mark(ids.incrementAndGet(), file, kind, line, tooltip) { highlighter.dispose() } + } + marks[mark.id] = mark + if (reveal.mirroring) reveal.file(file, line, preview = true) + return ToolResult.toon(row(mark)) + } + + private fun gutter(tooltip: String): GutterIconRenderer = object : GutterIconRenderer() { + override fun getIcon(): Icon = ICON + override fun getTooltipText(): String = tooltip + override fun equals(other: Any?): Boolean = other === this + override fun hashCode(): Int = System.identityHashCode(this) + } + + private suspend fun hint(args: ToolArgs): ToolResult { + val text = args.string("text") + val where = args.optionalString("where") ?: "after" + if (where != "before" && where != "after") throw ToolException("where must be before or after") + args.string("path") + val target = TargetContext.target(args) + val context = targets.of(target) + val mark = withContext(Dispatchers.EDT) { + val editor = CommonDataKeys.EDITOR.getData(context) + val file = CommonDataKeys.VIRTUAL_FILE.getData(context) + val inlay: Inlay<*>? = editor?.inlayModel?.addInlineElement(editor.caretModel.offset, where == "after", HintRenderer(text)) + if (editor == null || file == null || inlay == null) throw ToolException("no editor could take an inlay on ${target.path}") + Mark(ids.incrementAndGet(), file, "hint", target.line, text) { inlay.dispose() } + } + marks[mark.id] = mark + return ToolResult.toon(row(mark)) + } + + private suspend fun remove(args: ToolArgs): ToolResult { + val id = args.int("id", 0) + val mark = marks.remove(id) ?: throw ToolException("no mark with id $id; marks lists them") + withContext(Dispatchers.EDT) { mark.dispose() } + return ToolResult.toon( + buildJsonObject { + put("id", id) + put("removed", true) + }, + ) + } + + private suspend fun list(args: ToolArgs): ToolResult { + val path = args.optionalString("path") + val file = path?.let { readAction { Locations.file(project, it) } } + val rows = marks.values.filter { file == null || it.file == file }.map(::row) + return ToolResult.toon( + buildJsonObject { + put("count", rows.size) + put("marks", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private fun row(mark: Mark) = buildJsonObject { + put("id", mark.id) + put("path", Locations.relative(project, mark.file)) + put("kind", mark.kind) + put("line", mark.line) + put("tooltip", mark.tooltip) + } + + companion object { + + private val ICON: Icon = com.intellij.icons.AllIcons.General.Information + + val KINDS: Map = linkedMapOf( + "highlight" to EditorColors.SEARCH_RESULT_ATTRIBUTES, + "warning" to CodeInsightColors.WARNINGS_ATTRIBUTES, + "error" to CodeInsightColors.ERRORS_ATTRIBUTES, + "gutter" to EditorColors.SEARCH_RESULT_ATTRIBUTES, + ) + + private val PATH = Param("path", "File path, absolute or relative to the project root") + + val MARK_ADD = ToolSpec( + "mark_add", + "Marks lines of a file for the user: kind=highlight (default), warning or error colour the range line..to_line " + + "in every editor of the file, kind=gutter puts an icon with the tooltip in the gutter of line. Returns the " + + "mark's id; the file is shown in the preview tab.", + listOf( + PATH, + Param("line", "1-based first line (default 1)", type = "integer", required = false), + Param("to_line", "1-based last line (default: line)", type = "integer", required = false), + Param("kind", "highlight (default), warning, error or gutter", required = false), + Param("tooltip", "Text shown on hover and in the error stripe", required = false), + ), + mutates = true, + ) + + val MARK_REMOVE = ToolSpec( + "mark_remove", + "Removes a mark or a hint by the id mark_add or hint_add returned.", + listOf(Param("id", "The mark id", type = "integer")), + mutates = true, + ) + + val MARKS = ToolSpec( + "marks", + "The marks and hints Claude left in this session, all or for one file: id, path, kind, line, tooltip.", + listOf(Param("path", "Only the marks of this file (default: all)", required = false)), + ) + + val HINT_ADD = ToolSpec( + "hint_add", + "Adds an inline hint, as the IDE's parameter hints look, before or after a position of a file, in its editor; " + + "returns the hint's id for mark_remove.", + listOf( + PATH, + Param("line", "1-based line", type = "integer"), + Param("column", "1-based column (default 1)", type = "integer", required = false), + Param("text", "The hint text"), + Param("where", "before or after the position (default after)", required = false), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt new file mode 100644 index 00000000..9bfdad28 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/NavigateTools.kt @@ -0,0 +1,180 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.navigation.ChooseByNameContributor +import com.intellij.navigation.ChooseByNameContributorEx +import com.intellij.navigation.NavigationItem +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.searches.DefinitionsScopedSearch +import com.intellij.psi.search.searches.ReferencesSearch +import com.intellij.util.indexing.FindSymbolParameters +import dev.lain.claudejb.controller.mcp.tools.code.Locations.place +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class NavigateTools(private val project: Project) { + + fun domain(): ToolDomain = ToolDomain( + "navigate", + "Symbols as the IDE resolves them: find by name, go to definition, references, implementations", + listOf( + Tool(FIND_SYMBOLS) { ToolResult.toon(Batch.run(it, Batch.QUERIES, ::findOne)) }, + Tool(DEFINITION) { ToolResult.toon(Batch.run(it, Batch.POSITIONS, ::definitionOne)) }, + Tool(REFERENCES) { ToolResult.toon(Batch.run(it, Batch.POSITIONS, ::referencesOne)) }, + Tool(IMPLEMENTATIONS) { ToolResult.toon(Batch.run(it, Batch.POSITIONS, ::implementationsOne)) }, + ), + ) + + private suspend fun findOne(args: ToolArgs): JsonObject { + val query = args.string("query") + val max = args.int("max", DEFAULT_MAX) + val libraries = args.boolean("libraries", false) + val rows = indexed { + val scope = if (libraries) GlobalSearchScope.allScope(project) else GlobalSearchScope.projectScope(project) + val parameters = FindSymbolParameters.simple(project, libraries) + val items = LinkedHashSet() + for (contributor in contributors()) { + val names = LinkedHashSet() + contributor.processNames( + { name -> + if (name.contains(query, ignoreCase = true)) names += name + names.size < max + }, + scope, + null, + ) + for (name in names) { + if (items.size >= max) break + contributor.processElementsWithName( + name, + { item -> + items += item + items.size < max + }, + parameters, + ) + } + if (items.size >= max) break + } + items.filter { it is PsiElement && Locations.located(it) }.map(::symbolRow) + } + return buildJsonObject { + put("query", query) + table("symbols", rows, rows.size >= max) + } + } + + private suspend fun definitionOne(args: ToolArgs): JsonObject = indexed { + val target = resolved(args) + buildJsonObject { + put("kind", Locations.kind(target)) + place(project, target) + } + } + + private suspend fun referencesOne(args: ToolArgs): JsonObject { + val max = args.int("max", DEFAULT_MAX) + val rows = indexed { + val rows = ArrayList() + ReferencesSearch.search(resolved(args)).forEach { reference -> + rows += Locations.describe(project, reference.element) + rows.size < max + } + rows + } + return buildJsonObject { table("references", rows, rows.size >= max) } + } + + private suspend fun implementationsOne(args: ToolArgs): JsonObject { + val max = args.int("max", DEFAULT_MAX) + val rows = indexed { + val rows = ArrayList() + DefinitionsScopedSearch.search(resolved(args)).forEach { element -> + rows += buildJsonObject { + put("kind", Locations.kind(element)) + place(project, element) + } + rows.size < max + } + rows + } + return buildJsonObject { table("implementations", rows, rows.size >= max) } + } + + private fun resolved(args: ToolArgs): PsiElement = Locations.declarationAt(project, args) + + private fun symbolRow(item: NavigationItem): JsonObject = buildJsonObject { + put("name", item.name ?: "") + put("kind", Locations.kind(item)) + place(project, item as PsiElement, withText = false) + put("in", item.presentation?.locationString ?: "") + } + + private fun contributors(): List = + (ChooseByNameContributor.CLASS_EP_NAME.extensionList + ChooseByNameContributor.SYMBOL_EP_NAME.extensionList) + .filterIsInstance() + .distinct() + + private suspend fun indexed(body: () -> T): T = try { + readAction(body) + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + + private fun kotlinx.serialization.json.JsonObjectBuilder.table(key: String, rows: List, truncated: Boolean) { + put("count", rows.size) + put("truncated", truncated) + put(key, buildJsonArray { rows.forEach { add(it) } }) + } + + companion object { + + private const val DEFAULT_MAX = 50 + + val FIND_SYMBOLS = ToolSpec( + "find_symbols", + "Finds classes, functions and other named symbols whose name contains the query, as the IDE's Go to Symbol does; " + + "several queries at once with queries.", + listOf( + Param("query", "Part of the symbol name, case-insensitive", required = false), + Batch.param(Batch.QUERIES, "Several queries at once, one result per query; the other arguments apply to each"), + Param("libraries", "true to include library symbols (default false)", type = "boolean", required = false), + Param("max", "Maximum symbols to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val DEFINITION = ToolSpec( + "definition", + "Resolves the reference at a position to its declaration and returns where it is; several positions at once " + + "with positions.", + Locations.OPTIONAL_POSITION + Batch.positions("the declaration each resolves to"), + ) + + val REFERENCES = ToolSpec( + "references", + "Lists the places that reference the symbol at a position; several positions at once with positions.", + Locations.OPTIONAL_POSITION + Batch.positions("the references of each") + + Param("max", "Maximum references to return (default $DEFAULT_MAX)", type = "integer", required = false), + ) + + val IMPLEMENTATIONS = ToolSpec( + "implementations", + "Lists the implementations or overrides of the symbol at a position; several positions at once with positions.", + Locations.OPTIONAL_POSITION + Batch.positions("the implementations of each") + + Param("max", "Maximum results to return (default $DEFAULT_MAX)", type = "integer", required = false), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/OutlineTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/OutlineTools.kt new file mode 100644 index 00000000..c96c0d7d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/OutlineTools.kt @@ -0,0 +1,127 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.structureView.StructureViewTreeElement +import com.intellij.ide.structureView.TreeBasedStructureViewBuilder +import com.intellij.ide.util.treeView.smartTree.TreeElement +import com.intellij.lang.LanguageStructureViewBuilder +import com.intellij.navigation.NavigationItem +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNamedElement +import dev.lain.claudejb.controller.mcp.tools.code.Locations.place +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class OutlineTools(private val project: Project) { + + fun domain(): ToolDomain = ToolDomain( + "outline", + "The structure of a file as the IDE's Structure view shows it, and what a symbol at a position is", + listOf( + Tool(FILE_OUTLINE) { ToolResult.toon(Batch.run(it, Batch.PATHS, ::outlineOne)) }, + Tool(SYMBOL_INFO) { ToolResult.toon(Batch.run(it, Batch.POSITIONS, ::symbolOne)) }, + ), + ) + + private suspend fun outlineOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val depth = args.int("depth", DEFAULT_DEPTH) + val psiFile = readAction { Locations.psiFile(project, path) } + val builder = readAction { LanguageStructureViewBuilder.getInstance().getStructureViewBuilder(psiFile) } + as? TreeBasedStructureViewBuilder + ?: throw ToolException("the IDE has no structure view for $path") + val items = readAction { + val model = builder.createStructureViewModel(null) + try { + children(model.root, depth) + } finally { + Disposer.dispose(model) + } + } + return buildJsonObject { + put("path", path) + put("symbols", items) + } + } + + private fun children(element: TreeElement, depth: Int): JsonArray = buildJsonArray { + if (depth <= 0) return@buildJsonArray + for (child in element.children) add(node(child, depth - 1)) + } + + private fun node(element: TreeElement, depth: Int): JsonObject = buildJsonObject { + val presentation = element.presentation + put("name", presentation.presentableText ?: "") + presentation.locationString?.takeIf { it.isNotBlank() }?.let { put("detail", it) } + val value = (element as? StructureViewTreeElement)?.value as? PsiElement + if (value != null) { + put("kind", Locations.kind(value)) + put("line", line(value)) + } + val nested = children(element, depth) + if (nested.isNotEmpty()) put("children", nested) + } + + private suspend fun symbolOne(args: ToolArgs): JsonObject = readAction { + val declaration = Locations.declarationAt(project, args) + buildJsonObject { + put("name", (declaration as? PsiNamedElement)?.name ?: declaration.text.take(SIGNATURE_CHARS)) + put("kind", Locations.kind(declaration)) + put("signature", signature(declaration)) + (declaration as? NavigationItem)?.presentation?.let { presentation -> + presentation.presentableText?.let { put("presentation", it) } + presentation.locationString?.takeIf { it.isNotBlank() }?.let { put("in", it) } + } + place(project, declaration) + } + } + + private fun signature(element: PsiElement): String { + val text = element.navigationElement.text ?: return "" + val head = text.lineSequence().map { it.trim() } + .firstOrNull { line -> line.isNotEmpty() && COMMENT_OR_ANNOTATION.none(line::startsWith) } + return (head ?: text.trim()).take(SIGNATURE_CHARS) + } + + private fun line(element: PsiElement): Int { + val document = element.containingFile?.viewProvider?.document ?: return 0 + return document.getLineNumber(element.textOffset.coerceIn(0, document.textLength)) + 1 + } + + companion object { + + private const val DEFAULT_DEPTH = 3 + private const val SIGNATURE_CHARS = 200 + private val COMMENT_OR_ANNOTATION = listOf("/", "*", "@", "#") + + val FILE_OUTLINE = ToolSpec( + "file_outline", + "The declarations of a file as a tree — classes, functions, fields — with their lines, like the Structure view.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("one outline each"), + Param("depth", "How many levels of nesting to return (default $DEFAULT_DEPTH)", type = "integer", required = false), + ), + ) + + val SYMBOL_INFO = ToolSpec( + "symbol_info", + "What the symbol at a position is: its kind, name, declaring signature and where it is declared; several " + + "positions at once with positions.", + Locations.OPTIONAL_POSITION + Batch.positions("what each symbol is"), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt new file mode 100644 index 00000000..4ff6e9c8 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PresenceTools.kt @@ -0,0 +1,139 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.scratch.ScratchRootType +import com.intellij.lang.Language +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.WindowManager +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Items +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class PresenceTools(private val project: Project, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "presence", + "Claude's presence in the IDE beyond the chat: a banner over a file's editor with choices, the status bar's " + + "text, and scratch files", + listOf(Tool(BANNER_SHOW, ::bannerShow), Tool(BANNER_CLEAR, ::bannerClear), Tool(STATUS, ::status), Tool(SCRATCH_CREATE, ::scratch)), + ) + + private suspend fun bannerShow(args: ToolArgs): ToolResult { + val path = args.string("path") + val text = args.string("text") + val actions = args.strings("actions") + val file = readAction { Locations.file(project, path) } + withContext(Dispatchers.EDT) { project.getService(BannerRegistry::class.java).show(file, text, actions) } + reveal.file(file) + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("text", text) + put("actions", buildJsonArray { actions.forEach { add(JsonPrimitive(it)) } }) + put("shown", true) + }, + ) + } + + private suspend fun bannerClear(args: ToolArgs): ToolResult { + val path = args.string("path") + val file = readAction { Locations.file(project, path) } + val banner = withContext(Dispatchers.EDT) { project.getService(BannerRegistry::class.java).clear(file) } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("cleared", banner != null) + put("chosen", banner?.chosen ?: "") + }, + ) + } + + private suspend fun status(args: ToolArgs): ToolResult { + val text = args.string("text") + val shown = withContext(Dispatchers.EDT) { + val bar = WindowManager.getInstance().getStatusBar(project) ?: return@withContext false + bar.info = text + true + } + return ToolResult.toon( + buildJsonObject { + put("text", text) + put("shown", shown) + }, + ) + } + + private suspend fun scratch(args: ToolArgs): ToolResult { + val name = args.string("name") + val content = args.optionalString("content").orEmpty() + val languageId = args.optionalString("language") + val language = languageId?.let { Language.findLanguageByID(it) ?: throw ToolException("this IDE has no language with id $it") } + val file = withContext(Dispatchers.EDT) { ScratchRootType.getInstance().createScratchFile(project, name, language, content) } + ?: throw ToolException("the IDE did not create the scratch file $name") + reveal.file(file) + return ToolResult.toon( + buildJsonObject { + put("name", file.name) + put("path", file.path) + put("language", language?.id ?: "") + put("created", true) + }, + ) + } + + companion object { + + val BANNER_SHOW = ToolSpec( + "banner_show", + "Shows a notification banner over a file's editor, as the IDE does for its own notices, with a text and " + + "optional action labels the user can click; the banner stays until banner_clear, a click on Dismiss, or a " + + "click on an action, which banner_clear then reports as chosen. The file opens in a tab without focus.", + listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("text", "The banner text"), + Param("actions", "Action labels to offer, in order", type = "array", required = false, items = Items("string")), + ), + mutates = true, + ) + + val BANNER_CLEAR = ToolSpec( + "banner_clear", + "Removes the banner from a file's editor and returns which action the user chose, if any.", + listOf(Param("path", "File path, absolute or relative to the project root")), + mutates = true, + ) + + val STATUS = ToolSpec( + "status", + "Puts a short text in the IDE's status bar, where the IDE reports its own progress; the next IDE message " + + "replaces it.", + listOf(Param("text", "The status text")), + mutates = true, + ) + + val SCRATCH_CREATE = ToolSpec( + "scratch_create", + "Creates a scratch file (Scratches and Consoles) with a name, an optional language for highlighting and initial " + + "content, and opens it in the editor without focus; it lives outside the project and is never committed.", + listOf( + Param("name", "File name with extension, e.g. notes.md, query.sql"), + Param("language", "Language id for highlighting (default: from the extension)", required = false), + Param("content", "Initial content (default empty)", required = false), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt new file mode 100644 index 00000000..1c21df89 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/PsiTools.kt @@ -0,0 +1,214 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.psi.util.PsiTreeUtil +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class PsiTools(private val project: Project, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "psi", + "The IDE's syntax tree of a file: the tree to a depth, the element at a position with its parents, and edits that " + + "replace or insert elements through the PSI so the IDE reformats and re-resolves them", + listOf(Tool(PSI_TREE, ::tree), Tool(PSI_AT, ::at), Tool(PSI_REPLACE, ::replace), Tool(PSI_INSERT, ::insert)), + ) + + private suspend fun tree(args: ToolArgs): ToolResult { + val path = args.string("path") + val depth = args.int("depth", DEFAULT_DEPTH) + val max = args.int("max", DEFAULT_MAX) + val rows = smartReadAction(project) { + val psiFile = Locations.psiFile(project, path) + val root = args.optionalString("line")?.let { elementAt(psiFile, args) } ?: psiFile + Walk(depth, max, psiFile).also { it.visit(root, 0) }.out + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("count", rows.size) + put("truncated", rows.size >= max) + put("elements", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private inner class Walk(private val depth: Int, private val max: Int, private val file: PsiFile) { + val out = ArrayList() + + fun visit(element: PsiElement, level: Int) { + if (out.size >= max) return + out += row(element, level, file) + if (level >= depth) return + element.children.filterNot { it is PsiWhiteSpace }.forEach { visit(it, level + 1) } + } + } + + private fun row(element: PsiElement, level: Int, file: PsiFile): JsonObject = buildJsonObject { + val document = file.viewProvider.document + put("level", level) + put("type", element.node?.elementType?.toString() ?: element.javaClass.simpleName) + put("class", element.javaClass.simpleName) + put("line", document?.getLineNumber(element.textRange.startOffset)?.plus(1) ?: 0) + put("length", element.textLength) + put("text", element.text.lineSequence().firstOrNull()?.trim()?.take(TEXT_CHARS) ?: "") + } + + private suspend fun at(args: ToolArgs): ToolResult { + val path = args.string("path") + val rows = smartReadAction(project) { + val psiFile = Locations.psiFile(project, path) + val leaf = elementAt(psiFile, args) + generateSequence(leaf) { it.parent }.takeWhile { it !is PsiFile }.mapIndexed { index, e -> row(e, index, psiFile) }.toList() + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("count", rows.size) + put("parents", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private fun elementAt(psiFile: PsiFile, args: ToolArgs): PsiElement { + val position = Locations.locate(project, args) + return psiFile.findElementAt(position.offset) ?: throw ToolException("nothing at that position") + } + + private suspend fun replace(args: ToolArgs): ToolResult = edit(args, insertion = null) + + private suspend fun insert(args: ToolArgs): ToolResult { + val where = args.optionalString("where") ?: "after" + if (where != "before" && where != "after") throw ToolException("where must be before or after") + return edit(args, insertion = where) + } + + private suspend fun edit(args: ToolArgs, insertion: String?): ToolResult { + val path = args.string("path") + val text = args.string("text") + val levels = args.int("parent", 0) + val (file, target) = smartReadAction(project) { + val psiFile = Locations.psiFile(project, path) + var element = elementAt(psiFile, args) + repeat(levels) { element = element.parent?.takeIf { it !is PsiFile } ?: element } + psiFile to element + } + val outcome = writeCommandAction(project, "Claude: psi ${insertion ?: "replace"} in ${file.name}") { + val name = "fragment." + (file.virtualFile?.extension ?: "txt") + val fragment = PsiFileFactory.getInstance(project).createFileFromText(name, file.language, text) + val nodes = fragment.children.filter { it !is PsiWhiteSpace && it.textLength > 0 } + val placed = if (nodes.isNotEmpty() && !PsiTreeUtil.hasErrorElements(fragment)) { + place(target, nodes, insertion).also { CodeStyleManager.getInstance(project).reformat(it.parent ?: it) } + } else { + placeAsText(file, target, text, insertion) + } + row(placed, 0, file) + } + file.virtualFile?.let { reveal.file(it) } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("operation", insertion ?: "replace") + put("element", outcome) + }, + ) + } + + private fun placeAsText(file: PsiFile, target: PsiElement, text: String, insertion: String?): PsiElement { + val document = file.viewProvider.document ?: throw ToolException("${file.name} has no document") + val range = target.textRange + val start = if (insertion == "after") range.endOffset else range.startOffset + if (insertion == null) document.replaceString(range.startOffset, range.endOffset, text) else document.insertString(start, text) + PsiDocumentManager.getInstance(project).commitDocument(document) + CodeStyleManager.getInstance(project).reformatText(file, start, start + text.length) + return file.findElementAt(start)?.let { leaf -> leaf.parent?.takeIf { it.textRange.startOffset == start } ?: leaf } ?: file + } + + private fun place(target: PsiElement, nodes: List, insertion: String?): PsiElement { + if (insertion == "before") { + nodes.forEach { target.parent.addBefore(it, target) } + return target + } + val first = if (insertion == null) target.replace(nodes.first()) else target + var anchor = first + (if (insertion == null) nodes.drop(1) else nodes).forEach { anchor = anchor.parent.addAfter(it, anchor) } + return if (insertion == null) first else anchor + } + + companion object { + + private const val DEFAULT_DEPTH = 3 + private const val DEFAULT_MAX = 300 + private const val TEXT_CHARS = 80 + + private val PATH = Param("path", "File path, absolute or relative to the project root") + private val LINE = Param("line", "1-based line", type = "integer") + private val COLUMN = Param("column", "1-based column (default 1)", type = "integer", required = false) + private val PARENT = Param( + "parent", + "How many parents above the leaf to act on (default 0: the leaf)", + type = "integer", + required = false, + ) + + val PSI_TREE = ToolSpec( + "psi_tree", + "The PSI tree of a file, or of the element at a line, to a depth: level, element type, class, line, length and " + + "the first line of text of each element.", + listOf( + PATH, + Param("line", "1-based line whose element is the root (default: the whole file)", type = "integer", required = false), + COLUMN, + Param("depth", "Levels below the root (default $DEFAULT_DEPTH)", type = "integer", required = false), + Param("max", "Maximum elements (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val PSI_AT = ToolSpec( + "psi_at", + "The PSI leaf at a position and every parent up to the file, innermost first: what the IDE sees there.", + listOf(PATH, LINE, COLUMN), + ) + + val PSI_REPLACE = ToolSpec( + "psi_replace", + "Replaces the PSI element at a position (or a parent of it) with text parsed in the file's language, through the " + + "PSI, then reformats: the IDE keeps references and structure consistent. One undoable command.", + listOf(PATH, LINE, COLUMN, PARENT, Param("text", "The replacement, parsed in the file's language")), + mutates = true, + ) + + val PSI_INSERT = ToolSpec( + "psi_insert", + "Inserts text parsed in the file's language before or after the PSI element at a position (or a parent of it), " + + "through the PSI, then reformats. One undoable command.", + listOf( + PATH, + LINE, + COLUMN, + PARENT, + Param("text", "The text to insert, parsed in the file's language"), + Param("where", "before or after (default after)", required = false), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt new file mode 100644 index 00000000..b89a101d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/ReadTools.kt @@ -0,0 +1,97 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.readAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.nio.file.Path + +internal class ReadTools(private val project: Project, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "read", + "Files as the IDE sees them, unsaved edits included", + listOf(Tool(READ_FILE, ::readFile)), + ) + + private suspend fun readFile(args: ToolArgs): ToolResult = ToolResult.toon(Batch.run(args, Batch.PATHS, ::readOne)) + + private suspend fun readOne(args: ToolArgs): JsonObject { + val path = args.string("path") + val offset = args.int("offset", 1) + val limit = args.int("limit", DEFAULT_LIMIT) + if (offset < 1 || limit < 1) throw ToolException("offset and limit start at 1") + val file = readAction { resolveFile(project, path) } + val (row, from) = readAction { + val text = FileDocumentManager.getInstance().getDocument(file)?.immutableCharSequence?.toString() + ?: String(file.contentsToByteArray(), file.charset) + val lines = text.lines() + val from = minOf(offset, lines.size + 1) + val to = minOf(from + limit - 1, lines.size) + buildJsonObject { + put("path", path) + put("lines", lines.size) + put("from", from) + put("to", to) + put("text", lines.subList(from - 1, to).joinToString("\n")) + } to from + } + if (reveal.mirroring) reveal.file(file, from, preview = true) + return row + } + + companion object { + + fun resolveFile(project: Project, path: String): VirtualFile { + val file = locate(project, path) + if (file.isDirectory) throw ToolException("$path is a directory") + if (file.fileType.isBinary) throw ToolException("$path is binary") + return file + } + + fun resolveDirectory(project: Project, path: String): VirtualFile { + val dir = locate(project, path) + if (!dir.isDirectory) throw ToolException("$path is not a directory") + return dir + } + + private fun locate(project: Project, path: String): VirtualFile { + val base = project.basePath ?: throw ToolException("this project has no directory on disk") + val absolute = Path.of(path).let { if (it.isAbsolute) it else Path.of(base).resolve(it) }.normalize() + return LocalFileSystem.getInstance().findFileByNioFile(absolute) + ?: throw ToolException("no such path: $path") + } + + private const val DEFAULT_LIMIT = 400 + + val READ_FILE = ToolSpec( + "read_file", + "Reads a text file through the IDE, so unsaved editor changes are included, or several files at once with " + + "paths. Use offset and limit for large files. The file is shown in the editor's preview tab, without focus.", + listOf( + Param("path", "File path, absolute or relative to the project root", required = false), + Batch.paths("offset and limit apply to each"), + Param("offset", "First line to return, 1-based (default 1)", type = "integer", required = false), + Param( + "limit", + "Maximum number of lines to return (default $DEFAULT_LIMIT)", + type = "integer", + required = false, + ), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt new file mode 100644 index 00000000..a5f62dd3 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RecentTools.kt @@ -0,0 +1,157 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory +import com.intellij.openapi.fileEditor.impl.EditorHistoryManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.controller.mcp.FocusKeeper +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class RecentTools(private val project: Project, private val actions: IdeActions) { + + private val schemes = Schemes() + + fun domain(): ToolDomain = ToolDomain( + "recent", + "Where the user has been: recent and recently changed files, the editor's back and forward history, the " + + "clipboard against a file, and the IDE's schemes", + listOf(Tool(RECENT, ::recent), Tool(NAVIGATE_HISTORY, ::navigate), Tool(COMPARE_CLIPBOARD, ::compare), Tool(SCHEME, ::scheme)), + ) + + private suspend fun recent(args: ToolArgs): ToolResult { + val kind = args.optionalString("kind") ?: "files" + val max = args.int("max", DEFAULT_MAX) + val files = withContext(Dispatchers.EDT) { + when (kind) { + "files" -> EditorHistoryManager.getInstance(project).fileList.asReversed() + "changed_files" -> IdeDocumentHistory.getInstance(project).changedFiles.asReversed() + else -> throw ToolException("kind must be files or changed_files") + } + } + return ToolResult.toon( + buildJsonObject { + put("kind", kind) + put("count", files.size) + put("truncated", files.size > max) + put("files", buildJsonArray { files.take(max).forEach { add(JsonPrimitive(Locations.relative(project, it))) } }) + }, + ) + } + + private suspend fun navigate(args: ToolArgs): ToolResult { + val direction = args.string("direction") + if (direction !in DIRECTIONS) throw ToolException("direction must be one of ${DIRECTIONS.joinToString()}") + val moved = withContext(Dispatchers.EDT) { + val history = IdeDocumentHistory.getInstance(project) + FocusKeeper.keeping(project) { + when (direction) { + "back" -> history.isBackAvailable.also { if (it) history.back() } + "forward" -> true.also { history.forward() } + "last_change" -> true.also { history.navigatePreviousChange() } + else -> true.also { history.navigateNextChange() } + } + } + } + return ToolResult.toon( + buildJsonObject { + put("direction", direction) + put("moved", moved) + }, + ) + } + + private suspend fun compare(args: ToolArgs): ToolResult { + val target = TargetContext.target(args) + if (target.path == null) throw ToolException("compare_clipboard needs path") + actions.dispatch(COMPARE_CLIPBOARD_ACTION, target) + return ToolResult.toon( + buildJsonObject { + put("path", target.path) + put("dispatched", true) + }, + ) + } + + private suspend fun scheme(args: ToolArgs): ToolResult { + val kind = args.string("kind") + val wanted = schemeToSet(args) + if (kind !in Schemes.KINDS) throw ToolException("kind must be one of ${Schemes.KINDS.joinToString()}") + val listed = withContext(Dispatchers.EDT) { + if (wanted != null) FocusKeeper.keeping(project) { schemes.set(kind, wanted) } + schemes.list(kind) + } + return ToolResult.toon( + buildJsonObject { + put("kind", kind) + put("current", listed.current) + put("count", listed.names.size) + put("schemes", buildJsonArray { listed.names.forEach { add(JsonPrimitive(it)) } }) + }, + ) + } + + private fun schemeToSet(args: ToolArgs): String? = when (args.optionalString("action") ?: "list") { + "list" -> null + "set" -> args.optionalString("name")?.takeIf { it.isNotBlank() } ?: throw ToolException("action=set needs name") + else -> throw ToolException("action must be list or set") + } + + companion object { + + private const val DEFAULT_MAX = 50 + private const val COMPARE_CLIPBOARD_ACTION = "CompareClipboardWithSelection" + + val DIRECTIONS: List = listOf("back", "forward", "last_change", "next_change") + + val RECENT = ToolSpec( + "recent", + "The files the user opened most recently (kind=files, as View ▸ Recent Files lists them) or changed most " + + "recently in this session (kind=changed_files), newest first.", + listOf( + Param("kind", "files (default) or changed_files", required = false), + Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val NAVIGATE_HISTORY = ToolSpec( + "navigate_history", + "Moves the user's editor through the IDE's navigation history, as Navigate ▸ Back, Forward, Last Edit " + + "Location and Next Edit Location do: back, forward, last_change or next_change.", + listOf(Param("direction", "back, forward, last_change or next_change")), + mutates = true, + ) + + val COMPARE_CLIPBOARD = ToolSpec( + "compare_clipboard", + "Opens the IDE's diff of the clipboard against a file, as View ▸ Compare with Clipboard does, without taking " + + "the focus.", + listOf(Param("path", "File path, absolute or relative to the project root")), + ) + + val SCHEME = ToolSpec( + "scheme", + "Lists or sets one of the IDE's schemes, as View ▸ Quick Switch Scheme does: kind is theme, color, keymap or " + + "code_style; action=list returns the names with the current one, action=set applies name.", + listOf( + Param("kind", "theme, color, keymap or code_style"), + Param("action", "list (default) or set", required = false), + Param("name", "The scheme to apply (action=set)", required = false), + ), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorOpsTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorOpsTools.kt new file mode 100644 index 00000000..097d9a66 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorOpsTools.kt @@ -0,0 +1,139 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import dev.lain.claudejb.controller.mcp.IdeActions +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class RefactorOpsTools(private val actions: IdeActions) { + + fun domain(): ToolDomain = ToolDomain( + "refactor_ops", + "The Refactor menu beyond rename, move and safe delete: introduce, extract, inline and the member refactorings, " + + "each through the IDE's own refactoring with its dialog or in-place editor", + listOf(Tool(INTRODUCE, ::introduce), Tool(EXTRACT, ::extract), Tool(INLINE, ::inline), Tool(MEMBERS, ::members)), + ) + + private suspend fun introduce(args: ToolArgs): ToolResult = fire("kind", INTRODUCE_ACTIONS, args) + + private suspend fun extract(args: ToolArgs): ToolResult = fire("kind", EXTRACT_ACTIONS, args) + + private suspend fun inline(args: ToolArgs): ToolResult = fire(null, mapOf("inline" to INLINE_ACTION), args) + + private suspend fun members(args: ToolArgs): ToolResult = fire("action", MEMBER_ACTIONS, args) + + private suspend fun fire(key: String?, table: Map, args: ToolArgs): ToolResult { + val name = key?.let { args.string(it) } ?: table.keys.single() + val id = table[name] ?: throw ToolException("$key must be one of ${table.keys.joinToString()}") + val target = TargetContext.target(args, preview = false) + if (target.path == null) throw ToolException("$name needs path, line and column") + val fired = dispatchFirstEnabled(listOf(id) + ALTERNATIVES[id].orEmpty(), target) + return ToolResult.toon( + buildJsonObject { + put("refactoring", name) + put("id", fired) + put("path", target.path) + put("line", target.line) + put("column", target.column) + put("selected", target.selection != null) + put("dispatched", true) + }, + ) + } + + private suspend fun dispatchFirstEnabled(ids: List, target: TargetContext.Target): String { + var refused: ToolException? = null + for (id in ids) { + try { + actions.dispatch(id, target) + return id + } catch (e: ToolException) { + refused = e + } + } + throw refused ?: ToolException("no refactoring action to fire") + } + + companion object { + + private const val INLINE_ACTION = "Inline" + + private val ALTERNATIVES: Map> = mapOf("ExtractMethod" to listOf("ExtractFunction")) + + val INTRODUCE_ACTIONS: Map = linkedMapOf( + "variable" to "IntroduceVariable", + "constant" to "IntroduceConstant", + "field" to "IntroduceField", + "parameter" to "IntroduceParameter", + "functional_parameter" to "IntroduceFunctionalParameter", + ) + + val EXTRACT_ACTIONS: Map = linkedMapOf( + "method" to "ExtractMethod", + "interface" to "ExtractInterface", + "superclass" to "ExtractSuperclass", + "delegate" to "ExtractClass", + "module" to "ExtractModule", + ) + + val MEMBER_ACTIONS: Map = linkedMapOf( + "pull_up" to "MembersPullUp", + "push_down" to "MemberPushDown", + "change_signature" to "ChangeSignature", + "move" to "Move", + "encapsulate_fields" to "EncapsulateFields", + "make_static" to "MakeStatic", + "convert_to_instance" to "ConvertToInstanceMethod", + "inheritance_to_delegation" to "InheritanceToDelegation", + "anonymous_to_inner" to "AnonymousToInner", + "method_object" to "ReplaceMethodWithMethodObject", + ) + + private val POSITION = listOf( + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line of the caret, or where the selection starts", type = "integer"), + Param("column", "1-based column of the caret (default 1)", type = "integer", required = false), + ) + TargetContext.SELECTION_PARAMS + + val INTRODUCE = ToolSpec( + "introduce", + "Refactor ▸ Introduce on the expression at a position or in a selection (line..to_line): variable, constant, " + + "field, parameter or functional_parameter. The IDE's own refactoring runs, in place or with its dialog, " + + "for the user to name and confirm; the file opens in a tab without focus.", + listOf(Param("kind", "variable, constant, field, parameter or functional_parameter")) + POSITION, + mutates = true, + ) + + val EXTRACT = ToolSpec( + "extract", + "Refactor ▸ Extract on a selection or the element at a position: method, interface, superclass, delegate " + + "(Extract Class) or module. The IDE's dialog opens for the user to finish.", + listOf(Param("kind", "method, interface, superclass, delegate or module")) + POSITION, + mutates = true, + ) + + val INLINE = ToolSpec( + "inline", + "Refactor ▸ Inline on the symbol at a position (variable, method, class…): the IDE's dialog opens for the user " + + "to confirm the scope.", + POSITION, + mutates = true, + ) + + val MEMBERS = ToolSpec( + "members", + "A member refactoring on the element at a position, through the IDE's dialog: pull_up, push_down, " + + "change_signature, move, encapsulate_fields, make_static, convert_to_instance, inheritance_to_delegation, " + + "anonymous_to_inner or method_object (the Java ones are refused where the language has no such refactoring).", + listOf(Param("action", "One of the names above")) + POSITION, + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt new file mode 100644 index 00000000..e8c349f0 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/RefactorTools.kt @@ -0,0 +1,188 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.edtWriteAction +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.writeIntentReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.refactoring.RefactoringFactory +import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFilesOrDirectoriesProcessor +import com.intellij.refactoring.rename.RenamePsiElementProcessorBase +import com.intellij.refactoring.rename.RenameUtil +import com.intellij.refactoring.safeDelete.SafeDeleteProcessor +import com.intellij.refactoring.safeDelete.usageInfo.SafeDeleteReferenceUsageInfo +import com.intellij.usageView.UsageInfo +import com.intellij.util.containers.MultiMap +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.IOException +import java.nio.file.Path + +internal class RefactorTools(private val project: Project, private val refactorings: Refactorings = Refactorings(project)) { + + fun domain(): ToolDomain = ToolDomain( + "refactor", + "Rename, move and safe-delete with every reference updated, as the IDE's Refactor menu does", + listOf(Tool(RENAME, ::rename), Tool(MOVE_FILE, ::moveFile), Tool(SAFE_DELETE, ::safeDelete)), + ) + + private suspend fun rename(args: ToolArgs): ToolResult { + val newName = args.string("new_name") + val max = args.int("max", DEFAULT_MAX) + val (element, from, refactoring) = readAction { + val element = refactorings.target(args) + val from = refactorings.name(element) + validName(element, from, newName) + val refactoring = RefactoringFactory.getInstance(project).createRename(element, newName) + refactoring.setInteractive(null) + refactoring.setPreviewUsages(false) + Triple(element, from, refactoring) + } + val usages = refactorings.usages(refactoring) + readAction { conflicts(element, newName, usages) } + val changes = refactorings.rows(usages.toList(), max) + refactorings.perform(refactoring, usages) + return ToolResult.toon( + buildJsonObject { + put("from", from) + put("to", newName) + put("usages", usages.size) + put("files", changes.files) + put("truncated", changes.truncated) + put("changes", buildJsonArray { changes.rows.forEach { add(it) } }) + }, + ) + } + + private fun validName(element: PsiElement, from: String, newName: String) { + if (from == newName) throw ToolException("it is already named $newName") + if (!RenameUtil.isValidName(project, element, newName)) { + throw ToolException("$newName is not a valid name for this ${Locations.kind(element)}") + } + } + + private fun conflicts(element: PsiElement, newName: String, usages: Array) { + val conflicts = MultiMap() + RenameUtil.addConflictDescriptions(usages, conflicts) + val processor = RenamePsiElementProcessorBase.forPsiElement(element) + processor.findExistingNameConflicts(element, newName, conflicts, mapOf(element to newName)) + if (!conflicts.isEmpty) throw ToolException("renaming would conflict: " + conflicts.values().joinToString("; ")) + } + + private suspend fun moveFile(args: ToolArgs): ToolResult { + val path = args.string("path") + val destination = args.string("destination") + val target = Locations.inside(project, destination) + val (file, psiFile) = readAction { source(path, target, destination) } + val directory = edtWriteAction { createDirectories(target) } + val psiDirectory = readAction { destinationOf(directory, file.name, destination) } + val processor = MoveFilesOrDirectoriesProcessor(project, arrayOf(psiFile), psiDirectory, false, true, null, null) + processor.setPreviewUsages(false) + withContext(Dispatchers.EDT) { writeIntentReadAction { processor.run() } } + refactorings.saveAll() + return ToolResult.toon( + buildJsonObject { + put("from", path) + put("to", Locations.relative(project, directory) + "/" + file.name) + }, + ) + } + + private fun source(path: String, target: Path, destination: String): Pair { + val file = Locations.file(project, path) + if (file.parent.toNioPath() == target) throw ToolException("$path is already in $destination") + return file to (PsiManager.getInstance(project).findFile(file) ?: throw ToolException("the IDE has no PSI for $path")) + } + + private fun createDirectories(target: Path): VirtualFile = try { + VfsUtil.createDirectories(target.toString()) + } catch (e: IOException) { + throw ToolException("cannot create $target: ${e.message}", e) + } + + private fun destinationOf(directory: VirtualFile, name: String, destination: String): PsiDirectory { + if (directory.findChild(name) != null) throw ToolException("$destination already contains $name") + return PsiManager.getInstance(project).findDirectory(directory) ?: throw ToolException("the IDE has no PSI for $destination") + } + + private suspend fun safeDelete(args: ToolArgs): ToolResult { + val max = args.int("max", DEFAULT_MAX) + val (target, refactoring) = readAction { + val element = refactorings.target(args) + if (!SafeDeleteProcessor.validElement(element)) { + throw ToolException("the IDE cannot safe-delete this ${Locations.kind(element)}") + } + val refactoring = RefactoringFactory.getInstance(project).createSafeDelete(arrayOf(element)) + refactoring.isSearchInComments = false + refactoring.isSearchInNonJavaFiles = false + refactoring.setInteractive(null) + refactoring.setPreviewUsages(false) + refactorings.name(element) to refactoring + } + val usages = refactorings.usages(refactoring) + val unsafe = usages.filter { it is SafeDeleteReferenceUsageInfo && !it.isSafeDelete } + val blocking = refactorings.rows(unsafe, max) + if (unsafe.isEmpty()) refactorings.perform(refactoring, usages) + return ToolResult.toon( + buildJsonObject { + put("deleted", unsafe.isEmpty()) + put("target", target) + put("unsafe", unsafe.size) + put("truncated", blocking.truncated) + put("usages", buildJsonArray { blocking.rows.forEach { add(it) } }) + }, + ) + } + + companion object { + + private const val DEFAULT_MAX = 50 + private const val PATH = "File path, absolute or relative to the project root" + private val SYMBOL = listOf( + Param("line", "1-based line of the symbol; omit to target the file itself", type = "integer", required = false), + Param("column", "1-based column of the symbol (default 1)", type = "integer", required = false), + ) + + val RENAME = ToolSpec( + "rename", + "Renames the symbol at a position, or the file when no line is given, updating every reference as the IDE's " + + "Rename does. Fails, changing nothing, when the new name is invalid or would conflict.", + listOf(Param("path", PATH), Param("new_name", "The new name")) + SYMBOL + + Param("max", "Maximum changed usages to list (default $DEFAULT_MAX)", type = "integer", required = false), + mutates = true, + ) + + val MOVE_FILE = ToolSpec( + "move_file", + "Moves a file into a directory (created when missing) with the IDE's Move refactoring, so packages, imports " + + "and references follow. Fails when the destination already holds a file of that name.", + listOf(Param("path", PATH), Param("destination", "Directory path, absolute or relative to the project root")), + mutates = true, + ) + + val SAFE_DELETE = ToolSpec( + "safe_delete", + "Deletes the symbol at a position, or the file when no line is given, only when nothing else uses it; " + + "otherwise deletes nothing and lists the usages that block it.", + listOf(Param("path", PATH)) + SYMBOL + + Param("max", "Maximum blocking usages to list (default $DEFAULT_MAX)", type = "integer", required = false), + mutates = true, + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Refactorings.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Refactorings.kt new file mode 100644 index 00000000..c2af15a8 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Refactorings.kt @@ -0,0 +1,58 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.edtWriteAction +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.smartReadAction +import com.intellij.openapi.application.writeIntentReadAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Ref +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNamedElement +import com.intellij.refactoring.Refactoring +import com.intellij.usageView.UsageInfo +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject + +internal class UsageRows(val rows: List, val files: Int, val truncated: Boolean) + +internal class Refactorings(private val project: Project) { + + fun target(args: ToolArgs): PsiElement = + if (args.optionalString("line") == null) Locations.psiFile(project, args.string("path")) else Locations.declarationAt(project, args) + + fun name(element: PsiElement): String = (element as? PsiNamedElement)?.name ?: Locations.kind(element) + + suspend fun usages(refactoring: Refactoring): Array = try { + smartReadAction(project) { refactoring.findUsages() } + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + + suspend fun rows(usages: Collection, max: Int): UsageRows = readAction { + val located = usages.mapNotNull { it.element } + UsageRows( + located.take(max).map { Locations.describe(project, it) }, + located.mapNotNull { it.containingFile?.virtualFile }.distinct().size, + located.size > max, + ) + } + + suspend fun perform(refactoring: Refactoring, usages: Array) { + withContext(Dispatchers.EDT) { + writeIntentReadAction { + val ref = Ref(usages) + if (!refactoring.preprocessUsages(ref)) throw ToolException("the IDE cancelled the refactoring") + refactoring.doRefactoring(ref.get()) + } + } + saveAll() + } + + suspend fun saveAll() = edtWriteAction { FileDocumentManager.getInstance().saveAllDocuments() } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Schemes.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Schemes.kt new file mode 100644 index 00000000..923e7c97 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/Schemes.kt @@ -0,0 +1,56 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.ide.ui.LafManager +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.keymap.KeymapManager +import com.intellij.openapi.keymap.ex.KeymapManagerEx +import com.intellij.psi.codeStyle.CodeStyleSchemes +import dev.lain.claudejb.model.mcp.ToolException + +internal class Schemes { + + class Listed(val current: String, val names: List) + + fun list(kind: String): Listed = when (kind) { + THEME -> Listed(LafManager.getInstance().currentUIThemeLookAndFeel?.name.orEmpty(), themes().map { it.name }) + + COLOR -> EditorColorsManager.getInstance().let { Listed(it.globalScheme.displayName, it.allSchemes.map { s -> s.displayName }) } + + KEYMAP -> Listed( + KeymapManager.getInstance().activeKeymap.presentableName, + KeymapManagerEx.getInstanceEx().allKeymaps.map { it.presentableName }, + ) + + else -> Listed(CodeStyleSchemes.getInstance().currentScheme.name, CodeStyleSchemes.getInstance().allSchemes.map { it.name }) + } + + fun set(kind: String, name: String) { + when (kind) { + THEME -> LafManager.getInstance().setCurrentUIThemeLookAndFeel(themes().firstOrNull { it.name == name } ?: missing(kind, name)) + + COLOR -> EditorColorsManager.getInstance().let { manager -> + manager.setGlobalScheme(manager.allSchemes.firstOrNull { it.displayName == name } ?: missing(kind, name)) + } + + KEYMAP -> KeymapManagerEx.getInstanceEx().let { manager -> + manager.setActiveKeymap(manager.allKeymaps.firstOrNull { it.presentableName == name } ?: missing(kind, name)) + } + + else -> CodeStyleSchemes.getInstance().let { it.setCurrentScheme(it.findSchemeByName(name) ?: missing(kind, name)) } + } + } + + private fun themes() = LafManager.getInstance().installedThemes.toList() + + private fun missing(kind: String, name: String): Nothing = + throw ToolException("no $kind scheme named $name; action=list shows the names") + + companion object { + const val THEME = "theme" + const val COLOR = "color" + const val KEYMAP = "keymap" + const val CODE_STYLE = "code_style" + + val KINDS: List = listOf(THEME, COLOR, KEYMAP, CODE_STYLE) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt new file mode 100644 index 00000000..69e7d0e4 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/SearchTools.kt @@ -0,0 +1,198 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.find.FindModel +import com.intellij.find.impl.FindInProjectUtil +import com.intellij.openapi.application.readAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.coroutineToIndicator +import com.intellij.openapi.project.IndexNotReadyException +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.usageView.UsageInfo +import com.intellij.usages.FindUsagesProcessPresentation +import com.intellij.usages.UsageViewPresentation +import dev.lain.claudejb.model.mcp.Batch +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.nio.file.FileSystems +import java.nio.file.Path +import java.util.Collections + +internal class SearchTools(private val project: Project, private val io: CoroutineDispatcher = Dispatchers.IO) { + + fun domain(): ToolDomain = ToolDomain( + "search", + "Text and file search over the project's content roots", + listOf( + Tool(SEARCH_TEXT) { ToolResult.toon(Batch.run(it, Batch.QUERIES, ::searchOne)) }, + Tool(FIND_FILES) { ToolResult.toon(Batch.run(it, Batch.NAMES, ::findOne)) }, + Tool(LIST_DIRECTORY, ::listDirectory), + ), + ) + + private suspend fun searchOne(args: ToolArgs): JsonObject { + val query = args.string("query") + val max = args.int("max", DEFAULT_MAX) + val model = FindModel().apply { + stringToFind = query + isRegularExpressions = args.boolean("regex", false) + isCaseSensitive = args.boolean("case_sensitive", false) + isProjectScope = true + isWithSubdirectories = true + args.optionalString("path")?.let { + directoryName = ReadTools.resolveDirectory(project, it).path + isProjectScope = false + } + } + val hits = Collections.synchronizedList(ArrayList()) + withContext(io) { + try { + coroutineToIndicator { indicator -> + FindInProjectUtil.findUsages(model, project, indicator, PRESENTATION, emptySet()) { info -> + hits += info + hits.size < max + } + } + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + } + val found = synchronized(hits) { hits.toList() } + val rows = readAction { found.map { describe(it) }.distinct() } + return buildJsonObject { + put("query", query) + put("count", rows.size) + put("truncated", found.size >= max) + put("matches", buildJsonArray { rows.forEach { add(it) } }) + } + } + + private fun describe(info: UsageInfo): JsonObject = buildJsonObject { + val file = info.virtualFile + val document = file?.let { FileDocumentManager.getInstance().getDocument(it) } + put("file", file?.let(::relative) ?: "") + if (document != null) put("line", document.getLineNumber(info.navigationOffset) + 1) + } + + private suspend fun findOne(args: ToolArgs): JsonObject { + val name = args.string("name") + val max = args.int("max", DEFAULT_MAX) + val found = readAction { + try { + if ('*' in name || '?' in name) glob(name, max) else byName(name, max) + } catch (e: IndexNotReadyException) { + throw ToolException("the IDE is still indexing; retry in a moment", e) + } + } + return buildJsonObject { + put("name", name) + put("count", found.size) + put("truncated", found.size >= max) + put("files", buildJsonArray { found.forEach { add(buildJsonObject { put("file", it) }) } }) + } + } + + private fun byName(name: String, max: Int): List = + FilenameIndex.getVirtualFilesByName(name, false, GlobalSearchScope.projectScope(project)).map(::relative).sorted().take(max) + + private fun glob(pattern: String, max: Int): List { + val matcher = FileSystems.getDefault().getPathMatcher("glob:$pattern") + val out = ArrayList() + ProjectFileIndex.getInstance(project).iterateContent { file -> + if (!file.isDirectory && matcher.matches(Path.of(file.name))) out += relative(file) + out.size < max + } + return out.sorted() + } + + private suspend fun listDirectory(args: ToolArgs): ToolResult { + val path = args.optionalString("path") ?: "." + val depth = args.int("depth", 1) + val max = args.int("max", DEFAULT_MAX) + if (depth < 1 || max < 1) throw ToolException("depth and max start at 1") + val rows = readAction { + val root = ReadTools.resolveDirectory(project, path) + ArrayList().also { walk(root, depth, ProjectFileIndex.getInstance(project), it, max) } + } + return ToolResult.toon( + buildJsonObject { + put("path", path) + put("count", rows.size) + put("truncated", rows.size >= max) + put("entries", buildJsonArray { rows.forEach { add(it) } }) + }, + ) + } + + private fun walk(dir: VirtualFile, depth: Int, index: ProjectFileIndex, out: MutableList, max: Int) { + val children = dir.children.filterNot(index::isExcluded).sortedWith(compareBy({ !it.isDirectory }, { it.name })) + for (child in children) { + if (out.size >= max) return + out += buildJsonObject { + put("path", relative(child)) + put("kind", if (child.isDirectory) "dir" else "file") + put("size", if (child.isDirectory) 0L else child.length) + } + if (child.isDirectory && depth > 1) walk(child, depth - 1, index, out, max) + } + } + + private fun relative(file: VirtualFile): String = Locations.relative(project, file) + + companion object { + + private const val DEFAULT_MAX = 50 + private val PRESENTATION = FindUsagesProcessPresentation(UsageViewPresentation()) + + val SEARCH_TEXT = ToolSpec( + "search_text", + "Finds text or a regular expression across the project: one row per matching line with file and line, no text; " + + "read_file the lines you need.", + listOf( + Param("query", "Text or regular expression to find", required = false), + Batch.param(Batch.QUERIES, "Several searches at once, one result per query; the other arguments apply to each"), + Param("regex", "true to treat query as a regular expression (default false)", type = "boolean", required = false), + Param("case_sensitive", "true to match case (default false)", type = "boolean", required = false), + Param("path", "Directory to search under, relative to the project root (default: whole project)", required = false), + Param("max", "Maximum matches to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val FIND_FILES = ToolSpec( + "find_files", + "Finds files by exact name or by glob (for example *.kt or Test?.java) across the project's content roots.", + listOf( + Param("name", "Exact file name, or a glob on the file name", required = false), + Batch.param(Batch.NAMES, "Several names or globs at once, one result per name"), + Param("max", "Maximum files to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val LIST_DIRECTORY = ToolSpec( + "list_directory", + "Lists a directory as the project tree shows it, excluded and ignored entries left out: one row per entry " + + "with path, kind (dir or file) and size, directories first. Use it to see the shape of a directory; " + + "to find a file by name use find_files.", + listOf( + Param("path", "Directory, absolute or relative to the project root (default: the project root)", required = false), + Param("depth", "How many levels to descend (default 1)", type = "integer", required = false), + Param("max", "Maximum entries to return (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt new file mode 100644 index 00000000..ca3d7377 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/controller/mcp/tools/code/TemplateTools.kt @@ -0,0 +1,237 @@ +package dev.lain.claudejb.controller.mcp.tools.code + +import com.intellij.codeInsight.template.TemplateActionContext +import com.intellij.codeInsight.template.TemplateManager +import com.intellij.codeInsight.template.impl.TemplateImpl +import com.intellij.codeInsight.template.impl.TemplateManagerImpl +import com.intellij.codeInsight.template.impl.TemplateSettings +import com.intellij.ide.fileTemplates.FileTemplate +import com.intellij.ide.fileTemplates.FileTemplateManager +import com.intellij.ide.fileTemplates.FileTemplateUtil +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.readAction +import com.intellij.openapi.command.writeCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiManager +import dev.lain.claudejb.controller.mcp.FocusKeeper +import dev.lain.claudejb.controller.mcp.Reveal +import dev.lain.claudejb.controller.mcp.TargetContext +import dev.lain.claudejb.model.mcp.Param +import dev.lain.claudejb.model.mcp.Tool +import dev.lain.claudejb.model.mcp.ToolArgs +import dev.lain.claudejb.model.mcp.ToolDomain +import dev.lain.claudejb.model.mcp.ToolException +import dev.lain.claudejb.model.mcp.ToolResult +import dev.lain.claudejb.model.mcp.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.Properties + +internal class TemplateTools(private val project: Project, private val targets: TargetContext, private val reveal: Reveal) { + + fun domain(): ToolDomain = ToolDomain( + "templates", + "The IDE's live templates and file templates: list them, expand a live template at a position, create a file " + + "from a file template", + listOf( + Tool(TEMPLATES, ::templates), + Tool(TEMPLATE_APPLY, ::apply), + Tool(FILE_TEMPLATES, ::fileTemplates), + Tool(FILE_FROM_TEMPLATE, ::fromTemplate), + ), + ) + + private suspend fun templates(args: ToolArgs): ToolResult { + val query = args.optionalString("query").orEmpty() + val max = args.int("max", DEFAULT_MAX) + val all = TemplateSettings.getInstance().templates + .filter { !it.isDeactivated } + .filter { query.isEmpty() || listOf(it.key, it.groupName, it.description.orEmpty()).any { s -> s.contains(query, true) } } + .sortedWith(compareBy({ it.groupName }, { it.key })) + return ToolResult.toon( + buildJsonObject { + put("query", query) + put("count", all.size) + put("truncated", all.size > max) + put( + "templates", + buildJsonArray { + all.take(max).forEach { template -> + add( + buildJsonObject { + put("key", template.key) + put("group", template.groupName) + put("description", template.description.orEmpty()) + put("text", template.string.take(TEXT_CHARS)) + }, + ) + } + }, + ) + }, + ) + } + + private suspend fun apply(args: ToolArgs): ToolResult { + val key = args.string("key") + val group = args.optionalString("group") + val template = liveTemplate(key, group) + args.string("path") + val target = TargetContext.target(args, preview = false) + val context = targets.of(target) + withContext(Dispatchers.EDT) { + val editor = applicableEditor(context, template, target) + FocusKeeper.keeping(project) { TemplateManager.getInstance(project).startTemplate(editor, template) } + } + return ToolResult.toon( + buildJsonObject { + put("key", key) + put("group", template.groupName) + put("path", target.path) + put("line", target.line) + put("started", true) + }, + ) + } + + private fun applicableEditor(context: DataContext, template: TemplateImpl, target: TargetContext.Target): Editor { + val editor = CommonDataKeys.EDITOR.getData(context) ?: throw ToolException("no editor could be opened on ${target.path}") + val psiFile = CommonDataKeys.PSI_FILE.getData(context) + val applies = psiFile != null && TemplateManagerImpl.isApplicable(template, TemplateActionContext.expanding(psiFile, editor)) + if (!applies) { + throw ToolException("the live template ${template.key} does not apply at ${target.path}:${target.line} (${template.groupName})") + } + return editor + } + + private fun liveTemplate(key: String, group: String?): TemplateImpl = + TemplateSettings.getInstance().templates.firstOrNull { it.key == key && (group == null || it.groupName == group) } + ?: throw ToolException("no live template $key${group?.let { " in group $it" } ?: ""}; templates lists them") + + private suspend fun fileTemplates(args: ToolArgs): ToolResult { + val query = args.optionalString("query").orEmpty() + val max = args.int("max", DEFAULT_MAX) + val manager = FileTemplateManager.getInstance(project) + val all = (manager.allTemplates.toList() + manager.internalTemplates.toList()) + .filter { query.isEmpty() || it.name.contains(query, true) || it.extension.contains(query, true) } + .sortedBy { it.name } + return ToolResult.toon( + buildJsonObject { + put("query", query) + put("count", all.size) + put("truncated", all.size > max) + put( + "templates", + buildJsonArray { + all.take(max).forEach { template -> + add( + buildJsonObject { + put("name", template.name) + put("extension", template.extension) + put("text", template.text.take(TEXT_CHARS)) + }, + ) + } + }, + ) + }, + ) + } + + private suspend fun fromTemplate(args: ToolArgs): ToolResult { + val name = args.string("template") + val dir = args.string("dir") + val fileName = args.string("name") + val manager = FileTemplateManager.getInstance(project) + val template = fileTemplate(manager, name) + val properties = Properties(manager.defaultProperties).apply { + (args.json["props"] as? JsonObject)?.forEach { (k, v) -> setProperty(k, (v as? JsonPrimitive)?.content ?: v.toString()) } + } + val directory = directory(dir) + val created = writeCommandAction(project, "Claude: new $fileName from $name") { + runCatching { FileTemplateUtil.createFromTemplate(template, fileName, properties, directory) } + .getOrElse { throw ToolException("the template could not be applied: ${it.message}", it) } + } + val file = readAction { created.containingFile?.virtualFile } ?: throw ToolException("the template produced no file") + reveal.file(file) + return ToolResult.toon( + buildJsonObject { + put("template", name) + put("path", Locations.relative(project, file)) + put("created", true) + }, + ) + } + + private fun fileTemplate(manager: FileTemplateManager, name: String): FileTemplate = + manager.getTemplate(name) ?: manager.internalTemplates.firstOrNull { it.name == name } + ?: throw ToolException("no file template named $name; file_templates lists them") + + private suspend fun directory(dir: String): PsiDirectory = readAction { + Locations.inside(project, dir) + PsiManager.getInstance(project).findDirectory(ReadTools.resolveDirectory(project, dir)) + ?: throw ToolException("$dir is not a directory of this project") + } + + companion object { + + private const val DEFAULT_MAX = 100 + private const val TEXT_CHARS = 200 + + val TEMPLATES = ToolSpec( + "templates", + "The IDE's live templates (Settings ▸ Editor ▸ Live Templates): key, group, description and text, filtered " + + "by a fragment; use it before template_apply.", + listOf( + Param("query", "Fragment of the key, group or description (default: all)", required = false), + Param("max", "Maximum templates (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val TEMPLATE_APPLY = ToolSpec( + "template_apply", + "Expands a live template at a position of a file, as typing its key and Tab would: the IDE's template editor " + + "runs in the file's tab, without taking the focus, and its variables are the user's to fill or accept.", + listOf( + Param("key", "The template key, e.g. sout, fori, main"), + Param("group", "The template group when the key is ambiguous", required = false), + Param("path", "File path, absolute or relative to the project root"), + Param("line", "1-based line for the caret", type = "integer"), + Param("column", "1-based column for the caret (default 1)", type = "integer", required = false), + ), + mutates = true, + ) + + val FILE_TEMPLATES = ToolSpec( + "file_templates", + "The IDE's file templates (Settings ▸ Editor ▸ File and Code Templates): name, extension and text, filtered by " + + "a fragment; use it before file_from_template.", + listOf( + Param("query", "Fragment of the name or extension (default: all)", required = false), + Param("max", "Maximum templates (default $DEFAULT_MAX)", type = "integer", required = false), + ), + ) + + val FILE_FROM_TEMPLATE = ToolSpec( + "file_from_template", + "Creates a file from a file template in a directory, as New ▸