diff --git a/src/agents/claude-code.ts b/src/agents/claude-code.ts index 25b8bdd..10b1f23 100644 --- a/src/agents/claude-code.ts +++ b/src/agents/claude-code.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { which } from "../util/which.js"; import { OpperError } from "../errors.js"; +import { npmInstallGlobal } from "./npm-install.js"; import type { AgentAdapter, DetectResult, @@ -14,8 +15,8 @@ import { OPPER_COMPAT_URL } from "../config/endpoints.js"; // endpoint at `/v3/compat` serves both, so the picker auto-populates // with Opper's catalogue. -const INSTALL_HINT = - "Install via `npm i -g @anthropic-ai/claude-code` or see https://docs.claude.com/en/docs/claude-code/setup"; +const DOCS_URL = "https://docs.claude.com/en/docs/claude-code/setup"; +const INSTALL_HINT = `Install via \`npm i -g @anthropic-ai/claude-code\` or see ${DOCS_URL}`; async function detect(): Promise { const path = await which("claude"); @@ -24,11 +25,7 @@ async function detect(): Promise { } async function install(): Promise { - throw new OpperError( - "AGENT_NOT_FOUND", - "Claude Code must be installed manually.", - INSTALL_HINT, - ); + await npmInstallGlobal("@anthropic-ai/claude-code", DOCS_URL); } async function isConfigured(): Promise { diff --git a/src/agents/codex.ts b/src/agents/codex.ts index 00d355d..2cef487 100644 --- a/src/agents/codex.ts +++ b/src/agents/codex.ts @@ -4,7 +4,7 @@ import { join, dirname } from "node:path"; import { existsSync, readFileSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; import { which } from "../util/which.js"; -import { OpperError } from "../errors.js"; +import { npmInstallGlobal } from "./npm-install.js"; import type { AgentAdapter, DetectResult, @@ -65,11 +65,7 @@ async function detect(): Promise { } async function install(): Promise { - throw new OpperError( - "AGENT_NOT_FOUND", - "Codex must be installed manually.", - "Install via `npm i -g @openai/codex` or see https://github.com/openai/codex.", - ); + await npmInstallGlobal("@openai/codex", "https://github.com/openai/codex"); } async function isConfigured(): Promise { diff --git a/src/agents/npm-install.ts b/src/agents/npm-install.ts new file mode 100644 index 0000000..b138d36 --- /dev/null +++ b/src/agents/npm-install.ts @@ -0,0 +1,59 @@ +import { run } from "../util/run.js"; +import { which } from "../util/which.js"; +import { OpperError } from "../errors.js"; + +// On Windows, npm ships as a `.cmd` shim. Node refuses to execute .cmd +// files via spawnSync without a shell — even when given the explicit +// extension — so we have to route through cmd.exe (shell: true) on +// Windows. POSIX still runs npm directly, no shell. +const USE_SHELL = process.platform === "win32"; + +/** + * Run `npm install -g ` and surface a useful error if it fails. + * Used by every adapter whose upstream agent ships as a global npm package. + */ +export async function npmInstallGlobal( + packageName: string, + docsUrl: string, +): Promise { + if (!(await which("npm"))) { + throw new OpperError( + "AGENT_NOT_FOUND", + "npm is required to install this agent but was not found on PATH.", + `Install Node.js (which ships npm) from https://nodejs.org, or install ${packageName} manually from ${docsUrl}.`, + ); + } + + const result = run("npm", ["install", "-g", packageName], { + inherit: true, + shell: USE_SHELL, + }); + if (result.code === 0) return; + + // run() collapses both signal-killed children and spawn-time errors + // (EACCES on the npm binary, transient ENOENT, etc.) to code: -1. + // We tell them apart by stderr: with `inherit: true` run() only + // populates stderr when it has captured an Error.message from the + // spawn-failure path. Empty stderr ⇒ the child started and was killed + // by a signal (almost always Ctrl-C). + if (result.code === -1) { + if (result.stderr.trim().length > 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + `npm install -g ${packageName} failed to start: ${result.stderr.trim()}`, + `Resolve the underlying error, or install ${packageName} manually from ${docsUrl}.`, + ); + } + throw new OpperError( + "AGENT_NOT_FOUND", + `npm install -g ${packageName} was interrupted before completion.`, + `Re-run to retry, or install manually from ${docsUrl}.`, + ); + } + + throw new OpperError( + "AGENT_NOT_FOUND", + `npm install -g ${packageName} exited with code ${result.code}`, + `Check your network connection and npm permissions, or install manually from ${docsUrl}.`, + ); +} diff --git a/src/agents/openclaw.ts b/src/agents/openclaw.ts index 60e4391..201dc61 100644 --- a/src/agents/openclaw.ts +++ b/src/agents/openclaw.ts @@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { which } from "../util/which.js"; import { run } from "../util/run.js"; import { OpperError } from "../errors.js"; +import { npmInstallGlobal } from "./npm-install.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; import { DEFAULT_MODELS } from "../config/models.js"; import type { @@ -100,11 +101,7 @@ async function detect(): Promise { } async function install(): Promise { - throw new OpperError( - "AGENT_NOT_FOUND", - "OpenClaw must be installed manually.", - "Install via `npm i -g openclaw` or see https://docs.openclaw.ai.", - ); + await npmInstallGlobal("openclaw", "https://docs.openclaw.ai"); } async function isConfigured(): Promise { diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 87c82c7..4f527d6 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { which } from "../util/which.js"; +import { npmInstallGlobal } from "./npm-install.js"; import { configureOpenCode, readProjectConfigState, @@ -25,6 +26,10 @@ async function detect(): Promise { }; } +async function install(): Promise { + await npmInstallGlobal("opencode-ai", "https://opencode.ai"); +} + async function isConfigured(): Promise { const cfg = opencodeConfigPath("global"); if (!existsSync(cfg)) return false; @@ -109,5 +114,6 @@ export const opencode: AgentAdapter = { isConfigured, configure, unconfigure, + install, spawn, }; diff --git a/src/agents/pi.ts b/src/agents/pi.ts index f4ac6a8..e700035 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { which } from "../util/which.js"; import { run } from "../util/run.js"; import { OpperError } from "../errors.js"; +import { npmInstallGlobal } from "./npm-install.js"; import { OPPER_COMPAT_URL } from "../config/endpoints.js"; import { DEFAULT_MODELS } from "../config/models.js"; import type { @@ -98,11 +99,7 @@ async function detect(): Promise { } async function install(): Promise { - throw new OpperError( - "AGENT_NOT_FOUND", - "Pi must be installed manually.", - "Install via the instructions at https://pi.dev.", - ); + await npmInstallGlobal("@mariozechner/pi-coding-agent", "https://pi.dev"); } async function isConfigured(): Promise { diff --git a/src/commands/menu/agents.ts b/src/commands/menu/agents.ts index 045b605..f450673 100644 --- a/src/commands/menu/agents.ts +++ b/src/commands/menu/agents.ts @@ -57,6 +57,13 @@ async function agentMenu(initial: AdapterStatus, opts: MenuOptions): Promise = []; if (!installed) { + if (adapter.install) { + options.push({ + value: "install", + label: `Install ${adapter.displayName}`, + hint: adapter.docsUrl, + }); + } options.push({ value: "docs", label: "Show install instructions", @@ -121,6 +128,11 @@ async function agentMenu(initial: AdapterStatus, opts: MenuOptions): Promise = {}, + options: { inherit?: boolean } & Pick = {}, ): RunResult { const { inherit, ...rest } = options; const result = spawnSync(command, args, { diff --git a/test/agents/claude-code.test.ts b/test/agents/claude-code.test.ts index 3e7be80..ecceab3 100644 --- a/test/agents/claude-code.test.ts +++ b/test/agents/claude-code.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const whichMock = vi.fn(); vi.mock("../../src/util/which.js", () => ({ which: whichMock })); +const runMock = vi.fn(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + const spawnSyncMock = vi.fn(); vi.mock("node:child_process", async () => { const actual = await vi.importActual( @@ -23,6 +26,7 @@ const ROUTING = { describe("claude-code adapter", () => { beforeEach(() => { whichMock.mockReset(); + runMock.mockReset(); spawnSyncMock.mockReset(); }); @@ -59,7 +63,19 @@ describe("claude-code adapter", () => { }); }); - it("install throws with the install hint", async () => { + it("install runs `npm i -g @anthropic-ai/claude-code` and resolves on exit 0", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + await expect(claudeCode.install!()).resolves.toBeUndefined(); + const [cmd, args, options] = runMock.mock.calls[0]!; + expect(cmd).toMatch(/^npm(\.cmd)?$/); + expect(args).toEqual(["install", "-g", "@anthropic-ai/claude-code"]); + expect(options).toMatchObject({ inherit: true }); + }); + + it("install throws AGENT_NOT_FOUND when npm exits non-zero", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); await expect(claudeCode.install!()).rejects.toMatchObject({ code: "AGENT_NOT_FOUND", }); diff --git a/test/agents/codex.test.ts b/test/agents/codex.test.ts index 952332c..f684a7e 100644 --- a/test/agents/codex.test.ts +++ b/test/agents/codex.test.ts @@ -13,6 +13,9 @@ import { join } from "node:path"; const whichMock = vi.fn(); vi.mock("../../src/util/which.js", () => ({ which: whichMock })); +const runMock = vi.fn(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + const spawnSyncMock = vi.fn(); vi.mock("node:child_process", async () => { const actual = await vi.importActual( @@ -29,6 +32,7 @@ describe("codex adapter", () => { beforeEach(() => { whichMock.mockReset(); + runMock.mockReset(); spawnSyncMock.mockReset(); sandbox = mkdtempSync(join(tmpdir(), "opper-codex-")); prevHome = process.env.HOME; @@ -126,7 +130,19 @@ describe("codex adapter", () => { expect(text).toContain('theme = "dark"'); }); - it("install throws AGENT_NOT_FOUND with the install hint", async () => { + it("install runs `npm i -g @openai/codex` and resolves on exit 0", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + await expect(codex.install!()).resolves.toBeUndefined(); + const [cmd, args, options] = runMock.mock.calls[0]!; + expect(cmd).toMatch(/^npm(\.cmd)?$/); + expect(args).toEqual(["install", "-g", "@openai/codex"]); + expect(options).toMatchObject({ inherit: true }); + }); + + it("install throws AGENT_NOT_FOUND when npm exits non-zero", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); await expect(codex.install!()).rejects.toMatchObject({ code: "AGENT_NOT_FOUND", }); diff --git a/test/agents/npm-install.test.ts b/test/agents/npm-install.test.ts new file mode 100644 index 0000000..6f5c855 --- /dev/null +++ b/test/agents/npm-install.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const whichMock = vi.fn(); +vi.mock("../../src/util/which.js", () => ({ which: whichMock })); + +const runMock = vi.fn(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + +const { npmInstallGlobal } = await import("../../src/agents/npm-install.js"); + +describe("npmInstallGlobal", () => { + beforeEach(() => { + whichMock.mockReset(); + runMock.mockReset(); + }); + + it("invokes npm with `install -g ` and inherited stdio", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + + await expect( + npmInstallGlobal("some-pkg", "https://example.test"), + ).resolves.toBeUndefined(); + + const [cmd, args, opts] = runMock.mock.calls[0]!; + expect(cmd).toBe("npm"); + expect(args).toEqual(["install", "-g", "some-pkg"]); + expect(opts).toMatchObject({ inherit: true }); + }); + + it("routes through a shell on Windows so cmd.exe can resolve the npm.cmd shim", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + + await npmInstallGlobal("some-pkg", "https://example.test"); + + const [, , opts] = runMock.mock.calls[0]!; + // Node's spawnSync refuses to execute .cmd shims directly — without + // shell:true on Windows, every install would fail with code -1. + expect((opts as { shell?: boolean }).shell).toBe( + process.platform === "win32", + ); + }); + + it("throws AGENT_NOT_FOUND with a Node.js install hint when npm isn't on PATH", async () => { + whichMock.mockResolvedValue(null); + + await expect( + npmInstallGlobal("some-pkg", "https://example.test"), + ).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + message: expect.stringContaining("npm is required"), + }); + // Bailed out before invoking npm. + expect(runMock).not.toHaveBeenCalled(); + }); + + it("throws a graceful 'interrupted' error when code is -1 with empty stderr (signal kill)", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: -1, stdout: "", stderr: "" }); + + await expect( + npmInstallGlobal("some-pkg", "https://example.test"), + ).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + message: expect.stringContaining("interrupted"), + }); + }); + + it("surfaces the spawn error when code is -1 with stderr (e.g. EACCES on npm)", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ + code: -1, + stdout: "", + stderr: "EACCES: permission denied", + }); + + await expect( + npmInstallGlobal("some-pkg", "https://example.test"), + ).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + message: expect.stringContaining("EACCES: permission denied"), + }); + }); + + it("throws AGENT_NOT_FOUND with the exit code when npm exits non-zero", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 13, stdout: "", stderr: "" }); + + await expect( + npmInstallGlobal("some-pkg", "https://example.test"), + ).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + message: expect.stringContaining("exited with code 13"), + }); + }); +}); diff --git a/test/agents/opencode.test.ts b/test/agents/opencode.test.ts index 6dfc800..7bc7df9 100644 --- a/test/agents/opencode.test.ts +++ b/test/agents/opencode.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const whichMock = vi.fn(); vi.mock("../../src/util/which.js", () => ({ which: whichMock })); +const runMock = vi.fn(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + const configureOpenCodeMock = vi.fn(); const readProjectConfigStateMock = vi.fn(); vi.mock("../../src/setup/opencode.js", () => ({ @@ -30,6 +33,7 @@ const ROUTING = { describe("opencode adapter", () => { beforeEach(() => { whichMock.mockReset(); + runMock.mockReset(); configureOpenCodeMock.mockReset(); readProjectConfigStateMock.mockReset(); spawnSyncMock.mockReset(); @@ -45,7 +49,26 @@ describe("opencode adapter", () => { expect(opencode.displayName).toBe("OpenCode"); expect(opencode.docsUrl).toMatch(/^https:\/\//); expect(typeof opencode.spawn).toBe("function"); - expect(opencode.install).toBeUndefined(); // no scripted installer + expect(typeof opencode.install).toBe("function"); + }); + + it("install runs `npm i -g opencode-ai` with inherited stdio and resolves on exit 0", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + await expect(opencode.install!()).resolves.toBeUndefined(); + expect(runMock).toHaveBeenCalledTimes(1); + const [cmd, args, options] = runMock.mock.calls[0]!; + expect(cmd).toMatch(/^npm(\.cmd)?$/); + expect(args).toEqual(["install", "-g", "opencode-ai"]); + expect(options).toMatchObject({ inherit: true }); + }); + + it("install throws OpperError(AGENT_NOT_FOUND) when npm exits non-zero", async () => { + whichMock.mockResolvedValue("/usr/bin/npm"); + runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); + await expect(opencode.install!()).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + }); }); it("detect returns installed=false when opencode not on PATH", async () => { diff --git a/test/commands/menu.test.ts b/test/commands/menu.test.ts index 9f42e01..420aa6a 100644 --- a/test/commands/menu.test.ts +++ b/test/commands/menu.test.ts @@ -250,6 +250,20 @@ describe("menuCommand", () => { expect(launchMock).toHaveBeenCalledWith({ agent: "hermes", key: "default" }); }); + it("agents submenu → agent menu → Install runs adapter.install() when not installed", async () => { + hermesDetect.mockResolvedValue({ installed: false }); + hermesIsConfigured.mockResolvedValue(false); + answers.push(() => "agents"); + answers.push(() => "agent:hermes"); + answers.push(() => "install"); + answers.push(() => "back"); + answers.push(() => "back"); + answers.push(() => "quit"); + + await menuCommand({ key: "default" }); + expect(hermesAdapter.install).toHaveBeenCalled(); + }); + it("agents submenu → agent menu → Remove integration calls unconfigure()", async () => { hermesDetect.mockResolvedValue({ installed: true }); hermesIsConfigured.mockResolvedValue(true);