diff --git a/docs/getting-started/using-skills.md b/docs/getting-started/using-skills.md index 411090b..5e2e14f 100644 --- a/docs/getting-started/using-skills.md +++ b/docs/getting-started/using-skills.md @@ -5,18 +5,35 @@ Darnit ships with four skills that provide structured compliance workflows direc ## Installation ```bash -# Install MCP server config + skills globally +# Install MCP server config + skills globally (Claude Code) darnit install -# Install skills per-project (checked into git, shared with team) +# Same, but explicit client flag +darnit install --client claude-code + +# Install per-project: skills in .claude/skills/ and MCP in .mcp.json darnit install --project # MCP config only, no skills darnit install --mcp-only + +# Claude Desktop (GUI app) — different config file than Claude Code +darnit install --client claude-desktop ``` After installation, restart Claude Code. The skills appear as slash commands. +### Where MCP config is written + +| Client | Scope | MCP config file | +|--------|-------|-----------------| +| Claude Code (default) | Global | `~/.claude.json` | +| Claude Code | Project (`--project`) | `.mcp.json` in repo root | +| Claude Desktop | Global | `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`, Linux: `~/.config/Claude/`) | +| Cursor | Global | `~/.cursor/mcp.json` | + +> **Note:** `--client claude` still works but is deprecated. Use `claude-code` or `claude-desktop`. + ## Available Skills | Skill | What it does | @@ -117,6 +134,8 @@ Skills are an orchestration layer over the MCP tools — not a replacement. You | Project | `.claude/skills/` | `darnit install --project` | | Package | `darnit_baseline/skills/` | Bundled in the Python package | +Global MCP config for Claude Code is written to `~/.claude.json`; with `--project`, MCP config goes in `.mcp.json` at the repo root. + ## Multiple Modules When multiple darnit implementation modules are installed, each registers its own MCP tools: diff --git a/docs/install/from-source.md b/docs/install/from-source.md index 8c036cd..fd7bd01 100644 --- a/docs/install/from-source.md +++ b/docs/install/from-source.md @@ -54,10 +54,14 @@ darnit serve --framework openssf-baseline Or configure it as an MCP server in Claude Code: ```bash +# Recommended: use darnit's install command (writes ~/.claude.json) +darnit install --client claude-code + +# Or register manually with the Claude Code CLI claude mcp add --scope user darnit -- "$(which darnit)" serve --framework openssf-baseline ``` -(The `darnit install` command also writes MCP config, but it writes to Claude Desktop's settings file, not Claude Code's — see [#270](https://github.com/kusari-oss/darnit/issues/270). The direct `claude mcp add` command works around that until the install command is fixed.) +For a project-local MCP config, run `darnit install --project` from the repo root (writes `.mcp.json`). ## Switching branches diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index 1793a50..bde45ea 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -27,6 +27,7 @@ import argparse import importlib.metadata import json +import os import sys from pathlib import Path @@ -537,8 +538,30 @@ def cmd_install(args: argparse.Namespace) -> int: import shutil if args.client == "claude": - settings_path = Path.home() / ".claude" / "settings.json" - else: + logger.warning( + "`--client claude` is deprecated; use `--client claude-code` for Claude Code " + "or `--client claude-desktop` for Claude Desktop." + ) + if args.project and args.client not in ("claude", "claude-code"): + logger.warning( + "`--project` only applies to Claude Code (writes .mcp.json). " + f"Ignoring --project for --client {args.client}." + ) + + if args.client in ("claude", "claude-code"): + if args.project: + settings_path = Path.cwd() / ".mcp.json" + else: + settings_path = Path.home() / ".claude.json" + elif args.client == "claude-desktop": + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" / "Claude" + elif sys.platform == "win32": + base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "Claude" + else: + base = Path.home() / ".config" / "Claude" + settings_path = base / "claude_desktop_config.json" + else: # cursor settings_path = Path.home() / ".cursor" / "mcp.json" settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -561,9 +584,7 @@ def cmd_install(args: argparse.Namespace) -> int: } if "darnit" in mcp_servers and not args.force: - response = input( - f"'darnit' entry already exists in {settings_path}. Overwrite? [y/N]: " - ).strip().lower() + response = input(f"'darnit' entry already exists in {settings_path}. Overwrite? [y/N]: ").strip().lower() if response not in {"y", "yes"}: logger.info("Install cancelled.") return 1 @@ -580,7 +601,7 @@ def cmd_install(args: argparse.Namespace) -> int: logger.info(f"✓ Installed darnit MCP server config in {settings_path}") # Install skills - if not args.mcp_only and args.client == "claude": + if not args.mcp_only and args.client in ("claude", "claude-code"): if args.project: skills_target = Path.cwd() / ".claude" / "skills" logger.info(f"Installing skills (project) → {skills_target}") @@ -1023,9 +1044,9 @@ def create_parser() -> argparse.ArgumentParser: ) install_parser.add_argument( "--client", - choices=["claude", "cursor"], - default="claude", - help="Client to configure (default: claude)", + choices=["claude-code", "claude-desktop", "claude", "cursor"], + default="claude-code", + help="Client to configure (default: claude-code). 'claude' is deprecated — use claude-code or claude-desktop." , ) install_parser.add_argument( "--force", @@ -1040,7 +1061,7 @@ def create_parser() -> argparse.ArgumentParser: install_parser.add_argument( "--project", action="store_true", - help="Install skills into .claude/skills/ (per-project) instead of ~/.claude/skills/ (global)", + help="Install skills into .claude/skills/ and MCP config into .mcp.json (per-project) instead of global paths", ) install_parser.set_defaults(func=cmd_install) diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index 90ab343..4b759a8 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -1,6 +1,7 @@ """Tests for darnit.cli module.""" import json +import sys import pytest @@ -20,7 +21,7 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): assert exit_code == 0 - settings_path = tmp_path / ".claude" / "settings.json" + settings_path = tmp_path / ".claude.json" assert settings_path.exists() data = json.loads(settings_path.read_text()) @@ -29,15 +30,68 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): assert data["mcpServers"]["darnit"]["command"] == "uvx" assert data["mcpServers"]["darnit"]["args"] == ["--from", "darnit", "darnit", "serve"] + assert not any( + record.levelname == "WARNING" and "deprecated" in record.message.lower() for record in caplog.records + ) + # The install function emits via logger.info(...), so we need pytest's # logging-record capture (caplog), not its stderr-FD capture (capsys). # When another test installs a logging handler that intercepts records # before they reach the stderr FD, capsys sees nothing while caplog # still captures every record. See issue #248 for the full post-mortem. - assert any( - "Installed darnit MCP server config" in record.message - for record in caplog.records - ), "expected install confirmation log message was not emitted" + assert any("Installed darnit MCP server config" in record.message for record in caplog.records), ( + "expected install confirmation log message was not emitted" + ) + + +def test_install_claude_alias_emits_deprecation_warning(tmp_path, monkeypatch, caplog): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + exit_code = main(["install", "--client", "claude", "--mcp-only"]) + + assert exit_code == 0 + assert (tmp_path / ".claude.json").exists() + assert any(record.levelname == "WARNING" and "deprecated" in record.message.lower() for record in caplog.records) + + +def test_install_claude_desktop_uses_desktop_config(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) + monkeypatch.setattr(sys, "platform", "win32") + + exit_code = main(["install", "--client", "claude-desktop", "--mcp-only", "--force"]) + + assert exit_code == 0 + + settings_path = tmp_path / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json" + assert settings_path.exists() + data = json.loads(settings_path.read_text()) + assert "darnit" in data["mcpServers"] + + +def test_install_claude_code_project_writes_mcp_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + exit_code = main(["install", "--client", "claude-code", "--project", "--mcp-only"]) + + assert exit_code == 0 + + mcp_path = tmp_path / ".mcp.json" + assert mcp_path.exists() + data = json.loads(mcp_path.read_text()) + assert "darnit" in data["mcpServers"] + + +def test_install_project_warns_for_non_claude_code(tmp_path, monkeypatch, caplog): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + exit_code = main(["install", "--client", "cursor", "--project", "--mcp-only", "--force"]) + + assert exit_code == 0 + assert (tmp_path / ".cursor" / "mcp.json").exists() + assert any(record.levelname == "WARNING" and "--project" in record.message for record in caplog.records) + def test_install_cursor_creates_settings(tmp_path, monkeypatch): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) @@ -52,11 +106,11 @@ def test_install_cursor_creates_settings(tmp_path, monkeypatch): data = json.loads(settings_path.read_text()) assert "darnit" in data["mcpServers"] + def test_install_preserves_existing_settings(tmp_path, monkeypatch): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - settings_path = tmp_path / ".claude" / "settings.json" - settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path = tmp_path / ".claude.json" settings_path.write_text( json.dumps( { @@ -71,7 +125,7 @@ def test_install_preserves_existing_settings(tmp_path, monkeypatch): ) ) - exit_code = main(["install", "--force"]) + exit_code = main(["install", "--client", "claude-code", "--force"]) assert exit_code == 0 @@ -112,9 +166,7 @@ def test_validate_command_parses_framework_path(self): @pytest.mark.unit def test_audit_command_parses_flags(self): """The audit command keeps framework, tag, and output flags.""" - args = create_parser().parse_args( - ["audit", "-f", "openssf-baseline", "-t", "level:1", "-o", "json", "."] - ) + args = create_parser().parse_args(["audit", "-f", "openssf-baseline", "-t", "level:1", "-o", "json", "."]) assert args.command == "audit" assert args.framework == "openssf-baseline" @@ -125,9 +177,7 @@ def test_audit_command_parses_flags(self): @pytest.mark.unit def test_plan_command_parses_include_and_exclude(self): """The plan command keeps include/exclude filter arguments.""" - args = create_parser().parse_args( - ["plan", "--include", "AC", "--exclude", "VM", "."] - ) + args = create_parser().parse_args(["plan", "--include", "AC", "--exclude", "VM", "."]) assert args.command == "plan" assert args.include == "AC" @@ -148,7 +198,6 @@ def test_main_without_subcommand_prints_help_and_returns_zero( assert "usage:" in captured.out assert "Declarative compliance auditing for software projects" in captured.out - @pytest.mark.unit def test_audit_command_parses_profile_flag(self): """The audit command accepts --profile flag."""