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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started/using-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions docs/install/from-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 44 additions & 4 deletions packages/darnit/src/darnit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion tests/darnit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
Loading