From fcf5dd1140351c509027ca7a5e3504d4c9d11363 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 20 Apr 2026 14:57:15 -0400 Subject: [PATCH 1/5] 'init' --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 1022e339..306d632c 100644 --- a/uv.lock +++ b/uv.lock @@ -449,10 +449,10 @@ attestation = [ [package.metadata] requires-dist = [ { name = "cel-python", specifier = ">=0.5.0" }, - { name = "cryptography", marker = "extra == 'attestation'", specifier = ">=46.0.6" }, + { name = "cryptography", marker = "extra == 'attestation'", specifier = ">=46.0.6,<48" }, { name = "in-toto-attestation", marker = "extra == 'attestation'", specifier = ">=0.9.0" }, { name = "jinja2", specifier = ">=3.1.0" }, - { name = "mcp", specifier = ">=1.23.0" }, + { name = "mcp", specifier = ">=1.23.0,<2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, @@ -460,7 +460,7 @@ requires-dist = [ { name = "sigstore", marker = "extra == 'attestation'", specifier = ">=3.0.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tomllib-stubs", marker = "python_full_version < '3.11'", specifier = ">=0.1.0" }, - { name = "urllib3", marker = "extra == 'attestation'", specifier = ">=2.6.3" }, + { name = "urllib3", marker = "extra == 'attestation'", specifier = ">=2.6.3,<3" }, ] provides-extras = ["attestation"] From c3a4ee0c7c1e6c940df1f23bd15b801c5c94cfac Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 20 Apr 2026 15:39:48 -0400 Subject: [PATCH 2/5] Add timeout handling test for detect_forge --- tests/darnit/context/test_detectors.py | 11 +++++++++++ uv.lock | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/darnit/context/test_detectors.py b/tests/darnit/context/test_detectors.py index 52e57951..01ba0137 100644 --- a/tests/darnit/context/test_detectors.py +++ b/tests/darnit/context/test_detectors.py @@ -5,6 +5,7 @@ No real git repos or network calls needed. """ +import subprocess from pathlib import Path from unittest.mock import patch @@ -58,6 +59,16 @@ def test_git_command_fails(self, tmp_path: Path) -> None: result = detect_forge(str(tmp_path)) assert result == "unknown" + def test_git_command_times_out(self, tmp_path: Path) -> None: + """Returns 'unknown' when git remote lookup times out.""" + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10), + ): + result = detect_forge(str(tmp_path)) + + assert result == "unknown" + class TestDetectCI: """Tests for detect_ci().""" diff --git a/uv.lock b/uv.lock index 306d632c..1022e339 100644 --- a/uv.lock +++ b/uv.lock @@ -449,10 +449,10 @@ attestation = [ [package.metadata] requires-dist = [ { name = "cel-python", specifier = ">=0.5.0" }, - { name = "cryptography", marker = "extra == 'attestation'", specifier = ">=46.0.6,<48" }, + { name = "cryptography", marker = "extra == 'attestation'", specifier = ">=46.0.6" }, { name = "in-toto-attestation", marker = "extra == 'attestation'", specifier = ">=0.9.0" }, { name = "jinja2", specifier = ">=3.1.0" }, - { name = "mcp", specifier = ">=1.23.0,<2" }, + { name = "mcp", specifier = ">=1.23.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, @@ -460,7 +460,7 @@ requires-dist = [ { name = "sigstore", marker = "extra == 'attestation'", specifier = ">=3.0.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tomllib-stubs", marker = "python_full_version < '3.11'", specifier = ">=0.1.0" }, - { name = "urllib3", marker = "extra == 'attestation'", specifier = ">=2.6.3,<3" }, + { name = "urllib3", marker = "extra == 'attestation'", specifier = ">=2.6.3" }, ] provides-extras = ["attestation"] From aef5e2a0845c8ac2b267c9cc56d2b8aca8880798 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 10 Jun 2026 11:33:41 -0400 Subject: [PATCH 3/5] 'init' --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 182bb1d2..35f581ab 100644 --- a/uv.lock +++ b/uv.lock @@ -440,7 +440,7 @@ dependencies = [ requires-dist = [ { name = "darnit-core", editable = "packages/darnit" }, { name = "tree-sitter", specifier = ">=0.25" }, - { name = "tree-sitter-language-pack", specifier = ">=1.5" }, + { name = "tree-sitter-language-pack", specifier = ">=1.5,<1.8" }, ] [[package]] From 494bf4d0c04a25e04aa501fa18cacbe0dd227094 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 29 Jun 2026 18:57:07 -0400 Subject: [PATCH 4/5] fix(cli): write MCP install config to Claude Code paths (#270) --- docs/getting-started/using-skills.md | 23 +++++- docs/install/from-source.md | 6 +- packages/darnit/src/darnit/cli.py | 115 ++++++++++++++++----------- tests/darnit/test_cli.py | 54 +++++++++++-- 4 files changed, 142 insertions(+), 56 deletions(-) diff --git a/docs/getting-started/using-skills.md b/docs/getting-started/using-skills.md index 411090bb..d04dd0cc 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/settings.json` | +| 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 8c036cda..fd7bd01f 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 1793a50f..abfe9511 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -112,10 +112,7 @@ def format_results_text(results: list[dict], framework_name: str, show_all: bool for r in passes: lines.append(format_result_text(r)) if not show_all and len(by_status["PASS"]) > 10: - lines.append( - f" ... and {len(by_status['PASS']) - 10} more " - "(use --show-all to list every check)" - ) + lines.append(f" ... and {len(by_status['PASS']) - 10} more (use --show-all to list every check)") # With --show-all, list every remaining status (e.g. N/A) so the output # documents every check for conformance evidence. @@ -166,8 +163,7 @@ def cmd_audit(args: argparse.Namespace) -> int: # Warn about limited functionality in terminal mode logger.warning( - "Running in terminal mode (no LLM consultation). " - "For full capabilities, use 'darnit serve' with an MCP client." + "Running in terminal mode (no LLM consultation). For full capabilities, use 'darnit serve' with an MCP client." ) repo_path = Path(args.repo_path).resolve() @@ -235,9 +231,7 @@ def cmd_audit(args: argparse.Namespace) -> int: if args.output == "json": sys.stdout.write(format_results_json(results, config.framework_name) + "\n") else: - sys.stdout.write( - format_results_text(results, config.framework_name, show_all=args.show_all) + "\n" - ) + sys.stdout.write(format_results_text(results, config.framework_name, show_all=args.show_all) + "\n") # Return non-zero if any failures failures = [r for r in results if r.get("status") == "FAIL"] @@ -395,6 +389,7 @@ def cmd_init(args: argparse.Namespace) -> int: framework = args.framework else: from darnit.core.discovery import discover_implementations + impls = discover_implementations() if len(impls) == 1: framework = next(iter(impls)) @@ -457,6 +452,7 @@ def cmd_list(args: argparse.Namespace) -> int: return 0 + def cmd_profiles(args: argparse.Namespace) -> int: """List available audit profiles defined by loaded implementations.""" from darnit.core.discovery import discover_implementations @@ -489,13 +485,12 @@ def cmd_profiles(args: argparse.Namespace) -> int: return 0 + def _find_skills_dir() -> Path | None: """Find the skills directory from the darnit package.""" skills_dir = Path(__file__).parent / "skills" if skills_dir.is_dir(): - has_skills = any( - (d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir() - ) + has_skills = any((d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir()) if has_skills: return skills_dir return None @@ -537,8 +532,19 @@ def cmd_install(args: argparse.Namespace) -> int: import shutil if args.client == "claude": + logger.warning( + "`--client claude` is deprecated; use `--client claude-code` for Claude Code " + "or `--client claude-desktop` for Claude Desktop." + ) + + 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": settings_path = Path.home() / ".claude" / "settings.json" - else: + else: # cursor settings_path = Path.home() / ".cursor" / "mcp.json" settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -561,9 +567,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 +584,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}") @@ -600,6 +604,7 @@ def cmd_install(args: argparse.Namespace) -> int: logger.info("Skills available: /darnit-audit, /darnit-data, /darnit-comply, /darnit-remediate") return 0 + # Safety ceiling on audit<->collect_context rounds. Each iteration resolves ALL # pending questions in one batch (the answers comprehension in cmd_run), so this # bounds re-audit rounds, not the number of controls. @@ -705,6 +710,7 @@ def cmd_run(args: argparse.Namespace) -> int: return 1 if failed else 0 + def cmd_serve(args: argparse.Namespace) -> int: """Start the MCP server. @@ -771,7 +777,6 @@ def cmd_serve(args: argparse.Namespace) -> int: # Helpers - def _detect_default_branch(repo_path: Path) -> str: """Detect the default branch name.""" import subprocess @@ -806,7 +811,8 @@ def create_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "-V", "--version", + "-V", + "--version", action="version", # PyPI distribution name is `darnit-core` (the CLI command stays `darnit`). # Fall back to "dev" when running from a non-installed source checkout @@ -815,12 +821,14 @@ def create_parser() -> argparse.ArgumentParser: version=f"%(prog)s {_resolve_version()}", ) parser.add_argument( - "-v", "--verbose", + "-v", + "--verbose", action="store_true", help="Enable verbose output", ) parser.add_argument( - "-q", "--quiet", + "-q", + "--quiet", action="store_true", help="Suppress non-essential output", ) @@ -832,11 +840,11 @@ def create_parser() -> argparse.ArgumentParser: "serve", help="Start MCP server (recommended)", description="Start darnit as an MCP server. This is the recommended way to use darnit " - "as it enables full LLM consultation capabilities for intelligent analysis.\n\n" - "Usage:\n" - " darnit serve config.toml # Use TOML config file\n" - " darnit serve --framework NAME # Use named framework\n" - " darnit serve # Auto-detect framework", + "as it enables full LLM consultation capabilities for intelligent analysis.\n\n" + "Usage:\n" + " darnit serve config.toml # Use TOML config file\n" + " darnit serve --framework NAME # Use named framework\n" + " darnit serve # Auto-detect framework", formatter_class=argparse.RawDescriptionHelpFormatter, ) serve_parser.add_argument( @@ -845,7 +853,8 @@ def create_parser() -> argparse.ArgumentParser: help="Path to TOML config file (e.g., my-framework.toml)", ) serve_parser.add_argument( - "-f", "--framework", + "-f", + "--framework", help="Framework to use (default: auto-detect). Ignored if config file is provided.", ) serve_parser.set_defaults(func=cmd_serve) @@ -855,8 +864,8 @@ def create_parser() -> argparse.ArgumentParser: "audit", help="[Debug] Run audit without LLM", description="Run compliance audit in terminal mode. NOTE: This runs without LLM " - "consultation - checks requiring analysis will return WARN/inconclusive. " - "For full capabilities, use 'darnit serve' with an MCP client.", + "consultation - checks requiring analysis will return WARN/inconclusive. " + "For full capabilities, use 'darnit serve' with an MCP client.", ) audit_parser.add_argument( "repo_path", @@ -865,15 +874,17 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository (default: current directory)", ) audit_parser.add_argument( - "-f", "--framework", + "-f", + "--framework", help="Framework to use (name or path to .toml file)", ) audit_parser.add_argument( - "-t", "--tags", + "-t", + "--tags", action="append", default=[], help="Filter controls by attributes (e.g., level=1, domain=VM, security). " - "Multiple filters use AND logic. Bare values match tags list.", + "Multiple filters use AND logic. Bare values match tags list.", ) audit_parser.add_argument( "--include", @@ -884,7 +895,8 @@ def create_parser() -> argparse.ArgumentParser: help="Exclude these control IDs (comma-separated)", ) audit_parser.add_argument( - "-o", "--output", + "-o", + "--output", choices=["text", "json"], default="text", help="Output format (default: text)", @@ -898,10 +910,11 @@ def create_parser() -> argparse.ArgumentParser: "--show-all", action="store_true", help="List every check in text output: show all passed checks (no truncation) " - "and include N/A checks. Useful for documenting full OSPS Baseline conformance.", + "and include N/A checks. Useful for documenting full OSPS Baseline conformance.", ) audit_parser.add_argument( - "--profile", "-p", + "--profile", + "-p", dest="profile", default=None, help="Audit profile name to filter controls (e.g., 'level1_quick' or 'openssf-baseline:level1_quick')", @@ -921,15 +934,17 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository", ) plan_parser.add_argument( - "-f", "--framework", + "-f", + "--framework", help="Framework to use", ) plan_parser.add_argument( - "-t", "--tags", + "-t", + "--tags", action="append", default=[], help="Filter controls by attributes (e.g., level=1, domain=VM, security). " - "Multiple filters use AND logic. Bare values match tags list.", + "Multiple filters use AND logic. Bare values match tags list.", ) plan_parser.add_argument( "--include", @@ -940,7 +955,8 @@ def create_parser() -> argparse.ArgumentParser: help="Exclude these control IDs (comma-separated)", ) plan_parser.add_argument( - "--profile", "-p", + "--profile", + "-p", dest="profile", default=None, help="Audit profile name to filter controls", @@ -977,7 +993,8 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository", ) init_parser.add_argument( - "-f", "--framework", + "-f", + "--framework", help="Framework to extend (default: auto-detect)", ) init_parser.add_argument( @@ -996,8 +1013,8 @@ def create_parser() -> argparse.ArgumentParser: "run", help="Run full agentic workflow (LLM-powered)", description="Run the full autonomous compliance pipeline. " - "Loads project context, runs all checks, collects context, " - "and remediates failures. Requires an LLM API key.", + "Loads project context, runs all checks, collects context, " + "and remediates failures. Requires an LLM API key.", ) run_parser.add_argument( "repo_path", @@ -1011,8 +1028,8 @@ def create_parser() -> argparse.ArgumentParser: choices=["interactive", "noninteractive", "auto"], default="auto", help="Human feedback mode: interactive (prompts in terminal), " - "noninteractive (collects questions for later), " - "auto (interactive if terminal, noninteractive in CI)", + "noninteractive (collects questions for later), " + "auto (interactive if terminal, noninteractive in CI)", ) run_parser.set_defaults(func=cmd_run) @@ -1023,9 +1040,10 @@ 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 +1058,8 @@ 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 90ab3430..776beb8b 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -16,11 +16,11 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - exit_code = main(["install"]) + exit_code = main(["install", "--client", "claude-code"]) 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,6 +29,11 @@ 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 @@ -39,6 +44,46 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): 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_settings_json(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + exit_code = main(["install", "--client", "claude-desktop", "--mcp-only"]) + + assert exit_code == 0 + + settings_path = tmp_path / ".claude" / "settings.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_cursor_creates_settings(tmp_path, monkeypatch): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) @@ -55,8 +100,7 @@ def test_install_cursor_creates_settings(tmp_path, monkeypatch): 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 +115,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 From 9813b69936579cc0b3b8be2e02522328b66074e6 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 23 Jul 2026 13:10:27 -0400 Subject: [PATCH 5/5] fix(cli): address PR review for Claude install paths (#270) --- docs/getting-started/using-skills.md | 2 +- packages/darnit/src/darnit/cli.py | 106 ++++++++++++++------------- tests/darnit/test_cli.py | 47 ++++++------ 3 files changed, 81 insertions(+), 74 deletions(-) diff --git a/docs/getting-started/using-skills.md b/docs/getting-started/using-skills.md index d04dd0cc..5e2e14ff 100644 --- a/docs/getting-started/using-skills.md +++ b/docs/getting-started/using-skills.md @@ -29,7 +29,7 @@ After installation, restart Claude Code. The skills appear as slash commands. |--------|-------|-----------------| | Claude Code (default) | Global | `~/.claude.json` | | Claude Code | Project (`--project`) | `.mcp.json` in repo root | -| Claude Desktop | Global | `~/.claude/settings.json` | +| 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`. diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index abfe9511..bde45ea3 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 @@ -112,7 +113,10 @@ def format_results_text(results: list[dict], framework_name: str, show_all: bool for r in passes: lines.append(format_result_text(r)) if not show_all and len(by_status["PASS"]) > 10: - lines.append(f" ... and {len(by_status['PASS']) - 10} more (use --show-all to list every check)") + lines.append( + f" ... and {len(by_status['PASS']) - 10} more " + "(use --show-all to list every check)" + ) # With --show-all, list every remaining status (e.g. N/A) so the output # documents every check for conformance evidence. @@ -163,7 +167,8 @@ def cmd_audit(args: argparse.Namespace) -> int: # Warn about limited functionality in terminal mode logger.warning( - "Running in terminal mode (no LLM consultation). For full capabilities, use 'darnit serve' with an MCP client." + "Running in terminal mode (no LLM consultation). " + "For full capabilities, use 'darnit serve' with an MCP client." ) repo_path = Path(args.repo_path).resolve() @@ -231,7 +236,9 @@ def cmd_audit(args: argparse.Namespace) -> int: if args.output == "json": sys.stdout.write(format_results_json(results, config.framework_name) + "\n") else: - sys.stdout.write(format_results_text(results, config.framework_name, show_all=args.show_all) + "\n") + sys.stdout.write( + format_results_text(results, config.framework_name, show_all=args.show_all) + "\n" + ) # Return non-zero if any failures failures = [r for r in results if r.get("status") == "FAIL"] @@ -389,7 +396,6 @@ def cmd_init(args: argparse.Namespace) -> int: framework = args.framework else: from darnit.core.discovery import discover_implementations - impls = discover_implementations() if len(impls) == 1: framework = next(iter(impls)) @@ -452,7 +458,6 @@ def cmd_list(args: argparse.Namespace) -> int: return 0 - def cmd_profiles(args: argparse.Namespace) -> int: """List available audit profiles defined by loaded implementations.""" from darnit.core.discovery import discover_implementations @@ -485,12 +490,13 @@ def cmd_profiles(args: argparse.Namespace) -> int: return 0 - def _find_skills_dir() -> Path | None: """Find the skills directory from the darnit package.""" skills_dir = Path(__file__).parent / "skills" if skills_dir.is_dir(): - has_skills = any((d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir()) + has_skills = any( + (d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir() + ) if has_skills: return skills_dir return None @@ -536,6 +542,11 @@ def cmd_install(args: argparse.Namespace) -> int: "`--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: @@ -543,7 +554,13 @@ def cmd_install(args: argparse.Namespace) -> int: else: settings_path = Path.home() / ".claude.json" elif args.client == "claude-desktop": - settings_path = Path.home() / ".claude" / "settings.json" + 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" @@ -604,7 +621,6 @@ def cmd_install(args: argparse.Namespace) -> int: logger.info("Skills available: /darnit-audit, /darnit-data, /darnit-comply, /darnit-remediate") return 0 - # Safety ceiling on audit<->collect_context rounds. Each iteration resolves ALL # pending questions in one batch (the answers comprehension in cmd_run), so this # bounds re-audit rounds, not the number of controls. @@ -710,7 +726,6 @@ def cmd_run(args: argparse.Namespace) -> int: return 1 if failed else 0 - def cmd_serve(args: argparse.Namespace) -> int: """Start the MCP server. @@ -777,6 +792,7 @@ def cmd_serve(args: argparse.Namespace) -> int: # Helpers + def _detect_default_branch(repo_path: Path) -> str: """Detect the default branch name.""" import subprocess @@ -811,8 +827,7 @@ def create_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "-V", - "--version", + "-V", "--version", action="version", # PyPI distribution name is `darnit-core` (the CLI command stays `darnit`). # Fall back to "dev" when running from a non-installed source checkout @@ -821,14 +836,12 @@ def create_parser() -> argparse.ArgumentParser: version=f"%(prog)s {_resolve_version()}", ) parser.add_argument( - "-v", - "--verbose", + "-v", "--verbose", action="store_true", help="Enable verbose output", ) parser.add_argument( - "-q", - "--quiet", + "-q", "--quiet", action="store_true", help="Suppress non-essential output", ) @@ -840,11 +853,11 @@ def create_parser() -> argparse.ArgumentParser: "serve", help="Start MCP server (recommended)", description="Start darnit as an MCP server. This is the recommended way to use darnit " - "as it enables full LLM consultation capabilities for intelligent analysis.\n\n" - "Usage:\n" - " darnit serve config.toml # Use TOML config file\n" - " darnit serve --framework NAME # Use named framework\n" - " darnit serve # Auto-detect framework", + "as it enables full LLM consultation capabilities for intelligent analysis.\n\n" + "Usage:\n" + " darnit serve config.toml # Use TOML config file\n" + " darnit serve --framework NAME # Use named framework\n" + " darnit serve # Auto-detect framework", formatter_class=argparse.RawDescriptionHelpFormatter, ) serve_parser.add_argument( @@ -853,8 +866,7 @@ def create_parser() -> argparse.ArgumentParser: help="Path to TOML config file (e.g., my-framework.toml)", ) serve_parser.add_argument( - "-f", - "--framework", + "-f", "--framework", help="Framework to use (default: auto-detect). Ignored if config file is provided.", ) serve_parser.set_defaults(func=cmd_serve) @@ -864,8 +876,8 @@ def create_parser() -> argparse.ArgumentParser: "audit", help="[Debug] Run audit without LLM", description="Run compliance audit in terminal mode. NOTE: This runs without LLM " - "consultation - checks requiring analysis will return WARN/inconclusive. " - "For full capabilities, use 'darnit serve' with an MCP client.", + "consultation - checks requiring analysis will return WARN/inconclusive. " + "For full capabilities, use 'darnit serve' with an MCP client.", ) audit_parser.add_argument( "repo_path", @@ -874,17 +886,15 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository (default: current directory)", ) audit_parser.add_argument( - "-f", - "--framework", + "-f", "--framework", help="Framework to use (name or path to .toml file)", ) audit_parser.add_argument( - "-t", - "--tags", + "-t", "--tags", action="append", default=[], help="Filter controls by attributes (e.g., level=1, domain=VM, security). " - "Multiple filters use AND logic. Bare values match tags list.", + "Multiple filters use AND logic. Bare values match tags list.", ) audit_parser.add_argument( "--include", @@ -895,8 +905,7 @@ def create_parser() -> argparse.ArgumentParser: help="Exclude these control IDs (comma-separated)", ) audit_parser.add_argument( - "-o", - "--output", + "-o", "--output", choices=["text", "json"], default="text", help="Output format (default: text)", @@ -910,11 +919,10 @@ def create_parser() -> argparse.ArgumentParser: "--show-all", action="store_true", help="List every check in text output: show all passed checks (no truncation) " - "and include N/A checks. Useful for documenting full OSPS Baseline conformance.", + "and include N/A checks. Useful for documenting full OSPS Baseline conformance.", ) audit_parser.add_argument( - "--profile", - "-p", + "--profile", "-p", dest="profile", default=None, help="Audit profile name to filter controls (e.g., 'level1_quick' or 'openssf-baseline:level1_quick')", @@ -934,17 +942,15 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository", ) plan_parser.add_argument( - "-f", - "--framework", + "-f", "--framework", help="Framework to use", ) plan_parser.add_argument( - "-t", - "--tags", + "-t", "--tags", action="append", default=[], help="Filter controls by attributes (e.g., level=1, domain=VM, security). " - "Multiple filters use AND logic. Bare values match tags list.", + "Multiple filters use AND logic. Bare values match tags list.", ) plan_parser.add_argument( "--include", @@ -955,8 +961,7 @@ def create_parser() -> argparse.ArgumentParser: help="Exclude these control IDs (comma-separated)", ) plan_parser.add_argument( - "--profile", - "-p", + "--profile", "-p", dest="profile", default=None, help="Audit profile name to filter controls", @@ -993,8 +998,7 @@ def create_parser() -> argparse.ArgumentParser: help="Path to repository", ) init_parser.add_argument( - "-f", - "--framework", + "-f", "--framework", help="Framework to extend (default: auto-detect)", ) init_parser.add_argument( @@ -1013,8 +1017,8 @@ def create_parser() -> argparse.ArgumentParser: "run", help="Run full agentic workflow (LLM-powered)", description="Run the full autonomous compliance pipeline. " - "Loads project context, runs all checks, collects context, " - "and remediates failures. Requires an LLM API key.", + "Loads project context, runs all checks, collects context, " + "and remediates failures. Requires an LLM API key.", ) run_parser.add_argument( "repo_path", @@ -1028,8 +1032,8 @@ def create_parser() -> argparse.ArgumentParser: choices=["interactive", "noninteractive", "auto"], default="auto", help="Human feedback mode: interactive (prompts in terminal), " - "noninteractive (collects questions for later), " - "auto (interactive if terminal, noninteractive in CI)", + "noninteractive (collects questions for later), " + "auto (interactive if terminal, noninteractive in CI)", ) run_parser.set_defaults(func=cmd_run) @@ -1042,8 +1046,7 @@ def create_parser() -> argparse.ArgumentParser: "--client", 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.", + help="Client to configure (default: claude-code). 'claude' is deprecated — use claude-code or claude-desktop." , ) install_parser.add_argument( "--force", @@ -1058,8 +1061,7 @@ def create_parser() -> argparse.ArgumentParser: install_parser.add_argument( "--project", action="store_true", - help="Install skills into .claude/skills/ and MCP config into .mcp.json " - "(per-project) instead of global paths", + 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 776beb8b..4b759a8e 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 @@ -16,7 +17,7 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - exit_code = main(["install", "--client", "claude-code"]) + exit_code = main(["install"]) assert exit_code == 0 @@ -30,8 +31,7 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): 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 + 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 @@ -39,10 +39,9 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): # 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): @@ -52,20 +51,19 @@ def test_install_claude_alias_emits_deprecation_warning(tmp_path, monkeypatch, c 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 - ) + assert any(record.levelname == "WARNING" and "deprecated" in record.message.lower() for record in caplog.records) -def test_install_claude_desktop_uses_settings_json(tmp_path, monkeypatch): +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"]) + exit_code = main(["install", "--client", "claude-desktop", "--mcp-only", "--force"]) assert exit_code == 0 - settings_path = tmp_path / ".claude" / "settings.json" + 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"] @@ -84,6 +82,17 @@ def test_install_claude_code_project_writes_mcp_json(tmp_path, monkeypatch): 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) @@ -97,6 +106,7 @@ 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) @@ -156,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" @@ -169,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" @@ -192,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."""