diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 484f0a7b80..15ea907688 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -339,7 +339,8 @@ environment pull-request show `. Diff commands require an explicit target - Known ACP agents can appear automatically when their CLI is installed on the host; for example `opencode`, `omp`, Grok Build's `grok` CLI, or Hermes' `hermes` CLI on PATH appears as provider `acp-opencode`, `acp-omp`, - `acp-grok`, or `acp-hermes-agent`. + `acp-grok`, or `acp-hermes-agent`. `acp-cursor` and `acp-junie` are listed + even without their CLI, because bb can install those itself. - Cursor ACP threads discover project skills from `.cursor/skills`. This root can link to `.agents/skills`. `bb skill list` shows linked Cursor skills under `cursor-project` and keeps them read-only. @@ -347,15 +348,16 @@ environment pull-request show `. Diff commands require an explicit target a JSON array: `bb plugin config provider-acp set customAgents '[{"id":"amp", "displayName":"Amp","command":"amp","args":["acp"]}]'`. The user supplies a slug `id`; bb exposes it as provider id `acp-`, which is permanent. - `cursor` is reserved; `opencode`, `omp`, `grok` and `hermes-agent` are not, - so an entry with one of those ids replaces the shipped agent. The plugin - re-registers as soon as the setting changes. The configured command is local code execution and only works with a - co-located daemon. Optional per-agent fields: `args`, `env`, `cwd`, - `modelCli`, `reasoningCli`, `nativeReasoning`, `nativeSkillRoots` + `cursor` is reserved; `opencode`, `omp`, `grok`, `hermes-agent` and `junie` + are not, so an entry with one of those ids replaces the shipped agent. The + plugin re-registers as soon as the setting changes. The configured command + is local code execution and only works with a co-located daemon. Optional + per-agent fields: `args`, `env`, `cwd`, `modelCli`, `reasoningCli`, + `nativeReasoning`, `nativeSkillRoots` (`{"user": [...], "project": [...]}` relative paths), `permissionCli`, - `supportsManualCompaction`, and `dialect` (`cursor`, `opencode`, `omp`, or - `grok`). The old `customAcpAgents` array in `config.json` is deprecated; bb - reads it and warns until 0.41. + `supportsManualCompaction`, and `dialect` (`cursor`, `opencode`, `omp`, + `grok`, or `junie`). The old `customAcpAgents` array in `config.json` is + deprecated; bb reads it and warns until 0.41. - Top-level `customModels` in the same `config.json` registers extra picker models. `providerId` accepts a built-in provider id or any `acp-*` provider id. The provider must still accept the id: `claude-code` and `codex` accept diff --git a/apps/server/test/providers/plugin-provider-registration.test.ts b/apps/server/test/providers/plugin-provider-registration.test.ts index 9687005753..5c3c54c615 100644 --- a/apps/server/test/providers/plugin-provider-registration.test.ts +++ b/apps/server/test/providers/plugin-provider-registration.test.ts @@ -364,6 +364,11 @@ describe("buildPluginProviderRegistration", () => { logoUrl: "/api/v1/system/providers/acp-hermes-agent/logo", icon: undefined, }, + { + id: "acp-junie", + logoUrl: "/api/v1/system/providers/acp-junie/logo", + icon: undefined, + }, ]); }); }); diff --git a/apps/server/test/public/public-provider-installations.test.ts b/apps/server/test/public/public-provider-installations.test.ts index a5132051d7..5ce99706a2 100644 --- a/apps/server/test/public/public-provider-installations.test.ts +++ b/apps/server/test/public/public-provider-installations.test.ts @@ -154,12 +154,19 @@ describe("public provider installation routes", () => { expect(response.status).toBe(200); const body = (await readJson(response)) as ProviderCliStatusResponse; - expect(Object.keys(body)).toEqual(["codex", "claude-code", "pi", "acp-cursor"]); + expect(Object.keys(body)).toEqual([ + "codex", + "claude-code", + "pi", + "acp-cursor", + "acp-junie", + ]); expect(Object.values(body).map((status) => status.displayName)).toEqual([ "Codex", "Claude Code", "Pi", "Cursor", + "Junie", ]); expect( responder.requests @@ -181,7 +188,7 @@ describe("public provider installation routes", () => { ? request.command.providerId : null, ), - ).toEqual(["codex", "claude-code", "pi", "acp-cursor"]); + ).toEqual(["codex", "claude-code", "pi", "acp-cursor", "acp-junie"]); }); }); @@ -216,11 +223,17 @@ describe("public provider installation routes", () => { expect(response.status).toBe(200); const body = (await readJson(response)) as ProviderCliStatusResponse; - expect(Object.keys(body)).toEqual(["codex", "pi", "acp-cursor"]); + expect(Object.keys(body)).toEqual([ + "codex", + "pi", + "acp-cursor", + "acp-junie", + ]); expect(Object.values(body).map((status) => status.displayName)).toEqual([ "Codex", "Pi", "Cursor", + "Junie", ]); expect(warn).toHaveBeenCalledWith( { diff --git a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts index a7e9e2f58c..d90c0578d6 100644 --- a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts +++ b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts @@ -123,6 +123,19 @@ const FIRST_PARTY_PROVIDER_DECLARATIONS = [ visibility: "installed", hasLogo: true, }, + { + builtinName: "provider-acp", + pluginId: "provider-acp", + providerId: "acp-junie", + displayName: "Junie", + supportsThreadArchive: false, + supportsThreadRename: false, + fork: "none", + supportsManualCompaction: false, + supportsUsage: false, + visibility: "always", + hasLogo: true, + }, ] as const; const PROVIDER_IDS = FIRST_PARTY_PROVIDER_DECLARATIONS.map( @@ -404,6 +417,7 @@ describe("first-party provider plugins", () => { "acp-omp", "acp-grok", "acp-hermes-agent", + "acp-junie", ]); const infos = await listSystemProviderInfos(harness.deps, {}); expect(infos.find((info) => info.id === "pi")).toBeUndefined(); diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index a2a251c6b2..a5a469c573 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -1158,7 +1158,7 @@ describe("resolveSystemExecutionOptions", () => { registry.markRegistrationsSettled(); expect((await providersPromise).map((provider) => provider.id)).toEqual( - ["codex", "claude-code", "pi", "acp-cursor"], + ["codex", "claude-code", "pi", "acp-cursor", "acp-junie"], ); }, ); diff --git a/apps/server/test/system/provider-states.test.ts b/apps/server/test/system/provider-states.test.ts index 225b3e65a8..f6b2aebf59 100644 --- a/apps/server/test/system/provider-states.test.ts +++ b/apps/server/test/system/provider-states.test.ts @@ -74,6 +74,7 @@ describe("getProviderStates", () => { "claude-code", "pi", "acp-cursor", + "acp-junie", "acp-opencode", ]); expect(result.providers[0]).toMatchObject({ @@ -216,7 +217,7 @@ describe("getProviderStates", () => { // workspace-scoped and receives the environment path. expect(healthCwds.filter((cwd) => cwd === undefined)).toHaveLength(4); expect(healthCwds.filter((cwd) => cwd !== undefined)).toEqual( - Array(4).fill(environment.path), + Array(5).fill(environment.path), ); }); }); diff --git a/apps/server/test/threads/thread-default-policy.test.ts b/apps/server/test/threads/thread-default-policy.test.ts index 552732bdf9..7554f7d1d9 100644 --- a/apps/server/test/threads/thread-default-policy.test.ts +++ b/apps/server/test/threads/thread-default-policy.test.ts @@ -107,6 +107,7 @@ describe("resolveCreateThreadExecutionDefaults", () => { "acp-omp", "acp-grok", "acp-hermes-agent", + "acp-junie", ]); expect( resolveCreateThreadExecutionDefaults(userRegistry, { diff --git a/apps/web/src/landing/icons.tsx b/apps/web/src/landing/icons.tsx index 8728337fe5..b9ede3b959 100644 --- a/apps/web/src/landing/icons.tsx +++ b/apps/web/src/landing/icons.tsx @@ -142,6 +142,24 @@ export function OmpIcon({ className }: { className?: string }) { ); } +// Junie brand mark (JetBrains). Rendered in currentColor for light/dark +// theming. +export function JunieIcon({ className }: { className?: string }) { + return ( + + Junie + + + + + ); +} + // Hermes Agent brand mark. Rendered in currentColor for light/dark theming. export function HermesAgentIcon({ className }: { className?: string }) { return ( diff --git a/apps/web/src/landing/site.ts b/apps/web/src/landing/site.ts index 6473526170..1417a89698 100644 --- a/apps/web/src/landing/site.ts +++ b/apps/web/src/landing/site.ts @@ -32,7 +32,7 @@ declare const __SITE_ORIGIN__: string; const SITE_URL = __SITE_ORIGIN__; export const SITE_TITLE = "bb: the IDE that builds itself"; export const SITE_DESCRIPTION = - "bb can control, customize, and automate itself, laying the groundwork for your own software factory. Fully open source and local-first, with Claude Code, Codex, Cursor, Pi, OpenCode, Grok, omp, and Hermes."; + "bb can control, customize, and automate itself, laying the groundwork for your own software factory. Fully open source and local-first, with Claude Code, Codex, Cursor, Pi, OpenCode, Grok, omp, Hermes, and Junie."; /** Unfurl copy: the hero sub verbatim, without SITE_DESCRIPTION's provider * list because link previews truncate around 200 characters. */ export const OG_DESCRIPTION = diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 45a5289f75..0b4a257299 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -59,6 +59,7 @@ import { CursorIcon, GrokIcon, HermesAgentIcon, + JunieIcon, OmpIcon, OpenAiIcon, OpencodeIcon, @@ -372,6 +373,7 @@ const PROVIDER_ICONS = [ GrokIcon, OmpIcon, HermesAgentIcon, + JunieIcon, ] as const; /** How many provider logos stay visible on narrow screens before "+N more". */ @@ -1860,9 +1862,9 @@ function LandingPage() { }>

- Claude Code, Codex, Cursor, Pi, OpenCode, Grok, omp, and Hermes all - live in bb. Give a task to whichever fits, and have one agent spawn - and manage another, each in its own thread. + Claude Code, Codex, Cursor, Pi, OpenCode, Grok, omp, Hermes, and Junie + all live in bb. Give a task to whichever fits, and have one agent + spawn and manage another, each in its own thread.

Each runs on your own subscription: the provider plan you already pay diff --git a/docs/configuration.md b/docs/configuration.md index cd5f4e96fb..f8672fd447 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -326,8 +326,9 @@ Known ACP agents appear when their CLI is installed on the host. bb exposes `acp-opencode` when `opencode` is on PATH and can be launched as `opencode acp`, `acp-omp` when `omp` (oh-my-pi) is on PATH, `acp-grok` when Grok Build's `grok` CLI is on PATH and can be launched as `grok agent stdio`, and -`acp-hermes-agent` when Hermes' `hermes` CLI is on PATH. `acp-cursor` is always -listed. +`acp-hermes-agent` when Hermes' `hermes` CLI is on PATH. `acp-cursor` and +`acp-junie` are always listed, because bb can install those CLIs itself from +the provider's page; JetBrains' `junie` is launched as `junie --acp true`. Add your own agent through the ACP providers plugin's `customAgents` setting, which holds a JSON array. In the app it is the multi-line editor on the @@ -342,11 +343,11 @@ bb plugin config provider-acp set customAgents '[ Each entry needs `id` (lowercase letters, digits and dashes), `displayName`, and `command`. bb derives the provider id `acp-`; it never changes once a thread has used it. An id bb always lists (`cursor`) is reserved; an id bb -lists only where the agent is installed (`opencode`, `omp`, `grok`, -`hermes-agent`) is not, so an entry with that id REPLACES the shipped agent. -A replacing entry keeps the shipped agent's `nativeSkillRoots` unless it sets -its own, and bb still lists the roots that agent's host config names (its -config directory, compat trees, configured paths, plugins) either way. +lists only where the agent is installed (`opencode`, `omp`, `grok`, `hermes-agent`, `junie`) is not, so an entry with +that id REPLACES the shipped agent. A replacing entry keeps the shipped +agent's `nativeSkillRoots` unless it sets its own, and bb still lists the +roots that agent's host config names (its config directory, compat trees, +configured paths, plugins) either way. Optional fields: `args`, `env`, `cwd`, `modelCli` (CLI model listing and selection), `reasoningCli` (launch-time reasoning flags), `nativeReasoning` (ACP `session/set_config_option` reasoning), `nativeSkillRoots` (native skills @@ -356,7 +357,7 @@ an agent that nests skills or reads them from every ancestor directory), `permissionCli` (permission-mode launch flags), `supportsManualCompaction` (only if the agent accepts an explicit compaction request — bb hides `/compact` otherwise), and `dialect` (the vendor side channels bb reads for -the agent: `cursor`, `opencode`, `omp`, or `grok`). +the agent: `cursor`, `opencode`, `omp`, `grok`, or `junie`). The change applies immediately: the plugin re-registers its providers when the setting changes, with no restart and no `config refresh`. @@ -475,6 +476,7 @@ table lists what the shipped plugins declare and resolve. | omp | The active `~/.omp/.../agent` roots and supported Pi, Agents, Claude, Codex, and OpenCode roots | `.omp/skills` and the supported compatibility roots from the repository root to the current directory | | Grok Build | `$GROK_HOME/skills` or `~/.grok/skills`, plus `~/.agents/skills`, `~/.claude/skills`, `~/.cursor/skills` | The same four roots from the repository root to the current directory | | Hermes Agent | `$HERMES_HOME/skills` or `~/.hermes/skills` | None | +| Junie | `~/.junie/skills`, `~/.agents/skills` | `.junie/skills` and `.agents/skills` from the repository root to the current directory | OpenCode also uses `$OPENCODE_CONFIG_DIR/skills` when that variable exists. Pi and omp use `$PI_CODING_AGENT_DIR` when that variable exists. omp also uses diff --git a/packages/bb-app/README.md b/packages/bb-app/README.md index 051dcc361a..b78467100b 100644 --- a/packages/bb-app/README.md +++ b/packages/bb-app/README.md @@ -152,11 +152,12 @@ bb uses whichever providers you have configured. Common providers: | `opencode` | Install [opencode](https://opencode.ai/) and authenticate per its docs. | | `grok` | Install [Grok Build](https://docs.x.ai/build/overview) and authenticate with `grok login` or `XAI_API_KEY`. | | `hermes-agent` | Install [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/installation), configure credentials with `hermes model`, then verify ACP with `hermes acp --check`. | +| `junie` | Install [Junie CLI](https://junie.jetbrains.com/docs/junie-cli.html) (`curl -fsSL https://junie.jetbrains.com/install.sh \| bash`; BB can run the install from Settings), then run `junie` once to sign in on the welcome screen. | BB indexes the documented native skill roots for Codex, Claude Code, Pi, -Cursor, OpenCode, omp, Grok Build, and Hermes Agent. It includes user roots, -project roots, and compatibility roots such as `.agents/skills`. These skills -appear in the selected provider's `/` command menu. The Skills page and +Cursor, OpenCode, omp, Grok Build, Hermes Agent, and Junie. It includes user +roots, project roots, and compatibility roots such as `.agents/skills`. These +skills appear in the selected provider's `/` command menu. The Skills page and `bb skill list` show native skills for Claude Code, Codex, and Cursor. BB also reads configured Pi, omp, Grok, and Hermes skill directories, plus enabled provider plugin skills. diff --git a/packages/plugin-build/src/svg-asset.test.ts b/packages/plugin-build/src/svg-asset.test.ts index 03005bbdf3..47087cfd86 100644 --- a/packages/plugin-build/src/svg-asset.test.ts +++ b/packages/plugin-build/src/svg-asset.test.ts @@ -28,6 +28,7 @@ const FIRST_PARTY_BRANDING_SVGS = [ "plugins/provider-acp/icons/cursor.svg", "plugins/provider-acp/icons/grok.svg", "plugins/provider-acp/icons/hermes-agent.svg", + "plugins/provider-acp/icons/junie.svg", "plugins/provider-acp/icons/omp.svg", "plugins/provider-acp/icons/opencode.svg", "plugins/provider-claude-code/icons/claude-code.svg", diff --git a/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts b/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts index c7d9c0dc29..2aba46005c 100644 --- a/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts +++ b/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { CURSOR_ACP_MAINTENANCE, + getAcpProviderUsage, + JUNIE_ACP_MAINTENANCE, __testing, } from "./provider-maintenance.js"; @@ -99,4 +101,42 @@ describe("ACP provider maintenance", () => { message: "opencode install is not available on this host.", }); }); + + // Junie's dialect carries an installer and nothing else: bb installs the + // CLI with JetBrains' own script, while sign-in and quota stay inside the + // agent's session. An install-only surface must still produce a runnable + // command, and must not claim a usage surface it cannot answer. + it("installs Junie with JetBrains' installer and reports no usage", async () => { + const run = __testing.buildProviderInstallationRun( + { + ...cursorMissingInstallationStatus(), + executableName: "junie", + installAction: { + kind: "install" as const, + label: "Install" as const, + command: "install Junie", + }, + }, + { + maintenance: JUNIE_ACP_MAINTENANCE, + command: "junie", + action: "install", + }, + ); + expect(run).toMatchObject({ + available: true, + command: { command: "sh" }, + verification: { kind: "installed" }, + }); + expect(run.available ? run.command.displayCommand : "").toContain( + "https://junie.jetbrains.com/install.sh", + ); + + expect( + await getAcpProviderUsage({ + maintenance: JUNIE_ACP_MAINTENANCE, + command: "junie", + }), + ).toEqual({ supported: false }); + }); }); diff --git a/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts b/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts index cc8ce57bc8..4204dc1f7b 100644 --- a/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts +++ b/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts @@ -16,6 +16,7 @@ const CURSOR_DASHBOARD_URL = const CURSOR_KEYCHAIN_ACCOUNT = "cursor-user"; const CURSOR_ACCESS_TOKEN_SERVICE = "cursor-access-token"; const CURSOR_INSTALL_SCRIPT_URL = "https://cursor.com/install"; +const JUNIE_INSTALL_SCRIPT_URL = "https://junie.jetbrains.com/install.sh"; function cursorAuthFilePath(): string { if (process.platform === "win32") { @@ -124,14 +125,23 @@ function readAccountEmail(): string | null { * therefore the dialect's (see `dialect.ts`), never a bb provider id's. */ export interface AcpMaintenanceDialect { - /** The shell command that signs the user in. */ - loginCommand: string; + /** + * The shell command that signs the user in. Absent for an agent whose + * sign-in bb cannot name as one command (Junie authenticates inside its + * own session), so the surfaces fall back to the agent's own instructions + * rather than printing a command that does not exist. + */ + loginCommand?: string; /** How bb installs or updates the agent, when it can. */ installer(): { command: string; args: string[]; displayCommand: string }; - /** The signed-in account, or null when the agent is not signed in. */ - readAccount(): Promise<{ email: string | null } | null>; - /** The agent's usage windows, for the usage surfaces. */ - readUsage(): Promise; + /** + * The signed-in account, or null when the agent is not signed in. Absent + * for an agent whose credentials bb cannot read: health then reports the + * installed agent as ready rather than guessing it is signed out. + */ + readAccount?(): Promise<{ email: string | null } | null>; + /** The agent's usage windows, for the usage surfaces, when it has any. */ + readUsage?(): Promise; } function healthResult(args: { @@ -174,7 +184,7 @@ export async function getAcpProviderHealth(args: { return healthResult({ maintenance, status: "not_installed" }); } const version = await readCliVersion(args.command); - if (maintenance === undefined) { + if (maintenance?.readAccount === undefined) { return healthResult({ maintenance, status: "ready", @@ -372,11 +382,12 @@ export async function getAcpProviderUsage(args: { maintenance: AcpMaintenanceDialect | undefined; command: string | null; }): Promise { - if (args.maintenance === undefined) return { supported: false }; + const maintenance = args.maintenance; + if (maintenance?.readUsage === undefined) return { supported: false }; if (args.command === null || (await resolveExecutablePath(args.command)) === null) { return { supported: true, usage: { status: "not_installed" } }; } - return args.maintenance.readUsage(); + return maintenance.readUsage(); } /** Cursor's own maintenance surface; the cursor dialect carries it. */ @@ -390,6 +401,17 @@ export const CURSOR_ACP_MAINTENANCE: AcpMaintenanceDialect = { readUsage: readCursorUsage, }; +/** + * Junie's maintenance surface: bb installs and updates the CLI with the + * installer JetBrains documents, and nothing else. The shim that installer + * writes to `~/.local/bin/junie` keeps itself updated, and Junie's sign-in + * and quota live inside the agent's own session, so bb neither names a login + * command nor reads an account or usage. + */ +export const JUNIE_ACP_MAINTENANCE: AcpMaintenanceDialect = { + installer: () => downloadedInstallerCommand(JUNIE_INSTALL_SCRIPT_URL), +}; + async function readCursorUsage(): Promise { const accessToken = await readAccessToken(); if (!accessToken) { diff --git a/packages/provider-bridge-acp/src/dialect.ts b/packages/provider-bridge-acp/src/dialect.ts index 82fb269d7d..c85f46aaf7 100644 --- a/packages/provider-bridge-acp/src/dialect.ts +++ b/packages/provider-bridge-acp/src/dialect.ts @@ -23,6 +23,7 @@ import { basename } from "node:path"; import { z } from "zod"; import { CURSOR_ACP_MAINTENANCE, + JUNIE_ACP_MAINTENANCE, type AcpMaintenanceDialect, } from "./bridge/provider-maintenance.js"; import { delegationPresentation } from "./presentation.js"; @@ -476,6 +477,20 @@ export const OPENCODE_ACP_DIALECT: AcpDialect = { normalizeCommandEvent: normalizeOpenCodeCommandEvent, }; +// --------------------------------------------------------------------------- +// Junie (`junie --acp true`) +// --------------------------------------------------------------------------- + +/** + * Junie reads no vendor side channel bb knows of: the dialect exists only to + * carry its maintenance surface, so bb can install the CLI itself instead of + * sending the user to JetBrains' install page. + */ +export const JUNIE_ACP_DIALECT: AcpDialect = { + id: "junie", + maintenance: JUNIE_ACP_MAINTENANCE, +}; + // --------------------------------------------------------------------------- // Selection // --------------------------------------------------------------------------- @@ -488,6 +503,7 @@ export const OPENCODE_ACP_DIALECT: AcpDialect = { const DIALECTS_BY_ID: ReadonlyMap = new Map([ [CURSOR_ACP_DIALECT.id, CURSOR_ACP_DIALECT], [GROK_ACP_DIALECT.id, GROK_ACP_DIALECT], + [JUNIE_ACP_DIALECT.id, JUNIE_ACP_DIALECT], [OMP_ACP_DIALECT.id, OMP_ACP_DIALECT], [OPENCODE_ACP_DIALECT.id, OPENCODE_ACP_DIALECT], ]); @@ -496,6 +512,7 @@ const DIALECTS_BY_ID: ReadonlyMap = new Map([ const DIALECT_IDS_BY_COMMAND: Readonly> = { "cursor-agent": CURSOR_ACP_DIALECT.id, grok: GROK_ACP_DIALECT.id, + junie: JUNIE_ACP_DIALECT.id, omp: OMP_ACP_DIALECT.id, opencode: OPENCODE_ACP_DIALECT.id, }; diff --git a/packages/provider-bridge-acp/src/wire.test.ts b/packages/provider-bridge-acp/src/wire.test.ts index 47122d132b..33b41a51ce 100644 --- a/packages/provider-bridge-acp/src/wire.test.ts +++ b/packages/provider-bridge-acp/src/wire.test.ts @@ -157,6 +157,62 @@ describe("acpSessionNewResultSchema", () => { expect(parsed.data.configOptions?.[1].category).toBeUndefined(); expect(parsed.data.configOptions?.[1].options?.[0].name).toBeUndefined(); }); + + // Junie groups its model list by provider. A grouped entry has no `value` + // of its own, and parsing it as a flat value rejected the whole session with + // "ACP agent returned an unexpected session/new result". + it("flattens grouped select options into their values", () => { + const parsed = acpSessionNewResultSchema.safeParse({ + sessionId: "session-1", + configOptions: [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "v1:12:jetbrains-ai:gpt-5.6-sol", + options: [ + { + group: "jetbrains-ai", + name: "JetBrains AI", + options: [ + { + value: "v1:12:jetbrains-ai:gpt-5.6-sol", + name: "GPT-5.6-SOL", + }, + { value: "v1:12:jetbrains-ai:grok-4.6", name: "Grok 4.6" }, + ], + }, + { value: "local/qwen", name: "Qwen" }, + ], + }, + { + type: "select", + id: "brave_mode", + category: "mode", + currentValue: "on", + options: [ + { group: "safety", name: "Safety", options: [{ value: "off" }] }, + { value: "on", name: "Brave on" }, + ], + }, + ], + }); + + expect(parsed.success).toBe(true); + if (!parsed.success) { + return; + } + expect(parsed.data.configOptions?.[0].options).toEqual([ + { value: "v1:12:jetbrains-ai:gpt-5.6-sol", name: "GPT-5.6-SOL" }, + { value: "v1:12:jetbrains-ai:grok-4.6", name: "Grok 4.6" }, + { value: "local/qwen", name: "Qwen" }, + ]); + expect(parsed.data.configOptions?.[1].options).toEqual([ + { value: "off" }, + { value: "on", name: "Brave on" }, + ]); + }); }); describe("acpSessionForkResultSchema", () => { diff --git a/packages/provider-bridge-acp/src/wire.ts b/packages/provider-bridge-acp/src/wire.ts index 2f05422dba..43ed92c594 100644 --- a/packages/provider-bridge-acp/src/wire.ts +++ b/packages/provider-bridge-acp/src/wire.ts @@ -326,6 +326,30 @@ const acpConfigOptionSelectOptionSchema = z }) .passthrough(); +/** + * ACP's `SessionConfigSelectOptions` is a union: either a flat list of values + * or a list of `{ group, name, options }` headers. Junie serves its whole + * model list grouped by provider, and a grouped entry carries no `value` of + * its own, so parsing one as a flat value rejected the entire `session/new` + * result. bb has no grouped picker, so groups are flattened to their values. + */ +const acpConfigOptionSelectGroupSchema = z + .object({ + group: z.string(), + name: acpOptionalString, + options: z.array(acpConfigOptionSelectOptionSchema), + }) + .passthrough(); + +const acpConfigOptionSelectOptionsSchema = z + .array( + z.union([ + acpConfigOptionSelectGroupSchema.transform((group) => group.options), + acpConfigOptionSelectOptionSchema.transform((option) => [option]), + ]), + ) + .transform((entries) => entries.flat()); + const acpConfigOptionSchema = z .object({ id: z.string(), @@ -333,7 +357,7 @@ const acpConfigOptionSchema = z category: acpOptionalString, type: z.string(), currentValue: acpOptionalString, - options: z.array(acpConfigOptionSelectOptionSchema).optional(), + options: acpConfigOptionSelectOptionsSchema.optional(), }) .passthrough(); export type AcpConfigOption = z.infer; @@ -408,6 +432,11 @@ function parseAcpConfigOptions( ...(Array.isArray(loose.data.options) ? { options: loose.data.options.flatMap((selectOption) => { + const group = + acpConfigOptionSelectGroupSchema.safeParse(selectOption); + if (group.success) { + return group.data.options; + } const parsed = acpConfigOptionSelectOptionSchema.safeParse(selectOption); return parsed.success ? [parsed.data] : []; diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 710fb45bad..ee0b13931f 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -74,13 +74,15 @@ or forked provider sessions. Known ACP agents can appear automatically when their CLI is installed on the host. For example, opencode, omp, Grok Build's grok CLI, or Hermes' hermes CLI on PATH appears as provider acp-opencode, acp-omp, acp-grok, or -acp-hermes-agent. +acp-hermes-agent. acp-cursor and acp-junie are listed even when their CLI is +missing, because bb can install those from the provider's page. bb indexes the native user and project skill roots for Codex, Claude Code, Pi, -Cursor, OpenCode, omp, Grok Build, and Hermes Agent. This includes compatibility -roots such as .agents/skills and .claude/skills when the provider supports them. -It also includes project ancestor roots for providers that search to the Git -repository root. Configured Pi, omp, Grok, and Hermes directories are included. +Cursor, OpenCode, omp, Grok Build, Hermes Agent, and Junie. This includes +compatibility roots such as .agents/skills and .claude/skills when the provider +supports them. It also includes project ancestor roots for providers that +search to the Git repository root. Configured Pi, omp, Grok, and Hermes +directories are included. Enabled provider plugins also contribute skills to the selected provider's `/` command menu. `bb skill list` shows native skills for Claude Code, Codex, and Cursor. @@ -115,14 +117,14 @@ Custom ACP agents live in the ACP providers plugin's customAgents setting, a JSON array. Set it with bb plugin config provider-acp set customAgents '[...]'. Each entry needs id (lowercase letters, digits and dashes), displayName, and command. bb derives provider id acp- from the slug id. The id is permanent. -The id cursor is reserved because bb always lists that agent. The ids opencode, -omp, grok and hermes-agent are not reserved, so an entry with one of those ids -replaces the shipped agent. Use args, env, and cwd for the launch, modelCli -for CLI model listing/selection, reasoningCli for launch-time reasoning flags, +The id cursor is reserved. The ids opencode, omp, grok, hermes-agent and junie +are not reserved, so an entry with one of those ids replaces the shipped agent. +Use args, env, and cwd for the launch, modelCli for CLI model +listing/selection, reasoningCli for launch-time reasoning flags, nativeReasoning for ACP session/set_config_option reasoning, permissionCli for -permission-mode launch flags, and dialect (cursor, opencode, omp, or grok) for -the vendor side channels bb reads. Use nativeSkillRoots to add native skills to -the composer. +permission-mode launch flags, and dialect (cursor, opencode, omp, grok, or +junie) for the vendor side channels bb reads. Use nativeSkillRoots to add +native skills to the composer. Give it a user list and a project list. User roots resolve from the target host home directory. Project roots resolve from the selected workspace. Each root must use a relative path without dot segments. Set supportsManualCompaction to true only diff --git a/plugins/provider-acp/README.md b/plugins/provider-acp/README.md index fa0597b475..74a28b7e0c 100644 --- a/plugins/provider-acp/README.md +++ b/plugins/provider-acp/README.md @@ -1,7 +1,7 @@ # ACP providers First-party plugin for ACP (Agent Client Protocol) agent providers: Cursor, -opencode, omp, Grok Build and Hermes Agent. +opencode, omp, Grok Build, Hermes Agent and Junie. The plugin has no bridge of its own. Every agent it registers runs on the published ACP kit, `@get-bb/plugin-sdk/provider-bridge/acp`, which its diff --git a/plugins/provider-acp/icons/junie.svg b/plugins/provider-acp/icons/junie.svg new file mode 100644 index 0000000000..61a9221a5c --- /dev/null +++ b/plugins/provider-acp/icons/junie.svg @@ -0,0 +1,6 @@ + + Junie + + + + diff --git a/plugins/provider-acp/package.json b/plugins/provider-acp/package.json index 456edeb456..7a6c079b42 100644 --- a/plugins/provider-acp/package.json +++ b/plugins/provider-acp/package.json @@ -17,7 +17,8 @@ "opencode": "./icons/opencode.svg", "omp": "./icons/omp.svg", "grok": "./icons/grok.svg", - "hermes-agent": "./icons/hermes-agent.svg" + "hermes-agent": "./icons/hermes-agent.svg", + "junie": "./icons/junie.svg" } }, "server": "./server.ts", diff --git a/plugins/provider-acp/src/agents.test.ts b/plugins/provider-acp/src/agents.test.ts index 7455f540b3..1160af89a7 100644 --- a/plugins/provider-acp/src/agents.test.ts +++ b/plugins/provider-acp/src/agents.test.ts @@ -264,6 +264,9 @@ describe("acpProviderDeclaration", () => { expect(byId.get("acp-opencode")?.experimental_bridgeOptions).toMatchObject({ acpDialect: "opencode", }); + expect(byId.get("acp-junie")?.experimental_bridgeOptions).toMatchObject({ + acpDialect: "junie", + }); expect( byId.get("acp-opencode")?.capabilities.supportsManualCompaction, ).toBe(true); @@ -296,6 +299,16 @@ describe("acpProviderDeclaration", () => { expect(cursor.experimental_visibility).toBeUndefined(); expect(cursor.maintenance?.usage).toBe(true); expect(cursor.maintenance?.installation).toBe(true); + + // Junie is listed even when its CLI is missing, because that row is + // where the Install action lives. Its quota stays inside its own + // session, so bb claims no usage surface. + const junie = acpProviderDeclaration( + KNOWN_ACP_AGENTS.find((agent) => agent.id === "acp-junie")!, + ); + expect(junie.experimental_visibility).toBeUndefined(); + expect(junie.maintenance?.installation).toBe(true); + expect(junie.maintenance?.usage).toBe(false); }); it("gives a configured agent honest copy when it names no sign-in command", () => { diff --git a/plugins/provider-acp/src/agents.ts b/plugins/provider-acp/src/agents.ts index 13ca872af7..c6a6c84c1d 100644 --- a/plugins/provider-acp/src/agents.ts +++ b/plugins/provider-acp/src/agents.ts @@ -52,6 +52,12 @@ export interface AcpAgentDefinition { reasoningProbePriorityModelIds?: readonly string[]; /** Listed always, or only where the bridge reports the agent installed. */ visibility?: "always" | "installed"; + /** + * Whether a user-configured entry may not take this provider id. Separate + * from `visibility`: being listed always is about the picker, reserving an + * id is about who owns the registration. + */ + reserved?: boolean; /** How the user signs in and installs the agent. */ signInCommand?: string; installUrl?: string; diff --git a/plugins/provider-acp/src/configured-agents.test.ts b/plugins/provider-acp/src/configured-agents.test.ts index a68b5a5327..deb87236c8 100644 --- a/plugins/provider-acp/src/configured-agents.test.ts +++ b/plugins/provider-acp/src/configured-agents.test.ts @@ -119,6 +119,22 @@ describe("resolveConfiguredAcpAgents", () => { expect(resolved.warnings).toEqual([]); }); + // Junie is listed always so its Install action has a row to live on, which + // is a picker decision, not a claim on the id: overriding it stays open. + it("accepts an entry that overrides the always-listed Junie agent", () => { + const resolved = resolveConfiguredAcpAgents({ + settingValue: JSON.stringify([ + { id: "junie", displayName: "Junie", command: "/opt/junie" }, + ]), + legacyEntries: [], + reservedProviderIds: reserved, + shippedAgents: KNOWN_ACP_AGENTS, + }); + + expect(resolved.agents.map((agent) => agent.id)).toEqual(["acp-junie"]); + expect(resolved.warnings).toEqual([]); + }); + // The migration path in full: an old config entry keeps its native skills. // Both halves used to break — the strict plugin schema dropped the whole // agent, and the declaration never carried the roots on to the server. diff --git a/plugins/provider-acp/src/known-agents.ts b/plugins/provider-acp/src/known-agents.ts index c55750f0f7..f11680af0a 100644 --- a/plugins/provider-acp/src/known-agents.ts +++ b/plugins/provider-acp/src/known-agents.ts @@ -68,6 +68,7 @@ export const KNOWN_ACP_AGENTS: readonly AcpAgentDefinition[] = [ signInCommand: "cursor-agent login", installUrl: "https://cursor.com/docs/cli/installation", dialect: "cursor", + reserved: true, providerUsage: true, providerInstallation: true, // Cursor exposes bare session model ids plus effort and Fast options only @@ -264,20 +265,50 @@ export const KNOWN_ACP_AGENTS: readonly AcpAgentDefinition[] = [ // aware) and its config's `skills.external_dirs` all come from the host. nativeRootsResolver: resolveHermesNativeRoots, }, + { + id: "acp-junie", + displayName: "Junie", + icon: declaredIcon("junie"), + installUrl: "https://junie.jetbrains.com/docs/junie-cli.html", + dialect: "junie", + // bb installs the Junie CLI itself, with the installer JetBrains + // documents, so the provider is listed whether or not it is present: + // an installed-only listing hides the very row the Install action lives + // on (see acp-cursor, which is listed for the same reason). Junie's + // sign-in and quota stay inside its own session, so no usage surface. + providerInstallation: true, + // Unverified; the ACP tier's conservative default (see acp-cursor): the + // bridge refuses a fork the agent never advertised, so bb declares none + // until Junie's `initialize` reply is read. + fork: "none", + launch: { + displayName: "Junie", + // Junie CLI serves ACP over stdio when launched with `--acp true`. + command: "junie", + args: ["--acp", "true"], + env: {}, + // Junie reads its own `.junie/skills` and the cross-agent + // `.agents/skills` convention, from the repository root down to the + // current directory for the project trees. + nativeSkillRoots: { + user: plainRoots([".junie/skills", ".agents/skills"]), + project: ancestorRoots([".junie/skills", ".agents/skills"]), + }, + }, + }, ]; /** * The provider ids a user-configured agent may not take. * - * Only the always-listed agents are reserved. An installed-only agent is one - * bb hides unless its CLI is present, and overriding it — with a different - * command, extra args, a private build — is a documented thing to do, so a - * configured agent with that id REPLACES the shipped registration instead of - * being rejected. The always-listed ones stay reserved because a user who - * shadowed them would lose the agent bb guarantees is there. + * Overriding a shipped agent — with a different command, extra args, a + * private build — is a documented thing to do, so a configured agent with a + * shipped id REPLACES that registration instead of being rejected. Only + * `acp-cursor` opts out: bb installs it, signs in through it and reads its + * usage, so a user who shadowed it would lose surfaces bb guarantees. */ export const RESERVED_ACP_PROVIDER_IDS: ReadonlySet = new Set( - KNOWN_ACP_AGENTS.filter( - (agent) => (agent.visibility ?? "always") === "always", - ).map((agent) => agent.id), + KNOWN_ACP_AGENTS.filter((agent) => agent.reserved === true).map( + (agent) => agent.id, + ), );