diff --git a/README.md b/README.md index e6c6481..f8d4457 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,41 @@ OpenCode then discovers the skills the same way it discovers any skill — they tool and `/skills`, and the agent invokes them when relevant. There is no runtime download, unzip, or network call on load. +## JFrog Platform MCP + +When the environment is configured, the plugin also registers the **JFrog Platform remote MCP server** +(`https:///mcp`) into `config.mcp.jfrog`, so the JFrog platform tools appear in OpenCode +alongside the skills. + +**Prerequisites — both must be set:** + +- `JFROG_URL` — your JFrog platform URL (e.g. `https://mycompany.jfrog.io`). The legacy `JF_URL` and the + `JFROG_PLATFORM_URL` (Cursor-compat) names are also accepted. +- `JFROG_ACCESS_TOKEN` — a **JWT access token** created with `jf access-token-create` (or the legacy + `JF_ACCESS_TOKEN`). This **must be a JWT access token, not a 64-character reference token** — reference + tokens are rejected by the `/mcp` endpoint. + +The MCP is authenticated with the token directly (`Authorization: Bearer …`, `oauth: false`), so it works +headlessly with no interactive browser sign-in. Registration is a pure config mutation — there is no +network call on plugin load. + +**Opt-out:** set `JFROG_MCP_DISABLE=true` to skip MCP registration entirely. You can also scope the +exposed tools via OpenCode's `tools` globbing. If you define your own `mcp.jfrog` server in your config, +the plugin leaves it untouched. + +**Context cost:** the JFrog MCP exposes ~56 tools whose schemas are loaded into the model context on +every request (OpenCode has no lazy tool loading), measured at roughly **+32K tokens per request** in +OpenCode (~44K in Cursor). The MCP is enabled by default for parity with the JFrog Cursor/Claude plugins; +if that overhead matters for your workflow, disable it with `JFROG_MCP_DISABLE=true` or narrow the +surface with `tools` globbing. The bundled **skills** do not carry this cost — only their short +descriptions stay in context, and a skill's body loads only when it is invoked. + +**Token handling:** OpenCode does not expand `{env:…}` placeholders in config that a plugin injects at +runtime, so the plugin reads `JFROG_ACCESS_TOKEN` from the environment and sets the resolved +`Authorization: Bearer ` header directly. The token therefore lives in the in-memory session +config (sourced from your environment); the plugin itself never writes it to disk. Prefer a short-lived +token (`jf atc --expiry=…`). + ## Updating the bundled skills The skills are vendored at a pinned version. Updating them is a build-time step and **requires a new @@ -83,6 +118,12 @@ Logs are written to `/.opencode/event-log.txt`. If you see a **"bundled skills not found"** error (a toast in the TUI and/or an `ERROR` line in the log), the installed package is incomplete or corrupted — reinstall `@jfrog/opencode-jfrog-plugin`. +If the JFrog MCP shows **`401` / an SSE error** in `opencode mcp list` (or the TUI), the `/mcp` endpoint +rejected the token. Make sure `JFROG_ACCESS_TOKEN` is a **JWT** access token (`jf atc`), not a 64-char +reference token, and that it was issued for the same platform as `JFROG_URL` (check `jf c show`). MCP +connection status is surfaced by OpenCode itself — this plugin only registers the server. With +`JFROG_DEBUG_LOGS=true`, a non-JWT token also produces a `WARNING` line in the event log. + ## Upgrading from < 0.0.3 This release changes behavior in ways that are **not** backward compatible: diff --git a/src/index.test.ts b/src/index.test.ts index d820f64..b1fbb4a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,16 @@ // (c) JFrog Ltd. (2026) -import { describe, it, expect, mock } from 'bun:test'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Config, PluginInput } from '@opencode-ai/plugin'; @@ -30,6 +40,32 @@ function skillsOf(config: Config): { paths?: string[] } | undefined { return (config as { skills?: { paths?: string[] } }).skills; } +type McpEntry = { + type?: string; + url?: string; + oauth?: boolean; + enabled?: boolean; + headers?: Record; +}; + +function mcpOf(config: Config): Record | undefined { + return (config as { mcp?: Record }).mcp; +} + +function toastCount(client: PluginInput['client'], substr: string): number { + const showToast = client.tui.showToast as unknown as ReturnType; + return showToast.mock.calls.filter((args) => + String((args[0] as { body?: { message?: string } })?.body?.message ?? '').includes(substr) + ).length; +} + +async function runBash(hooks: Awaited>, command: string): Promise { + await hooks['tool.execute.before']?.( + { tool: 'bash', sessionID: 's', callID: 'c' } as never, + { args: { command } } as never + ); +} + describe('jfrog opencode plugin exports', () => { it('exposes the same plugin as server and JfrogOpencodePlugin', () => { expect(server).toBe(JfrogOpencodePlugin); @@ -37,10 +73,10 @@ describe('jfrog opencode plugin exports', () => { }); describe('JfrogOpencodePlugin config hook', () => { - it('returns only a config hook (no event hook)', async () => { + it('returns config and tool.execute.before hooks', async () => { const hooks = await server(pluginInput()); expect(hooks.config).toBeDefined(); - expect((hooks as { event?: unknown }).event).toBeUndefined(); + expect(hooks['tool.execute.before']).toBeDefined(); }); it('adds the bundled skills dir to config.skills.paths (object form)', async () => { @@ -62,18 +98,116 @@ describe('JfrogOpencodePlugin config hook', () => { expect(bundled.length).toBe(1); }); - it('shows the `jf setup` nudge only once across multiple config calls', async () => { + it('does not toast a setup hint from the config hook', async () => { const client = createClient(); const hooks = await server(pluginInput(client)); - const config = {} as Config; - await hooks.config?.(config); - await hooks.config?.(config); - const showToast = client.tui.showToast as unknown as ReturnType; - const nudges = showToast.mock.calls.filter((args) => { - const message = (args[0] as { body?: { message?: string } })?.body?.message ?? ''; - return message.includes('jf setup'); - }); - expect(nudges.length).toBe(1); + await hooks.config?.({} as Config); + expect(toastCount(client, 'JFrog:')).toBe(0); + }); +}); + +// Just-in-time setup hints surfaced from the tool hook on the first `jf` command. +describe('JFrog setup hints (tool.execute.before)', () => { + const ENV_KEYS = [ + 'PATH', + 'JFROG_URL', + 'JF_URL', + 'JFROG_PLATFORM_URL', + 'JFROG_ACCESS_TOKEN', + 'JF_ACCESS_TOKEN', + 'JFROG_MCP_DISABLE', + ]; + let saved: Record; + let bin: string | undefined; + + beforeEach(() => { + saved = {}; + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + } + // Default scenario: jf absent + MCP env absent. + process.env.PATH = ''; + for (const key of ENV_KEYS.slice(1)) { + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + if (bin) { + rmSync(bin, { recursive: true, force: true }); + bin = undefined; + } + }); + + function installJf(): void { + bin = mkdtempSync(join(tmpdir(), 'jfbin-')); + const jfPath = join(bin, 'jf'); + writeFileSync(jfPath, '#!/bin/sh\n'); + chmodSync(jfPath, 0o755); + process.env.PATH = bin; + } + + it('hints to install the CLI when `jf` is missing (on a jf command)', async () => { + const client = createClient(); + const hooks = await server(pluginInput(client)); + await runBash(hooks, 'jf rt ping'); + expect(toastCount(client, 'was not found on your PATH')).toBe(1); + }); + + it('shows NO hint when `jf` is present (MCP setup is surfaced by OpenCode + README, not toasts)', async () => { + installJf(); + // Even a non-JWT token / missing env produces no toast — those are not the plugin's concern now. + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'cmVmdGtuOnJlZmVyZW5jZQ'; + const client = createClient(); + const hooks = await server(pluginInput(client)); + await runBash(hooks, 'jf rt ping'); + expect(toastCount(client, 'JFrog:')).toBe(0); + }); + + it('shows only the install hint when `jf` is absent, regardless of MCP env', async () => { + // PATH='' (jf absent) is the describe default. + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'eyJhbGciOiJSUzI1NiJ9.payload.sig'; + const client = createClient(); + const hooks = await server(pluginInput(client)); + await runBash(hooks, 'jf rt ping'); + expect(toastCount(client, 'was not found on your PATH')).toBe(1); + expect(toastCount(client, 'JFrog:')).toBe(1); + }); + + it('shows at most one hint per session', async () => { + const client = createClient(); + const hooks = await server(pluginInput(client)); + await runBash(hooks, 'jf rt ping'); + await runBash(hooks, 'jf c show'); + expect(toastCount(client, 'JFrog:')).toBe(1); + }); + + it('does not hint for non-jf bash commands', async () => { + const client = createClient(); + const hooks = await server(pluginInput(client)); + await runBash(hooks, 'npm install lodash'); + await runBash(hooks, 'echo jfrog'); // `jf` is not a standalone command here + expect(toastCount(client, 'JFrog:')).toBe(0); + }); + + it('does not hint for non-bash tools', async () => { + const client = createClient(); + const hooks = await server(pluginInput(client)); + await hooks['tool.execute.before']?.( + { tool: 'read', sessionID: 's', callID: 'c' } as never, + { args: { command: 'jf rt ping' } } as never + ); + expect(toastCount(client, 'JFrog:')).toBe(0); }); }); @@ -111,3 +245,145 @@ describe('vendored skills content sanity (V9)', () => { }); } }); + +// JFrog Platform remote MCP injection via the config hook (token auth, headless). +describe('JfrogOpencodePlugin JFrog remote MCP injection', () => { + const ENV_KEYS = [ + 'JFROG_URL', + 'JF_URL', + 'JFROG_PLATFORM_URL', + 'JFROG_ACCESS_TOKEN', + 'JF_ACCESS_TOKEN', + 'JFROG_MCP_DISABLE', + ]; + let savedEnv: Record; + + beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + async function runConfig(): Promise { + const hooks = await server(pluginInput()); + const config = {} as Config; + await hooks.config?.(config); + return config; + } + + it('injects a remote jfrog MCP when JFROG_URL + JFROG_ACCESS_TOKEN are set', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + const jfrog = mcpOf(await runConfig())?.jfrog; + expect(jfrog).toBeDefined(); + expect(jfrog?.type).toBe('remote'); + expect(jfrog?.url).toBe('https://example.jfrog.io/mcp'); + expect(jfrog?.oauth).toBe(false); + expect(jfrog?.enabled).toBe(true); + }); + + it('injects the resolved token into the Authorization header', async () => { + // OpenCode does not expand {env:} in plugin-injected config, so the token value is materialized. + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'eyJresolvedtokenvalue'; + const jfrog = mcpOf(await runConfig())?.jfrog; + expect(jfrog?.headers?.Authorization).toBe('Bearer eyJresolvedtokenvalue'); + }); + + it('normalizes scheme and trailing slash in the host', async () => { + process.env.JFROG_URL = 'https://x.jfrog.io/'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://x.jfrog.io/mcp'); + }); + + it('preserves an explicit http:// scheme (no silent https upgrade)', async () => { + process.env.JFROG_URL = 'http://internal.corp/'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.url).toBe('http://internal.corp/mcp'); + }); + + it('defaults to https:// when the host omits a scheme', async () => { + process.env.JFROG_URL = 'bare.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://bare.jfrog.io/mcp'); + }); + + it('accepts the legacy JF_URL host name', async () => { + process.env.JF_URL = 'legacy.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://legacy.jfrog.io/mcp'); + }); + + it('accepts the cursor-compat JFROG_PLATFORM_URL host name', async () => { + process.env.JFROG_PLATFORM_URL = 'cursor.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())?.jfrog?.url).toBe('https://cursor.jfrog.io/mcp'); + }); + + it('uses the legacy JF_ACCESS_TOKEN value when only it is set', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JF_ACCESS_TOKEN = 'eyJlegacytokenvalue'; + const auth = mcpOf(await runConfig())?.jfrog?.headers?.Authorization; + expect(auth).toBe('Bearer eyJlegacytokenvalue'); + }); + + it('skips injection when the host is missing', async () => { + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + expect(mcpOf(await runConfig())).toBeUndefined(); + }); + + it('skips injection when the token is missing', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + expect(mcpOf(await runConfig())).toBeUndefined(); + }); + + it('registers the MCP even when the token is not a JWT (shape only warns, never gates)', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'reference-token-not-a-jwt'; + const jfrog = mcpOf(await runConfig())?.jfrog; + expect(jfrog).toBeDefined(); + expect(jfrog?.url).toBe('https://example.jfrog.io/mcp'); + expect(jfrog?.headers?.Authorization).toBe('Bearer reference-token-not-a-jwt'); + }); + + it('skips injection when JFROG_MCP_DISABLE=true', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + process.env.JFROG_MCP_DISABLE = 'true'; + expect(mcpOf(await runConfig())).toBeUndefined(); + }); + + it('does not overwrite a user-defined jfrog MCP entry', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + const existing: McpEntry = { type: 'remote', url: 'https://user.example/mcp', enabled: false }; + const hooks = await server(pluginInput()); + const config = { mcp: { jfrog: existing } } as unknown as Config; + await hooks.config?.(config); + expect(mcpOf(config)?.jfrog).toEqual(existing); + }); + + it('is idempotent across repeated config calls', async () => { + process.env.JFROG_URL = 'https://example.jfrog.io'; + process.env.JFROG_ACCESS_TOKEN = 'jwt-token'; + const hooks = await server(pluginInput()); + const config = {} as Config; + await hooks.config?.(config); + const first = mcpOf(config)?.jfrog; + await hooks.config?.(config); + expect(mcpOf(config)?.jfrog).toEqual(first); + }); +}); diff --git a/src/index.ts b/src/index.ts index 8ab46f8..7476f39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,79 +1,225 @@ // (c) JFrog Ltd. (2026) import type { Config, Plugin } from '@opencode-ai/plugin'; -import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; -import { dirname, join } from 'path'; +import { + accessSync, + appendFileSync, + constants, + existsSync, + mkdirSync, + readdirSync, + statSync, +} from 'fs'; +import { delimiter, dirname, join } from 'path'; import { fileURLToPath } from 'url'; +// ── Constants ───────────────────────────────────────────────────────────────── + const LOG_FILE = join(process.cwd(), '.opencode', 'event-log.txt'); -// dist/index.js -> ../skills after build/install; src/index.ts -> ../skills in dev. +// Works for both src/index.ts (dev) and dist/index.js (installed): `..` lands on skills/ in both. const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills'); +// Env var names, in precedence order (new JFROG_* first, then legacy JF_* / Cursor's JFROG_PLATFORM_URL). +const HOST_ENV_VARS = ['JFROG_URL', 'JF_URL', 'JFROG_PLATFORM_URL'] as const; +const TOKEN_ENV_VARS = ['JFROG_ACCESS_TOKEN', 'JF_ACCESS_TOKEN'] as const; + +const JF_CLI_INSTALL_HINT = + 'JFrog: the `jf` CLI was not found on your PATH. Install it ' + + '(https://jfrog.com/getting-started-with-jfrog-cli/) to run JFrog commands and `jf setup `.'; + +// ── Types ───────────────────────────────────────────────────────────────────── + type Logger = (_message: string) => void; -type ConfigWithSkills = Config & { skills?: { paths?: string[] } }; +type Toast = (_message: string, _variant: 'error' | 'info') => void; +type ConfigWithJfrog = Config & { + skills?: { paths?: string[] }; + mcp?: Record; +}; +type McpCredentials = { baseUrl: string; tokenVar: string }; +type McpServer = NonNullable[string]; + +// ── Pure helpers ──────────────────────────────────────────────────────────────── const isNonEmptyDir = (dir: string): boolean => { - if (!existsSync(dir)) { - return false; - } try { - return statSync(dir).isDirectory() && readdirSync(dir).length > 0; + return existsSync(dir) && statSync(dir).isDirectory() && readdirSync(dir).length > 0; } catch { return false; } }; +/** + * True if `cmd` is found as an executable on PATH. Cheap synchronous scan; spawns no subprocess. + * Cross-platform: uses the OS PATH delimiter and probes Windows executable extensions. + */ +const commandExists = (cmd: string): boolean => { + const names = process.platform === 'win32' ? [`${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd]; + return (process.env.PATH ?? '').split(delimiter).some((dir) => + !dir + ? false + : names.some((name) => { + try { + accessSync(join(dir, name), constants.X_OK); + return true; + } catch { + return false; + } + }) + ); +}; + +const firstDefinedEnv = (names: readonly string[]): string | undefined => + names.map((name) => process.env[name]).find((value) => !!value); + +// Preserve an explicit http/https scheme (default https when none); strip trailing slashes. +const toBaseUrl = (raw: string): string => { + const trimmed = raw.replace(/\/+$/, ''); + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +}; + +const isJfCommand = (command: string): boolean => /(?:^|[\s;&|(])jf(?:\s|$)/.test(command); + +/** JFrog JWT access tokens are base64url JWTs that begin with `eyJ`; reference tokens do not. */ +const looksLikeJwt = (token: string): boolean => token.startsWith('eyJ'); + +/** + * Resolve the JFrog MCP host + token env var name from the environment. + * Returns undefined when MCP is disabled or either value is absent. + */ +const resolveMcpCredentials = (): McpCredentials | undefined => { + if (process.env.JFROG_MCP_DISABLE === 'true') { + return undefined; + } + const host = firstDefinedEnv(HOST_ENV_VARS); + const tokenVar = TOKEN_ENV_VARS.find((name) => process.env[name]); + return host && tokenVar ? { baseUrl: toBaseUrl(host), tokenVar } : undefined; +}; + +/** + * Build the OpenCode remote-MCP entry with the resolved Bearer token. + * + * Note: OpenCode does NOT expand `{env:...}` in config injected by a plugin at runtime (it only + * templates values loaded from opencode.json), so the token value must be materialized here. It comes + * from the user's own environment and is used in-memory for the connection. + */ +const mcpServerEntry = ({ baseUrl }: McpCredentials, token: string): McpServer => ({ + type: 'remote', + url: `${baseUrl}/mcp`, + oauth: false, + headers: { Authorization: `Bearer ${token}` }, + enabled: true, +}); + +// ── Config mutators (side-effecting, but localized) ─────────────────────────────── + +// The config hook runs multiple times per session; surface the broken-package error only once. +let skillsErrorShown = false; + +/** Register the bundled skills dir. Returns false (and toasts once) when the package is broken. */ +const registerSkills = (cfg: ConfigWithJfrog, log: Logger, toast: Toast): boolean => { + cfg.skills = cfg.skills ?? {}; + cfg.skills.paths = cfg.skills.paths ?? []; + + if (!isNonEmptyDir(BUNDLED_SKILLS_DIR)) { + if (!skillsErrorShown) { + skillsErrorShown = true; + const message = + `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + + 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; + log('ERROR ' + message); + toast(message, 'error'); + } + return false; + } + + if (!cfg.skills.paths.includes(BUNDLED_SKILLS_DIR)) { + cfg.skills.paths.push(BUNDLED_SKILLS_DIR); + } + log('config.skills.paths=' + JSON.stringify(cfg.skills.paths)); + return true; +}; + +/** Inject the JFrog Platform remote MCP. No network on load; never clobbers a user-defined `jfrog`. */ +const registerMcp = (cfg: ConfigWithJfrog, log: Logger): void => { + const credentials = resolveMcpCredentials(); + if (!credentials) { + log( + 'mcp: jfrog remote MCP not registered (need JFROG_URL + JFROG_ACCESS_TOKEN; or JFROG_MCP_DISABLE=true)' + ); + return; + } + + const token = process.env[credentials.tokenVar] ?? ''; + if (!looksLikeJwt(token)) { + log( + `mcp: WARNING ${credentials.tokenVar} does not look like a JWT access token; the MCP will likely ` + + 'fail with HTTP 401. Create one with `jf atc` (a reference token will not work).' + ); + } + + cfg.mcp = cfg.mcp ?? {}; + if (cfg.mcp.jfrog) { + return; + } + cfg.mcp.jfrog = mcpServerEntry(credentials, token); + log(`mcp: registered jfrog remote MCP at ${credentials.baseUrl}/mcp`); +}; + +// ── Plugin ──────────────────────────────────────────────────────────────────── + /** OpenCode loads plugins via the `server` export (see `PluginModule` in @opencode-ai/plugin). */ const jfrogOpencodePlugin: Plugin = async ({ client }) => { - const logDir = dirname(LOG_FILE); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); + if (!existsSync(dirname(LOG_FILE))) { + mkdirSync(dirname(LOG_FILE), { recursive: true }); } + const log: Logger = (message) => { if (process.env.JFROG_DEBUG_LOGS === 'true') { appendFileSync(LOG_FILE, message + '\n', 'utf-8'); } }; - // Fire-and-forget: showToast never resolves in headless sessions (no TUI to ack it), so awaiting it - // would hang the config hook. Durable signals always go through log() regardless of UI. - const toast = (message: string, variant: 'error' | 'info'): void => { + + // Fire-and-forget: in headless sessions showToast never resolves, awaiting it would hang the hook. + const toast: Toast = (message, variant) => { void client.tui .showToast({ body: { message, variant, duration: 10000 } }) .catch(() => undefined); }; + log('JfrogOpencodePlugin starting...'); - // The config hook can run multiple times per session; nudge the user only once. - let nudgeShown = false; + // Detect the JFrog CLI ONCE at load (cached for the session) so the per-tool hook stays a cheap + // boolean check. MCP setup issues (missing env, bad/non-JWT token, 401) are surfaced by OpenCode's + // own `mcp list`/TUI and documented in the README — the plugin does not nag for those. + const hasJfCli = commandExists('jf'); + log('jf CLI on PATH: ' + hasJfCli); + + // Nudge to install the CLI just-in-time — on the first `jf` command — at most once per session. A + // tool hook fires mid-session (TUI live); a config-hook toast would be dropped at bootstrap before + // the TUI subscribes to events. + let installHintShown = false; + const adviseInstallOnce = (): void => { + if (installHintShown || hasJfCli) { + return; + } + installHintShown = true; + toast(JF_CLI_INSTALL_HINT, 'info'); + }; return { config: async (config) => { - const cfg = config as ConfigWithSkills; - cfg.skills = cfg.skills ?? {}; - cfg.skills.paths = cfg.skills.paths ?? []; - - // Fail loud: a missing/empty bundled dir means a broken build/package. - if (!isNonEmptyDir(BUNDLED_SKILLS_DIR)) { - const message = - `JFrog: bundled skills not found at ${BUNDLED_SKILLS_DIR}. ` + - 'The plugin package may be broken; reinstall @jfrog/opencode-jfrog-plugin.'; - log('ERROR ' + message); - toast(message, 'error'); + const cfg = config as ConfigWithJfrog; + // Skills and MCP are independent features — register both even if one is broken. + registerSkills(cfg, log, toast); + registerMcp(cfg, log); + }, + 'tool.execute.before': async (input, output) => { + if (installHintShown || hasJfCli || input.tool !== 'bash') { return; } - - if (!cfg.skills.paths.includes(BUNDLED_SKILLS_DIR)) { - cfg.skills.paths.push(BUNDLED_SKILLS_DIR); - } - log('config.skills.paths=' + JSON.stringify(cfg.skills.paths)); - - // R2 interim nudge until package-manager setup is handled by a skill. - if (!nudgeShown) { - nudgeShown = true; - toast( - 'JFrog: run `jf setup ` to configure package managers against Artifactory.', - 'info' - ); + const command = String((output.args as { command?: string })?.command ?? ''); + if (isJfCommand(command)) { + adviseInstallOnce(); } }, };