diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json index 6cf9f2a..e38e771 100644 --- a/.devin-plugin/plugin.json +++ b/.devin-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.3.7", + "version": "0.3.8", "description": "Official JFrog plugin for Devin. Ships the JFrog skills bundle (platform ops, init, MCP management, AI Catalog, package safety, reference architecture, package-manager setup) and registers the JFrog Platform MCP server (remote HTTP + OAuth).", "author": { "name": "JFrog Ltd.", diff --git a/.github/scripts/sync-skills-vendor.json b/.github/scripts/sync-skills-vendor.json index 1124b64..f77c425 100644 --- a/.github/scripts/sync-skills-vendor.json +++ b/.github/scripts/sync-skills-vendor.json @@ -1,6 +1,6 @@ { "repo": "jfrog/jfrog-skills", - "pin": "v0.36.0", + "pin": "v0.37.0", "paths": [ "skills" ] diff --git a/skills/jfrog-init/SKILL.md b/skills/jfrog-init/SKILL.md index de2727f..e94437e 100644 --- a/skills/jfrog-init/SKILL.md +++ b/skills/jfrog-init/SKILL.md @@ -4,7 +4,7 @@ description: Set up and verify the JFrog plugin. Run on first install, to comple disable-model-invocation: true compatibility: >- Requires Node.js 18 or newer, and network access to the JFrog platform. -allowed-tools: Bash(node --version) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-config.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-server-ping.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-reinstall-jfrog-plugin.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" path*) Bash(node -e "import('${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs').then(function(m){console.log(m.detectHarness())})") Bash(npx --version) Bash(uname:*) AskUserQuestion +allowed-tools: Bash(node --version) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-config.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-opencode-mcp-auth.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp-responding.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-server-ping.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-reinstall-jfrog-plugin.mjs"*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" path*) Bash(node "${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs" --harness) Bash(npx --version) Bash(uname:*) AskUserQuestion metadata: role: workflow --- @@ -13,7 +13,7 @@ metadata: **First output must be a tool call, not text.** No "I'll start..." preamble. -Walks a fixed, ordered checklist and stops at the first red result, guiding +Walks a fixed, ordered checklist and stops at the first non-passing result, guiding the user through the matching fix before re-checking. Every detector in `scripts/` is idempotent, read-only, JSON-emitting, and implemented in Node (`.mjs`), so the same detector runs unmodified on macOS, Linux, and @@ -33,12 +33,11 @@ this is just the map: - **Project selection** — the user answers with a project name or key; an `AskUserQuestion` picker offers the first two enumerated projects plus "Other" (Step 6). -- Everything else in the walk is read-only, except Step 5's placeholder - substitution (writes the plugin's `mcp.json`, unattended by design — - no `AskUserQuestion`, see `references/script-invocation.md`), Step 8's - `~/.netrc` write (also unattended, no `AskUserQuestion` — see - `references/marketplace-setup.md`), and the Final summary's state - write below. +- Everything else is read-only, except: Step 5's placeholder + substitution and OpenCode-only `mcp.jfrog` write (both unattended, no + `AskUserQuestion` — see `references/script-invocation.md`); Step 8's + `~/.netrc` write (same, see `references/marketplace-setup.md`); and + the Final summary's state write below. Step 1's `nvm` install and `jfrog-install-jf-cli.mjs`, Step 3's web-login scripts, Step 8's `jfrog-add-claude-marketplace.mjs` call, @@ -47,44 +46,68 @@ deliberately **not** in `allowed-tools` and will raise the harness's own approval prompt — intended, not a misconfiguration; see `references/script-invocation.md`. -`${CLAUDE_SKILL_DIR}` below is this file's own directory. Claude Code -substitutes it automatically, identically, in both this text and the -`allowed-tools` Bash rules above — write it literally rather than -resolving it yourself, so the two stay byte-for-byte consistent -regardless of install depth (see `references/script-invocation.md`). On -a harness that doesn't perform this substitution (e.g. Cursor or Codex, -neither of which consults `allowed-tools` for approval — every command -below still raises its own prompt there), replace it with the real -absolute path of this file's directory yourself, same as before. +## Step 0: resolve the `${CLAUDE_SKILL_DIR}` placeholder (do this FIRST) + +**`${CLAUDE_SKILL_DIR}` is this skill's own absolute directory** — the +folder holding this `SKILL.md`, `scripts/`, and `references/`. Every +`node "${CLAUDE_SKILL_DIR}/scripts/…"` command below needs it resolved +first. + +- **Claude Code** — substitutes it automatically. Run commands as + written. +- **Every other harness** — you MUST look it up and export it before + Step 1: + 1. Look up this skill's absolute directory from wherever your + harness loaded this file (per-harness plugin roots are listed in + `references/mcp-plugin-config.md`). On OpenCode specifically, it + announces its own real path in its own preamble — use that value + directly instead of hunting for it. + 2. Export it: + ```bash + export CLAUDE_SKILL_DIR="" + ``` + 3. Verify with the sentinel — **STOP** and re-resolve if it fails: + ```bash + ls "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs" + ``` + 4. If your harness does not persist environment variables across + commands, use the resolved absolute path directly in every + `node` invocation instead of `${CLAUDE_SKILL_DIR}`. + +Skipping this fails silently rather than loudly — see +`references/script-invocation.md` for why. Step 0 resolves *script +paths*; Step 5's `JFROG_INIT_HARNESS` resolves *harness detection*. +Non-Claude harnesses need both. ## At a glance (always-read core) - **Order matters.** Walk [Steps 1](#step-1-nodejs--18-installed)–[8](#step-8-claude-agent-plugin-marketplace-registered) - in exact order, stop at the first non-green result — except Step 5 - red/error, Step 6's one-retry cap, Step 7's "not entitled" and - "catalog unreachable" outcomes, and Step 8 entirely (all four - non-blocking). See + in exact order, stop at the first non-passing result — except Step 5's + failure/error outcomes, Step 5b entirely, Step 6's one-retry cap, Step 7's "not + entitled" and "catalog unreachable" outcomes, and Step 8 entirely + (all five non-blocking). See [The checklist, in order](#the-checklist-in-order). -- **`rc=$?` is mandatory** on every detector invocation — see - [Invoking scripts](#invoking-scripts-avoid-the-red-error-framing). A - bare `; true` hides every red/ask result as green. +- **Capturing the exit code before forcing success is mandatory** (`; + rc=$?; true` bash / `; $rc=$LASTEXITCODE; exit 0` PowerShell — see + `references/invoking-and-output-rules.md`'s "Invoking scripts" section). A + bare success-forcer with no capture hides every failure/ask result as success. - **Approval gates:** `AskUserQuestion` Yes/No before auto-installing Node (Step 1) or `jf` (Step 2); `AskUserQuestion` picker for web-login vs. token (Step 3/4); `AskUserQuestion` picker for project selection (Step 6). Everything else is read-only except Step 5's - placeholder substitution (plus, for kiro-cli, creating or merging the - `jfrog` entry into `~/.kiro/settings/mcp.json`), Step 8's `~/.netrc` - write, and the Final summary's state write. + placeholder substitution (plus OpenCode `mcp.jfrog` write and kiro-cli + `~/.kiro/settings/mcp.json` merge), Step 8's `~/.netrc` write, and the + Final summary's state write. - **Never surface the checklist.** Run silently — no step narration, no raw JSON/exit codes, no branch-reasoning said out loud. See - [Customer-facing output](#customer-facing-output). + `references/invoking-and-output-rules.md`'s "Customer-facing output" section. - **`` for Steps 4-7** always comes from the shared resolver (explicit arg → `JF_SERVER_ID` → `isDefault` → sole configured server → ask) — never invented, never `jf`'s own fallback. Step 8 reuses the same value. See [Resolving ``](#resolving-server-id-for-steps-4-7). - **Persist state before the final summary** — run - `jfrog-state-file.mjs set` whenever Steps 1-4 are green, regardless of + `jfrog-state-file.mjs set` whenever Steps 1-4 all pass, regardless of Step 5/6/7. See [Final summary](#final-summary). - **Never store, log, or print an access token** — credentials stay inside `jf`'s own process or in-memory for one `fetch` call. **Step 8 @@ -120,77 +143,20 @@ Steps: [1](#step-1-nodejs--18-installed) → [2](#step-2-jfrog-cli-installed) base skill's invariant — see `runJf()` in `scripts/lib/jf.mjs` for why (telemetry-only impact). -## Customer-facing output - -**The user does not need to see the checklist you are walking, but does -need to see what actually happened.** Run the detectors silently, -capture their output for your own reasoning, and surface only what the -user needs to know or act on: - -- **Do not** narrate step numbers ("Step 1…", "moving to Step 3…") - while the walk is in progress. -- **Do not** paste detector JSON, exit codes, or shell command output - into the reply. -- **Do not** narrate the branch-selection reasoning behind an - `AskUserQuestion` or plain-text prompt — e.g. explaining that - `unresolved` wasn't `"server"`, or that `candidatesWithNames` had two - or more entries, so this is "the generic ask using the first two - candidates." That reasoning (in `server-picker.md`, `project-picker.md`, - and the other reference docs' branch tables) is written for you to - follow silently, not to summarize out loud — the field names in it are - never user-facing. The only output the user sees at an ask point is - the prompt itself. -- **Do not** narrate whether the `AskUserQuestion` tool is available in - the current harness before falling back to the plain-text prompt - (e.g. "the AskUserQuestion tool isn't available here, I'll present - this as a plain question instead"). If it isn't available, silently - use the plain-text fallback already documented for that ask point. -- **Do not** announce that you're about to run the checklist, or name - which check comes first — not even generically ("I'll run the setup - checklist silently, starting with the JFrog CLI check" is itself a - violation: it names a step while claiming to be silent). The same - applies to reading reference docs: "I'll start by reading the flow - docs" is a preamble. Silently means no preamble message at all — not - before running commands, not before reading files. Say nothing until - you have something the user needs to act on (an ask, a red result) - or the final summary. - -Instead: - -- **When everything passes**, give a short recap in the final summary - (see "Final summary" below): a short, emoji-based checklist — JF CLI - & Config, JFrog MCP Plugin, Project & AI Catalog — so the user sees - the end state of every check at a glance, not raw step numbers and - not the Node.js check (an implementation detail, not user-facing). -- **When something is red**, say *what's wrong in plain English* and - *what the user needs to do next*, in one or two sentences. Show the - exact command they need to run (they must see what they're - approving). -- **On failure, the raw detector error line is fair game** to include - verbatim as a debugging aid — one line, without the JSON wrapper. - -The rest of this file documents the flow **for you (the model)**, not -for the user. - -## Invoking scripts: avoid the red "Error" framing - -Every detector command shown below signals red/ask states via a -non-zero exit code, by design — append `; rc=$?; true` when invoking -any of them. **`rc=$?` is not optional**: every Step's branch table -below keys off the exit code, and a bare `; true` throws it away, so -every red and ask silently reads as green. **Read -`references/script-invocation.md` in full** before running any command -in this walk — the exact pattern and why it's required, not optional -background. +## Operating rules -## Flow +**Stop and read `references/invoking-and-output-rules.md` in full before running +any command in this walk** — required behavior, not optional +background. It covers three things: -**Follow this flow literally.** Every decision node is covered by a -detector or fix script below; every user-facing prompt uses the exact -wording documented in the corresponding step. Do not reorder, do not -skip, do not narrate the diagram to the user. Read -`references/flow-diagram.md` for the full flowchart before starting a -walk — the same logic as the Steps below, drawn as a map. +- **Customer-facing output** — the user never sees step numbers, raw + JSON/exit codes, or branch-selection reasoning; only asks, failures + in plain English, and the final summary. +- **Invoking scripts: exit codes are signal, not failure** — every + detector needs `; rc=$?; true` appended, and every Step's branch + table below keys off that captured exit code. +- **Flow** — follow the flow literally, no reordering or skipping; see + `references/flow-diagram.md` for the full map. ## The checklist, in order @@ -198,16 +164,19 @@ walk — the same logic as the Steps below, drawn as a map. 2. **JFrog CLI (`jf`) installed?** — `scripts/jfrog-detect-jf-cli.mjs` 3. **`jf` connected to a server?** — `scripts/jfrog-detect-jf-config.mjs` 4. **Server reachable + credentials valid?** — `scripts/jfrog-detect-server-ping.mjs [server-id]` -5. **JFrog MCP plugin file has a jfrog entry?** — `scripts/jfrog-detect-jfrog-mcp.mjs [server-id]` +5. **JFrog MCP plugin file has a jfrog entry?** — `scripts/jfrog-detect-jfrog-mcp.mjs [server-id]`, then **is the JFrog MCP server enabled on this JPD?** — `scripts/jfrog-detect-jfrog-mcp-responding.mjs [server-id]` (non-blocking) + - **5b (OpenCode only): MCP auth token present?** — + `scripts/jfrog-detect-opencode-mcp-auth.mjs`, runs only when Step 5 + exits 0 and the harness is OpenCode 6. **Project resolved?** — `scripts/jfrog-detect-project.mjs [server-id] [project-input]` 7. **AI Catalog reachable & user entitled?** — `scripts/jfrog-detect-catalog-runtime.mjs [server-id]` 8. **Claude agent-plugin marketplace registered?** — `scripts/jfrog-add-claude-marketplace.mjs [server-id] [project-key]`, Claude Code only -Run detectors in this exact order and stop at the first non-green -result — except Step 5 going red/error (see Step 5), Step 6 hitting -its one-retry cap (see Step 6), Step 7's "not entitled" and "catalog -unreachable" outcomes (see Step 7), and Step 8 entirely (see Step 8), -all four non-blocking. Step +Run detectors in this exact order and stop at the first non-passing +result — except Step 5 going into failure/error (see Step 5), Step 5b entirely +(see Step 5b), Step 6 hitting its one-retry cap (see Step 6), Step 7's +"not entitled" and "catalog unreachable" outcomes (see Step 7), and +Step 8 entirely (see Step 8), all five non-blocking. Step 1 has no script — a Node script can't verify Node exists — so every step after it is written in Node and can assume Node is present. The JPD URL is read directly from @@ -253,20 +222,20 @@ regardless of everything else. Read the output yourself, no JSON to parse: -- Either command errors (e.g. `command not found: node`) → **red**: +- Either command errors (e.g. `command not found: node`) → **not usable**: Node.js (or `npx`) is not installed / the install is broken. - `node --version` prints a version like `v16.2.0` → parse the major - number yourself. `< 18` → **red**: "Node.js `` is too old — + number yourself. `< 18` → **too old**: "Node.js `` is too old — jfrog-init requires Node ≥ 18." -- `node --version` ≥ 18 **and** `npx --version` succeeds → **green** → - proceed to Step 2. +- `node --version` ≥ 18 **and** `npx --version` succeeds → proceed to + Step 2. **Never paste the raw shell output.** Translate to plain English — "npx is not installed" not `` `command not found` ``, "Node.js v16 is too old" not the version string verbatim. The raw output is for your reasoning, not for the user. -On red, **stop and read `references/node-install-prompt.md` in full +On failure, **stop and read `references/node-install-prompt.md` in full before responding to the user.** It has the exact `AskUserQuestion` payload, the forbidden phrases, and the install commands — required behavior, not optional background. Even on the install path there's no @@ -279,15 +248,15 @@ install is a bash/PowerShell command the model runs directly. node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"; rc=$?; true ``` -- **Exit 0 (green)** → proceed to Step 3. -- **Exit 1 (red), `reason: "missing"`** → `jf` not found on PATH → **stop +- **Exit 0** → proceed to Step 3. +- **Exit 1, `reason: "missing"`** → `jf` not found on PATH → **stop and read `references/jf-cli-install-prompt.md` in full** — required behavior, not optional background. -- **Exit 1 (red), `reason: "broken"`** → `jf` is on PATH but hung or +- **Exit 1, `reason: "broken"`** → `jf` is on PATH but hung or failed to run → **stop and read `references/jf-cli-install-prompt.md` in full** — it has a separate payload for this case; required behavior, not optional background. -- **Exit 1 (red), `reason: "outdated"`** → `jf` installed but below the +- **Exit 1, `reason: "outdated"`** → `jf` installed but below the required minimum version → **stop and read `references/jf-cli-update-prompt.md` in full** — required behavior, not optional background. @@ -315,8 +284,8 @@ If multiple servers are configured with none marked default, that ambiguity is surfaced starting at Step 4, not here — see "Resolving `` for Steps 4-7" above. -- **Exit 0 (green)** → proceed to Step 4. -- **Exit 1 (red)** → `jf` is installed but not connected to any +- **Exit 0** → proceed to Step 4. +- **Exit 1** → `jf` is installed but not connected to any server. **Stop and read `references/jf-config-auth-picker.md` in full before responding to the user** — the Step 3 section has the exact web-login-vs-token `AskUserQuestion`, this skill's own local @@ -337,33 +306,53 @@ blocks) — required behavior, not optional background. ## Step 5: JFrog MCP plugin file has a jfrog entry? -If you are Kiro or Kiro CLI, prefix the command with -`JFROG_INIT_HARNESS=kiro` or `JFROG_INIT_HARNESS=kiro-cli` on the -same line — no other harness needs this. Silent, like every other -setup command in this walk. +If you are Kiro, Kiro CLI, Copilot in VS Code, or Devin, run only the matching command below. Silent, like every other setup command in this walk. +(Devin identifies itself from system instructions; no env var carries the signal.) ```bash JFROG_INIT_HARNESS=kiro node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true JFROG_INIT_HARNESS=kiro-cli node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true -node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true +JFROG_INIT_HARNESS=vscode node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true +JFROG_INIT_HARNESS=devin node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp.mjs" "[server-id]"; rc=$?; true ``` Pass the same `` already resolved for Step 4 (empty string if Step 4 resolved silently via default/single-server) — this reuses it for the placeholder fix instead of re-resolving from scratch. -**Read-only against the JFrog plugin's own `mcp.json` — with one -exception: automatic placeholder substitution** of an unresolved -`${JFROG_PLATFORM_URL}` / `${JFROG_URL}` with the real JPD URL from -`jf config` (plus, for kiro-cli, adding the `jfrog` entry to -`~/.kiro/settings/mcp.json` when no plugin has put one there — see +**Read-only** against the JFrog plugin's `mcp.json`, with exceptions: substituting +an unresolved `${JFROG_PLATFORM_URL}` / `${JFROG_URL}` placeholder (Cursor / VS Code / +Claude Code), writing a `mcp.jfrog` entry on OpenCode (which has no plugin-owned file), +and adding the `jfrog` entry to `~/.kiro/settings/mcp.json` for kiro-cli (see `references/mcp-plugin-config.md`). -**Stop and read `references/mcp-plugin-config.md` in full** — exactly -how the substitution works, the per-harness plugin-config paths, and -the required exit-code branches (Exit 1/3 non-blocking, Exit 2 the one -outcome that still blocks) — required behavior, not optional -background. +**Stop and read `references/mcp-plugin-config.md` in full** — substitution mechanics, +per-harness paths, exit-code branches (Exit 1/3 non-blocking, Exit 2 the one that +still blocks), and its **MCP-responding probe** (server enabled + signed in) — +required behavior, not optional. + +## Step 5b (OpenCode only): MCP auth token present? + +Runs only when Step 5 exits **0** — the exit code, not the JSON +`status` field — and the harness is OpenCode. Determine the harness +the same way Step 8 does, with `detectHarness()`: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs" --harness +``` + +If it returns anything other than `opencode`, or Step 5 didn't exit 0, +**skip this step silently** — same treatment as Step 8's preconditions +below. Otherwise, run: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-opencode-mcp-auth.mjs"; rc=$?; true +``` + +**Stop and read `references/mcp-plugin-config.md` in full** for what +this checks, why a non-empty auth-file key isn't proof of a working +token, and the exit-code meanings. ## Step 6: Project resolved? @@ -390,8 +379,8 @@ the forbidden-phrasing rules for each — this is required behavior for the step, not optional background. **Stop and read `references/project-resolution-branches.md` in full** -for exactly how to branch on the detector's exit code (green / ask / -red with the one-retry cap / error) — required behavior, not optional +for exactly how to branch on the detector's exit code (success / ask / +failure with the one-retry cap / error) — required behavior, not optional background. ## Step 7: AI Catalog reachable & user entitled? @@ -408,14 +397,14 @@ required behavior, not optional background. ## Step 8: Claude agent-plugin marketplace registered? -Two preconditions, in this order. **Step 7 must have been green** — the +Two preconditions, in this order. **Step 7 must have passed (Exit 0)** — the marketplace is served by the same AI Catalog that Step 7 probes, so -after a non-blocking red there (unreachable, or not entitled) there is +after a non-blocking failure there (unreachable, or not entitled) there is nothing to register. Then, **Claude Code only** — check the current harness by reusing `detectHarness()` from -`scripts/jfrog-resolve-mcp-config.mjs` (the same export Step 5 already +`scripts/jfrog-resolve-mcp-config.mjs` (the same export Step 5b already uses), e.g. -`node -e "import('${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs').then(function(m){console.log(m.detectHarness())})"`. +`node "${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs" --harness`. If either precondition fails, **skip this step silently** — never run the script below, no `AskUserQuestion`, no note anywhere, not even in @@ -436,16 +425,16 @@ canonical project key, or an empty string if Step 6 resolved none. acting on the exit code** — required behavior, not optional background. -- **Exit 0 (green)** → success. The last stdout line is +- **Exit 0** → success. The last stdout line is `Successfully added marketplace: ` — extract `` for the Final Summary's trailing line. -- **Exit 1 or 3 (red)** → non-blocking failure. Nothing beyond the Final +- **Exit 1 or 3** → non-blocking failure. Nothing beyond the Final Summary's ⚠️ line, and never volunteer which cause it was. ## Final summary **Persist the walk's state before rendering any outcome below.** -Whenever Steps 1-4 are green (regardless of what Step 5/6/7 reported), +Whenever Steps 1-4 all pass (regardless of what Step 5/6/7 reported), run: ```bash @@ -453,8 +442,8 @@ node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" set "" "`?" prompt @@ -465,87 +454,9 @@ persist. (Running the whole walk via `jfrog-detect-all.mjs` instead — see "Running everything at once" below — does this same write itself; don't call both.) -**Give the user a short recap, not the raw checklist.** See -"Customer-facing output" above — no step numbers, no raw JSON. Render a -short, emoji-based checklist, not a prose paragraph or a five-line -plain-text list. Three grouped lines cover all five checks: - -- **JF CLI & Config** — Steps 2-4 (`jf` installed and connected to a - server). Always fully resolved here — this checklist only renders - once Steps 1-4 all passed (see "Anything else red" below for the - alternative). -- **JFrog MCP Plugin** — Step 5. -- **Project & AI Catalog** — Steps 6 and 7 together. - -Skip Node.js (Step 1) — implementation detail, not user-facing. - -**Rules for the checklist:** -1. Do **not** use the word "done" anywhere in it. -2. Keep those checks in exactly these three grouped lines — never - expand back out to five. -3. All three groups fully resolved → use this exact format, verbatim: - - > ✨ **JFrog initialization complete!** - > ✅ JF CLI & Config - > ✅ JFrog MCP Plugin - > ✅ Project & AI Catalog - -4. A group with something outstanding gets ⚠️ instead of ✅, plus a - short fact after an em dash: - - > ✨ **JFrog initialization complete!** - > ✅ JF CLI & Config - > ⚠️ JFrog MCP Plugin — not configured - > ✅ Project & AI Catalog - - For the merged **Project & AI Catalog** line, if only one of the two - is outstanding name just that one; if both are, separate them with a - semicolon: `⚠️ Project & AI Catalog — project not set up yet; catalog - access not entitled`. - -5. **Step 8 gets a fourth checklist line, but only when it ran** — - nothing appears when it was skipped: - - **Success** — `✅ JFrog Marketplace`, plus this trailing sentence - after the checklist block, in this exact wording: - - > Added the JFrog marketplace `` to Claude Code. - > Browse available plugins with `/plugins`, or install directly with - > `claude plugin install @` - - - **Red** — `⚠️ JFrog Marketplace — not registered`, and no trailing - sentence. - -Never phrase a ⚠️ line as a failure or as something the user needs to -fix before continuing — all of them are non-blocking by design. The -short fact after the em dash is the same underlying cause this skill -has always surfaced, just worded without "pending": - -- **Step 5 red/error (MCP plugin not configured):** `not configured`. - If the user asks why or how to fix it, that's when the specific cause - from Step 5's `detail` comes in — either run - `jfrog-reinstall-jfrog-plugin.mjs` (see Step 5) for the per-harness - reinstall remedy, or point at resolving `jf config`, matching - whichever cause Step 5 actually reported. -- **Step 6 hit its retry cap (no project resolved):** `project not set - up yet`. If the user asks, mention they can pick one whenever they're - ready. The server/JPD are still recorded to the state file either way - (see the persistence step at the top of this section); a project - picked in an earlier walk, if any, is left as-is rather than cleared. -- **Step 7 returned exit 4 (not entitled):** `catalog access not - entitled`. If the user asks for the fix: ask your JFrog admin for the - "AI Catalog Read" role to browse or install MCPs from the catalog. -- **Step 7 returned exit 1 (catalog not hosted / unreachable):** - `catalog not reachable on this JPD`. No fix instruction; there may be - nothing to fix (this JPD may simply not host the AI Catalog). -- **Something happened this walk** (Node.js/`jf` CLI installed, `jf - config` connected, a project resolved in Step 6, an MCP placeholder - substituted in Step 5, etc.): still the same checklist — the action - itself isn't called out per-line, ✅ is ✅ regardless of whether it - needed fixing this walk. -- **Anything else red** (Steps 1-4 not all green): one or two sentences - naming what's blocking and what to do next, no checklist — there's - nothing to check off yet. Include the raw detector error line if it - helps debug, without the JSON wrapper. +**Read `references/final-summary-rendering.md` in full** for the exact +recap checklist format, the ⚠️ wording rule per outstanding step, and +Step 8's extra checklist line. ## Running everything at once @@ -555,38 +466,23 @@ fields, and the state-file write behavior. ## Non-goals (out of scope for this skill) -- Installing the JFrog IDE plugin, or replacing its auto-config. -- Installing the VS Code hook. -- A first-MCP wizard for an empty catalog. -- Persisting the picked **project key** to `JF_PROJECT` or any shell - profile. Step 6 asks every walk and threads the pick forward as a - positional argument only — nothing about project selection ever - touches a shell profile. (Two other, unrelated things in this walk - *do*: Step 1's `nvm`-based Node install, and Step 2's Plan C fallback - when npm itself isn't usable — both append one PATH line to the - user's shell rc file, disclosed up front in the install consent - prompts, see `node-install-prompt.md` / `jf-cli-install-prompt.md`. - Plans A/B of Step 2 — the common case — don't touch a shell profile - at all, relying on npm's own global bin directory instead.) -- Granting AI Catalog roles/permissions — Step 7 only instructs. -- Storing access tokens to disk, logging them, or printing them. - Step 4's authenticated check keeps the credential inside `jf`'s own - process (`jf rt ping`); Steps 6 and 7 extract it from `jf config - export` only in memory, for one `fetch` call. Step 3/4's token-based - `jf config` path (see `references/jf-config-auth-picker.md`) never - touches this skill or the model at all — the user runs that command - themselves. **Step 8 is the one deliberate exception** — it writes - the token to `~/.netrc`; see `references/marketplace-setup.md`. +**Read `references/out-of-scope.md` in full** for the exact list — IDE +plugin/VS Code hook install, first-MCP wizard, project-key +persistence, AI Catalog role grants, and access-token handling +(Step 8's `~/.netrc` write is the one deliberate exception). + ## Before you run `/jfrog-init` — checklist [At a glance](#at-a-glance-always-read-core) invariants: -- [ ] Walk Steps 1-8 in exact order; stop at the first non-green result - (Step 5 red/error, Step 6's retry cap, Step 7 "not entitled" or - "unreachable", and Step 8 entirely are non-blocking) -- [ ] Every detector invocation appends `; rc=$?; true` — never a bare - `; true` +- [ ] Walk Steps 1-8 in exact order; stop at the first non-passing result + (Step 5's failure/error outcomes, Step 5b entirely, Step 6's retry cap, Step 7 + "not entitled" or "unreachable", and Step 8 entirely are + non-blocking) +- [ ] Every detector invocation captures then forces success (`; rc=$?; + true` bash / `; $rc=$LASTEXITCODE; exit 0` PowerShell) — never a + bare success-forcer with no capture - [ ] `AskUserQuestion` before auto-installing Node (Step 1) or `jf` (Step 2); picker for web-login vs. token (Step 3/4); picker for project selection (Step 6) @@ -594,7 +490,7 @@ fields, and the state-file write behavior. branch-reasoning surfaced to the user - [ ] `` for Steps 4-8 comes only from the shared resolver — never invented, never `jf`'s own fallback -- [ ] Steps 1-4 green → `jfrog-state-file.mjs set +- [ ] Steps 1-4 all pass → `jfrog-state-file.mjs set ` before rendering the final summary - [ ] Never store, log, or print an access token — except Step 8's `~/.netrc` write diff --git a/skills/jfrog-init/references/batch-walk.md b/skills/jfrog-init/references/batch-walk.md index c590e4a..b6caa0c 100644 --- a/skills/jfrog-init/references/batch-walk.md +++ b/skills/jfrog-init/references/batch-walk.md @@ -2,12 +2,15 @@ `node scripts/jfrog-detect-all.mjs [server-id] [project-key]` runs Steps 1–7 in order and stops at the first non-green result — except Step 5 going -red/error, Step 6 going red (ambiguous/404/403), and Step 7 going red +red/error, Step 5b going red/error, Step 6 going red (ambiguous/404/403), and Step 7 going red in either of its two non-blocking shapes (exit 1: catalog not hosted / unreachable / 5xx, or exit 4: reachable but not entitled), all of which are non-blocking: Steps 1-4 passing is what "green" means here, -and the MCP-plugin, project-resolution, and catalog-availability gaps -are each reported as separate signals. This script makes exactly one +and the MCP-plugin, MCP-auth, project-resolution, and catalog-availability +gaps are each reported as separate signals. Step 5b itself only runs when +Step 5 came back green **and** `detectHarness()` returns `opencode` — every +other harness authenticates MCP tools through `jf config` credentials +directly, so there's nothing for it to check. This script makes exactly one project-resolution attempt per invocation and has no way to tell a first attempt from a last one, so it always treats a Step 6 red as non-blocking — the interactive walk (see Step 6 in `SKILL.md`) is what @@ -20,18 +23,26 @@ picked project as arg 2 — unless that `ask` carries `"unresolved": "server"`, in which case it's a server pick (see Step 6's branches) and the re-invocation picks server-id (arg 1) instead. -Exit 0 = Steps 1-4 green (MCP configured or not, project resolved -or not, catalog entitled or not); exit 1 = something needs fixing. The -final JSON line adds `mcpConfigured: true|false`, `projectResolved: +Exit 0 = Steps 1-4 green (MCP configured or not, MCP token present or not, +MCP server enabled or not, project resolved or not, catalog entitled or +not); exit 1 = something needs fixing. The final JSON line adds +`mcpConfigured: true|false`, `mcpAuthed: "ok" | "missing" | +"not_applicable"`, `mcpResponding: true|false`, `projectResolved: true|false`, and `catalogEntitled: true|false` so a caller can tell the exit-0 cases apart — plus `catalogReason: "unreachable" | "not_entitled"` -whenever `catalogEntitled` is `false`, so the Final Summary can name the -specific gap instead of a generic one. Writes the `~/.jfrog/setup.json` -state-file hint whenever Steps 1-4 are green, **regardless of -`projectResolved`, `mcpConfigured`, or `catalogEntitled`** — the server -and JPD URL are worth remembering on their own, independent of whether -a project got picked, the MCP plugin is wired up, or the AI Catalog is -reachable/the user is entitled to it. An unresolved project is passed +whenever `catalogEntitled` is `false`, and `mcpRespondingReason: +"not_enabled" | "unreachable"` whenever `mcpResponding` is `false`, so the +Final Summary can name the specific gap instead of a generic one. +`mcpAuthed` is `"not_applicable"` on every harness but OpenCode — there is +no gap to report where the check never runs, and a boolean there would +conflate "no gap" with "not checked". Writes the +`~/.jfrog/setup.json` state-file hint whenever Steps 1-4 are green, +**regardless of `projectResolved`, `mcpConfigured`, `mcpAuthed`, +`mcpResponding`, or `catalogEntitled`** — the server and JPD URL are worth +remembering on their own, independent of whether a project got picked, the +MCP plugin is wired up, its auth token is present, the MCP server is +enabled on the JPD, or the AI Catalog is reachable/the user is entitled to +it. An unresolved project is passed to `jfrog-state-file.mjs` as an empty key, which leaves any previously recorded `currentActiveProject` alone rather than erasing it (see `jfrog-state-file.mjs`); it's never written as a fresh, unvalidated diff --git a/skills/jfrog-init/references/final-summary-rendering.md b/skills/jfrog-init/references/final-summary-rendering.md new file mode 100644 index 0000000..6348c7d --- /dev/null +++ b/skills/jfrog-init/references/final-summary-rendering.md @@ -0,0 +1,96 @@ +# Final summary — recap rendering rules + +**Give the user a short recap, not the raw checklist.** See +"Customer-facing output" in `SKILL.md` — no step numbers, no raw JSON. +Render a short, emoji-based checklist, not a prose paragraph or a +five-line plain-text list. Three grouped lines cover all five checks: + +- **JF CLI & Config** — `jf` installed and connected to a server. Always + fully resolved here — this checklist only renders once every + prerequisite up through server connectivity has passed (see "Anything + else red" below for the alternative). +- **JFrog MCP Plugin** — the MCP plugin check. +- **Project & AI Catalog** — the project-resolution and AI-Catalog-access + checks, together. + +Skip the Node.js setup step — implementation detail, not user-facing. + +**Rules for the checklist:** +1. Do **not** use the word "done" anywhere in it. +2. Keep those checks in exactly these three grouped lines — never + expand back out to five. +3. All three groups fully resolved → use this exact format, verbatim: + + > ✨ **JFrog initialization complete!** + > ✅ JF CLI & Config + > ✅ JFrog MCP Plugin + > ✅ Project & AI Catalog + +4. A group with something outstanding gets ⚠️ instead of ✅, plus a + short fact after an em dash: + + > ✨ **JFrog initialization complete!** + > ✅ JF CLI & Config + > ⚠️ JFrog MCP Plugin — not configured + > ✅ Project & AI Catalog + + For the merged **Project & AI Catalog** line, if only one of the two + is outstanding name just that one; if both are, separate them with a + semicolon: `⚠️ Project & AI Catalog — project not set up yet; catalog + access not entitled`. + +5. **Step 8 gets a fourth checklist line, but only when it ran** — + nothing appears when it was skipped: + - **Success** — `✅ JFrog Marketplace`, plus this trailing sentence + after the checklist block, in this exact wording: + + > Added the JFrog marketplace `` to Claude Code. + > Browse available plugins with `/plugins`, or install directly with + > `claude plugin install @` + + - **Red** — `⚠️ JFrog Marketplace — not registered`, and no trailing + sentence. + +**If the user asks why** (troubleshooting reference — not part of the render itself): + +Never phrase a ⚠️ line as a failure or as something the user needs to +fix before continuing — all of them are non-blocking by design. The +short fact after the em dash is the same underlying cause this skill +has always surfaced, just worded without "pending": + +- **Step 5 red/error (MCP plugin not configured):** `not configured`. + If the user asks why or how to fix it, that's when the specific cause + from Step 5's `detail` comes in — either run + `jfrog-reinstall-jfrog-plugin.mjs` (see Step 5) for the per-harness + reinstall remedy, or point at resolving `jf config`, matching + whichever cause Step 5 actually reported. +- **Step 5 MCP status** (only when the config check above is green) — the + **probe** decides, the only signal tied to *this* JPD: + - exit 4 → ⚠️ `not enabled on this JPD` (+ ask admin, docs from `detail`) + - exit 1 → ⚠️ `could not confirm it's enabled` + - exit 0 → MCP is on; check your session for JFrog MCP tools **on this JPD** + (same base URL, else they don't count): visible → ✅; else ⚠️ `enabled — sign + in to use it`, and offer sign-in **after the summary, never mid-walk** +- **Step 5b red (OpenCode OAuth incomplete):** `not authenticated`. If + the user asks, that's when `opencode mcp auth jfrog` comes in. +- **Step 6 hit its retry cap (no project resolved):** `project not set + up yet`. If the user asks, mention they can pick one whenever they're + ready. The server/JPD are still recorded to the state file either way + (see the persistence step at the top of `SKILL.md`'s Final summary + section); a project picked in an earlier walk, if any, is left as-is + rather than cleared. +- **Step 7 returned exit 4 (not entitled):** `catalog access not + entitled`. If the user asks for the fix: ask your JFrog admin for the + "AI Catalog Read" role to browse or install MCPs from the catalog. +- **Step 7 returned exit 1 (catalog not hosted / unreachable):** + `catalog not reachable on this JPD`. No fix instruction; there may be + nothing to fix (this JPD may simply not host the AI Catalog). +- **Something happened this walk** (Node.js/`jf` CLI installed, `jf + config` connected, a project resolved in Step 6, an MCP placeholder + substituted in Step 5, etc.): still the same checklist — the action + itself isn't called out per-line, ✅ is ✅ regardless of whether it + needed fixing this walk. +- **Anything else red** (Steps 1-4 not all green): one or two sentences + naming what's blocking and what to do next, no checklist — there's + nothing to check off yet. Include the raw detector error line if it + helps debug, without the JSON wrapper. diff --git a/skills/jfrog-init/references/flow-diagram.md b/skills/jfrog-init/references/flow-diagram.md index 8ed406d..d69bce1 100644 --- a/skills/jfrog-init/references/flow-diagram.md +++ b/skills/jfrog-init/references/flow-diagram.md @@ -68,7 +68,10 @@ flowchart TD ASKSRV5 --> S5 S5 -->|missing/invalid/no entry, incl. substitution failure| F5["Note: reinstall or update the JFrog plugin, or resolve jf config (non-blocking)"]:::fixBox F5 --> S6 - S5 -->|yes, valid url| S6 + S5 -->|yes, valid url| S5R["5b. JFrog MCP server enabled on this JPD? (jfrog-detect-jfrog-mcp-responding.mjs, anonymous GET <JPD>/mcp — non-blocking)"]:::stepBox + S5R -->|not_enabled: ask admin / unreachable: couldn't confirm| F5R["Note: surface in Final Summary (non-blocking)"]:::fixBox + S5R -->|enabled: ✅ if signed in to this JPD, else offer sign-in AFTER Final Summary| S6 + F5R --> S6 S6["6. Project resolved?"]:::stepBox S6 -->|state file has current project| ASKREUSE["AskUserQuestion: reuse CURRENT or pick different"]:::fixBox diff --git a/skills/jfrog-init/references/invoking-and-output-rules.md b/skills/jfrog-init/references/invoking-and-output-rules.md new file mode 100644 index 0000000..07dec55 --- /dev/null +++ b/skills/jfrog-init/references/invoking-and-output-rules.md @@ -0,0 +1,72 @@ +# Operating rules: silent walk, exit codes, and flow + +## Customer-facing output + +**The user does not need to see the checklist you are walking, but does +need to see what actually happened.** Run the detectors silently, +capture their output for your own reasoning, and surface only what the +user needs to know or act on: + +- **Do not** narrate step numbers ("Step 1…", "moving to Step 3…") + while the walk is in progress. +- **Do not** paste detector JSON, exit codes, or shell command output + into the reply. +- **Do not** narrate the branch-selection reasoning behind an + `AskUserQuestion` or plain-text prompt — e.g. explaining that + `unresolved` wasn't `"server"`, or that `candidatesWithNames` had two + or more entries, so this is "the generic ask using the first two + candidates." That reasoning (in `server-picker.md`, `project-picker.md`, + and the other reference docs' branch tables) is written for you to + follow silently, not to summarize out loud — the field names in it are + never user-facing. The only output the user sees at an ask point is + the prompt itself. +- **Do not** narrate whether the `AskUserQuestion` tool is available in + the current harness before falling back to the plain-text prompt + (e.g. "the AskUserQuestion tool isn't available here, I'll present + this as a plain question instead"). If it isn't available, silently + use the plain-text fallback already documented for that ask point. +- **Do not** announce that you're about to run the checklist, or name + which check comes first — not even generically ("I'll run the setup + checklist silently, starting with the JFrog CLI check" is itself a + violation: it names a step while claiming to be silent). The same + applies to reading reference docs: "I'll start by reading the flow + docs" is a preamble. Silently means no preamble message at all — not + before running commands, not before reading files. Say nothing until + you have something the user needs to act on (an ask, a failing result) + or the final summary. + +Instead: + +- **When everything passes**, give a short recap in the final summary + (see SKILL.md's "Final summary" section): a short, emoji-based checklist — JF CLI + & Config, JFrog MCP Plugin, Project & AI Catalog — so the user sees + the end state of every check at a glance, not raw step numbers and + not the Node.js check (an implementation detail, not user-facing). +- **When something fails**, say *what's wrong in plain English* and + *what the user needs to do next*, in one or two sentences. Show the + exact command they need to run (they must see what they're + approving). +- **On failure, the raw detector error line is fair game** to include + verbatim as a debugging aid — one line, without the JSON wrapper. + +SKILL.md documents the flow **for you (the model)**, not for the user. + +## Invoking scripts: exit codes are signal, not failure + +Every detector command shown in SKILL.md signals a failure or an ask via a +non-zero exit code, by design — append `; rc=$?; true` when invoking +any of them. **`rc=$?` is not optional**: every Step's branch table +in SKILL.md keys off the exit code, and a bare `; true` throws it away, so +every failure and ask silently reads as success. **Read +`references/script-invocation.md` in full** before running any command +in this walk — the exact pattern and why it's required, not optional +background. + +## Flow + +**Follow this flow literally.** Every decision node is covered by a +detector or fix script in SKILL.md; every user-facing prompt uses the exact +wording documented in the corresponding step. Do not reorder, do not +skip, do not narrate the diagram to the user. Read +`references/flow-diagram.md` for the full flowchart before starting a +walk — the same logic as SKILL.md's Steps, drawn as a map. diff --git a/skills/jfrog-init/references/mcp-plugin-config.md b/skills/jfrog-init/references/mcp-plugin-config.md index 59a52b2..efe18c8 100644 --- a/skills/jfrog-init/references/mcp-plugin-config.md +++ b/skills/jfrog-init/references/mcp-plugin-config.md @@ -2,12 +2,13 @@ Background for Step 5 of `/jfrog-init` (`SKILL.md`). The model doesn't need this to execute the step — `jfrog-detect-jfrog-mcp.mjs` handles -detection and substitution and reports the result as JSON — but it's -useful for debugging a red/error result or explaining what happened. +detection, substitution, and (OpenCode only) writing a new entry, and +reports the result as JSON — but it's useful for debugging a red/error +result or explaining what happened. -**Placeholder substitution.** The plugin sometimes ships an `mcp.json` -where the JPD URL is a placeholder that would otherwise need to be -resolved at runtime from an env var: +**Placeholder substitution (Cursor / VS Code / Claude Code).** The +plugin sometimes ships an `mcp.json` where the JPD URL is a placeholder +that would otherwise need to be resolved at runtime from an env var: ```json {"mcpServers": {"jfrog": {"url": "https://${JFROG_PLATFORM_URL}/mcp"}}} @@ -20,6 +21,17 @@ Codex's plugin ships the same idea in a different shape — no {"jfrog": {"url": "https:///mcp"}} ``` +The VS Code plugin uses VS Code's own env-var syntax, with an `env:` +prefix inside `${...}`: + +```json +{"mcpServers": {"jfrog": {"type": "http", "url": "https://${env:JFROG_PLATFORM_URL}/mcp"}}} +``` + +The Copilot extension **does not** expand `${env:VAR}` before loading +the MCP, so leaving the placeholder in place also silently fails to +connect; Step 5 substitutes it the same way as the others. + Because we have that URL sitting in `jf config`, and because leaving the placeholder in place means the MCP silently fails to load in the IDE / agent, Step 5 auto-substitutes it. If the detector finds the @@ -36,18 +48,57 @@ placeholder pattern anywhere in the file, it calls into that one `url` string. 3. Replaces in two passes — first a placeholder preceded by a scheme (`https://${...}`, where our own scheme would otherwise double up), - then a bare one. Each pass recognizes all three syntaxes: `${VAR}`, - `$VAR`, and Codex's ``. + then a bare one. Each pass recognizes four syntaxes: `${VAR}`, + VS Code's `${env:VAR}`, bare `$VAR`, and Codex's ``. 4. Re-serializes the whole file (`JSON.stringify(parsed, null, 2)`) and writes atomically (temp file + rename) so a partial write cannot corrupt the file. Original formatting/whitespace elsewhere in the file is not preserved byte-for-byte. 5. Is idempotent — subsequent runs find no placeholder and no-op. -This is the only place `/jfrog-init` writes to a harness's plugin-owned -`mcp.json` — with one further exception for Kiro CLI: ensuring a `jfrog` -entry exists in `~/.kiro/settings/mcp.json`, which no plugin ships (see -below). Everything else in Step 5 is read-only. +This is the ONLY place `/jfrog-init` writes to the plugin-owned +`mcp.json` for these three harnesses. Everything else in Step 5 is +read-only for them — with two further exceptions: OpenCode and Kiro CLI +(see below). + +**OpenCode is structurally different.** The JFrog OpenCode plugin +(`@jfrog/opencode-jfrog-plugin`) ships no static `mcp.json` of its +own — it injects an `mcp.jfrog` entry into OpenCode's in-memory config +at startup, via a `config` hook, but **only when `$JFROG_PLATFORM_URL` +is set**. `/jfrog-init` deliberately does not rely on that env var — +the JPD URL is already known from `jf config`, the same as every other +harness — so instead it writes the entry directly into the **user's +own** OpenCode config file: + +```json +{"mcp": {"jfrog": {"type": "remote", "url": "https://acme.jfrog.io/mcp", "enabled": true}}} +``` + +If the detector finds no `mcp.jfrog` entry at all (the expected steady +state until this runs once), it calls `jfrog-write-opencode-mcp.mjs`, +which: + +1. Parses the file as **strict JSON**. If that fails — most often + because it's an `opencode.jsonc` with comments, which a + `JSON.parse`/`JSON.stringify` round-trip would silently destroy — + it refuses to write and reports an error instead; Step 5 falls back + to a manual-paste instruction (see + `jfrog-reinstall-jfrog-plugin.mjs`'s OpenCode branch) rather than + risking comment loss. +2. Never overwrites an existing `mcp.jfrog` entry — same behavior as + the plugin's own runtime injection, and it protects any manual + configuration the user already has. +3. Reads the JPD URL from `jf config` (default server, or the one + passed as arg 2) and writes `{"type": "remote", "url": "/mcp", + "enabled": true}` under `mcp.jfrog`. +4. Re-serializes the whole file and writes atomically (temp file + + rename), same as the placeholder substituter. +5. Is idempotent — subsequent runs find the entry already present and + no-op. + +This is the ONLY place `/jfrog-init` writes to the user's own OpenCode +config, and the only Step 5 write that targets a file NOT owned by the +plugin (there is no plugin-owned file for OpenCode to write to). **Per-harness plugin-owned config file:** @@ -57,8 +108,41 @@ below). Everything else in Step 5 is read-only. | VS Code | `~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json` | | Claude Code | `~/.claude/plugins/cache//jfrog//.mcp.json` (glob) | | Codex | `$CODEX_HOME/plugins/cache/codex-plugin/jfrog//.mcp.json` (glob → newest; `$CODEX_HOME` defaults to `~/.codex`) | +| OpenCode | *(no plugin-owned file — see above)* | | Kiro (IDE) | `~/.kiro/powers/installed/jfrog-kiro-power/mcp.json` (stable path) | | Kiro CLI | `~/.kiro/settings/mcp.json` — Kiro's own global MCP config, not shipped by any plugin, so the `jfrog` entry is **created or merged in** with a placeholder url, then substituted like every other row above | +| Devin | `~/.local/share/devin/cli/plugins/cache/github.com_jfrog_devin-plugin-//mcp.json` (glob → newest; the scan is restricted to slugs starting with `github.com_jfrog_devin-plugin-` so other Devin plugins that also ship an `mcp.json` can't be picked up by mistake) | + +**OpenCode's own config file** (not plugin-owned — this is the user's +personal config). The global file (item 3 below) is **always** loaded by +OpenCode; `$OPENCODE_CONFIG` / `$OPENCODE_CONFIG_DIR` each add a second +file merged on top of it — they do **not** replace it (see +[harness-opencode.md](../../jfrog-mcp-management/references/harness-opencode.md)). +`$OPENCODE_CONFIG` is honored as the write target (the file must already +exist on disk — start OpenCode once to initialize it): + +1. `$OPENCODE_CONFIG`, if set (an explicit file path override). +2. `$OPENCODE_CONFIG_DIR/opencode.json[c]`, if `$OPENCODE_CONFIG_DIR` is set. +3. `~/.config/opencode/opencode.json` (or `.jsonc`, if that's the one + that already exists) otherwise — the global file OpenCode always loads + (honors `$XDG_CONFIG_HOME`). Not a macOS/Linux-only example: neither + OpenCode nor this resolver translates it on Windows, so the literal + path there is `homedir()\.config\opencode\opencode.json` (e.g. + `C:\Users\\.config\opencode\opencode.json`) — no `%APPDATA%`. + Confirmed live on Windows: a fresh install's first run creates exactly + that path (as `opencode.jsonc`, its default first-run format). + +Whenever the write target picked from 1 or 2 above isn't the global file +itself, `jfrog-resolve-mcp-config.mjs` also returns the global file as +`layerPaths`, and `jfrog-detect-jfrog-mcp.mjs` checks it for an existing +`mcp.jfrog` entry before writing — deferring to that entry instead of +writing a second one into the override file, which OpenCode's merge would +otherwise let shadow it. + +Project-scope `opencode.json` (in the project root) is deliberately +**never** used — a project config is often committed to git, and writing +a `jfrog` MCP entry into a file the user might share is a different +action than writing to a personal, git-ignored config. The Kiro CLI merge is additive and never destructive: the file normally holds the user's other MCP servers, so a `jfrog` entry that already has a @@ -68,28 +152,33 @@ stays a symlink, and a file that isn't valid JSON is reported rather than rewritten. Harness detection (in priority order): `CODEX_SANDBOX` / `CLAUDECODE` / -`CURSOR_TRACE_ID` / `VSCODE_PID` / `TERM_PROGRAM`. Override with -`JFROG_INIT_HARNESS=claude|cursor|vscode|codex|kiro|kiro-cli` or a -specific file via `JFROG_INIT_MCP_CONFIG=/abs/path`. Neither Kiro target -has an auto-detect signal yet — both are reachable only via the -`JFROG_INIT_HARNESS=kiro` / `kiro-cli` overrides. +`CURSOR_TRACE_ID` / `OPENCODE` / `OPENCODE_SESSION_ID` / `VSCODE_PID` / +`TERM_PROGRAM`. Override with +`JFROG_INIT_HARNESS=claude|cursor|vscode|codex|opencode|kiro|kiro-cli|devin` +or a specific file via `JFROG_INIT_MCP_CONFIG=/abs/path`. Kiro / Kiro CLI / +Devin have no auto-detect signal, and the Copilot extension runtime +may sanitize VS Code's env vars from the plugin subprocess — all four +are reachable via the matching `JFROG_INIT_HARNESS=...` override, +exported from Step 5 in `SKILL.md`. `SKILL.md`'s Step 5 already has you export `JFROG_INIT_HARNESS=kiro` / -`kiro-cli` up front when you're running as one of those two — before -the detector ever runs, so Exit 3 below isn't the trigger for it. +`kiro-cli` / `vscode` / `devin` up front when you're running as one of +those targets — before the detector ever runs, so Exit 3 below isn't +the trigger for it. **What the detector verifies** (three things): -1. Plugin file exists and is non-empty at its harness-specific path. +1. Config file exists and is non-empty at its harness-specific path. 2. Parses as valid JSON. -3. Contains a `jfrog` entry (nested under `mcpServers` on every harness - but Codex, which has no wrapper) with a non-empty `url`. +3. Contains a `jfrog` entry (`mcpServers.jfrog` on Cursor/VS Code/Claude + Code, bare top-level `jfrog` on Codex, `mcp.jfrog` on OpenCode) with + a non-empty `url`. An entry that exists but has a missing or empty + `url` also causes a red — fix or remove the entry and re-run. It does NOT enforce any other `type`/`url` shape (each plugin owns its -own schema) and it does NOT probe the endpoint — a mis-configured MCP -endpoint surfaces immediately the first time the user invokes it, and -the walk's other network checks (Steps 4, 7) already prove the JPD is -reachable. +own schema), and does NOT probe the endpoint — whether the server is +actually enabled on the JPD is a separate, network check (see +"MCP-responding probe" below). **Step 5 branches, required behavior:** @@ -97,14 +186,14 @@ reachable. - **Exit 1 (red)** or **Exit 3 (error)** → **non-blocking** — proceed to Step 6 as if green, but remember the cause for the Final Summary. Steps 6 and 7 call the JPD's REST APIs directly with `jf config` - credentials, never through the JFrog MCP, so a broken or - missing plugin `mcp.json` doesn't affect whether those checks are - accurate — there's nothing to gain by stopping the walk over it. - Tell the red causes apart from the detector's `detail` for the - Final Summary note: - - Plugin file missing / empty / lacks a valid `jfrog` entry. Fix: - **reinstall or update the JFrog plugin.** If the user asks why or - how to fix it, run: + credentials, never through the `jfrog` MCP entry, so a broken or + missing config doesn't affect whether those checks are accurate — + there's nothing to gain by stopping the walk over it. Tell the + causes apart from the detector's `detail` for the Final Summary + note: + - **(Cursor / VS Code / Claude Code / Codex / Kiro IDE)** Plugin file + missing / empty / lacks a valid `jfrog` entry. Fix: **reinstall or + update the JFrog plugin.** If the user asks why or how to fix it, run: ```bash node "${CLAUDE_SKILL_DIR}/scripts/jfrog-reinstall-jfrog-plugin.mjs"; true @@ -112,33 +201,111 @@ reachable. and relay its per-harness remedy — it only diagnoses and prints, never writes to the plugin's mcp.json. - - Plugin file has a placeholder and automatic substitution failed - with no url set for the resolved server-id. Fix: **resolve `jf - config`**. Reinstalling the plugin does not fix this. - - Kiro CLI only: it could not create or update its own - `~/.kiro/settings/mcp.json` — no plugin ships this file, so there's - nothing to reinstall. The detail names the actual cause (e.g. the - parent path blocked by a non-directory, or a permissions error). - Fix: **correct the file or parent-directory permissions/path**, - then re-run. - - (Exit 3 only) Harness could not be detected, or plugin file is + - **(Cursor / VS Code / Claude Code)** Plugin file has a placeholder + and automatic substitution failed with no url set for the resolved + server-id. Fix: **resolve `jf config`**. Reinstalling the plugin + does not fix this. + - **(OpenCode)** Config has no `mcp.jfrog` entry and the automatic + write failed — either no `jf` server resolvable (fix: **resolve + `jf config`**) or the file isn't strict JSON (fix: **paste the + entry in manually** — run `jfrog-reinstall-jfrog-plugin.mjs` for + the exact JSON to paste and where). + - **(Kiro CLI)** Could not create or update `~/.kiro/settings/mcp.json` + — no plugin ships this file, so there's nothing to reinstall. The + detail names the actual cause. Fix: **correct the file or + parent-directory permissions/path**, then re-run. + - (Exit 3 only) Harness could not be detected, or the config file is invalid JSON / unreadable. Show the raw detector error in the note. **Do not react to this by guessing a harness or trying `JFROG_INIT_HARNESS` values to see what resolves it.** If this is - Kiro or Kiro CLI, the override was already exported before the - detector's first run (top of Step 5), so it should not reach Exit - 3 for that cause at all. Otherwise this is Exit 3, non-blocking + Kiro, Kiro CLI, or Copilot in VS Code, the override was already + exported before the detector's first run (top of Step 5), so it + should not reach Exit 3 for that cause at all. Otherwise this is + Exit 3, non-blocking like every other cause above: note it and move on to Step 6 in the same turn, with zero visible pause — do not stop to read this file or any other reference doc over it. -- **Exit 2 (`ask`)** → the one outcome that still blocks: placeholder - present, but the jf server-id is ambiguous — every step from here on - needs a resolved server-id, so there's nothing to skip ahead to. - **Stop and read `references/server-picker.md` in full**, then - re-invoke with the pick as the positional argument. +- **Exit 2 (`ask`)** → the one outcome that still blocks: a fix needs + the jf server-id (placeholder substitution on Cursor/VS Code/Claude + Code, or the initial write on OpenCode/Kiro CLI) but it's ambiguous — + every step from here on needs a resolved server-id, so there's nothing + to skip ahead to. **Stop and read `references/server-picker.md` in + full**, then re-invoke with the pick as the positional argument. **Note on Claude Code**: today the released Claude JFrog plugin does not include a `.mcp.json` in its shipped tree, so Step 5 goes red on Claude Code until the plugin ships one — this no longer stops the walk, but the Final Summary still notes it. Never fall back to project-scope `.mcp.json`. + +## Step 5b (OpenCode only) — MCP auth token + +Step 5 wires the `mcp.jfrog` *address*; OpenCode's `"type": "remote"` entry +also needs its own OAuth token before MCP tools work. That token lives in +`~/.local/share/opencode/mcp-auth.json` (honors `$XDG_DATA_HOME`), keyed +by server name (`jfrog`), managed by OpenCode — never by this skill. Same +un-translated path on Windows (`homedir()\.local\share\opencode\mcp-auth.json`, +no `%LOCALAPPDATA%`) — see the config-path note above. The parent directory +(`.local\share\opencode`) is confirmed live on Windows, holding OpenCode's +db/log/repos; the `mcp-auth.json` file itself only appears after an actual +OAuth handshake, not from an install alone, so its exact path is inferred +from source (`packages/opencode/src/mcp/auth.ts` in `anomalyco/opencode`), +not observed directly. + +A non-empty `jfrog` key in that file is **not** proof of a working credential: +OpenCode writes PKCE state there *before* the browser redirect and only adds +`tokens.accessToken` once the OAuth callback completes. A user who started +but never finished the browser login is left with a key but no token. +`jfrog-detect-opencode-mcp-auth.mjs` checks `tokens.accessToken` specifically. + +The auth command is interactive (opens a browser, blocks up to 60s) — no +non-interactive form exists. The script is read-only; the fix is always +the user running `opencode mcp auth jfrog` themselves. + +**Exit codes:** 0 = token present; 1 = missing or incomplete (tell user to +run `opencode mcp auth jfrog`, non-blocking); 3 = file unreadable/invalid +JSON (non-blocking, note in Final Summary). Never runs on Cursor/VS +Code/Claude Code — those use `jf config` credentials directly. + +## MCP-responding probe — is the server enabled on this JPD? + +The config check proves the plugin's `mcp.json` is wired up, not that a +Platform Admin has **enabled** the JFrog MCP server. An enabled endpoint is +OAuth-protected (it answers a Bearer challenge); until an admin enables it, +`/mcp` has no challenge — a bare 403 on SaaS, or 404 self-managed. +After the config check, run once: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jfrog-mcp-responding.mjs" "[server-id]"; rc=$?; true +``` + +No result here stops `/jfrog-init` — the walk always finishes, and it never +pauses for MCP sign-in mid-way; any sign-in offer waits until **after the Final +Summary**. When the config check was green, the **probe decides** the Final +Summary's **JFrog MCP Plugin** line, because it is the only signal tied to +*this* JPD. + +> Note: the JFrog MCP tools you can see may belong to a *different* JPD (you can +> be signed in to JPD-A while probing JPD-B), so they confirm sign-in only when +> they target *this* JPD's base URL, and **never** turn a `not_enabled` or +> `unreachable` probe green. + +- **Exit 4 (`not_enabled`)** → `not enabled on this JPD`, plus the fix from the + detector's `detail` (*ask your JFrog platform admin to enable it*, with the + docs link `https://docs.jfrog.com/integrations/docs/enable-the-jfrog-mcp-server`). +- **Exit 1 (`unreachable`)** → `could not confirm it's enabled`. Often transient + (proxy / VPN / timeout); re-run to recheck. +- **Exit 0 (`enabled`)** → this JPD's MCP is on. Read sign-in from your session's + JFrog MCP tools (they count only for *this* JPD — see the note above): + - **Tools available and for this JPD** (same base URL) → green (signed in). + - **Not available, `needsAuth`, or not confirmably this JPD** → the summary + line is `enabled — sign in to use it`. Step 5 is non-blocking, so do **not** + open the browser here — offer to sign in only **after the Final Summary** + (the walk's one browser action, and its last). If the user accepts, trigger + the JFrog MCP's own sign-in through your harness's MCP auth, then follow up: + tools now visible → connected; still not visible → reload the window to load + them (expected — don't ask an already signed-in user to sign in again). + +The result is in `jfrog-detect-all.mjs`'s summary as `mcpResponding` +(with `mcpRespondingReason` on the non-green cases). The auth-status read +above is an agent-level check, not part of that script's output. diff --git a/skills/jfrog-init/references/out-of-scope.md b/skills/jfrog-init/references/out-of-scope.md new file mode 100644 index 0000000..b99a242 --- /dev/null +++ b/skills/jfrog-init/references/out-of-scope.md @@ -0,0 +1,24 @@ +# Non-goals (out of scope for this skill) + +- Installing the JFrog IDE plugin, or replacing its auto-config. +- Installing the VS Code hook. +- A first-MCP wizard for an empty catalog. +- Persisting the picked **project key** to `JF_PROJECT` or any shell + profile. Step 6 asks every walk and threads the pick forward as a + positional argument only — nothing about project selection ever + touches a shell profile. (Two other, unrelated things in this walk + *do*: Step 1's `nvm`-based Node install, and Step 2's Plan C fallback + when npm itself isn't usable — both append one PATH line to the + user's shell rc file, disclosed up front in the install consent + prompts, see `node-install-prompt.md` / `jf-cli-install-prompt.md`. + Plans A/B of Step 2 — the common case — don't touch a shell profile + at all, relying on npm's own global bin directory instead.) +- Granting AI Catalog roles/permissions — Step 7 only instructs. +- Storing access tokens to disk, logging them, or printing them. + Step 4's authenticated check keeps the credential inside `jf`'s own + process (`jf rt ping`); Steps 6 and 7 extract it from `jf config + export` only in memory, for one `fetch` call. Step 3/4's token-based + `jf config` path (see `references/jf-config-auth-picker.md`) never + touches this skill or the model at all — the user runs that command + themselves. **Step 8 is the one deliberate exception** — it writes + the token to `~/.netrc`; see `references/marketplace-setup.md`. diff --git a/skills/jfrog-init/references/script-invocation.md b/skills/jfrog-init/references/script-invocation.md index e6595d4..9875f6e 100644 --- a/skills/jfrog-init/references/script-invocation.md +++ b/skills/jfrog-init/references/script-invocation.md @@ -40,13 +40,40 @@ node --version; true npx --version; true ``` +## PowerShell hosts + +Some harnesses run Bash-tool commands through native PowerShell, not a +POSIX shell (OpenCode on Windows is one) — there, `$?` isn't a numeric +exit code and bare `true` doesn't exist, so `; rc=$?; true` throws +`CommandNotFoundException`. Use the PowerShell equivalent instead: + +```powershell +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"; $rc=$LASTEXITCODE; exit 0 +``` + +`$LASTEXITCODE` is PowerShell's `$?`; `exit 0` plays `true`'s role. +Swap this suffix in for every detector call when the Bash tool is +PowerShell-backed — this is a shell property, not an OS one (Claude +Code's Bash tool is a real POSIX shell even on Windows). + +The bare-`; true` commands above (`node --version`, `npx --version`, and +`jfrog-reinstall-jfrog-plugin.mjs`) hit the same `true`-doesn't-exist +problem, since nothing there needs `$rc` captured — swap in a bare `; +exit 0` instead: + +```powershell +node --version; exit 0 +npx --version; exit 0 +``` + ## What's deliberately not pre-approved -`allowed-tools` in `SKILL.md` covers `node --version`, the six read-only +`allowed-tools` in `SKILL.md` covers `node --version`, the seven read-only detectors named individually — `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs"`, `jfrog-detect-jf-cli.mjs`, `jfrog-detect-jf-config.mjs`, -`jfrog-detect-jfrog-mcp.mjs`, `jfrog-detect-project.mjs`, and +`jfrog-detect-jfrog-mcp.mjs`, `jfrog-detect-jfrog-mcp-responding.mjs`, +`jfrog-detect-opencode-mcp-auth.mjs`, `jfrog-detect-project.mjs`, and `jfrog-detect-server-ping.mjs` — and `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-re*.mjs"` (the purely diagnostic `jfrog-reinstall-jfrog-plugin.mjs` and the two `jfrog-resolve-*.mjs` @@ -70,18 +97,18 @@ lookups), `node itself writes `~/.jfrog/setup.json` on overall green (see the Final summary in `SKILL.md`), the same mutation `jfrog-state-file.mjs set` is excluded below for. PR review caught this; the fix was to enumerate - the six read-only detectors by exact filename instead of a wildcard, + the seven read-only detectors by exact filename instead of a wildcard, which also closes a path-traversal-shaped concern with the wildcard form (`jfrog-detect-*.mjs` has no anchor stopping `*` from matching path separators, unlike an exact filename). -- `jfrog-substitute-mcp-placeholders.mjs` (the one script that edits the - plugin's `mcp.json` in place — see `mcp-plugin-config.md`). **Unlike - every other entry in this list, this exclusion is theoretical, not - operative**: `SKILL.md` never invokes this script as a standalone +- `jfrog-substitute-mcp-placeholders.mjs` (edits the plugin's `mcp.json` + in place — see `mcp-plugin-config.md`). **Unlike every other entry in + this list, this exclusion is theoretical, not operative**: `SKILL.md` + never invokes this script as a standalone `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-substitute-mcp-placeholders.mjs"` Bash command, so its absence from `allowed-tools` never actually gates anything. Its only real call site is the in-process import in - `jfrog-detect-jfrog-mcp.mjs` (itself one of the six explicitly-named + `jfrog-detect-jfrog-mcp.mjs` (itself one of the seven explicitly-named detectors above) — the harness's permission system approves Bash commands, not the function calls a pre-approved script makes once running, so the mutation executes with no prompt whenever Step 5 finds @@ -113,12 +140,15 @@ and web-login already sit behind their own `AskUserQuestion` consent prompt, so the user has agreed before either runs. `jfrog-install-jf-cli.mjs`, `jfrog-substitute-mcp-placeholders.mjs`, -`jfrog-state-file.mjs set`, and `jfrog-add-claude-marketplace.mjs` are -excluded for a related but distinct reason: they're the four scripts in -this directory that mutate something outside their own process (a -downloaded binary made executable and run, the plugin's `mcp.json`, the -setup state file, and `~/.netrc` plus Claude Code's own marketplace -config, respectively) rather than just reading state and emitting JSON. +`jfrog-write-opencode-mcp.mjs`, `jfrog-state-file.mjs set`, and +`jfrog-add-claude-marketplace.mjs` are excluded for a related but +distinct reason: they're the five scripts in this directory that mutate +something outside their own process (a downloaded binary made executable +and run, the plugin's `mcp.json`, the user's own OpenCode config — the +first mutation targeting a personal file rather than a plugin-owned one +— the setup state file, and `~/.netrc` plus Claude Code's own +marketplace config, respectively) rather than just reading state and +emitting JSON. A prior version of this grant covered every `*.mjs` in `scripts/` indiscriminately — PR review on this same branch pointed out that pre-approves running any of these without the model (or a @@ -153,7 +183,13 @@ either is used — so the two are guaranteed byte-for-byte identical regardless of how deep the real install path is (`~/.agents/skills/jfrog-init`, several directories deeper under a Cursor plugin cache path, a `dev/dev-symlinks.sh` dev symlink, etc.), -never something the model has to resolve itself. +never something the model has to resolve itself. On a harness that +doesn't substitute it (Cursor, Codex, OpenCode), a model that forgets +to replace it fails silently rather than loudly: both bash and +PowerShell expand an unset `${CLAUDE_SKILL_DIR}` to `""`, so the +command's path quietly collapses to a wrong one (e.g. `Cannot find +module '/scripts/jfrog-detect-jf-cli.mjs'`) instead of erroring on the +substitution itself. That guarantee is also why each pattern below anchors on a literal `node "${CLAUDE_SKILL_DIR}` immediately, e.g. `Bash(node @@ -191,18 +227,18 @@ Bash patterns are inherently fragile in general and recommend PreToolUse hooks for anything that needs a hard guarantee — not available to a skill shipped as a plain directory. Treat this anchor as a real improvement, not a proof of soundness against every possible -`node` flag combination. And treat it as Claude-Code-specific: Cursor -doesn't consult `allowed-tools` for Bash approval at all (a separate -mechanism, `.cursor/cli.json`'s own `Shell(...)` rules), so every -command in this file still raises its own prompt there regardless of -how this pattern is written. +`node` flag combination. And treat it as Claude-Code-specific: neither +Cursor nor Codex consults `allowed-tools` for Bash approval at all +(Cursor has its own separate mechanism, `.cursor/cli.json`'s +`Shell(...)` rules), so every command in this file still raises its +own prompt there regardless of how this pattern is written. So expect the harness to raise its own approval prompt for every case -listed at the top of this section — **except `jfrog-substitute-mcp-placeholders.mjs`**, -whose mutation runs unattended via the in-process call from -`jfrog-detect-jfrog-mcp.mjs` as documented above. Both outcomes are -intended. Do not treat either as a misconfiguration, and do not suggest -widening `allowed-tools` to silence the prompts, or adding a standalone -`allowed-tools` entry for the substituter to "fix" its silence — that -would just pre-approve a second, redundant call path into the same -mutation. +listed at the top of this section — **except `jfrog-substitute-mcp-placeholders.mjs` +and `jfrog-write-opencode-mcp.mjs`**, both of which run unattended via +in-process calls from `jfrog-detect-jfrog-mcp.mjs` as documented above. +All outcomes are intended. Do not treat any of them as a misconfiguration, +and do not suggest widening `allowed-tools` to silence the prompts, or +adding standalone `allowed-tools` entries for the substituter or writer +to "fix" their silence — that would just pre-approve a second, redundant +call path into the same mutations. diff --git a/skills/jfrog-init/scripts/jfrog-detect-all.mjs b/skills/jfrog-init/scripts/jfrog-detect-all.mjs index 4c8c1c1..b40f83b 100755 --- a/skills/jfrog-init/scripts/jfrog-detect-all.mjs +++ b/skills/jfrog-init/scripts/jfrog-detect-all.mjs @@ -18,6 +18,8 @@ // 3. jf server configured -> jfrog-detect-jf-config.mjs // 4. server reachable -> jfrog-detect-server-ping.mjs // 5. jfrog MCP -> jfrog-detect-jfrog-mcp.mjs +// 5b. OpenCode MCP auth -> jfrog-detect-opencode-mcp-auth.mjs +// (only when Step 5 is green AND detectHarness() === "opencode") // 6. project resolved -> jfrog-detect-project.mjs // 7. AI Catalog + entitled -> jfrog-detect-catalog-runtime.mjs // @@ -46,6 +48,10 @@ // still blocks — every step from here on needs a resolved server-id, // so there's nothing to skip ahead to. // +// It also probes whether the JFrog MCP server is ENABLED on the JPD (when an +// admin hasn't turned it on the endpoint has no OAuth challenge — a bare 403 +// on SaaS, or 404 self-managed) — non-blocking, via `mcpResponding`. +// // Step 6 (project) going red (no match / not entitled / ambiguous // match — exit 1 only) is ALSO non-blocking here — this script makes // exactly one resolution attempt per invocation; the interactive walk @@ -63,7 +69,8 @@ // (exit 2, "ask") still blocks, same reasoning as Step 5. // // Exit 0 -> Steps 1-4 green (see catalogEntitled / mcpConfigured / -// projectResolved for the three non-blocking gaps) +// mcpAuthed / mcpResponding / projectResolved for the five +// non-blocking gaps) // Exit 1 -> a check failed / went red / requires action import { emit, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl } from "./lib/jf.mjs"; @@ -73,6 +80,9 @@ import { detectJfCli } from "./jfrog-detect-jf-cli.mjs"; import { detectJfConfig } from "./jfrog-detect-jf-config.mjs"; import { detectServerPing } from "./jfrog-detect-server-ping.mjs"; import { detectJfrogMcp } from "./jfrog-detect-jfrog-mcp.mjs"; +import { detectHarness } from "./jfrog-resolve-mcp-config.mjs"; +import { detectOpencodeMcpAuth } from "./jfrog-detect-opencode-mcp-auth.mjs"; +import { detectJfrogMcpResponding } from "./jfrog-detect-jfrog-mcp-responding.mjs"; import { detectProject } from "./jfrog-detect-project.mjs"; import { detectCatalogRuntime } from "./jfrog-detect-catalog-runtime.mjs"; import { setStateForServer } from "./jfrog-state-file.mjs"; @@ -113,6 +123,9 @@ if (overall === 0 && (await detectServerPing(SERVER_ID)) !== 0) overall = 1; const steps1To4Passed = overall === 0; let mcpConfigured = true; +// "not_applicable" (not a boolean) — this check never runs outside +// OpenCode; only the OpenCode branch below sets "ok"/"missing". +let mcpAuthed = "not_applicable"; if (overall === 0) { const mcpCode = detectJfrogMcp(SERVER_ID); if (mcpCode === 2) { @@ -121,6 +134,25 @@ if (overall === 0) { overall = 1; } else if (mcpCode !== 0) { mcpConfigured = false; + } else if (detectHarness() === "opencode") { + // Step 5b: OpenCode's `mcp.jfrog` entry also needs its own OAuth + // token before MCP tools work — only checked here (never blocks) + // once Step 5 itself has come back green. + mcpAuthed = detectOpencodeMcpAuth() === 0 ? "ok" : "missing"; + } +} + +// Is the JFrog MCP server enabled on this JPD? (403/404 = an admin hasn't.) +let mcpResponding = true; +let mcpRespondingReason; +if (overall === 0) { + const respCode = await detectJfrogMcpResponding(SERVER_ID); + if (respCode === 4) { + mcpResponding = false; + mcpRespondingReason = "not_enabled"; + } else if (respCode !== 0) { + mcpResponding = false; + mcpRespondingReason = "unreachable"; } } @@ -224,6 +256,9 @@ if (overall === 0) { catalogEntitled, ...(catalogReason ? { catalogReason } : {}), mcpConfigured, + mcpAuthed, + mcpResponding, + ...(mcpRespondingReason ? { mcpRespondingReason } : {}), projectResolved, }) ); diff --git a/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp-responding.mjs b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp-responding.mjs new file mode 100644 index 0000000..7eb12e1 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp-responding.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Is the JFrog MCP server enabled on this JPD? One anonymous GET to /mcp +// (URL from `jf config`). An enabled endpoint is OAuth-protected, so it answers +// with a "Bearer" auth challenge; a not-enabled one doesn't. Non-blocking. +// +// Cases: +// enabled (exit 0) server enabled (a 2xx or Bearer challenge) — NOT +// proof the user is signed in; caller checks the tools +// not_enabled (exit 4) admin hasn't enabled it — a 404, or a bare 403 +// unreachable (exit 1) nothing we trust as enabled — 000, 5xx, a redirect, +// a 401 with no challenge; also resolver "red" +// ask (exit 2) server-id ambiguous — see resolveServerOrEmit +// error (exit 3) jf not installed, or no URL in jf config + +import { emit, isMainModule, urlForServer, normalizeJpdUrl, anonymousFetch } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; + +const DOCS_URL = "https://docs.jfrog.com/integrations/docs/enable-the-jfrog-mcp-server"; +const HTTP_FORBIDDEN = "403"; +const HTTP_NOT_FOUND = "404"; + +export async function detectJfrogMcpResponding(serverIdArg) { + const { serverId, configList, exitCode } = resolveServerOrEmit("jfrog-mcp-responding", serverIdArg, { status: "error", exitCode: 3 }); + if (exitCode !== null) return exitCode; + + const url = normalizeJpdUrl(urlForServer(configList, serverId)); + if (!url) { + emit({ check: "jfrog-mcp-responding", status: "error", detail: `no url found in jf config for server-id=${serverId}` }); + return 3; + } + const endpoint = `${url}/mcp`; + const { status, headers } = await anonymousFetch(endpoint); + // Enabled = OAuth-protected: it returns a "Bearer" challenge (or a 2xx if + // already trusted). No challenge (a redirect, a bare 401) is not enabled. + // Match "Bearer" as a challenge SCHEME: at the start or after a comma, and + // followed by space/comma/end — so NotBearer and the param `Bearer=...` + // don't count. Strip quoted params first, so a comma inside + // realm="login, Bearer required" can't look like a separator. + const wwwAuth = (headers.get("www-authenticate") || "").replace(/"(?:[^"\\]|\\.)*"/g, ""); + const oauthChallenge = /(?:^|,)\s*bearer(?=\s|,|$)/i.test(wwwAuth); + + if (oauthChallenge || /^2/.test(status)) { + // exit 0 = server ENABLED. Deliberately NOT "green" — that only means the + // server is on, not that the user is signed in (the caller checks that). + emit({ check: "jfrog-mcp-responding", status: "enabled", detail: `JFrog MCP server enabled at ${endpoint} (HTTP ${status})` }); + return 0; + } + if (status === HTTP_NOT_FOUND || status === HTTP_FORBIDDEN) { + emit({ check: "jfrog-mcp-responding", status: "not_enabled", detail: `JFrog MCP server not enabled at ${endpoint} (HTTP ${status}) — ask your JFrog platform administrator to enable it (see ${DOCS_URL}).` }); + return 4; + } + emit({ check: "jfrog-mcp-responding", status: "unreachable", detail: `could not confirm the JFrog MCP server at ${endpoint} (HTTP ${status})` }); + return 1; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = await detectJfrogMcpResponding(process.argv[2]); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs index 35f1351..2cdebb9 100755 --- a/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs +++ b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs @@ -1,61 +1,51 @@ #!/usr/bin/env node -// Verifies the JFrog PLUGIN'S OWN mcp.json (per harness) exists at its -// installed path AND contains a jfrog entry (under mcpServers.jfrog on every -// harness but Codex, which has a bare top-level jfrog key). This file is -// owned by the plugin — we NEVER write to it, with one exception: -// automatic placeholder substitution (see jfrog-substitute-mcp-placeholders.mjs). -// If it's missing, malformed, or lacks the jfrog entry, the correct fix -// is "reinstall or update the JFrog plugin". +// Verifies the JFrog jfrog entry exists in the current harness's MCP +// config AND has a non-empty url. For Cursor/VS Code/Claude/Codex this is +// the plugin's own mcp.json, never written to except for placeholder +// substitution (see jfrog-substitute-mcp-placeholders.mjs); OpenCode has +// no plugin-owned config, so a missing entry is written via +// jfrog-write-opencode-mcp.mjs instead. // // NO endpoint reachability probe — this is a pure "is the plugin // configured?" check. The walk's other network checks already prove the // JPD is reachable, and a dead endpoint surfaces immediately the first // time the user invokes the MCP. // -// Idempotent, read-only, zero mutation (aside from the placeholder fix). +// Idempotent, read-only, zero mutation (aside from the placeholder fix and +// the OpenCode write, both of which are themselves idempotent). // // Usage: node jfrog-detect-jfrog-mcp.mjs [server-id] // -// [server-id] is forwarded as-is to jfrog-substitute-mcp-placeholders.mjs -// so the placeholder fix reuses the same server the caller already -// resolved (e.g. in Step 4), instead of re-resolving from scratch. +// [server-id] is forwarded to the fixer so it reuses the server resolved in Step 4. // // Exit 0 -> green (plugin entry present) // Exit 1 -> red (plugin file missing/empty/not installed, or missing jfrog entry) -// Exit 2 -> ask (placeholder present but the jf server-id is ambiguous — -// caller must prompt from `candidates` and re-invoke) +// Exit 2 -> ask (placeholder present, or OpenCode entry missing, but the jf +// server-id is ambiguous — caller must prompt from +// `candidates` and re-invoke) // Exit 3 -> error (harness could not be detected, plugin mcp.json is invalid // JSON, or the file could not be read) import { readFileSync, statSync } from "node:fs"; -import { emit as emitJf, hasMcpPlaceholder, isMainModule, jfrogMcpUrl } from "./lib/jf.mjs"; +import { emit as emitJf, hasMcpPlaceholder, isMainModule, jfrogMcpEntry, jfrogMcpUrl } from "./lib/jf.mjs"; import { resolveMcpConfig } from "./jfrog-resolve-mcp-config.mjs"; import { substituteMcpPlaceholders } from "./jfrog-substitute-mcp-placeholders.mjs"; +import { writeOpencodeMcp } from "./jfrog-write-opencode-mcp.mjs"; function emit(status, file, detail, extra = {}) { emitJf({ check: "jfrog-mcp", status, file, detail, ...extra }); } -// Surfaces the substituter's own failure detail (ambiguous server w/ -// candidates, no url set in jf config, or a read/write error) instead of -// one hardcoded message, so the user is pointed at the actual cause -// instead of always being told to check the JPD URL even when the real -// issue is an ambiguous server-id. -function substituterFailureDetail(result) { +// Extracts the failure reason from a fixer result, appending candidates when +// the server is ambiguous. Shared by substituteMcpPlaceholders / writeOpencodeMcp. +function fixerFailureDetail(result) { return Array.isArray(result.candidates) && result.candidates.length ? `${result.detail} (candidates: ${result.candidates.join(", ")})` : result.detail; } -// Exported so jfrog-detect-all.mjs can call this in-process instead of -// shelling out to a `node` subprocess and re-parsing its stdout — the -// same in-process pattern jfrog-resolve-jf-server.mjs / -// jfrog-resolve-mcp-config.mjs / jfrog-substitute-mcp-placeholders.mjs -// use. The CLI entry point below is a thin wrapper around this function. -// -// Returns the exit code rather than calling process.exit() — a forced -// exit can truncate the JSON line's stdout write if it's still draining -// through a pipe. +// Exported for in-process calls; returns exit code (process.exit() risks +// truncating a still-draining stdout pipe). export function detectJfrogMcp(serverIdArg) { const SERVER_ID = serverIdArg || ""; const resolved = resolveMcpConfig(); @@ -67,7 +57,8 @@ export function detectJfrogMcp(serverIdArg) { return resolved.code === 2 ? 1 : 3; } - const target = resolved.path; + let target = resolved.path; + const harness = resolved.harness; // A single guarded stat instead of existsSync()+statSync() — two // separate calls leave a TOCTOU window where the file can vanish @@ -80,7 +71,8 @@ export function detectJfrogMcp(serverIdArg) { size = 0; } if (size === 0) { - emit("red", target, "plugin mcp.json is missing or empty — reinstall or update the JFrog plugin"); + const detail = harness === "opencode" ? "OpenCode config file is empty — add the mcp.jfrog entry manually" : "plugin mcp.json is missing or empty — reinstall or update the JFrog plugin"; + emit("red", target, detail); return 1; } @@ -95,13 +87,37 @@ export function detectJfrogMcp(serverIdArg) { try { parsed = JSON.parse(raw); } catch { - emit("error", target, "plugin mcp.json is not valid JSON — reinstall or update the JFrog plugin"); + // OpenCode's config may be user-authored .jsonc — a parse failure + // there isn't "reinstall the plugin" (no plugin file exists). + const detail = + harness === "opencode" + ? "OpenCode config is not valid JSON (likely .jsonc with comments) — add the mcp.jfrog entry manually" + : "plugin mcp.json is not valid JSON — reinstall or update the JFrog plugin"; + emit("error", target, detail); return 3; } - // Substitute a placeholder in the jfrog entry's own url with the real - // JPD URL from `jf config` — checked there specifically, not the raw - // file text, so an unrelated MCP entry can't trigger it. + // The global file may already have an entry that this layer would + // otherwise shadow — defer to it if so (see resolveMcpConfig()). + if (harness === "opencode" && jfrogMcpEntry(parsed) === null) { + for (const layerPath of resolved.layerPaths || []) { + let layerParsed; + try { + layerParsed = JSON.parse(readFileSync(layerPath, "utf8")); + } catch { + continue; + } + if (jfrogMcpEntry(layerParsed) !== null) { + target = layerPath; + parsed = layerParsed; + break; + } + } + } + + // Auto-substitute any ${JFROG_PLATFORM_URL}/${JFROG_URL} placeholder with + // the real JPD URL — checked against the jfrog entry's own url, not the + // raw file text, so an unrelated MCP entry can't trigger it. const preSubstitutionUrl = jfrogMcpUrl(parsed); if (typeof preSubstitutionUrl === "string" && hasMcpPlaceholder(preSubstitutionUrl)) { const result = substituteMcpPlaceholders(target, SERVER_ID); @@ -117,7 +133,7 @@ export function detectJfrogMcp(serverIdArg) { // instead of collapsing both into red — Step 5 in SKILL.md relies on // that distinction to pick the right Final Summary wording. const status = result.status === "error" ? "error" : "red"; - emit(status, target, `plugin mcp.json contains a JFROG_PLATFORM_URL placeholder and automatic substitution failed — ${substituterFailureDetail(result)}`); + emit(status, target, `plugin mcp.json contains a JFROG_PLATFORM_URL placeholder and automatic substitution failed — ${fixerFailureDetail(result)}`); return status === "error" ? 3 : 1; } try { @@ -126,19 +142,44 @@ export function detectJfrogMcp(serverIdArg) { emit("error", target, `substitution succeeded but re-reading ${target} failed: ${err.message}`); return 3; } + } else if (preSubstitutionUrl === null && harness === "opencode" && !process.env.JFROG_INIT_MCP_CONFIG) { + // No mcp.jfrog entry at all — OpenCode's expected steady state until + // /jfrog-init writes one, sourced from jf config. + const result = writeOpencodeMcp(target, SERVER_ID); + if (result.exitCode === 2) { + emit("ask", target, result.detail, { unresolved: "server", candidates: result.candidates }); + return 2; + } + if (result.exitCode !== 0) { + const status = result.status === "error" ? "error" : "red"; + emit(status, target, `OpenCode config has no mcp.jfrog entry and writing one failed — ${fixerFailureDetail(result)}`); + return status === "error" ? 3 : 1; + } + try { + parsed = JSON.parse(readFileSync(target, "utf8")); + } catch (err) { + emit("error", target, `write succeeded but re-reading ${target} failed: ${err.message}`); + return 3; + } } const url = jfrogMcpUrl(parsed); const hasUrl = typeof url === "string" && url.trim() !== ""; if (!hasUrl) { - emit("red", target, "plugin mcp.json has no valid jfrog entry (missing or empty url) — reinstall or update the JFrog plugin"); + const detail = + harness === "opencode" + ? "no usable mcp.jfrog url — add or fix the entry and re-run /jfrog-init" + : "plugin mcp.json has no valid jfrog entry (missing or empty url) — reinstall or update the JFrog plugin"; + emit("red", target, detail); return 1; } - emit("green", target, "plugin mcp.json present with a jfrog entry"); + const detail = harness === "opencode" ? "OpenCode config present with mcp.jfrog entry" : "plugin mcp.json present with a jfrog entry"; + emit("green", target, detail); return 0; } if (isMainModule(import.meta.url)) { process.exitCode = detectJfrogMcp(process.argv[2]); } + diff --git a/skills/jfrog-init/scripts/jfrog-detect-opencode-mcp-auth.mjs b/skills/jfrog-init/scripts/jfrog-detect-opencode-mcp-auth.mjs new file mode 100755 index 0000000..96a6936 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-opencode-mcp-auth.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Checks ~/.local/share/opencode/mcp-auth.json for a completed jfrog OAuth +// token (tokens.accessToken non-empty). Read-only — never runs the interactive +// `opencode mcp auth jfrog` command itself. OpenCode-only; other harnesses +// use agent-guard, which reads jf config credentials directly. +// +// Path and shape are reverse-engineered from a real OpenCode install, not +// its source — a future restructure would silently read as "not authenticated". +// +// Exit 0 -> green (tokens.accessToken present) +// Exit 1 -> red (file/entry missing, or OAuth flow started but not finished) +// Exit 3 -> error (file unreadable or not valid JSON) + +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { emit as emitJf, isMainModule } from "./lib/jf.mjs"; + +export function resolveOpencodeMcpAuthPath() { + const dataBase = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(dataBase, "opencode", "mcp-auth.json"); +} + +function emit(status, file, detail) { + emitJf({ check: "opencode-mcp-auth", status, file, detail }); +} + +export function detectOpencodeMcpAuth() { + const target = resolveOpencodeMcpAuthPath(); + + let size; + try { size = statSync(target).size; } catch (err) { + if (err.code !== "ENOENT") { + emit("error", target, `could not stat ${target}: ${err.message}`); + return 3; + } + size = 0; + } + if (size === 0) { + emit("red", target, "no OpenCode MCP auth store yet — run `opencode mcp auth jfrog`"); + return 1; + } + + let raw; + try { + raw = readFileSync(target, "utf8"); + } catch (err) { + emit("error", target, `could not read ${target}: ${err.message}`); + return 3; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + emit("error", target, `${target} is not valid JSON`); + return 3; + } + + // A jfrog key with only PKCE state (clientInfo, codeVerifier, oauthState) + // means OAuth was started but never completed — only tokens.accessToken + // being non-empty means the handshake actually finished. + const entry = parsed?.jfrog; + const accessToken = entry !== null && typeof entry === "object" ? entry?.tokens?.accessToken : undefined; + const hasToken = typeof accessToken === "string" && accessToken.trim() !== ""; + if (!hasToken) { + const detail = + entry !== null && typeof entry === "object" && !Array.isArray(entry) && Object.keys(entry).length > 0 + ? "jfrog entry exists but has no access token (OAuth flow started but never completed) — run `opencode mcp auth jfrog`" + : "no jfrog entry in OpenCode MCP auth store — run `opencode mcp auth jfrog`"; + emit("red", target, detail); + return 1; + } + + emit("green", target, "OpenCode MCP auth store has a jfrog access token"); + return 0; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = detectOpencodeMcpAuth(); +} diff --git a/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs index 30a52ca..bc439fe 100755 --- a/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs +++ b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs @@ -9,6 +9,9 @@ // in this skill that does edit the file in place; this script is only // reached when that auto-fix isn't applicable.) // +// OpenCode has no plugin-owned mcp.json — remedy differs: add the JFrog +// plugin or paste the mcp.jfrog entry manually (see mcp-plugin-config.md). +// // Usage: node jfrog-reinstall-jfrog-plugin.mjs // Always exits 0 after printing. @@ -25,7 +28,31 @@ const resolved = ? { path: join(homedir(), ".kiro", "settings", "mcp.json") } : resolveMcpConfig(); -console.log(`The JFrog MCP entry lives inside the JFrog plugin's own mcp.json file. +if (harness === "opencode") { + console.log(`OpenCode has no plugin-owned mcp.json — its JFrog plugin injects the +mcp.jfrog entry into OpenCode's own config at startup. /jfrog-init writes +that entry directly into your opencode.json[c] instead, sourced from +\`jf config\`. + +If /jfrog-init sent you here, either: + +1. The JFrog OpenCode plugin itself isn't installed yet. Add it to your + opencode.json's "plugin" array: + + {"plugin": ["@jfrog/opencode-jfrog-plugin"]} + + Restart OpenCode, then re-run /jfrog-init. + +2. Your opencode.json[c] has no mcp.jfrog entry and /jfrog-init could not + write one automatically (most often because the file has comments and + isn't strict JSON). Add this yourself, using the real URL of your JPD: + + {"mcp": {"jfrog": {"type": "remote", "url": "https:///mcp", "enabled": true}}} + + Restart OpenCode, then re-run /jfrog-init. +`); +} else { + console.log(`The JFrog MCP entry lives inside the JFrog plugin's own mcp.json file. This script never writes to it — it only diagnoses and prints the fix. If /jfrog-init sent you here, the plugin's mcp.json is missing, empty, @@ -33,36 +60,36 @@ or otherwise invalid, and the fix is to reinstall or update the JFrog plugin in your IDE. `); -switch (harness) { - case "claude": - console.log(`Claude Code: + switch (harness) { + case "claude": + console.log(`Claude Code: claude plugin uninstall jfrog-beta/jfrog # if already installed claude plugin install jfrog-beta/jfrog After install, restart Claude Code, then re-run /jfrog-init.`); - break; - case "cursor": - console.log(`Cursor: + break; + case "cursor": + console.log(`Cursor: Open Cursor → Settings → Plugins (or Extensions) → search "JFrog" → Uninstall (if present) → Install. Restart Cursor. Then re-run /jfrog-init.`); - break; - case "vscode": - console.log(`VS Code: + break; + case "vscode": + console.log(`VS Code: code --uninstall-extension JFrog.jfrog-vscode-extension || true code --install-extension JFrog.jfrog-vscode-extension --force Restart VS Code, then re-run /jfrog-init.`); - break; - case "codex": - console.log(`Codex: + break; + case "codex": + console.log(`Codex: codex plugin remove jfrog@codex-plugin # if already installed codex plugin marketplace add jfrog/codex-plugin # skip if already configured codex plugin marketplace upgrade codex-plugin codex plugin add jfrog@codex-plugin Restart Codex, then re-run /jfrog-init.`); - break; + break; case "kiro": console.log(`Kiro IDE: Open the Powers panel → Add Custom Power → Import from GitHub → @@ -76,6 +103,13 @@ Restart Codex, then re-run /jfrog-init.`); If /jfrog-init reports the file is invalid, open ~/.kiro/settings/mcp.json, fix the JSON (keep the other MCP server entries), then re-run /jfrog-init.`); break; + case "devin": + console.log(`Devin CLI: + devin plugins uninstall jfrog -y # if already installed + devin plugins install jfrog/devin-plugin -y + +Restart the Devin session, then re-run /jfrog-init.`); + break; default: console.log(`Reinstall the JFrog plugin in whichever IDE you're using: Cursor: Settings → Plugins → search "JFrog" → reinstall. @@ -84,11 +118,12 @@ Restart Codex, then re-run /jfrog-init.`); Codex: codex plugin marketplace add jfrog/codex-plugin && codex plugin add jfrog@codex-plugin Kiro: Powers panel → Add Custom Power → Import from GitHub. Kiro CLI: no reinstall needed — entry is created automatically by /jfrog-init. + Devin: devin plugins install jfrog/devin-plugin -y Restart the IDE afterwards, then re-run /jfrog-init.`); -} + } -console.log(` + console.log(` Expected plugin-owned paths (for reference): Cursor: ~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json @@ -98,13 +133,18 @@ Expected plugin-owned paths (for reference): ($CODEX_HOME defaults to ~/.codex) Kiro: ~/.kiro/powers/installed/jfrog-kiro-power/mcp.json Kiro CLI: ~/.kiro/settings/mcp.json (not plugin-owned; created by /jfrog-init) + Devin: ~/.local/share/devin/cli/plugins/cache///mcp.json `); +} + +const configLabel = harness === "opencode" ? "Config" : "Plugin's mcp.json"; if (resolved.path && existsSync(resolved.path)) { - console.log(`Plugin's mcp.json currently resolves to: ${resolved.path}`); + console.log(`${configLabel} currently resolves to: ${resolved.path}`); } else if (resolved.path) { - console.log(`Plugin's mcp.json is expected at ${resolved.path}, but nothing is there right now.`); + console.log(`${configLabel} is expected at ${resolved.path}, but nothing is there right now.`); } else { - console.log("Plugin's mcp.json is not on disk right now:"); + console.log(`${configLabel} is not on disk right now:`); console.log(` ${resolved.error}`); } + diff --git a/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs index b675d76..87ed381 100755 --- a/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs +++ b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs @@ -22,6 +22,8 @@ // Kiro's own global MCP config, so the jfrog entry is // created — or merged into an existing file — with a // placeholder url. See ensureKiroCliJfrogEntry() below.) +// Devin: ~/.local/share/devin/cli/plugins/cache///mcp.json +// (glob → newest; is prefix-filtered to github.com_jfrog_devin-plugin-*.) // // NOTE (Claude): the current released Claude plugin (jfrog-beta/0.3.0-beta.1) // does NOT ship a .mcp.json — the source repo has one, but the packager @@ -29,26 +31,40 @@ // Code throws a "plugin file not installed" error, which the detector // converts into a clear red / "reinstall the JFrog plugin" instruction. // +// OpenCode has no plugin-owned mcp.json — resolves to the user's own config. +// ~/.config/opencode/opencode.json[c] is always loaded; $OPENCODE_CONFIG and +// $OPENCODE_CONFIG_DIR each ADD a second file merged on top of it, never +// replacing it (see skills/jfrog-mcp-management/references/harness-opencode.md). +// Write target, in priority order: $OPENCODE_CONFIG (must already exist), +// else $OPENCODE_CONFIG_DIR/opencode.json[c], else the global file. When the +// write target isn't the global file, resolveOpencodePath() also returns the +// global file as `layerPaths` — callers must check it for an existing +// mcp.jfrog entry before writing a shadowing duplicate. +// // Harness detection (env-var signals, in order): // 1. Codex -> $CODEX_SANDBOX / $CODEX_THREAD_ID / $CODEX_CI set // 2. Claude Code -> $CLAUDECODE / $CLAUDE_CODE_* set // 3. Cursor -> $CURSOR_AGENT / $CURSOR_CLI / $CURSOR_TRACE_ID set, // or TERM_PROGRAM=cursor -// 4. VS Code -> $VSCODE_PID set, or TERM_PROGRAM=vscode +// 4. OpenCode -> $OPENCODE / $OPENCODE_SESSION_ID set +// 5. VS Code -> $VSCODE_PID set, TERM_PROGRAM=vscode. The Copilot +// extension runtime may sanitize these from the plugin +// subprocess; in that case Copilot self-identifies via +// JFROG_INIT_HARNESS=vscode (see SKILL.md Step 5). // Codex is listed first because a Codex session launched from inside // another harness's terminal still carries that host's own signal — and -// nesting goes both ways, so more than one signal can be present at once. -// When that happens, detectHarness() below walks the process ancestry to -// find which harness actually spawned this invocation. +// nesting goes both ways (OpenCode included), so more than one signal +// can be present at once. When that happens, detectHarness() below walks +// the process ancestry to find which harness actually spawned this +// invocation. // detectHarness() is the single JS implementation — exported and reused // by every other script in this skill that needs harness information. // -// Kiro (IDE and CLI) has no detect signal yet — reachable only via the -// JFROG_INIT_HARNESS=kiro / kiro-cli overrides below. +// Kiro (IDE and CLI) and Devin have no detect signal — reachable only +// via the JFROG_INIT_HARNESS=kiro / kiro-cli / devin overrides below. // // Overrides: -// - JFROG_INIT_HARNESS=claude|cursor|vscode|codex|kiro|kiro-cli forces -// one specific harness. +// - JFROG_INIT_HARNESS forces one specific harness (see VALID_HARNESSES below). // - JFROG_INIT_MCP_CONFIG=/abs/path forces one specific path. // (Escape hatch — bypasses the plugin-path resolution entirely.) // - CODEX_HOME=/abs/path Codex's own var, honored by @@ -59,35 +75,44 @@ // Exit 0 -> path resolved // Exit 1 -> could not detect the current harness // Exit 2 -> harness detected, but the plugin's mcp.json is not installed +// (OpenCode: no config file exists yet at any of the candidate +// paths above — nothing to write the entry into.) import { execFileSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { isMainModule, jfrogMcpEntry } from "./lib/jf.mjs"; +import { isMainModule } from "./lib/jf.mjs"; -const VALID_HARNESSES = new Set(["claude", "cursor", "vscode", "codex", "kiro", "kiro-cli"]); +const VALID_HARNESSES = new Set(["claude", "cursor", "vscode", "codex", "opencode", "kiro", "kiro-cli", "devin"]); // One entry per harness, in priority order (see doc comment above) — used // both as the signal check and as the static fallback when the ancestry -// tie-break can't resolve it. Adding a harness (OpenCode, ...) is just a -// new entry here — Kiro has none yet, see the header note above. +// tie-break can't resolve it. const HARNESS_SIGNALS = [ { name: "codex", signaled: () => process.env.CODEX_SANDBOX || process.env.CODEX_THREAD_ID || process.env.CODEX_CI }, { name: "claude", signaled: () => process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.CLAUDE_CODE_SESSION_ID }, // Checked before VS Code: Cursor's CLI/agent surfaces can report TERM_PROGRAM=vscode. { name: "cursor", signaled: () => process.env.CURSOR_AGENT || process.env.CURSOR_CLI || process.env.CURSOR_TRACE_ID || process.env.TERM_PROGRAM === "cursor" }, + { name: "opencode", signaled: () => process.env.OPENCODE || process.env.OPENCODE_SESSION_ID }, + // Best-effort auto-detect for VS Code. The Copilot extension runtime may + // sanitize these env vars from the plugin subprocess; when it does, this + // row won't fire and Copilot in VS Code must self-identify via + // `JFROG_INIT_HARNESS=vscode` (see SKILL.md Step 5). { name: "vscode", signaled: () => process.env.VSCODE_PID || process.env.TERM_PROGRAM === "vscode" }, ]; -// Breaks ties when multiple harness signals fire at once: env vars are -// inherited by child processes regardless of nesting direction, so -// presence alone can't tell them apart. Walks up from the immediate -// parent — skipping the shell/node layers each harness spawns to run a -// command (e.g. codex -> bash -> claude -> bash -> node) — until a -// process name matches a candidate, or maxDepth is hit. Unix-only (ps); -// returns [] on failure (e.g. Windows), which falls through to the -// static priority order. +// Breaks ties when multiple harness signals fire at once. Walks process +// ancestry until a candidate name matches, or falls back to static priority. +// Unix-only (ps); returns [] on failure, which falls through to priority order. +// On Windows this always returns [] (no `ps`), so a nested harness there +// (e.g. OpenCode launched from inside Claude Code or Cursor, inheriting +// CLAUDECODE/CURSOR_TRACE_ID) falls through to the static order below and +// can be misidentified as claude/cursor instead of opencode — confirmed on +// a Windows box: `ps` is only a PowerShell alias for Get-Process, invisible +// to execFileSync, which throws ENOENT exactly as assumed here. Known gap; +// a real fix needs a Windows-native ancestry lookup (e.g. Win32_Process via +// Get-CimInstance), which is out of scope for this PR. function getAncestorChain(maxDepth = 12) { const chain = []; let pid = process.ppid; @@ -106,15 +131,12 @@ function getAncestorChain(maxDepth = 12) { return chain; } -// JFROG_INIT_HARNESS is matched case-insensitively so the documented -// override doesn't silently fail on a case mismatch. getAncestors is -// injectable so tests can stub the tie-break without spawning `ps`. +// JFROG_INIT_HARNESS is matched case-insensitively (e.g. "Claude", "CURSOR"). +// getAncestors is injectable so tests can stub the tie-break without spawning `ps`. export function detectHarness(getAncestors = getAncestorChain) { if (process.env.JFROG_INIT_HARNESS) return process.env.JFROG_INIT_HARNESS.trim().toLowerCase(); const candidates = HARNESS_SIGNALS.filter((h) => h.signaled()).map((h) => h.name); if (candidates.length <= 1) return candidates[0] || ""; - // Multiple signals at once means nested harnesses — resolve via - // ancestry, else fall back to the static priority order. for (const comm of getAncestors()) { const match = candidates.find((name) => comm.includes(name)); if (match) return match; @@ -122,6 +144,53 @@ export function detectHarness(getAncestors = getAncestorChain) { return candidates[0]; } +// Resolves the OpenCode config path to write to (see doc comment above). +// Honors OPENCODE_CONFIG, OPENCODE_CONFIG_DIR, and XDG_CONFIG_HOME. Prefers +// .json; falls back to .jsonc only if that already exists (OpenCode's own +// bootstrap writes .jsonc, not .json). Verified on Windows: a fresh install +// lands at C:\Users\\.config\opencode\opencode.jsonc — no +// %APPDATA%/%LOCALAPPDATA% equivalent. +export function resolveOpencodePath() { + const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } }; + const pickExtension = (dir) => { + const jsonPath = join(dir, "opencode.json"); + const jsoncPath = join(dir, "opencode.jsonc"); + return isFile(jsoncPath) && !isFile(jsonPath) ? jsoncPath : jsonPath; + }; + const globalPath = pickExtension(join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")); + + let p; + let explicitFile = false; + if (process.env.OPENCODE_CONFIG) { + p = process.env.OPENCODE_CONFIG; + explicitFile = true; + if (existsSync(p) && !statSync(p).isFile()) { + return { error: `OPENCODE_CONFIG=${p} is not a regular file`, code: 1 }; + } + } else if (process.env.OPENCODE_CONFIG_DIR) { + p = pickExtension(process.env.OPENCODE_CONFIG_DIR); + } else { + p = globalPath; + } + + if (!existsSync(p)) { + // OPENCODE_CONFIG names one exact file — OpenCode won't create it on + // startup, so the fix is pointing the var at a real file, not "start + // OpenCode". + if (explicitFile) { + return { + error: `OPENCODE_CONFIG=${p} does not exist — point it at an existing opencode.json[c], or unset it to use the default location.`, + code: 2, + }; + } + return { + error: `No OpenCode config found at ${p} — start OpenCode at least once (or create the file yourself) so there's a config to add the jfrog MCP entry to.`, + code: 2, + }; + } + return { path: p, layerPaths: p === globalPath ? [] : [globalPath] }; +} + // Picks the newest file matching `/*/<...tailParts>` by mtime. function newestMatch(dir, tailParts) { let best = null; @@ -199,6 +268,51 @@ function resolveClaudePath() { return { path: match }; } +// Flat cache — filter to our slug so other plugins' mcp.json can't +// win the newest-mtime race. +const DEVIN_JFROG_SLUG_PREFIX = "github.com_jfrog_devin-plugin-"; +function newestDevinMatch() { + const cacheDir = join(homedir(), ".local", "share", "devin", "cli", "plugins", "cache"); + let slugs; + try { + slugs = readdirSync(cacheDir, { withFileTypes: true }); + } catch { + return null; + } + let best = null; + let bestMtime = -Infinity; + for (const slug of slugs) { + if (!slug.isDirectory()) continue; + if (!slug.name.startsWith(DEVIN_JFROG_SLUG_PREFIX)) continue; + const candidate = newestMatch(join(cacheDir, slug.name), ["mcp.json"]); + if (!candidate) continue; + let mtime; + try { + mtime = statSync(candidate).mtimeMs; + } catch { + continue; + } + if (mtime > bestMtime) { + best = candidate; + bestMtime = mtime; + } + } + return best; +} + +function resolveDevinPath() { + const match = newestDevinMatch(); + if (!match) { + return { + error: + "JFrog Devin plugin does not ship an mcp.json at ~/.local/share/devin/cli/plugins/cache/github.com_jfrog_devin-plugin-*/*/mcp.json\n" + + " reinstall the JFrog plugin: devin plugins install jfrog/devin-plugin -y", + code: 2, + }; + } + return { path: match }; +} + function resolveCursorPath() { const match = newestMatch(join(homedir(), ".cursor", "plugins", "cache", "cursor-public", "jfrog"), ["mcp.json"]); if (!match) { @@ -377,8 +491,11 @@ function resolveKiroCliPath() { } export function resolveMcpConfig() { + // JFROG_INIT_MCP_CONFIG fixes the path outright, but the harness is + // still needed for OpenCode-specific wording — skip only the ancestry + // walk, not detection entirely. if (process.env.JFROG_INIT_MCP_CONFIG) { - return { path: process.env.JFROG_INIT_MCP_CONFIG }; + return { path: process.env.JFROG_INIT_MCP_CONFIG, harness: detectHarness(() => []) }; } const harness = detectHarness(); @@ -389,42 +506,48 @@ export function resolveMcpConfig() { // set the very variable they already set. if (process.env.JFROG_INIT_HARNESS && !VALID_HARNESSES.has(harness)) { return { - error: `JFROG_INIT_HARNESS=${process.env.JFROG_INIT_HARNESS} is not one of: claude, cursor, vscode, codex, kiro, kiro-cli.`, + error: `JFROG_INIT_HARNESS=${process.env.JFROG_INIT_HARNESS} is not one of: claude, cursor, vscode, codex, opencode, kiro, kiro-cli, devin.`, code: 1, + harness, }; } switch (harness) { - case "claude": - return resolveClaudePath(); - case "cursor": - return resolveCursorPath(); - case "vscode": - return resolveVscodePath(); - case "codex": - return resolveCodexPath(); - case "kiro": - return resolveKiroPath(); - case "kiro-cli": - return resolveKiroCliPath(); + case "claude": return { ...resolveClaudePath(), harness }; + case "cursor": return { ...resolveCursorPath(), harness }; + case "vscode": return { ...resolveVscodePath(), harness }; + case "codex": return { ...resolveCodexPath(), harness }; + case "opencode": return { ...resolveOpencodePath(), harness }; + case "kiro": return { ...resolveKiroPath(), harness }; + case "kiro-cli": return { ...resolveKiroCliPath(), harness }; + case "devin": return { ...resolveDevinPath(), harness }; default: return { error: - "could not detect current harness (Claude Code / Cursor / VS Code / Codex).\n" + - " Set JFROG_INIT_HARNESS=claude|cursor|vscode|codex|kiro|kiro-cli, or\n" + + "could not detect current harness (Claude Code / Cursor / VS Code / Codex / OpenCode / Devin).\n" + + " Set JFROG_INIT_HARNESS=claude|cursor|vscode|codex|opencode|kiro|kiro-cli|devin, or\n" + " JFROG_INIT_MCP_CONFIG=/absolute/path/to/mcp.json to override.", code: 1, + harness, }; } } if (isMainModule(import.meta.url)) { - const result = resolveMcpConfig(); - if (result.path) { - process.stdout.write(result.path + "\n"); - process.exitCode = 0; + // `--harness` alone skips resolveMcpConfig() entirely — those callers + // only need the harness name. Also avoids the old `node -e + // "import(...)"` form, which broke on Windows + // (ERR_UNSUPPORTED_ESM_URL_SCHEME on a raw path). + if (process.argv[2] === "--harness") { + process.stdout.write(detectHarness() + "\n"); } else { - process.stderr.write(`error: ${result.error}\n`); - process.exitCode = result.code; + const result = resolveMcpConfig(); + if (result.path) { + process.stdout.write(result.path + "\n"); + process.exitCode = 0; + } else { + process.stderr.write(`error: ${result.error}\n`); + process.exitCode = result.code; + } } } diff --git a/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs index a058e3a..915ce26 100755 --- a/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs +++ b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs @@ -1,44 +1,18 @@ #!/usr/bin/env node -// Rewrites a placeholder-style env-var reference in the JFrog plugin's -// mcp.json — specifically the `jfrog` entry's `.url` (nested under -// `mcpServers` on every harness but Codex, which has no wrapper) — with -// the real JPD URL from `jf config`. This is the ONLY code path in -// /jfrog-init that writes to the plugin-owned mcp.json. -// -// Scoped to that one field (rather than a file-wide text replace) so an -// unrelated MCP server entry or JSON value that happens to contain the -// same placeholder text is never touched. -// -// Placeholders handled (`$VAR`, `${VAR}`, and Codex's `` forms): -// - JFROG_PLATFORM_URL -// - JFROG_URL -// -// The substitution normalizes the URL to the JPD root before writing, so -// `"url": "https://${JFROG_PLATFORM_URL}/mcp"` becomes -// `"url": "https://acme.jfrog.io/mcp"` regardless of what shape `.url` -// had in `jf config`. -// -// Idempotent: no matching placeholder = no write, exit 0. Atomic: write -// goes to a temp file next to the target and is renamed into place. -// Note: rewrites via JSON.parse/stringify (2-space indent), so unrelated -// formatting in the plugin's file is not preserved byte-for-byte. -// -// Exported as substituteMcpPlaceholders() — a pure function, no stdout -// writes — so jfrog-detect-jfrog-mcp.mjs can call it in-process instead of -// shelling out to a `node` subprocess and re-parsing its stdout, the same -// in-process pattern jfrog-resolve-jf-server.mjs/jfrog-resolve-mcp-config.mjs -// use. The CLI entry point below is a thin wrapper around the same function. +// Replaces $JFROG_PLATFORM_URL / $JFROG_URL placeholders in the jfrog entry's +// url field (`mcpServers.jfrog`, bare `jfrog` on Codex, or `mcp.jfrog` on +// OpenCode) with the real JPD URL from `jf config`. Atomic write, idempotent, +// symlink-safe. // // Usage: node jfrog-substitute-mcp-placeholders.mjs [server-id] // -// Exit 0 -> substituted (or no substitution needed) -// Exit 1 -> no jf server configured, or resolved server-id has no url -// Exit 2 -> multiple jf servers configured, none marked default, no -// server-id passed — ambiguous, caller must ask the user -// Exit 3 -> read/write error, or jf missing +// Exit 0 -> substituted (or no placeholder found) +// Exit 1 -> no jf server configured, or server-id has no url +// Exit 2 -> ambiguous server — caller must ask +// Exit 3 -> read/write error or jf missing -import { existsSync, readFileSync, realpathSync, writeFileSync, renameSync, statSync, chmodSync, unlinkSync } from "node:fs"; -import { emit as emitJf, isMainModule, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl, mcpPlaceholderRegexes, jfrogMcpEntry, jfrogMcpUrl, hasMcpPlaceholder, askServerResult, describeJfUnavailable } from "./lib/jf.mjs"; +import { existsSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync, unlinkSync, realpathSync } from "node:fs"; +import { emit as emitJf, isMainModule, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl, mcpPlaceholderRegexes, jfrogMcpEntry, hasMcpPlaceholder, askServerResult, describeJfUnavailable } from "./lib/jf.mjs"; import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; // Result shape: { exitCode, status, detail, candidates? } — mirrors the @@ -67,15 +41,13 @@ export function substituteMcpPlaceholders(target, serverIdOverride) { return { exitCode: 3, status: "error", detail: "target file is not valid JSON — refusing to modify" }; } - const currentUrl = jfrogMcpUrl(parsed); + const entry = jfrogMcpEntry(parsed); + const currentUrl = entry !== null && typeof entry?.url === "string" ? entry.url : null; if (currentUrl === null) { return { exitCode: 0, status: "green", detail: "no jfrog entry url present — nothing to substitute" }; } - // Checked before resolving a jf server at all — an unresolvable/ambiguous - // server shouldn't turn a jfrog.url that has no placeholder into a red/ask - // result; there's nothing here that needs the server to fix. if (!hasMcpPlaceholder(currentUrl)) { return { exitCode: 0, status: "green", detail: "no placeholder found — nothing to substitute" }; } @@ -100,14 +72,10 @@ export function substituteMcpPlaceholders(target, serverIdOverride) { const { withScheme, bare } = mcpPlaceholderRegexes(); - // Both forms replace the full match with jpdUrl itself (which already - // carries the correct scheme) rather than preserving whatever scheme - // literally preceded the placeholder in the plugin's mcp.json — that text - // reflects the plugin's shipped template, not the real JPD's scheme. let newUrl = currentUrl.replace(withScheme, () => jpdUrl); newUrl = newUrl.replace(bare, () => jpdUrl); - jfrogMcpEntry(parsed).url = newUrl; + entry.url = newUrl; const rewritten = JSON.stringify(parsed, null, 2) + "\n"; const real = realpathSync(target); @@ -128,15 +96,7 @@ export function substituteMcpPlaceholders(target, serverIdOverride) { chmodSync(tmp, statSync(real).mode & 0o777); renameSync(tmp, real); } catch (err) { - // A run killed between the write and the rename (Ctrl-C, OOM, harness - // timeout) leaves tmp behind; the name is only unique per PID, so the - // next run to reuse that PID would otherwise hit EEXIST here forever. - // Same cleanup as jfrog-install-jf-cli.mjs's direct-download temp write. - try { - unlinkSync(tmp); - } catch { - // Never created, already renamed, or not ours to remove. - } + try { unlinkSync(tmp); } catch { /* not created, already renamed, or not ours */ } return { exitCode: 3, status: "error", detail: `could not write ${target}: ${err.message}` }; } @@ -154,8 +114,5 @@ if (isMainModule(import.meta.url)) { detail: result.detail, ...(result.candidates ? { candidates: result.candidates } : {}), }); - // Sets process.exitCode rather than calling process.exit() — a forced - // exit can truncate a still-draining stdout write, same reason every - // other script in this skill was already fixed this way. process.exitCode = result.exitCode; } diff --git a/skills/jfrog-init/scripts/jfrog-write-opencode-mcp.mjs b/skills/jfrog-init/scripts/jfrog-write-opencode-mcp.mjs new file mode 100755 index 0000000..5410901 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-write-opencode-mcp.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Writes a `mcp.jfrog` entry into the user's OpenCode config, sourced from +// `jf config`. Idempotent, atomic; refuses non-JSON files (including .jsonc). +// Exported as writeOpencodeMcp() for in-process calls. +// +// Usage: node jfrog-write-opencode-mcp.mjs [server-id] +// +// Exit 0 -> written (or already present) +// Exit 1 -> no jf server configured, or server-id has no url +// Exit 2 -> ambiguous server, no default — caller must ask +// Exit 3 -> read/write error, jf missing, or file can't take the entry + +import { existsSync, readFileSync, writeFileSync, renameSync, realpathSync, statSync, chmodSync, unlinkSync } from "node:fs"; +import { emit as emitJf, isMainModule, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl, askServerResult, describeJfUnavailable } from "./lib/jf.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; + +// Result shape: { exitCode, status, detail, candidates? } +export function writeOpencodeMcp(target, serverIdOverride) { + if (!target) { + return { exitCode: 3, status: "error", detail: "usage: jfrog-write-opencode-mcp.mjs [server-id]" }; + } + if (!existsSync(target)) { + return { exitCode: 3, status: "error", detail: "target file does not exist" }; + } + + let raw; + try { + raw = readFileSync(target, "utf8"); + } catch (err) { + return { exitCode: 3, status: "error", detail: `could not read ${target}: ${err.message}` }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { + exitCode: 3, + status: "error", + detail: "target file is not valid JSON — refusing to modify; paste the entry in manually", + }; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + exitCode: 3, + status: "error", + detail: "target file is not a JSON object — refusing to modify; paste the entry in manually", + }; + } + + // Refuse to overwrite a non-object mcp value; absent/null is creatable. + if (parsed.mcp != null && (typeof parsed.mcp !== "object" || Array.isArray(parsed.mcp))) { + return { + exitCode: 3, + status: "error", + detail: "the target file's `mcp` key is not a JSON object — refusing to modify; paste the entry in manually", + }; + } + + // Check mcp.jfrog directly — jfrogMcpEntry() prefers mcpServers over mcp, + // so a config carrying both mcpServers (for another tool) and mcp.jfrog + // (the valid jfrog entry) would cause it to return null and trigger an + // overwrite of the existing entry. Direct lookup is safe here because + // writeOpencodeMcp is OpenCode-specific; mcp.jfrog is always the target shape. + const existingEntry = parsed.mcp?.jfrog; + if (existingEntry !== null && existingEntry !== undefined && typeof existingEntry === "object" && !Array.isArray(existingEntry)) { + return { exitCode: 0, status: "green", detail: "mcp.jfrog entry already present — left untouched" }; + } + + if (!jfAvailable()) { + return { exitCode: 3, status: "error", detail: describeJfUnavailable() }; + } + const configList = jfConfigShow(); + const resolvedId = resolveJfServer(serverIdOverride, configList); + if (!resolvedId) { + if (configList.length === 0) { + return { exitCode: 1, status: "red", detail: "no jf server configured — run `jf config add --interactive`" }; + } + const ask = askServerResult("opencode-mcp-write", configList); + return { exitCode: 2, status: "ask", detail: ask.detail, candidates: ask.candidates }; + } + + const jpdUrl = normalizeJpdUrl(urlForServer(configList, resolvedId)); + if (!jpdUrl) { + return { exitCode: 1, status: "red", detail: `server-id '${resolvedId}' has no url in jf config` }; + } + + if (parsed.mcp == null) { + parsed.mcp = {}; + } + parsed.mcp.jfrog = { type: "remote", url: `${jpdUrl}/mcp`, enabled: true }; + const rewritten = JSON.stringify(parsed, null, 2) + "\n"; + + // Resolve symlinks before rename so dotfiles-managed configs stay linked. + let real; + let mode; + try { + real = realpathSync(target); + mode = statSync(real).mode & 0o777; + } catch (err) { + return { exitCode: 3, status: "error", detail: `could not resolve ${target}: ${err.message}` }; + } + + const tmp = `${real}.tmp.${process.pid}.${Date.now()}`; + try { + // "wx" refuses to overwrite a pre-planted symlink; chmod locks in the + // target's exact mode rather than relying on the umask default. + writeFileSync(tmp, rewritten, { flag: "wx", mode }); + chmodSync(tmp, mode); + renameSync(tmp, real); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + return { exitCode: 3, status: "error", detail: `could not write ${target}: ${err.message}` }; + } + + return { exitCode: 0, status: "green", detail: `wrote mcp.jfrog entry pointing at ${jpdUrl}` }; +} + +if (isMainModule(import.meta.url)) { + const TARGET = process.argv[2] || ""; + const SERVER_ID = process.argv[3] || ""; + const result = writeOpencodeMcp(TARGET, SERVER_ID); + emitJf({ + check: "opencode-mcp-write", + status: result.status, + file: TARGET, + detail: result.detail, + ...(result.candidates ? { candidates: result.candidates } : {}), + }); + process.exitCode = result.exitCode; +} diff --git a/skills/jfrog-init/scripts/lib/claude.mjs b/skills/jfrog-init/scripts/lib/claude.mjs index 6cae32d..0ae7aab 100644 --- a/skills/jfrog-init/scripts/lib/claude.mjs +++ b/skills/jfrog-init/scripts/lib/claude.mjs @@ -4,7 +4,7 @@ import { resolveCommand, runCommand } from "./command.mjs"; const CLAUDE_TIMEOUT_MS = 30_000; -const SHELL_UNSAFE = /[&|;$<>`"'\\\s]/; +export const SHELL_UNSAFE = /[&|;$<>`"'\\^\s]/; export const claude = resolveCommand("claude"); diff --git a/skills/jfrog-init/scripts/lib/command.mjs b/skills/jfrog-init/scripts/lib/command.mjs index 5799607..7e281a9 100644 --- a/skills/jfrog-init/scripts/lib/command.mjs +++ b/skills/jfrog-init/scripts/lib/command.mjs @@ -82,7 +82,18 @@ export function resolveCommand(command) { // message; execFileSync discards stdout on failure. A killed child leaves status // null and both streams empty, so the reason goes into `out`. export function runCommand({ command, target, shell }, args, { timeoutMs }) { - const result = spawnSync(target, args, { encoding: "utf8", timeout: timeoutMs, shell }); + // DEP0190 fires on every shell:true spawn with an args array. This + // function has no screening of its own — it's each caller's + // responsibility to validate its own args first (see claude.mjs's + // SHELL_UNSAFE check before calling this). Scoped to just this call. + const prevNoDeprecation = process.noDeprecation; + process.noDeprecation = true; + let result; + try { + result = spawnSync(target, args, { encoding: "utf8", timeout: timeoutMs, shell }); + } finally { + process.noDeprecation = prevNoDeprecation; + } let out = `${result.stdout || ""}${result.stderr || ""}`; if (result.error) { out += result.error.code === "ETIMEDOUT" diff --git a/skills/jfrog-init/scripts/lib/jf.mjs b/skills/jfrog-init/scripts/lib/jf.mjs index 34d3684..3a914db 100755 --- a/skills/jfrog-init/scripts/lib/jf.mjs +++ b/skills/jfrog-init/scripts/lib/jf.mjs @@ -65,25 +65,40 @@ export function selfHealPath() { // still-in-progress prompt and misreport working credentials as broken. export const JF_CLI_TIMEOUT_MS = 30_000; +// Under `shell: true`, cmd.exe would read a metacharacter as a separator +// (`^` escapes the next character). Tolerates a space — unlike claude.mjs's +// SHELL_UNSAFE, a `jf` arg (e.g. a server ID) may legitimately contain one. +export const JF_ARG_UNSAFE = /[&|;$<>`"'\\^\n]/; + // The one place every `jf` spawn goes through. `timeoutMs` overrides the // local-operation default for a network-bound caller. export function runJf(args, { timeoutMs = JF_CLI_TIMEOUT_MS } = {}) { selfHealPath(); const { target, shell } = resolveCommand("jf"); - // Under `shell: true`, cmd.exe would read a metacharacter as a separator. if (shell) { - const unsafe = args.find((a) => /[&|;$<>`"'\\\n]/.test(a)); + const unsafe = args.find((a) => JF_ARG_UNSAFE.test(a)); if (unsafe !== undefined) { throw new Error(`runJf: refusing shell-unsafe argument: ${JSON.stringify(unsafe)}`); } } // Without this, execFileSync forwards jf's stderr to ours. - return execFileSync(target, args, { - encoding: "utf8", - timeout: timeoutMs, - shell, - stdio: ["ignore", "pipe", "pipe"], - }); + // Node's DEP0190 fires on every shell:true spawn with an args array, + // warning generically about unescaped-argument injection — the exact + // risk the unsafe-character check above already closes off. Scoped to + // just this call (saved/restored) so it doesn't mask an unrelated + // deprecation warning elsewhere in the same process. + const prevNoDeprecation = process.noDeprecation; + process.noDeprecation = true; + try { + return execFileSync(target, args, { + encoding: "utf8", + timeout: timeoutMs, + shell, + stdio: ["ignore", "pipe", "pipe"], + }); + } finally { + process.noDeprecation = prevNoDeprecation; + } } // ---- in-process memoization ---- @@ -321,22 +336,26 @@ export async function authedFetch(creds, path) { } } -// Anonymous GET with no auth, manual redirects, 10s timeout — the -// reachability probe shared by every detector that needs to know whether a -// URL is up before trying anything authenticated against it -// (jfrog-detect-server-ping.mjs, jfrog-detect-catalog-runtime.mjs's Part -// A). Never throws: any connection failure (DNS, TLS, timeout, refused) -// collapses to "000" so callers can treat that one string as the uniform -// "unreachable" case. -export async function anonymousFetchStatus(endpoint) { +// The uniform "unreachable" status: any connection failure (DNS, TLS, +// timeout, refused) collapses to this so callers can treat it as one case. +export const HTTP_UNREACHABLE = "000"; + +// Anonymous GET (no auth, manual redirects, 10s timeout) — the shared +// reachability probe. Returns status + headers; anonymousFetchStatus wraps it +// for callers that only need the status. Never throws. +export async function anonymousFetch(endpoint) { try { const res = await fetch(endpoint, { redirect: "manual", signal: AbortSignal.timeout(10_000) }); - return String(res.status); + return { status: String(res.status), headers: res.headers }; } catch { - return "000"; + return { status: HTTP_UNREACHABLE, headers: new Headers() }; } } +export async function anonymousFetchStatus(endpoint) { + return (await anonymousFetch(endpoint)).status; +} + // Node's built-in fetch does not read HTTPS_PROXY/HTTP_PROXY, so telling // the user to set them wouldn't fix anything here — point at the network // itself instead. Shared so the wording can't drift between the call @@ -398,35 +417,42 @@ export function emit(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); } -// `${VAR}`, bare `$VAR`, or Codex's `` — strict pairing so it doesn't -// also match malformed/unrelated text like `${JFROG_URL_SUFFIX}`. Shared -// by the detector and substituter so both agree on what counts as one. -const MCP_PLACEHOLDER_PATTERN = "\\$\\{(?:JFROG_PLATFORM_URL|JFROG_URL)\\}|\\$(?:JFROG_PLATFORM_URL|JFROG_URL)\\b|<(?:JFROG_PLATFORM_URL|JFROG_URL)>"; +// `${VAR}`, VS Code's `${env:VAR}`, bare `$VAR`, or Codex's `` — strict +// pairing so it doesn't also match malformed/unrelated text like +// `${JFROG_URL_SUFFIX}`. The `env:` prefix is `${...}`-only; the bare `$VAR` +// and `` forms don't take it in any harness. Shared by the detector and +// substituter so both agree on what counts as one. +const MCP_PLACEHOLDER_PATTERN = "\\$\\{(?:env:)?(?:JFROG_PLATFORM_URL|JFROG_URL)\\}|\\$(?:JFROG_PLATFORM_URL|JFROG_URL)\\b|<(?:JFROG_PLATFORM_URL|JFROG_URL)>"; export function hasMcpPlaceholder(text) { return new RegExp(MCP_PLACEHOLDER_PATTERN).test(text); } -// Every harness but Codex nests the entry under `mcpServers.jfrog`; Codex's -// plugin mcp.json has no wrapper — bare top-level `jfrog`. Branch keyed on -// the KEY's presence, not truthiness, so `{ "mcpServers": null, "jfrog": -// {...} }` isn't mistaken for Codex-shaped, and JFROG_INIT_MCP_CONFIG can -// still point at a Codex-shaped file from any harness. -// -// Returns the live entry object (mutations land on `parsed`), or null. +// Returns the live jfrog entry object (`mcpServers.jfrog`, `mcp.jfrog` on +// OpenCode, or bare `jfrog` on Codex), or null. Callers rewriting `.url` +// mutate in place; shared by the detector and substituter. export function jfrogMcpEntry(parsed) { if (parsed === null || typeof parsed !== "object") return null; - const wrapped = "mcpServers" in parsed; - const entry = wrapped ? parsed.mcpServers?.jfrog : parsed.jfrog; - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null; - if (!wrapped && !("url" in entry)) return null; - return entry; + const isValidEntry = (e) => e !== null && typeof e === "object" && !Array.isArray(e) && "url" in e; + + // Gated by which wrapper key is present — a bare top-level jfrog key + // next to an empty mcpServers/mcp is never used as a fallback. + const hasWrapper = "mcpServers" in parsed || "mcp" in parsed; + const candidates = []; + if ("mcpServers" in parsed) candidates.push(parsed.mcpServers?.jfrog); + if ("mcp" in parsed) candidates.push(parsed.mcp?.jfrog); + if (!hasWrapper) candidates.push(parsed.jfrog); + + for (const candidate of candidates) { + if (isValidEntry(candidate)) return candidate; + } + return null; } // Returns the url string (possibly empty) on a valid entry, null otherwise. export function jfrogMcpUrl(parsed) { const entry = jfrogMcpEntry(parsed); - return entry && typeof entry.url === "string" ? entry.url : null; + return entry !== null && typeof entry.url === "string" ? entry.url : null; } // Fresh RegExp instances every call — a shared module-level `g`-flagged diff --git a/skills/jfrog-mcp-management/SKILL.md b/skills/jfrog-mcp-management/SKILL.md index 7b2d630..382c8fc 100644 --- a/skills/jfrog-mcp-management/SKILL.md +++ b/skills/jfrog-mcp-management/SKILL.md @@ -37,7 +37,9 @@ the path by locating this SKILL.md file and using its parent directory. ## Runtime requirement -Node.js on PATH — `node` runs the Step 0 check, `npx` runs `@jfrog/agent-guard`. +Node.js 18+ on PATH — `node` runs the Step 0 check, `npx` runs +`@jfrog/agent-guard`. On an older runtime the Step 0 check exits with +`Unknown: requires Node.js 18 or newer`. ## Step 0: Agent Guard activation (silent, mandatory) @@ -85,8 +87,10 @@ node "/scripts/jfrog-agent-guard-check.mjs" If the first run fails with a network error, timeout, or other spurious issue (sandbox, DNS, HTTP 5xx), run the same command again with network access and use -that second exit code. Do not retry when the reason is missing credentials or -missing jf config. +that second exit code. In a sandboxed harness (e.g. Cursor, Codex) such a +failure is often the sandbox's network allowlist, not a real outage — check your +`harness-.md` reference for a sandbox/allowlist fix. Do not retry when the +reason is missing credentials or missing jf config. Interpret the exit code: @@ -109,12 +113,35 @@ Keep reading for Install / list-available only when the user explicitly asked to use Agent Guard anyway (evaluating the catalog without enabling it for the organization is legitimate). +### Exit 3 — Multiple JFrog servers, none set as default + +The gate could not tell which JPD to check: no complete `JFROG_URL`/`JF_URL` + +`JFROG_ACCESS_TOKEN`/`JF_ACCESS_TOKEN` credential pair is set in the env, +there is no default `jf` server, and two or more servers are configured. +This is NOT a disabled or unknown result — the gate just needs the user to say which server to use. + +Do this: + +1. Show the user the server ids from the gate's `AmbiguousServer:` line (they + match `jf config show`) and let the user manually select one. +2. Re-run Step 0 with the selected id as the `` argument and act on + that exit code. Exit 0 or Exit 2 → follow that section. Any other non-zero + means the id is not a configured `jf` server (a typo) or was unreachable — + tell the user it did not resolve and have them pick a valid one (or stop); do + NOT fall through to "status unknown", which would bypass Agent Guard. + +Wait for the user's selection before continuing; the gate stays ambiguous until +they choose. + +This selection is not relevant for **List → Currently installed** and **Remove**, +which touch local config only — handle those normally, with no server selection. + ### Any other non-zero exit — status unknown The check did not reach a definitive platform answer (no credentials, timeout, HTTP error, network/DNS). Treat it like Exit 2 for Install and List → Available to install (ignore those sections; keep serving the user). List → Currently -installed and Remove still proceed. +installed and Remove still proceed. (Exit 3 is handled above, not here.) Mention once — as a side note while continuing — what failed and that the user can use the `jfrog-init` command to fix any local configuration issues. Do not @@ -327,8 +354,9 @@ with `type: "http"` AND Step 4 wrote no static auth header into `env`. Skip for local MCPs and for remote MCPs whose auth comes from a static token in `env`. `--login` opens the browser, runs OAuth, caches tokens in -`~/.jfrog/jfrogmcp.conf.json`. Warn the user "I'm going to open your browser to -sign you in to ``" before: +`~/.jfrog/jfrogmcp.conf.json`. In the same turn, tell the user you are about +to open the browser to sign them in to `` and immediately run the +command — do not wait for confirmation or a follow-up prompt: ``` npx --yes \ diff --git a/skills/jfrog-mcp-management/references/agent-guard-common.md b/skills/jfrog-mcp-management/references/agent-guard-common.md index ea9ce04..3108a24 100644 --- a/skills/jfrog-mcp-management/references/agent-guard-common.md +++ b/skills/jfrog-mcp-management/references/agent-guard-common.md @@ -57,7 +57,11 @@ node "/scripts/jfrog-agent-guard-env-probe.mjs" chain: existing Agent Guard MCP entries (any harness config file per [harness-common.md](harness-common.md); `_JF_ARGS` → `project=`) → `JF_PROJECT` env var → ASK the user. If none resolves, STOP and ask — NEVER - guess, NEVER assume `default`, NEVER invent JFrog project keys. + guess, NEVER assume `default`, NEVER invent JFrog project keys. There is no + platform-wide / project-less mode: when you ask, explain a real key is + required, prompt once (you may suggest the resolved candidates), and never + offer a "no project" / "platform-wide" / "skip" option. If the key is + rejected, re-prompt once — do not fall back to a project-less call. - **`` is auto-resolvable.** This extends the base skill's [server selection rules](../../jfrog/SKILL.md#server-selection-rules-mandatory) diff --git a/skills/jfrog-mcp-management/references/harness-cursor.md b/skills/jfrog-mcp-management/references/harness-cursor.md index fc22c6e..14930d1 100644 --- a/skills/jfrog-mcp-management/references/harness-cursor.md +++ b/skills/jfrog-mcp-management/references/harness-cursor.md @@ -64,3 +64,38 @@ troubleshooting in [key-rules-and-troubleshooting.md](key-rules-and-troubleshoot Cursor has no `enabledMcpjsonServers`-style precedence files — enable/disable is the UI toggle above. OAuth `--login` in a sandbox must run with `all` permissions (see [runtime-permissions.md](runtime-permissions.md)). + +## Sandbox network allowlist (Agents Window) + +**Trigger — do not apply this by default.** Only act on this once you actually +see it: in a Cursor sandbox agent (`CURSOR_SANDBOX=seatbelt`, e.g. the Agents +Window) an Agent Guard call can't reach a JFrog host — the Step 0 activation +check can't reach the platform, and/or an `npx` / catalog call returns a `403` +on a JFrog host. That's Cursor's sandbox network allowlist. A Step 0 network +failure here is inconclusive — don't report the platform as disabled or abort. +The gate uses `jf api` for a configured server and a direct request for +environment-only credentials. After the host is allowlisted, re-run Step 0 +and follow its exit code. Do not bypass the gate. + +1. **Retry the same command once with `required_permissions: ["full_network"]`** + (see [runtime-permissions.md](runtime-permissions.md)). +2. **If it 403s again, has no effect, or the param isn't on your Shell tool + (e.g. the Agents Window), go straight to the allowlist fallback below.** Do + NOT keep retrying `full_network`, and do NOT offer to run the command outside + the sandbox or ask the user to run it in their own terminal. Create or edit + `~/.cursor/sandbox.json` (user) or `.cursor/sandbox.json` (project) + yourself, adding the blocked host to the existing `allow` array if the file + already exists (don't overwrite it), then ask the user to reload the window. + +Allow whatever host the `403` names; JFrog usually also redirects the binary +download to Amazon S3, so `*.s3.amazonaws.com` is typically needed too. SaaS: + +```json +{ "networkPolicy": { "default": "deny", "allow": ["*.jfrog.io", "*.s3.amazonaws.com"] } } +``` + +Self-hosted (platform on your own domain): + +```json +{ "networkPolicy": { "default": "deny", "allow": ["artifactory.example.com", "*.s3.amazonaws.com"] } } +``` diff --git a/skills/jfrog-mcp-management/references/key-rules-and-troubleshooting.md b/skills/jfrog-mcp-management/references/key-rules-and-troubleshooting.md index 656f715..7f837ff 100644 --- a/skills/jfrog-mcp-management/references/key-rules-and-troubleshooting.md +++ b/skills/jfrog-mcp-management/references/key-rules-and-troubleshooting.md @@ -136,7 +136,10 @@ row in [harness-common.md](harness-common.md). - **Network / proxy / DNS error** — outside the agent guard's scope; tell the user and stop. This is package-unreachable per [Classify npx @jfrog/agent-guard failures](#classify-npx-jfrogagent-guard-failures) - — never install the MCP by any other means as a workaround. + — never install the MCP by any other means as a workaround. Before stopping, + though: in a Cursor sandbox agent a `403` on a JFrog host is the sandbox + allowlist and has a concrete fix — see the "403 … in a Cursor sandbox agent" + entry below. - **npx package fetch returns 403 or 404** — usually a corporate proxy/VPN, a blocked or wrong registry, the JFrog registry being unreachable, or a curation policy — not a missing package. The default @@ -147,3 +150,10 @@ row in [harness-common.md](harness-common.md). the access token is valid for that repo). Same hard-stop rule applies: do not fall back to the usual MCP install routes that skip the approved catalog and Agent Guard as the MCP proxy. +- **403 (or a Step 0 network failure) in a Cursor sandbox agent (Agents + Window)** — Cursor's network allowlist. Retry once with + `required_permissions: ["full_network"]`; if it still 403s, has no effect, or + the param isn't on the Shell tool, create `~/.cursor/sandbox.json` (or the + project `.cursor/sandbox.json`) per the "Sandbox network allowlist" section in + [harness-cursor.md](harness-cursor.md). Do NOT keep retrying `full_network` + or punt to a manual/out-of-sandbox run. diff --git a/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-check.mjs b/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-check.mjs index ff8726f..32675ab 100644 --- a/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-check.mjs +++ b/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-check.mjs @@ -11,22 +11,58 @@ // Contract: // - exit 0 -> Agent Guard ENABLED (skill may proceed) // - exit 2 -> Agent Guard DISABLED (registry off, or _JF_AGENT_GUARD_FORCE_DISABLE) +// - exit 3 -> AMBIGUOUS: no env creds, no default jf server, but two+ +// configured servers exist. Skill lists them and asks the user +// to pick one; it must NOT auto-pick. // - exit 1 -> status UNKNOWN: no credentials, timeout, network/DNS error. // Not sure if Enabled or not. // - a single human-readable reason line is written to stdout for -// diagnostics only; Step 0 keys off the EXIT CODE, not the text. +// diagnostics only; Step 0 keys off the EXIT CODE, not the text. The +// exit-3 line ends with the candidate server ids. // // Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. -import { execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import { homedir } from "node:os"; +import { join, delimiter } from "node:path"; import process from "node:process"; +// Global `fetch` requires Node.js 18+. +const MIN_NODE_MAJOR = 18; + const SETTINGS_PATH = "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; // Self-hosted JPDs serve the same API behind `/bridge-client`. Tried ONLY // after the root path 404s, so SaaS still costs exactly one request. const BRIDGE_CLIENT_PREFIX = "/bridge-client"; -const REQUEST_TIMEOUT_MS = 5000; +// Matches the other `jf api` wrappers in this repo (slow JPD + TLS through +// a corporate proxy). Tests may override via JF_AGENT_GUARD_TIMEOUT_MS. +const REQUEST_TIMEOUT_MS = (() => { + const raw = process.env.JF_AGENT_GUARD_TIMEOUT_MS; + const n = raw ? Number(raw) : 30_000; + return Number.isFinite(n) && n > 0 ? n : 30_000; +})(); +// `jf config export` reads local state, so the network budget does not apply +// to it. Keep a floor so a short JF_AGENT_GUARD_TIMEOUT_MS (used to exercise +// request timeouts) cannot make the credential read time out instead. +const CONFIG_READ_TIMEOUT_MS = Math.max(REQUEST_TIMEOUT_MS, 10_000); +const JF_WRAPPER_SLACK_MS = 1000; +const HINT_MAX_CHARS = 160; +const JF_CREDENTIAL_ENV = [ + "JFROG_URL", + "JF_URL", + "JFROG_ACCESS_TOKEN", + "JF_ACCESS_TOKEN", + "JFROG_USER", + "JF_USER", + "JFROG_PASSWORD", + "JF_PASSWORD", +]; +const JF_HOME_BIN_DIR = join(homedir(), ".jfrog", "bin"); +const JF_HOME_BINS = ( + process.platform === "win32" ? ["jf.exe", "jf.cmd", "jf.bat"] : ["jf"] +).map((name) => join(JF_HOME_BIN_DIR, name)); const debugEnabled = process.env.JF_AGENT_GUARD_DEBUG === "true"; const debug = (message) => { @@ -60,6 +96,18 @@ const registryDisabled = (reason) => { throw GATE_DONE; }; +// Multiple configured servers, none default, no env creds: ask the user to +// pick one instead of a misleading Unknown. Ids are appended for the skill. +const ambiguousServer = (serverIds) => { + process.stdout.write( + `AmbiguousServer: multiple jf config servers exist but none is marked ` + + `default; ask the user which to use, then re-run with : ` + + `${serverIds.join(", ")}\n`, + ); + process.exitCode = 3; + throw GATE_DONE; +}; + // Exactly one positional argv[2]. Extras (argv[3..]), flags, and URLs are // ALWAYS caller bugs — stop the gate immediately so a multi-JPD setup does // not report the wrong platform's state. Everything else is treated as a @@ -97,8 +145,10 @@ function readGateServerId() { } // Resolve credentials from Path A (environment variables) or Path B -// (the default JFrog CLI configuration). Returns { baseUrl, token, source } -// or null when neither path yields a usable URL + access token. +// (the default JFrog CLI configuration). Returns +// { baseUrl, token?, user?, password?, source, serverId? } or null when +// neither path yields a usable URL plus a token or user+password. +// `serverId` is set only for Path B and is passed to `jf api --server-id`. function resolveCredentials() { const explicitServerId = readGateServerId(); // When the caller names a specific server, honor it or stop. Do not fall @@ -114,38 +164,103 @@ function resolveCredentials() { ); } - // Path A — environment variables. + // Path A — environment variables. Token or user+password, same as Path B. const envUrl = env("JFROG_URL", "JF_URL"); const envToken = env("JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); - if (envUrl && envToken) { + const envUser = env("JFROG_USER", "JF_USER"); + const envPassword = env("JFROG_PASSWORD", "JF_PASSWORD"); + if (envUrl && (envToken || (envUser && envPassword))) { debug("Using credentials from environment variables (Path A)."); - return { baseUrl: envUrl, token: envToken, source: "environment variables" }; + return { + baseUrl: envUrl, + ...(envToken ? { token: envToken } : {}), + ...(envUser && envPassword ? { user: envUser, password: envPassword } : {}), + source: "environment variables", + }; } debug( "Environment credentials incomplete; trying JFrog CLI config (Path B).", ); // Path B — default server from the local JFrog CLI configuration. - return resolveFromCliConfig(undefined); + const credsFromDefaultServer = resolveFromCliConfig(undefined); + if (credsFromDefaultServer) return credsFromDefaultServer; + + // The default export yielded no usable bearer credentials. `jf config export` + // cannot list servers, so enumerate them separately to classify the failure. + const configuredServers = listConfiguredServers(); + const serverIds = configuredServers.map((server) => server.serverId); + const hasDefaultServer = configuredServers.some((server) => server.isDefault); + + // Exactly one server: unambiguous — use it even if not marked default. + if (serverIds.length === 1) { + debug(`No default server, but exactly one configured ('${serverIds[0]}'); using it.`); + const credsFromOnlyServer = resolveFromCliConfig(serverIds[0]); + if (credsFromOnlyServer) return credsFromOnlyServer; + } + + // Two+ servers with NONE marked default: genuinely ambiguous — ask the user. + // If a default IS marked but its credentials did not resolve above, that is + // an unknown result (fall through to null), not ambiguity. + if (serverIds.length > 1 && !hasDefaultServer) { + debug(`No default server and ${serverIds.length} configured; needs user selection.`); + return { needsServerSelection: true, serverIds }; + } + return null; +} + +// Configured servers ({ serverId, isDefault }) from `jf config show +// --format=json` (needs no default, masks tokens). Returns [] when jf is +// unavailable or nothing is configured. +function listConfiguredServers() { + let showOutput; + try { + showOutput = execFileSync("jf", ["config", "show", "--format=json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug(`'jf config show --format=json' failed: ${error?.message}`); + return []; + } + let servers; + try { + servers = JSON.parse(showOutput); + } catch (error) { + debug(`Could not parse 'jf config show --format=json' output: ${error?.message}`); + return []; + } + if (!Array.isArray(servers)) return []; + return servers + .filter( + (server) => + server && typeof server.serverId === "string" && server.serverId.trim(), + ) + .map((server) => ({ + serverId: server.serverId.trim(), + isDefault: server.isDefault === true, + })); } function resolveFromCliConfig(serverId) { // `jf config export [server ID]` emits the server as a base64-encoded JSON - // blob containing url, accessToken, and serverId. An optional server ID may - // be passed; without it the CLI's default server is used. We use the CLI - // rather than reading ~/.jfrog/jfrog-cli.conf.v6 directly because newer CLIs - // do not persist the access token in that file (and the platform URL may be - // stored only as an /artifactory-suffixed URL there, which is wrong for - // /ml/core). + // blob containing url, accessToken or user+password, and serverId. An + // optional server ID may be passed; without it the CLI's default server is + // used. We use the CLI rather than reading ~/.jfrog/jfrog-cli.conf.v6 + // directly because newer CLIs do not persist the access token in that file. const exportArgs = serverId ? ["config", "export", serverId] : ["config", "export"]; let exported; try { - exported = execFileSync("jf", exportArgs, { - encoding: "utf8", + exported = runJf(exportArgs, { + timeoutMs: CONFIG_READ_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"], - timeout: 2000, }).trim(); } catch (error) { + // Do not fall back to ambient env credentials when an explicit server ID + // is unsafe for a Windows shell. That could check a different JPD than + // the one the caller requested. + if (serverId && error?.code === "EINVAL") throw error; debug( `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, ); @@ -160,81 +275,243 @@ function resolveFromCliConfig(serverId) { return null; } - // `url` is the platform/JPD root — the base the /ml/core settings path needs. - const baseUrl = cfg?.url; - const token = cfg?.accessToken; + // `url` must be the platform root for /ml/core; a server missing it + // (for example, added with --artifactory-url only) is unusable. + const baseUrl = typeof cfg?.url === "string" ? cfg.url : ""; + const token = typeof cfg?.accessToken === "string" ? cfg.accessToken : ""; + const user = typeof cfg?.user === "string" ? cfg.user : ""; + const password = typeof cfg?.password === "string" ? cfg.password : ""; if (!baseUrl) { - debug("Exported JFrog CLI config has no platform URL."); + debug( + "Exported JFrog CLI config has no platform URL; re-run /jfrog-init to configure one.", + ); return null; } - if (!token) { - debug("Exported JFrog CLI config has no access token (bearer auth needed)."); + if (!token && !(user && password)) { + debug("Exported JFrog CLI config has no access token or user+password."); return null; } - const id = cfg?.serverId ?? "default"; - return { baseUrl, token, source: `JF CLI config (server '${id}')` }; + // Only pass a real server ID through to `jf api --server-id`. The display + // fallback "default" is not a jf server name and would make jf reject the + // call as "Server ID 'default' does not exist". + const id = serverId || cfg?.serverId; + return { + baseUrl, + ...(token ? { token } : {}), + ...(user && password ? { user, password } : {}), + ...(id ? { serverId: id } : {}), + source: `JF CLI config (server '${id || "default"}')`, + }; } -/** Drops the internal `notFound` marker from a fetchSetting() result. */ -function strip({ notFound, ...result }) { - return result; +function resolveJfPath() { + const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean); + const names = + process.platform === "win32" + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((ext) => "jf" + ext.toLowerCase()) + : process.env.JF_AGENT_GUARD_TEST_CMD_SHIM === "true" + ? ["jf", "jf.cmd", "jf.bat"] + : ["jf"]; + for (const dir of dirs) { + for (const name of names) { + const full = join(dir, name); + try { + accessSync( + full, + process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK, + ); + return full; + } catch { + // keep looking + } + } + } + if (process.env.JF_AGENT_GUARD_NO_SELF_HEAL !== "true") { + for (const candidate of JF_HOME_BINS) { + try { + accessSync( + candidate, + process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK, + ); + return candidate; + } catch { + // Keep looking for a usable fixed-location fallback. + } + } + } + return ""; } -async function isGatewayPluginEnabled(baseUrl, token) { - // Normalize to the platform root: drop trailing slashes and a trailing - // `/artifactory` segment. Users commonly export JFROG_URL as - // `https://myco.jfrog.io/artifactory`, but the settings path lives under - // `/ml/core` off the platform root — without this, Path A would build - // `.../artifactory/ml/core/...` and 404 into a false "unknown" (exit 1). - const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); +function runJf(args, { timeoutMs, extraEnv, unsetEnv = [], stdio } = {}) { + const jfPath = resolveJfPath() || "jf"; + const needsShell = /\.(cmd|bat)$/i.test(jfPath); + let shellCommand; + if (needsShell) { + // cmd.exe tokenizes the joined command line. Quote the executable so a + // path with spaces (e.g. `C:\Program Files\...\jf.cmd`) stays one token, + // and reject metacharacters in both the path and the args. + // `\` is a Windows path separator — do not treat it as unsafe in jfPath. + // Args reject `%` because cmd expands `%VAR%` even inside double quotes + // (a URL like `https://jpd.example/%2F` would mutate before jf saw + // --url). Backslash is safe inside these quoted cmd.exe arguments and is + // needed for Windows client-certificate paths. + const unsafe = + /[&|;$<>`"'%^!\r\n]/.test(jfPath) || + args.some((a) => /[&|;$<>`"'%^!\r\n]/.test(a)); + if (unsafe) { + const err = new Error("refusing shell-unsafe jf argument"); + err.code = "EINVAL"; + throw err; + } + // Build one fully quoted command string. Passing an args array together + // with shell:true is deprecated in recent Node versions and would still + // rely on Node's unescaped space-joining. + shellCommand = [`"${jfPath}"`, ...args.map((a) => `"${a}"`)].join(" "); + } + const childEnv = { ...process.env }; + for (const name of unsetEnv) delete childEnv[name]; + Object.assign(childEnv, extraEnv); + const options = { + encoding: "utf8", + timeout: timeoutMs, + stdio: stdio ?? ["ignore", "pipe", "pipe"], + env: childEnv, + }; + if (needsShell) { + return execSync(shellCommand, { + ...options, + shell: + process.platform === "win32" + ? process.env.ComSpec || "cmd.exe" + : "/bin/sh", + }); + } + return execFileSync(jfPath, args, options); +} - const rootResult = await fetchSetting(root + SETTINGS_PATH, token); - if (!rootResult.notFound) return strip(rootResult); +function stripUrlUserinfo(baseUrl) { + let value = String(baseUrl || "").replace(/\/+$/, ""); + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) { + parsed.username = ""; + parsed.password = ""; + value = parsed.toString().replace(/\/+$/, ""); + } + } catch { + // Leave non-standard URL forms unchanged for jf to validate. + } + return value; +} - // Root 404 -> possibly self-hosted. Each attempt gets its OWN timeout - // budget: a reused AbortController would start the retry already spent. - debug(`Root ${SETTINGS_PATH} returned 404; retrying behind ${BRIDGE_CLIENT_PREFIX}.`); - const bridgeResult = await fetchSetting( - root + BRIDGE_CLIENT_PREFIX + SETTINGS_PATH, - token, - ); - // Bridge may only UPGRADE the verdict; anything else keeps the root result. - if (bridgeResult.ok || bridgeResult.registryOff) return strip(bridgeResult); - return strip(rootResult); +function normalizePlatformRoot(baseUrl) { + let root = stripUrlUserinfo(baseUrl); + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of ["/artifactory", "/ui"]) { + if (root.endsWith(suffix)) { + root = root.slice(0, -suffix.length); + stripped = true; + } + } + } + return root; } -// One HTTP attempt against a fully-built settings URL. `notFound` marks the -// 404 that triggers the `/bridge-client` retry; callers strip it before -// returning so the result shape main() sees is unchanged. -async function fetchSetting(url, token) { - debug(`Fetching gateway plugin setting from ${url}`); - - // Trade-off: we use a direct fetch() rather than `jf api` (the pattern other - // scripts in this repo use for authenticated JFrog REST calls) because this - // gate keys off exact HTTP status codes — 200+value:false vs 401/403 vs - // unreachable each map to a different exit code — and parsing `jf api`'s - // "[Warn] ... returned NNN" / "Http Status: NNN" stderr convention for that - // is brittle. The cost: this call does NOT inherit any corporate-proxy or - // custom-CA settings baked into the user's `jf` config, so an env that only - // works through jf's transport can surface here as an unreachable/timeout - // (exit 1). If that becomes common, switch to `jf api` and parse its status. +function sanitizeHint(hint, secrets) { + let text = String(hint || "").trim(); + for (const secret of secrets) { + if (secret) text = text.split(secret).join("[redacted]"); + } + if (text.length > HINT_MAX_CHARS) text = `${text.slice(0, HINT_MAX_CHARS - 3)}...`; + return text; +} + +function jsonFromJfStdout(stdout) { + const cleaned = String(stdout || "") + .split("\n") + .filter((line) => !line.includes("[Info]") && !line.includes("[Warn]")) + .join("\n") + .trim(); + return cleaned ? JSON.parse(cleaned) : null; +} + +// `jf api` writes `[Info] Http Status: NNN` (and on non-2xx, `[Warn] ... +// returned NNN`) to stderr. Last match wins so a retry/log line can't hide +// the actual status. 0 means "couldn't determine a status" — treat as +// unreachable, not as HTTP 0. +function parseHttpStatus(text) { + let status = 0; + for (const line of String(text || "").split("\n")) { + const http = line.match(/Http Status:\s*(\d+)/); + if (http) { + status = Number(http[1]); + continue; + } + const returned = line.match(/\breturned\s+(\d{3})\b/); + if (returned) status = Number(returned[1]); + } + return status; +} + +function jfFailure(error) { + const stdout = error?.stdout ? error.stdout.toString() : ""; + const stderr = error?.stderr + ? error.stderr.toString() + : error?.code === "EINVAL" + ? String(error.message || "") + : ""; + const timedOut = error?.code === "ETIMEDOUT" || error?.killed === true; + return { + ok: false, + stdout, + stderr, + status: parseHttpStatus(stderr) || parseHttpStatus(stdout), + timedOut, + missing: error?.code === "ENOENT", + }; +} + +function runJfApi(args, extraEnv, unsetEnv) { + try { + // jf's own --timeout is in seconds; keep the Node wrapper slightly + // longer so a slow JPD surfaces as jf's timeout, not a SIGTERM. + const stdout = runJf(["api", ...args], { + timeoutMs: REQUEST_TIMEOUT_MS + JF_WRAPPER_SLACK_MS, + extraEnv: { + ...extraEnv, + // Avoid a second proxy-sensitive wait after the settings response. + JFROG_CLI_REPORT_USAGE: "false", + }, + unsetEnv, + }); + return { ok: true, stdout, stderr: "" }; + } catch (error) { + return jfFailure(error); + } +} + +async function fetchGatewayPluginEnabled(url, creds) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const authorization = creds.token + ? `Bearer ${creds.token}` + : `Basic ${Buffer.from(`${creds.user}:${creds.password}`).toString("base64")}`; try { const response = await fetch(url, { method: "GET", headers: { Accept: "application/json", - Authorization: `Bearer ${token}`, + Authorization: authorization, }, signal: controller.signal, }); if (!response.ok) { debug(`Settings request returned HTTP ${response.status}.`); - // Non-OK (incl. 401/403) means an auth/permission/transport problem, NOT - // a deliberately-disabled registry — report unknown (exit 1) rather than - // claiming disabled. Only HTTP 200 + value:false is "disabled". return { ok: false, notFound: response.status === 404, @@ -242,53 +519,191 @@ async function fetchSetting(url, token) { }; } const data = await response.json(); - // Be tolerant about where and how the flag is carried, so a shape/casing - // change on the platform side can't turn a genuinely-enabled registry into - // a false "disabled" (exit 1). The endpoint URL already names the setting - // (`.../mcp_gateway_plugin_enabled`), so the body may arrive as any of: - // - `{ settings: { mcpGatewayPluginEnabled: } }` (wrapped); - // - the same at the top level, un-wrapped; - // - `{ value: }` (bare wrapper, key implied by the URL); - // - a bare boolean `true` / `false`. - // Casing: the path segment is snake_case while JFrog JSON bodies are - // typically camelCase — accept either. - const unwrap = (v) => - v !== null && typeof v === "object" ? v?.value : v; - const container = data?.settings ?? data; - const named = - container?.mcpGatewayPluginEnabled ?? - container?.mcp_gateway_plugin_enabled; - // `named` first (explicit key), then the bare wrapper / bare boolean forms. - const value = - typeof data === "boolean" - ? data - : named !== undefined - ? unwrap(named) - : unwrap(container); - debug(`Settings response indicates gateway plugin enabled=${value}.`); - if (value === true) return { ok: true }; - if (value === false) { + return gatewaySettingFromBody(data); + } catch (error) { + const reason = + error?.name === "AbortError" ? "timeout" : error?.message ?? "unknown error"; + debug(`Settings request failed: ${reason}`); + return { ok: false, reason: `settings endpoint unreachable (${reason})` }; + } finally { + clearTimeout(timeout); + } +} + +function gatewaySettingFromBody(data) { + // Be tolerant about where and how the flag is carried, so a shape/casing + // change on the platform side can't turn a genuinely-enabled registry into + // a false "disabled" (exit 1). The endpoint URL already names the setting + // (`.../mcp_gateway_plugin_enabled`), so the body may arrive as any of: + // - `{ settings: { mcpGatewayPluginEnabled: } }` (wrapped); + // - the same at the top level, un-wrapped; + // - `{ value: }` (bare wrapper, key implied by the URL); + // - a bare boolean `true` / `false`. + // Casing: the path segment is snake_case while JFrog JSON bodies are + // typically camelCase — accept either. + const unwrap = (v) => + v !== null && typeof v === "object" ? v?.value : v; + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + // `named` first (explicit key), then the bare wrapper / bare boolean forms. + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; +} + +/** Drops the internal `notFound` marker from a fetchSetting() result. */ +function strip({ notFound: _notFound, ...result }) { + return result; +} + +async function isGatewayPluginEnabled(creds) { + const rootResult = await fetchSetting(SETTINGS_PATH, creds); + if (!rootResult.notFound) return strip(rootResult); + + // Root 404 -> possibly self-hosted, where the same API sits behind + // `/bridge-client`. Both transports below start a fresh attempt (a new + // `jf api` process, or a new AbortController), so the retry never inherits + // a spent timeout budget. + debug( + `Root ${SETTINGS_PATH} returned 404; retrying behind ${BRIDGE_CLIENT_PREFIX}.`, + ); + const bridgeResult = await fetchSetting( + BRIDGE_CLIENT_PREFIX + SETTINGS_PATH, + creds, + ); + // Bridge may only UPGRADE the verdict; anything else keeps the root result, + // so every pre-existing reason string and exit code is untouched. + if (bridgeResult.ok || bridgeResult.registryOff) return strip(bridgeResult); + return strip(rootResult); +} + +// One attempt against `settingsPath` — over `jf api` when a server is +// selected, a direct request otherwise. `notFound` marks the 404 that +// triggers the `/bridge-client` retry; callers strip it before returning. +async function fetchSetting(settingsPath, creds) { + // Status mapping: HTTP 200 + value:false → exit 2; 401/403 / other + // non-2xx → exit 1; unreachable → exit 1. `jf api` prints + // "Http Status: NNN" on stderr and the body on stdout. + const root = normalizePlatformRoot(creds.baseUrl); + const settingsUrl = root + settingsPath; + if (!creds.serverId) { + debug(`No jf server configured; requesting ${settingsUrl} directly.`); + return fetchGatewayPluginEnabled(settingsUrl, creds); + } + debug(`Fetching gateway plugin setting via jf api (server '${creds.serverId}')`); + const call = runJfApi( + [ + "--timeout", + String(Math.ceil(REQUEST_TIMEOUT_MS / 1000)), + "--server-id", + creds.serverId, + "-H", + "Accept: application/json", + settingsPath, + ], + undefined, + // Do not let incomplete or stale ambient credentials override the + // explicitly selected server. + JF_CREDENTIAL_ENV, + ); + // A CLI older than 2.100.0 has no `jf api`: the unknown-command failure + // carries no HTTP status, so treat it like a missing CLI rather than an + // unreachable platform, and use the direct request instead. + const unsupported = + !call.ok && + !call.status && + /is not a jf command|unknown command/i.test( + `${call.stderr || ""}\n${call.stdout || ""}`, + ); + if (call.missing || unsupported) { + debug("jf unusable for 'jf api'; falling back to fetch()."); + return fetchGatewayPluginEnabled(settingsUrl, creds); + } + if (call.timedOut) { + try { + const data = jsonFromJfStdout(call.stdout); + if (data !== null) { + const completed = gatewaySettingFromBody(data); + if (completed.ok || completed.registryOff) return completed; + } + } catch { + // Partial stdout is expected when Node terminates a timed-out process. + } + debug("Settings request timed out."); + return { ok: false, reason: "settings endpoint unreachable (timeout)" }; + } + if (!call.ok) { + if (call.status) { + debug(`Settings request returned HTTP ${call.status}.`); + // Non-OK (incl. 401/403) means an auth/permission/transport problem, NOT + // a deliberately-disabled registry — stay silent (exit 1) rather than + // sending the user to IT. Only HTTP 200 + value:false is "disabled". return { ok: false, - registryOff: true, - reason: "mcp gateway plugin setting returned false", + notFound: call.status === 404, + reason: `settings endpoint returned HTTP ${call.status}`, }; } + const rawHint = (call.stderr || "") + .trim() + .split("\n") + .filter(Boolean) + .at(-1); + const hint = sanitizeHint(rawHint, [creds.token, creds.password]); + debug(`Settings request failed: ${hint || "jf api failed"}`); return { ok: false, - reason: "settings endpoint returned an invalid gateway-plugin setting", + reason: `settings endpoint unreachable (${hint || "jf api failed"})`, }; + } + + let data; + try { + data = jsonFromJfStdout(call.stdout); } catch (error) { - const reason = - error?.name === "AbortError" ? "timeout" : error?.message ?? "unknown error"; - debug(`Settings request failed: ${reason}`); - return { ok: false, reason: `settings endpoint unreachable (${reason})` }; - } finally { - clearTimeout(timeout); + debug(`Could not parse settings JSON: ${error?.message}`); + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } + if (data === null) { + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; } + return gatewaySettingFromBody(data); } async function main() { + // Fail up front on Node < 18 with an explicit reason. + const nodeMajor = Number.parseInt(process.versions.node, 10); + if (Number.isFinite(nodeMajor) && nodeMajor < MIN_NODE_MAJOR) { + unknown( + `requires Node.js ${MIN_NODE_MAJOR} or newer (detected ${process.version})`, + ); + return; + } + // Manual overrides bypass credential resolution and the network call // entirely. Checked first, in this order, so a conflicting config fails // safe (disabled) rather than silently favoring enablement. @@ -308,12 +723,16 @@ async function main() { const creds = resolveCredentials(); if (!creds) { unknown( - "JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + "JFROG_URL/JF_URL + credentials not set and no default JF CLI config found", ); return; } + if (creds.needsServerSelection) { + ambiguousServer(creds.serverIds); + return; + } - const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token); + const result = await isGatewayPluginEnabled(creds); if (result.ok) { enabled(`via ${creds.source}`); return; @@ -335,7 +754,11 @@ try { // user (the skill's Step 0 is silent). Downgrade to the safe "unknown" exit. debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); try { - unknown("unexpected error"); + unknown( + error?.code === "EINVAL" + ? "JF CLI argument rejected as shell-unsafe" + : "unexpected error", + ); } catch (halt) { if (halt !== GATE_DONE) throw halt; } diff --git a/skills/jfrog/SKILL.md b/skills/jfrog/SKILL.md index bfacd0b..23dd7c0 100644 --- a/skills/jfrog/SKILL.md +++ b/skills/jfrog/SKILL.md @@ -19,7 +19,7 @@ compatibility: >- (CLI) and Tier 3 (jf api) operations; without it, only MCP (Tier 1) is available. metadata: role: base - version: "0.36.0" + version: "0.37.0" --- # JFrog Skill