From b069498ead8e72916047c8d7c4a65f82fe37eb1b Mon Sep 17 00:00:00 2001 From: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:04:01 -0400 Subject: [PATCH 1/4] feat(cli): add darnit install --from-source darnit install always registers 'uvx --from darnit-mcp darnit serve' as the MCP server command. From a source checkout that runs the PyPI package, not the code that was installed. --from-source registers the darnit executable of the running installation instead: the console script next to the running interpreter, falling back to darnit on PATH. The default entry is unchanged. Signed-off-by: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Assisted-by: Claude:claude-fable-5-1 --- CHANGELOG.md | 4 +++ docs/getting-started/using-skills.md | 3 +++ docs/install/from-source.md | 8 +++--- packages/darnit/src/darnit/cli.py | 38 +++++++++++++++++++++++--- tests/darnit/test_cli.py | 40 ++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 749c1189..3e86839d 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, + `darnit install` writes `uvx --from darnit-mcp darnit serve`, which runs + the PyPI package 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`, 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..697c324e 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 is +# `uvx --from darnit-mcp darnit serve`, which runs the PyPI package instead. +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`). +For a project-local MCP config, run `darnit install --project --from-source` 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 d056c9d4..994c4c25 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 @@ -690,10 +707,17 @@ 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 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 + darnit_entry = {"command": str(darnit_exe), "args": ["serve"]} + else: + 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 +1792,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..46bd1150 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -183,6 +183,46 @@ 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"]} + + +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"]} + + +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) + + exit_code = main(["install", "--mcp-only", "--from-source"]) + + assert exit_code == 1 + assert not (tmp_path / ".claude.json").exists() + assert any("--from-source" in record.message for record in caplog.records) + + class TestCreateParser: """Tests for CLI argument parsing.""" From 83110ce29b7e8e2907e9602aeeddaa57f8adc08d Mon Sep 17 00:00:00 2001 From: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:45:01 -0400 Subject: [PATCH 2/4] fix(cli): name the framework in the --from-source entry A source checkout installs every workspace plugin, and darnit serve without --framework takes the first one it finds (community-spec). Claude Code then connected to a server that had no OpenSSF Baseline tools. Register 'serve --framework openssf-baseline', the same arguments as the manual command in docs/install/from-source.md. Found by running the registered server from Claude Code. Signed-off-by: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Assisted-by: Claude:claude-fable-5-1 --- CHANGELOG.md | 3 ++- packages/darnit/src/darnit/cli.py | 7 ++++++- tests/darnit/test_cli.py | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e86839d..ca8b7d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,7 +144,8 @@ 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, + running installation as the MCP server command, with + `--framework openssf-baseline`. Without it, `darnit install` writes `uvx --from darnit-mcp darnit serve`, which runs the PyPI package even when darnit was installed from a source checkout. - Operator configuration: a user-level TOML file found the same way by the diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index 994c4c25..88d0bdec 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -712,7 +712,12 @@ def cmd_install(args: argparse.Namespace) -> int: if darnit_exe is None: logger.error("--from-source: the darnit executable of this installation was not found.") return 1 - darnit_entry = {"command": str(darnit_exe), "args": ["serve"]} + # Name the framework: a source checkout installs every workspace plugin, + # and `darnit serve` without --framework takes the first one it finds. + darnit_entry = { + "command": str(darnit_exe), + "args": ["serve", "--framework", "openssf-baseline"], + } else: darnit_entry = { "command": "uvx", diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index 46bd1150..072d172e 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -195,7 +195,7 @@ def test_install_from_source_registers_local_executable(tmp_path, monkeypatch): 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"]} + 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): @@ -208,7 +208,7 @@ def test_install_from_source_falls_back_to_path(tmp_path, monkeypatch): assert exit_code == 0 entry = json.loads((tmp_path / ".claude.json").read_text())["mcpServers"]["darnit"] - assert entry == {"command": str(on_path), "args": ["serve"]} + 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): From 5319be7bca294ada087baea18335bae89abf844d Mon Sep 17 00:00:00 2001 From: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:08:51 -0400 Subject: [PATCH 3/4] fix(cli): name the framework in the uvx entry too Review on #577: the uvx entry runs the darnit-mcp release on PyPI, and releases before 0.2.0 serve the first installed framework by name. Pass --framework openssf-baseline there as well, so clients set up the documented way get the Baseline tools whatever version uvx resolves. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Marc-cn <130138935+Marc-cn@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ packages/darnit/src/darnit/cli.py | 7 ++++--- tests/darnit/test_cli.py | 9 ++++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8b7d7b..759c25d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `darnit install` names the framework in the MCP entry it writes + (`darnit serve --framework openssf-baseline`), for both the `uvx` entry + and `--from-source`. 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. - `darnit install --from-source` registers the darnit executable of the running installation as the MCP server command, with `--framework openssf-baseline`. Without it, diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index 88d0bdec..86fd9e70 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -712,8 +712,9 @@ def cmd_install(args: argparse.Namespace) -> int: if darnit_exe is None: logger.error("--from-source: the darnit executable of this installation was not found.") return 1 - # Name the framework: a source checkout installs every workspace plugin, - # and `darnit serve` without --framework takes the first one it finds. + # 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"], @@ -721,7 +722,7 @@ def cmd_install(args: argparse.Namespace) -> int: else: darnit_entry = { "command": "uvx", - "args": ["--from", "darnit-mcp", "darnit", "serve"], + "args": ["--from", "darnit-mcp", "darnit", "serve", "--framework", "openssf-baseline"], } if "darnit" in mcp_servers and not args.force: diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index 072d172e..d924516e 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 From 4e3c15a2010f568ef3cb73fb07ff799b59c5bb69 Mon Sep 17 00:00:00 2001 From: Marc-cn <130138935+Marc-cn@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:57:59 -0400 Subject: [PATCH 4/4] fix(cli): resolve the --from-source command before writing anything Review on #571. - The executable lookup runs before the settings path is prepared, so a failed lookup creates no client directory and no .bak of an existing settings file. The registered command is logged. - from-source.md no longer recommends --project with --from-source: it writes the absolute path of the checkout venv into .mcp.json. - CHANGELOG: the uvx --framework change is under Fixed; the comment and the entry no longer quote the old uvx line. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Marc-cn <130138935+Marc-cn@users.noreply.github.com> --- CHANGELOG.md | 18 +++++++-------- docs/install/from-source.md | 6 ++--- packages/darnit/src/darnit/cli.py | 38 +++++++++++++++++-------------- tests/darnit/test_cli.py | 6 ++++- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759c25d0..a5dd7168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,16 +143,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `darnit install` names the framework in the MCP entry it writes - (`darnit serve --framework openssf-baseline`), for both the `uvx` entry - and `--from-source`. 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. - `darnit install --from-source` registers the darnit executable of the - running installation as the MCP server command, with - `--framework openssf-baseline`. Without it, - `darnit install` writes `uvx --from darnit-mcp darnit serve`, which runs - the PyPI package even when darnit was installed from a source checkout. + 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`, @@ -582,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/install/from-source.md b/docs/install/from-source.md index 697c324e..2422fba9 100644 --- a/docs/install/from-source.md +++ b/docs/install/from-source.md @@ -55,15 +55,15 @@ Or configure it as an MCP server in Claude Code: ```bash # Recommended: use darnit's install command (writes ~/.claude.json). -# --from-source registers this checkout's darnit. Without it the entry is -# `uvx --from darnit-mcp darnit serve`, which runs the PyPI package instead. +# --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-source` 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 86fd9e70..6541d428 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -670,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" @@ -707,23 +728,6 @@ def cmd_install(args: argparse.Namespace) -> int: shutil.copy2(settings_path, backup_path) mcp_servers = config.setdefault("mcpServers", {}) - 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"], - } if "darnit" in mcp_servers and not args.force: response = input(f"'darnit' entry already exists in {settings_path}. Overwrite? [y/N]: ").strip().lower() diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index d924516e..1701938d 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -222,11 +222,15 @@ def test_install_from_source_fails_when_executable_is_missing(tmp_path, monkeypa 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 not (tmp_path / ".claude.json").exists() + 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)