mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat: add Junie CLI (JetBrains) agent across all 6 clouds (#2300)
Adds JetBrains' Junie CLI as a new agent in the spawn matrix. - agent: npm install -g @jetbrains/junie-cli, launched via `junie` - env: JUNIE_OPENROUTER_API_KEY (native OpenRouter BYOK support) - cloudInitTier: node (npm-based install) - matrix: all 6 clouds implemented (local, hetzner, aws, digitalocean, gcp, sprite) - icon: JetBrains org avatar (assets/agents/junie.png) - tests: 7 unit tests in junie-agent.test.ts - version bump: 0.15.9 → 0.15.10 Closes #2296 Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
51dec6e877
commit
252e8fc726
11 changed files with 351 additions and 1 deletions
|
|
@ -26,5 +26,9 @@
|
|||
"hermes": {
|
||||
"url": "https://s.w.org/images/core/emoji/17.0.2/svg/2695.svg",
|
||||
"ext": "png"
|
||||
},
|
||||
"junie": {
|
||||
"url": "https://avatars.githubusercontent.com/u/878437?s=200&v=4",
|
||||
"ext": "png"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
assets/agents/junie.png
Normal file
BIN
assets/agents/junie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -218,6 +218,32 @@
|
|||
"category": "cli",
|
||||
"tagline": "Persistent AI agent with memory, tools, and multi-platform messaging",
|
||||
"tags": ["agent", "messaging", "memory", "tools"]
|
||||
},
|
||||
"junie": {
|
||||
"name": "Junie",
|
||||
"description": "JetBrains' AI coding agent with native OpenRouter BYOK support",
|
||||
"url": "https://www.jetbrains.com/junie/",
|
||||
"install": "npm install -g @jetbrains/junie-cli",
|
||||
"launch": "junie",
|
||||
"env": {
|
||||
"JUNIE_OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}",
|
||||
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}"
|
||||
},
|
||||
"notes": "Natively supports OpenRouter via JUNIE_OPENROUTER_API_KEY. Subagent tasks may require GPT-4.1 Mini, GPT-4.1, or GPT-5 models to be enabled on your OpenRouter account.",
|
||||
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/junie.png",
|
||||
"featured_cloud": ["hetzner", "aws", "digitalocean"],
|
||||
"creator": "JetBrains",
|
||||
"repo": "JetBrains/junie",
|
||||
"license": "Proprietary",
|
||||
"created": "2026-03",
|
||||
"added": "2026-03",
|
||||
"github_stars": 5000,
|
||||
"stars_updated": "2026-03-07",
|
||||
"language": "TypeScript",
|
||||
"runtime": "node",
|
||||
"category": "cli",
|
||||
"tagline": "JetBrains' AI coding agent — BYOK with OpenRouter, IDE-quality intelligence in the terminal",
|
||||
"tags": ["coding", "terminal", "jetbrains", "byok"]
|
||||
}
|
||||
},
|
||||
"clouds": {
|
||||
|
|
@ -352,6 +378,12 @@
|
|||
"aws/hermes": "implemented",
|
||||
"digitalocean/hermes": "implemented",
|
||||
"gcp/hermes": "implemented",
|
||||
"sprite/hermes": "implemented"
|
||||
"sprite/hermes": "implemented",
|
||||
"local/junie": "implemented",
|
||||
"hetzner/junie": "implemented",
|
||||
"aws/junie": "implemented",
|
||||
"digitalocean/junie": "implemented",
|
||||
"gcp/junie": "implemented",
|
||||
"sprite/junie": "implemented"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
packages/cli/src/__tests__/junie-agent.test.ts
Normal file
86
packages/cli/src/__tests__/junie-agent.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* junie-agent.test.ts — Unit tests for the Junie CLI agent configuration.
|
||||
*
|
||||
* Verifies that:
|
||||
* - The junie agent is registered in createCloudAgents
|
||||
* - envVars returns JUNIE_OPENROUTER_API_KEY and OPENROUTER_API_KEY
|
||||
* - launchCmd includes 'junie'
|
||||
* - cloudInitTier is 'node' (npm-installed agent)
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
|
||||
// ── Suppress stderr output from logStep/logError during tests ────────────────
|
||||
|
||||
let stderrSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
// ── Mock oauth to avoid interactive prompts ──────────────────────────────────
|
||||
|
||||
mock.module("../shared/oauth", () => ({
|
||||
getOrPromptApiKey: mock(() => Promise.resolve("sk-or-v1-test-key")),
|
||||
getModelIdInteractive: mock(() => Promise.resolve("openrouter/auto")),
|
||||
}));
|
||||
|
||||
// ── Import module under test ──────────────────────────────────────────────────
|
||||
|
||||
const { createCloudAgents } = await import("../shared/agent-setup");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function createMockRunner() {
|
||||
return {
|
||||
runServer: mock(() => Promise.resolve()),
|
||||
uploadFile: mock(() => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Junie agent config", () => {
|
||||
it("is registered in createCloudAgents", () => {
|
||||
const { agents } = createCloudAgents(createMockRunner());
|
||||
expect(agents["junie"]).toBeDefined();
|
||||
expect(agents["junie"].name).toBe("Junie");
|
||||
});
|
||||
|
||||
it("resolveAgent finds junie by name", () => {
|
||||
const { resolveAgent } = createCloudAgents(createMockRunner());
|
||||
const agent = resolveAgent("junie");
|
||||
expect(agent.name).toBe("Junie");
|
||||
});
|
||||
|
||||
it("resolveAgent finds junie case-insensitively", () => {
|
||||
const { resolveAgent } = createCloudAgents(createMockRunner());
|
||||
const agent = resolveAgent("JUNIE");
|
||||
expect(agent.name).toBe("Junie");
|
||||
});
|
||||
|
||||
it("envVars sets JUNIE_OPENROUTER_API_KEY", () => {
|
||||
const { agents } = createCloudAgents(createMockRunner());
|
||||
const vars = agents["junie"].envVars("sk-or-v1-test-key");
|
||||
const junieKey = vars.find((v) => v.startsWith("JUNIE_OPENROUTER_API_KEY="));
|
||||
expect(junieKey).toBe("JUNIE_OPENROUTER_API_KEY=sk-or-v1-test-key");
|
||||
});
|
||||
|
||||
it("envVars sets OPENROUTER_API_KEY", () => {
|
||||
const { agents } = createCloudAgents(createMockRunner());
|
||||
const vars = agents["junie"].envVars("sk-or-v1-test-key");
|
||||
const orKey = vars.find((v) => v.startsWith("OPENROUTER_API_KEY="));
|
||||
expect(orKey).toBe("OPENROUTER_API_KEY=sk-or-v1-test-key");
|
||||
});
|
||||
|
||||
it("launchCmd includes junie", () => {
|
||||
const { agents } = createCloudAgents(createMockRunner());
|
||||
const cmd = agents["junie"].launchCmd();
|
||||
expect(cmd).toContain("junie");
|
||||
});
|
||||
|
||||
it("cloudInitTier is node", () => {
|
||||
const { agents } = createCloudAgents(createMockRunner());
|
||||
expect(agents["junie"].cloudInitTier).toBe("node");
|
||||
});
|
||||
});
|
||||
|
|
@ -669,6 +669,25 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
|
|||
launchCmd: () =>
|
||||
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:$PATH; hermes",
|
||||
},
|
||||
|
||||
junie: {
|
||||
name: "Junie",
|
||||
cloudInitTier: "node",
|
||||
preProvision: promptGithubAuth,
|
||||
install: () =>
|
||||
installAgent(
|
||||
runner,
|
||||
"Junie",
|
||||
`${NPM_PREFIX_SETUP} && npm install -g \${_NPM_G_FLAGS} @jetbrains/junie-cli && ` +
|
||||
"{ grep -qF '.npm-global/bin' ~/.bashrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.bashrc; } && " +
|
||||
"{ [ ! -f ~/.zshrc ] || grep -qF '.npm-global/bin' ~/.zshrc 2>/dev/null || echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc; }",
|
||||
),
|
||||
envVars: (apiKey) => [
|
||||
`JUNIE_OPENROUTER_API_KEY=${apiKey}`,
|
||||
`OPENROUTER_API_KEY=${apiKey}`,
|
||||
],
|
||||
launchCmd: () => "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; junie",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
26
sh/aws/junie.sh
Normal file
26
sh/aws/junie.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled aws.js (local or from GitHub release)
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/aws/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/aws/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download and run compiled TypeScript bundle
|
||||
AWS_JS=$(mktemp)
|
||||
trap 'rm -f "$AWS_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/aws-latest/aws.js" -o "$AWS_JS" \
|
||||
|| { printf '\033[0;31mFailed to download aws.js\033[0m\n' >&2; exit 1; }
|
||||
exec bun run "$AWS_JS" junie "$@"
|
||||
76
sh/digitalocean/junie.sh
Normal file
76
sh/digitalocean/junie.sh
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled digitalocean.js (local or from GitHub release)
|
||||
# Includes restart loop for SIGTERM recovery on DigitalOcean
|
||||
|
||||
_AGENT_NAME="junie"
|
||||
_MAX_RETRIES=3
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
# Run command in the foreground so bun gets full terminal access (raw mode,
|
||||
# arrow keys for interactive prompts). The old pattern backgrounded the child
|
||||
# with & + wait so a SIGTERM trap could forward the signal, but that removed
|
||||
# bun from the foreground process group and broke @clack/prompts multiselect.
|
||||
# Now SIGTERM is detected from exit code 143 (128 + 15) after the child exits.
|
||||
_run_with_restart() {
|
||||
local attempt=0
|
||||
local backoff=2
|
||||
while [ "$attempt" -lt "$_MAX_RETRIES" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
"$@"
|
||||
local exit_code=$?
|
||||
|
||||
# Normal exit
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# SIGTERM (143) or SIGKILL (137) — attempt restart
|
||||
if [ "$exit_code" -eq 143 ] || [ "$exit_code" -eq 137 ]; then
|
||||
printf '\033[0;33m[spawn/%s] Agent process terminated (exit %s). The droplet is likely still running.\033[0m\n' \
|
||||
"$_AGENT_NAME" "$exit_code" >&2
|
||||
printf '\033[0;33m[spawn/%s] Check your DigitalOcean dashboard: https://cloud.digitalocean.com/droplets\033[0m\n' \
|
||||
"$_AGENT_NAME" >&2
|
||||
if [ "$attempt" -lt "$_MAX_RETRIES" ]; then
|
||||
printf '\033[0;33m[spawn/%s] Restarting (attempt %s/%s, backoff %ss)...\033[0m\n' \
|
||||
"$_AGENT_NAME" "$((attempt + 1))" "$_MAX_RETRIES" "$backoff" >&2
|
||||
sleep "$backoff"
|
||||
backoff=$((backoff * 2))
|
||||
continue
|
||||
else
|
||||
printf '\033[0;31m[spawn/%s] Max restart attempts reached (%s). Giving up.\033[0m\n' \
|
||||
"$_AGENT_NAME" "$_MAX_RETRIES" >&2
|
||||
return "$exit_code"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Other failure — exit with the original code
|
||||
return "$exit_code"
|
||||
done
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/digitalocean/main.ts" ]]; then
|
||||
_run_with_restart bun run "$SPAWN_CLI_DIR/packages/cli/src/digitalocean/main.ts" "$_AGENT_NAME" "$@"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Remote — download bundled digitalocean.js from GitHub release
|
||||
DO_JS=$(mktemp)
|
||||
trap 'rm -f "$DO_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/digitalocean-latest/digitalocean.js" -o "$DO_JS" \
|
||||
|| { printf '\033[0;31mFailed to download digitalocean.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
_run_with_restart bun run "$DO_JS" "$_AGENT_NAME" "$@"
|
||||
exit $?
|
||||
27
sh/gcp/junie.sh
Normal file
27
sh/gcp/junie.sh
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled gcp.js (local or from GitHub release)
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/gcp/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/gcp/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled gcp.js from GitHub release
|
||||
GCP_JS=$(mktemp)
|
||||
trap 'rm -f "$GCP_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \
|
||||
|| { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$GCP_JS" junie "$@"
|
||||
26
sh/hetzner/junie.sh
Normal file
26
sh/hetzner/junie.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled hetzner.js (local or from GitHub release)
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/hetzner/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/hetzner/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download and run compiled TypeScript bundle
|
||||
HETZNER_JS=$(mktemp)
|
||||
trap 'rm -f "$HETZNER_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/hetzner-latest/hetzner.js" -o "$HETZNER_JS" \
|
||||
|| { printf '\033[0;31mFailed to download hetzner.js\033[0m\n' >&2; exit 1; }
|
||||
exec bun run "$HETZNER_JS" junie "$@"
|
||||
27
sh/local/junie.sh
Normal file
27
sh/local/junie.sh
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled local.js (local or from GitHub release)
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/local/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/local/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled local.js from GitHub release
|
||||
LOCAL_JS=$(mktemp)
|
||||
trap 'rm -f "$LOCAL_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/local-latest/local.js" -o "$LOCAL_JS" \
|
||||
|| { printf '\033[0;31mFailed to download local.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$LOCAL_JS" junie "$@"
|
||||
27
sh/sprite/junie.sh
Normal file
27
sh/sprite/junie.sh
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim: ensures bun is available, runs bundled sprite.js (local or from GitHub release)
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sprite/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sprite/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sprite.js from GitHub release
|
||||
SPRITE_JS=$(mktemp)
|
||||
trap 'rm -f "$SPRITE_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sprite-latest/sprite.js" -o "$SPRITE_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sprite.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SPRITE_JS" junie "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue