Skip to content
Merged
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 .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jfrog",
"version": "0.1.5",
"version": "0.1.6",
"description": "JFrog skills and the JFrog MCP server for Codex \u2014 interact with the JFrog Platform.",
"author": {
"name": "JFrog",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-plugin",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"type": "module",
"description": "JFrog skills and MCP server plugin for OpenAI Codex.",
Expand Down
2 changes: 1 addition & 1 deletion 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.34.0",
"pin": "v0.35.0",
"paths": [
"skills"
]
Expand Down
17 changes: 4 additions & 13 deletions skills/jfrog-init/references/flow-diagram.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,11 @@ flowchart TD

S6["6. Project resolved?"]:::stepBox
S6 -->|state file has current project| ASKREUSE["AskUserQuestion: reuse CURRENT or pick different"]:::fixBox
ASKREUSE -->|reuse| VALPROJ["Validate via authenticated GET /access/api/v1/projects/KEY"]:::stepBox
ASKREUSE -->|different| ASKPROJ["AskUserQuestion: first 2 projects, or Other to type one"]:::fixBox
ASKREUSE -->|reuse| S7
ASKREUSE -->|different| ASKPROJ["AskUserQuestion: first 2 projects (from enumeration), or Other to type one"]:::fixBox
S6 -->|no state file| ASKPROJ
ASKPROJ --> RESOLVE["Resolve name-or-key (case-insensitive) against project list from authenticated GET /access/api/v1/projects"]:::stepBox
RESOLVE -->|no match, 1st attempt| ASKPROJ
RESOLVE -->|no match again, 2nd attempt: give up| F6
RESOLVE -->|matched| VALPROJ
VALPROJ -->|404 or 403, 1st attempt| ASKPROJ
VALPROJ -->|404 or 403 again, 2nd attempt: give up| F6
VALPROJ -->|401, credentials rejected| STOPCREDS["STOP: show raw error (re-auth via the Step 3/4 picker)"]:::stopBox
VALPROJ -->|2xx| S7

F6["Note: no project resolved after 1 retry — continue without one (non-blocking)"]:::fixBox
F6 --> S7
ASKPROJ -->|picked or typed — accepted verbatim, no probe, no matching| S7
ASKPROJ -->|enumeration failed: jf missing / credentials rejected| STOPCREDS["STOP: show raw error (re-auth via the Step 3/4 picker)"]:::stopBox

S7["7. AI Catalog reachable and user entitled?"]:::stepBox
S7 -->|anon 404 / connection failure / 5xx, exit 1| F7U["Note: catalogReason=unreachable — JPD may not host AI Catalog, or it's down right now (non-blocking)"]:::fixBox
Expand Down
77 changes: 30 additions & 47 deletions skills/jfrog-init/references/project-resolution-branches.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Step 6 — resolve/validate branches
# Step 6 — resolve branches

**Required behavior for Step 6, not optional background.** Read this in
full whenever `jfrog-detect-project.mjs` returns anything other than a
Expand All @@ -15,49 +15,32 @@ where one is shown, or the Final Summary.
— the server-id is ambiguous. Follow "Resolving `<server-id>` for
Steps 4-7" in `SKILL.md` (prompt for a server from `candidates`), then
re-invoke Step 6 with the picked server-id as arg 1.
- **No input passed (`ask`, exit 2, no `unresolved`)** → use the
picker/free-form ask from `references/project-picker.md`, then
re-invoke with the picked value as arg 2.
- **Input passed (resolve + validate)** →
- **Exit 0 (green)** → project exists and is accessible; the
canonical key is in the JSON `resolvedKey` field. Proceed to
Step 7 (`jfrog-detect-catalog-runtime.mjs [server-id]`), which
takes no project argument — the input string only needs to be
kept around as arg 2 to `jfrog-detect-all.mjs` itself, so a
re-run re-resolves Step 6 the same way.
- **Exit 1 (red)** → ambiguous input, 404, or 403 — **cap re-asks at
one retry within a single walk.** The first time any of these
three happens, re-run the picker/free-form ask (`project-picker.md`);
if the user's second attempt *also* comes back ambiguous/404/403,
stop asking — proceed to Step 7 without a resolved project
(non-blocking, same pattern as Step 5), and note it in the Final
Summary instead of asking a third time. Never loop indefinitely on
a silently-automatic retry the user didn't explicitly choose to
continue (unlike Step 3's config picker, which loops on an
explicit "did you finish?" the user opts into each time).
- Ambiguous input → `candidates` lists the tied keys; re-run the
picker/free-form ask (using the full `candidatesWithNames`, not
just the tied subset).
- HTTP 404 → project does not exist on this JPD; re-run the
picker/free-form ask.
- HTTP 403 → project exists but the user isn't entitled to this
**specific** one — JFrog project ACLs are per-project, so this
says nothing about any other project. Show the raw error, then
re-run the picker/free-form ask (using `candidatesWithNames`,
same as the 404 case) so the user can pick a different project
instead of dead-ending; mention they can also ask their JFrog
admin for access to the one they tried.
- HTTP 5xx, or the probe could not connect at all → the JPD is
erroring or unreachable right now. Re-picking won't help, so show
the raw error and move on rather than re-running the picker; the
Final Summary reports it via `projectResolved: false`. (Grouped
with the retryable reds rather than with Exit 3 because it is a
transient backend/network condition, not a broken setup — the
same reason `jfrog-detect-catalog-runtime.mjs` calls its own
"can't connect" red.)
- **Exit 3 (error)** → `jf` missing, credentials unavailable/rejected
(including HTTP 401 — this says nothing about whether the project
exists), a 2xx response that wasn't shaped like the real GetProject
endpoint, or an unexpected HTTP code. Show the raw detector error —
this one is a genuine stop, not subject to the retry cap above (no
re-pick can fix bad credentials).
- **No input passed (`ask`/`error`, no `unresolved`)** → enumeration
(`GET /access/api/v1/projects`) runs to drive the picker, and this is
the only remaining way Step 6 can fail to resolve a project:
- **Exit 2 (`ask`)** → use the picker/free-form ask from
`references/project-picker.md`, then re-invoke with the picked
value as arg 2. Whatever the user picks or types is then accepted
verbatim by the Exit 0 branch below — no second round-trip through
matching.
- **Exit 3 (error)** → `jf` missing or credentials unavailable/
rejected (needed to authenticate the enumeration call). Show the
raw detector error — this is a genuine stop (no re-pick can fix bad
credentials).
- **Input passed (resolve — no matching, no validation)** → accepted
exactly as given, with no network call at all: no per-project
existence/access probe (GetProject requires Platform/Project Admin,
same as the enumeration call — a non-admin caller would just get 403
from both and never learn whether the project is real), and no
name-or-key matching against the enumerated list either (that also
depended on enumeration succeeding, and could only ever flag two
enumerated projects as ambiguous — never useful to the same
non-admin accounts this exists for). There is no ambiguous (red)
outcome any more; a typed input is always green.
- **Exit 0 (green)** → the input is accepted verbatim; `resolvedKey`
is exactly what was passed, and the detail line says
existence/access was not verified. Proceed to Step 7
(`jfrog-detect-catalog-runtime.mjs [server-id]`), which takes no
project argument — the input string only needs to be kept around
as arg 2 to `jfrog-detect-all.mjs` itself, so a re-run re-resolves
Step 6 the same way.
181 changes: 39 additions & 142 deletions skills/jfrog-init/scripts/jfrog-detect-project.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Resolves and validates the project key for the walk. The picked value is
// Resolves the project key for the walk. The picked value is
// NEVER persisted (no env var, no shell profile) — the caller passes it as
// a positional argument on the re-invocation and threads it forward.
//
Expand All @@ -12,42 +12,33 @@
// /access/, off the JPD root, so credentials are resolved via `jf config
// export` and a direct fetch is issued instead.
//
// Resolution — matches BOTH project_key and display_name (case-
// insensitive; see lib/projects.mjs for the exact tier order). Never
// guesses, never assumes "default", never invents a value.
//
// The enumeration call is cached per server for a few minutes (see
// lib/project-cache.mjs) — the caller re-invokes this script once per
// user attempt within a single walk, and matching is offline anyway,
// so only the first attempt actually hits the network.
// Enumeration only ever runs to populate the interactive picker (no input
// passed) — see "Why no matching either" below for why a passed input skips
// it entirely. Cached per server for a few minutes (see lib/project-cache.mjs)
// since the picker can re-invoke this script more than once per walk.
//
// Usage: node jfrog-detect-project.mjs [server-id] [project-input]
//
// Every non-green result also carries `candidatesWithNames` (up to the
// full enumerated project list, `{key, displayName}`, sorted) whenever
// A no-input `ask` result carries `candidatesWithNames` (up to the full
// enumerated project list, `{key, displayName}`, sorted) whenever
// enumeration succeeded, so the caller can offer the first two as an
// interactive pick-one-or-type-your-own prompt instead of demanding the
// user type a key or name from memory. A confirmed-not-found input (404)
// additionally carries `similarProjects` — up to 2 "did you mean...?"
// suggestions (see lib/projects.mjs) — when the typed input looks like a
// near-miss of an existing project (e.g. "widgets20" when "widgets2" exists).
// user type a key from memory.
//
// Exit 0 -> green (project exists and is accessible; resolved canonical key in detail)
// Exit 1 -> red (project does not exist, no access, ambiguous match, or a
// 5xx from the existence probe — the backend is erroring
// — or the probe could not connect at all. The last two
// are indistinguishable from here, so they share a
// classification, the same way
// jfrog-detect-catalog-runtime.mjs treats its own "000".)
// Exit 0 -> green (input accepted exactly as passed, in `resolvedKey` —
// no matching, no existence/access check; see below)
// Exit 2 -> ask (no input passed, or multiple jf servers configured with none
// resolvable — caller must prompt the user and re-invoke)
// Exit 3 -> error (jf missing, credentials unavailable/rejected, an
// unexpected non-5xx HTTP code, or a 2xx response that
// wasn't shaped like the real GetProject endpoint)
// Exit 3 -> error (jf missing, or credentials unavailable/rejected)
//
// Matching against the enumerated list is also skipped: GetProjectsList
// requires Platform/Project Admin (same as the removed per-key probe), so
// non-admin callers got 403 either way. A typed input is accepted verbatim;
// enumeration only runs for the interactive picker (ask path) below.

import { emit, isMainModule, resolveCreds, authedFetch, NETWORK_UNREACHABLE_HINT } from "./lib/jf.mjs";
import { emit, isMainModule, resolveCreds, authedFetch } from "./lib/jf.mjs";
import { resolveServerOrEmit } from "./jfrog-resolve-jf-server.mjs";
import { resolveProject, projectsWithNames, findSimilarProjects, capCandidatesForDisplay } from "./lib/projects.mjs";
import { projectsWithNames, capCandidatesForDisplay } from "./lib/projects.mjs";
import { readCachedProjectList, writeCachedProjectList } from "./lib/project-cache.mjs";

// Exported so jfrog-detect-all.mjs can call this in-process instead of
Expand All @@ -67,7 +58,20 @@ export async function detectProject(serverIdArg, projectInputArg) {
return { exitCode: resolved.exitCode };
}
const { serverId } = resolved;
const projectInput = projectInputArg || "";
const projectInput = (projectInputArg || "").trim();

// ---------- Resolve branch: an input was passed ----------
// No matching, no existence/access probe — see "Why no matching either"
// above. Accepted exactly as given; always green.
if (projectInput) {
emit({
check: "project",
status: "green",
detail: `project ${projectInput} accepted — existence/access on the JPD was not verified`,
resolvedKey: projectInput,
});
return { exitCode: 0, resolvedKey: projectInput };
}

const creds = resolveCreds(serverId);
if (!creds) {
Expand All @@ -79,132 +83,22 @@ export async function detectProject(serverIdArg, projectInputArg) {
return { exitCode: 3 };
}

const rawGet = (path) => authedFetch(creds, path);

// ---------- Fetch project list first (needed for every branch) ----------
// ---------- Ask branch: no input passed; return candidates ----------
// Cached per server for a short TTL (lib/project-cache.mjs): the
// interactive picker re-invokes this script once per user attempt within
// the same walk, and re-enumerating on every typed guess is wasted
// network traffic — matching/similarity search below already runs
// offline against whatever list we have in memory.
// the same walk, and re-enumerating on every attempt is wasted network
// traffic.
let list;
const cachedBody = readCachedProjectList(serverId, creds);
if (cachedBody !== null) {
list = { code: 200, body: cachedBody };
} else {
list = await rawGet("/access/api/v1/projects");
list = await authedFetch(creds, "/access/api/v1/projects");
if (list.code >= 200 && list.code < 300) writeCachedProjectList(serverId, creds, list.body);
}
const enumOk = list.code >= 200 && list.code < 300;
const candidatesWithNames = enumOk ? projectsWithNames(list.body) : [];

// ---------- Resolve+validate branch: an input was passed ----------
if (projectInput) {
let resolvedKey = projectInput;
if (enumOk) {
const resolved = resolveProject(candidatesWithNames, projectInput);
if (resolved?.tied) {
emit({
check: "project",
status: "red",
detail: `input "${projectInput}" matches multiple projects; be more specific`,
candidates: resolved.tied,
...capCandidatesForDisplay(candidatesWithNames),
});
return { exitCode: 1 };
}
if (resolved?.key) resolvedKey = resolved.key;
// No match against the enumeration — could still be a valid key the
// enumeration missed (paging, ACL), so let the existence probe below
// be the ultimate arbiter.
}

// Encode as a single path segment (not just URI-escape) so an input like
// "../projects" can't change which endpoint gets hit.
const projectPath = `/access/api/v1/projects/${encodeURIComponent(resolvedKey)}`;
const probe = await rawGet(projectPath);
// A 2xx status alone isn't proof this is really the GetProject response —
// a captive portal or misrouted network can also answer 200. Require the
// expected shape (an object carrying the project's own key) too, the
// same guard jfrog-detect-catalog-runtime.mjs applies to its endpoint.
const looksLikeProject =
probe.body &&
typeof probe.body === "object" &&
(typeof probe.body.project_key === "string" || typeof probe.body.projectKey === "string");
if (probe.code >= 200 && probe.code < 300 && looksLikeProject) {
emit({ check: "project", status: "green", detail: `project ${resolvedKey} exists and is accessible (HTTP ${probe.code})`, resolvedKey });
return { exitCode: 0, resolvedKey };
}
if (probe.code >= 200 && probe.code < 300 && !looksLikeProject) {
emit({
check: "project",
status: "error",
detail: `got HTTP ${probe.code} from ${creds.baseUrl}${projectPath} but the response wasn't the expected project shape — this may not be the JPD's real endpoint (captive portal / proxy?)`,
});
return { exitCode: 3 };
}
if (probe.code >= 500 && probe.code < 600) {
// A 5xx means the backend itself is erroring, same as a connection
// failure from the caller's perspective — treat it as "red", not
// "error", matching jfrog-detect-catalog-runtime.mjs and
// jfrog-detect-server-ping.mjs's classification of the same code class.
emit({ check: "project", status: "red", detail: `${creds.baseUrl}${projectPath} returned HTTP ${probe.code} — the backend is erroring` });
return { exitCode: 1 };
}
if (probe.code === 404) {
const similarProjects = enumOk ? findSimilarProjects(candidatesWithNames, projectInput) : [];
emit({
check: "project",
status: "red",
detail: `no project matches "${projectInput}" on this JPD — pick a different one`,
resolvedKey,
...capCandidatesForDisplay(candidatesWithNames),
...(similarProjects.length > 0 ? { similarProjects } : {}),
});
return { exitCode: 1 };
}
if (probe.code === 401) {
// Unlike 403, a 401 means the credentials themselves were rejected —
// this says nothing about whether the project exists.
emit({
check: "project",
status: "error",
detail: `cannot verify project ${resolvedKey}: /access rejected the credentials in jf config (HTTP 401). Re-run \`jf config add --interactive\`.`,
});
return { exitCode: 3 };
}
if (probe.code === 403) {
// ACLs are per-project — not entitled to this one says nothing about
// any other, so carry candidatesWithNames the same as the 404 branch
// to let the caller re-offer the picker instead of dead-ending.
emit({
check: "project",
status: "red",
detail: `project ${resolvedKey} exists but your user is not entitled to see it (HTTP 403) — pick a project you have access to, or contact your JFrog admin`,
...capCandidatesForDisplay(candidatesWithNames),
});
return { exitCode: 1 };
}
if (probe.code === 0) {
// Red, not error — the 5xx branch above treats "the backend is
// erroring" as red precisely because it's indistinguishable from a
// connection failure from here, and
// jfrog-detect-catalog-runtime.mjs maps its own "000" to red too.
// Classifying the real thing as an error would make the same
// condition blocking in Step 6 and non-blocking in Step 7.
emit({
check: "project",
status: "red",
detail: `could not reach ${creds.baseUrl}${projectPath} (connection failed)${NETWORK_UNREACHABLE_HINT}`,
...capCandidatesForDisplay(candidatesWithNames),
});
return { exitCode: 1 };
}
emit({ check: "project", status: "error", detail: `project validation returned unexpected HTTP ${probe.code} for ${creds.baseUrl}${projectPath}` });
return { exitCode: 3 };
}

// ---------- Ask branch: no input passed; return candidates ----------
// Branches on `enumOk`, not `candidatesWithNames.length`, so a JPD with
// zero accessible projects (enumeration succeeded, list is empty) isn't
// misreported as "enumeration was not available".
Expand All @@ -220,6 +114,9 @@ export async function detectProject(serverIdArg, projectInputArg) {
: "no project chosen — ask the user which project to use (no projects are accessible on this JPD)",
...(candidatesWithNames.length > 0 ? { candidates: shown.candidatesWithNames.map((p) => p.key), ...shown } : {}),
});
} else if (list.code === 401 || list.code === 403) {
emit({ check: "project", status: "error", detail: `project enumeration failed: /access rejected credentials (HTTP ${list.code}). Re-run \`jf config add --interactive\`.` });
return { exitCode: 3 };
} else {
emit({ check: "project", status: "ask", detail: "no project chosen — ask the user which project to use (project enumeration was not available)" });
}
Expand Down
2 changes: 1 addition & 1 deletion skills/jfrog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.34.0"
version: "0.35.0"
---

# JFrog Skill
Expand Down
Loading