diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 9737db3..f3a97a8 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.0.8", + "version": "1.0.9", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.", "author": { "name": "Sendbird, Inc.", diff --git a/.github/workflows/update-marketplace.yml b/.github/workflows/update-marketplace.yml new file mode 100644 index 0000000..bd8153b --- /dev/null +++ b/.github/workflows/update-marketplace.yml @@ -0,0 +1,110 @@ +name: Update Codex Marketplace + +on: + release: + types: + - published + workflow_dispatch: + inputs: + ref: + description: Git ref to publish into the marketplace snapshot + required: false + default: main + +jobs: + update-marketplace: + runs-on: ubuntu-latest + permissions: + contents: read + env: + MARKETPLACE_REPO: sendbird/codex-marketplace + MARKETPLACE_BRANCH_PREFIX: auto/update-cc- + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.ref || github.ref_name }} + + - name: Verify marketplace token is configured + env: + MARKETPLACE_TOKEN: ${{ secrets.SENDBIRD_CODEX_MARKETPLACE_PUSH_TOKEN }} + run: | + if [ -z "$MARKETPLACE_TOKEN" ]; then + echo "SENDBIRD_CODEX_MARKETPLACE_PUSH_TOKEN is required to update $MARKETPLACE_REPO" >&2 + exit 1 + fi + + - name: Build marketplace snapshot + run: | + SNAPSHOT_DIR="$RUNNER_TEMP/marketplace-snapshot/cc" + mkdir -p "$SNAPSHOT_DIR" + for entry in \ + .codex-plugin \ + CHANGELOG.md \ + LICENSE \ + NOTICE \ + README.md \ + agents \ + assets \ + hooks \ + internal-skills \ + package.json \ + prompts \ + schemas \ + scripts \ + skills + do + if [ -e "$entry" ]; then + rsync -a "$entry" "$SNAPSHOT_DIR/" + fi + done + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: sendbird/codex-marketplace + path: marketplace + token: ${{ secrets.SENDBIRD_CODEX_MARKETPLACE_PUSH_TOKEN }} + fetch-depth: 0 + + - name: Update marketplace snapshot + run: | + rm -rf marketplace/plugins/cc + mkdir -p marketplace/plugins + rsync -a --delete "$RUNNER_TEMP/marketplace-snapshot/cc/" marketplace/plugins/cc/ + + - name: Create marketplace pull request + env: + GH_TOKEN: ${{ secrets.SENDBIRD_CODEX_MARKETPLACE_PUSH_TOKEN }} + REF_NAME: ${{ github.event.release.tag_name || inputs.ref || github.ref_name }} + run: | + cd marketplace + + if git diff --quiet -- plugins/cc; then + echo "Marketplace snapshot already matches $REF_NAME" + exit 0 + fi + + BRANCH_NAME="${MARKETPLACE_BRANCH_PREFIX}${REF_NAME//\//-}" + git checkout -B "$BRANCH_NAME" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add plugins/cc + git commit -m "Update cc plugin snapshot to $REF_NAME" + git push --force-with-lease origin "$BRANCH_NAME" + + EXISTING_PR_NUMBER=$(gh pr list \ + --repo "$MARKETPLACE_REPO" \ + --head "$BRANCH_NAME" \ + --json number \ + --jq '.[0].number // empty') + + if [ -n "$EXISTING_PR_NUMBER" ]; then + echo "Marketplace PR already exists: #$EXISTING_PR_NUMBER" + exit 0 + fi + + gh pr create \ + --repo "$MARKETPLACE_REPO" \ + --base main \ + --head "$BRANCH_NAME" \ + --title "Update cc plugin snapshot to $REF_NAME" \ + --body "Update \`plugins/cc\` in the Sendbird Codex marketplace to \`$REF_NAME\` from \`sendbird/cc-plugin-codex\`." diff --git a/CHANGELOG.md b/CHANGELOG.md index 473c966..62619fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.0.9 + +- Add marketplace-aware install foundation for Codex 0.121+: the installer can now prefer `marketplace/add` + `plugin/install` when an official marketplace source is available, while keeping the existing legacy fallback path for unsupported builds. +- Generalize managed plugin identity handling so setup, hook cleanup, and cache detection work for `cc@` installs instead of assuming `cc@local-plugins`. +- Document the new canonical marketplace location at `sendbird/codex-marketplace` and make Sendbird marketplace install the first documented path, with `$cc:setup` called out as the required post-install hook repair step. + ## v1.0.8 - Clarify the routing boundary between `$cc:review`, `$cc:adversarial-review`, and `$cc:rescue`, including the rule that ordinary code-review requests default to `review`, stronger scrutiny plus custom focus text belongs to `adversarial-review`, and rescue is only for Claude-owned follow-through work. diff --git a/README.md b/README.md index bd5b404..36ccd93 100644 --- a/README.md +++ b/README.md @@ -43,20 +43,37 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex ### 1. Install -```bash -npx cc-plugin-codex install -``` - -That's the entire install. It: +Use one of these install paths, in this order: + +1. **Sendbird marketplace (preferred)** + ```bash + codex marketplace add sendbird/codex-marketplace + ``` + Then install `cc` from the Sendbird marketplace inside Codex, and run `$cc:setup` once. Marketplace/plugin install places the plugin, but this plugin still owns global hook setup and repair. + +2. **`npx` installer** + ```bash + npx cc-plugin-codex install + ``` + This is the cross-platform path we test on every release. + +3. **Local checkout install** + ```bash + git clone https://github.com/sendbird/cc-plugin-codex.git ~/.codex/plugins/cc + cd ~/.codex/plugins/cc + node scripts/local-plugin-install.mjs install --plugin-root ~/.codex/plugins/cc + ``` + After install, run `$cc:setup`. + +The `npx` installer: - Copies the plugin to `~/.codex/plugins/cc` - Activates the plugin through Codex app-server when available - Falls back to config-based activation on older Codex builds - Enables `codex_hooks = true` - Installs lifecycle, review-gate, and unread-result hooks -On Windows, prefer the `npx` path above. The shell-script installer below is POSIX-only. -Codex CLI's official guidance still treats Windows support as experimental and recommends a WSL workspace for the best Codex experience. Claude Code supports both native Windows and WSL. In hosted CI we currently keep Windows on the native cross-platform core suite, while full integration and E2E coverage run on Linux and macOS. -The `npx` install path is the cross-platform path we test on every release. +On Windows, prefer either the Sendbird marketplace path or the `npx` path. The shell-script installer below is POSIX-only. +Codex CLI's official guidance still treats Windows support as experimental and recommends a WSL workspace for the best Codex experience. Claude Code supports both native Windows and WSL. > **Prerequisites:** Node.js 18+, Codex with hook support, and `claude` CLI installed and authenticated. > If you don't have the Claude CLI yet: @@ -213,6 +230,7 @@ $cc:setup --disable-review-gate # turn it off ``` Setup checks Claude Code availability, hook installation, and review-gate state. If hooks are missing, it reinstalls them. If Claude Code isn't installed, it offers to install it. +This is also the repair path for marketplace-installed copies of the plugin: marketplace install can place the plugin, but `$cc:setup` is what confirms `codex_hooks = true` and installs the managed global hooks if they are missing. ## Background Jobs @@ -279,16 +297,32 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C ## Install Variants -### npx (recommended) +### Sendbird marketplace (preferred) + +Add the marketplace: ```bash -npx cc-plugin-codex install +codex marketplace add sendbird/codex-marketplace ``` -### Shell script +Then install `cc` from the Sendbird marketplace inside Codex, and run: + +```text +$cc:setup +``` + +Marketplace/plugin install places the plugin, but it does **not** install this plugin's managed global hooks for you. `$cc:setup` is the repair/install step that confirms `codex_hooks = true` and installs hooks when they are missing. + +### npx ```bash -curl -fsSL "https://raw.githubusercontent.com/sendbird/cc-plugin-codex/main/scripts/install.sh" | bash +npx cc-plugin-codex install +``` + +After install, run: + +```text +$cc:setup ``` ### Local checkout @@ -299,8 +333,26 @@ cd ~/.codex/plugins/cc node scripts/local-plugin-install.mjs install --plugin-root ~/.codex/plugins/cc ``` +After install, run: + +```text +$cc:setup +``` + `local-plugin-install.mjs` expects `--plugin-root` to be the managed install directory itself. If you want to install from an arbitrary checkout path, use `npx cc-plugin-codex install` instead. +### Shell script (POSIX-only) + +```bash +curl -fsSL "https://raw.githubusercontent.com/sendbird/cc-plugin-codex/main/scripts/install.sh" | bash +``` + +After install, run: + +```text +$cc:setup +``` + ### Update Re-run the install command — it's idempotent. diff --git a/package.json b/package.json index 5ba628c..813a919 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.0.8", + "version": "1.0.9", "description": "Claude Code Plugin for Codex by Sendbird", "type": "module", "author": { diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 784b485..9e5df0e 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -981,6 +981,33 @@ function renderStatusPayload(report, asJson) { return asJson ? report : renderStatusReport(report); } +function statusPayloadSurfacesStoredResult(job) { + return ( + Boolean(job) && + (job.status === "completed" || + job.status === "failed" || + job.status === "cancelled" || + job.status === "cancel_failed" || + job.status === "unknown") && + Object.prototype.hasOwnProperty.call(job, "result") + ); +} + +function markViewedViaStatusAccess(workspaceRoot, jobs) { + const viewedAt = nowIso(); + let changed = false; + + for (const job of jobs) { + if (!job?.id || job.resultViewedAt || !statusPayloadSurfacesStoredResult(job)) { + continue; + } + patchJob(workspaceRoot, job.id, { resultViewedAt: viewedAt }); + changed = true; + } + + return changed; +} + // --------------------------------------------------------------------------- // Foreground execution wrapper // --------------------------------------------------------------------------- @@ -1476,12 +1503,24 @@ async function handleStatus(argv) { const cwd = resolveCommandCwd(options); const reference = positionals[0] ?? ""; if (reference) { - const snapshot = options.wait + let snapshot = options.wait ? await waitForSingleJobSnapshot(cwd, reference, { timeoutMs: options["timeout-ms"], pollIntervalMs: options["poll-interval-ms"] }) : buildSingleJobSnapshot(cwd, reference); + if ( + options.json && + markViewedViaStatusAccess(snapshot.workspaceRoot, [snapshot.job]) + ) { + snapshot = options.wait + ? { + ...buildSingleJobSnapshot(cwd, reference), + waitTimedOut: snapshot.waitTimedOut, + timeoutMs: snapshot.timeoutMs, + } + : buildSingleJobSnapshot(cwd, reference); + } outputCommandResult( snapshot, renderJobStatusReport(snapshot.job), @@ -1494,7 +1533,16 @@ async function handleStatus(argv) { throw new Error("`status --wait` requires a job id."); } - const report = buildStatusSnapshot(cwd, { all: options.all }); + let report = buildStatusSnapshot(cwd, { all: options.all }); + if ( + options.json && + markViewedViaStatusAccess(report.workspaceRoot, [ + report.latestFinished, + ...report.recent, + ]) + ) { + report = buildStatusSnapshot(cwd, { all: options.all }); + } outputResult(renderStatusPayload(report, options.json), options.json); } diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index 07ca031..1558203 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -14,12 +14,13 @@ * 3. Read existing ~/.codex/hooks.json (or empty {hooks:{}}) * 4. For each event type, append new hooks (don't overwrite existing) * 5. Write merged result - * 6. Check if ~/.codex/config.toml has codex_hooks = true, print guidance if not + * 6. Ensure ~/.codex/config.toml has codex_hooks = true */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { ensureCodexHooksEnabled } from "./lib/codex-config.mjs"; import { resolveCodexHome } from "./lib/codex-paths.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -57,6 +58,15 @@ function writeJsonFile(filePath, data) { fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8"); } +function configureCodexHooks() { + const existing = readTextFile(CODEX_CONFIG_TOML) ?? ""; + const { changed, content } = ensureCodexHooksEnabled(existing); + if (changed || !fs.existsSync(CODEX_CONFIG_TOML)) { + writeTextFile(CODEX_CONFIG_TOML, content); + } + return changed; +} + function escapeShellArgument(value) { const text = String(value); if (process.platform === "win32") { @@ -241,23 +251,10 @@ function main() { console.log(` Added: ${addedCount} hook entries`); console.log(` Skipped: ${skippedCount} duplicate entries`); - // Step 6: Check config.toml for codex_hooks setting - let hasCodexHooks = false; - if (fs.existsSync(CODEX_CONFIG_TOML)) { - const configContent = fs.readFileSync(CODEX_CONFIG_TOML, "utf8"); - // Simple check — TOML parsing not needed for a boolean flag - hasCodexHooks = /codex_hooks\s*=\s*true/i.test(configContent); - } - - if (!hasCodexHooks) { - console.log("\n--- IMPORTANT ---"); - console.log("Codex hooks are not enabled in your config."); - console.log("Add the following to ~/.codex/config.toml:"); - console.log(""); - console.log(" [features]"); - console.log(" codex_hooks = true"); - console.log(""); - console.log("This enables Codex to execute lifecycle hooks from hooks.json."); + // Step 6: Ensure config.toml enables codex_hooks + const codexHooksChanged = configureCodexHooks(); + if (codexHooksChanged) { + console.log("\nEnabled codex_hooks in ~/.codex/config.toml."); } else { console.log("\nCodex hooks are enabled in config.toml. Ready to go."); } diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index ecc37cf..1507f9b 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -13,19 +13,12 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { samePath, resolveCodexHome } from "./lib/codex-paths.mjs"; import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; +import { listManagedPluginCacheEntries } from "./lib/plugin-identity.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, ".."); const CODEX_HOME = resolveCodexHome(); const INSTALL_DIR = path.join(CODEX_HOME, "plugins", "cc"); -const CACHE_DIR = path.join( - CODEX_HOME, - "plugins", - "cache", - "local-plugins", - "cc", - "local" -); const INCLUDED_PATHS = [ ".codex-plugin", "CHANGELOG.md", @@ -136,17 +129,21 @@ function uninstall() { } fs.rmSync(INSTALL_DIR, { recursive: true, force: true }); - fs.rmSync(CACHE_DIR, { recursive: true, force: true }); + for (const cacheEntry of listManagedPluginCacheEntries(CODEX_HOME)) { + fs.rmSync(cacheEntry.cachePath, { recursive: true, force: true }); + } const pluginsDir = path.dirname(INSTALL_DIR); - const cachePluginDir = path.dirname(CACHE_DIR); - const cacheMarketplaceDir = path.dirname(cachePluginDir); - const cacheDir = path.dirname(cacheMarketplaceDir); + const cacheDir = path.join(CODEX_HOME, "plugins", "cache"); if (fs.existsSync(pluginsDir) && fs.readdirSync(pluginsDir).length === 0) { fs.rmdirSync(pluginsDir); } - removeIfEmpty(cachePluginDir); - removeIfEmpty(cacheMarketplaceDir); - removeIfEmpty(cacheDir); + if (fs.existsSync(cacheDir)) { + for (const marketplaceName of fs.readdirSync(cacheDir)) { + removeIfEmpty(path.join(cacheDir, marketplaceName, "cc")); + removeIfEmpty(path.join(cacheDir, marketplaceName)); + } + removeIfEmpty(cacheDir); + } console.log(`Plugin files removed from ${INSTALL_DIR}`); } diff --git a/scripts/lib/codex-config.mjs b/scripts/lib/codex-config.mjs new file mode 100644 index 0000000..8ea0fc6 --- /dev/null +++ b/scripts/lib/codex-config.mjs @@ -0,0 +1,63 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +function normalizeTrailingNewline(text) { + return `${String(text).replace(/\s*$/, "")}\n`; +} + +export function ensureCodexHooksEnabled(content) { + const lines = String(content ?? "").split("\n"); + const next = []; + let inFeatures = false; + let foundFeatures = false; + let foundCodexHooks = false; + let changed = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + if (inFeatures && !foundCodexHooks) { + next.push("codex_hooks = true"); + foundCodexHooks = true; + changed = true; + } + inFeatures = trimmed === "[features]"; + foundFeatures ||= inFeatures; + next.push(line); + continue; + } + + if (inFeatures && /^codex_hooks\s*=/.test(trimmed)) { + foundCodexHooks = true; + if (trimmed !== "codex_hooks = true") { + next.push("codex_hooks = true"); + changed = true; + } else { + next.push(line); + } + continue; + } + + next.push(line); + } + + if (inFeatures && !foundCodexHooks) { + next.push("codex_hooks = true"); + changed = true; + } + + if (!foundFeatures) { + if (next.length > 0 && next[next.length - 1].trim() !== "") { + next.push(""); + } + next.push("[features]", "codex_hooks = true"); + changed = true; + } + + return { + changed, + content: normalizeTrailingNewline(next.join("\n").replace(/\n{3,}/g, "\n\n")), + }; +} diff --git a/scripts/lib/managed-global-integration.mjs b/scripts/lib/managed-global-integration.mjs index 9148f98..9a4914f 100644 --- a/scripts/lib/managed-global-integration.mjs +++ b/scripts/lib/managed-global-integration.mjs @@ -7,21 +7,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { normalizePathSlashes, resolveCodexHome } from "./codex-paths.mjs"; +import { + getManagedPluginSignals as getManagedPluginSignalsBase, + LEGACY_MARKETPLACE_NAME, + listManagedPluginCacheEntries, + PLUGIN_NAME, +} from "./plugin-identity.mjs"; -const MARKETPLACE_NAME = "local-plugins"; -const PLUGIN_NAME = "cc"; -const HOME_DIR = os.homedir(); const CODEX_HOME = resolveCodexHome(); -const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); +const HOME_DIR = os.homedir(); const CODEX_HOOKS_FILE = path.join(CODEX_HOME, "hooks.json"); -const CODEX_PLUGIN_CACHE_DIR = path.join( - CODEX_HOME, - "plugins", - "cache", - MARKETPLACE_NAME, - PLUGIN_NAME, - "local" -); const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); const MANAGED_WRAPPER_SKILLS = [ @@ -33,11 +28,6 @@ const MANAGED_WRAPPER_SKILLS = [ "cancel", "setup", ]; -const PLUGIN_SECTION_HEADER_PATTERN = - /^\[\s*plugins\s*\.\s*["']?cc@local-plugins["']?\s*\]\s*(?:#.*)?$/i; -const PLUGIN_ENABLED_PATTERN = /^enabled\s*=\s*true\s*(?:#.*)?$/i; -const TOML_SECTION_PATTERN = /^\[.*\]\s*(?:#.*)?$/; -const TOML_ASSIGNMENT_PATTERN = /^[A-Za-z0-9_.-]+\s*=/; function readText(filePath) { if (!fs.existsSync(filePath)) { @@ -60,10 +50,6 @@ function removeIfEmpty(dirPath) { } } -function readConfigFile() { - return readText(CODEX_CONFIG_FILE) ?? ""; -} - export function removeManagedHooks(pluginRoot) { const raw = readText(CODEX_HOOKS_FILE); if (!raw) { @@ -73,14 +59,19 @@ export function removeManagedHooks(pluginRoot) { const parsed = JSON.parse(raw); const nextHooks = {}; let changed = false; - const hookPrefix = normalizePathSlashes(path.join(pluginRoot, "hooks")) + "/"; + const hookPrefixes = [ + normalizePathSlashes(path.join(pluginRoot, "hooks")) + "/", + ...listManagedPluginCacheEntries().map( + (cacheEntry) => normalizePathSlashes(path.join(cacheEntry.cachePath, "hooks")) + "/" + ), + ]; for (const [eventName, entries] of Object.entries(parsed.hooks ?? {})) { const keptEntries = []; for (const entry of entries ?? []) { const keptNested = (entry.hooks ?? []).filter((hook) => { const command = normalizePathSlashes(String(hook?.command ?? "")); - const shouldRemove = command.includes(hookPrefix); + const shouldRemove = hookPrefixes.some((hookPrefix) => command.includes(hookPrefix)); changed ||= shouldRemove; return !shouldRemove; }); @@ -125,82 +116,7 @@ export function removeManagedSkillWrappers() { } export function getManagedPluginSignals() { - const configContent = readText(CODEX_CONFIG_FILE); - const cachePresent = fs.existsSync(CODEX_PLUGIN_CACHE_DIR); - - if (configContent == null) { - return { - configState: "unknown", - cachePresent, - reason: "config-missing", - }; - } - - if (configContent.trim() === "") { - return { - configState: "unknown", - cachePresent, - reason: "config-empty", - }; - } - - const hasTomlLikeStructure = configContent - .split("\n") - .map((line) => line.trim()) - .some( - (line) => - line !== "" && - !line.startsWith("#") && - (TOML_SECTION_PATTERN.test(line) || TOML_ASSIGNMENT_PATTERN.test(line)) - ); - - if (!hasTomlLikeStructure) { - return { - configState: "unknown", - cachePresent, - reason: "config-unrecognized", - }; - } - - const pluginSection = configContent.split("\n").reduce( - (state, line) => { - const trimmed = line.trim(); - if (!state.inSection) { - if (PLUGIN_SECTION_HEADER_PATTERN.test(trimmed)) { - state.inSection = true; - state.foundSection = true; - } - return state; - } - if (trimmed.startsWith("[")) { - state.inSection = false; - return state; - } - if (trimmed !== "") { - state.lines.push(trimmed); - } - return state; - }, - { inSection: false, foundSection: false, lines: [] } - ); - - if (!pluginSection.foundSection) { - return { - configState: "inactive", - cachePresent, - reason: "plugin-section-missing", - }; - } - - const pluginEnabled = - Array.isArray(pluginSection.lines) && - pluginSection.lines.some((line) => PLUGIN_ENABLED_PATTERN.test(line)); - - return { - configState: pluginEnabled ? "active" : "inactive", - cachePresent, - reason: pluginEnabled ? "plugin-enabled" : "plugin-disabled", - }; + return getManagedPluginSignalsBase(); } export function isCodexPluginActive() { @@ -226,3 +142,5 @@ export function resolveManagedMarketplacePluginPath(pluginRoot) { } return `./${normalizePathSlashes(relative)}`; } + +export { LEGACY_MARKETPLACE_NAME, PLUGIN_NAME }; diff --git a/scripts/lib/plugin-identity.mjs b/scripts/lib/plugin-identity.mjs new file mode 100644 index 0000000..eeca51e --- /dev/null +++ b/scripts/lib/plugin-identity.mjs @@ -0,0 +1,187 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from "node:fs"; +import path from "node:path"; +import { resolveCodexHome } from "./codex-paths.mjs"; + +export const PLUGIN_NAME = "cc"; +export const LEGACY_MARKETPLACE_NAME = "local-plugins"; + +const MANAGED_PLUGIN_SECTION_PATTERN = + /^\[\s*plugins\s*\.\s*["']?cc@([^"'\]]+)["']?\s*\]\s*(?:#.*)?$/i; +const PLUGIN_ENABLED_PATTERN = /^enabled\s*=\s*true\s*(?:#.*)?$/i; +const TOML_SECTION_PATTERN = /^\[.*\]\s*(?:#.*)?$/; +const TOML_ASSIGNMENT_PATTERN = /^[A-Za-z0-9_.-]+\s*=/; + +function readText(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, "utf8"); +} + +export function pluginIdForMarketplace(marketplaceName) { + return `${PLUGIN_NAME}@${marketplaceName}`; +} + +export function pluginConfigHeader(marketplaceName) { + return `[plugins."${pluginIdForMarketplace(marketplaceName)}"]`; +} + +export function parseManagedPluginSections(configContent) { + const sections = []; + const lines = String(configContent ?? "").split("\n"); + let current = null; + + for (const line of lines) { + const trimmed = line.trim(); + const sectionMatch = trimmed.match(MANAGED_PLUGIN_SECTION_PATTERN); + if (sectionMatch) { + current = { + pluginId: pluginIdForMarketplace(sectionMatch[1]), + marketplaceName: sectionMatch[1], + enabled: false, + }; + sections.push(current); + continue; + } + + if (trimmed.startsWith("[")) { + current = null; + continue; + } + + if (current && PLUGIN_ENABLED_PATTERN.test(trimmed)) { + current.enabled = true; + } + } + + return sections; +} + +export function listManagedPluginCacheEntries(codexHome = resolveCodexHome()) { + const cacheRoot = path.join(codexHome, "plugins", "cache"); + if (!fs.existsSync(cacheRoot)) { + return []; + } + + const entries = []; + for (const marketplaceName of fs.readdirSync(cacheRoot).sort()) { + const pluginCacheRoot = path.join(cacheRoot, marketplaceName, PLUGIN_NAME); + if (!fs.existsSync(pluginCacheRoot)) { + continue; + } + + for (const cacheEntryName of fs.readdirSync(pluginCacheRoot).sort()) { + const cachePath = path.join(pluginCacheRoot, cacheEntryName); + let stats = null; + try { + stats = fs.statSync(cachePath); + } catch { + continue; + } + + if (!stats.isDirectory()) { + continue; + } + + entries.push({ + marketplaceName, + pluginId: pluginIdForMarketplace(marketplaceName), + cacheEntryName, + cachePath, + }); + } + } + + return entries; +} + +export function getManagedPluginSignals(codexHome = resolveCodexHome()) { + const configFile = path.join(codexHome, "config.toml"); + const configContent = readText(configFile); + const cacheEntries = listManagedPluginCacheEntries(codexHome); + const cachePresent = cacheEntries.length > 0; + + if (configContent == null) { + return { + configState: "unknown", + cachePresent, + reason: "config-missing", + sections: [], + activeSection: null, + cacheEntries, + }; + } + + if (configContent.trim() === "") { + return { + configState: "unknown", + cachePresent, + reason: "config-empty", + sections: [], + activeSection: null, + cacheEntries, + }; + } + + const hasTomlLikeStructure = configContent + .split("\n") + .map((line) => line.trim()) + .some( + (line) => + line !== "" && + !line.startsWith("#") && + (TOML_SECTION_PATTERN.test(line) || TOML_ASSIGNMENT_PATTERN.test(line)) + ); + + if (!hasTomlLikeStructure) { + return { + configState: "unknown", + cachePresent, + reason: "config-unrecognized", + sections: [], + activeSection: null, + cacheEntries, + }; + } + + const sections = parseManagedPluginSections(configContent); + const activeSection = sections.find((section) => section.enabled) ?? null; + + if (sections.length === 0) { + return { + configState: "inactive", + cachePresent, + reason: "plugin-section-missing", + sections, + activeSection, + cacheEntries, + }; + } + + return { + configState: activeSection ? "active" : "inactive", + cachePresent, + reason: activeSection ? "plugin-enabled" : "plugin-disabled", + sections, + activeSection, + cacheEntries, + }; +} + +export function getPreferredMarketplaceName( + fallback = LEGACY_MARKETPLACE_NAME, + codexHome = resolveCodexHome() +) { + const signals = getManagedPluginSignals(codexHome); + return ( + signals.activeSection?.marketplaceName ?? + signals.sections[0]?.marketplaceName ?? + signals.cacheEntries[0]?.marketplaceName ?? + fallback + ); +} diff --git a/scripts/local-plugin-install.mjs b/scripts/local-plugin-install.mjs index 16a2e04..246cea4 100644 --- a/scripts/local-plugin-install.mjs +++ b/scripts/local-plugin-install.mjs @@ -12,8 +12,17 @@ import process from "node:process"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { callCodexAppServer } from "./lib/codex-app-server.mjs"; +import { ensureCodexHooksEnabled } from "./lib/codex-config.mjs"; import { normalizePathSlashes, resolveCodexHome, samePath } from "./lib/codex-paths.mjs"; import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; +import { + parseManagedPluginSections, + getPreferredMarketplaceName, + LEGACY_MARKETPLACE_NAME, + pluginConfigHeader, + pluginIdForMarketplace, + PLUGIN_NAME, +} from "./lib/plugin-identity.mjs"; import { cleanupManagedGlobalIntegrations, resolveManagedMarketplacePluginPath, @@ -22,9 +31,7 @@ import { const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_PLUGIN_ROOT = path.resolve(SCRIPT_DIR, ".."); -const MARKETPLACE_NAME = "local-plugins"; const MARKETPLACE_DISPLAY_NAME = "Local Plugins"; -const PLUGIN_NAME = "cc"; const HOME_DIR = os.homedir(); const CODEX_HOME = resolveCodexHome(); const MARKETPLACE_FILE = path.join(HOME_DIR, ".agents", "plugins", "marketplace.json"); @@ -32,7 +39,6 @@ const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); const INSTALLED_PLUGIN_ROOT = path.join(CODEX_HOME, "plugins", PLUGIN_NAME); -const PLUGIN_CONFIG_HEADER = `[plugins."${PLUGIN_NAME}@${MARKETPLACE_NAME}"]`; const EXPORTED_SKILLS = [ "review", "adversarial-review", @@ -42,6 +48,24 @@ const EXPORTED_SKILLS = [ "cancel", "setup", ]; +function resolveInstallerMarketplaceConfig() { + const configuredName = + process.env.CC_PLUGIN_CODEX_MARKETPLACE_NAME?.trim() || + getPreferredMarketplaceName(LEGACY_MARKETPLACE_NAME); + const source = process.env.CC_PLUGIN_CODEX_MARKETPLACE_SOURCE?.trim() || null; + const refName = process.env.CC_PLUGIN_CODEX_MARKETPLACE_REF?.trim() || null; + const sparsePaths = (process.env.CC_PLUGIN_CODEX_MARKETPLACE_SPARSE_PATHS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + return { + marketplaceName: configuredName, + source, + refName, + sparsePaths: sparsePaths.length > 0 ? sparsePaths : null, + }; +} function usage() { console.error( @@ -207,11 +231,11 @@ function installCodexSkillWrappers(pluginRoot) { } } -function loadMarketplaceFile() { +function loadMarketplaceFile(marketplaceName) { const existing = readText(MARKETPLACE_FILE); if (!existing) { return { - name: MARKETPLACE_NAME, + name: marketplaceName, interface: { displayName: MARKETPLACE_DISPLAY_NAME, }, @@ -228,7 +252,7 @@ function loadMarketplaceFile() { parsed.plugins = []; } if (!parsed.name) { - parsed.name = MARKETPLACE_NAME; + parsed.name = marketplaceName; } if (!parsed.interface || typeof parsed.interface !== "object") { parsed.interface = {}; @@ -249,9 +273,9 @@ function saveMarketplaceFile(data) { writeText(MARKETPLACE_FILE, `${JSON.stringify(data, null, 2)}\n`); } -function upsertMarketplaceEntry(pluginRoot) { +function upsertMarketplaceEntry(pluginRoot, marketplaceName) { const pluginPath = resolveManagedMarketplacePluginPath(pluginRoot); - const marketplace = loadMarketplaceFile(); + const marketplace = loadMarketplaceFile(marketplaceName); const nextEntry = { name: PLUGIN_NAME, source: { @@ -277,19 +301,15 @@ function upsertMarketplaceEntry(pluginRoot) { saveMarketplaceFile(marketplace); } -function removeMarketplaceEntry(pluginRoot) { +function removeMarketplaceEntry(pluginRoot, marketplaceName) { const existing = readText(MARKETPLACE_FILE); if (!existing) { return; } - const pluginPath = resolveManagedMarketplacePluginPath(pluginRoot); - const marketplace = loadMarketplaceFile(); + const marketplace = loadMarketplaceFile(marketplaceName); marketplace.plugins = marketplace.plugins.filter((plugin) => { - if (plugin?.name !== PLUGIN_NAME) { - return true; - } - return plugin?.source?.path !== pluginPath; + return plugin?.name !== PLUGIN_NAME; }); saveMarketplaceFile(marketplace); } @@ -323,7 +343,8 @@ function removeTomlSections(content, headers) { }; } -function ensurePluginEnabled(content) { +function ensurePluginEnabled(content, marketplaceName) { + const pluginHeader = pluginConfigHeader(marketplaceName); const lines = content.split("\n"); const next = []; let inPluginSection = false; @@ -339,7 +360,7 @@ function ensurePluginEnabled(content) { foundEnabled = true; changed = true; } - inPluginSection = trimmed === PLUGIN_CONFIG_HEADER; + inPluginSection = trimmed === pluginHeader; foundPluginSection ||= inPluginSection; next.push(line); continue; @@ -368,63 +389,7 @@ function ensurePluginEnabled(content) { if (next.length > 0 && next[next.length - 1].trim() !== "") { next.push(""); } - next.push(PLUGIN_CONFIG_HEADER, "enabled = true"); - changed = true; - } - - return { - changed, - content: normalizeTrailingNewline(next.join("\n").replace(/\n{3,}/g, "\n\n")), - }; -} - -function ensureCodexHooksEnabled(content) { - const lines = content.split("\n"); - const next = []; - let inFeatures = false; - let foundFeatures = false; - let foundCodexHooks = false; - let changed = false; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - if (inFeatures && !foundCodexHooks) { - next.push("codex_hooks = true"); - foundCodexHooks = true; - changed = true; - } - inFeatures = trimmed === "[features]"; - foundFeatures ||= inFeatures; - next.push(line); - continue; - } - - if (inFeatures && /^codex_hooks\s*=/.test(trimmed)) { - foundCodexHooks = true; - if (trimmed !== "codex_hooks = true") { - next.push("codex_hooks = true"); - changed = true; - } else { - next.push(line); - } - continue; - } - - next.push(line); - } - - if (inFeatures && !foundCodexHooks) { - next.push("codex_hooks = true"); - foundCodexHooks = true; - changed = true; - } - - if (!foundFeatures) { - if (next.length > 0 && next[next.length - 1].trim() !== "") { - next.push(""); - } - next.push("[features]", "codex_hooks = true"); + next.push(pluginHeader, "enabled = true"); changed = true; } @@ -442,9 +407,14 @@ function writeConfigFile(content) { writeText(CODEX_CONFIG_FILE, normalizeTrailingNewline(content)); } -function removePluginConfigBlock() { +function removePluginConfigBlock(marketplaceName) { const existing = readConfigFile(); - const pluginRemoval = removeTomlSections(existing, new Set([PLUGIN_CONFIG_HEADER])); + const managedHeaders = parseManagedPluginSections(existing).map((section) => + pluginConfigHeader(section.marketplaceName) + ); + const headers = + managedHeaders.length > 0 ? new Set(managedHeaders) : new Set([pluginConfigHeader(marketplaceName)]); + const pluginRemoval = removeTomlSections(existing, headers); if (pluginRemoval.changed) { writeConfigFile(pluginRemoval.content); } @@ -456,9 +426,9 @@ function configureCodexHooks() { writeConfigFile(content); } -function enablePluginThroughConfigFallback() { +function enablePluginThroughConfigFallback(marketplaceName) { const existing = readConfigFile(); - const { content } = ensurePluginEnabled(existing); + const { content } = ensurePluginEnabled(existing, marketplaceName); writeConfigFile(content); } @@ -474,18 +444,34 @@ function runInstallHooks(pluginRoot) { } } -async function installPluginThroughCodex() { +async function installPluginThroughCodex(marketplacePath) { await callCodexAppServer({ - cwd: path.dirname(MARKETPLACE_FILE), + cwd: path.dirname(marketplacePath), method: "plugin/install", params: { - marketplacePath: MARKETPLACE_FILE, + marketplacePath, pluginName: PLUGIN_NAME, forceRemoteSync: false, }, }); } +async function addMarketplaceThroughCodex({ source, refName, sparsePaths }) { + const params = { source }; + if (refName) { + params.refName = refName; + } + if (sparsePaths && sparsePaths.length > 0) { + params.sparsePaths = sparsePaths; + } + + return await callCodexAppServer({ + cwd: INSTALLED_PLUGIN_ROOT, + method: "marketplace/add", + params, + }); +} + function isCodexInstallFallbackEligible(error) { const message = error instanceof Error ? error.message : String(error); return ( @@ -496,18 +482,29 @@ function isCodexInstallFallbackEligible(error) { ); } -async function uninstallPluginThroughCodex() { +function isCodexMarketplaceAddFallbackEligible(error) { + const message = error instanceof Error ? error.message : String(error); + return ( + /Method not found/i.test(message) || + /Failed to start .*codex/i.test(message) || + /app-server exited before responding to marketplace\/add/i.test(message) || + /app-server timed out waiting for marketplace\/add/i.test(message) + ); +} + +async function uninstallPluginThroughCodex(marketplaceName) { await callCodexAppServer({ cwd: CODEX_HOME, method: "plugin/uninstall", params: { - pluginId: `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, + pluginId: pluginIdForMarketplace(marketplaceName), forceRemoteSync: false, }, }); } export async function install(pluginRoot, skipHookInstall) { + const marketplaceConfig = resolveInstallerMarketplaceConfig(); assertSupportedPluginRoot(pluginRoot); if ( samePath(pluginRoot, INSTALLED_PLUGIN_ROOT) && @@ -515,17 +512,39 @@ export async function install(pluginRoot, skipHookInstall) { ) { materializeInstalledSkillPaths(pluginRoot); } - upsertMarketplaceEntry(pluginRoot); + let marketplacePath = MARKETPLACE_FILE; + let usedLegacyMarketplaceFallback = false; + + if (marketplaceConfig.source) { + try { + const result = await addMarketplaceThroughCodex(marketplaceConfig); + marketplacePath = path.join(result.installedRoot, ".agents", "plugins", "marketplace.json"); + } catch (error) { + if (!isCodexMarketplaceAddFallbackEligible(error)) { + throw error; + } + upsertMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); + usedLegacyMarketplaceFallback = true; + const detail = error instanceof Error ? error.message : String(error); + console.warn( + `Warning: Codex marketplace/add unavailable; falling back to a personal marketplace entry. ${detail}` + ); + } + } else { + upsertMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); + usedLegacyMarketplaceFallback = true; + } + configureCodexHooks(); let usedFallback = false; try { - await installPluginThroughCodex(); + await installPluginThroughCodex(marketplacePath); removeManagedSkillWrappers(); } catch (error) { if (!isCodexInstallFallbackEligible(error)) { throw error; } - enablePluginThroughConfigFallback(); + enablePluginThroughConfigFallback(marketplaceConfig.marketplaceName); installCodexSkillWrappers(pluginRoot); usedFallback = true; const detail = error instanceof Error ? error.message : String(error); @@ -539,21 +558,25 @@ export async function install(pluginRoot, skipHookInstall) { if (usedFallback) { console.log("Installed using fallback local-plugin activation."); } + if (usedLegacyMarketplaceFallback && marketplaceConfig.source) { + console.log("Installed using legacy personal marketplace registration."); + } console.log(`Installed ${PLUGIN_NAME} from ${pluginRoot}`); } export async function uninstall(pluginRoot) { + const marketplaceConfig = resolveInstallerMarketplaceConfig(); cleanupManagedGlobalIntegrations(pluginRoot); - removeMarketplaceEntry(pluginRoot); + removeMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); try { - await uninstallPluginThroughCodex(); + await uninstallPluginThroughCodex(marketplaceConfig.marketplaceName); } catch (error) { const detail = error instanceof Error ? error.message : String(error); console.warn( `Warning: Codex plugin uninstall failed; continuing managed cleanup. ${detail}` ); } - removePluginConfigBlock(); + removePluginConfigBlock(marketplaceConfig.marketplaceName); console.log(`Uninstalled ${PLUGIN_NAME} from ${pluginRoot}`); } diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index 99f774f..cf5e6ac 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -84,10 +84,13 @@ describe("install-hooks.mjs", () => { const result = runInstallHooks(homeDir); const hooksFile = path.join(homeDir, ".codex", "hooks.json"); + const configFile = path.join(homeDir, ".codex", "config.toml"); assert.ok(fs.existsSync(hooksFile)); + assert.ok(fs.existsSync(configFile)); const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); + const config = fs.readFileSync(configFile, "utf8"); const sessionStartCommand = hooks.hooks.SessionStart[0].hooks[0].command; assert.ok(sessionStartCommand.includes(`${PROJECT_ROOT}/hooks/session-lifecycle-hook.mjs`)); @@ -101,9 +104,32 @@ describe("install-hooks.mjs", () => { const userPromptCommand = hooks.hooks.UserPromptSubmit[0].hooks[0].command; assert.ok(userPromptCommand.includes(`${PROJECT_ROOT}/hooks/unread-result-hook.mjs`)); + assert.match(config, /\[features\]/); + assert.match(config, /codex_hooks = true/); assert.ok(result.stdout.includes("Codex hooks installation complete.")); }); + it("upgrades an existing false codex_hooks setting to true", () => { + const homeDir = makeTempHome(); + tempHomes.push(homeDir); + + const codexDir = path.join(homeDir, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, "config.toml"), + "[features]\ncodex_hooks = false\n", + "utf8" + ); + + const result = runInstallHooks(homeDir); + const config = fs.readFileSync(path.join(codexDir, "config.toml"), "utf8"); + + assert.match(config, /\[features\]/); + assert.match(config, /codex_hooks = true/); + assert.doesNotMatch(config, /codex_hooks = false/); + assert.match(result.stdout, /Enabled codex_hooks/i); + }); + it("does not duplicate semantically identical hook commands when quoting changes", () => { const homeDir = makeTempHome(); tempHomes.push(homeDir); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index a2b7929..5bd0200 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -77,6 +77,40 @@ function copyFixture(sourceRoot) { } } +function copyMarketplaceFixture(sourceRoot, marketplaceName = "sendbird") { + const marketplaceRoot = path.join(sourceRoot, "sendbird-marketplace"); + const pluginRoot = path.join(marketplaceRoot, "plugins", "cc"); + copyFixture(pluginRoot); + fs.mkdirSync(path.join(marketplaceRoot, ".agents", "plugins"), { recursive: true }); + fs.writeFileSync( + path.join(marketplaceRoot, ".agents", "plugins", "marketplace.json"), + `${JSON.stringify( + { + name: marketplaceName, + interface: { displayName: "Sendbird Plugins" }, + plugins: [ + { + name: "cc", + source: { + source: "local", + path: "./plugins/cc", + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_USE", + }, + category: "Coding", + }, + ], + }, + null, + 2 + )}\n`, + "utf8" + ); + return marketplaceRoot; +} + function runInstaller(command, homeDir, sourceRoot, extraEnv = {}) { const result = spawnSync( process.execPath, @@ -282,6 +316,145 @@ rl.on("line", (line) => { }; } +function createMarketplaceAwareCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { + const scriptPath = makeTempHelper("fake-codex-app-server-marketplace"); + const logPath = path.join(codexHome, "fake-codex-requests.log"); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.writeFileSync( + scriptPath, + `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +const codexHome = ${JSON.stringify(codexHome)}; +const logPath = ${JSON.stringify(logPath)}; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function readConfig(configPath) { + return fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : ""; +} + +function normalizeTrailingNewline(text) { + return text.replace(/\\s*$/, "") + "\\n"; +} + +function removeSection(content, header) { + const lines = content.split("\\n"); + const kept = []; + let skip = false; + for (const line of lines) { + const trimmed = line.trim(); + if (skip && trimmed.startsWith("[")) { + skip = false; + } + if (!skip && trimmed === header) { + skip = true; + continue; + } + if (!skip) { + kept.push(line); + } + } + return normalizeTrailingNewline(kept.join("\\n").replace(/\\n{3,}/g, "\\n\\n")); +} + +function appendPluginSection(configPath, pluginId) { + const header = '[plugins."' + pluginId + '"]'; + const base = removeSection(readConfig(configPath), header).replace(/\\s*$/, ""); + const next = [header, "enabled = true", ""].join("\\n"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, (base ? base + "\\n\\n" : "") + next + "\\n", "utf8"); +} + +function copyPlugin(sourceRoot, destinationRoot) { + fs.rmSync(destinationRoot, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destinationRoot), { recursive: true }); + fs.cpSync(sourceRoot, destinationRoot, { recursive: true }); +} + +function marketplaceRootFromPath(marketplacePath) { + return path.dirname(path.dirname(path.dirname(marketplacePath))); +} + +function installMarketplace(sourceRoot) { + const marketplacePath = path.join(sourceRoot, ".agents", "plugins", "marketplace.json"); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8")); + const installedRoot = path.join(codexHome, "marketplaces", marketplace.name); + fs.rmSync(installedRoot, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(installedRoot), { recursive: true }); + fs.cpSync(sourceRoot, installedRoot, { recursive: true }); + return { + alreadyAdded: false, + installedRoot, + marketplaceName: marketplace.name, + }; +} + +function handleInstall(params) { + const marketplace = JSON.parse(fs.readFileSync(params.marketplacePath, "utf8")); + const plugin = marketplace.plugins.find((entry) => entry.name === params.pluginName); + if (!plugin) { + throw new Error("missing plugin in marketplace"); + } + const pluginId = params.pluginName + "@" + marketplace.name; + const sourceRoot = path.resolve(marketplaceRootFromPath(params.marketplacePath), plugin.source.path); + const cacheRoot = path.join(codexHome, "plugins", "cache", marketplace.name, params.pluginName, "local"); + copyPlugin(sourceRoot, cacheRoot); + appendPluginSection(path.join(codexHome, "config.toml"), pluginId); + return { + authPolicy: plugin.policy?.authentication || "ON_USE", + appsNeedingAuth: [], + }; +} + +function logMessage(message) { + fs.appendFileSync(logPath, JSON.stringify(message) + "\\n", "utf8"); +} + +rl.on("line", (line) => { + if (!line.trim()) { + return; + } + + const message = JSON.parse(line); + logMessage(message); + + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } }) + "\\n"); + return; + } + + if (message.method === "marketplace/add") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: installMarketplace(message.params.source) }) + "\\n"); + return; + } + + if (message.method === "plugin/install") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: handleInstall(message.params) }) + "\\n"); + return; + } + + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "Method not found" }, + }) + "\\n" + ); +});\n`, + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); + + return { + env: { + CC_PLUGIN_CODEX_EXECUTABLE: scriptPath, + }, + logPath, + }; +} + function createMethodNotFoundCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { const scriptPath = makeTempHelper("fake-codex-app-server-method-not-found"); const logPath = path.join(codexHome, "fake-codex-requests.log"); @@ -647,6 +820,68 @@ describe("installer-cli", () => { requests.some((request) => request.method === "plugin/install"), "installer should use Codex's official plugin/install path" ); + assert.ok( + !requests.some((request) => request.method === "marketplace/add"), + "npx install should install the current package directly instead of redirecting through a marketplace by default" + ); + }); + + it("prefers Codex marketplace/add when an official marketplace source is configured", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const fakeCodex = createMarketplaceAwareCodex(homeDir); + copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); + + const configFile = path.join(homeDir, ".codex", "config.toml"); + const config = fs.readFileSync(configFile, "utf8"); + const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); + const requests = readFakeCodexLog(fakeCodex.logPath); + const pluginInstallRequest = requests.find((request) => request.method === "plugin/install"); + + assert.match(config, /\[plugins\."cc@sendbird"\]/); + assert.ok( + requests.some((request) => request.method === "marketplace/add"), + "installer should prefer Codex marketplace/add when a marketplace source is configured" + ); + assert.equal( + pluginInstallRequest?.params?.marketplacePath, + path.join(homeDir, ".codex", "marketplaces", "sendbird", ".agents", "plugins", "marketplace.json") + ); + assert.ok( + !fs.existsSync(marketplaceFile), + "official marketplace installs should not mutate the personal marketplace file" + ); + }); + + it("falls back to the personal marketplace entry when marketplace/add is unavailable", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const fakeCodex = createMethodNotFoundCodex(homeDir); + copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); + + const result = runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); + + const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); + const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); + const config = fs.readFileSync(path.join(homeDir, ".codex", "config.toml"), "utf8"); + + assert.equal(marketplace.name, "sendbird"); + assert.equal(marketplace.plugins[0].name, "cc"); + assert.match(config, /\[plugins\."cc@sendbird"\]/); + assert.match(result.stderr, /marketplace\/add unavailable/i); + assert.match(result.stderr, /config fallback/i); }); it("materializes installed skill paths for a direct local checkout install", () => { @@ -849,11 +1084,28 @@ describe("installer-cli", () => { ); runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + + const marketplacePath = path.join(homeDir, ".agents", "plugins", "marketplace.json"); + const marketplaceBeforeUninstall = JSON.parse(fs.readFileSync(marketplacePath, "utf8")); + marketplaceBeforeUninstall.plugins.push({ + name: "cc", + source: { source: "local", path: "./stale/cc" }, + policy: { installation: "AVAILABLE", authentication: "ON_USE" }, + category: "Coding", + }); + fs.writeFileSync(marketplacePath, JSON.stringify(marketplaceBeforeUninstall, null, 2) + "\n", "utf8"); + + fs.appendFileSync( + path.join(homeDir, ".codex", "config.toml"), + '\n[plugins."cc@sendbird"]\nenabled = true\n', + "utf8" + ); + runInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); const installDir = path.join(homeDir, ".codex", "plugins", "cc"); const marketplace = JSON.parse( - fs.readFileSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"), "utf8") + fs.readFileSync(marketplacePath, "utf8") ); const config = fs.readFileSync(path.join(homeDir, ".codex", "config.toml"), "utf8"); const hooks = JSON.parse(fs.readFileSync(path.join(homeDir, ".codex", "hooks.json"), "utf8")); @@ -863,9 +1115,94 @@ describe("installer-cli", () => { assert.equal(marketplace.plugins[0].name, "other"); assert.match(config, /\[plugins\."github@openai-curated"\]/); assert.doesNotMatch(config, /\[plugins\."cc@local-plugins"\]/); + assert.doesNotMatch(config, /\[plugins\."cc@sendbird"\]/); assert.equal(hooks.hooks.SessionStart[0].hooks[0].command, "echo custom-hook"); }); + it("removes versioned marketplace cache entries during uninstall", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const fakeCodex = createFakeCodex(homeDir); + copyFixture(sourceRoot); + + runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + + const versionedCacheDir = path.join( + homeDir, + ".codex", + "plugins", + "cache", + "sendbird", + "cc", + "1.0.8" + ); + fs.mkdirSync(path.join(versionedCacheDir, "skills"), { recursive: true }); + fs.appendFileSync( + path.join(homeDir, ".codex", "config.toml"), + '\n[plugins."cc@sendbird"]\nenabled = true\n', + "utf8" + ); + + runInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); + + assert.ok(!fs.existsSync(versionedCacheDir)); + assert.ok(!fs.existsSync(path.dirname(versionedCacheDir))); + }); + + it("removes managed hook commands that point at versioned marketplace cache roots", () => { + const homeDir = makeTempHome(); + const sourceRoot = makeTempSource(); + const fakeCodex = createFakeCodex(homeDir); + copyFixture(sourceRoot); + + runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + + const codexDir = path.join(homeDir, ".codex"); + const versionedCacheDir = path.join( + codexDir, + "plugins", + "cache", + "sendbird", + "cc", + "1.0.9" + ); + const hooksFile = path.join(codexDir, "hooks.json"); + + fs.mkdirSync(path.join(versionedCacheDir, "hooks"), { recursive: true }); + fs.appendFileSync( + path.join(codexDir, "config.toml"), + '\n[plugins."cc@sendbird"]\nenabled = true\n', + "utf8" + ); + fs.writeFileSync( + hooksFile, + JSON.stringify( + { + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [ + { + type: "command", + command: `node '${path.join(versionedCacheDir, "hooks", "session-lifecycle-hook.mjs")}'`, + }, + ], + }, + ], + }, + }, + null, + 2 + ) + "\n", + "utf8" + ); + + runInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); + + assert.ok(!fs.existsSync(hooksFile), "uninstall should remove managed hooks even when they point at a versioned cache root"); + }); + it("removes managed hooks before calling Codex plugin/uninstall", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 65bb274..297ea1b 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -1186,7 +1186,7 @@ describe("claude-companion integration", () => { } }); - it("marks foreground task results as viewed and marks explicit result retrievals as viewed", async () => { + it("marks foreground task results as viewed and marks status/result retrievals as viewed", async () => { const testEnv = createTestEnvironment(); const sessionEnv = { ...testEnv.env, @@ -1220,7 +1220,17 @@ describe("claude-companion integration", () => { ], { env: sessionEnv } ); - await waitForTerminalStatus(testEnv, backgroundLaunch.jobId, sessionEnv); + await (async () => { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const storedJob = readStoredJobById(testEnv, backgroundLaunch.jobId); + if (storedJob.status === "completed" || storedJob.status === "failed") { + return; + } + await sleep(25); + } + assert.fail(`Timed out waiting for stored terminal job on ${backgroundLaunch.jobId}`); + })(); const beforeResult = readStoredJobById(testEnv, backgroundLaunch.jobId); assert.equal( @@ -1229,16 +1239,32 @@ describe("claude-companion integration", () => { "background completion should remain unread until result is fetched" ); - runCompanion( - ["result", "--cwd", testEnv.workspaceDir, backgroundLaunch.jobId], + const statusPayload = runCompanionJson( + ["status", "--cwd", testEnv.workspaceDir, backgroundLaunch.jobId, "--json"], { env: sessionEnv } ); + assert.equal( + statusPayload.job.id, + backgroundLaunch.jobId, + "status --json should return the finished background job" + ); + + const afterStatus = readStoredJobById(testEnv, backgroundLaunch.jobId); + assert.match( + afterStatus.resultViewedAt ?? "", + /\d{4}-\d{2}-\d{2}T/, + "fetching finished job details through status --json should mark the job as viewed" + ); + + runCompanion(["result", "--cwd", testEnv.workspaceDir, backgroundLaunch.jobId], { + env: sessionEnv, + }); const afterResult = readStoredJobById(testEnv, backgroundLaunch.jobId); assert.match( afterResult.resultViewedAt ?? "", /\d{4}-\d{2}-\d{2}T/, - "fetching result should mark the job as viewed" + "fetching result should keep the job marked as viewed" ); } finally { cleanupTestEnvironment(testEnv); diff --git a/tests/plugin-identity.test.mjs b/tests/plugin-identity.test.mjs new file mode 100644 index 0000000..7405cd5 --- /dev/null +++ b/tests/plugin-identity.test.mjs @@ -0,0 +1,101 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + getManagedPluginSignals, + getPreferredMarketplaceName, + listManagedPluginCacheEntries, + parseManagedPluginSections, + pluginConfigHeader, + pluginIdForMarketplace, +} from "../scripts/lib/plugin-identity.mjs"; + +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length > 0) { + fs.rmSync(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe("plugin identity helpers", () => { + it("returns no managed sections for empty or unrelated config", () => { + assert.deepEqual(parseManagedPluginSections(""), []); + assert.deepEqual(parseManagedPluginSections('[plugins."other@market"]\nenabled = true\n'), []); + }); + + it("parses managed plugin sections for arbitrary marketplace names", () => { + const config = [ + pluginConfigHeader("sendbird"), + "enabled = true", + "", + pluginConfigHeader("local-plugins"), + "enabled = false", + "", + ].join("\n"); + + assert.deepEqual(parseManagedPluginSections(config), [ + { + pluginId: pluginIdForMarketplace("sendbird"), + marketplaceName: "sendbird", + enabled: true, + }, + { + pluginId: pluginIdForMarketplace("local-plugins"), + marketplaceName: "local-plugins", + enabled: false, + }, + ]); + }); + + it("detects both legacy local and versioned marketplace cache entries", () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-identity-")); + tempDirs.push(codexHome); + + fs.mkdirSync(path.join(codexHome, "plugins", "cache", "local-plugins", "cc", "local"), { + recursive: true, + }); + fs.mkdirSync(path.join(codexHome, "plugins", "cache", "sendbird", "cc", "1.0.8"), { + recursive: true, + }); + + assert.deepEqual( + listManagedPluginCacheEntries(codexHome).map((entry) => ({ + marketplaceName: entry.marketplaceName, + cacheEntryName: entry.cacheEntryName, + })), + [ + { marketplaceName: "local-plugins", cacheEntryName: "local" }, + { marketplaceName: "sendbird", cacheEntryName: "1.0.8" }, + ] + ); + }); + + it("reads managed plugin signals from an explicit codex home", () => { + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-signals-")); + tempDirs.push(codexHome); + + fs.writeFileSync( + path.join(codexHome, "config.toml"), + `${pluginConfigHeader("sendbird")}\nenabled = true\n`, + "utf8" + ); + fs.mkdirSync(path.join(codexHome, "plugins", "cache", "sendbird", "cc", "1.0.9"), { + recursive: true, + }); + + const signals = getManagedPluginSignals(codexHome); + + assert.equal(signals.configState, "active"); + assert.equal(signals.activeSection?.marketplaceName, "sendbird"); + assert.equal(signals.cachePresent, true); + assert.equal(getPreferredMarketplaceName("local-plugins", codexHome), "sendbird"); + }); +});