From 6f658df86bea48defda62432634defae7e9e81ce Mon Sep 17 00:00:00 2001 From: "jfrog-agentic-release-bot[bot]" <276080306+jfrog-agentic-release-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:37:26 +0000 Subject: [PATCH] [patch] chore: sync skills to v0.28.0 --- .cursor-plugin/marketplace.json | 2 +- .github/scripts/sync-skills-vendor.json | 2 +- plugins/jfrog/.cursor-plugin/plugin.json | 2 +- .../SKILL.md | 76 ++-- .../references/discovering-plugins.md | 83 +++++ .../references/discovering-skills.md | 0 .../references/installing-plugins.md | 136 +++++++ .../references/installing-skills.md | 24 +- .../references/managing-installed-plugins.md | 103 ++++++ .../references/managing-installed-skills.md | 0 .../references/publishing-plugins.md | 199 ++++++++++ .../references/publishing-skills.md | 0 plugins/jfrog/skills/jfrog-init/SKILL.md | 29 +- .../jfrog-init/references/flow-diagram.md | 5 +- .../references/marketplace-setup.md | 24 +- .../scripts/jfrog-add-claude-marketplace.mjs | 20 +- .../jfrog-init/scripts/lib/claude-config.mjs | 69 ++++ .../skills/jfrog-init/scripts/lib/netrc.mjs | 2 +- .../jfrog-setup-package-managers/SKILL.md | 31 +- .../references/workspace-binding.md | 26 +- .../scripts/merge-workspace-binding.sh | 346 ++++++++++++++++++ plugins/jfrog/skills/jfrog/SKILL.md | 21 +- .../references/jfrog-cli-install-upgrade.md | 6 +- .../skills/jfrog/scripts/check-environment.sh | 44 ++- .../jfrog/scripts/cli-newer-version-offer.sh | 52 +++ 25 files changed, 1206 insertions(+), 96 deletions(-) rename plugins/jfrog/skills/{jfrog-ai-catalog-skills => jfrog-ai-catalog}/SKILL.md (53%) create mode 100644 plugins/jfrog/skills/jfrog-ai-catalog/references/discovering-plugins.md rename plugins/jfrog/skills/{jfrog-ai-catalog-skills => jfrog-ai-catalog}/references/discovering-skills.md (100%) create mode 100644 plugins/jfrog/skills/jfrog-ai-catalog/references/installing-plugins.md rename plugins/jfrog/skills/{jfrog-ai-catalog-skills => jfrog-ai-catalog}/references/installing-skills.md (90%) create mode 100644 plugins/jfrog/skills/jfrog-ai-catalog/references/managing-installed-plugins.md rename plugins/jfrog/skills/{jfrog-ai-catalog-skills => jfrog-ai-catalog}/references/managing-installed-skills.md (100%) create mode 100644 plugins/jfrog/skills/jfrog-ai-catalog/references/publishing-plugins.md rename plugins/jfrog/skills/{jfrog-ai-catalog-skills => jfrog-ai-catalog}/references/publishing-skills.md (100%) create mode 100644 plugins/jfrog/skills/jfrog-init/scripts/lib/claude-config.mjs create mode 100755 plugins/jfrog/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh create mode 100755 plugins/jfrog/skills/jfrog/scripts/cli-newer-version-offer.sh diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index d479a13..92357cc 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.14", + "version": "0.5.15", "pluginRoot": "plugins" }, "plugins": [ diff --git a/.github/scripts/sync-skills-vendor.json b/.github/scripts/sync-skills-vendor.json index 3f2a186..2e9a74b 100644 --- a/.github/scripts/sync-skills-vendor.json +++ b/.github/scripts/sync-skills-vendor.json @@ -1,6 +1,6 @@ { "repo": "jfrog/jfrog-skills", - "pin": "v0.25.0", + "pin": "v0.28.0", "paths": [ "skills" ] diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index 04250c5..f7cf10c 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.14", + "version": "0.5.15", "description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/skills/jfrog-ai-catalog-skills/SKILL.md b/plugins/jfrog/skills/jfrog-ai-catalog/SKILL.md similarity index 53% rename from plugins/jfrog/skills/jfrog-ai-catalog-skills/SKILL.md rename to plugins/jfrog/skills/jfrog-ai-catalog/SKILL.md index f129918..fc94f85 100644 --- a/plugins/jfrog/skills/jfrog-ai-catalog-skills/SKILL.md +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-ai-catalog/references/discovering-plugins.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/discovering-plugins.md new file mode 100644 index 0000000..8aed8a3 --- /dev/null +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-ai-catalog-skills/references/discovering-skills.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/discovering-skills.md similarity index 100% rename from plugins/jfrog/skills/jfrog-ai-catalog-skills/references/discovering-skills.md rename to plugins/jfrog/skills/jfrog-ai-catalog/references/discovering-skills.md diff --git a/plugins/jfrog/skills/jfrog-ai-catalog/references/installing-plugins.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/installing-plugins.md new file mode 100644 index 0000000..88f66de --- /dev/null +++ b/plugins/jfrog/skills/jfrog-ai-catalog/references/installing-plugins.md @@ -0,0 +1,136 @@ +# 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 pass it straight +through as `--harness `. + +If `tool` is `unknown` or empty, do **not** guess — ask the user for the +desired install path and use `--path ` instead. + +If the CLI rejects the harness with `unknown agent`, fall back to asking the +user for `--path `, the same as the unknown/empty case above. + +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/plugins/jfrog/skills/jfrog-ai-catalog-skills/references/installing-skills.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/installing-skills.md similarity index 90% rename from plugins/jfrog/skills/jfrog-ai-catalog-skills/references/installing-skills.md rename to plugins/jfrog/skills/jfrog-ai-catalog/references/installing-skills.md index d591aee..5566250 100644 --- a/plugins/jfrog/skills/jfrog-ai-catalog-skills/references/installing-skills.md +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-ai-catalog/references/managing-installed-plugins.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/managing-installed-plugins.md new file mode 100644 index 0000000..f510ddc --- /dev/null +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-ai-catalog-skills/references/managing-installed-skills.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/managing-installed-skills.md similarity index 100% rename from plugins/jfrog/skills/jfrog-ai-catalog-skills/references/managing-installed-skills.md rename to plugins/jfrog/skills/jfrog-ai-catalog/references/managing-installed-skills.md diff --git a/plugins/jfrog/skills/jfrog-ai-catalog/references/publishing-plugins.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/publishing-plugins.md new file mode 100644 index 0000000..53d3ba0 --- /dev/null +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-ai-catalog-skills/references/publishing-skills.md b/plugins/jfrog/skills/jfrog-ai-catalog/references/publishing-skills.md similarity index 100% rename from plugins/jfrog/skills/jfrog-ai-catalog-skills/references/publishing-skills.md rename to plugins/jfrog/skills/jfrog-ai-catalog/references/publishing-skills.md diff --git a/plugins/jfrog/skills/jfrog-init/SKILL.md b/plugins/jfrog/skills/jfrog-init/SKILL.md index f356ba4..6fbde42 100644 --- a/plugins/jfrog/skills/jfrog-init/SKILL.md +++ b/plugins/jfrog/skills/jfrog-init/SKILL.md @@ -191,7 +191,7 @@ walk — the same logic as the Steps below, drawn as a map. 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]`, Claude Code only +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 @@ -417,11 +417,12 @@ walk. Otherwise run: ```bash -node "${CLAUDE_SKILL_DIR}/scripts/jfrog-add-claude-marketplace.mjs" "[server-id]"; rc=$?; true +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). +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 @@ -430,9 +431,8 @@ 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. Say **nothing** — - exactly as in the skip above, not even in the Final Summary, and never - volunteer which cause it was. +- **Exit 1 or 3 (red)** → non-blocking failure. Nothing beyond the Final + Summary's ⚠️ line, and never volunteer which cause it was. ## Final summary @@ -473,8 +473,8 @@ 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 it to exactly these three grouped lines — never expand back out - to five. +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!** @@ -495,19 +495,20 @@ Skip Node.js (Step 1) — implementation detail, not user-facing. semicolon: `⚠️ Project & AI Catalog — project not set up yet; catalog access not entitled`. -5. **Step 8's outcome is never a fourth checklist line** — still exactly - three grouped lines above. On Claude Code only, append one trailing - sentence after the checklist block: - - **Success** — this exact wording, do not reword it: +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 @` - - **Failed/error, or skipped** — nothing. + - **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 — these three are non-blocking by design. The +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": diff --git a/plugins/jfrog/skills/jfrog-init/references/flow-diagram.md b/plugins/jfrog/skills/jfrog-init/references/flow-diagram.md index 5467430..4a1cb54 100644 --- a/plugins/jfrog/skills/jfrog-init/references/flow-diagram.md +++ b/plugins/jfrog/skills/jfrog-init/references/flow-diagram.md @@ -100,9 +100,10 @@ flowchart TD 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["Reply: Added the JFrog marketplace ... — trailing sentence after Final Summary"]:::autoBox - S8 -->|exit 1 or 3, failed| DONE + 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 diff --git a/plugins/jfrog/skills/jfrog-init/references/marketplace-setup.md b/plugins/jfrog/skills/jfrog-init/references/marketplace-setup.md index 2ffc523..78effd4 100644 --- a/plugins/jfrog/skills/jfrog-init/references/marketplace-setup.md +++ b/plugins/jfrog/skills/jfrog-init/references/marketplace-setup.md @@ -12,13 +12,12 @@ published to Artifactory become installable via `/plugin install`. ## The `~/.netrc` write -The only place this skill itself puts a token on disk, 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 URL passed to -`claude plugin marketplace add` carries the token and Claude Code saves -that URL in its own plugin config. +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 @@ -27,8 +26,9 @@ nothing resolvable it fails red instead. ## Required branches -- **Exit 0 (green)** → success. Reply with the success sentence in - `SKILL.md`'s Final summary rule 5, verbatim. -- **Exit 1 or 3 (red), or skipped (Step 7 not green, or not Claude - Code)** → say **nothing**, exactly as if Step 8 didn't exist for this - walk. +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/plugins/jfrog/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs b/plugins/jfrog/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs index 2ef5c2a..aa56bd0 100755 --- a/plugins/jfrog/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs +++ b/plugins/jfrog/skills/jfrog-init/scripts/jfrog-add-claude-marketplace.mjs @@ -2,12 +2,13 @@ // 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] +// 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, @@ -50,17 +51,18 @@ function readServerCreds(serverId) { return { jpd, login, token }; } -function marketplaceUrl({ jpd, login, token }, prefix) { +function marketplaceUrl({ jpd, login, token }, prefix, projectKey) { const userinfo = `${encodeURIComponent(login)}:${encodeURIComponent(token)}`; const base = `${jpd.host}${jpd.pathname.replace(/\/+$/, "")}`; - return `${jpd.protocol}//${userinfo}@${base}${prefix}${MARKETPLACE_PATH}`; + 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) { +function register(argServerId, projectKey) { if (!jfAvailable()) fail(`ERROR: ${describeJfUnavailable()}`, 3); if (!claude.found) fail("ERROR: claude not on PATH.", 3); @@ -71,8 +73,10 @@ function register(argServerId) { const failures = []; for (const prefix of MARKETPLACE_PREFIXES) { - const { ok, out } = marketplaceAdd(marketplaceUrl(creds, prefix)); + const url = marketplaceUrl(creds, prefix, projectKey); + const { ok, out } = marketplaceAdd(url); if (ok) { + moveTokenToHeader(url); process.stdout.write(redactToken(out, creds.token)); return 0; } @@ -82,9 +86,9 @@ function register(argServerId) { return 1; } -function main(argServerId) { +function main(argServerId, projectKey) { try { - return register(argServerId); + return register(argServerId, projectKey); } catch (err) { if (err.exitCode === undefined) throw err; process.stderr.write(`${err.message}\n`); @@ -93,5 +97,5 @@ function main(argServerId) { } if (isMainModule(import.meta.url)) { - process.exitCode = main(process.argv[2] || ""); + process.exitCode = main(process.argv[2] || "", process.argv[3] || ""); } diff --git a/plugins/jfrog/skills/jfrog-init/scripts/lib/claude-config.mjs b/plugins/jfrog/skills/jfrog-init/scripts/lib/claude-config.mjs new file mode 100644 index 0000000..060f5bc --- /dev/null +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-init/scripts/lib/netrc.mjs b/plugins/jfrog/skills/jfrog-init/scripts/lib/netrc.mjs index ae29425..968d4c7 100644 --- a/plugins/jfrog/skills/jfrog-init/scripts/lib/netrc.mjs +++ b/plugins/jfrog/skills/jfrog-init/scripts/lib/netrc.mjs @@ -28,7 +28,7 @@ export function dropNetrcHost(content, host) { return kept.join("\n"); } -// Saves `content` as ~/.netrc. +// 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 { diff --git a/plugins/jfrog/skills/jfrog-setup-package-managers/SKILL.md b/plugins/jfrog/skills/jfrog-setup-package-managers/SKILL.md index 160086f..fdab6e6 100644 --- a/plugins/jfrog/skills/jfrog-setup-package-managers/SKILL.md +++ b/plugins/jfrog/skills/jfrog-setup-package-managers/SKILL.md @@ -17,8 +17,12 @@ metadata: # 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. @@ -57,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 @@ -92,6 +97,8 @@ session also needs `jf api` / advanced CLI. `//`. - **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). @@ -101,7 +108,8 @@ session also needs `jf api` / advanced CLI. |------|--------------| | [`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 @@ -214,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 diff --git a/plugins/jfrog/skills/jfrog-setup-package-managers/references/workspace-binding.md b/plugins/jfrog/skills/jfrog-setup-package-managers/references/workspace-binding.md index 24f135c..66e0af0 100644 --- a/plugins/jfrog/skills/jfrog-setup-package-managers/references/workspace-binding.md +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh b/plugins/jfrog/skills/jfrog-setup-package-managers/scripts/merge-workspace-binding.sh new file mode 100755 index 0000000..b4ef837 --- /dev/null +++ b/plugins/jfrog/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/plugins/jfrog/skills/jfrog/SKILL.md b/plugins/jfrog/skills/jfrog/SKILL.md index 15d49a9..8cd0cf5 100644 --- a/plugins/jfrog/skills/jfrog/SKILL.md +++ b/plugins/jfrog/skills/jfrog/SKILL.md @@ -19,7 +19,7 @@ compatibility: >- (CLI) and Tier 3 (jf api) operations; without it, only MCP (Tier 1) is available. metadata: role: base - version: "0.25.0" + version: "0.28.0" --- # JFrog Skill @@ -36,6 +36,8 @@ Network-facing `jf` this session. Exempt until ``: `jf --version`, - **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 `) @@ -143,7 +145,7 @@ thing to check — re-run with the appropriate escalation above. 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: +session. Skip the CLI offer during `jfrog-init`. ```bash bash /scripts/check-environment.sh @@ -152,6 +154,17 @@ bash /scripts/check-environment.sh # 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. @@ -198,7 +211,8 @@ on every line). This is a **session-global invariant**: it applies to *every* 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 (see -[Never invent](#never-invent-jfrog_cli_user_agent)). +[Never invent](#never-invent-jfrog_cli_user_agent)) and whether the CLI +offer already ran. Subagents do not re-ask. | Exit | Meaning | |------|---------| @@ -452,6 +466,7 @@ 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 / diff --git a/plugins/jfrog/skills/jfrog/references/jfrog-cli-install-upgrade.md b/plugins/jfrog/skills/jfrog/references/jfrog-cli-install-upgrade.md index ba87338..3100bf8 100644 --- a/plugins/jfrog/skills/jfrog/references/jfrog-cli-install-upgrade.md +++ b/plugins/jfrog/skills/jfrog/references/jfrog-cli-install-upgrade.md @@ -20,7 +20,8 @@ brew install jfrog-cli curl -fL https://install-cli.jfrog.io | sh ``` -After installation, run `jf --version` to confirm and refresh the cache. +After installation, run `jf --version` to confirm, then +`bash /scripts/check-environment.sh --force`. ## Upgrading the JFrog CLI @@ -35,4 +36,5 @@ brew upgrade jfrog-cli curl -fL https://install-cli.jfrog.io | sh ``` -After upgrading, run `jf --version` to confirm and refresh the cache. +After upgrading, run `jf --version` to confirm, then +`bash /scripts/check-environment.sh --force`. diff --git a/plugins/jfrog/skills/jfrog/scripts/check-environment.sh b/plugins/jfrog/skills/jfrog/scripts/check-environment.sh index 0a73014..04c23b3 100755 --- a/plugins/jfrog/skills/jfrog/scripts/check-environment.sh +++ b/plugins/jfrog/skills/jfrog/scripts/check-environment.sh @@ -135,8 +135,44 @@ check_cli() { meets_minimum="false" fi + # Offer once per new latest. suggest_upgrade is tri-state: + # true — pending offer + # false — declined (or not behind); --clear writes false + # missing / non-semver prev latest — never evaluated → set true when behind + local prev_latest="" prev_flag="" suggest_upgrade=false + if [[ -f "$CACHE_FILE" ]] && command -v jq &>/dev/null; then + prev_latest="$(jq -r '.latest_version_available // empty' "$CACHE_FILE" 2>/dev/null || true)" + if jq -e '.suggest_upgrade == true' "$CACHE_FILE" >/dev/null 2>&1; then + prev_flag=true + suggest_upgrade=true + elif jq -e '.suggest_upgrade == false' "$CACHE_FILE" >/dev/null 2>&1; then + prev_flag=false + fi + fi + if [[ ! "$latest_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$prev_latest" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + latest_version="$prev_latest" + else + suggest_upgrade=false + fi + fi + if [[ "$latest_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + && [[ "$cli_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + && version_lt "$cli_version" "$latest_version"; then + if [[ ! "$prev_latest" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || [[ "$prev_latest" != "$latest_version" ]]; then + suggest_upgrade=true + elif [[ "$prev_flag" != "false" ]]; then + suggest_upgrade=true + else + suggest_upgrade=false + fi + elif [[ "$latest_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + suggest_upgrade=false + fi + mkdir -p "$CACHE_DIR" - local state + local state tmp state=$(cat < "$CACHE_FILE" + tmp="${CACHE_DIR}/.jfrog-skill-state.$$.tmp" + printf '%s\n' "$state" >"$tmp" && mv "$tmp" "$CACHE_FILE" echo "$state" >&2 return 1 } diff --git a/plugins/jfrog/skills/jfrog/scripts/cli-newer-version-offer.sh b/plugins/jfrog/skills/jfrog/scripts/cli-newer-version-offer.sh new file mode 100755 index 0000000..490f9c5 --- /dev/null +++ b/plugins/jfrog/skills/jfrog/scripts/cli-newer-version-offer.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# cli-newer-version-offer.sh — Read the pending CLI upgrade flag. +# +# check-environment.sh is the only writer of suggest_upgrade (true when +# latest is first evaluated or replaced and is strictly newer than +# cli_version). This script only reads that bool and, after Yes or No, +# --clear writes false. It does not run jf. +# +# Usage: +# bash cli-newer-version-offer.sh +# bash cli-newer-version-offer.sh --clear +# +# stdout (exactly one line, always exit 0): +# SKIP +# NEWER_AVAILABLE + +set -euo pipefail + +JFROG_HOME="${JFROG_CLI_HOME_DIR:-$HOME/.jfrog}" +CACHE_DIR="$JFROG_HOME/skills-cache" +CACHE_FILE="$CACHE_DIR/jfrog-skill-state.json" + +emit_skip() { + printf '%s\n' "SKIP" + exit 0 +} + +write_cache() { + command -v jq >/dev/null 2>&1 || return 0 + mkdir -p "$CACHE_DIR" || return 0 + local state="$1" + local tmp="${CACHE_DIR}/.jfrog-skill-state.$$.tmp" + printf '%s\n' "$state" >"$tmp" || return 0 + mv "$tmp" "$CACHE_FILE" || return 0 +} + +if [[ "${1:-}" == "--clear" ]]; then + if [[ -f "$CACHE_FILE" ]] && command -v jq >/dev/null 2>&1; then + local_state="$(jq -c '.suggest_upgrade = false' \ + "$CACHE_FILE" 2>/dev/null)" && write_cache "$local_state" + fi + emit_skip +fi + +if command -v jq >/dev/null 2>&1 && [[ -f "$CACHE_FILE" ]] \ + && jq -e '.suggest_upgrade == true' "$CACHE_FILE" >/dev/null 2>&1; then + current="$(jq -r '.cli_version // empty' "$CACHE_FILE" 2>/dev/null)" || emit_skip + latest="$(jq -r '.latest_version_available // empty' "$CACHE_FILE" 2>/dev/null)" || emit_skip + printf '%s\n' "NEWER_AVAILABLE ${current} ${latest}" + exit 0 +fi +emit_skip