Skip to content
Merged
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ The CLI also offers a per-agent submenu (`opper` → Agents → *agent* → Laun
To remove an agent's Opper integration without uninstalling the agent itself:

```bash
opper agents uninstall claude-desktop # works for any registered adapter
opper agents remove claude-desktop # works for any registered adapter
```

This is the non-interactive equivalent of the menu's "Uninstall" action. It clears Opper-owned config (e.g., flips Claude Desktop's `deploymentMode` back to `"1p"`, removes the `opper` provider block from OpenCode / Pi / OpenClaw, etc.) without touching anything you put there yourself.
This is the non-interactive equivalent of the menu's "Remove Opper integration" action. It clears Opper-owned config (e.g., flips Claude Desktop's `deploymentMode` back to `"1p"`, removes the `opper` provider block from OpenCode / Pi / OpenClaw, etc.) without touching anything you put there yourself.

## Ask — built-in support agent

Expand Down
116 changes: 105 additions & 11 deletions src/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type {
OpperRouting,
} from "./types.js";

import { rm } from "node:fs/promises";

import { OPPER_COMPAT_URL } from "../config/endpoints.js";
import { PICKER_MODELS } from "../config/models.js";

Expand Down Expand Up @@ -57,6 +59,38 @@ function stripOpperBlock(text: string): string {
return `${before}\n${after}`;
}

function extractOpperBlock(text: string): string | null {
const startIdx = text.indexOf(SENTINEL_OPEN);
if (startIdx === -1) return null;
const endIdx = text.indexOf(SENTINEL_CLOSE, startIdx);
if (endIdx === -1) return null;
return text.slice(startIdx, endIdx + SENTINEL_CLOSE.length);
}

// Snapshot/restore variants that target the LAST opener-closer pair in
// the file. `writeOpperBlock` always appends our block at the end, so
// post-spawn the well-formed block is the last one. Using indexOf-first
// for restore would cross-match a stale unclosed opener with our new
// closer and strip user data between them.
function extractLastOpperBlock(text: string): string | null {
const startIdx = text.lastIndexOf(SENTINEL_OPEN);
if (startIdx === -1) return null;
const endIdx = text.indexOf(SENTINEL_CLOSE, startIdx);
if (endIdx === -1) return null;
return text.slice(startIdx, endIdx + SENTINEL_CLOSE.length);
}

function stripLastOpperBlock(text: string): string {
const startIdx = text.lastIndexOf(SENTINEL_OPEN);
if (startIdx === -1) return text;
const endIdx = text.indexOf(SENTINEL_CLOSE, startIdx);
if (endIdx === -1) return text;
const before = text.slice(0, startIdx).replace(/\n$/, "");
const after = text.slice(endIdx + SENTINEL_CLOSE.length).replace(/^\n/, "");
if (before.length === 0) return after;
return `${before}\n${after}`;
}

async function detect(): Promise<DetectResult> {
const path = await which("codex");
if (!path) return { installed: false };
Expand Down Expand Up @@ -140,19 +174,79 @@ function hasProfileArg(args: string[]): boolean {
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// Rewrite our provider/profile block on every launch so the latest
// session URL (and any tags it carries) is the active base_url.
// Narrow snapshot: capture just the sentinel-delimited opper block (or
// its absence). On exit we restore that block on top of whatever the
// file looks like by then — anything outside the sentinels (user
// edits to [settings], theme, etc.) is preserved.
const cfg = codexConfigPath();
const fileExistedBefore = existsSync(cfg);
// Tolerate read failures (perm, transient I/O) — same baseline as
// writeOpperBlock, which falls back to empty on read errors. A
// hard fail here would regress launch for users with unreadable
// configs.
let opperBlockBefore: string | null = null;
if (fileExistedBefore) {
try {
// Use lastIndexOf so a stale unclosed opener earlier in the file
// doesn't cause us to capture (and later restore) unrelated user
// content as part of the "block".
opperBlockBefore = extractLastOpperBlock(readFileSync(cfg, "utf8"));
} catch {
opperBlockBefore = null;
}
}

await writeOpperBlock(routing.baseUrl);
try {
const env: NodeJS.ProcessEnv = {
...process.env,
OPPER_API_KEY: routing.apiKey,
};
const finalArgs = hasProfileArg(args)
? args
: ["--profile", DEFAULT_PROFILE, ...args];
const result = spawnSync("codex", finalArgs, { stdio: "inherit", env });
return result.status ?? -1;
} finally {
await restoreOpperBlock(cfg, opperBlockBefore, fileExistedBefore);
}
}

const env: NodeJS.ProcessEnv = {
...process.env,
OPPER_API_KEY: routing.apiKey,
};
const finalArgs = hasProfileArg(args)
? args
: ["--profile", DEFAULT_PROFILE, ...args];
const result = spawnSync("codex", finalArgs, { stdio: "inherit", env });
return result.status ?? -1;
async function restoreOpperBlock(
cfg: string,
blockBefore: string | null,
fileExistedBefore: boolean,
): Promise<void> {
try {
const current = existsSync(cfg) ? readFileSync(cfg, "utf8") : "";
// Use lastIndexOf-based strip: writeOpperBlock appended our block
// at the end, so stripping the LAST opener-closer pair removes
// exactly what we wrote. indexOf-first would cross-match a stale
// unclosed opener with our new closer and erase user data between.
const stripped = stripLastOpperBlock(current);
let next: string;
if (blockBefore === null) {
next = stripped;
} else if (stripped.length === 0) {
next = `${blockBefore}\n`;
} else {
next = stripped.endsWith("\n")
? `${stripped}${blockBefore}\n`
: `${stripped}\n${blockBefore}\n`;
}
if (!fileExistedBefore && next.trim().length === 0) {
await rm(cfg, { force: true });
return;
}
await mkdir(dirname(cfg), { recursive: true });
await writeFile(cfg, next, "utf8");
} catch (err) {
process.stderr.write(
`opper: failed to restore ${cfg} after launch: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}

export const codex: AgentAdapter = {
Expand Down
72 changes: 57 additions & 15 deletions src/agents/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OpperError } from "../errors.js";
import { npmInstallGlobal } from "./npm-install.js";
import { OPPER_COMPAT_URL } from "../config/endpoints.js";
import { DEFAULT_MODELS, pickerModelsForLaunch } from "../config/models.js";
import { withJsonKeys } from "../util/config-snapshot.js";
import type {
AgentAdapter,
ConfigureOptions,
Expand Down Expand Up @@ -117,11 +118,22 @@ async function unconfigure(): Promise<void> {
}
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// Ensure our provider is current with the latest credentials, the
// chosen launch model, and the per-session base URL on every spawn.
await setOpperProvider(routing.apiKey, routing.model, routing.baseUrl);
function isDaemonInvocation(args: string[]): boolean {
// `start` and `restart` both launch a long-lived service that
// outlives spawnSync. `stop` / `status` / `logs` return synchronously
// and are safe to snapshot/restore around.
for (let i = 0; i < args.length - 1; i++) {
if (
(args[i] === "gateway" || args[i] === "daemon") &&
(args[i + 1] === "start" || args[i + 1] === "restart")
) {
Comment thread
joch marked this conversation as resolved.
return true;
}
}
return false;
}

async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// OpenClaw is a gateway/daemon, not an interactive REPL. Default to
// `gateway start` — installs/starts the background service via
// launchd/systemd, returns quickly, and the gateway keeps serving
Expand All @@ -133,18 +145,48 @@ async function spawn(args: string[], routing: OpperRouting): Promise<number> {
// opper launch openclaw -- agent --local -m "summarise ..."
// opper launch openclaw -- gateway run # foreground if you
// # really want it
const finalArgs = args.length > 0 ? args : ["gateway", "start"];
const result = spawnSync("openclaw", finalArgs, { stdio: "inherit" });

if (args.length === 0 && result.status === 0) {
console.log(
"\nOpenClaw gateway started in the background.\n" +
" Stop it with: openclaw gateway stop\n" +
" Status: openclaw gateway status\n" +
" Logs: openclaw logs\n",
);
// Detect daemon launches by subcommand, not arg count. Scan for the
// adjacent pair `gateway start` / `daemon start` anywhere in args
// because OpenClaw allows global flags before the subcommand
// (e.g. `opper launch openclaw -- --profile dev gateway start`).
const finalArgs = args.length === 0 ? ["gateway", "start"] : args;
const isDaemonStart = isDaemonInvocation(finalArgs);

// Snapshot/restore only fits one-shot synchronous invocations. For the
// daemon path, `spawnSync` returns as soon as the gateway detaches —
// restoring models.json then would either break the running gateway's
// routing (if it re-reads the file) or be cosmetic at best (if it
// cached at startup). Leave the file as-is for that path; the daemon
// owns its lifecycle and the user controls it via `gateway stop/start`.
//
// Trade-off: a direct `openclaw gateway start` run *after* an `opper
// launch openclaw` (without `opper launch` in front) will pick up the
// session-tagged URL until the next `opper launch` or `opper agents
// add openclaw` rewrites it. Acceptable — direct gateway use after a
// launch is rare, and the leak only persists in that narrow window.
if (isDaemonStart) {
await setOpperProvider(routing.apiKey, routing.model, routing.baseUrl);
const result = spawnSync("openclaw", finalArgs, { stdio: "inherit" });
if (result.status === 0) {
console.log(
"\nOpenClaw gateway started in the background.\n" +
" Stop it with: openclaw gateway stop\n" +
" Status: openclaw gateway status\n" +
" Logs: openclaw logs\n",
);
}
return result.status ?? -1;
}
return result.status ?? -1;

// Snapshot just `providers.opper` so direct `openclaw` invocations
// after the launch don't inherit this session's URL — and so any
// sibling providers / top-level keys the user edits mid-spawn aren't
// clobbered on restore.
return withJsonKeys(modelsPath(), [["providers", PROVIDER_KEY]], async () => {
await setOpperProvider(routing.apiKey, routing.model, routing.baseUrl);
const result = spawnSync("openclaw", finalArgs, { stdio: "inherit" });
return result.status ?? -1;
});
}

export const openclaw: AgentAdapter = {
Expand Down
107 changes: 79 additions & 28 deletions src/agents/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
configureOpenCode,
readProjectConfigState,
} from "../setup/opencode.js";
import { OPPER_COMPAT_URL } from "../config/endpoints.js";
import { opencodeConfigPath } from "../util/editor-paths.js";
import { withJsonKeys } from "../util/config-snapshot.js";
import { brand } from "../ui/colors.js";
import type {
AgentAdapter,
Expand Down Expand Up @@ -101,47 +103,96 @@ async function setSessionBaseUrl(
await writeFile(cfg, JSON.stringify(parsed, null, 2), "utf8");
}

/**
* Read `provider.opper.options.baseURL` from an opencode config without
* mutating the file. Returns undefined when the file or the provider is
* absent, or when the JSON is malformed — callers fall back to a default.
*/
function readBaseUrl(location: "global" | "local"): string | undefined {
const cfg = opencodeConfigPath(location);
if (!existsSync(cfg)) return undefined;
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as {
provider?: { opper?: { options?: { baseURL?: unknown } } };
};
const url = parsed.provider?.opper?.options?.baseURL;
return typeof url === "string" ? url : undefined;
} catch {
return undefined;
}
}

async function spawn(
args: string[],
routing: OpperRouting,
opts: SpawnOptions = {},
): Promise<number> {
const scope = opts.configScope ?? "user";
const location = scope === "project" ? "local" : "global";

if (scope === "project") {
// Explicit opt-in to writing the cwd-local config. We never silently
// mutate a project config the user didn't ask us to touch — that file
// is usually checked in.
// `--project` is opt-in to a persistent, usually-checked-in project
// config. Reverting the whole opper provider on exit would defeat
// the point — instead we apply the session URL only for the spawn
// and reset baseURL afterwards. The opper provider block stays in
// place across launches.
//
// Capture restoreUrl *before* configureOpenCode runs, since
// `overwrite: true` replaces provider.opper with template values
// (compat URL) — capturing after would discard a hand-edited
// self-hosted baseURL.
const restoreUrl = readBaseUrl("local") ?? OPPER_COMPAT_URL;
await configureOpenCode({ location: "local", overwrite: true });
} else {
await configureOpenCode({ location: "global", overwrite: true });

// OpenCode reads `./opencode.json` if present and uses it instead of
// the user-level config. If one exists without an Opper provider,
// whatever we just wrote globally is dead weight — warn so the user
// can re-run with `--project`.
const projectPath = opencodeConfigPath("local");
const state = readProjectConfigState(projectPath);
if (state.exists && !state.hasOpperProvider) {
process.stderr.write(
brand.dim(
`note: ${projectPath} will shadow the user-level Opper config. Re-run with \`--project\` to write the Opper provider there instead.\n`,
),
);
await setSessionBaseUrl(routing.baseUrl, "local");
try {
const env: NodeJS.ProcessEnv = {
...process.env,
OPPER_API_KEY: routing.apiKey,
};
const result = spawnSync("opencode", args, { stdio: "inherit", env });
return result.status ?? -1;
} finally {
await setSessionBaseUrl(restoreUrl, "local");
}
}

// Rewrite the baseURL to the per-session URL on every launch so
// generations land on the right session.
await setSessionBaseUrl(routing.baseUrl, location);
// User-scope: snapshot the Opper-owned keys. The template writes
// `provider.opper`, a top-level `model: "opper/..."`, AND a top-level
// `$schema` — all three need narrow restore. Without `$schema`, a
// fresh first launch leaves a `{"$schema": "..."}` file behind even
// though it's meant to be ephemeral. Without `model`, an orphaned
// `model: "opper/..."` points at a removed provider. OpenCode mutates
// sibling keys (theme, MCP servers, …) during a session — those are
// outside our keyPaths and survive the restore.
return withJsonKeys(
opencodeConfigPath("global"),
[["provider", "opper"], ["model"], ["$schema"]],
async () => {
await configureOpenCode({ location: "global", overwrite: true });

const env: NodeJS.ProcessEnv = {
...process.env,
OPPER_API_KEY: routing.apiKey,
};
const result = spawnSync("opencode", args, { stdio: "inherit", env });
return result.status ?? -1;
// OpenCode reads `./opencode.json` if present and uses it instead
// of the user-level config. If one exists without an Opper
// provider, whatever we just wrote globally is dead weight — warn
// so the user can re-run with `--project`.
const projectPath = opencodeConfigPath("local");
const state = readProjectConfigState(projectPath);
if (state.exists && !state.hasOpperProvider) {
process.stderr.write(
brand.dim(
`note: ${projectPath} will shadow the user-level Opper config. Re-run with \`--project\` to write the Opper provider there instead.\n`,
),
);
}

await setSessionBaseUrl(routing.baseUrl, "global");

const env: NodeJS.ProcessEnv = {
...process.env,
OPPER_API_KEY: routing.apiKey,
};
const result = spawnSync("opencode", args, { stdio: "inherit", env });
return result.status ?? -1;
},
);
}

export const opencode: AgentAdapter = {
Expand Down
Loading
Loading