diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b72a83..d21c5882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,11 @@ jobs: uv run python -c "from darnit.core.discovery import discover_implementations; print(discover_implementations())" uv run python -c "from darnit_baseline import register; print(register())" + - name: Lint and smoke-test the quick-start script + run: | + shellcheck scripts/quickstart.sh + scripts/quickstart.sh --help + type-check: name: Type Check runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e28cdf2..aae0b8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `scripts/quickstart.sh`: one-command setup from source. It installs uv and + zizmor if they are missing (asking first), installs darnit with the + versions pinned in `uv.lock`, runs an OpenSSF Baseline level 1 audit, and + offers to register the MCP server with Claude Code. +- `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. - 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/README.md b/README.md index c2a4da72..5f2994f9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ Once [#229](https://github.com/kusari-oss/darnit/issues/229) lands, `pipx instal and `uv tool install darnit-mcp` will become the recommended end-user paths, with container, Homebrew, standalone-binary, and Claude Code plugin channels documented in [`docs/install/README.md`](docs/install/README.md). + +To set up from source with one command (it offers to install uv, sets darnit up, and audits a repository), see [One-command setup](docs/install/from-source.md#one-command-setup). + ### Optional: Opengrep for taint analysis The threat model generator can use [Opengrep](https://github.com/opengrep/opengrep) 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..97e44525 100644 --- a/docs/install/from-source.md +++ b/docs/install/from-source.md @@ -4,6 +4,26 @@ For users who want to **try an unreleased feature**, contribute to darnit, or ru This path is necessary today because darnit's packages aren't yet on PyPI. After the first stable release (`v0.1.0`), the published channels become the easier path; from-source becomes a contributor-or-feature-preview workflow. +## One-command setup + +`scripts/quickstart.sh` does the steps on this page for you. It checks for git, offers to install uv and zizmor if they are missing, installs darnit with the dependency versions pinned in `uv.lock`, runs an OpenSSF Baseline level 1 audit on a repository, and offers to register the MCP server with Claude Code. It changes nothing in the audited repository. + +From a checkout: + +```bash +./scripts/quickstart.sh /path/to/your/repo +``` + +Without a checkout (the script clones darnit into `~/.local/share/darnit`): + +```bash +curl -fsSL https://raw.githubusercontent.com/darnitdevorg/darnit/main/scripts/quickstart.sh -o quickstart.sh +less quickstart.sh # read it before you run it +bash quickstart.sh /path/to/your/repo +``` + +Options: `--yes` answers yes to every prompt, `--all-levels` audits all Baseline levels, `--no-mcp` skips the Claude Code registration, and `--dir DIR` sets where darnit is cloned. `--help` lists them. + ## Quick install ```bash @@ -54,14 +74,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 20fd9668..197186e3 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,22 @@ 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 + # 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", + "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() @@ -1745,6 +1774,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/scripts/quickstart.sh b/scripts/quickstart.sh new file mode 100755 index 00000000..ef94c64b --- /dev/null +++ b/scripts/quickstart.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# +# quickstart.sh - Install darnit from source and run a first audit +# +# Usage: +# ./scripts/quickstart.sh [options] [path/to/repo] +# +# Requirements: git, and curl or wget if uv has to be installed. +# Nothing in the audited repository is modified. + +set -euo pipefail + +REPO_URL="https://github.com/darnitdevorg/darnit.git" +# The uv release installed when uv is missing. The script was tested with it. +UV_VERSION="0.13.0" +UV_INSTALLER="https://astral.sh/uv/${UV_VERSION}/install.sh" +ORIGINAL_PATH="$PATH" +DARNIT_HOME="${DARNIT_HOME:-$HOME/.local/share/darnit}" +ASSUME_YES=0 +ALL_LEVELS=0 +WANT_MCP=1 +TARGET="" + +if [ -t 1 ]; then + RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; NC=$'\033[0m' +else + RED=""; GREEN=""; YELLOW=""; NC="" +fi + +info() { printf '%s==>%s %s\n' "$GREEN" "$NC" "$*"; } +warn() { printf '%swarning:%s %s\n' "$YELLOW" "$NC" "$*" >&2; } +die() { printf '%serror:%s %s\n' "$RED" "$NC" "$*" >&2; exit 1; } + +usage() { + cat <<'USAGE' +Install darnit from source and run a first OpenSSF Baseline audit. + +Usage: quickstart.sh [options] [path/to/repo] + + 1. Checks for git; offers to install uv and zizmor if they are missing + 2. Uses the darnit checkout this script lives in, or clones one + 3. Installs darnit into the checkout's virtual environment, using the + pinned versions in uv.lock, and links it into ~/.local/bin + 4. Audits the given repository (default: the current directory, if it + is a git repository) against OpenSSF Baseline level 1 + 5. Offers to register the darnit MCP server with Claude Code + +Options: + -y, --yes Answer yes to every prompt + --all-levels Audit all Baseline levels, not just level 1 + --no-mcp Do not offer to register the MCP server + --dir DIR Where to clone darnit (default: ~/.local/share/darnit, + or $DARNIT_HOME) + -h, --help Show this help +USAGE +} + +confirm() { + # confirm QUESTION [DEFAULT]; DEFAULT is y or n and is what Enter means. + local reply="" default="${2:-y}" hint="[Y/n]" + if [ "$default" = "n" ]; then + hint="[y/N]" + fi + if [ "$ASSUME_YES" -eq 1 ]; then + return 0 + fi + if [ -t 0 ]; then + read -r -p "$1 $hint " reply || true + elif { : /dev/null; then + read -r -p "$1 $hint " reply /dev/null 2>&1; then + die "git is required. Install it with your package manager and re-run." + fi + if ! command -v uv >/dev/null 2>&1; then + export PATH="$HOME/.local/bin:$PATH" + fi + if ! command -v uv >/dev/null 2>&1; then + info "uv is not installed. darnit uses it to manage Python and dependencies." + if ! confirm "Install uv $UV_VERSION from astral.sh into ~/.local/bin? Your shell profile is not changed." n; then + die "uv is required. See https://docs.astral.sh/uv/getting-started/installation/" + fi + if command -v curl >/dev/null 2>&1; then + curl -LsSf "$UV_INSTALLER" | env UV_NO_MODIFY_PATH=1 sh + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$UV_INSTALLER" | env UV_NO_MODIFY_PATH=1 sh + else + die "curl or wget is needed to install uv." + fi + export PATH="$HOME/.local/bin:$PATH" + if ! command -v uv >/dev/null 2>&1; then + die "uv was installed but is not on PATH. Open a new shell and re-run." + fi + fi + + # 2. Locate or clone the darnit source + local src="" self="${BASH_SOURCE[0]:-}" cand="" + if [ -n "$self" ] && [ -f "$self" ]; then + cand="$(cd "$(dirname "$self")/.." && pwd)" + if [ -d "$cand/packages/darnit" ]; then + src="$cand" + fi + fi + if [ -z "$src" ]; then + if [ -d "$DARNIT_HOME/packages/darnit" ]; then + info "Using the existing darnit checkout at $DARNIT_HOME" + else + info "Cloning darnit into $DARNIT_HOME" + git clone --depth 1 "$REPO_URL" "$DARNIT_HOME" + fi + src="$DARNIT_HOME" + fi + + # 3. Install from the lockfile, so users get the tested dependency versions + info "Installing darnit from $src (the first run can take a minute)" + # --inexact: leave packages from other extras or groups in place, so a + # contributor's checkout synced with --all-extras is not stripped. + uv sync --frozen --inexact --quiet --project "$src" + + local darnit="$src/.venv/bin/darnit" + if [ ! -x "$darnit" ]; then + die "the darnit executable was not found at $darnit" + fi + info "Installed $("$darnit" --version 2>&1 | tail -1)" + + # Put darnit on PATH, without replacing anything that is not this link + local bindir="${XDG_BIN_HOME:-$HOME/.local/bin}" link + link="$bindir/darnit" + if [ ! -e "$link" ] && [ ! -L "$link" ]; then + mkdir -p "$bindir" + ln -s "$darnit" "$link" + fi + if [ "$(readlink "$link" 2>/dev/null || true)" != "$darnit" ]; then + warn "$link already exists and was left alone. Run $darnit directly, or remove it and re-run." + else + case ":$ORIGINAL_PATH:" in + *":$bindir:"*) ;; + *) warn "$bindir is not on your PATH. Add it (export PATH=\"$bindir:\$PATH\"), or run $darnit directly." ;; + esac + fi + + # Optional tools that some checks shell out to + if ! command -v zizmor >/dev/null 2>&1; then + if confirm "Install zizmor (used by the GitHub Actions workflow checks) with uv?" n; then + uv tool install --quiet zizmor || warn "zizmor could not be installed." + fi + fi + if ! command -v zizmor >/dev/null 2>&1; then + warn "zizmor not found. The checks that use it will show as ERROR." + fi + if ! command -v gh >/dev/null 2>&1; then + warn "GitHub CLI (gh) not found. Checks that query GitHub will show as ERROR. Install it from https://cli.github.com/ and run 'gh auth login'." + elif ! gh auth token >/dev/null 2>&1; then + warn "GitHub CLI (gh) is not logged in. Checks that query GitHub will show as ERROR. Run 'gh auth login'." + fi + + # 4. First audit + if [ -z "$TARGET" ] && [ -e "$PWD/.git" ] && [ "$(pwd -P)" != "$(cd "$src" && pwd -P)" ]; then + TARGET="$PWD" + fi + if [ -n "$TARGET" ]; then + if [ ! -d "$TARGET" ]; then die "not a directory: $TARGET"; fi + TARGET="$(cd "$TARGET" && pwd)" + local audit_args=(-q audit --no-fail --framework openssf-baseline) + if [ "$ALL_LEVELS" -eq 0 ]; then + audit_args+=(--tags level=1) + fi + if ! git -C "$TARGET" remote get-url origin >/dev/null 2>&1; then + warn "$TARGET has no 'origin' remote. Checks that query GitHub will show as ERROR until it is pushed." + fi + info "Auditing $TARGET" + "$darnit" "${audit_args[@]}" "$TARGET" + else + info "No repository given, so no audit was run." + fi + + # 5. MCP registration (enables remediation through Claude Code) + if [ "$WANT_MCP" -eq 1 ]; then + if command -v claude >/dev/null 2>&1; then + if confirm "Register darnit in Claude Code? This replaces any darnit entry in ~/.claude.json and reinstalls the darnit skills in ~/.claude/skills/." n; then + if ! "$darnit" install --client claude-code --from-source --force; then + warn "registration failed. Retry with: darnit install --client claude-code --from-source" + fi + fi + else + info "Claude Code was not found, so the MCP server was not registered." + info "After installing it, run: darnit install --client claude-code --from-source" + fi + fi + + printf '\n' + info "Done. Next steps:" + printf ' Audit a repo: darnit audit --framework openssf-baseline --tags level=1 /path/to/repo\n' + printf ' Fix findings: cd /path/to/repo && claude, then ask:\n' + printf ' "Audit this repo against the OpenSSF Baseline level 1, then show me\n' + printf ' the remediation plan as a dry run. Do not apply anything."\n' +} + +main "$@" diff --git a/tests/darnit/test_cli.py b/tests/darnit/test_cli.py index 13472815..c507ffb1 100644 --- a/tests/darnit/test_cli.py +++ b/tests/darnit/test_cli.py @@ -135,6 +135,46 @@ def test_install_preserves_existing_settings(tmp_path, monkeypatch): assert "darnit" in data["mcpServers"] +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) + + 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."""