Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/agents/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<DetectResult> {
const path = await which("claude");
Expand All @@ -24,11 +25,7 @@ async function detect(): Promise<DetectResult> {
}

async function install(): Promise<void> {
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<boolean> {
Expand Down
8 changes: 2 additions & 6 deletions src/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,11 +65,7 @@ async function detect(): Promise<DetectResult> {
}

async function install(): Promise<void> {
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<boolean> {
Expand Down
59 changes: 59 additions & 0 deletions src/agents/npm-install.ts
Original file line number Diff line number Diff line change
@@ -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 <pkg>` 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<void> {
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}.`,
Comment on lines +39 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish spawn failures from user-interrupted installs

run() uses code: -1 for both signal interruption and process-spawn errors, but this branch treats every -1 as a Ctrl-C cancellation. In environments where npm exists on PATH but cannot be executed (for example permission-denied or transient command resolution failures), users will get a misleading “interrupted” message and lose the actionable error context. Handle -1 by inspecting the underlying error/stderr (or propagate it) so real execution failures are not misreported as user cancellation.

Useful? React with 👍 / 👎.

);
}

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}.`,
);
}
7 changes: 2 additions & 5 deletions src/agents/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -100,11 +101,7 @@ async function detect(): Promise<DetectResult> {
}

async function install(): Promise<void> {
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<boolean> {
Expand Down
6 changes: 6 additions & 0 deletions src/agents/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,10 @@ async function detect(): Promise<DetectResult> {
};
}

async function install(): Promise<void> {
await npmInstallGlobal("opencode-ai", "https://opencode.ai");
}

async function isConfigured(): Promise<boolean> {
const cfg = opencodeConfigPath("global");
if (!existsSync(cfg)) return false;
Expand Down Expand Up @@ -109,5 +114,6 @@ export const opencode: AgentAdapter = {
isConfigured,
configure,
unconfigure,
install,
spawn,
};
7 changes: 2 additions & 5 deletions src/agents/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -98,11 +99,7 @@ async function detect(): Promise<DetectResult> {
}

async function install(): Promise<void> {
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<boolean> {
Expand Down
12 changes: 12 additions & 0 deletions src/commands/menu/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ async function agentMenu(initial: AdapterStatus, opts: MenuOptions): Promise<voi
const options: Array<{ value: string; label: string; hint?: string }> = [];

if (!installed) {
if (adapter.install) {
options.push({
value: "install",
label: `Install ${adapter.displayName}`,
hint: adapter.docsUrl,
});
}
options.push({
value: "docs",
label: "Show install instructions",
Expand Down Expand Up @@ -121,6 +128,11 @@ async function agentMenu(initial: AdapterStatus, opts: MenuOptions): Promise<voi
await adapter.unconfigure();
log.success(`${adapter.displayName} integration removed.`);
break;
case "install":
if (!adapter.install) break;
await adapter.install();
log.success(`${adapter.displayName} installed.`);
break;
case "docs":
log.info(`Install ${adapter.displayName}: ${adapter.docsUrl}`);
break;
Expand Down
2 changes: 1 addition & 1 deletion src/util/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface RunResult {
export function run(
command: string,
args: string[],
options: { inherit?: boolean } & Pick<SpawnSyncOptions, "cwd" | "env"> = {},
options: { inherit?: boolean } & Pick<SpawnSyncOptions, "cwd" | "env" | "shell"> = {},
): RunResult {
const { inherit, ...rest } = options;
const result = spawnSync(command, args, {
Expand Down
18 changes: 17 additions & 1 deletion test/agents/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:child_process")>(
Expand All @@ -23,6 +26,7 @@ const ROUTING = {
describe("claude-code adapter", () => {
beforeEach(() => {
whichMock.mockReset();
runMock.mockReset();
spawnSyncMock.mockReset();
});

Expand Down Expand Up @@ -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",
});
Expand Down
18 changes: 17 additions & 1 deletion test/agents/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("node:child_process")>(
Expand All @@ -29,6 +32,7 @@ describe("codex adapter", () => {

beforeEach(() => {
whichMock.mockReset();
runMock.mockReset();
spawnSyncMock.mockReset();
sandbox = mkdtempSync(join(tmpdir(), "opper-codex-"));
prevHome = process.env.HOME;
Expand Down Expand Up @@ -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",
});
Expand Down
97 changes: 97 additions & 0 deletions test/agents/npm-install.test.ts
Original file line number Diff line number Diff line change
@@ -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 <pkg>` 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"),
});
});
});
Loading
Loading