Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devin-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"displayName": "JFrog Platform",
"version": "0.3.11",
"version": "0.3.12",
"description": "Official JFrog plugin for Devin. Ships the JFrog skills bundle (platform ops, init, MCP management, AI Catalog, package safety, reference architecture, package-manager setup) and registers the JFrog Platform MCP server (remote HTTP + OAuth).",
"author": {
"name": "JFrog Ltd.",
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/sync-skills-vendor.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"repo": "jfrog/jfrog-skills",
"pin": "v0.44.0",
"pin": "v0.46.0",
"paths": [
"skills"
]
Expand Down
62 changes: 37 additions & 25 deletions skills/jfrog-ai-catalog/references/installing-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ target is already installed, so its repo is fixed — not automatically by
`jf`, but by reading it back from that install's own record (see *Update an
installed skill* below) — and `allowStatus` is a per-repo fact, not
per-version, so every version in that already-allowed repo is equally
allowed. "Latest version in this specific, already-resolved repo" carries
allowed — once update has confirmed that repo is still in the `--allowed-only`
listing. "Latest version in this specific, already-resolved repo" carries
none of the version/repo ambiguity this rule exists to prevent.

**The `jf skills install` command takes no project.** Resolving which repo hosts
Expand Down Expand Up @@ -288,39 +289,50 @@ On success, reply using **this exact template**:

To upgrade an installed skill to a newer version, use the CLI (it re-downloads
and reinstalls in place). **`jf skills update` does not remember which repo the
skill was installed from — you must resolve and pass `--repo` yourself.**
skill was installed from — a repo has to be passed with `--repo`.**
Without it, update either fails with `multiple skills repositories found`
(listing every skills repo on the whole server, not scoped to this project or
its governance policy at all) or, if handed some other repo, silently updates
from it — even one that never hosted the originally installed skill. Never
run `jf skills update` without a resolved `--repo`.
from it — even one that never hosted the originally installed skill. The
script below resolves and checks the repo for you.

**Resolve `<repo>` from the existing install, never by asking the user or
guessing.** Every install writes it to `.jfrog/skill-info.json` under the
install directory:
**Run the update script; never build the `jf skills update` command yourself.**
It reads `<repo>` from the install's `.jfrog/skill-info.json`, checks it is a
plain id, confirms it is still in the project's `--allowed-only` listing, and
only then runs `jf skills update`. That file is untrusted (a project can commit
its own copy), so never paste a value from it into a command.

```bash
jq -r '.repo' "<install-dir>/<slug>/.jfrog/skill-info.json"
```

If that file or its `.repo` field is missing (for example, a skill that was
manually copied in rather than installed with `jf skills install`), tell the
user you can't safely determine which repo to update from and stop. Do not
substitute a repo from a fresh catalog listing instead — a same-named repo
returned there is not proof it is the repo this specific install came from.
Resolve `<PROJECT>` per **Resolve the project** in `../SKILL.md` Prerequisites,
then run the script with network access:

```bash
jf skills update "<slug>" --server-id "<SID>" --harness "<harness>" --repo "<repo>" --version "latest" --quiet
# Preview without touching Artifactory:
jf skills update "<slug>" --server-id "<SID>" --harness "<harness>" --repo "<repo>" --dry-run
# Reinstall even if already at the target version:
jf skills update "<slug>" --server-id "<SID>" --harness "<harness>" --repo "<repo>" --force --quiet
node "<skill_path>/scripts/jfrog-update-skill.mjs" --slug '<slug>' --install-dir "<install-dir>" \
--server-id "<SID>" --project "<PROJECT>" --harness "<harness>"
# Preview without touching Artifactory: add --dry-run
# Reinstall even if already at the target version: add --force
```

Use the same install-target flag (`--harness`/`--global`/`--project-dir`/
`--path`) the skill was installed with (Copilot: plus `--global`). After
updating, re-verify the `SKILL.md` (see *Verify the install landed* above). If the update download 403s, handle it
as in *Handling a blocked download* above.
Keep the slug in single quotes (double quotes don't stop `$(...)`). If it has
anything but letters, digits, `.`, `_` or `-`, don't run the command: tell the
user the skill name isn't valid.

Use the same install target the skill was installed with:
`--harness "<harness>"`, `--global`, `--project-dir "<dir>"` or `--path "<dir>"`
(`--harness` can be combined with `--global`, as Copilot does, or with
`--project-dir`). Always invoke via `node <path>`.

Handle the exit code:

- **0** — updated. Re-verify the `SKILL.md` (see *Verify the install landed*
above) and reply with the template below.
- **3** — refused (the repo is missing or unsafe, or is no longer
governance-allowed). Show the single stderr line **verbatim** and stop. Do not
retry, and do not pick a repo from the catalog listing instead — a same-named
repo returned there is not proof it is the repo this install came from.
- **1** — failed. If the download 403s, handle it as in *Handling a blocked
download* above, using the repo the script printed (`Updating from repo: …`)
and the version from `jf`'s `Using latest version: <version>` line; otherwise report the error line.
- **2** — usage error: a bug in how the script was invoked.

On success, reply using **this exact template**:

Expand Down
24 changes: 14 additions & 10 deletions skills/jfrog-ai-catalog/references/managing-installed-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,26 @@ skill, see *Update an installed skill* in `installing-skills.md`.
## Remove a skill

"Remove" defaults to removing the **locally installed** skill. There is no
`jf skills uninstall`. Delete the installed skill directory after confirming it
exists:

```bash
if [ -d "<install-dir>/<slug>" ]; then
rm -rf "<install-dir>/<slug>"
else
echo "Not installed, nothing to remove"
fi
```
`jf skills uninstall`.

**Confirm before deleting.** Show exactly what will be removed using **this exact
template** and wait for an explicit "yes":

> Removing skill `<slug>` deletes its local install from `<harness>`. Do you want to remove it?

Only after the "yes", delete the installed skill directory with the script (it
rejects a slug that isn't a plain name):

```bash
node "<skill_path>/scripts/jfrog-remove-skill.mjs" --slug '<slug>' --install-dir "<install-dir>"
```

Keep the slug in single quotes (double quotes don't stop `$(...)`). If it has
anything but letters, digits, `.`, `_` or `-`, don't run the command: tell the
user the skill name isn't valid.

On exit `1` or `3`, show its stderr line verbatim and stop. On `2`, fix the call.

On success, reply using **this exact template**:

> Removed `<slug>` from `<harness>`.
Expand Down
31 changes: 31 additions & 0 deletions skills/jfrog-ai-catalog/scripts/jfrog-remove-skill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env node
// jfrog-remove-skill.mjs — delete an installed skill's directory.
//
// node jfrog-remove-skill.mjs --slug <slug> --install-dir <dir>
//
// The slug comes from `jf skills list`, so it must be a plain name. No shell is
// used. Exit: 0 removed | 1 not installed | 2 usage error | 3 invalid slug.
// On 1 and 3, stderr is one line to show the user.

import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { parseArgs } from 'node:util';

function fail(code, message) {
process.stderr.write(`${message}\n`);
process.exit(code);
}

let values = {};
try {
({ values } = parseArgs({ options: { slug: { type: 'string' }, 'install-dir': { type: 'string' } } }));
} catch {}
const { slug, 'install-dir': installDir } = values;
if (!slug || !installDir) fail(2, 'usage: jfrog-remove-skill.mjs --slug <slug> --install-dir <dir>');

// Letters, digits, "." "_" "-", starting with a letter or digit (so never "." / ".." / "-x").
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) fail(3, "That skill name isn't valid, so I can't remove it.");

const target = join(installDir, slug);
if (!existsSync(target)) fail(1, 'Not installed, nothing to remove');
rmSync(target, { recursive: true });
213 changes: 213 additions & 0 deletions skills/jfrog-ai-catalog/scripts/jfrog-update-skill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
#!/usr/bin/env node
// jfrog-update-skill.mjs — update an installed skill, only from a repo that is
// still governance-allowed.
//
// node jfrog-update-skill.mjs --slug <slug> --install-dir <dir> --server-id <SID> \
// --project <PROJECT> <target> [--dry-run] [--force]
// <target>: --harness <h> [--global | --project-dir <dir>] | --global | --project-dir <dir>
// | --path <dir>
//
// The repo comes from <install-dir>/<slug>/.jfrog/skill-info.json, which any
// project can commit, so it is untrusted: it must be a plain id and appear in
// the project's `--allowed-only` listing. No shell is used (spawnSync,
// shell:false), so a value is only ever one argument. The one exception is npx
// on Windows (see runNpx).
//
// Exit: 0 updated | 1 failed | 2 usage error | 3 refused (unsafe or not allowed)
// On 1 and 3, stderr is one line to show the user. `jf` output passes through.

import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const DEFAULT_REGISTRY = 'https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/';
const MAX_PAGES = 20;
const REQUIRED_FLAGS = ['--slug', '--install-dir', '--server-id', '--project'];
const TARGET_FLAGS = ['--harness', '--project-dir', '--path'];
const VALUE_FLAGS = [...REQUIRED_FLAGS, ...TARGET_FLAGS];
const BOOL_FLAGS = ['--global', '--dry-run', '--force'];
// A plain id: letters, digits, "." "_" "-". Never "." / "..", never a leading "-".
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
const USAGE =
'usage: jfrog-update-skill.mjs --slug <slug> --install-dir <dir> --server-id <SID> ' +
'--project <PROJECT> <target> [--dry-run] [--force]\n' +
'<target>: --harness <h> [--global | --project-dir <dir>] | --global | --project-dir <dir> | --path <dir>';

// An error with the exit code to finish with.
class Stop extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}

const listingError = (reason) =>
new Stop(1, `Could not check the allowed repositories${reason ? `: ${reason}` : ''}`);

function isSafeId(value) {
return (
typeof value === 'string' &&
SAFE_ID.test(value) &&
value !== '.' &&
value !== '..' &&
!value.startsWith('-')
);
}

function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (BOOL_FLAGS.includes(flag)) {
args[flag] = true;
} else if (VALUE_FLAGS.includes(flag)) {
const value = argv[++i];
if (value === undefined || value.startsWith('--')) {
throw new Stop(2, `${flag} requires a value\n${USAGE}`);
}
args[flag] = value.trim();
} else {
throw new Stop(2, `unknown argument: ${flag}\n${USAGE}`);
}
}
for (const flag of REQUIRED_FLAGS) {
if (!args[flag]) throw new Stop(2, `${flag} is required\n${USAGE}`);
}

// --path stands alone; otherwise any mix of --harness / --project-dir / --global
// (at least one). `jf` rejects combinations it doesn't support.
const targets = TARGET_FLAGS.filter((flag) => args[flag]);
const validTarget = args['--path']
? targets.length === 1 && !args['--global']
: targets.length > 0 || args['--global'];
if (!validTarget) throw new Stop(2, `invalid install target\n${USAGE}`);

if (!isSafeId(args['--slug'])) throw new Stop(3, "That skill name isn't valid, so I can't update it.");
return args;
}

// Never prints the file's value: it is untrusted.
function readInstalledRepo(installDir, slug) {
let repo;
try {
repo = JSON.parse(readFileSync(join(installDir, slug, '.jfrog', 'skill-info.json'), 'utf8')).repo;
} catch {
// A missing or unreadable file is handled like a missing repo.
}
if (!isSafeId(repo)) {
throw new Stop(3, "I can't safely tell which repo this skill was installed from, so I can't update it.");
}
return repo;
}

function lastErrorLine(text) {
const lines = String(text || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.includes('Trace ID'));
return lines[lines.length - 1] ?? '';
}

// Windows runs npx as npx.cmd, which only works through cmd.exe. reject cmd
// metacharacters in the arguments and pass one quoted command string.
const CMD_UNSAFE = /[&|;$<>`"'%^!\r\n]/;

function runNpx(args, options) {
if (process.platform !== 'win32') return spawnSync('npx', args, options);
if (args.some((arg) => CMD_UNSAFE.test(arg))) throw listingError('unsafe argument');
const command = ['npx', ...args.map((arg) => `"${arg}"`)].join(' ');
return spawnSync(command, { ...options, shell: process.env.ComSpec || 'cmd.exe' });
}

function fetchPage({ slug, project, serverId }, cursor) {
const registry = process.env.JFROG_AGENT_GUARD_REPO || DEFAULT_REGISTRY;
const cursorArgs = cursor ? ['--cursor', cursor] : [];
const result = runNpx(
[
'--yes', '--registry', registry, '@jfrog/agent-guard',
'--list-skill-versions', '--project', project, '--skill', slug, '--allowed-only',
'--server', serverId, '--format', 'json', ...cursorArgs,
],
{ encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] },
);
if (result.error || result.status !== 0) throw listingError(lastErrorLine(result.stderr));
try {
return JSON.parse(result.stdout.trim());
} catch {
throw listingError('unreadable response');
}
}

// Every allowed repo that hosts the slug. An incomplete listing is an error:
// the repo we need could be on a page we never read.
function listAllowedRepos(query) {
const repos = new Set();
let cursor = '';
for (let page = 0; page < MAX_PAGES; page++) {
const data = fetchPage(query, cursor);
for (const version of data.versions ?? []) {
for (const location of version.locations ?? []) {
if (typeof location.repoKey === 'string') repos.add(location.repoKey);
}
}
if (data.exhausted === true) return repos;
if (!data.cursor) throw listingError('incomplete listing');
cursor = data.cursor;
}
throw listingError('too many pages');
}

function targetArgs(args) {
const flags = TARGET_FLAGS.filter((flag) => args[flag]).flatMap((flag) => [flag, args[flag]]);
return args['--global'] ? [...flags, '--global'] : flags;
}

function runUpdate(args, repo) {
// Always --quiet: an agent shell has no TTY, so a prompt would fail.
const mode = args['--dry-run']
? ['--dry-run', '--quiet']
: ['--version', 'latest', '--quiet', ...(args['--force'] ? ['--force'] : [])];
process.stdout.write(`Updating from repo: ${repo}\n`);
const result = spawnSync(
'jf',
[
'skills', 'update', args['--slug'],
'--server-id', args['--server-id'],
...targetArgs(args),
'--repo', repo,
...mode,
],
{ stdio: 'inherit' },
);
if (result.error) throw new Stop(1, 'Could not run the jf CLI');
// jf's own exit codes must not be read as 2 or 3.
process.exitCode = result.status === 0 ? 0 : 1;
}

function main() {
const args = parseArgs(process.argv.slice(2));
const slug = args['--slug'];

const repo = readInstalledRepo(args['--install-dir'], slug);
const allowed = listAllowedRepos({ slug, project: args['--project'], serverId: args['--server-id'] });
if (!allowed.has(repo)) {
throw new Stop(
3,
`\`${slug}\` is installed from \`${repo}\`, which is not governance-allowed for this project, so I can't update it.`,
);
}
runUpdate(args, repo);
}

// Set exitCode instead of exiting, so pending output is flushed.
try {
main();
} catch (error) {
if (error instanceof Stop) {
process.stderr.write(`${error.message}\n`);
process.exitCode = error.code;
} else {
process.stderr.write('Updating the skill failed: an unexpected error occurred.\n');
process.exitCode = 1;
}
}
2 changes: 1 addition & 1 deletion skills/jfrog-mcp-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ add-on where the agent provides it.
asserting whether this skill did. **When its `url` contains `/mcp-gateway/`
(a remote MCP Gateway entry), show that complete `url`, from `http` to the
end — in a table too, never shortened with `...`** — e.g.
`https://acme.jfrog.io/mcp-gateway/mcp/proj/com.supabase%2Fmcp`, not
`https://acme.jfrog.io/mcp-gateway/mcp/proj/02UkVNT1RFX2NvbS5zdXBhYmFzZS9tY3A`, not
`.../mcp-gateway/...`.
2. **If the harness exposes an MCP status command or view** (the harness-config
"List installed" column — e.g. Claude Code's `claude mcp list`, Cursor/VS
Expand Down
Loading
Loading