diff --git a/CHANGELOG.md b/CHANGELOG.md index 749c1189..a5dd7168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `darnit install --from-source` registers the darnit executable of the + running installation as the MCP server command. Without it, the entry + runs the darnit-mcp release on PyPI through `uvx`, even when darnit was + installed from a source checkout. - Operator configuration: a user-level TOML file found the same way by the CLI, MCP server, and harness (`--operator-config PATH`, else `$XDG_CONFIG_HOME/darnit/config.toml` or `~/.config/darnit/config.toml`, @@ -572,6 +576,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 error now gives the real cause. Both guards said "not installed" for any import error, which was wrong for an installed package with a missing dependency or a broken release (#573). +- `darnit install` names the framework in the MCP entry it writes + (`serve --framework openssf-baseline`). The `uvx` entry runs the + darnit-mcp release on PyPI, and releases before 0.2.0 serve the first + installed framework by name, which on a full install is gittuf. The + command is also logged, so the PATH fallback of `--from-source` is + visible. - `darnit serve` with no `--framework` now serves OpenSSF Baseline when it is installed, the same default as `darnit audit`. It used to take the first installed framework by name, which on a full `darnit-mcp` install diff --git a/docs/getting-started/using-skills.md b/docs/getting-started/using-skills.md index 1e61a9fd..8d894df7 100644 --- a/docs/getting-started/using-skills.md +++ b/docs/getting-started/using-skills.md @@ -19,6 +19,9 @@ darnit install --mcp-only # Claude Desktop (GUI app) — different config file than Claude Code darnit install --client claude-desktop + +# From a source checkout: register this installation, not the PyPI package +darnit install --from-source ``` After installation, restart Claude Code. The skills appear as slash commands. diff --git a/docs/install/from-source.md b/docs/install/from-source.md index fd7bd01f..2422fba9 100644 --- a/docs/install/from-source.md +++ b/docs/install/from-source.md @@ -54,14 +54,16 @@ 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 +# Recommended: use darnit's install command (writes ~/.claude.json). +# --from-source registers this checkout's darnit. Without it the entry runs +# the darnit-mcp release on PyPI through uvx, not this checkout. +darnit install --client claude-code --from-source # Or register manually with the Claude Code CLI claude mcp add --scope user darnit -- "$(which darnit)" serve --framework openssf-baseline ``` -For a project-local MCP config, run `darnit install --project` from the repo root (writes `.mcp.json`). +`--project` writes the absolute path of this checkout's venv into the repository's `.mcp.json`, so it is not recommended with `--from-source`; see `docs/SECURITY_GUIDE.md` on project-scoped registration. ## Switching branches diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index d056c9d4..6541d428 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -638,6 +638,23 @@ def _install_skills(target_dir: Path, force: bool = False) -> int: return installed +def _local_darnit_executable() -> Path | None: + """Return the darnit console script of the running installation, if any. + + Looks next to the running interpreter first, so the answer is the same + whether darnit was started from a virtual environment, through a symlink, + or by ``uv run``. Falls back to ``darnit`` on PATH. + """ + import shutil + + name = "darnit.exe" if sys.platform == "win32" else "darnit" + candidate = Path(sys.executable).parent / name + if candidate.is_file(): + return candidate + found = shutil.which("darnit") + return Path(found) if found else None + + def cmd_install(args: argparse.Namespace) -> int: """Install darnit MCP server config and skills into a supported client.""" import shutil @@ -653,6 +670,27 @@ def cmd_install(args: argparse.Namespace) -> int: f"Ignoring --project for --client {args.client}." ) + # Resolve the command first, so a failed lookup writes nothing: no + # client directory, no backup of an existing settings file. + if getattr(args, "from_source", False): + darnit_exe = _local_darnit_executable() + if darnit_exe is None: + logger.error("--from-source: the darnit executable of this installation was not found.") + return 1 + # Name the framework. Both entries do: `darnit serve` without + # --framework took the first installed framework by name until #577, + # and the uvx entry runs whatever darnit-mcp release is on PyPI. + darnit_entry = { + "command": str(darnit_exe), + "args": ["serve", "--framework", "openssf-baseline"], + } + else: + darnit_entry = { + "command": "uvx", + "args": ["--from", "darnit-mcp", "darnit", "serve", "--framework", "openssf-baseline"], + } + logger.info("Registering MCP server command: %s %s", darnit_entry["command"], " ".join(darnit_entry["args"])) + if args.client in ("claude", "claude-code"): if args.project: settings_path = Path.cwd() / ".mcp.json" @@ -690,10 +728,6 @@ def cmd_install(args: argparse.Namespace) -> int: shutil.copy2(settings_path, backup_path) mcp_servers = config.setdefault("mcpServers", {}) - darnit_entry = { - "command": "uvx", - "args": ["--from", "darnit-mcp", "darnit", "serve"], - } if "darnit" in mcp_servers and not args.force: response = input(f"'darnit' entry already exists in {settings_path}. Overwrite? [y/N]: ").strip().lower() @@ -1768,6 +1802,12 @@ def create_parser() -> argparse.ArgumentParser: help="Install skills into .claude/skills/ and MCP config into .mcp.json (per-project) instead of " "user-scope paths. Not recommended: the repository then controls how darnit is launched.", ) + install_parser.add_argument( + "--from-source", + action="store_true", + help="Register the darnit executable of this installation (a source checkout or any other " + "non-PyPI install) instead of `uvx --from darnit-mcp`.", + ) install_parser.set_defaults(func=cmd_install) return parser diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index 8c6f5a47..1701938d 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -28,7 +28,14 @@ def test_install_claude_creates_settings(tmp_path, monkeypatch, caplog): assert "mcpServers" in data assert "darnit" in data["mcpServers"] assert data["mcpServers"]["darnit"]["command"] == "uvx" - assert data["mcpServers"]["darnit"]["args"] == ["--from", "darnit-mcp", "darnit", "serve"] + assert data["mcpServers"]["darnit"]["args"] == [ + "--from", + "darnit-mcp", + "darnit", + "serve", + "--framework", + "openssf-baseline", + ] assert not any( record.levelname == "WARNING" and "deprecated" in record.message.lower() for record in caplog.records @@ -183,6 +190,50 @@ def test_serve_refuses_to_guess_between_other_frameworks(monkeypatch, caplog): assert any("--framework" in record.message for record in caplog.records) +def test_install_from_source_registers_local_executable(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + bin_dir = tmp_path / "venv" / "bin" + bin_dir.mkdir(parents=True) + exe_name = "darnit.exe" if sys.platform == "win32" else "darnit" + (bin_dir / exe_name).write_text("") + monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) + + exit_code = main(["install", "--mcp-only", "--from-source"]) + + assert exit_code == 0 + entry = json.loads((tmp_path / ".claude.json").read_text())["mcpServers"]["darnit"] + assert entry == {"command": str(bin_dir / exe_name), "args": ["serve", "--framework", "openssf-baseline"]} + + +def test_install_from_source_falls_back_to_path(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + on_path = tmp_path / "onpath" / "darnit" + monkeypatch.setattr(sys, "executable", str(tmp_path / "nowhere" / "python")) + monkeypatch.setattr("shutil.which", lambda name: str(on_path) if name == "darnit" else None) + + exit_code = main(["install", "--mcp-only", "--from-source"]) + + assert exit_code == 0 + entry = json.loads((tmp_path / ".claude.json").read_text())["mcpServers"]["darnit"] + assert entry == {"command": str(on_path), "args": ["serve", "--framework", "openssf-baseline"]} + + +def test_install_from_source_fails_when_executable_is_missing(tmp_path, monkeypatch, caplog): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.setattr(sys, "executable", str(tmp_path / "nowhere" / "python")) + monkeypatch.setattr("shutil.which", lambda name: None) + settings = tmp_path / ".claude.json" + settings.write_text('{"mcpServers": {}}') + + exit_code = main(["install", "--mcp-only", "--from-source"]) + + assert exit_code == 1 + assert settings.read_text() == '{"mcpServers": {}}' + assert not settings.with_suffix(".json.bak").exists(), "a failed lookup must not write a backup" + assert not (tmp_path / ".cursor").exists() + assert any("--from-source" in record.message for record in caplog.records) + + class TestCreateParser: """Tests for CLI argument parsing."""