diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8eedcc3..a8073712d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- Added `kitaru setup`, which installs the agent skills from `zenml-io/kitaru-skills` into `~/.agents/skills` (plus `~/.claude/skills` and `~/.codex/skills` when those CLIs are present) and registers `kitaru-mcp` with every detected coding agent: Claude Code and Codex through their own `mcp add` commands, Cursor and Windsurf through their JSON configuration files. Inside a project it launches the server through `uv run --directory ` and uses Claude Code's project scope; a tool install points at the absolute `kitaru-mcp` path. Re-running replaces the previous entry, so it is safe to run again after installing a new editor. `--mode` and the global `--server` select the MCP capability mode and target; `--no-skills` and `--no-mcp` skip either half. The one-line installer now runs it instead of carrying its own client detection. - Session node listings support the shared filter-expression syntax through the API, SDK, MCP, and `kitaru session nodes --filter`. The `node_type` field supports `eq`, `ne`, and `in` comparisons before pagination, so LLM and tool calls can be read without paging past framework spans. Omitting the filter still returns every node type in ascending index order. - Added evaluators to imports. Pass `evaluators` on `POST /api/v1/imports`, or `--evaluator` to `kitaru session import`, and every listed evaluator scores every imported session once the import finishes. A failed evaluator marks the job failed while the import's `stats` still records the parse outcome. Added `GET /api/v1/imports` and `GET /api/v1/imports/{import_id}`, with `client.imports.list(...)` and `client.imports.get(...)`, the `kitaru import list` and `kitaru import get` commands, and the `import` kind of the MCP `kitaru_activity_read` tool, to read imports back with their `stats` and `error`. Sessions created by an import carry `import_id`. - Added agent-scoped insights. Create a batch of insights for an agent with `client.insights.create(...)` or `POST /api/v1/insights`, each carrying a name, a title, an optional description, and data of type `text`, `categorical`, or `binned`. Insights can be listed with filters on `agent_id`, `name`, and `type`, fetched, updated in title and description, and deleted. @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- `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. - `if_missing` baseline scoring now adopts every evaluation an evaluator call produced instead of only one of them, so a rerun's baseline aggregates no longer lose metrics from an evaluator that returns multiple results. ## [0.25.0] - 2026-09-03 diff --git a/README.md b/README.md index cde0abeac..3672e88e0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Kitaru turns that history into something you can test: ## ⚡ Get started -**1. Install.** Open a terminal in your agent's repository and run one line. It adds Kitaru to that project's environment with `uv` (the worker that replays your agent has to live next to its dependencies), installs the coding-agent skills, and registers the MCP server with Claude Code and Codex. It ends by printing the two ways to get a server: `kitaru login --local` (Docker, free) or `kitaru login` for the managed cloud (14-day trial, no credit card required). +**1. Install.** Open a terminal in your agent's repository and run one line. It adds Kitaru to that project's environment with `uv` (the worker that replays your agent has to live next to its dependencies), installs the coding-agent skills, and registers the MCP server with Claude Code, Codex, Cursor, and Windsurf (that part is `kitaru setup`, re-run it after installing a new editor). It ends by printing the two ways to get a server: `kitaru login --local` (Docker, free) or `kitaru login` for the managed cloud (14-day trial, no credit card required). ```bash curl -fsSL https://kitaru.ai/install | bash @@ -65,12 +65,14 @@ uv run kitaru login --local # local server in Docker # or: uv run kitaru login ``` -**2. Make your coding assistant Kitaru-capable.** This is the intended way to drive Kitaru: skills teach the method, and the MCP server gives your assistant bounded operations. +**2. Make your coding assistant Kitaru-capable.** This is the intended way to drive Kitaru: skills teach the method, and the MCP server gives your assistant bounded operations. The installer already did this; one command does it again for any coding agent you install later (Claude Code, Codex, Cursor, Windsurf): ```bash -npx skills add zenml-io/kitaru-skills +uv run kitaru setup # or: kitaru setup --mode read-only, --no-skills, --no-mcp ``` +It installs the [skills](https://github.com/zenml-io/kitaru-skills) into `~/.agents/skills` (plus each agent's own skills directory) and registers `kitaru-mcp` with every agent it finds. For any other MCP client it prints the JSON to paste: + ```json { "mcpServers": { diff --git a/docs/book/agent-native/setup.md b/docs/book/agent-native/setup.md index 5207bd5a0..518bdf12f 100644 --- a/docs/book/agent-native/setup.md +++ b/docs/book/agent-native/setup.md @@ -13,7 +13,9 @@ Kitaru observes your production agents; your coding assistant is how you talk to Skills and MCP work together: the skills say how to work, and the server bounds what can be touched. {% hint style="success" %} -Used the [one-line installer](../getting-started/installation.md)? It already installed the MCP server, registered it with Claude Code (in your repo's `.mcp.json` when run inside a repository, user scope otherwise) and Codex, pointed at `http://localhost:8000` in `standard` mode, and installed the skills. Skip to [Capability modes and tools](#capability-modes-and-tools) unless you use another assistant or a different server URL. +Used the [one-line installer](../getting-started/installation.md)? It ran `kitaru setup`, which installed the skills and registered the MCP server with every coding agent it found: Claude Code (in your repo's `.mcp.json` when run inside a repository, user scope otherwise), Codex, Cursor, and Windsurf, pointed at `http://localhost:8000` in `standard` mode. Skip to [Capability modes and tools](#capability-modes-and-tools) unless you use another assistant or a different server URL. + +Installed a new coding agent since, or changed servers? Run `kitaru setup` again (`uv run kitaru setup` inside a project). It replaces the previous `kitaru` entry rather than adding a second one, and rewrites each installed skill directory from the current release (local edits under `~/.agents/skills/kitaru-*` are overwritten); `--mode read-only` and the global `--server URL` change the mode and target, and `--no-skills` / `--no-mcp` limit it to one half. {% endhint %} ## Install the MCP server diff --git a/docs/book/getting-started/installation.md b/docs/book/getting-started/installation.md index 80450a460..e9fe4d376 100644 --- a/docs/book/getting-started/installation.md +++ b/docs/book/getting-started/installation.md @@ -14,8 +14,8 @@ curl -fsSL https://kitaru.ai/install | bash That one command: 1. Adds `kitaru[cli,mcp,worker]` to the project's environment with `uv add`. The worker that replays your agent has to live next to your agent's dependencies, so this is the environment that matters. uv is installed first if you do not have it; no system Python and no `sudo` are needed. -2. Installs the [agent skills](../agent-native/setup.md) into `~/.agents/skills`, plus `~/.claude/skills` and `~/.codex/skills` when Claude Code or Codex is installed. -3. Registers the MCP server with Claude Code (in the repo's `.mcp.json`) and Codex, as `uv run --directory kitaru-mcp`. +2. Runs `kitaru setup`, which installs the [agent skills](../agent-native/setup.md) into `~/.agents/skills`, plus `~/.claude/skills` and `~/.codex/skills` when Claude Code or Codex is installed. +3. The same `kitaru setup` registers the MCP server with every coding agent it finds: Claude Code (in the repo's `.mcp.json`), Codex, Cursor (`.cursor/mcp.json` in the repo), and Windsurf, as `uv run --directory kitaru-mcp`. Anything else gets the JSON to paste. 4. Prints the two ways to get a server, and stops: ``` @@ -25,7 +25,7 @@ uv run kitaru login managed cloud. 14-day trial, no credit card requi (Inside a project Kitaru is not on your PATH, hence `uv run`. The isolated install uses plain `kitaru`.) -Works on macOS, Linux, WSL, and Git Bash on Windows. Running it again upgrades. +Works on macOS, Linux, WSL, and Git Bash on Windows. Running it again upgrades. Installed a new coding agent later? Run `uv run kitaru setup` (or `kitaru setup`) and it wires that one up too; `--mode` and the global `--server` pick the MCP capability mode and target server. {% hint style="info" %} **Not in a repository?** Run it anywhere and it installs an isolated `kitaru` CLI on your PATH instead (a `uv tool` environment under `~/.local/share/uv/tools/kitaru`). That is enough to log in, import traces, run evaluators, and serve MCP, but replays need Kitaru inside the agent's own project, so re-run the installer there when you have one. `--project` and `--global` force either mode. @@ -37,7 +37,7 @@ Works on macOS, Linux, WSL, and Git Bash on Windows. Running it again upgrades. | `--with kitaru-pydantic-ai` | Also install a package into the same environment (repeatable) | | `--server https://your-team.kitaru.ai` | Point the MCP server at a team server instead of `http://localhost:8000` | | `--project` / `--global` | Force the in-project or the isolated install | -| `--no-skills`, `--no-mcp` | Skip those steps | +| `--no-skills`, `--no-mcp` | Skip those steps (`kitaru setup` takes the same flags later) | | `--no-modify-path` | Leave your shell rc files alone (global mode) | `curl -fsSL https://kitaru.ai/install | bash -s -- --help` lists everything, with environment-variable equivalents. @@ -46,13 +46,13 @@ Works on macOS, Linux, WSL, and Git Bash on Windows. Running it again upgrades. ```bash uv add "kitaru[cli,mcp,worker]" kitaru-pydantic-ai # into this project; pick your adapter -npx skills add zenml-io/kitaru-skills # the coding-agent skills -uv run kitaru login # managed cloud; 14-day trial, no credit card required -uv run kitaru login --local # local server in Docker -# or: uv run kitaru login # an existing managed or self-hosted workspace +uv run kitaru setup # skills + MCP server for every coding agent found +uv run kitaru login # managed cloud; 14-day trial, no credit card required +uv run kitaru login --local # local server in Docker +# or: uv run kitaru login # an existing managed or self-hosted workspace ``` -plus registering `uv run kitaru-mcp --server http://localhost:8000 --mode standard` with your assistant, as described in [Set up your coding agent](../agent-native/setup.md). +`kitaru setup` is what the installer runs for steps 2 and 3; [Set up your coding agent](../agent-native/setup.md) describes what it writes and how to do it by hand. **Already inside Claude Code, Codex, or Cursor?** Open your agent's repository there, paste this, and it runs the same installer for you: @@ -63,10 +63,10 @@ Set up Kitaru in this repository by following https://kitaru.ai/install.md. Use ## Verify ```bash -kitaru doctor +kitaru doctor # or: uvx kitaru doctor, before you open a new terminal ``` -It checks the CLI, the server connection, authentication, and whether the skills are installed. Server connection and authentication fail until you have run `kitaru login --local` (needs [Docker](https://docs.docker.com/get-started/get-docker/)) or `kitaru login` for the managed cloud; the sections below cover both. +It checks the CLI, the server connection, authentication, and whether the skills are installed (`kitaru setup` installs them if not). Server connection and authentication fail until you have run `kitaru login --local` (needs [Docker](https://docs.docker.com/get-started/get-docker/)) or `kitaru login` for the managed cloud; the sections below cover both. Then read the [Quickstart](quickstart.md). It is written as prompts for your coding agent, and everything it needs is now in place. diff --git a/install.sh b/install.sh index bd729f16b..01fd78984 100755 --- a/install.sh +++ b/install.sh @@ -14,21 +14,22 @@ # `kitaru` and `kitaru-mcp` on PATH (~/.local/bin). Good for the CLI, # MCP server, imports and evaluators; replays need the project form. # --project / --global force either. -# 3. Installs the Kitaru agent skills (zenml-io/kitaru-skills) from the -# repository tarball into ~/.agents/skills, plus ~/.claude/skills and -# ~/.codex/skills when those CLIs are installed. No Node needed. -# 4. Registers the Kitaru MCP server with Claude Code and Codex if their -# CLIs are installed; prints the JSON for everything else. -# 5. Stops there and prints the two ways to get a server: local in Docker +# 3. Runs `kitaru setup`, which installs the Kitaru agent skills +# (zenml-io/kitaru-skills) into ~/.agents/skills plus ~/.claude/skills +# and ~/.codex/skills when those CLIs are installed, and registers the +# Kitaru MCP server with Claude Code, Codex, Cursor, and Windsurf when +# found (printing the JSON for everything else). Re-run `kitaru setup` +# after installing a new coding agent. Kitaru releases before `setup` +# existed get the same steps done by this script instead. +# 4. Stops there and prints the two ways to get a server: local in Docker # (`kitaru login --local`) or the managed cloud (`kitaru login`). Login # is a decision, so the script does not make it for you. # # Nothing here needs sudo. Everything lands under $HOME. Re-running upgrades. # -# Design borrowed from raindrop.sh/install (step output, tty handling, -# --no-* escape hatches) and astral.sh/uv/install.sh (no root, no assumptions -# about the system Python). Kitaru ships as a Python package, not a binary, so -# uv is the one dependency this script will install for you. +# Design follows astral.sh/uv/install.sh (no root, no assumptions about the +# system Python, --no-* escape hatches). Kitaru ships as a Python package, not +# a binary, so uv is the one dependency this script will install for you. # Bash-only, but fail politely under sh/dash/zsh-as-sh. This line is POSIX so # it runs before the shell reaches any bash syntax below. @@ -306,7 +307,33 @@ else fi # --------------------------------------------------------------------------- -# 3. Coding-agent skills +# 3. Skills and MCP registration, via `kitaru setup` +# --------------------------------------------------------------------------- +# The CLI owns client detection so it can be re-run after installing a new +# coding agent. `schema setup` is offline and only succeeds on versions that +# have the command; older ones fall through to the bash implementation below. +SETUP_DONE=0 +if [ "$KITARU_SKIP_SKILLS" = "1" ] && [ "$KITARU_SKIP_MCP" = "1" ]; then + note "Skipping skills and MCP registration (--no-skills --no-mcp)" + SETUP_DONE=1 +elif "$KITARU_BIN" schema setup >/dev/null 2>&1; then + step "Running kitaru setup (skills and MCP registration)" + SETUP_ARGS=(--mode "$KITARU_MCP_MODE") + [ "$KITARU_SKIP_SKILLS" = "1" ] && SETUP_ARGS+=(--no-skills) + [ "$KITARU_SKIP_MCP" = "1" ] && SETUP_ARGS+=(--no-mcp) + SETUP_SERVER_ARGS=() + [ -n "$KITARU_SERVER" ] && SETUP_SERVER_ARGS=(--server "$KITARU_SERVER") + if [ "$KITARU_QUIET" = "1" ]; then + quiet "$KITARU_BIN" "${SETUP_SERVER_ARGS[@]:+${SETUP_SERVER_ARGS[@]}}" setup "${SETUP_ARGS[@]}" || warn "kitaru setup failed; run it again later: $KITARU_BIN setup" + else + "$KITARU_BIN" "${SETUP_SERVER_ARGS[@]:+${SETUP_SERVER_ARGS[@]}}" setup "${SETUP_ARGS[@]}" /dev/null || true)" != "$KITARU_BIN" ]; then + K="uvx kitaru" +else + K="kitaru" +fi if [ "$KITARU_SCOPE" = "project" ]; then say " Installed into this project's environment, so run it as ${C_BOLD}uv run kitaru ...${C_RESET}" say " (or activate $VENV_DIR)." say "" -elif [ "$(PATH="$ORIG_PATH" command -v kitaru 2>/dev/null || true)" != "$KITARU_BIN" ]; then - say " Open a new terminal so 'kitaru' is on your PATH." +elif [ "$K" = "uvx kitaru" ]; then + say " 'kitaru' is not on this shell's PATH yet. Run it as ${C_BOLD}uvx kitaru ...${C_RESET} for now;" + say " a new terminal will have plain 'kitaru'." say "" fi if [ -n "$KITARU_SERVER" ]; then @@ -453,6 +492,7 @@ if [ "$KITARU_SCOPE" = "global" ]; then fi say "" say " No agent yet? ${C_BOLD}Use kitaru-guided-tour to show me Kitaru on the example agent.${C_RESET}" +say " New editor? $K setup (wires skills and MCP into it)" say " Check setup: $K doctor" say " Docs: https://docs.zenml.io/kitaru" say "" diff --git a/src/kitaru/cli/app.py b/src/kitaru/cli/app.py index afc73f325..b5c1d0dd3 100644 --- a/src/kitaru/cli/app.py +++ b/src/kitaru/cli/app.py @@ -56,6 +56,7 @@ workers, ) from kitaru.cli import auth as auth_commands +from kitaru.cli import setup as setup_commands from kitaru.cli.config import ( CONFIG_KEYS, ResolvedTarget, @@ -933,6 +934,59 @@ async def doctor() -> CommandResult: ) +@_register( + app, + _spec( + ("setup",), + "Install the agent skills and register the MCP server with every " + "detected coding agent. Re-run after installing a new one. The global " + "--server picks the server the MCP server targets.", + parameters=( + ParameterSpec( + "--mode", + "string", + "option", + False, + "MCP capability mode: read-only, standard (default), or destructive.", + ), + ParameterSpec( + "--no-skills", "boolean", "option", False, "Skip installing the skills." + ), + ParameterSpec( + "--no-mcp", + "boolean", + "option", + False, + "Skip registering the MCP server.", + ), + ), + read_only=False, + side_effects=("writes_local_file", "executes_local_code"), + idempotency="idempotent", + errors=( + "invalid_arguments", + "invalid_configuration", + "network_error", + "internal_error", + ), + ), +) +async def setup( + *, + mode: Annotated[str, Parameter(name="--mode")] = "standard", + no_skills: Annotated[bool, Parameter(name="--no-skills")] = False, + no_mcp: Annotated[bool, Parameter(name="--no-mcp")] = False, +) -> CommandResult: + """Wire skills and the MCP server into installed coding agents.""" + invocation = _invocation() + return await setup_commands.setup( + server=invocation.server, + mode=mode, + install_skills=not no_skills, + register_mcp=not no_mcp, + ) + + @_register( app, _spec( diff --git a/src/kitaru/cli/output.py b/src/kitaru/cli/output.py index 76874c145..3c919adef 100644 --- a/src/kitaru/cli/output.py +++ b/src/kitaru/cli/output.py @@ -447,6 +447,9 @@ def _emit_human_detail( if view.renderer == "doctor": _emit_doctor(console, value) return + if view.renderer == "setup": + _emit_setup(console, value) + return if not view.sections: fields = tuple( field for field in view.fields if field.min_console_width <= console.width @@ -485,9 +488,10 @@ def _emit_root(console: Console, value: dict[str, Any]) -> None: def _emit_doctor(console: Console, value: dict[str, Any]) -> None: """Render diagnostic checks as an operational checklist.""" healthy = bool(value.get("healthy")) - label = "healthy" if healthy else "needs attention" - style = "green" if healthy else "red" - console.print(f"Kitaru is [{style}]{label}[/{style}].") + if healthy: + console.print("Kitaru is [green]healthy[/green].") + else: + console.print("Kitaru [red]needs attention[/red].") checks = value.get("checks") if not isinstance(checks, list) or not checks: return @@ -509,6 +513,42 @@ def _emit_doctor(console: Console, value: dict[str, Any]) -> None: console.print(table) +def _emit_setup(console: Console, value: dict[str, Any]) -> None: + """Render setup steps as a checklist plus the manual MCP snippet.""" + install = _display_value(value.get("install")) + console.print( + f"Kitaru MCP server: [bold]{_display_value(value.get('server_url'))}[/bold] " + f"in [bold]{_display_value(value.get('mode'))}[/bold] mode " + f"({install} install)." + ) + steps = value.get("steps") + if isinstance(steps, list) and steps: + table = Table(title="Steps", title_justify="left") + table.add_column("Step") + table.add_column("Target") + table.add_column("Status") + table.add_column("Detail") + for step in steps: + if not isinstance(step, dict): + continue + status = _display_value(step.get("status")) + table.add_row( + Text(_display_value(step.get("kind"))), + Text(_display_value(step.get("target"))), + Text(status, style=_SETUP_STATUS_STYLES.get(status, "")), + Text(_display_value(step.get("detail"))), + ) + console.print(table) + if isinstance(steps, list) and any( + isinstance(step, dict) and step.get("target") == "manual" for step in steps + ): + console.print("For any other MCP client, add:") + console.print(json.dumps(value.get("mcp_snippet"), indent=2)) + + +_SETUP_STATUS_STYLES = {"done": "green", "skipped": "yellow", "failed": "red"} + + def _emit_human_section( console: Console, title: str, diff --git a/src/kitaru/cli/presentation.py b/src/kitaru/cli/presentation.py index 4fc17fd2e..90803b771 100644 --- a/src/kitaru/cli/presentation.py +++ b/src/kitaru/cli/presentation.py @@ -47,7 +47,7 @@ class HumanView: fields: tuple[HumanField, ...] sections: tuple[HumanSection, ...] = () empty_message: str = "No results found." - renderer: Literal["default", "doctor", "root"] = "default" + renderer: Literal["default", "doctor", "root", "setup"] = "default" def _format_count(value: Any) -> str: @@ -290,6 +290,11 @@ def _build_view( fields=(), renderer="doctor", ), + "setup": HumanView( + title="Setup", + fields=(), + renderer="setup", + ), "agent.list": _build_view("Agents", _ASSET_FIELDS, _ASSET_SECTIONS), "agent.get": _build_view("Agent", _ASSET_FIELDS, _ASSET_SECTIONS), "agent.register": _build_view("Agent", (), _REGISTRATION_SECTIONS), diff --git a/src/kitaru/cli/setup.py b/src/kitaru/cli/setup.py new file mode 100644 index 000000000..43698405d --- /dev/null +++ b/src/kitaru/cli/setup.py @@ -0,0 +1,688 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Wire Kitaru skills and the MCP server into installed coding agents. + +`kitaru setup` is the re-runnable half of the one-line installer. It installs +the agent skills from `zenml-io/kitaru-skills` into every skill location a +detected client reads, then registers `kitaru-mcp` with every client that can +be configured programmatically. Re-running it after installing a new coding +agent wires that agent up too. Every write replaces the previous Kitaru entry, +so repeated runs update rather than duplicate. +""" + +import asyncio +import io +import json +import os +import shutil +import sys +import tarfile +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Literal + +import httpx + +from kitaru.cli.config import resolve_target +from kitaru.cli.output import CLIError, CommandResult +from kitaru.cli.skill_discovery import SKILLS_URL, get_kitaru_skill_status + +SKILLS_ARCHIVE_URL = f"{SKILLS_URL}/archive/refs/heads/main.tar.gz" +DEFAULT_SERVER_URL = "http://localhost:8000" +LOCAL_URL_ENV = "KITARU_LOCAL_URL" +MCP_SERVER_NAME = "kitaru" +McpMode = Literal["read-only", "standard", "destructive"] +MCP_MODES: tuple[McpMode, ...] = ("read-only", "standard", "destructive") + +_MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 +_MAX_SKILL_FILE_BYTES = 4 * 1024 * 1024 +_COMMAND_TIMEOUT = 60.0 + + +@dataclass(frozen=True, slots=True) +class ProcessResult: + """Captured subprocess result.""" + + returncode: int + stdout: str + stderr: str + + +@dataclass(frozen=True, slots=True) +class McpLaunch: + """How a client should start the Kitaru MCP server.""" + + command: str + args: tuple[str, ...] + scope: Literal["project", "user"] + project_dir: Path | None + + def as_json(self) -> dict[str, Any]: + """Return the entry shape file-configured MCP clients expect.""" + return {"command": self.command, "args": list(self.args)} + + +@dataclass(frozen=True, slots=True) +class ExtractedSkills: + """Skills read out of the repository tarball.""" + + files: dict[str, dict[str, bytes]] + skipped: list[str] + + +async def setup( + *, + server: str | None, + mode: str, + install_skills: bool, + register_mcp: bool, + cwd: Path | None = None, + home: Path | None = None, +) -> CommandResult: + """Install skills and register the MCP server with detected clients. + + Args: + server: Server URL the MCP server should target. Defaults to the + same resolution every other command uses (``KITARU_API_URL``, + then the stored server), then ``KITARU_LOCAL_URL``, then the + local Docker server. + mode: MCP capability mode. + install_skills: Whether to install the agent skills. + register_mcp: Whether to register the MCP server. + cwd: Working directory; defaults to the process working directory. + home: User home; defaults to the current user's home. + + Returns: + One step per client or location with its outcome, plus next actions. + The exit code is 1 when a detected client could not be configured or + no skill destination could be written. + + Raises: + CLIError: If the mode or server URL is invalid. + """ + if mode not in MCP_MODES: + raise CLIError( + "invalid_arguments", + f"--mode must be one of {', '.join(MCP_MODES)}.", + ) + current = (cwd or Path.cwd()).absolute() + user_home = (home or Path.home()).absolute() + steps: list[dict[str, Any]] = [] + warnings: list[str] = [] + exit_code = 0 + + if not install_skills and not register_mcp: + warnings.append("Nothing to do: both --no-skills and --no-mcp were given.") + + if install_skills: + destinations = skill_destinations(user_home) + outcomes = await _install_skills(destinations) + written = 0 + for destination, outcome in zip(destinations, outcomes, strict=True): + steps.append(outcome) + if outcome["status"] == "done": + written += 1 + else: + warnings.append(f"Skills, {destination}: {outcome['detail']}") + if written == 0: + exit_code = 1 + + # The launch is only needed for MCP registration. A kitaru[cli]-only + # install with --no-mcp must still get its skills. + launch: McpLaunch | None = None + server_url: str | None = None + snippet: dict[str, Any] | None = None + if register_mcp: + server_url = _resolve_server_url(server) + try: + launch = resolve_mcp_launch(current, user_home) + except CLIError as error: + detail = error.message + (f" {error.hint}" if error.hint else "") + steps.append(_step("mcp", "kitaru-mcp", "failed", detail)) + warnings.append(f"MCP server not registered: {detail}") + exit_code = 1 + else: + mcp_args = (*launch.args, "--server", server_url, "--mode", mode) + snippet = { + "mcpServers": { + MCP_SERVER_NAME: { + "command": launch.command, + "args": list(mcp_args), + } + } + } + clients = detect_mcp_clients(current, user_home, launch) + registered = 0 + for client in clients: + step = await client.register(launch.command, mcp_args) + steps.append(step) + if step["status"] == "done": + registered += 1 + else: + warnings.append(f"{client.name}: {step['detail']}") + if not clients: + steps.append( + _step( + "mcp", + "manual", + "skipped", + "No configurable MCP client detected. Add the snippet " + "below (mcp_snippet in JSON output) to your client's " + "MCP configuration.", + ) + ) + elif registered == 0: + warnings.append( + "The MCP server could not be registered with any detected " + "client. Fix the errors above and run `kitaru setup` again." + ) + exit_code = 1 + + scope = launch.scope if launch else _scope_only(current) + result_item: dict[str, Any] = { + "install": scope, + "project_dir": str(launch.project_dir) + if launch and launch.project_dir + else None, + "server_url": server_url, + "mode": mode, + "skills": get_kitaru_skill_status(cwd=current, home=user_home)["skills"], + "steps": steps, + "mcp_snippet": snippet, + } + next_actions: list[str] = [] + if register_mcp and launch is not None: + next_actions.append( + "Restart your coding agent so it picks up the new MCP server." + ) + if scope == "user": + next_actions.append( + "Replays need Kitaru inside the agent's own project: run " + "`kitaru setup` again from that repository after adding it there." + ) + return CommandResult( + item=result_item, + warnings=warnings, + next_actions=next_actions, + exit_code=exit_code, + ) + + +# --------------------------------------------------------------------------- +# Launch resolution +# --------------------------------------------------------------------------- + + +def resolve_mcp_launch(cwd: Path, home: Path) -> McpLaunch: + """Decide how clients should launch kitaru-mcp for this installation. + + A project install (this interpreter lives in a project's virtual + environment) launches through `uv run --directory ` so the + client gets the project's environment without activating it, and Claude + Code gets a project-scoped entry. Any other install points at the + kitaru-mcp executable next to this interpreter, by absolute path, so a + client that does not share the user's PATH still finds it. + """ + project_dir = _find_project_dir(Path(sys.prefix), cwd) + uv = shutil.which("uv") + if project_dir is not None and uv is not None: + return McpLaunch( + command=uv, + args=("run", "--directory", str(project_dir), "kitaru-mcp"), + scope="project", + project_dir=project_dir, + ) + executable = _find_sibling_executable("kitaru-mcp") + if executable is None: + raise CLIError( + "invalid_configuration", + "kitaru-mcp is not installed next to this kitaru.", + hint='Install the MCP extra: uv add "kitaru[cli,mcp,worker]" ' + 'or uv tool install "kitaru[cli,mcp,worker]".', + ) + return McpLaunch(command=str(executable), args=(), scope="user", project_dir=None) + + +def _scope_only(cwd: Path) -> Literal["project", "user"]: + """Report the install scope without requiring kitaru-mcp to exist.""" + if _find_project_dir(Path(sys.prefix), cwd) is not None: + return "project" + return "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 + + +def _find_sibling_executable(name: str) -> Path | None: + """Find an executable in the directory of the running interpreter.""" + directory = Path(sys.executable).parent + for candidate in (directory / name, directory / f"{name}.exe"): + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +def _resolve_server_url(explicit: str | None) -> str: + """Pick the server URL the MCP server should target. + + Uses the same resolution as every other command (explicit option, + ``KITARU_API_URL``, stored server), then ``KITARU_LOCAL_URL`` as the + installer always has, then the default local Docker server. + """ + try: + return resolve_target(explicit_server=explicit).server_url + except CLIError as error: + if error.kind != "invalid_configuration" or explicit is not None: + raise + if "No Kitaru server was resolved" not in error.message: + raise + local = os.environ.get(LOCAL_URL_ENV) + if local: + return resolve_target(explicit_server=local).server_url + return DEFAULT_SERVER_URL + + +# --------------------------------------------------------------------------- +# Skills +# --------------------------------------------------------------------------- + + +def skill_destinations(home: Path) -> list[Path]: + """Return every skill directory a detected client reads. + + `~/.agents/skills` is the cross-agent location and always included. + Client-specific directories are added when that client's CLI is on PATH + or its home directory already exists. + """ + destinations = [home / ".agents" / "skills"] + for executable, directory in (("claude", ".claude"), ("codex", ".codex")): + if shutil.which(executable) or (home / directory).is_dir(): + destinations.append(home / directory / "skills") + return destinations + + +async def _install_skills(destinations: Sequence[Path]) -> list[dict[str, Any]]: + """Download the skills archive and install every skill into each destination. + + Returns one step per destination. A destination is written skill by + skill through a staging directory and an atomic rename, so a failure + or interrupt leaves each skill either at its previous version or at the + new one, never half-copied, and the other destinations are unaffected. + """ + try: + archive = await _fetch_skills_archive() + extracted = _extract_skills(archive) + except CLIError as error: + return [ + _step("skills", str(destination), "failed", error.message) + for destination in destinations + ] + skills = extracted.files + if not skills: + return [ + _step( + "skills", + str(destination), + "failed", + "The skills archive contained no skills.", + ) + for destination in destinations + ] + skipped_note = ( + f" Skipped oversized files: {', '.join(extracted.skipped)}." + if extracted.skipped + else "" + ) + outcomes: list[dict[str, Any]] = [] + for destination in destinations: + try: + _write_skills(destination, skills) + except OSError as error: + outcomes.append( + _step("skills", str(destination), "failed", f"{error}{skipped_note}") + ) + continue + outcomes.append( + _step( + "skills", + str(destination), + "done", + f"{len(skills)} skills: {', '.join(sorted(skills))}{skipped_note}", + ) + ) + return outcomes + + +def _write_skills(destination: Path, skills: dict[str, dict[str, bytes]]) -> None: + """Replace each skill directory under ``destination`` atomically. + + Every skill is written in full to a staging directory next to its final + location, then swapped in with a rename. The previous version is only + removed once the new one is complete. + """ + destination.mkdir(parents=True, exist_ok=True) + for name, files in skills.items(): + target = destination / name + staging = Path( + tempfile.mkdtemp(prefix=f".{name}.", suffix=".staging", dir=destination) + ) + try: + for relative, content in files.items(): + path = staging / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + if not (staging / "SKILL.md").is_file(): + raise OSError(f"{name}: SKILL.md missing after extraction") + if target.is_symlink() or target.is_file(): + target.unlink() + elif target.is_dir(): + retired = Path( + tempfile.mkdtemp(prefix=f".{name}.", suffix=".old", dir=destination) + ) + retired.rmdir() + os.replace(target, retired) + os.replace(staging, target) + shutil.rmtree(retired, ignore_errors=True) + continue + os.replace(staging, target) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +async def _fetch_skills_archive() -> bytes: + """Download the skills repository tarball, refusing oversized bodies.""" + try: + async with ( + httpx.AsyncClient(follow_redirects=True, timeout=60) as client, + client.stream("GET", SKILLS_ARCHIVE_URL) as response, + ): + response.raise_for_status() + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if size > _MAX_ARCHIVE_BYTES: + raise CLIError( + "internal_error", + "The skills archive is unexpectedly large.", + ) + chunks.append(chunk) + except httpx.HTTPError as error: + raise CLIError( + "network_error", + f"Could not download the skills from {SKILLS_ARCHIVE_URL}: {error}", + retryable=True, + ) from error + return b"".join(chunks) + + +def _extract_skills(archive: bytes) -> ExtractedSkills: + """Read `skills//...` regular files out of the repository tarball. + + Members are read explicitly instead of extracted, so no archive path can + escape the destination and no symlink or device entry is ever created. + Oversized members are skipped and reported by name. + """ + skills: dict[str, dict[str, bytes]] = {} + skipped: list[str] = [] + try: + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar: + for member in tar: + if not member.isfile(): + continue + parts = PurePosixPath(member.name).parts + # -/skills// + if len(parts) < 4 or parts[1] != "skills": + continue + if any(part in {"", ".", ".."} for part in parts): + continue + relative = str(Path(*parts[3:])) + if member.size > _MAX_SKILL_FILE_BYTES: + skipped.append(f"{parts[2]}/{relative}") + continue + name = parts[2] + extracted = tar.extractfile(member) + if extracted is None: + continue + skills.setdefault(name, {})[relative] = extracted.read() + except (tarfile.TarError, EOFError, OSError) as error: + raise CLIError( + "internal_error", f"The skills archive could not be read: {error}" + ) from error + return ExtractedSkills( + files={name: files for name, files in skills.items() if "SKILL.md" in files}, + skipped=skipped, + ) + + +# --------------------------------------------------------------------------- +# MCP clients +# --------------------------------------------------------------------------- + + +class McpClient: + """One coding agent that can be pointed at the Kitaru MCP server.""" + + name: str + + async def register(self, command: str, args: tuple[str, ...]) -> dict[str, Any]: + """Register the server and report the outcome as one step.""" + raise NotImplementedError + + +class ClaudeCodeClient(McpClient): + """Claude Code, configured through its own `claude mcp` commands.""" + + name = "Claude Code" + + def __init__(self, executable: str, scope: Literal["project", "user"]) -> None: + """Remember the CLI path and the scope to register under.""" + self.executable = executable + self.scope = scope + + 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. + """ + existing = await _run_command(self.executable, "mcp", "get", MCP_SERVER_NAME) + if existing.returncode == 0: + 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, + ) + if added.returncode != 0: + return _step("mcp", self.name, "failed", _failure_detail(added)) + current = await _run_command(self.executable, "mcp", "get", MCP_SERVER_NAME) + if current.returncode == 0 and command not in current.stdout: + return _step( + "mcp", + self.name, + "failed", + f"registered in {self.scope} scope, but 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 _step( + "mcp", self.name, "done", f"server '{MCP_SERVER_NAME}', {self.scope} scope" + ) + + +class CodexClient(McpClient): + """Codex CLI, configured through `codex mcp add` (which overwrites).""" + + name = "Codex" + + def __init__(self, executable: str) -> None: + """Remember the CLI path.""" + self.executable = executable + + async def register(self, command: str, args: tuple[str, ...]) -> dict[str, Any]: + """Add or overwrite the `kitaru` entry.""" + added = await _run_command( + self.executable, "mcp", "add", MCP_SERVER_NAME, "--", command, *args + ) + if added.returncode != 0: + return _step("mcp", self.name, "failed", _failure_detail(added)) + return _step("mcp", self.name, "done", f"server '{MCP_SERVER_NAME}'") + + +class JsonFileClient(McpClient): + """A client configured by an `mcpServers` object in a JSON file.""" + + def __init__(self, name: str, path: Path) -> None: + """Remember the display name and the configuration file.""" + self.name = name + self.path = path + + async def register(self, command: str, args: tuple[str, ...]) -> dict[str, Any]: + """Merge the `kitaru` entry into the file, keeping other servers. + + The file is rewritten through a uniquely named temporary file in the + same directory and swapped in with a rename, preserving the original + file mode (these files can hold other servers' secrets). + """ + try: + document = _read_json_object(self.path) + servers = document.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + servers[MCP_SERVER_NAME] = {"command": command, "args": list(args)} + document["mcpServers"] = servers + self.path.parent.mkdir(parents=True, exist_ok=True) + mode = self.path.stat().st_mode if self.path.exists() else None + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(json.dumps(document, indent=2) + "\n") + if mode is not None: + os.chmod(temporary, mode) + os.replace(temporary, self.path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + except (OSError, ValueError) as error: + return _step("mcp", self.name, "failed", f"{self.path}: {error}") + return _step("mcp", self.name, "done", str(self.path)) + + +def _read_json_object(path: Path) -> dict[str, Any]: + """Read a JSON object from a file, treating a missing file as empty.""" + if not path.exists(): + return {} + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("expected a JSON object at the top level") + return document + + +def detect_mcp_clients(cwd: Path, home: Path, launch: McpLaunch) -> list[McpClient]: + """Return every configurable MCP client found on this machine. + + Claude Code and Codex are detected by their CLIs. Cursor and Windsurf are + detected by their home directories and configured through their JSON + files; Cursor gets a project-level file for a project install. + """ + clients: list[McpClient] = [] + claude = shutil.which("claude") + if claude: + clients.append(ClaudeCodeClient(claude, launch.scope)) + codex = shutil.which("codex") + if codex: + clients.append(CodexClient(codex)) + if (home / ".cursor").is_dir(): + root = launch.project_dir if launch.project_dir is not None else home + clients.append(JsonFileClient("Cursor", root / ".cursor" / "mcp.json")) + windsurf = home / ".codeium" / "windsurf" + if windsurf.is_dir(): + clients.append(JsonFileClient("Windsurf", windsurf / "mcp_config.json")) + return clients + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _run_command(executable: str, *arguments: str) -> ProcessResult: + """Run one bounded external command with stdin closed.""" + try: + process = await asyncio.create_subprocess_exec( + executable, + *arguments, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as error: + return ProcessResult(returncode=127, stdout="", stderr=str(error)) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), _COMMAND_TIMEOUT) + except TimeoutError: + process.kill() + await process.communicate() + return ProcessResult( + returncode=124, stdout="", stderr=f"{executable} did not finish in time." + ) + return ProcessResult( + returncode=process.returncode or 0, + stdout=stdout.decode("utf-8", errors="replace"), + stderr=stderr.decode("utf-8", errors="replace"), + ) + + +def _failure_detail(result: ProcessResult) -> str: + """Summarize a failed command from its last output line.""" + output = (result.stderr or result.stdout).strip().splitlines() + tail = output[-1] if output else "no output" + return f"exit {result.returncode}: {tail}" + + +def _step(kind: str, target: str, status: str, detail: str) -> dict[str, Any]: + """Build one fixed-shape setup step.""" + return {"kind": kind, "target": target, "status": status, "detail": detail} diff --git a/src/kitaru/cli/skill_discovery.py b/src/kitaru/cli/skill_discovery.py index 0e2f68547..c20d10f18 100644 --- a/src/kitaru/cli/skill_discovery.py +++ b/src/kitaru/cli/skill_discovery.py @@ -23,7 +23,7 @@ import yaml -INSTALL_COMMAND = "npx skills add zenml-io/kitaru-skills" +INSTALL_COMMAND = "kitaru setup" SKILLS_URL = "https://github.com/zenml-io/kitaru-skills" SkillHost = Literal["agents", "claude", "codex"] diff --git a/tests/cli/test_schema.py b/tests/cli/test_schema.py index 987fed91a..4020ecca7 100644 --- a/tests/cli/test_schema.py +++ b/tests/cli/test_schema.py @@ -75,6 +75,7 @@ def test_top_level_schema_includes_completed_stage_one_slices() -> None: "replay", "schema", "session", + "setup", "status", "version", "worker", diff --git a/tests/cli/test_setup.py b/tests/cli/test_setup.py new file mode 100644 index 000000000..a8561d585 --- /dev/null +++ b/tests/cli/test_setup.py @@ -0,0 +1,554 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""`kitaru setup`: skills install and MCP registration per client.""" + +import io +import json +import os +import stat +import tarfile +from pathlib import Path + +import pytest + +from kitaru.cli import setup as setup_cli +from kitaru.cli.output import CLIError +from kitaru.cli.setup import McpLaunch, ProcessResult + +MCP = "/opt/kitaru/bin/kitaru-mcp" + + +def _archive( + skills: dict[str, dict[str, str]], *, extra: dict[str, str] | None = None +) -> bytes: + """Build a GitHub-style repository tarball with the given skills.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + entries = { + f"kitaru-skills-main/skills/{name}/{file}": content + for name, files in skills.items() + for file, content in files.items() + } + entries.update(extra or {}) + for path, content in entries.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(path) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buffer.getvalue() + + +_SKILLS = { + "kitaru-investigation": { + "SKILL.md": "---\nname: kitaru-investigation\ndescription: Investigate.\n---\n", + "reference/steps.md": "steps", + }, + "kitaru-replay-experiment": { + "SKILL.md": "---\nname: kitaru-replay-experiment\ndescription: Replay.\n---\n", + }, +} + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch) -> Path: + """An isolated home with no clients, no stored server, a user-scope launch.""" + user_home = tmp_path / "home" + user_home.mkdir() + monkeypatch.setenv("HOME", str(user_home)) + monkeypatch.delenv("KITARU_API_URL", raising=False) + monkeypatch.delenv("KITARU_LOCAL_URL", raising=False) + monkeypatch.setattr(setup_cli.shutil, "which", lambda name: None) + monkeypatch.setattr( + setup_cli, + "resolve_mcp_launch", + lambda cwd, home: McpLaunch( + command=MCP, args=(), scope="user", project_dir=None + ), + ) + monkeypatch.setattr("kitaru.cli.config.get_server_url", lambda: None, raising=True) + + async def fetch() -> bytes: + return _archive(_SKILLS) + + monkeypatch.setattr(setup_cli, "_fetch_skills_archive", fetch) + return user_home + + +async def _run(home: Path, **overrides): + """Run setup with defaults against the isolated home.""" + options = { + "server": None, + "mode": "standard", + "install_skills": True, + "register_mcp": True, + "cwd": home / "work", + "home": home, + } + options.update(overrides) + (home / "work").mkdir(exist_ok=True) + return await setup_cli.setup(**options) + + +def _clis(monkeypatch, **paths: str) -> None: + monkeypatch.setattr(setup_cli.shutil, "which", lambda name: paths.get(name)) + + +def _fake_claude(calls: list[tuple[str, ...]], *, existing: bool, winner: str): + """A claude/codex CLI stub; `winner` is what `claude mcp get` reports.""" + + async def run(executable: str, *arguments: str) -> ProcessResult: + calls.append((executable, *arguments)) + if arguments[:2] == ("mcp", "get"): + # Before the add: only "existing" answers. After: the winner. + adds = [c for c in calls if c[1:3] == ("mcp", "add")] + if not adds and not existing: + return ProcessResult(returncode=1, stdout="", stderr="not found") + return ProcessResult(returncode=0, stdout=f"kitaru: {winner}", stderr="") + return ProcessResult(returncode=0, stdout="", stderr="") + + return run + + +async def test_installs_skills_into_agents_dir_and_reports_manual_snippet(home: Path): + """With no client detected, skills land in ~/.agents and the snippet is printed.""" + result = await _run(home) + + item = result.item + agents = home / ".agents" / "skills" + assert (agents / "kitaru-investigation" / "SKILL.md").is_file() + assert ( + agents / "kitaru-investigation" / "reference" / "steps.md" + ).read_text() == "steps" + assert (agents / "kitaru-replay-experiment" / "SKILL.md").is_file() + assert not (home / ".claude").exists() + assert not [p for p in agents.iterdir() if p.name.startswith(".")] + assert item["skills"] == ["kitaru-investigation", "kitaru-replay-experiment"] + assert item["server_url"] == "http://localhost:8000" + assert item["mcp_snippet"] == { + "mcpServers": { + "kitaru": { + "command": MCP, + "args": ["--server", "http://localhost:8000", "--mode", "standard"], + } + } + } + statuses = {(s["kind"], s["target"]): s["status"] for s in item["steps"]} + assert statuses == {("skills", str(agents)): "done", ("mcp", "manual"): "skipped"} + assert result.exit_code == 0 + assert result.warnings == [] + + +async def test_rerun_replaces_stale_skill_files(home: Path): + """A re-run removes files the previous skill version shipped.""" + await _run(home) + stale = home / ".agents" / "skills" / "kitaru-investigation" / "old.md" + stale.write_text("stale", encoding="utf-8") + + await _run(home) + + assert not stale.exists() + assert (stale.parent / "SKILL.md").is_file() + + +async def test_archive_entries_outside_skills_are_ignored(home: Path, monkeypatch): + """Path traversal, top-level files, and skill dirs without SKILL.md are dropped.""" + + async def fetch() -> bytes: + return _archive( + _SKILLS, + extra={ + "kitaru-skills-main/README.md": "readme", + "kitaru-skills-main/skills/../../escape.md": "bad", + "kitaru-skills-main/skills/no-manifest/notes.md": "no manifest", + }, + ) + + monkeypatch.setattr(setup_cli, "_fetch_skills_archive", fetch) + await _run(home) + + skills = home / ".agents" / "skills" + assert sorted(p.name for p in skills.iterdir()) == sorted(_SKILLS) + assert not (home / "escape.md").exists() + assert not (home / ".agents" / "escape.md").exists() + + +async def test_oversized_member_is_skipped_and_reported(home: Path, monkeypatch): + """A file over the size limit is left out and named in the step detail.""" + monkeypatch.setattr(setup_cli, "_MAX_SKILL_FILE_BYTES", 80) + + async def fetch() -> bytes: + return _archive( + _SKILLS, + extra={"kitaru-skills-main/skills/kitaru-investigation/big.md": "x" * 100}, + ) + + monkeypatch.setattr(setup_cli, "_fetch_skills_archive", fetch) + result = await _run(home, register_mcp=False) + + step = result.item["steps"][0] + assert step["status"] == "done" + assert "Skipped oversized files: kitaru-investigation/big.md" in step["detail"] + assert not ( + home / ".agents" / "skills" / "kitaru-investigation" / "big.md" + ).exists() + assert result.item["skills"] == ["kitaru-investigation", "kitaru-replay-experiment"] + + +async def test_claude_and_codex_clients_register_through_their_clis( + home: Path, monkeypatch +): + """Detected CLIs get the skills copy and an MCP entry via `mcp add`.""" + _clis(monkeypatch, claude="/bin/claude", codex="/bin/codex") + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr( + setup_cli, "_run_command", _fake_claude(calls, existing=True, winner=MCP) + ) + + result = await _run(home, server="http://localhost:9000", mode="read-only") + + for directory in (".agents", ".claude", ".codex"): + assert ( + home / directory / "skills" / "kitaru-investigation" / "SKILL.md" + ).is_file() + expected = (MCP, "--server", "http://localhost:9000", "--mode", "read-only") + assert calls == [ + ("/bin/claude", "mcp", "get", "kitaru"), + ("/bin/claude", "mcp", "remove", "--scope", "user", "kitaru"), + ("/bin/claude", "mcp", "add", "--scope", "user", "kitaru", "--", *expected), + ("/bin/claude", "mcp", "get", "kitaru"), + ("/bin/codex", "mcp", "add", "kitaru", "--", *expected), + ] + mcp_steps = [s for s in result.item["steps"] if s["kind"] == "mcp"] + assert [(s["target"], s["status"]) for s in mcp_steps] == [ + ("Claude Code", "done"), + ("Codex", "done"), + ] + assert result.warnings == [] + assert result.exit_code == 0 + + +async def test_claude_entry_shadowed_by_another_scope_is_reported( + home: Path, monkeypatch +): + """When `claude mcp get` still shows another command after the add, fail.""" + _clis(monkeypatch, claude="/bin/claude") + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr( + setup_cli, + "_run_command", + _fake_claude(calls, existing=True, winner="/old/kitaru-mcp"), + ) + + result = await _run(home, install_skills=False) + + step = result.item["steps"][0] + assert step["status"] == "failed" + assert "another scope still wins" in step["detail"] + assert result.exit_code == 1 + + +async def test_one_failed_client_among_several_is_a_warning(home: Path, monkeypatch): + """One failing client does not stop the others or fail the command.""" + _clis(monkeypatch, claude="/bin/claude", codex="/bin/codex") + + async def run(executable: str, *arguments: str) -> ProcessResult: + if executable == "/bin/claude": + return ProcessResult( + returncode=1, stdout="", stderr="boom\nclaude exploded" + ) + return ProcessResult(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(setup_cli, "_run_command", run) + + result = await _run(home, install_skills=False) + + steps = {s["target"]: s for s in result.item["steps"]} + assert steps["Claude Code"]["status"] == "failed" + assert steps["Claude Code"]["detail"] == "exit 1: claude exploded" + assert steps["Codex"]["status"] == "done" + assert result.warnings == ["Claude Code: exit 1: claude exploded"] + assert result.exit_code == 0 + + +async def test_every_detected_client_failing_exits_nonzero(home: Path): + """Detected but unconfigurable clients are not reported as 'none detected'.""" + cursor = home / ".cursor" + cursor.mkdir() + (cursor / "mcp.json").write_text("not json", encoding="utf-8") + + result = await _run(home, install_skills=False) + + targets = [s["target"] for s in result.item["steps"]] + assert targets == ["Cursor"] + assert result.item["steps"][0]["status"] == "failed" + assert any( + "could not be registered with any detected" in w for w in result.warnings + ) + assert result.exit_code == 1 + + +async def test_json_clients_merge_into_existing_config_and_keep_mode(home: Path): + """Cursor and Windsurf keep other servers, replace only `kitaru`, keep 0600.""" + cursor = home / ".cursor" + cursor.mkdir() + config = cursor / "mcp.json" + config.write_text( + json.dumps( + {"mcpServers": {"other": {"command": "x"}, "kitaru": {"command": "old"}}} + ), + encoding="utf-8", + ) + config.chmod(0o600) + (home / ".codeium" / "windsurf").mkdir(parents=True) + + result = await _run(home, install_skills=False) + await _run(home, install_skills=False) + + cursor_config = json.loads(config.read_text(encoding="utf-8")) + assert cursor_config["mcpServers"]["other"] == {"command": "x"} + assert cursor_config["mcpServers"]["kitaru"] == { + "command": MCP, + "args": ["--server", "http://localhost:8000", "--mode", "standard"], + } + assert stat.S_IMODE(config.stat().st_mode) == 0o600 + assert [p.name for p in cursor.iterdir()] == ["mcp.json"] + windsurf_config = json.loads( + (home / ".codeium" / "windsurf" / "mcp_config.json").read_text(encoding="utf-8") + ) + assert set(windsurf_config["mcpServers"]) == {"kitaru"} + assert [(s["target"], s["status"]) for s in result.item["steps"]] == [ + ("Cursor", "done"), + ("Windsurf", "done"), + ] + + +async def test_project_install_uses_uv_run_and_project_scope(home: Path, monkeypatch): + """A project launch registers Claude in project scope and Cursor in the repo.""" + project = home / "repo" + project.mkdir() + (home / ".cursor").mkdir() + monkeypatch.setattr( + setup_cli, + "resolve_mcp_launch", + lambda cwd, home: McpLaunch( + command="/bin/uv", + args=("run", "--directory", str(project), "kitaru-mcp"), + scope="project", + project_dir=project, + ), + ) + _clis(monkeypatch, claude="/bin/claude") + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr( + setup_cli, "_run_command", _fake_claude(calls, existing=False, winner="/bin/uv") + ) + + result = await _run(home, install_skills=False, cwd=project) + + assert calls == [ + ("/bin/claude", "mcp", "get", "kitaru"), + ( + "/bin/claude", + "mcp", + "add", + "--scope", + "project", + "kitaru", + "--", + "/bin/uv", + "run", + "--directory", + str(project), + "kitaru-mcp", + "--server", + "http://localhost:8000", + "--mode", + "standard", + ), + ("/bin/claude", "mcp", "get", "kitaru"), + ] + assert (project / ".cursor" / "mcp.json").is_file() + assert result.item["install"] == "project" + assert result.item["project_dir"] == str(project) + assert result.next_actions == [ + "Restart your coding agent so it picks up the new MCP server." + ] + + +async def test_server_resolution_matches_other_commands(home: Path, monkeypatch): + """KITARU_API_URL wins over the stored server; KITARU_LOCAL_URL is the fallback.""" + monkeypatch.setenv("KITARU_LOCAL_URL", "http://localhost:9100") + result = await _run(home, install_skills=False) + assert result.item["server_url"] == "http://localhost:9100" + + monkeypatch.setattr( + "kitaru.cli.config.get_server_url", lambda: "https://team.example" + ) + result = await _run(home, install_skills=False) + assert result.item["server_url"] == "https://team.example" + + monkeypatch.setenv("KITARU_API_URL", "https://env.example") + result = await _run(home, install_skills=False) + assert result.item["server_url"] == "https://env.example" + + +async def test_invalid_server_and_mode_are_rejected(home: Path): + """A malformed --server or an unknown --mode is an argument error.""" + with pytest.raises(CLIError) as error: + await _run(home, server="not a url", install_skills=False) + assert error.value.kind == "invalid_arguments" + with pytest.raises(CLIError) as error: + await _run(home, mode="yolo") + assert error.value.kind == "invalid_arguments" + + +async def test_no_mcp_installs_skills_without_kitaru_mcp(home: Path, monkeypatch): + """A kitaru[cli]-only install with --no-mcp still gets its skills.""" + + def missing(cwd, home): + raise CLIError( + "invalid_configuration", "kitaru-mcp is not installed next to this kitaru." + ) + + monkeypatch.setattr(setup_cli, "resolve_mcp_launch", missing) + + result = await _run(home, register_mcp=False) + + assert (home / ".agents" / "skills" / "kitaru-investigation" / "SKILL.md").is_file() + assert result.item["mcp_snippet"] is None + assert result.item["server_url"] is None + assert result.exit_code == 0 + + +async def test_missing_kitaru_mcp_is_a_failed_step_not_an_abort( + home: Path, monkeypatch +): + """Skills still install when MCP registration cannot even resolve a launch.""" + + def missing(cwd, home): + raise CLIError( + "invalid_configuration", + "kitaru-mcp is not installed next to this kitaru.", + hint="Install the MCP extra.", + ) + + monkeypatch.setattr(setup_cli, "resolve_mcp_launch", missing) + + result = await _run(home) + + kinds = [(s["kind"], s["status"]) for s in result.item["steps"]] + assert kinds == [("skills", "done"), ("mcp", "failed")] + assert "Install the MCP extra." in result.item["steps"][1]["detail"] + assert result.exit_code == 1 + + +async def test_skill_download_failure_is_reported_per_destination( + home: Path, monkeypatch +): + """A download failure marks every destination failed, MCP still proceeds.""" + + async def fetch() -> bytes: + raise CLIError("network_error", "offline", retryable=True) + + monkeypatch.setattr(setup_cli, "_fetch_skills_archive", fetch) + + result = await _run(home) + + skills_steps = [s for s in result.item["steps"] if s["kind"] == "skills"] + assert [s["status"] for s in skills_steps] == ["failed"] + assert result.warnings[0].startswith("Skills, ") + assert result.item["steps"][-1]["target"] == "manual" + assert result.exit_code == 1 + + +async def test_unwritable_destination_leaves_previous_skill_intact( + home: Path, monkeypatch +): + """A write failure in one destination neither destroys its old skill nor others.""" + _clis(monkeypatch, codex="/bin/codex") + await _run(home, register_mcp=False) + codex_skill = home / ".codex" / "skills" / "kitaru-investigation" + assert codex_skill.is_dir() + + real_mkdtemp = setup_cli.tempfile.mkdtemp + + def failing_mkdtemp(*args, **kwargs): + if str(kwargs.get("dir", "")).startswith(str(home / ".codex")): + raise OSError("disk full") + return real_mkdtemp(*args, **kwargs) + + monkeypatch.setattr(setup_cli.tempfile, "mkdtemp", failing_mkdtemp) + + result = await _run(home, register_mcp=False) + + statuses = {s["target"]: s["status"] for s in result.item["steps"]} + assert statuses[str(home / ".agents" / "skills")] == "done" + assert statuses[str(home / ".codex" / "skills")] == "failed" + assert (codex_skill / "SKILL.md").is_file() + assert result.exit_code == 0 + + +async def test_nothing_to_do_is_a_warning(home: Path): + """--no-skills --no-mcp does nothing and says so.""" + result = await _run(home, install_skills=False, register_mcp=False) + assert result.item["steps"] == [] + assert result.warnings == [ + "Nothing to do: both --no-skills and --no-mcp were given." + ] + + +def test_resolve_mcp_launch_project_mode(tmp_path: Path, monkeypatch): + """A .venv under a pyproject, run from inside the project, launches via uv run.""" + project = tmp_path / "repo" + (project / ".venv" / "bin").mkdir(parents=True) + (project / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8") + monkeypatch.setattr(setup_cli.sys, "prefix", str(project / ".venv")) + monkeypatch.setattr( + setup_cli.shutil, "which", lambda name: "/bin/uv" if name == "uv" else None + ) + + launch = setup_cli.resolve_mcp_launch(project / "src", tmp_path) + + assert launch == McpLaunch( + command="/bin/uv", + args=("run", "--directory", str(project), "kitaru-mcp"), + scope="project", + project_dir=project, + ) + monkeypatch.setattr( + setup_cli.sys, "executable", str(project / ".venv" / "bin" / "python") + ) + with pytest.raises(CLIError): + # From outside the project there is no sibling executable either. + setup_cli.resolve_mcp_launch(tmp_path, tmp_path) + + +def test_resolve_mcp_launch_user_mode_uses_sibling_executable( + tmp_path: Path, monkeypatch +): + """A tool install points at the absolute kitaru-mcp next to the interpreter.""" + bin_dir = tmp_path / "tools" / "kitaru" / "bin" + bin_dir.mkdir(parents=True) + mcp = bin_dir / "kitaru-mcp" + mcp.write_text("#!/bin/sh\n", encoding="utf-8") + mcp.chmod(mcp.stat().st_mode | stat.S_IXUSR) + monkeypatch.setattr(setup_cli.sys, "prefix", str(bin_dir.parent)) + monkeypatch.setattr(setup_cli.sys, "executable", str(bin_dir / "python")) + + launch = setup_cli.resolve_mcp_launch(tmp_path, tmp_path) + + assert launch.scope == "user" + assert launch.command == str(mcp) + assert launch.args == () + assert os.access(launch.command, os.X_OK)