Skip to content
Open
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
23 changes: 21 additions & 2 deletions docs/getting-started/using-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion docs/install/from-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 31 additions & 10 deletions packages/darnit/src/darnit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import argparse
import importlib.metadata
import json
import os
import sys
from pathlib import Path

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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}")
Expand Down Expand Up @@ -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",
Expand All @@ -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)

Expand Down
79 changes: 64 additions & 15 deletions tests/darnit/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for darnit.cli module."""

import json
import sys

import pytest

Expand All @@ -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())
Expand All @@ -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)
Expand All @@ -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(
{
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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."""
Expand Down
Loading