diff --git a/apps/example/.env.example b/apps/example/.env.example index 8d15398cb..2e6dfb71c 100644 --- a/apps/example/.env.example +++ b/apps/example/.env.example @@ -1,6 +1,8 @@ SLACK_BOT_TOKEN= SLACK_SIGNING_SECRET= JUNIOR_SECRET= +DATABASE_URL= +JUNIOR_DATABASE_URL= REDIS_URL= JUNIOR_BOT_NAME=junior-example GITHUB_APP_ID= diff --git a/apps/example/README.md b/apps/example/README.md index 63b388a34..eeaca6283 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -7,7 +7,7 @@ It demonstrates: - one local skill (`/example-local`) - one plugin-bundled skill (`/example-bundle-help`) - one bundle-only plugin (`app/plugins/example-bundle/plugin.yaml`) with no credential broker config -- installed plugin packages (`@sentry/junior-agent-browser`, `@sentry/junior-datadog`, `@sentry/junior-github`, `@sentry/junior-hex`, `@sentry/junior-linear`, `@sentry/junior-notion`, `@sentry/junior-sentry`, `@sentry/junior-vercel`) +- installed plugin packages (`@sentry/junior-agent-browser`, `@sentry/junior-datadog`, `@sentry/junior-github`, `@sentry/junior-hex`, `@sentry/junior-linear`, `@sentry/junior-notion`, `@sentry/junior-scheduler`, `@sentry/junior-sentry`, `@sentry/junior-vercel`) ## Run @@ -28,6 +28,7 @@ Copy `.env.example` and set: - `SLACK_BOT_TOKEN` - `SLACK_SIGNING_SECRET` +- `DATABASE_URL` or `JUNIOR_DATABASE_URL` - `REDIS_URL` - `AI_MODEL` (optional) - `AI_FAST_MODEL` (optional) diff --git a/apps/example/package.json b/apps/example/package.json index 115379362..47cb0f1b1 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", + "predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-scheduler build", "dev": "nitro dev", - "prebuild": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", + "prebuild": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior-scheduler build", "build": "junior snapshot create && nitro build", "postbuild": "node scripts/check-vercel-output.mjs", "preview": "nitro preview", @@ -20,6 +20,7 @@ "@sentry/junior-hex": "workspace:*", "@sentry/junior-linear": "workspace:*", "@sentry/junior-notion": "workspace:*", + "@sentry/junior-scheduler": "workspace:*", "@sentry/junior-sentry": "workspace:*", "@sentry/junior-vercel": "workspace:*", "hono": "^4.12.22" diff --git a/apps/example/plugins.ts b/apps/example/plugins.ts index 85f78fc65..df28fce13 100644 --- a/apps/example/plugins.ts +++ b/apps/example/plugins.ts @@ -1,6 +1,7 @@ import { defineJuniorPlugins } from "@sentry/junior"; import { juniorDashboardPlugin } from "@sentry/junior-dashboard"; import { githubPlugin } from "@sentry/junior-github"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; import { exampleDashboardAuthRequired, exampleDashboardMockConversations, @@ -25,6 +26,7 @@ export const plugins = defineJuniorPlugins([ "@sentry/junior-hex", "@sentry/junior-linear", "@sentry/junior-notion", + schedulerPlugin(), "@sentry/junior-sentry", "@sentry/junior-vercel", ]); diff --git a/packages/junior/package.json b/packages/junior/package.json index 8b2aee791..dd7907cbc 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -79,6 +79,7 @@ "chat": "4.29.0", "drizzle-orm": "catalog:", "hono": "^4.12.22", + "jiti": "^2.7.0", "jose": "^6.2.3", "just-bash": "3.0.1", "node-html-markdown": "^2.0.0", diff --git a/packages/junior/skills/jr-rpc/SKILL.md b/packages/junior/skills/jr-rpc/SKILL.md index 90438d9c7..b57a00405 100644 --- a/packages/junior/skills/jr-rpc/SKILL.md +++ b/packages/junior/skills/jr-rpc/SKILL.md @@ -1,12 +1,20 @@ --- name: jr-rpc -description: Manage low-level config flows via jr-rpc bash commands, including setting a default GitHub repo. Use only when the user explicitly asks to read or update provider defaults/config. Do not use for PR, branch, push, or auth-order questions; load the matching provider skill instead. +description: Manage low-level config and plugin introspection flows via jr-rpc bash commands. Use only when the user explicitly asks to read or update provider defaults/config or list installed plugins. Do not use for PR, branch, push, or auth-order questions; load the matching provider skill instead. allowed-tools: bash --- # jr-rpc -Manage low-level config flows for the current agent turn. +Manage low-level config and plugin introspection flows for the current agent turn. + +## Plugins + +`jr-rpc plugins list` — list installed plugins visible to the current Junior runtime. + +Command syntax: + +- `jr-rpc plugins list` ## Configuration diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 0da4e13e0..ef6a0fa9a 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -9,7 +9,6 @@ import { generateAssistantReply } from "@/chat/respond"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress-tracing"; import { getPluginCatalogSignature, - getPluginProviders, setPluginCatalogConfig, } from "@/chat/plugins/registry"; import { @@ -20,9 +19,13 @@ import { } from "@/chat/plugins/agent-hooks"; import { validatePluginDatabaseRequirements } from "@/chat/plugins/db"; import type { PluginCatalogConfig } from "@/chat/plugins/types"; +import { + validatePluginEgressCredentialHooks, + validatePluginRegistrations, +} from "@/chat/plugins/validation"; import type { - PluginRouteMethod, PluginRegistration, + PluginRouteMethod, } from "@sentry/junior-plugin-api"; import { pluginCatalogConfigFromEnv, @@ -205,76 +208,6 @@ function validateBuildIncludesPluginHookRegistrations( ); } -function validatePluginRegistrations( - registrations: PluginRegistration[], -): void { - const loadedPlugins = getPluginProviders(); - const loadedNames = new Set( - loadedPlugins.map((plugin) => plugin.manifest.name), - ); - - for (const registration of registrations) { - if (!loadedNames.has(registration.manifest.name)) { - throw new Error( - `Plugin registration "${registration.manifest.name}" does not have a matching plugin manifest. Add an inline manifest, packageName, or app-local plugin.yaml with the same name.`, - ); - } - } -} - -function validatePluginEgressCredentialHooks( - registrations: PluginRegistration[], -): void { - const plugins = new Map( - registrations.map((registration) => [ - registration.manifest.name, - registration, - ]), - ); - - for (const provider of getPluginProviders()) { - const hooks = plugins.get(provider.manifest.name)?.hooks; - const hasGrantHook = Boolean(hooks?.grantForEgress); - const hasIssueHook = Boolean(hooks?.issueCredential); - const hasGenericCredentials = Boolean( - provider.manifest.credentials || provider.manifest.apiHeaders, - ); - const hasDomains = Boolean(provider.manifest.domains?.length); - const hasHookManagedOAuth = Boolean( - provider.manifest.oauth && !provider.manifest.credentials, - ); - if (!hasGrantHook && !hasIssueHook) { - if (hasDomains && !hasGenericCredentials) { - throw new Error( - `Plugin "${provider.manifest.name}" manifest.domains requires egress credential hooks when no generic credentials or apiHeaders are configured.`, - ); - } - if (hasHookManagedOAuth) { - throw new Error( - `Plugin "${provider.manifest.name}" manifest.oauth without oauth-bearer credentials requires egress credential hooks.`, - ); - } - continue; - } - - if (!hasGrantHook || !hasIssueHook) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks must include both grantForEgress and issueCredential.`, - ); - } - if (hasGenericCredentials) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks must use manifest.domains instead of generic credentials or apiHeaders.`, - ); - } - if (!hasDomains) { - throw new Error( - `Plugin "${provider.manifest.name}" egress credential hooks require manifest.domains to list sandbox egress hosts.`, - ); - } - } -} - /** Mount plugin HTTP handlers before core routes claim those paths. */ function mountPluginRoutes(app: Hono, routes: PluginRouteRegistration[]): void { for (const route of routes) { diff --git a/packages/junior/src/chat/capabilities/jr-rpc-command.ts b/packages/junior/src/chat/capabilities/jr-rpc-command.ts index 3e7656c76..213ce3d34 100644 --- a/packages/junior/src/chat/capabilities/jr-rpc-command.ts +++ b/packages/junior/src/chat/capabilities/jr-rpc-command.ts @@ -1,6 +1,7 @@ import { Bash, defineCommand } from "just-bash"; import type { ChannelConfigurationService } from "@/chat/configuration/types"; import { logInfo } from "@/chat/logging"; +import { getPluginProviders } from "@/chat/plugins/registry"; import type { Skill } from "@/chat/skills"; type JrRpcDeps = { @@ -256,6 +257,42 @@ async function handleConfigCommand( }); } +async function handlePluginsCommand( + args: string[], +): Promise> { + const usage = "jr-rpc plugins list"; + const subverb = (args[0] ?? "").trim(); + if (subverb !== "list" || args.length !== 1) { + return commandResult({ + stderr: `Usage:\n${usage}\n`, + exitCode: 2, + }); + } + + const plugins = getPluginProviders() + .map((plugin) => ({ + name: plugin.manifest.name, + displayName: plugin.manifest.displayName, + description: plugin.manifest.description, + capabilities: [...plugin.manifest.capabilities], + configKeys: [...plugin.manifest.configKeys], + hasCredentials: Boolean(plugin.manifest.credentials), + hasMcp: Boolean(plugin.manifest.mcp), + hasOAuth: Boolean(plugin.manifest.oauth), + hasSkills: Boolean(plugin.skillsDir), + hasMigrations: Boolean(plugin.migrationsDir), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + + return commandResult({ + stdout: { + ok: true, + plugins, + }, + exitCode: 0, + }); +} + function createJrRpcCommand(deps: JrRpcDeps) { return defineCommand("jr-rpc", async (args) => { const usage = [ @@ -263,11 +300,15 @@ function createJrRpcCommand(deps: JrRpcDeps) { "jr-rpc config set [--json]", "jr-rpc config unset ", "jr-rpc config list [--prefix ]", + "jr-rpc plugins list", ].join("\n"); const verb = (args[0] ?? "").trim(); if (verb === "config") { return handleConfigCommand(args.slice(1), deps); } + if (verb === "plugins") { + return handlePluginsCommand(args.slice(1)); + } return commandResult({ stderr: `Unsupported jr-rpc command. Use:\n${usage}\n`, exitCode: 2, diff --git a/packages/junior/src/chat/plugins/validation.ts b/packages/junior/src/chat/plugins/validation.ts new file mode 100644 index 000000000..8f0e2d4f1 --- /dev/null +++ b/packages/junior/src/chat/plugins/validation.ts @@ -0,0 +1,74 @@ +import type { PluginRegistration } from "@sentry/junior-plugin-api"; +import { getPluginProviders } from "@/chat/plugins/registry"; + +/** Validate hook registrations against the loaded plugin manifest catalog. */ +export function validatePluginRegistrations( + registrations: PluginRegistration[], +): void { + const loadedPlugins = getPluginProviders(); + const loadedNames = new Set( + loadedPlugins.map((plugin) => plugin.manifest.name), + ); + + for (const registration of registrations) { + if (!loadedNames.has(registration.manifest.name)) { + throw new Error( + `Plugin registration "${registration.manifest.name}" does not have a matching plugin manifest. Add an inline manifest, packageName, or app-local plugin.yaml with the same name.`, + ); + } + } +} + +/** Validate credential hook registrations against the loaded plugin manifests. */ +export function validatePluginEgressCredentialHooks( + registrations: PluginRegistration[], +): void { + const plugins = new Map( + registrations.map((registration) => [ + registration.manifest.name, + registration, + ]), + ); + + for (const provider of getPluginProviders()) { + const hooks = plugins.get(provider.manifest.name)?.hooks; + const hasGrantHook = Boolean(hooks?.grantForEgress); + const hasIssueHook = Boolean(hooks?.issueCredential); + const hasGenericCredentials = Boolean( + provider.manifest.credentials || provider.manifest.apiHeaders, + ); + const hasDomains = Boolean(provider.manifest.domains?.length); + const hasHookManagedOAuth = Boolean( + provider.manifest.oauth && !provider.manifest.credentials, + ); + if (!hasGrantHook && !hasIssueHook) { + if (hasDomains && !hasGenericCredentials) { + throw new Error( + `Plugin "${provider.manifest.name}" manifest.domains requires egress credential hooks when no generic credentials or apiHeaders are configured.`, + ); + } + if (hasHookManagedOAuth) { + throw new Error( + `Plugin "${provider.manifest.name}" manifest.oauth without oauth-bearer credentials requires egress credential hooks.`, + ); + } + continue; + } + + if (!hasGrantHook || !hasIssueHook) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks must include both grantForEgress and issueCredential.`, + ); + } + if (hasGenericCredentials) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks must use manifest.domains instead of generic credentials or apiHeaders.`, + ); + } + if (!hasDomains) { + throw new Error( + `Plugin "${provider.manifest.name}" egress credential hooks require manifest.domains to list sandbox egress hosts.`, + ); + } + } +} diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index abeda1c80..e04489dff 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -349,6 +349,7 @@ const TOOL_POLICY_RULES = [ `- Sandbox-backed file and shell tools operate in an isolated workspace rooted at ${SANDBOX_WORKSPACE_ROOT}; readFile/writeFile paths are sandbox-workspace paths, bash runs inside that workspace, and attachFile accepts absolute or workspace-relative sandbox paths.`, "- If a sandbox-backed tool reports that sandbox execution is unavailable, treat that as a blocker for local file/shell inspection; do not pretend host files were inspected.", "- For user-provided URLs, use `webFetch`; for discovery, use `webSearch` then fetch/read promising sources; for current time/date context, use `systemTime`.", + "- Run `jr-rpc config get|set|unset|list` for provider defaults and `jr-rpc plugins list` for installed plugin introspection as standalone bash commands; do not chain them with `cd`, `&&`, pipes, or provider commands.", "- If the first result is empty, stale, ambiguous, or incomplete, try a focused alternate query, path, command, or source before concluding the answer cannot be verified.", ]; @@ -526,7 +527,7 @@ function buildContextSection(params: { if (configLines) { blocks.push( renderTag("configuration", [ - "Ambient provider defaults; explicit targets win. Run `jr-rpc config get|set|unset|list` as standalone bash commands; do not chain with `cd`, `&&`, pipes, or provider commands.", + "Ambient provider defaults; explicit targets win.", ...configLines, ]), ); diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 78770c3ec..d6bc11a9b 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -11,8 +11,26 @@ import { stdout as defaultStdout, } from "node:process"; import { randomUUID } from "node:crypto"; +import { statSync } from "node:fs"; +import path from "node:path"; import * as readline from "node:readline/promises"; +import { createJiti } from "jiti"; +import { + pluginCatalogConfigFromEnv, + pluginCatalogConfigFromPluginSet, + pluginHookRegistrationsFromPluginSet, + type JuniorPluginSet, +} from "@/plugins"; import { normalizeLocalConversationId } from "@/chat/local/conversation"; +import { setPlugins, validatePlugins } from "@/chat/plugins/agent-hooks"; +import { + getPluginCatalogSignature, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; +import { + validatePluginEgressCredentialHooks, + validatePluginRegistrations, +} from "@/chat/plugins/validation"; import type { LocalAgentReply } from "@/chat/local/runner"; export const CHAT_USAGE = "usage: junior chat\n junior chat -p "; @@ -34,6 +52,7 @@ const DEFAULT_IO: ChatIo = { output: defaultStdout, write: (text) => writeStream(defaultStdout, text), }; +const localPluginLoader = createJiti(import.meta.url, { moduleCache: false }); class ChatOutputError extends Error { constructor(error: unknown) { @@ -97,6 +116,70 @@ function defaultStateAdapterForLocalChat(): void { process.env.JUNIOR_STATE_ADAPTER = "memory"; } +function isFile(targetPath: string): boolean { + try { + return statSync(targetPath).isFile(); + } catch { + return false; + } +} + +function isPluginSet(value: unknown): value is JuniorPluginSet { + return ( + Boolean(value) && + typeof value === "object" && + Array.isArray((value as Partial).packageNames) && + Array.isArray((value as Partial).registrations) + ); +} + +function localPluginModuleCandidates(cwd = process.cwd()): string[] { + return ["plugins.js", "plugins.mjs", "plugins.ts"] + .map((fileName) => path.resolve(cwd, fileName)) + .filter(isFile); +} + +async function loadLocalPluginSet(): Promise { + for (const pluginModulePath of localPluginModuleCandidates()) { + const mod = + await localPluginLoader.import>(pluginModulePath); + const pluginSet = mod.plugins ?? mod.default; + if (!isPluginSet(pluginSet)) { + throw new Error( + `${pluginModulePath} must export a defineJuniorPlugins(...) set as "plugins" or default`, + ); + } + return pluginSet; + } + return undefined; +} + +async function configureLocalChatPlugins(): Promise { + const pluginSet = await loadLocalPluginSet(); + const plugins = pluginHookRegistrationsFromPluginSet(pluginSet); + const pluginConfig = pluginSet + ? pluginCatalogConfigFromPluginSet(pluginSet) + : pluginCatalogConfigFromEnv(); + const shouldValidatePluginCatalog = + Boolean(pluginConfig) || Boolean(pluginSet?.registrations.length); + const { validatePluginDatabaseRequirements } = + await import("@/chat/plugins/db"); + validatePlugins(plugins); + const previousPluginCatalogConfig = setPluginCatalogConfig(pluginConfig); + try { + if (shouldValidatePluginCatalog) { + getPluginCatalogSignature(); + validatePluginRegistrations(pluginSet?.registrations ?? []); + validatePluginEgressCredentialHooks(pluginSet?.registrations ?? []); + } + validatePluginDatabaseRequirements(plugins); + setPlugins(plugins); + } catch (error) { + setPluginCatalogConfig(previousPluginCatalogConfig); + throw error; + } +} + function parseChatArgs(argv: string[]): ChatCommandOptions | undefined { if (argv.length === 0) { return { mode: "interactive" }; @@ -140,6 +223,7 @@ async function runPrompt( io: ChatIo, ): Promise { defaultStateAdapterForLocalChat(); + await configureLocalChatPlugins(); const conversationId = newRunConversationId(); const { runLocalAgentTurn } = await import("@/chat/local/runner"); @@ -162,6 +246,7 @@ async function runPrompt( async function runInteractive(io: ChatIo): Promise { defaultStateAdapterForLocalChat(); + await configureLocalChatPlugins(); const conversationId = newRunConversationId(); const { runLocalAgentTurn } = await import("@/chat/local/runner"); diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 56d498cbc..a3fe80be1 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -196,7 +196,7 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); it("copies a bounded SQL conversation backfill slice", async () => { const stateAdapter = getStateAdapter(); @@ -236,7 +236,7 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); it("seeds active awaiting continuations into conversation work", async () => { const stateAdapter = getStateAdapter(); @@ -508,5 +508,5 @@ describe("upgrade CLI migrations", () => { } finally { await fixture.close(); } - }); + }, 15_000); }); diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index 41d04643a..0e7f8461e 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -9,7 +9,15 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; const originalEnv = { ...process.env }; const originalCwd = process.cwd(); @@ -20,6 +28,7 @@ const examplePluginsModule = path.join(exampleRoot, "plugins.ts"); const exampleDashboardConfig = path.join(exampleRoot, "dashboard.ts"); const examplePackageJson = path.join(exampleRoot, "package.json"); const exampleRequire = createRequire(exampleEntry); +const exampleDatabaseUrl = "postgres://configured.example.test/neon"; const vercelEnvNames = [ "VERCEL", "VERCEL_ENV", @@ -107,6 +116,10 @@ describe.sequential("example build discovery integration", () => { buildJuniorPackage(); }, 60_000); + beforeEach(() => { + process.env.JUNIOR_DATABASE_URL ??= exampleDatabaseUrl; + }); + afterEach(() => { process.chdir(originalCwd); process.env = { ...originalEnv }; @@ -176,6 +189,9 @@ describe.sequential("example build discovery integration", () => { expect(packageJson.scripts?.prebuild).toContain( "pnpm --filter @sentry/junior-dashboard build", ); + expect(packageJson.scripts?.prebuild).toContain( + "pnpm --filter @sentry/junior-scheduler build", + ); expect(packageJson.scripts?.postbuild).toBe( "node scripts/check-vercel-output.mjs", ); diff --git a/packages/junior/tests/unit/cli/chat-cli.test.ts b/packages/junior/tests/unit/cli/chat-cli.test.ts index 3a6455eec..058f7bb99 100644 --- a/packages/junior/tests/unit/cli/chat-cli.test.ts +++ b/packages/junior/tests/unit/cli/chat-cli.test.ts @@ -1,4 +1,7 @@ import { PassThrough, Writable } from "node:stream"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CHAT_USAGE, runChat } from "@/cli/chat"; import type { @@ -16,6 +19,9 @@ vi.mock("@/chat/local/runner", () => ({ const ORIGINAL_STATE_ADAPTER = process.env.JUNIOR_STATE_ADAPTER; const ORIGINAL_REDIS_URL = process.env.REDIS_URL; +const ORIGINAL_DATABASE_URL = process.env.DATABASE_URL; +const ORIGINAL_JUNIOR_DATABASE_URL = process.env.JUNIOR_DATABASE_URL; +const ORIGINAL_CWD = process.cwd(); function reply(outcome: LocalAgentTurnResult["outcome"]): { conversationId: string; @@ -37,8 +43,12 @@ describe("chat cli", () => { }); afterEach(() => { + process.chdir(ORIGINAL_CWD); restoreEnv("JUNIOR_STATE_ADAPTER", ORIGINAL_STATE_ADAPTER); restoreEnv("REDIS_URL", ORIGINAL_REDIS_URL); + restoreEnv("DATABASE_URL", ORIGINAL_DATABASE_URL); + restoreEnv("JUNIOR_DATABASE_URL", ORIGINAL_JUNIOR_DATABASE_URL); + vi.resetModules(); }); it("returns usage for invalid argument forms", async () => { @@ -108,6 +118,124 @@ describe("chat cli", () => { expect(process.env.JUNIOR_STATE_ADAPTER).toBe("memory"); }); + it("fails local chat for database plugins without SQL configuration", async () => { + delete process.env.DATABASE_URL; + delete process.env.JUNIOR_DATABASE_URL; + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.mjs"), + `export const plugins = { + packageNames: [], + registrations: [ + { + database: {}, + manifest: { + name: "database-plugin", + displayName: "Database Plugin", + description: "Database-backed local chat test plugin", + }, + }, + ], +}; +`, + ); + process.chdir(tempDir); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(1); + expect(io.error).toHaveBeenCalledWith( + "Plugin database access requires JUNIOR_DATABASE_URL or DATABASE_URL for: database-plugin", + ); + expect(runner.runLocalAgentTurn).not.toHaveBeenCalled(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it("fails local chat for unresolved plugin packages", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.mjs"), + `export const plugins = { + packageNames: ["@sentry/junior-missing-plugin"], + registrations: [], +}; +`, + ); + process.chdir(tempDir); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(1); + expect(io.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Plugin package "@sentry/junior-missing-plugin" was configured but could not be resolved', + ), + ); + expect(runner.runLocalAgentTurn).not.toHaveBeenCalled(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it("loads TypeScript local plugin modules before prompt local chat", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "junior-chat-plugins-")); + writeFileSync( + path.join(tempDir, "plugins.ts"), + `const packageNames: string[] = []; + +export const plugins = { + packageNames, + registrations: [ + { + manifest: { + name: "typescript-plugin", + displayName: "TypeScript Plugin", + description: "TypeScript local plugin module test", + }, + }, + ], +}; +`, + ); + process.chdir(tempDir); + runner.runLocalAgentTurn.mockImplementation(async (_input, deps) => { + const result = reply("success"); + await deps.deliverReply(result.reply); + return result; + }); + + try { + const io = { + error: vi.fn(), + input: process.stdin, + output: process.stdout, + write: vi.fn(), + }; + + expect(await runChat(["-p", "hello"], io)).toBe(0); + expect(runner.runLocalAgentTurn).toHaveBeenCalledOnce(); + } finally { + process.chdir(ORIGINAL_CWD); + rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("preserves an explicit prompt local chat state adapter", async () => { process.env.JUNIOR_STATE_ADAPTER = "redis"; process.env.REDIS_URL = "redis://localhost:6379"; diff --git a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts index b93cf9465..98773d800 100644 --- a/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts +++ b/packages/junior/tests/unit/handlers/jr-rpc-command.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; import { createChannelConfigurationService } from "@/chat/configuration/service"; +import { setPluginCatalogConfig } from "@/chat/plugins/registry"; import type { Skill } from "@/chat/skills"; const activeSkill: Skill = { @@ -134,6 +135,52 @@ describe("jr-rpc custom command", () => { }); }); + it("lists installed plugins", async () => { + const previousConfig = setPluginCatalogConfig({ + inlineManifests: [ + { + manifest: { + name: "example", + displayName: "Example", + description: "Example plugin", + capabilities: ["example.search"], + configKeys: ["example.repo"], + }, + }, + ], + }); + try { + const result = await maybeExecuteJrRpcCustomCommand( + "jr-rpc plugins list", + { + activeSkill, + }, + ); + const handled = expectHandled(result); + expect(handled.result.exit_code).toBe(0); + const output = JSON.parse(handled.result.stdout); + expect(output.ok).toBe(true); + expect(output.plugins).toEqual( + expect.arrayContaining([ + { + name: "example", + displayName: "Example", + description: "Example plugin", + capabilities: ["example.search"], + configKeys: ["example.repo"], + hasCredentials: false, + hasMcp: false, + hasOAuth: false, + hasSkills: false, + hasMigrations: false, + }, + ]), + ); + } finally { + setPluginCatalogConfig(previousConfig); + } + }); + it("unsets configuration values", async () => { const configuration = makeChannelConfiguration(); const onConfigurationValueChanged = vi.fn(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 898d6e2b9..ae0a3014e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: "@sentry/junior-notion": specifier: workspace:* version: link:../../packages/junior-notion + "@sentry/junior-scheduler": + specifier: workspace:* + version: file:packages/junior-scheduler(@neondatabase/serverless@1.1.0) "@sentry/junior-sentry": specifier: workspace:* version: link:../../packages/junior-sentry @@ -195,6 +198,9 @@ importers: hono: specifier: ^4.12.22 version: 4.12.22 + jiti: + specifier: ^2.7.0 + version: 2.7.0 jose: specifier: ^6.2.3 version: 6.2.3