#!/usr/bin/env sh # # GitGuardian agent installer — https://agents.gitguardian.com # # curl -fsSL agents.gitguardian.com | sh # # Installs GitGuardian skills and the bundled Developer MCP server into your # AI coding agent(s), sourced from https://github.com/GitGuardian/agent-skills. # The MCP server is installed by the Claude Code and Codex plugin manifests. # The Cursor (skills.sh) path installs skills only — no MCP server. # # --------------------------------------------------------------------------- # BETA # # This distribution is pre-1.0. The skills work today, but they may change # shape between releases: no stability guarantee yet on skill names, slash # commands, or file layout. # --------------------------------------------------------------------------- # # What gets installed, and what runs # # Installed Declarative files only — skill markdown plus plugin and # MCP JSON manifests. No compiled binary, no background # daemon, no edits to your shell profile. # # Run by this Your own agent CLI ('claude plugin ...', 'codex plugin # script ...') and, for the Cursor + 50-agent path, the # third-party skills.sh installer ('npx skills add'). # Nothing else is executed. # # Run later, ggshield, GitGuardian's open-source CLI. This script does # when you ask not install it; the skills walk you through installing # and authenticating it on first use. # # Leaves your 'ggshield secret scan' sends the scanned content to the # machine GitGuardian API for detection. 'ggshield hmsl check' # sends only short hash prefixes, never plaintext. The # hosted Developer MCP server authenticates over browser # OAuth; this script stores no token. # # Source github.com/GitGuardian/agent-skills — MIT, CI-validated. # # The trust chain, end to end # # 1. This script is served from agents.gitguardian.com over HTTPS. It is # plain POSIX sh, and you are reading all of it — review before piping: # # curl -fsSL agents.gitguardian.com | less # # 2. It installs from the rolling 'stable' tag, which the release workflow # moves to each newly published release. It never installs from 'main'. # Pin an immutable release tag yourself when reproducibility matters: # # curl -fsSL agents.gitguardian.com | sh -s -- --version v0.5.1 # # 3. Each agent then fetches the selected git tag — 'owner/repo@tag' for # Claude Code and Codex, 'owner/repo#tag' for skills.sh (whose parser # treats '@' as a skill-name selector) — so what lands on disk is always # a released ref. 'stable' is mutable; versioned tags are immutable. # Both are visible at github.com/GitGuardian/agent-skills/releases # # 4. What lands is declarative: markdown skills plus plugin and MCP JSON. # Nothing compiled, nothing that runs on its own. set -euf # ---- constants ---- MARKETPLACE_REPO="GitGuardian/agent-skills" MARKETPLACE_NAME="gitguardian-agent-skills" PLUGIN="gitguardian" SKILLS_ID="gitguardian/agent-skills" MCP_URL="https://mcp.gitguardian.com/mcp" MCP_URL_EU="https://mcp.eu1.gitguardian.com/mcp" # skills.sh is itself fetched from npm and executes arbitrary code, so the # installer pins it to an exact published version rather than npx@latest. SKILLS_NPM_PACKAGE="${GG_SKILLS_NPM_PACKAGE:-skills@1.5.23}" RELEASES_PAGE="https://github.com/GitGuardian/agent-skills/releases" # ---- options (set by parse_args) ---- AGENT_FLAG="" EU=0 # reserved for future confirmation prompts ASSUME_YES=0 # Selected ref: --version flag, else GG_SKILLS_VERSION, else rolling stable. VERSION="${GG_SKILLS_VERSION:-stable}" # ---- selection result ---- CHOSEN="" # ---- ui helpers ---- info() { printf '%s\n' "> $*"; } warn() { printf '%s\n' "! $*" >&2; } error() { printf '%s\n' "x $*" >&2; } has() { command -v "$1" >/dev/null 2>&1; } # run: execute a command, or log it to RUN_LOG when set (test seam). run() { if [ -n "${RUN_LOG:-}" ]; then printf '%s\n' "$*" >> "$RUN_LOG" else "$@" fi } print_beta_notice() { printf '\nGitGuardian agent skills (beta)\n' printf 'Pre-1.0: skills may change shape between releases; no stability\n' printf 'guarantee yet on skill names, slash commands, or file layout.\n' } usage() { cat <<'EOF' GitGuardian agent installer (beta) Beta: pre-1.0 distribution. Skills may change shape between releases; no stability guarantee yet on skill names, slash commands, or file layout. Usage: curl -fsSL agents.gitguardian.com | sh curl -fsSL agents.gitguardian.com | sh -s -- [options] Options: --agent Install non-interactively to these agent(s). Comma/space separated: claude, codex, cursor, all --version Install a specific release tag instead of the rolling 'stable' tag. Also settable as GG_SKILLS_VERSION. Pass 'main' to track the branch instead of a release (unpinned — not recommended). --eu Show EU MCP endpoint guidance (mcp.eu1.gitguardian.com) -y, --yes Reserved; no effect in v1 -h, --help Show this help EOF } parse_args() { while [ $# -gt 0 ]; do # shellcheck disable=SC2034 case "$1" in --agent) AGENT_FLAG=${2:-} case "$AGENT_FLAG" in ''|-*) error "Missing or invalid value for --agent"; usage; exit 1 ;; esac if [ $# -ge 2 ]; then shift 2; else shift; fi ;; --agent=*) AGENT_FLAG=${1#*=} case "$AGENT_FLAG" in ''|-*) error "Missing or invalid value for --agent"; usage; exit 1 ;; esac shift ;; --version) VERSION=${2:-} case "$VERSION" in ''|-*) error "Missing or invalid value for --version"; usage; exit 1 ;; esac if [ $# -ge 2 ]; then shift 2; else shift; fi ;; --version=*) VERSION=${1#*=} case "$VERSION" in ''|-*) error "Missing or invalid value for --version"; usage; exit 1 ;; esac shift ;; --eu) EU=1; shift ;; -y|--yes) ASSUME_YES=1; shift ;; -h|--help) usage; exit 0 ;; *) error "Unknown option: $1"; usage; exit 1 ;; esac done } # ---- version selection ---- # A tag goes straight into an argv element ('owner/repo@TAG'), never into eval, # so this guards against typos and stray flags rather than shell injection. validate_version() { case "$1" in ''|-*|*..*|/*|*/|.) return 1 ;; *[!A-Za-z0-9._/-]*) return 1 ;; esac return 0 } # True when the value is a full 40-hex git commit SHA. Claude Code and Codex # accept a SHA as an immutable ref, but skills.sh clones with `--branch` and # resolves only branch/tag names, so the Cursor path must reject it. is_commit_sha() { [ "${#1}" -eq 40 ] || return 1 case "$1" in *[!0-9a-fA-F]*) return 1 ;; esac return 0 } # Best-effort: name the release the rolling 'stable' tag currently points at, # so installs stay auditable in logs even though the default ref moves. One # ls-remote, matching stable's object id against the release tags (plain and # peeled, so annotated tags match too). Purely informational: prints nothing # on any failure, and the install proceeds either way. stable_release() { has git || return 0 git ls-remote "https://github.com/$MARKETPLACE_REPO.git" 'refs/tags/*' 2>/dev/null \ | awk ' { ref = $2 sub(/^refs\/tags\//, "", ref) sub(/\^\{\}$/, "", ref) if (ref == "stable") { sha = $1 } else { tag[$1] = ref } } END { if (sha != "" && tag[sha] != "") print tag[sha] } ' } # Validate the selected ref and say whether it moves or is pinned. The default # 'stable' tag is maintained by the release workflow and only points to # published releases; versioned release tags remain available for exact pins. check_version() { if ! validate_version "$VERSION"; then error "Invalid --version value: $VERSION" error "Expected a release tag such as v0.6.1. See $RELEASES_PAGE" exit 1 fi case "$VERSION" in stable) rel=$(stable_release) if [ -n "$rel" ]; then info "Using stable (currently $rel)" else info "Using stable (the latest published release)" fi ;; main) warn "Installing from 'main' — unpinned, and not a released version." ;; *) info "Pinned to $VERSION" ;; esac } # The selected source every agent installs from. Claude Code and Codex take # 'owner/repo@tag'; skills.sh takes 'owner/repo#tag' (its parser treats '@' # as a skill-name selector). marketplace_source() { printf '%s@%s' "$MARKETPLACE_REPO" "$VERSION" } skills_source() { printf '%s#%s' "$SKILLS_ID" "$VERSION" } # Read the git ref Claude Code has recorded for this marketplace. Used to # confirm a pin actually took effect rather than trusting the add's exit code # (an add can fail while a stale marketplace is left in place). Prints nothing # when the ref cannot be read; callers treat that as "not configured". # Uses an object-aware block parser so it works regardless of JSON key order. claude_recorded_ref() { claude plugin marketplace list --json 2>/dev/null \ | awk -v want="$MARKETPLACE_NAME" ' /\{/ { in_obj = 1; name = ""; ref = "" } in_obj && /"name"[[:space:]]*:/ { s = $0 sub(/.*"name"[[:space:]]*:[[:space:]]*"/, "", s) sub(/".*/, "", s) name = s } in_obj && /"ref"[[:space:]]*:/ { s = $0 sub(/.*"ref"[[:space:]]*:[[:space:]]*"/, "", s) sub(/".*/, "", s) ref = s } in_obj && /\}/ { if (name == want && ref != "") { print ref exit } in_obj = 0 } ' } # ---- agent metadata ---- agent_label() { case "$1" in claude) printf 'Claude Code' ;; codex) printf 'Codex' ;; cursor) printf 'Cursor + others (skills.sh)' ;; esac } agent_index() { case "$1" in claude) printf '1' ;; codex) printf '2' ;; cursor) printf '3' ;; esac } agent_detected() { case "$1" in claude) has claude ;; codex) has codex ;; cursor) has cursor ;; esac } detect_agents() { detected="" for a in claude codex cursor; do if agent_detected "$a"; then detected="$detected $a"; fi done printf '%s' "${detected# }" } # parse_selection RAW DETECTED -> chosen keys (space separated) parse_selection() { raw=$1 detected=$2 if [ -z "$(printf '%s' "$raw" | tr -d ' \t')" ]; then printf '%s' "$detected" return 0 fi # shellcheck disable=SC2086 for tok in $raw; do case "$tok" in 4|all) printf 'claude codex cursor'; return 0 ;; esac done chosen="" # shellcheck disable=SC2086 for tok in $raw; do case "$tok" in 1|claude) chosen="$chosen claude" ;; 2|codex) chosen="$chosen codex" ;; 3|cursor) chosen="$chosen cursor" ;; *) warn "Ignoring unknown selection: $tok" ;; esac done out="" # shellcheck disable=SC2086 for c in $chosen; do case " $out " in *" $c "*) ;; *) out="$out $c" ;; esac done printf '%s' "${out# }" } menu_default_label() { out="" # shellcheck disable=SC2086 for a in $1; do out="$out $(agent_index "$a")"; done printf '%s' "${out# }" } print_menu() { detected=$1 printf '\nWhich agent(s) should GitGuardian be installed to?\n\n' i=1 for a in claude codex cursor; do mark="" case " $detected " in *" $a "*) mark=" ✓ detected" ;; esac printf ' %s) %s%s\n' "$i" "$(agent_label "$a")" "$mark" i=$((i + 1)) done printf ' 4) All of the above\n\n' } choose_agents() { if [ -n "$AGENT_FLAG" ]; then CHOSEN=$(parse_selection "$(printf '%s' "$AGENT_FLAG" | tr ',' ' ')" "") return 0 fi detected=$(detect_agents) # `[ -r /dev/tty ]` only checks permission bits and is true even in headless # sessions, so probe by actually opening the terminal. printf is a regular # builtin, so a redirection failure here does not exit the shell under set -e. if { printf '' >/dev/tty; } 2>/dev/null; then print_menu "$detected" >/dev/tty printf 'Enter numbers (e.g. 1 3), or Enter for detected [%s]: ' "$(menu_default_label "$detected")" >/dev/tty IFS= read -r reply /dev/null; then warn "Claude: could not refresh the marketplace cache; continuing." fi recorded=$(claude_recorded_ref) if [ "$recorded" != "$VERSION" ]; then error "Claude: marketplace not set to $VERSION (recorded: ${recorded:-})." error "Re-run after 'claude plugin marketplace remove $MARKETPLACE_NAME', or check your network." return 1 fi run claude plugin install "$PLUGIN@$MARKETPLACE_NAME" } configure_codex() { if ! has codex; then warn "Codex CLI not found; skipping." return 1 fi info "Configuring Codex ($VERSION)..." if ! run codex plugin marketplace add "$(marketplace_source)"; then # A marketplace already set to a different ref reports "already added # from a different source"; remove and re-add to retarget it. if run codex plugin marketplace remove "$MARKETPLACE_NAME" \ && run codex plugin marketplace add "$(marketplace_source)"; then : else error "Codex: could not set marketplace to $VERSION." return 1 fi fi # For a moving ref, an explicit upgrade is required: re-adding an existing # marketplace with the same ref reports alreadyAdded and leaves the cached # clone on whatever commit it had (observed: re-add stayed on v0.6.0 while # 'marketplace upgrade' moved it to v0.6.1). Not fatal: a fresh add is # already current. if [ "$VERSION" = "stable" ] \ && ! run codex plugin marketplace upgrade "$MARKETPLACE_NAME" 2>/dev/null; then warn "Codex: could not refresh the marketplace cache; continuing." fi info "Codex: run 'codex', then '/plugins' and install '$PLUGIN' to finish." } configure_cursor() { if ! has npx; then warn "npx (Node.js) not found; needed for skills.sh." warn "Install Node.js, then: npx $SKILLS_NPM_PACKAGE add $(skills_source)" return 1 fi # skills.sh clones with `git clone --branch `, which resolves branch # and tag names but not commit SHAs. if is_commit_sha "$VERSION"; then warn "skills.sh cannot pin to a commit SHA; pass a release tag for the Cursor path." return 1 fi info "Installing skills via skills.sh (Cursor + 50+ agents), $VERSION..." info "Cursor gets skills only — the MCP server is not installed for this path." run npx --yes "$SKILLS_NPM_PACKAGE" add "$(skills_source)" } configure_one() { # Belt-and-braces: main() validates first, but this prevents an accidental # install without any selected ref regardless of call order. if [ -z "$VERSION" ]; then error "Internal error: no version selected; refusing to install." return 1 fi case "$1" in claude) configure_claude ;; codex) configure_codex ;; cursor) configure_cursor ;; esac } print_next_steps() { if [ -z "$(printf '%s' "$1" | tr -d ' \t')" ]; then warn "No agents were configured. Install an agent CLI (claude/codex/npx) and re-run, or pass --agent." return 0 fi mcp=$MCP_URL printf '\nDone. Configured:%s (from %s)\n\n' "$1" "$VERSION" printf 'Next steps:\n' printf ' - Restart your agent session to load the new skills.\n' # Claude Code and Codex also install the bundled MCP server via their plugin # manifests; the Cursor (skills.sh) path installs skills only. case " $1 " in *" claude "*|*" codex "*) printf ' - The GitGuardian MCP server (%s) uses browser OAuth on first use.\n' "$mcp" if [ "$EU" = 1 ]; then printf ' - EU workspace: point your agent MCP config at %s\n' "$MCP_URL_EU" fi ;; esac case " $1 " in *" cursor "*) printf ' - Cursor: skills only — the MCP server was not installed for this path.\n' ;; esac if [ "$EU" = 1 ]; then case " $1 " in *" claude "*|*" codex "*) ;; *) printf ' - --eu had no effect: no MCP server was installed. EU endpoint: %s\n' "$MCP_URL_EU" ;; esac fi if [ "$VERSION" = "stable" ]; then printf ' - This install follows the rolling stable tag. Re-run this installer\n' printf ' to pick up a newer release, or pass --version to pin one.\n' else printf ' - This install uses %s. Re-run with --version to change it.\n' "$VERSION" fi printf ' - Docs: https://github.com/GitGuardian/agent-skills\n\n' } main() { parse_args "$@" print_beta_notice check_version choose_agents if [ -z "$(printf '%s' "$CHOSEN" | tr -d ' \t')" ]; then error "No agent selected. Nothing to do." exit 1 fi configured="" # shellcheck disable=SC2086 for a in $CHOSEN; do if configure_one "$a"; then configured="$configured $a" else warn "Setup for '$a' was skipped or did not finish cleanly." fi done print_next_steps "$configured" # Fail closed at the pipeline level too: a curl | sh that installed nothing # must not report success. if [ -z "$(printf '%s' "$configured" | tr -d ' \t')" ]; then error "Nothing was installed. See the errors above." exit 1 fi } if [ "${GG_INSTALL_LIB:-}" != "1" ]; then main "$@" fi