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 .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "JFrog Platform plugins for Cursor",
"version": "0.5.4",
"version": "0.5.5",
"pluginRoot": "plugins"
},
"plugins": [
Expand Down
7 changes: 7 additions & 0 deletions .github/scripts/sync-modules-vendor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"repo": "JFROG/jfrog-agent-hooks",
"pin": "jfrog-agent-hooks/v0.5.0",
"paths": [
"modules"
]
}
2 changes: 1 addition & 1 deletion plugins/jfrog/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jfrog",
"displayName": "JFrog Platform",
"version": "0.5.4",
"version": "0.5.5",
"description": "JFrog Platform integration with MCP, security skills, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.",
"author": {
"name": "JFrog",
Expand Down
17 changes: 17 additions & 0 deletions plugins/jfrog/modules/assets/agents-default-conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"logLevel": "info",
"packageResolution": {
"enabled": false,
"verifyRepos": true,
"cacheTtlDays": 7,
"defaultGlobalRepos": {
"npm": "npm-virtual",
"pypi": "pypi-virtual",
"maven": "maven-virtual",
"go": "go-virtual",
"docker": "docker-virtual",
"helm": "helm-virtual",
"nuget": "nuget-virtual"
}
}
}
71 changes: 71 additions & 0 deletions plugins/jfrog/modules/claude-session-start.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env node
// Claude Code SessionStart hook runner.
//
// Usage: node claude-session-start.mjs <capability>
// Example: node claude-session-start.mjs package-resolution
//
// stdout: JSON with hookSpecificOutput.additionalContext. No stdout is a no-op.

import process from "node:process";

import { runCapability } from "./core/run-capability.mjs";
import { ensureAgentsConfigScaffold, agentsConfigLoadWarnings } from "./core/agents-config.mjs";
import { readStdin, parseSessionId, detectHarness, parseWorkspaceRoots } from "./core/io.mjs";
import { setLogContext, createLogger } from "./core/logger.mjs";

const HARNESS_ID = "claude_code";
const log = createLogger("session-start");

/** @returns {string | null} JSON stdout payload, or null when there is nothing to inject. */
function formatSessionStartStdout(text) {
if (!text?.trim()) return null;
return JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: text,
},
});
}

function writeStdout(payload) {
if (payload !== null) process.stdout.write(payload);
}

function writeNoOp() {
// Claude SessionStart: no stdout on no-op.
}

async function main() {
const capability = process.argv[2];
if (!capability) {
writeNoOp();
return;
}

const startedAtMs = Date.now();
const stdinRaw = await readStdin();
const harness = detectHarness(stdinRaw);
if (harness && harness !== HARNESS_ID) {
writeNoOp();
return;
}
const sessionId = parseSessionId(stdinRaw);
const workspaceRoots = parseWorkspaceRoots(stdinRaw);
setLogContext({ ide: HARNESS_ID, sessionId });
ensureAgentsConfigScaffold();
for (const w of agentsConfigLoadWarnings()) {
log.warn(w.message, { path: w.path });
}
const text = await runCapability(capability, {
ide: HARNESS_ID,
sessionId,
workspaceRoots,
startedAtMs,
});
writeStdout(formatSessionStartStdout(text));
}

main().catch(() => {
writeNoOp();
process.exit(0);
});
184 changes: 184 additions & 0 deletions plugins/jfrog/modules/core/agents-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Local admin config at ~/.jfrog/agents-conf.json (shipped template: assets/agents-default-conf.json).
//
// Read-only helpers — no network. Session starters call ensureAgentsConfigScaffold()
// before capabilities run so first-time installs get a writable config file.

import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

/** modules bundle root (parent of core/ and assets/). */
const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

const TEMPLATE_PATH = path.join(PLUGIN_ROOT, "assets", "agents-default-conf.json");

const DEFAULT_LOG_LEVEL = "info";
const DEFAULT_CACHE_TTL_DAYS = 7;

let memoizedRaw = undefined;
let memoizedForPath = null;
/** @type {{ source: 'missing' | 'user' | 'template', parseFailed: boolean, path: string }} */
let loadMeta = { source: "missing", parseFailed: false, path: "" };

function agentsConfigPath() {
return path.join(homedir(), ".jfrog", "agents-conf.json");
}

function resetLoadMeta(configPath) {
loadMeta = { source: "missing", parseFailed: false, path: configPath };
}

/**
* Copy the shipped template to ~/.jfrog/agents-conf.json when missing.
* Never overwrites an existing file.
*/
export function ensureAgentsConfigScaffold() {
const configPath = agentsConfigPath();
if (existsSync(configPath)) return { created: false, path: configPath };
try {
mkdirSync(path.dirname(configPath), { recursive: true });
copyFileSync(TEMPLATE_PATH, configPath);
memoizedRaw = undefined;
return { created: true, path: configPath };
} catch {
return { created: false, path: configPath };
}
}

export { agentsConfigPath };

export function agentsConfigTemplatePath() {
return TEMPLATE_PATH;
}

/** @returns {number | null} mtime in ms, or null when the file is absent */
export function getAgentsConfigMtimeMs() {
try {
return statSync(agentsConfigPath()).mtimeMs;
} catch {
return null;
}
}

function parseAgentsJson(raw) {
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}

function readAgentsConfigRaw() {
const configPath = agentsConfigPath();
if (memoizedForPath !== configPath) {
memoizedRaw = undefined;
memoizedForPath = configPath;
resetLoadMeta(configPath);
}
if (memoizedRaw !== undefined) return memoizedRaw;

const userExists = existsSync(configPath);
if (userExists) {
try {
const parsed = parseAgentsJson(readFileSync(configPath, "utf8"));
if (parsed) {
memoizedRaw = parsed;
loadMeta = { source: "user", parseFailed: false, path: configPath };
return memoizedRaw;
}
loadMeta = { source: "template", parseFailed: true, path: configPath };
} catch {
loadMeta = { source: "template", parseFailed: true, path: configPath };
}
}

try {
memoizedRaw = parseAgentsJson(readFileSync(TEMPLATE_PATH, "utf8"));
if (!userExists) {
loadMeta = { source: memoizedRaw ? "template" : "missing", parseFailed: false, path: configPath };
}
} catch {
memoizedRaw = null;
if (!userExists) loadMeta = { source: "missing", parseFailed: false, path: configPath };
}
return memoizedRaw;
}

/** Call after loadAgentsConfig() — surfaces user-file parse failures. */
export function getAgentsConfigLoadMeta() {
readAgentsConfigRaw();
return { ...loadMeta };
}

/** @returns {Array<{ message: string, path: string }>} */
export function agentsConfigLoadWarnings() {
loadAgentsConfig();
if (!loadMeta.parseFailed) return [];
return [
{
message: "agents-conf.json unreadable; using shipped template defaults",
path: loadMeta.path,
},
];
}

/** @returns {object | null} raw section or null */
export function getAgentsConfigSection(name) {
const config = readAgentsConfigRaw();
if (!config) return null;
const section = config[name];
return section && typeof section === "object" ? section : null;
}

/** @returns {{ logLevel: string, packageResolution: object }} merged with documented defaults */
export function loadAgentsConfig() {
const file = readAgentsConfigRaw() ?? {};
const pr =
file.packageResolution && typeof file.packageResolution === "object"
? file.packageResolution
: {};
const defaultGlobalRepos =
pr.defaultGlobalRepos && typeof pr.defaultGlobalRepos === "object"
? normalizeRepoMap(pr.defaultGlobalRepos)
: {};

return {
logLevel: normalizeLogLevel(file.logLevel),
packageResolution: {
enabled: pr.enabled === true,
verifyRepos: pr.verifyRepos !== false,
cacheTtlDays: normalizeCacheTtlDays(pr.cacheTtlDays),
defaultGlobalRepos,
},
};
}

export function getGlobalLogLevel() {
return loadAgentsConfig().logLevel;
}

function normalizeLogLevel(level) {
const s = typeof level === "string" ? level.toLowerCase() : "";
const allowed = new Set(["silent", "debug", "info", "warn", "error"]);
return allowed.has(s) ? s : DEFAULT_LOG_LEVEL;
}

function normalizeCacheTtlDays(days) {
if (days === 0) return 0;
if (typeof days !== "number" || !Number.isFinite(days) || days < 0) {
return DEFAULT_CACHE_TTL_DAYS;
}
return Math.floor(days);
}

/** Trim string repo keys; drop empty values. */
export function normalizeRepoMap(raw) {
if (!raw || typeof raw !== "object") return {};
const out = {};
for (const [type, key] of Object.entries(raw)) {
if (typeof key === "string" && key.trim()) out[type] = key.trim();
}
return out;
}
95 changes: 95 additions & 0 deletions plugins/jfrog/modules/core/io.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Shared stdin helpers for the subprocess-style adapters (Claude, Cursor).
//
// Hooks deliver their JSON payload on stdin immediately; in non-hook contexts
// (CI, npm scripts, terminal smoke tests) nothing arrives, so we bail out after
// a short idle window rather than hang.

import process from "node:process";

export function readStdin({ idleMs = 50 } = {}) {
return new Promise((resolve) => {
if (process.stdin.isTTY) return resolve("");
let data = "";
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
resolve(data);
};
const idleTimer = setTimeout(settle, idleMs);
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
idleTimer.refresh();
});
process.stdin.on("end", () => {
clearTimeout(idleTimer);
settle();
});
process.stdin.on("error", () => {
clearTimeout(idleTimer);
settle();
});
});
}

export function parseSessionId(stdinRaw) {
if (!stdinRaw) return undefined;
try {
return JSON.parse(stdinRaw)?.session_id;
} catch {
return undefined;
}
}

// Positively identify the harness that invoked this hook from its stdin
// payload. Returns "cursor", "claude_code", or null when it can't tell
// (no stdin — e.g. terminal smoke tests — or an unrecognized shape).
//
// Why this matters: Cursor reads sessionStart hooks from BOTH
// ~/.cursor/hooks.json AND ~/.claude/settings.json. Without this, a Cursor
// session fires the Claude adapter too, double-injecting the policy. Each
// adapter uses this to no-op when a different harness invoked it.
//
// Cursor: cursor_version / agent_type. Claude: transcript_path / hook_event_name /
// session_id. Cursor also reads ~/.claude/settings.json, so each adapter no-ops
// when a different harness invoked it.
export function detectHarness(stdinRaw) {
if (!stdinRaw) return null;
try {
const p = JSON.parse(stdinRaw);
if (!p) return null;
if (p.cursor_version || p.agent_type === "cursor") {
return "cursor";
}
if (p.transcript_path || p.hook_event_name || p.session_id) {
return "claude_code";
}
} catch {
// stdin wasn't JSON — can't tell.
}
return null;
}

/**
* Workspace roots for this hook invocation.
* Cursor: workspace_roots[]. Claude: payload cwd. Fallback: process.cwd().
*
* @param {string} [stdinRaw]
* @returns {string[]}
*/
export function parseWorkspaceRoots(stdinRaw) {
if (stdinRaw?.trim()) {
try {
const p = JSON.parse(stdinRaw);
if (Array.isArray(p.workspace_roots) && p.workspace_roots.length) {
return p.workspace_roots.filter((r) => typeof r === "string" && r);
}
if (typeof p.cwd === "string" && p.cwd) return [p.cwd];
} catch {
// fall through
}
}

return [process.cwd()];
}
Loading
Loading