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
29 changes: 13 additions & 16 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
buildPersistentTaskThreadName,
DEFAULT_CONTINUE_PROMPT,
findLatestTaskThread,
getCodexAuthStatus,
getCodexAvailability,
getCodexLoginStatus,
getSessionRuntimeStatus,
interruptAppServerTurn,
parseStructuredOutput,
Expand Down Expand Up @@ -176,19 +176,19 @@ function firstMeaningfulLine(text, fallback) {
return line ?? fallback;
}

function buildSetupReport(cwd, actionsTaken = []) {
async function buildSetupReport(cwd, actionsTaken = []) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
const codexStatus = getCodexAvailability(cwd);
const authStatus = getCodexLoginStatus(cwd);
const authStatus = await getCodexAuthStatus(cwd);
const config = getConfig(workspaceRoot);

const nextSteps = [];
if (!codexStatus.available) {
nextSteps.push("Install Codex with `npm install -g @openai/codex`.");
}
if (codexStatus.available && !authStatus.loggedIn) {
if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) {
nextSteps.push("Run `!codex login`.");
nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`.");
}
Expand All @@ -209,7 +209,7 @@ function buildSetupReport(cwd, actionsTaken = []) {
};
}

function handleSetup(argv) {
async function handleSetup(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd"],
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
Expand All @@ -231,7 +231,7 @@ function handleSetup(argv) {
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
}

const finalReport = buildSetupReport(cwd, actionsTaken);
const finalReport = await buildSetupReport(cwd, actionsTaken);
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
}

Expand All @@ -245,14 +245,11 @@ function buildAdversarialReviewPrompt(context, focusText) {
});
}

function ensureCodexReady(cwd) {
const authStatus = getCodexLoginStatus(cwd);
if (!authStatus.available) {
function ensureCodexAvailable(cwd) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
}
if (!authStatus.loggedIn) {
throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry.");
}
}

function buildNativeReviewTarget(target) {
Expand Down Expand Up @@ -325,7 +322,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) {
}

async function executeReviewRun(request) {
ensureCodexReady(request.cwd);
ensureCodexAvailable(request.cwd);
ensureGitRepository(request.cwd);

const target = resolveReviewTarget(request.cwd, {
Expand Down Expand Up @@ -429,7 +426,7 @@ async function executeReviewRun(request) {

async function executeTaskRun(request) {
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
ensureCodexReady(request.cwd);
ensureCodexAvailable(request.cwd);

const taskMetadata = buildTaskRunMetadata({
prompt: request.prompt,
Expand Down Expand Up @@ -728,7 +725,7 @@ async function handleTask(argv) {
});

if (options.background) {
ensureCodexReady(cwd);
ensureCodexAvailable(cwd);
requireTaskRequest(prompt, resumeLast);

const job = buildTaskJob(workspaceRoot, taskMetadata, write);
Expand Down Expand Up @@ -967,7 +964,7 @@ async function main() {

switch (subcommand) {
case "setup":
handleSetup(argv);
await handleSetup(argv);
break;
case "review":
await handleReview(argv);
Expand Down
1 change: 1 addition & 0 deletions plugins/codex/scripts/lib/app-server-protocol.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
capabilities?: InitializeCapabilities;
brokerEndpoint?: string;
disableBroker?: boolean;
reuseExistingBroker?: boolean;
}

export interface AppServerMethodMap {
Expand Down
7 changes: 5 additions & 2 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import process from "node:process";
import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
import { terminateProcessTree } from "./process.mjs";

const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
Expand Down Expand Up @@ -333,7 +333,10 @@ export class CodexAppServerClient {
let brokerEndpoint = null;
if (!options.disableBroker) {
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
if (!brokerEndpoint) {
if (!brokerEndpoint && options.reuseExistingBroker) {
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
}
if (!brokerEndpoint && !options.reuseExistingBroker) {
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
brokerEndpoint = brokerSession?.endpoint ?? null;
}
Expand Down
180 changes: 153 additions & 27 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
import { readJsonFile } from "./fs.mjs";
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
import { loadBrokerSession } from "./broker-lifecycle.mjs";
import { binaryAvailable, runCommand } from "./process.mjs";
import { binaryAvailable } from "./process.mjs";

const SERVICE_NAME = "claude_code_codex_plugin";
const TASK_THREAD_PREFIX = "Codex Companion Task";
Expand Down Expand Up @@ -652,6 +652,134 @@ function buildResultStatus(turnState) {
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}

const BUILTIN_PROVIDER_LABELS = new Map([
["openai", "OpenAI"],
["ollama", "Ollama"],
["lmstudio", "LM Studio"]
]);

function normalizeProviderId(value) {
const providerId = typeof value === "string" ? value.trim() : "";
return providerId || null;
}

function formatProviderLabel(providerId, providerConfig = null) {
const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : "";
if (configuredName) {
return configuredName;
}
if (!providerId) {
return "The active provider";
}
return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId;
}

function buildAuthStatus(fields = {}) {
return {
available: true,
loggedIn: false,
detail: "not authenticated",
source: "unknown",
authMethod: null,
verified: null,
requiresOpenaiAuth: null,
provider: null,
...fields
};
}

function resolveProviderConfig(configResponse) {
const config = configResponse?.config;
if (!config || typeof config !== "object") {
return {
providerId: null,
providerConfig: null
};
}

const providerId = normalizeProviderId(config.model_provider);
const providers =
config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers)
? config.model_providers
: null;
const providerConfig =
providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;

return {
providerId,
providerConfig
};
}

function buildAppServerAuthStatus(accountResponse, configResponse) {
const account = accountResponse?.account ?? null;
const requiresOpenaiAuth =
typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null;
const { providerId, providerConfig } = resolveProviderConfig(configResponse);
const providerLabel = formatProviderLabel(providerId, providerConfig);

if (account?.type === "chatgpt") {
const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null;
return buildAuthStatus({
loggedIn: true,
detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active",
source: "app-server",
authMethod: "chatgpt",
verified: true,
requiresOpenaiAuth,
provider: providerId
});
}

if (account?.type === "apiKey") {
return buildAuthStatus({
loggedIn: true,
detail: "API key configured (unverified)",
source: "app-server",
authMethod: "apiKey",
verified: false,
requiresOpenaiAuth,
provider: providerId
});
}

if (requiresOpenaiAuth === false) {
return buildAuthStatus({
loggedIn: true,
detail: `${providerLabel} is configured and does not require OpenAI authentication`,
source: "app-server",
requiresOpenaiAuth,
provider: providerId
});
}

return buildAuthStatus({
loggedIn: false,
detail: `${providerLabel} requires OpenAI authentication`,
source: "app-server",
requiresOpenaiAuth,
provider: providerId
});
}

async function getCodexAuthStatusFromClient(client, cwd) {
try {
const accountResponse = await client.request("account/read", { refreshToken: false });
const configResponse = await client.request("config/read", {
includeLayers: false,
cwd
});

return buildAppServerAuthStatus(accountResponse, configResponse);
} catch (error) {
return buildAuthStatus({
loggedIn: false,
detail: error instanceof Error ? error.message : String(error),
source: "app-server"
});
}
}

export function getCodexAvailability(cwd) {
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
if (!versionStatus.available) {
Expand Down Expand Up @@ -691,38 +819,39 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
};
}

export function getCodexLoginStatus(cwd) {
export async function getCodexAuthStatus(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
return {
available: false,
loggedIn: false,
detail: availability.detail
detail: availability.detail,
source: "availability",
authMethod: null,
verified: null,
requiresOpenaiAuth: null,
provider: null
};
}

const result = runCommand("codex", ["login", "status"], { cwd });
if (result.error) {
return {
available: true,
let client = null;
try {
client = await CodexAppServerClient.connect(cwd, {
env: options.env,
reuseExistingBroker: true
});
return await getCodexAuthStatusFromClient(client, cwd);
} catch (error) {
return buildAuthStatus({
loggedIn: false,
detail: result.error.message
};
}

if (result.status === 0) {
return {
available: true,
loggedIn: true,
detail: result.stdout.trim() || "authenticated"
};
detail: error instanceof Error ? error.message : String(error),
source: "app-server"
});
} finally {
if (client) {
await client.close().catch(() => {});
}
}

return {
available: true,
loggedIn: false,
detail: result.stderr.trim() || result.stdout.trim() || "not authenticated"
};
}

export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
Expand All @@ -745,12 +874,9 @@ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
};
}

const brokerEndpoint = process.env[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
let client = null;
try {
client = brokerEndpoint
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
await client.request("turn/interrupt", { threadId, turnId });
return {
attempted: true,
Expand Down
18 changes: 12 additions & 6 deletions plugins/codex/scripts/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

import { getCodexLoginStatus } from "./lib/codex.mjs";
import { getCodexAvailability } from "./lib/codex.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import { getConfig, listJobs } from "./lib/state.mjs";
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
Expand Down Expand Up @@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) {
}

function buildSetupNote(cwd) {
const authStatus = getCodexLoginStatus(cwd);
if (authStatus.available && authStatus.loggedIn) {
const availability = getCodexAvailability(cwd);
if (availability.available) {
return null;
}

const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
const detail = availability.detail ? ` ${availability.detail}.` : "";
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
}

function parseStopReviewOutput(rawOutput) {
Expand Down Expand Up @@ -175,4 +175,10 @@ function main() {
logNote(runningTaskNote);
}

main();
try {
main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}
Loading