Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Fixed

- `kitaru setup` is now safe to re-run against Claude Code. It verifies the whole registered launch (command, `--server`, and `--mode`) when it reads the entry back, so a stale entry in another scope or a failed readback is reported instead of passing as success, and it restores the previous `kitaru` entry when the replacement cannot be added. A project whose uv environment is set through `UV_PROJECT_ENVIRONMENT` is now recognized as a project install, so Cursor gets the project file rather than the global one. A skill whose final rename fails is put back in place instead of being left in a hidden retired directory.
- Analyzer tasks that return an empty list now complete successfully without creating insights, so an analysis with no eligible findings does not fail its import job. Insight editor copy containing Markdown formatting falls back to deterministic plain text.
- Worker-only installations now include the packaging dependency, and `kitaru importer test` accepts importer objects exposing `parse` and `fetch`.
- `kitaru doctor` no longer prints "Kitaru is needs attention", and its missing-skills hint points at `kitaru setup`. The one-line installer prints `uvx kitaru ...` for its next steps when the tool directory is not on the current shell's PATH yet, so they work without opening a new terminal.
Expand Down
157 changes: 124 additions & 33 deletions src/kitaru/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,22 @@ def _scope_only(cwd: Path) -> Literal["project", "user"]:


def _find_project_dir(prefix: Path, cwd: Path) -> Path | None:
"""Return the project a virtual environment belongs to, if any."""
if prefix.name != ".venv":
return None
project = prefix.parent
if not (project / "pyproject.toml").is_file():
return None
# The working directory must be inside the project, otherwise a setup
# run from elsewhere would register a project-scoped entry in the wrong
# place.
try:
cwd.relative_to(project)
except ValueError:
return None
return project
"""Return the project whose uv environment is ``prefix``, if any.

The environment is ``<project>/.venv`` unless ``UV_PROJECT_ENVIRONMENT``
points elsewhere; uv resolves a relative value against the project root.
"""
environment = os.environ.get("UV_PROJECT_ENVIRONMENT") or ".venv"
resolved_prefix = prefix.resolve()
# Walk up from the working directory rather than down from the prefix, so
# a setup run from outside the project never registers a project-scoped
# entry in the wrong place.
for candidate in (cwd, *cwd.parents):
if not (candidate / "pyproject.toml").is_file():
continue
if (candidate / environment).resolve() == resolved_prefix:
return candidate
return None


def _find_sibling_executable(name: str) -> Path | None:
Expand Down Expand Up @@ -408,7 +410,13 @@ def _write_skills(destination: Path, skills: dict[str, dict[str, bytes]]) -> Non
)
retired.rmdir()
os.replace(target, retired)
os.replace(staging, target)
try:
os.replace(staging, target)
except BaseException:
# Put the previous version back so a failed swap never
# leaves the skill missing or hidden in the retired copy.
os.replace(retired, target)
raise
shutil.rmtree(retired, ignore_errors=True)
continue
os.replace(staging, target)
Expand Down Expand Up @@ -498,6 +506,42 @@ async def register(self, command: str, args: tuple[str, ...]) -> dict[str, Any]:
raise NotImplementedError


@dataclass(frozen=True, slots=True)
class ClaudeEntry:
"""An MCP entry as `claude mcp get` reports it."""

command: str
args: tuple[str, ...]
scope: str

def matches(self, command: str, args: tuple[str, ...]) -> bool:
"""Check that this entry launches exactly ``command`` with ``args``."""
return self.command == command and self.args == args


def _parse_claude_entry(output: str) -> ClaudeEntry | None:
"""Read the launch and scope out of `claude mcp get` output.

The CLI prints one `Command:` line, one space-joined `Args:` line, and a
`Scope:` line starting with `User`, `Project`, or `Local`. A space-joined
argument list cannot recover an argument containing whitespace; entries
written by `kitaru setup` never contain one.
"""
fields: dict[str, str] = {}
for line in output.splitlines():
key, separator, value = line.strip().partition(":")
if separator:
fields.setdefault(key, value.strip())
command = fields.get("Command")
if not command:
return None
return ClaudeEntry(
command=command,
args=tuple(fields.get("Args", "").split()),
scope=fields.get("Scope", "").split(" ", 1)[0].lower(),
)


class ClaudeCodeClient(McpClient):
"""Claude Code, configured through its own `claude mcp` commands."""

Expand All @@ -512,43 +556,90 @@ async def register(self, command: str, args: tuple[str, ...]) -> dict[str, Any]:
"""Replace any existing `kitaru` entry and verify it is the one in use.

`claude mcp get` is scope-agnostic and reports whichever entry wins.
After adding ours, read it back: if the winning entry does not carry
our command, an entry in another scope shadows it and the user has to
remove that one.
An entry in our scope that already matches is left untouched. Otherwise
the entry in our scope is removed and ours added; if the add fails, the
removed entry is put back so a failed run never leaves Claude Code
without the server it had. The result is read back and must carry
exactly our command and arguments: a stale entry in another scope, or a
readback that cannot be parsed, is reported as a failure.
"""
existing = await _run_command(self.executable, "mcp", "get", MCP_SERVER_NAME)
previous: ClaudeEntry | None = None
removed: ProcessResult | None = None
if existing.returncode == 0:
await _run_command(
entry = _parse_claude_entry(existing.stdout)
if entry is not None and entry.scope == self.scope:
if entry.matches(command, args):
return self._done()
previous = entry
removed = await _run_command(
self.executable, "mcp", "remove", "--scope", self.scope, MCP_SERVER_NAME
)
added = await _run_command(
self.executable,
"mcp",
"add",
"--scope",
self.scope,
MCP_SERVER_NAME,
"--",
command,
*args,
)
added = await self._add(command, args)
if added.returncode != 0:
return _step("mcp", self.name, "failed", _failure_detail(added))
detail = _failure_detail(added)
if removed is not None and removed.returncode == 0:
detail += await self._restore(previous)
return _step("mcp", self.name, "failed", detail)
current = await _run_command(self.executable, "mcp", "get", MCP_SERVER_NAME)
if current.returncode == 0 and command not in current.stdout:
if current.returncode != 0:
return _step(
"mcp",
self.name,
"failed",
f"registered in {self.scope} scope, but an entry named "
f"registered in {self.scope} scope, but reading it back failed "
f"({_failure_detail(current)}). Check `claude mcp get "
f"{MCP_SERVER_NAME}` and run `kitaru setup` again.",
)
entry = _parse_claude_entry(current.stdout)
if entry is None or not entry.matches(command, args):
return _step(
"mcp",
self.name,
"failed",
f"registered in {self.scope} scope, but `claude mcp get` reports "
f"a different command or arguments: an entry named "
f"'{MCP_SERVER_NAME}' in another scope still wins. Remove it "
f"with `claude mcp remove {MCP_SERVER_NAME}` in that scope and "
"run `kitaru setup` again.",
)
return self._done()

def _done(self) -> dict[str, Any]:
"""Build the successful registration step."""
return _step(
"mcp", self.name, "done", f"server '{MCP_SERVER_NAME}', {self.scope} scope"
)

async def _add(self, command: str, args: tuple[str, ...]) -> ProcessResult:
"""Add the `kitaru` entry in this client's scope."""
return await _run_command(
self.executable,
"mcp",
"add",
"--scope",
self.scope,
MCP_SERVER_NAME,
"--",
command,
*args,
)

async def _restore(self, previous: ClaudeEntry | None) -> str:
"""Re-add the entry that was removed and describe the outcome."""
if previous is None:
return (
f"; the previous '{MCP_SERVER_NAME}' entry was removed and could "
"not be read back to restore it, re-add it manually"
)
restored = await self._add(previous.command, previous.args)
if restored.returncode == 0:
return f"; the previous '{MCP_SERVER_NAME}' entry was restored"
return (
f"; the previous '{MCP_SERVER_NAME}' entry could not be restored "
f"({_failure_detail(restored)})"
)


class CodexClient(McpClient):
"""Codex CLI, configured through `codex mcp add` (which overwrites)."""
Expand Down
Loading
Loading