diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 08beedb..022121c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jfrog", - "version": "0.1.13", + "version": "0.1.14", "description": "JFrog skills and the JFrog MCP server for Codex \u2014 interact with the JFrog Platform.", "author": { "name": "JFrog", diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 42f4bad..c517c0f 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,6 +1,6 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.12.2", + "pin": "jfrog-agent-hooks/v0.13.0", "paths": [ "modules" ] diff --git a/modules/core/jf-user-agent.mjs b/modules/core/jf-user-agent.mjs index d28c17c..9744577 100644 --- a/modules/core/jf-user-agent.mjs +++ b/modules/core/jf-user-agent.mjs @@ -20,7 +20,12 @@ import { spawnJfSync } from "./jf-process.mjs"; // Plugin sync stamps this literal with the release semver (jfrog-sync-modules.py // stamp). Only `modules/` is vendored, so nothing outside this tree is readable // at runtime. Unstamped trees (this repo, local dev) report 0.0.0. -const PKG_VERSION = "0.12.2"; +const PKG_VERSION = "0.13.0"; + +/** Hooks release semver stamped at plugin sync ("0.0.0" when unstamped). */ +export function hooksPackageVersion() { + return PKG_VERSION; +} /** Product UA for plugin `fetch()` (no trigger, no jfrog-cli-go). */ export function skillsProductUserAgent() { diff --git a/modules/core/jpd-get.mjs b/modules/core/jpd-get.mjs new file mode 100644 index 0000000..cd04dd7 --- /dev/null +++ b/modules/core/jpd-get.mjs @@ -0,0 +1,121 @@ +// GET a JPD path through `jf api` (so jf's proxy settings apply), or through +// fetch when jf is missing or older than 2.100.0. jf runs with the caller's env, +// so the caller sets JFROG_CLI_USER_AGENT. + +import { execFile } from "node:child_process"; + +import { authHeader, safeErrorMessage } from "./jf-identity.mjs"; +import { jfInvocation } from "./jf-process.mjs"; +import { skillsProductUserAgent } from "./jf-user-agent.mjs"; + +// Never throws. Resolves to { status, body } (body: parsed JSON of a 2xx, else +// null), or { error } when there was no HTTP answer. +export async function jpdGet( + apiPath, + identity, + { env, timeoutMs, runJfFn = runJf, fetchFn = fetch }, +) { + if (identity.serverId) { + const viaJf = await getWithJf(apiPath, identity.serverId, { + env, + timeoutMs, + runJfFn, + }); + if (viaJf) return viaJf; + } + return getWithFetch(apiPath, identity, { timeoutMs, fetchFn }); +} + +// Asks through `jf api`. Returns null when jf can't run it, +// so the caller falls back to fetch. +async function getWithJf(apiPath, serverId, { env, timeoutMs, runJfFn }) { + const run = await runJfFn( + ["api", "--server-id", serverId, "-H", "Accept: application/json", apiPath], + { env, timeoutMs }, + ); + if (run.missing) return null; + if (run.timedOut) return { error: "timeout" }; + const status = parseHttpStatus(run.stderr) || (run.code === 0 ? 200 : 0); + if (status) { + const ok = status >= 200 && status < 300; + return { status, body: ok ? parseJson(run.stdout) : null }; + } + if (/is not a jf command|unknown command/i.test(run.stderr + run.stdout)) { + return null; + } + return { error: lastLine(run.stderr) || `jf api exited with ${run.code}` }; +} + +// Asks directly with the token. Used only when `jf api` can't be used. +async function getWithFetch(apiPath, identity, { timeoutMs, fetchFn }) { + const root = identity.url.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + try { + const response = await fetchFn(root + apiPath, { + headers: { + Accept: "application/json", + Authorization: authHeader(identity), + "User-Agent": skillsProductUserAgent(), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + const body = response.ok ? await response.json().catch(() => null) : null; + return { status: response.status, body }; + } catch (err) { + return { + error: err?.name === "TimeoutError" ? "timeout" : safeErrorMessage(err), + }; + } +} + +// Runs jf and waits for it to finish. Never throws: it returns the output, and +// flags a jf killed at the timeout (timedOut) or not installed (missing). +export function runJf(args, { env, timeoutMs }) { + const { command, args: argv } = jfInvocation(args, { env }); + return new Promise((resolve) => { + execFile( + command, + argv, + { env, timeout: timeoutMs, windowsHide: true, encoding: "utf8" }, + (err, stdout, stderr) => { + resolve({ + code: err ? (Number.isInteger(err.code) ? err.code : 1) : 0, + stdout: String(stdout ?? ""), + stderr: String(stderr ?? ""), + timedOut: Boolean(err?.killed), + missing: err?.code === "ENOENT", + }); + }, + ); + }); +} + +// jf api reports the HTTP status only as a stderr line. +// Returns that number, or 0 when there's none. +// The last match wins, so a retry line can't hide the final status. +function parseHttpStatus(text) { + let status = 0; + for (const line of text.split("\n")) { + const match = line.match(/Http Status:\s*(\d{3})/); + if (match) status = Number(match[1]); + } + return status; +} + +// The response body jf api prints on stdout. Returns null when it isn't valid JSON. +function parseJson(stdout) { + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + +// The error to report when jf failed with no HTTP answer. +function lastLine(stderr) { + const line = stderr + .trim() + .split("\n") + .at(-1) + ?.replace(/^\s*\[\w+\]\s*/, ""); + return line ? safeErrorMessage({ message: line }) : ""; +} diff --git a/modules/core/local-shadow-trigger.mjs b/modules/core/local-shadow-trigger.mjs new file mode 100644 index 0000000..89d7d7b --- /dev/null +++ b/modules/core/local-shadow-trigger.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +// Local Shadow AI trigger. +// node local-shadow-trigger.mjs session_start runs once per new session (SessionStart hook) +// +// Flow: +// 1. the default jf CLI server has an access token +// 2. the backend says scanning is enabled (asked through `jf api`, see jpd-get.mjs) +// 3. read the plugin version from plugin.json ("UNKNOWN" when unreadable) +// 4. start `agent-guard --local-shadow-scan --server [--session-id ] [--workspace ] +// --plugin-version --harness ` in the background +// The first step that fails ends the run. Each run appends its result to +// ~/.jfrog/shadow/local-shadow-hook.log. Claude Code only; see README +// "Local Shadow AI scan". + +import { spawn } from "node:child_process"; +import { + appendFileSync, + mkdirSync, + readFileSync, + renameSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { isMainEntry } from "./entry.mjs"; +import { detectHarness, readStdin } from "./io.mjs"; +import { + getPlatformIdentity, + IdentityCause, + isHttpsIdentityUrl, + safeErrorMessage, +} from "./jf-identity.mjs"; +import { axesFromAdapterIde, envWithHookUserAgent } from "./jf-user-agent.mjs"; +import { jpdGet } from "./jpd-get.mjs"; +import { + AGENT_GUARD_PACKAGE, + buildNpxSpawnOptions, + quoteSpawnArgs, + resolveAgentGuardBin, + resolveAgentGuardNpmRegistry, + resolveNpxCommand, +} from "./rewrite-mcp-json.mjs"; + +export const RESULT = Object.freeze({ + NOT_LOGGED_IN: "not_logged_in", + UNSUPPORTED_AUTH: "unsupported_auth", + SCANNING_DISABLED: "scanning_disabled", + CHECK_FAILED: "check_failed", + SCANNER_STARTED: "scanner_started", +}); + +export const SCANNING_ENABLED_PATH = + "/aicatalog/api/v1/local-shadow/scanning-enabled"; +const REQUEST_TIMEOUT_MS = 30_000; +export const HOOK_LOG_FILE = "local-shadow-hook.log"; +export const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; +export const UNKNOWN_PLUGIN_VERSION = "UNKNOWN"; + +// This plugin's harness, as detectHarness names it. +export const HARNESS_ID = "claude_code"; +// Per harness: the folder its plugin keeps plugin.json in. +const PLUGIN_MANIFEST_DIRS = Object.freeze({ + claude_code: ".claude-plugin", +}); + +// /modules, as in agents-config.mjs. +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const PLUGIN_DIR = path.dirname(PLUGIN_ROOT); + +// The scanner's state directory: $JFROG_CLI_HOME_DIR/shadow, or ~/.jfrog/shadow. +export function shadowDir(env) { + const jfHome = + env.JFROG_CLI_HOME_DIR?.trim() || path.join(homedir(), ".jfrog"); + return path.join(jfHome, "shadow"); +} + +// The plugin's version from its plugin.json. Throws when it can't be read. +export function readPluginVersion(pluginDir = PLUGIN_DIR) { + const manifest = JSON.parse( + readFileSync( + path.join(pluginDir, PLUGIN_MANIFEST_DIRS[HARNESS_ID], "plugin.json"), + "utf8", + ), + ); + const version = + typeof manifest?.version === "string" ? manifest.version.trim() : ""; + if (!version) throw new Error("plugin.json has no version"); + return version; +} + +// Asks the backend whether scanning is enabled for this account. +// Returns null when enabled, otherwise the { result, error } to log. +async function checkScanningEnabled(identity, { env, timeoutMs, getFn }) { + const failed = (detail) => ({ + result: RESULT.CHECK_FAILED, + error: `scanning enabled: ${detail}`, + }); + const { status, body, error } = await getFn(SCANNING_ENABLED_PATH, identity, { + env: envWithHookUserAgent(env, axesFromAdapterIde(HARNESS_ID, env)), + timeoutMs, + }); + if (error) return failed(error); + if (status === 401) { + return { result: RESULT.NOT_LOGGED_IN, error: "token rejected (HTTP 401)" }; + } + if (status < 200 || status >= 300) return failed(`HTTP ${status}`); + if (body?.enabled === true) return null; + if (body?.enabled === false) return { result: RESULT.SCANNING_DISABLED }; + return failed("response has no boolean 'enabled'"); +} + +// Starts `agent-guard --local-shadow-scan` in the background. Resolves as soon as +// the OS has started the process; it never waits for the scan to finish. +async function startScanner( + env, + platform, + spawnFn, + serverId, + session, + pluginVersion, +) { + const scanArgs = [ + "--local-shadow-scan", + ...(serverId ? ["--server", serverId] : []), + ...(session.id ? ["--session-id", session.id] : []), + ...(session.workspace ? ["--workspace", session.workspace] : []), + "--plugin-version", + pluginVersion, + "--harness", + HARNESS_ID, + ]; + const bin = resolveAgentGuardBin(env); + const command = bin ?? resolveNpxCommand(platform); + const args = bin + ? scanArgs + : quoteSpawnArgs( + [ + "--yes", + "--prefer-offline", + "--registry", + resolveAgentGuardNpmRegistry(env), + AGENT_GUARD_PACKAGE, + ...scanArgs, + ], + platform, + ); + const child = spawnFn(command, args, { + ...buildNpxSpawnOptions(env, platform, { local: Boolean(bin) }), + stdio: "ignore", + windowsHide: true, + }); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + child.unref(); +} + +// Appends one JSON line to the trigger log. When the log reaches HOOK_LOG_MAX_BYTES +// it's renamed to .1 (replacing the old backup) and a new log starts. +export function appendHookLog(logPath, entry) { + try { + mkdirSync(path.dirname(logPath), { recursive: true, mode: DIR_MODE }); + const size = statSync(logPath, { throwIfNoEntry: false })?.size ?? 0; + if (size >= HOOK_LOG_MAX_BYTES) renameSync(logPath, `${logPath}.1`); + const line = JSON.stringify({ time: new Date().toISOString(), ...entry }); + appendFileSync(logPath, `${line}\n`, { mode: FILE_MODE }); + } catch { + // A failed log write must never fail the hook. + } +} + +// Steps 1-4 of the flow at the top. Returns the { result, error? } to log; +// an unreadable plugin version adds pluginVersionError. +async function runChecksAndStartScanner({ + env, + session, + getIdentityFn, + getFn, + timeoutMs, + spawnFn, + platform, + pluginDir, +}) { + const { identity, cause } = getIdentityFn(); + if ( + identity?.auth?.kind === "basic" || + cause === IdentityCause.JF_UNSUPPORTED_AUTH + ) { + return { + result: RESULT.UNSUPPORTED_AUTH, + error: "the scanner needs an access token, not a username and password", + }; + } + if (identity?.auth?.kind !== "bearer" || !identity.auth.token) { + return { result: RESULT.NOT_LOGGED_IN }; + } + if (!isHttpsIdentityUrl(identity)) { + return { + result: RESULT.CHECK_FAILED, + error: "platform URL is not HTTPS; the token is not sent in cleartext", + }; + } + + const notEnabled = await checkScanningEnabled(identity, { + env, + timeoutMs, + getFn, + }); + if (notEnabled) return notEnabled; + + let pluginVersion = UNKNOWN_PLUGIN_VERSION; + let pluginVersionError; + try { + pluginVersion = readPluginVersion(pluginDir); + } catch (err) { + pluginVersionError = safeErrorMessage(err); + } + const versionNote = pluginVersionError ? { pluginVersionError } : {}; + + try { + await startScanner( + env, + platform, + spawnFn, + identity.serverId, + session, + pluginVersion, + ); + } catch (err) { + return { + result: RESULT.CHECK_FAILED, + error: `start scanner: ${safeErrorMessage(err)}`, + ...versionNote, + }; + } + return { result: RESULT.SCANNER_STARTED, ...versionNote }; +} + +// The stdin payload, or null when it isn't a JSON object. +export function parsePayload(input) { + try { + const payload = JSON.parse(input); + return payload && typeof payload === "object" ? payload : null; + } catch { + return null; + } +} + +// Claude Code also fires SessionStart on resume, /clear and compaction; those +// don't scan. New sessions are "startup" (Claude, Codex) or "new" (VS Code +// Copilot). No source always scans. +const NEW_SESSION_SOURCES = new Set([undefined, "startup", "new"]); +export function isNewSession(payload) { + return NEW_SESSION_SOURCES.has(payload?.source); +} + +// The session a scan is for, from the SessionStart payload: its session_id and cwd (the +// project dir when the payload has no cwd). Either may be missing. +export function parseSession(payload, env) { + const text = (value) => + typeof value === "string" && value ? value : undefined; + return { + id: text(payload?.session_id), + workspace: text(payload?.cwd) ?? text(env.CLAUDE_PROJECT_DIR), + }; +} + +// One trigger run: the checks, then one line in local-shadow-hook.log. +// trigger is session_start. +export async function runLocalShadowTrigger(trigger, overrides = {}) { + const deps = { + env: process.env, + session: {}, + getIdentityFn: getPlatformIdentity, + getFn: jpdGet, + timeoutMs: REQUEST_TIMEOUT_MS, + spawnFn: spawn, + platform: process.platform, + pluginDir: PLUGIN_DIR, + ...overrides, + }; + const outcome = await runChecksAndStartScanner(deps); + appendHookLog(path.join(shadowDir(deps.env), HOOK_LOG_FILE), { + trigger, + ...outcome, + }); +} + +if (isMainEntry(import.meta.url)) { + const trigger = process.argv[2]; + readStdin() + .then((input) => { + const payload = parsePayload(input); + // Cursor also runs Claude's hooks; only this plugin's harness scans. + return ( + detectHarness(input) === HARNESS_ID && + isNewSession(payload) && + runLocalShadowTrigger(trigger, { + session: parseSession(payload, process.env), + }) + ); + }) + .catch(() => {}) + .finally(() => process.exit(0)); +} diff --git a/modules/package-resolution/onboarding/session-start-nudge.md b/modules/package-resolution/onboarding/session-start-nudge.md index 91335ee..b792a3c 100644 --- a/modules/package-resolution/onboarding/session-start-nudge.md +++ b/modules/package-resolution/onboarding/session-start-nudge.md @@ -11,9 +11,10 @@ install, the Yes/No ask below must be the first thing in your reply — before any other sentence. Ask which package/version you need in the same reply if you must, but the ask below comes first. -> Agent Package Resolution can route **T** installs through your Artifactory -> **virtual** repositories instead of public registries. Want to set that up -> for **T** now? Details: {{ADMIN_GUIDE_URL}} +> JFrog Agent Package Resolution is installed on this machine. It can route +> **T** installs through your Artifactory **virtual** repositories instead of +> public registries. Want to set that up for **T** now? Details: +> {{ADMIN_GUIDE_URL}} - **Yes** → run `node "{{CONFIGURE_COMMAND}}" onboarding-procedure` - **No** → run `node "{{CONFIGURE_COMMAND}}" dismiss --type ` (use the APR diff --git a/modules/package-resolution/scripts/apr-status.mjs b/modules/package-resolution/scripts/apr-status.mjs new file mode 100644 index 0000000..3d40884 --- /dev/null +++ b/modules/package-resolution/scripts/apr-status.mjs @@ -0,0 +1,187 @@ +// APR status file — "is the hook installed and firing on this machine?" +// +// Rewritten on every SessionStart (except the kill switch) so a developer, +// jfrog-skills, or `configure.mjs` can confirm APR is live without a debug log. +// It records what this run already decided — mode, resolved repos, and the +// eager-setup receipts — and never inspects package-manager config files. +// Whether a package manager still points at Artifactory (valid / drifted) is +// `jf setup --status`, not this file. +// +// File: ~/.jfrog/skills-cache/apr-status-v1.json +// { +// "schemaVersion": 1, +// "lastFiredAt": "2026-10-01T06:00:00.000Z", +// "harness": "cursor", +// "hooksVersion": "0.9.0", +// "mode": "routing", +// "reason": "jf-config", +// "configureCommand": "/abs/path/to/configure.mjs", +// "serverId": "my-server", +// "types": { +// "npm": { "repoKey": "npm-virtual", +// "setup": { "npm": { "status": "ok", "repoKey": "npm-virtual", +// "configuredAt": "..." } } }, +// "pypi": { "repoKey": null } +// } +// } +// +// `pending` mode adds `cause` (jf-not-installed, jf-not-configured, +// jf-unsupported-auth, jf-auth-failed, insecure-url) — `reason` alone is always +// "missing-identity" there and doesn't say what to fix. + +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import { createLogger } from "../../core/logger.mjs"; +import { getPlatformIdentity } from "../../core/jf-identity.mjs"; +import { hooksPackageVersion } from "../../core/jf-user-agent.mjs"; +import { configureCommandPath } from "./onboarding.mjs"; +import { governedPackageTypes, resolve as resolveRepo } from "./resolver.mjs"; +import { readReceipt, receiptEntry } from "./eager-setup-receipt.mjs"; +import { packageManagersForType } from "./package-manager-family.mjs"; + +const log = createLogger("apr-status"); + +export const APR_STATUS_SCHEMA_VERSION = 1; + +/** @returns {string} absolute path to the status file */ +export function aprStatusFilePath() { + return path.join(homedir(), ".jfrog", "skills-cache", "apr-status-v1.json"); +} + +/** + * Per-type summary of what APR did this session. Only package managers with a + * receipt entry for `serverId` appear under `setup`. + * @param {{ + * governed: string[], + * resolved: Record, + * receipt: object, + * serverId: string, + * }} input + * @returns {Record }>} + */ +export function summarizeTypes({ governed, resolved, receipt, serverId }) { + const types = {}; + for (const type of governed) { + const repoKey = resolved[type]?.repoKey ?? null; + const setup = {}; + for (const packageManager of packageManagersForType(type)) { + const entry = receiptEntry(receipt, serverId, packageManager); + if (!entry) continue; + setup[packageManager] = { + status: entry.status, + repoKey: entry.repoKey, + configuredAt: entry.configuredAt, + }; + } + types[type] = Object.keys(setup).length ? { repoKey, setup } : { repoKey }; + } + return types; +} + +/** + * Collect the routing-only fields (serverId + per-type summary). Uses the + * resolver's per-session cache, so this re-reads what render already resolved. + * @returns {Promise<{ serverId?: string, types: object }>} + */ +async function routingSummary() { + const identity = getPlatformIdentity().identity; + const serverId = identity?.serverId ?? "default"; + const governed = governedPackageTypes(); + const resolved = {}; + for (const type of governed) { + const r = await resolveRepo(type); + if (r) resolved[type] = r; + } + const receipt = await readReceipt(); + return { + serverId, + types: summarizeTypes({ governed, resolved, receipt, serverId }), + }; +} + +/** + * @param {{ + * harness?: string, + * mode: string, + * reason?: string, + * cause?: string, + * serverId?: string, + * types?: object, + * now?: number, + * }} input + * @returns {object} + */ +export function buildAprStatus({ + harness, + mode, + reason, + cause, + serverId, + types, + now = Date.now(), +}) { + return { + schemaVersion: APR_STATUS_SCHEMA_VERSION, + lastFiredAt: new Date(now).toISOString(), + harness: harness ?? null, + hooksVersion: hooksPackageVersion(), + mode, + reason: reason ?? null, + ...(mode === "pending" && cause ? { cause } : {}), + configureCommand: configureCommandPath(), + ...(serverId ? { serverId } : {}), + types: types ?? {}, + }; +} + +/** + * Atomic write (temp file + rename) so a concurrent reader never sees a + * half-written file. Overwrites whatever is there, parseable or not. + * @param {object} status + * @param {string} [file] + */ +export function writeAprStatus(status, file = aprStatusFilePath()) { + mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + try { + writeFileSync(tmp, `${JSON.stringify(status, null, 2)}\n`, "utf8"); + renameSync(tmp, file); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + throw err; + } +} + +/** + * Record this SessionStart in the status file. Never throws — the status file + * must not break injection. Callers skip it under the kill switch. + * @param {{ ide?: string }} ctx + * @param {{ mode: string, reason?: string, cause?: string }} flag + * @returns {Promise<{ written: boolean }>} + */ +export async function recordAprStatus(ctx, flag) { + try { + const routing = flag.mode === "routing" ? await routingSummary() : {}; + writeAprStatus( + buildAprStatus({ + harness: ctx?.ide, + mode: flag.mode, + reason: flag.reason, + cause: flag.cause, + ...routing, + }), + ); + return { written: true }; + } catch (err) { + log.warn("apr status write failed", { + error: err?.message ?? String(err), + }); + return { written: false }; + } +} diff --git a/modules/package-resolution/scripts/index.mjs b/modules/package-resolution/scripts/index.mjs index 22c4e17..742bca8 100644 --- a/modules/package-resolution/scripts/index.mjs +++ b/modules/package-resolution/scripts/index.mjs @@ -9,6 +9,7 @@ import { isPackageResolutionEnabled } from "./feature-flag.mjs"; import { renderInstruction } from "./render-instruction.mjs"; import { orchestrateEagerSetup } from "./eager-setup.mjs"; import { maybeSendAprHeartbeat } from "./apr-heartbeat.mjs"; +import { recordAprStatus } from "./apr-status.mjs"; import { maybeMigrateScaffoldEnabled, resolveOnboardingNudge, @@ -78,6 +79,7 @@ export const packageResolution = { nudgeReason: nudge.reason, mode: "off", }; + if (!killSwitch) await recordAprStatus(ctx, flag); return nudge.text; } @@ -132,6 +134,7 @@ export const packageResolution = { ...(autoSetupStatus ? { eagerSetup: true } : {}), ...meta, }; + await recordAprStatus(ctx, flag); return combined; }, }; diff --git a/modules/package-resolution/scripts/onboarding.mjs b/modules/package-resolution/scripts/onboarding.mjs index 6f8b6bf..1a2e457 100644 --- a/modules/package-resolution/scripts/onboarding.mjs +++ b/modules/package-resolution/scripts/onboarding.mjs @@ -59,7 +59,7 @@ function adminGuideUrlForIde(ide) { return ADMIN_GUIDE_URL_BY_IDE[ide] ?? CLAUDE_ADMIN_GUIDE_URL; } -function configureCommandPath() { +export function configureCommandPath() { return path.join(here, "configure.mjs"); } diff --git a/package.json b/package.json index ab3139d..d181a04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin", - "version": "0.1.13", + "version": "0.1.14", "private": true, "type": "module", "description": "JFrog skills and MCP server plugin for OpenAI Codex.",