diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..fedd4bd --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "vidxp", + "interface": { + "displayName": "VidXP" + }, + "plugins": [ + { + "name": "vidxp", + "source": { + "source": "local", + "path": "./plugins/vidxp" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index e0c6d56..33f465d 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -22,6 +22,11 @@ on: required: false default: "" type: string + plugin_marketplace_ref: + description: Git ref for release builds; empty keeps a local marketplace. + required: false + default: "" + type: string workflow_dispatch: inputs: checkout_ref: @@ -44,6 +49,11 @@ on: required: false default: "" type: string + plugin_marketplace_ref: + description: Git ref for release builds; empty keeps a local marketplace. + required: false + default: "" + type: string permissions: contents: read @@ -179,6 +189,7 @@ jobs: working-directory: desktop env: VIDXP_DESKTOP_SIGN: ${{ (runner.os == 'macOS' && inputs.sign) && '1' || '' }} + VIDXP_PLUGIN_MARKETPLACE_REF: ${{ inputs.plugin_marketplace_ref }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index db5e50a..9ca543b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -114,6 +114,7 @@ jobs: artifact_retention_days: 30 checkout_ref: ${{ inputs.head_sha }} package_artifact_name: vidxp-python-dist + plugin_marketplace_ref: ${{ inputs.target_branch }} sign: true secrets: inherit diff --git a/Dockerfile b/Dockerfile index b27524f..6722152 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ FROM python:3.14-slim-trixie AS runtime-base LABEL org.opencontainers.image.title="VidXP" \ org.opencontainers.image.description="Video indexing and search" \ org.opencontainers.image.source="https://github.com/grayhatdevelopers/vidxp" \ + org.opencontainers.image.vendor="Grayhat Developers PVT Ltd" \ org.opencontainers.image.licenses="MIT" RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/LICENSE b/LICENSE index b4255c5..5e28035 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Grayhat Developers +Copyright (c) 2026 Grayhat Developers PVT Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index bdb0400..a25a125 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,9 +4,7 @@ include docs/images/logo.png recursive-include src/vidxp/assets/upload_page * recursive-include src/vidxp/assets/artifact_download * recursive-include src/vidxp/assets/mcp_app * -include src/vidxp/bundled_plugins/vidxp/.mcp.json -include src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json -recursive-include src/vidxp/bundled_plugins/vidxp/skills * +recursive-include plugins/vidxp * include web/upload-page/package.json include web/upload-page/package-lock.json include web/upload-page/scripts/build.mjs diff --git a/README.md b/README.md index 2926077..609d6f5 100644 --- a/README.md +++ b/README.md @@ -159,20 +159,26 @@ questions about a library, and return inspectable evidence such as boards, frames, and clips. Clients can connect locally over stdio or to a self-hosted VidXP server. -### ChatGPT and Codex skills +### Codex plugin and skills -VidXP includes reusable skill source folders for the two common agent workflows: +VidXP is distributed as a Codex plugin through a Git marketplace hosted in +this GitHub repository. It includes three reusable agent workflows: -- [Ingest and index videos](skills/vidxp-ingest-video/SKILL.md) -- [Find moments and return inspectable evidence](skills/vidxp-find-video-evidence/SKILL.md) +- [Install Desktop or CLI and connect Codex](plugins/vidxp/skills/vidxp-install/SKILL.md) +- [Ingest and index videos](plugins/vidxp/skills/vidxp-ingest-video/SKILL.md) +- [Find moments and return inspectable evidence](plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md) -Download a skill folder directly, or install the versioned VidXP plugin bundle -shipped inside the Python package. The plugin keeps both skills and the local -`vidxp-mcp` server definition together. Its MCP App resource also gives -compatible hosts an interactive upload and evidence-review view; every workflow -continues to work through ordinary MCP tool results when a host has no UI. -When the MCP feature is installed, VidXP Desktop can configure another MCP -client or install the complete local plugin directly into Codex. +Paste this into Codex: + +```text +Add https://github.com/grayhatdevelopers/vidxp as a Git plugin marketplace, install the VidXP plugin, then use its $vidxp-install skill to set up VidXP on this computer. +``` + +VidXP Desktop can perform the same setup from its **Set up in Codex** button. + +The MCP App resource gives compatible hosts an interactive upload and +evidence-review view; every workflow continues to work through ordinary MCP +tool results when a host has no UI. - [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md) - [ChatGPT and Codex plugin integration](docs/integrations/openai-plugin.md) @@ -240,14 +246,14 @@ Contributions are welcome. Read the ## Credits -Built by Grayhat Developers PVT Ltd. and maintained by the community. +Built by [Grayhat](https://grayhat.studio/) and maintained by the community. Originally researched by students: - [Abdullah Mansoor](https://github.com/abdullahmansoor321) - [Muhammad Haroon](https://github.com/haroon10725) - [Sarah Jawaid](https://github.com/sarr266) - [Talha Ahmed](https://github.com/talhaahmed1234) -Working with [Dr Shahab Tahzeeb](https://scholar.google.com/citations?user=cryeRB0AAAAJ&hl=en) ([NED University of Engineering and Technology](https://www.neduet.edu.pk/)) and [Saad Bazaz](https://scholar.google.com/citations?user=mrJo09oAAAAJ&hl=en) ([Grayhat](https://grayhat.studio)). +Working with [Dr Shahab Tahzeeb](https://scholar.google.com/citations?user=cryeRB0AAAAJ&hl=en) ([NED University of Engineering and Technology](https://www.neduet.edu.pk/)) and [Saad Bazaz](https://scholar.google.com/citations?user=mrJo09oAAAAJ&hl=en) ([Grayhat](https://grayhat.studio/)). Email: info@grayhat.studio diff --git a/desktop/THIRD_PARTY_NOTICES.txt b/desktop/THIRD_PARTY_NOTICES.txt index de97bba..be6f47e 100644 --- a/desktop/THIRD_PARTY_NOTICES.txt +++ b/desktop/THIRD_PARTY_NOTICES.txt @@ -9,7 +9,7 @@ VidXP Desktop and VidXP | MIT | https://github.com/grayhatdevelopers/vidxp MIT License -Copyright (c) 2026 Grayhat Developers +Copyright (c) 2026 Grayhat Developers PVT Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/desktop/package.json b/desktop/package.json index caa6492..b64ffd2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -17,7 +17,7 @@ "model-catalog:check": "uv run --frozen python scripts/model-catalog.py --check", "tauri": "tauri", "sync:branding": "node scripts/sync-branding.mjs", - "icons": "npm run sync:branding && tauri icon ../docs/images/logo.png --output src-tauri/icons", + "icons": "tauri icon ../docs/images/logo.png --output src-tauri/icons && npm run sync:branding", "predesktop:dev": "npm run sync:branding", "desktop:dev": "tauri dev", "predesktop:build": "npm run icons", diff --git a/desktop/scripts/sync-branding.mjs b/desktop/scripts/sync-branding.mjs index 98968d2..624836f 100644 --- a/desktop/scripts/sync-branding.mjs +++ b/desktop/scripts/sync-branding.mjs @@ -6,7 +6,15 @@ const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const source = resolve(desktopRoot, "../docs/images/logo.png"); const publicDirectory = resolve(desktopRoot, "public"); const favicon = resolve(publicDirectory, "icon.png"); +const pluginLogo = resolve(desktopRoot, "../plugins/vidxp/assets/logo.png"); +const artifactLogo = resolve( + desktopRoot, + "../src/vidxp/assets/artifact_download/vidxp-logo.png", +); +const generatedIcon = resolve(desktopRoot, "src-tauri/icons/128x128.png"); mkdirSync(publicDirectory, { recursive: true }); copyFileSync(source, favicon); -console.log("Synced the VidXP desktop favicon from the shared icon."); +copyFileSync(source, pluginLogo); +copyFileSync(generatedIcon, artifactLogo); +console.log("Synced VidXP branding from docs/images/logo.png."); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4d64de0..3fe453b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -51,6 +51,8 @@ const RUNTIME_PACKAGE_WHEEL_NAME: &str = const RUNTIME_PACKAGE_WHEEL_SHA256: &str = include_str!(concat!(env!("OUT_DIR"), "/runtime-package-sha256.txt")); const MODEL_CACHE_CATALOG_BYTES: &[u8] = include_bytes!("../../model-cache-catalog.json"); +const CODEX_PLUGIN_MARKETPLACE_SOURCE: &str = "grayhatdevelopers/vidxp"; +const CODEX_PLUGIN_MARKETPLACE_REF: Option<&str> = option_env!("VIDXP_PLUGIN_MARKETPLACE_REF"); const PRODUCT_DATA_DIRECTORY_NAME: &str = "VidXP"; const RUNTIME_CONSTRAINTS_FILE_NAME: &str = "runtime-constraints.txt"; const MAX_SETUP_OUTPUT_BYTES: usize = 4 * 1024 * 1024; @@ -3693,11 +3695,25 @@ async fn install_codex_plugin( installer_path.display() )); } - let marketplace_root = paths.private_data.join("codex-marketplace"); let mut command = target_command(&profile, &paths, &installer_path); + if let Some(marketplace_ref) = + CODEX_PLUGIN_MARKETPLACE_REF.filter(|value| !value.is_empty()) + { + command + .arg("--marketplace-source") + .arg(CODEX_PLUGIN_MARKETPLACE_SOURCE) + .arg("--marketplace-ref") + .arg(marketplace_ref) + .arg("--marketplace-sparse") + .arg(".agents/plugins") + .arg("--marketplace-sparse") + .arg("plugins/vidxp"); + } else { + command + .arg("--marketplace-root") + .arg(paths.private_data.join("codex-marketplace")); + } command - .arg("--marketplace-root") - .arg(&marketplace_root) .arg("--repository") .arg("default") .arg("--index-directory") diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index f328a1e..e4d9a19 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -34,6 +34,8 @@ "bundle": { "active": true, "targets": "all", + "publisher": "Grayhat Developers PVT Ltd", + "copyright": "Copyright © 2026 Grayhat Developers PVT Ltd", "resources": [ "../THIRD_PARTY_NOTICES.txt", "../../LICENSE" diff --git a/docs/desktop.md b/docs/desktop.md index a899996..43a73e1 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -188,17 +188,17 @@ action, and **Quit VidXP** runs supervised shutdown for the interface and any Desktop-owned repository worker. The active-target panel also reports and controls local video processing through the existing `JobService` worker lifecycle, reports the app integration service, creates copyable AI-assistant -MCP config, installs the bundled MCP-and-skills plugin directly into Codex, and +MCP config, installs the VidXP plugin and local MCP server into Codex, and presents the existing doctor result as product health rather than raw output. -Codex setup exports a target-specific copy of the plugin from the selected -VidXP runtime into a dedicated Desktop-private `vidxp-local` marketplace. The -copy keeps the versioned skills but replaces its generic MCP command with the -selected runtime's absolute executable, repository, and data paths. Desktop -then uses `codex plugin marketplace add --json` and -`codex plugin add vidxp@vidxp-local --json`; it never edits Codex configuration -or the user's personal marketplace by hand. Re-running setup refreshes the -managed bundle using a deterministic plugin-version cache key. A new Codex chat -is required before the refreshed skills and tools are available. +Release builds add the repository's Git marketplace from `main` for beta or +`release` for stable, then install `vidxp@vidxp`. Development and pull-request +builds export the packaged plugin to a dedicated Desktop-private +`vidxp-local` marketplace. Desktop uses the supported Codex CLI commands to +register the marketplace, plugin, and selected runtime's absolute `vidxp-mcp` +command with its repository and data paths. Re-running setup refreshes these +registrations; a successful release setup removes the obsolete local +marketplace. A new Codex task is required before refreshed skills and tools are +available. The browser and app integration service remain loopback-only by default. Explicit sharing controls compose their existing `--share` modes: browser sharing reports its LAN URL and warns that it is unauthenticated, while API/MCP diff --git a/docs/integrations/openai-plugin.md b/docs/integrations/openai-plugin.md index df86622..9f5783d 100644 --- a/docs/integrations/openai-plugin.md +++ b/docs/integrations/openai-plugin.md @@ -1,44 +1,61 @@ # ChatGPT and Codex plugin integration -VidXP ships one versioned plugin bundle with its Python distribution. The -bundle combines the local MCP server definition with VidXP's canonical ingest -and evidence-search skills, while the MCP server exposes an optional interactive -view for hosts that implement MCP Apps. +VidXP ships one versioned plugin from this repository. It provides the install, +ingest, and evidence-search skills, while an installed VidXP runtime provides +the local MCP server and its optional interactive MCP App view. -The packaged bundle lives at -`src/vidxp/bundled_plugins/vidxp/` and contains: +The sole editable plugin source lives at `plugins/vidxp/` and contains: - `.codex-plugin/plugin.json`, the plugin manifest; -- `.mcp.json`, which starts the installed `vidxp-mcp` command; and -- `skills/`, a release snapshot of the canonical root `skills/` folders. +- `assets/logo.png`, used for the plugin logo and composer icon; and +- `skills/`, the canonical install, ingest, and evidence workflows. -The root skill folders remain the authoring source. Packaging tests require the -bundled snapshot to match their file inventory and text exactly, and Release -Please keeps the plugin manifest version aligned with the Python package. +The repository marketplace is declared at `.agents/plugins/marketplace.json`. +Other product code in the repository does not affect the marketplace: Codex +reads only that manifest and the plugin path it declares. In a repository +checkout the marketplace is discoverable as an available local marketplace; it +is not installed merely because the repository was opened. Packaging copies +the canonical plugin into the Python wheel at build time, and Release Please +keeps its manifest version aligned with the Python package. ## Install in Codex +### Ask Codex to install it + +```text +Add https://github.com/grayhatdevelopers/vidxp as a Git plugin marketplace, install the VidXP plugin, then use its $vidxp-install skill to set up VidXP on this computer. +``` + +### VidXP Desktop button + With **AI assistant integration** enabled, VidXP Desktop shows two distinct actions: -- **Set up in Codex** exports a target-specific copy of this bundle to a - Desktop-managed `vidxp-local` marketplace, registers that marketplace through - the Codex CLI, and installs the plugin. The generated `.mcp.json` pins the - selected installation's absolute `vidxp-mcp` command, repository, and data - paths, so Codex does not depend on its process `PATH`. +- **Set up in Codex** installs the plugin and registers the selected + installation's absolute `vidxp-mcp` command, repository, and data paths, so + Codex does not depend on its process `PATH`. - **Copy MCP setup** retains the transport-only JSON flow for other compatible local MCP clients. -The Codex action installs the MCP server and both skills as one unit. It uses -the documented JSON forms of `codex plugin marketplace add` and -`codex plugin add`, leaves personal marketplace files and `config.toml` -untouched, and asks the user to start a new Codex chat after installation. The -exported marketplace lives in VidXP Desktop's private application-data -directory. Its catalog is written to the Codex marketplace contract at -`.agents/plugins/marketplace.json`, with the plugin at `plugins/vidxp/`. The -exporter also removes the obsolete root-level `marketplace.json` from earlier -VidXP-managed exports. The canonical distributable bundle remains checked into -this repository and packaged in every VidXP wheel. +Signed beta builds register `grayhatdevelopers/vidxp` at `main`; signed stable +builds use `release`. Both use sparse checkout for `.agents/plugins` and +`plugins/vidxp`, so unrelated repository content is not downloaded into the +plugin cache. Development and pull-request builds export the packaged plugin to +the Desktop-private `vidxp-local` marketplace instead. The action uses the +documented `codex plugin marketplace add`, `codex plugin add`, and +`codex mcp add` commands. A successful Git install removes the obsolete managed +`vidxp-local` registration. Start a new Codex task after setup. + +### Manual fallback + +If an agent does not have an authorized local shell, add the beta marketplace +manually with: + +```text +codex plugin marketplace add grayhatdevelopers/vidxp --ref main \ + --sparse .agents/plugins --sparse plugins/vidxp +codex plugin add vidxp@vidxp +``` ## Interactive MCP App @@ -66,8 +83,9 @@ only exact HTTPS origins if future component assets or requests require them. ## Connect ChatGPT -The bundled `.mcp.json` is for local plugin hosts. A ChatGPT connection still -requires a publicly reachable Streamable HTTP endpoint (VidXP serves `/mcp`), +The Git marketplace described above is for Codex's local plugin system. A +ChatGPT connection still requires a publicly reachable Streamable HTTP endpoint +(VidXP serves `/mcp`), an HTTPS deployment or secure development tunnel, and registration in ChatGPT Developer Mode. diff --git a/src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json b/plugins/vidxp/.codex-plugin/plugin.json similarity index 75% rename from src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json rename to plugins/vidxp/.codex-plugin/plugin.json index 4232648..e845b81 100644 --- a/src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json +++ b/plugins/vidxp/.codex-plugin/plugin.json @@ -3,7 +3,8 @@ "version": "0.4.0-b.3", "description": "Ingest, index, search, and inspect video evidence with VidXP.", "author": { - "name": "Grayhat" + "name": "Grayhat", + "url": "https://grayhat.studio/" }, "homepage": "https://github.com/grayhatdevelopers/vidxp", "repository": "https://github.com/grayhatdevelopers/vidxp", @@ -17,12 +18,15 @@ "developerName": "Grayhat", "category": "Productivity", "capabilities": ["Read", "Write", "Interactive"], - "websiteURL": "https://github.com/grayhatdevelopers/vidxp", + "websiteURL": "https://grayhat.studio/", "defaultPrompt": [ "Find and verify moments in my indexed videos.", + "Set up VidXP on this computer.", "Ingest and index a video with VidXP." ], - "brandColor": "#6D5EF7" - }, - "mcpServers": "./.mcp.json" + "brandColor": "#6D5EF7", + "composerIcon": "./assets/logo.png", + "logo": "./assets/logo.png", + "logoDark": "./assets/logo.png" + } } diff --git a/plugins/vidxp/assets/logo.png b/plugins/vidxp/assets/logo.png new file mode 100644 index 0000000..f9c9d21 Binary files /dev/null and b/plugins/vidxp/assets/logo.png differ diff --git a/skills/vidxp-find-video-evidence/SKILL.md b/plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md similarity index 100% rename from skills/vidxp-find-video-evidence/SKILL.md rename to plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md diff --git a/skills/vidxp-find-video-evidence/agents/openai.yaml b/plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml similarity index 100% rename from skills/vidxp-find-video-evidence/agents/openai.yaml rename to plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml diff --git a/skills/vidxp-ingest-video/SKILL.md b/plugins/vidxp/skills/vidxp-ingest-video/SKILL.md similarity index 100% rename from skills/vidxp-ingest-video/SKILL.md rename to plugins/vidxp/skills/vidxp-ingest-video/SKILL.md diff --git a/skills/vidxp-ingest-video/agents/openai.yaml b/plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml similarity index 100% rename from skills/vidxp-ingest-video/agents/openai.yaml rename to plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml diff --git a/plugins/vidxp/skills/vidxp-install/SKILL.md b/plugins/vidxp/skills/vidxp-install/SKILL.md new file mode 100644 index 0000000..605c420 --- /dev/null +++ b/plugins/vidxp/skills/vidxp-install/SKILL.md @@ -0,0 +1,87 @@ +--- +name: vidxp-install +description: Install, update, or repair VidXP Desktop or the VidXP CLI and connect its local MCP server to Codex. Use when a user asks to install VidXP, choose between Desktop and CLI, enable local video search features, configure VidXP for an agent, or fix a missing VidXP runtime. This bootstrap skill does not require VidXP to already be installed. +--- + +# Install VidXP + +Reuse a working local VidXP setup when the user approves it. Otherwise, help them choose a setup, install only what they approve, verify it, and connect Codex to its local MCP server. + +## Check for an existing installation first + +Before offering a new install, look for every `vidxp` executable available to the local shell. Do not modify any candidate. For each one, run: + +```text + --version + desktop-probe --json --desktop-version codex-plugin --request-id codex-install +``` + +For each successful probe, show the user its executable, version, `data_root`, `repository_root`, and `model_root`. These are the installation's effective paths; existing downloaded models under the reported `model_root` can be reused. Ask the user to confirm whether to reuse that installation and those paths. + +If the user approves reuse, skip installation and upgrades. Use executables from that same environment, preserve the reported paths when registering MCP, and repair a missing surface or dependency only with approval. If no compatible installation is found or the user declines reuse, continue with the setup choice. + +## Start with the choice + +Determine which surface the user wants before downloading anything: + +- Recommend **Desktop** when they want a guided installer, managed runtime, feature selection, or browser interface. +- Recommend **CLI** when they want scripting, terminal control, automation, or a minimal agent-only setup. +- Clarify that both keep video processing local. The plugin itself is only the bootstrap and agent guidance; VidXP provides the actual MCP server after installation. +- A browser-only ChatGPT session cannot install native software. Continue only when the agent has an authorized local shell, or give the user the exact manual steps. + +Ask before starting an installer, changing a tool environment, or downloading models. Do not download model weights until the user has chosen the search capabilities that need them. + +## Desktop path + +1. Identify the operating system and architecture. +2. Use the latest applicable release from `https://github.com/grayhatdevelopers/vidxp/releases`: + - Windows x86-64: signed setup executable. + - Apple Silicon macOS: signed and notarized DMG. + - Linux x86-64: AppImage. +3. Prefer the stable release unless the user explicitly requests beta. Verify any published checksum before launching the artifact. +4. Let the user complete the native installer and choose the VidXP capabilities in Desktop. Do not silently select model-heavy features. +5. In VidXP Desktop, use **Set up Codex** after the runtime reports healthy. Desktop registers this plugin and the exact private-runtime `vidxp-mcp` command. +6. Start a new Codex task, then verify the VidXP MCP tools are available. + +## CLI path + +1. Verify that `uv` is installed from `https://docs.astral.sh/uv/getting-started/installation/`. +2. Install the CPU edition with MCP support: + + ```text + uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]" + ``` + + Add `frontend` to the extras only when the user wants the browser interface. +3. Initialize runtime dependencies: + + ```text + vidxp init + ``` + +4. Run `vidxp prepare` only after the user approves the required model downloads. Prefer capability-specific preparation when their choice is narrower than the default set. +5. Validate the installation: + + ```text + vidxp doctor + ``` + +6. Resolve the installed `vidxp-mcp` executable to an absolute path. Register it through the supported Codex CLI instead of editing configuration files by hand: + + ```text + codex mcp add vidxp -- --repository default + ``` + + When reusing an installation, also pass its reported `data_root` as `--data-dir` and a non-default `repository_root` as `--index-directory`. If `model_root` is not `/models`, add `--env VIDXP_MODEL_CACHE=` before `vidxp` so Codex launches MCP against the same model cache. + +7. Run `codex mcp get vidxp --json` and start a new Codex task before testing VidXP tools. + +## Updates and repairs + +- Desktop: use a current installer for the same channel, then re-run **Set up Codex** so the private runtime path and plugin source are refreshed. +- CLI: use `uv tool upgrade vidxp`, run `vidxp doctor`, and re-register the resolved `vidxp-mcp` command if its path changed. +- If Codex reports duplicate VidXP plugins, keep the Git-backed `vidxp` marketplace for release installs and remove the obsolete `vidxp@vidxp-local` entry only after the Git-backed plugin works. + +## Finish with evidence + +Report the selected surface and release channel, exact installer or command used, `vidxp doctor` result, registered MCP command path, and whether a new Codex task can see the VidXP tools. Never call an unverified installation successful. diff --git a/plugins/vidxp/skills/vidxp-install/agents/openai.yaml b/plugins/vidxp/skills/vidxp-install/agents/openai.yaml new file mode 100644 index 0000000..5ffa3fc --- /dev/null +++ b/plugins/vidxp/skills/vidxp-install/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Install VidXP" + short_description: "Install VidXP Desktop or CLI and connect Codex" + default_prompt: "Use $vidxp-install to reuse an existing VidXP installation if I approve it, or install my chosen setup, then register its local MCP server and verify it." diff --git a/pyproject.toml b/pyproject.toml index 0fa6398..baeb6b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,10 +79,6 @@ vidxp = [ "requirements/*.txt", "migrations/*.py", "migrations/versions/*.py", - "bundled_plugins/vidxp/.mcp.json", - "bundled_plugins/vidxp/.codex-plugin/plugin.json", - "bundled_plugins/vidxp/skills/*/SKILL.md", - "bundled_plugins/vidxp/skills/*/agents/*.yaml", ] [tool.setuptools.dynamic.optional-dependencies] diff --git a/release-please-config.json b/release-please-config.json index 5ecd2f6..43e6a57 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -18,7 +18,7 @@ }, { "jsonpath": "$.version", - "path": "src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "path": "plugins/vidxp/.codex-plugin/plugin.json", "type": "json" }, { diff --git a/release-please-config.stable.json b/release-please-config.stable.json index 45d5255..dbff67c 100644 --- a/release-please-config.stable.json +++ b/release-please-config.stable.json @@ -21,7 +21,7 @@ }, { "jsonpath": "$.version", - "path": "src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "path": "plugins/vidxp/.codex-plugin/plugin.json", "type": "json" }, { diff --git a/setup.py b/setup.py index 31da439..5226952 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ from pathlib import Path -from shutil import copyfile +from shutil import copyfile, copytree from setuptools import setup from setuptools.command.build_py import build_py @@ -13,5 +13,10 @@ def run(self): target.parent.mkdir(parents=True, exist_ok=True) copyfile(source, target) + plugin_source = Path(__file__).parent / "plugins" / "vidxp" + plugin_target = Path(self.build_lib) / "vidxp" / "bundled_plugins" / "vidxp" + copytree(plugin_source, plugin_target, dirs_exist_ok=True) + copyfile(source, plugin_target / "assets" / "logo.png") + setup(cmdclass={"build_py": BuildPy}) diff --git a/src/vidxp/bundled_plugins/vidxp/.mcp.json b/src/vidxp/bundled_plugins/vidxp/.mcp.json deleted file mode 100644 index bed572e..0000000 --- a/src/vidxp/bundled_plugins/vidxp/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "vidxp": { - "command": "vidxp-mcp", - "args": ["--repository", "default"] - } - } -} diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md deleted file mode 100644 index d78fe75..0000000 --- a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: vidxp-find-video-evidence -description: Use VidXP to search indexed videos and surface inspectable evidence boards, keyframes, and clips before analysis. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Favor one-pass evidence delivery and only add brief accuracy feedback; do not trigger for ingesting new media or ordinary video editing. ---- - -# Find video evidence with VidXP - -## Workflow - -1. Resolve the `vidxp` MCP tools, then call `get_workspace`. If the requested - video is not indexed, explain that it must be indexed first. -2. Submit one retrieval job. Use `search_moments` to locate moments; use - `query_video` only when the user asks for a synthesized answer. Use - `command.query` with `search_moments` and `command.question` with - `query_video`. Set `command.media_id` when the user means one video. -3. In that initial job, put exactly this inside `command`: - `"evidence_delivery": {"mode": "keyframes_and_clips", "max_items": 3}`. - This prepares the ranked board, standalone keyframes, and clips without a - second retrieval pass. Never send `command.materialize`. -4. Call `wait_job` for bounded waits. Pass its `observation_token` as - `after_observation_token` on the next wait. When terminal, call - `get_job_evidence` once. It returns the concise evidence index and visual - content without the full structured job dump. Search and query may take - time; update the user when the stage changes or about once per minute, never - after every wait and never with an invented ETA. -5. Surface the returned board, keyframes, and clips immediately. Do not call - `get_job`, repeat the search, materialize more evidence, create another - board, or perform a self-directed verification loop before showing the - initial evidence. -6. Stop after the first evidence delivery. Only when the user explicitly asks - for another selection or format, use tile evidence IDs with - `materialize_job_evidence`, or use `create_evidence_board` for a custom - selection or `next_start_rank` continuation. - -## Actor scope - -- Actor data is available through `query_video`, not name search. It represents - anonymous, video-scoped face clusters—not a named or cross-video identity. -- Treat its image as a representative full frame, not an exact face crop or - proof of continuous presence. Do not claim exhaustive named appearances. -- For named-person requests, surface the best scene candidates immediately and - label uncertain matches as candidates. Do not delay delivery while trying to - prove identity through additional searches. - -## Output - -- Lead with evidence, not a search narrative: first embed the returned board or - frame or provide its working resource link, then list the ready clips and - keyframes. Use the returned `local_path` or `download_url`; never write an - unlinked label such as “View evidence board.” Use `get_artifact_download` - only if neither is returned. -- After the evidence, add at most a brief accuracy note. State uncertainty or - visible mismatches without launching another search. Accuracy feedback must - not replace or precede the evidence. -- Preserve the source job and evidence IDs. Describe scores as retrieval scores, - and distinguish a visible appearance from a dialogue or caption mention. -- Stop waiting on success, failure, or cancellation. An empty result means no - matching indexed evidence was found, not that the event is absent from the - original video. diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml deleted file mode 100644 index 4d2a39f..0000000 --- a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml +++ /dev/null @@ -1,13 +0,0 @@ -interface: - display_name: "Find Video Evidence with VidXP" - short_description: "Surface video boards, frames, and clips first" - default_prompt: "Use $vidxp-find-video-evidence to surface the best board, keyframes, and clips before brief accuracy feedback." - -policy: - allow_implicit_invocation: true - -dependencies: - tools: - - type: "mcp" - value: "vidxp" - description: "VidXP video search and evidence tools" diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md deleted file mode 100644 index 198023f..0000000 --- a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: vidxp-ingest-video -description: Use VidXP to upload, import, register, and automatically index video files through its MCP tools. Trigger for requests to add, upload, ingest, import, register, or index one or more videos, including attached videos and accessible local paths, even when the user does not name VidXP. Do not trigger for editing, transcoding, or searching a video that is already indexed. ---- - -# Ingest video with VidXP - -## Workflow - -1. Resolve the `vidxp` MCP tools and call `get_workspace`. Do not import a video - that is already registered or indexed. -2. Choose indexable modalities from the workspace. Use `dialogue` and `scene` - for ordinary content retrieval. Add `actor` only when anonymous recurring-face - clusters are wanted; it does not identify people by name. -3. Call `get_runtime_readiness`. If selected models are missing, submit - `prepare_models`, use `wait_job` with its observation token for subsequent - bounded waits, then fetch `get_job` once when terminal. -4. Use `ingest_local_media` for one to ten paths accessible to VidXP; otherwise - use `create_media_upload` and give the returned link to the user. Keep - `index_after_import` enabled unless registration-only behavior was requested. -5. Poll the returned ingestion or upload ID with `get_media_ingestion` or - `get_media_upload`. Honor its poll interval, reuse the same identifiers, and - do not resubmit unchanged work. -6. Stop at a terminal state and report each file's state, media ID, index job, - and searchable snapshot or generation. If indexing fails after registration, - retry with `start_indexing`; do not upload the file again. -7. If the request also asks about the video, continue directly into the VidXP - evidence workflow once it is searchable. - -## Long operations - -- Tell the user that model preparation and indexing can take several minutes. -- Update when the stage changes or about once per minute; do not narrate every - status check or invent an ETA. -- Treat files independently so one failure does not hide successful siblings. diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml deleted file mode 100644 index f502ebe..0000000 --- a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml +++ /dev/null @@ -1,13 +0,0 @@ -interface: - display_name: "Ingest Video with VidXP" - short_description: "Upload or ingest videos and index them with VidXP" - default_prompt: "Use $vidxp-ingest-video to ingest and index my video with VidXP." - -policy: - allow_implicit_invocation: true - -dependencies: - tools: - - type: "mcp" - value: "vidxp" - description: "VidXP video ingestion and indexing tools" diff --git a/src/vidxp/codex_plugin.py b/src/vidxp/codex_plugin.py index 5ad3410..4f2bfa0 100644 --- a/src/vidxp/codex_plugin.py +++ b/src/vidxp/codex_plugin.py @@ -17,6 +17,7 @@ PLUGIN_NAME = "vidxp" MARKETPLACE_NAME = "vidxp-local" +GIT_MARKETPLACE_NAME = "vidxp" MANAGED_MARKER = ".vidxp-managed-marketplace" MARKETPLACE_MANIFEST = Path(".agents") / "plugins" / "marketplace.json" @@ -52,7 +53,10 @@ def to_dict(self) -> dict[str, str | None]: def bundled_codex_plugin() -> Path: - return Path(__file__).resolve().parent / "bundled_plugins" / PLUGIN_NAME + packaged = Path(__file__).resolve().parent / "bundled_plugins" / PLUGIN_NAME + if (packaged / ".codex-plugin" / "plugin.json").is_file(): + return packaged + return Path(__file__).resolve().parents[2] / "plugins" / PLUGIN_NAME def _json_bytes(payload: Any) -> bytes: @@ -65,8 +69,8 @@ def _write_json(path: Path, payload: Any) -> None: path.write_bytes(_json_bytes(payload)) -def _bundle_digest(source: Path, mcp_config: dict[str, Any]) -> str: - digest = hashlib.sha256(_json_bytes(mcp_config)) +def _bundle_digest(source: Path) -> str: + digest = hashlib.sha256() for path in sorted(item for item in source.rglob("*") if item.is_file()): digest.update(path.relative_to(source).as_posix().encode("utf-8")) digest.update(path.read_bytes()) @@ -109,16 +113,9 @@ def export_codex_plugin( plugin_root = plugins_root / PLUGIN_NAME marker = root / MANAGED_MARKER - mcp_config = stdio_client_config( - registry=registry, - repository=repository, - index_directory=index_directory, - data_directory=data_directory, - device=device, - ) plugin_version = ( f"{__version__.split('+', 1)[0]}+codex." - f"{_bundle_digest(source, mcp_config)}" + f"{_bundle_digest(source)}" ) staging_parent = Path(tempfile.mkdtemp(prefix=".vidxp-plugin-", dir=plugins_root)) @@ -130,7 +127,6 @@ def export_codex_plugin( manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["version"] = plugin_version _write_json(manifest_path, manifest) - _write_json(staging_plugin / ".mcp.json", mcp_config) if backup.exists(): shutil.rmtree(backup) @@ -224,6 +220,88 @@ def _run_codex_json( return payload +def _run_codex( + command: str, + arguments: Sequence[str], + *, + runner: CommandRunner, +) -> subprocess.CompletedProcess[str]: + try: + completed = runner( + [command, *arguments], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise CodexPluginInstallError(f"Codex could not be started: {exc}") from exc + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + raise CodexPluginInstallError( + detail or f"Codex exited with status {completed.returncode}." + ) + return completed + + +def _plugin_version(plugin_root: Path) -> str: + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + version = manifest["version"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc: + raise CodexPluginInstallError( + "This VidXP installation does not contain a valid plugin manifest." + ) from exc + if not isinstance(version, str) or not version: + raise CodexPluginInstallError("The VidXP plugin version is invalid.") + return version + + +def _remove_legacy_local_plugin( + command: str, + *, + runner: CommandRunner, +) -> None: + plugins = _run_codex_json( + command, + ["plugin", "list", "--json"], + runner=runner, + ).get("installed", []) + if any( + isinstance(plugin, dict) + and plugin.get("pluginId") == f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + for plugin in plugins + ): + _run_codex_json( + command, + [ + "plugin", + "remove", + f"{PLUGIN_NAME}@{MARKETPLACE_NAME}", + "--json", + ], + runner=runner, + ) + + marketplaces = _run_codex_json( + command, + ["plugin", "marketplace", "list", "--json"], + runner=runner, + ).get("marketplaces", []) + if any( + isinstance(marketplace, dict) + and marketplace.get("name") == MARKETPLACE_NAME + for marketplace in marketplaces + ): + _run_codex_json( + command, + ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"], + runner=runner, + ) + + def _configured_codex_command( environment: Mapping[str, str], ) -> str | None: @@ -297,8 +375,11 @@ def resolve_codex_command( def install_codex_plugin( - marketplace_root: Path, + marketplace_root: Path | None, *, + marketplace_source: str | None = None, + marketplace_ref: str | None = None, + marketplace_sparse: Sequence[str] = (), registry: str | None = None, repository: str = "default", index_directory: str | None = None, @@ -307,16 +388,17 @@ def install_codex_plugin( codex_command: str | None = None, runner: CommandRunner = subprocess.run, ) -> CodexPluginInstall: - """Export, register, and install VidXP's local Codex plugin.""" + """Install VidXP's plugin and register its target-specific MCP command.""" + + if marketplace_source and marketplace_root is not None: + raise CodexPluginInstallError( + "Choose either a Git marketplace source or a local marketplace root." + ) + if not marketplace_source and marketplace_root is None: + raise CodexPluginInstallError( + "A Git marketplace source or local marketplace root is required." + ) - exported = export_codex_plugin( - marketplace_root, - registry=registry, - repository=repository, - index_directory=index_directory, - data_directory=data_directory, - device=device, - ) command = codex_command or resolve_codex_command() if command is None: raise CodexPluginInstallError( @@ -324,51 +406,108 @@ def install_codex_plugin( "app, make the codex command available, and try again." ) - marketplace_result = _run_codex_json( - command, - [ + if marketplace_source: + marketplace_arguments = [ + "plugin", + "marketplace", + "add", + marketplace_source, + ] + if marketplace_ref: + marketplace_arguments.extend(("--ref", marketplace_ref)) + for sparse_path in marketplace_sparse: + marketplace_arguments.extend(("--sparse", sparse_path)) + marketplace_arguments.append("--json") + marketplace_path = ( + f"{marketplace_source}@{marketplace_ref}" + if marketplace_ref + else marketplace_source + ) + marketplace_name_fallback = GIT_MARKETPLACE_NAME + plugin_version = _plugin_version(bundled_codex_plugin()) + else: + assert marketplace_root is not None + exported = export_codex_plugin( + marketplace_root, + registry=registry, + repository=repository, + index_directory=index_directory, + data_directory=data_directory, + device=device, + ) + marketplace_arguments = [ "plugin", "marketplace", "add", exported.marketplace_root, "--json", - ], + ] + marketplace_path = exported.marketplace_path + marketplace_name_fallback = exported.marketplace_name + plugin_version = exported.plugin_version + + marketplace_result = _run_codex_json( + command, + marketplace_arguments, runner=runner, ) marketplace_name = str( - marketplace_result.get("marketplaceName") or exported.marketplace_name + marketplace_result.get("marketplaceName") or marketplace_name_fallback ) plugin_result = _run_codex_json( command, [ "plugin", "add", - f"{exported.plugin_name}@{marketplace_name}", + f"{PLUGIN_NAME}@{marketplace_name}", "--json", ], runner=runner, ) + + mcp = stdio_client_config( + registry=registry, + repository=repository, + index_directory=index_directory, + data_directory=data_directory, + device=device, + )["mcpServers"][PLUGIN_NAME] + _run_codex( + command, + [ + "mcp", + "add", + PLUGIN_NAME, + "--", + str(mcp["command"]), + *(str(argument) for argument in mcp["args"]), + ], + runner=runner, + ) + if marketplace_source: + _remove_legacy_local_plugin(command, runner=runner) + return CodexPluginInstall( - plugin_name=str(plugin_result.get("name") or exported.plugin_name), + plugin_name=str(plugin_result.get("name") or PLUGIN_NAME), plugin_id=( None if plugin_result.get("pluginId") is None else str(plugin_result["pluginId"]) ), plugin_version=str( - plugin_result.get("version") or exported.plugin_version + plugin_result.get("version") or plugin_version ), marketplace_name=str( plugin_result.get("marketplaceName") or marketplace_name ), - marketplace_path=exported.marketplace_path, + marketplace_path=marketplace_path, installed_path=( None if plugin_result.get("installedPath") is None else str(plugin_result["installedPath"]) ), detail=( - "VidXP is installed in Codex with its MCP server and skills. " - "Start a new Codex chat to use the updated plugin." + "VidXP is installed in Codex with its skills and local MCP server. " + "Start a new Codex task to use the updated plugin." ), ) diff --git a/src/vidxp/codex_plugin_cli.py b/src/vidxp/codex_plugin_cli.py index c93ebf0..ae0195b 100644 --- a/src/vidxp/codex_plugin_cli.py +++ b/src/vidxp/codex_plugin_cli.py @@ -15,9 +15,19 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument( "--marketplace-root", type=Path, - required=True, help="Dedicated local marketplace directory managed by VidXP Desktop.", ) + parser.add_argument( + "--marketplace-source", + help="Git marketplace source, such as owner/repository.", + ) + parser.add_argument("--marketplace-ref", help="Git ref to fetch.") + parser.add_argument( + "--marketplace-sparse", + action="append", + default=[], + help="Sparse checkout path for a Git marketplace; repeat as needed.", + ) parser.add_argument("--registry") parser.add_argument("--repository", default="default") parser.add_argument("--index-directory") @@ -32,6 +42,9 @@ def main(arguments: Sequence[str] | None = None) -> None: try: result = install_codex_plugin( options.marketplace_root, + marketplace_source=options.marketplace_source, + marketplace_ref=options.marketplace_ref, + marketplace_sparse=options.marketplace_sparse, registry=options.registry, repository=options.repository, index_directory=options.index_directory, diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py index 54aa0ee..edae7c9 100644 --- a/tests/test_ci_scope.py +++ b/tests/test_ci_scope.py @@ -41,6 +41,7 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self): "pyproject.toml", "uv.lock", ".github/workflows/desktop.yml", + "plugins/vidxp/skills/vidxp-ingest-video/SKILL.md", ): with self.subTest(path=path): self.assertTrue(classify([path]).run_desktop) @@ -48,7 +49,7 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self): for path in ( "README.md", "docs/releasing.md", - "skills/vidxp-ingest-video/SKILL.md", + ".agents/plugins/marketplace.json", "web/upload-page/src/app.js", ): with self.subTest(path=path): diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 9aa8a73..5fecbc3 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -16,7 +16,7 @@ ) -def test_export_codex_plugin_materializes_skills_and_target_mcp_config() -> None: +def test_export_codex_plugin_materializes_the_canonical_skill_bundle() -> None: with TemporaryDirectory() as directory: root = Path(directory) / "codex-marketplace" index_directory = Path("C:/VidXP/repositories/default") @@ -34,7 +34,6 @@ def test_export_codex_plugin_materializes_skills_and_target_mcp_config() -> None encoding="utf-8" ) ) - mcp = json.loads((plugin_root / ".mcp.json").read_text(encoding="utf-8")) marketplace_path = root / ".agents" / "plugins" / "marketplace.json" marketplace = json.loads(marketplace_path.read_text(encoding="utf-8")) @@ -45,18 +44,8 @@ def test_export_codex_plugin_materializes_skills_and_target_mcp_config() -> None assert ( plugin_root / "skills" / "vidxp-find-video-evidence" / "SKILL.md" ).is_file() - assert mcp["mcpServers"]["vidxp"]["args"] == [ - "--repository", - "default", - "--index-directory", - str(index_directory), - "--data-dir", - str(data_directory), - ] - assert Path(mcp["mcpServers"]["vidxp"]["command"]).name.lower() in { - "vidxp-mcp", - "vidxp-mcp.exe", - } + assert (plugin_root / "skills" / "vidxp-install" / "SKILL.md").is_file() + assert not (plugin_root / ".mcp.json").exists() assert marketplace["name"] == "vidxp-local" assert marketplace["plugins"][0]["source"]["path"] == "./plugins/vidxp" assert marketplace["plugins"][0]["policy"] == { @@ -91,7 +80,7 @@ def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: calls.append(command) if command[1:4] == ["plugin", "marketplace", "add"]: payload = {"marketplaceName": "vidxp-local", "alreadyAdded": False} - else: + elif command[1:3] == ["plugin", "add"]: payload = { "pluginId": "vidxp@vidxp-local", "name": "vidxp", @@ -99,6 +88,8 @@ def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: "version": "0.4.0+codex.example", "installedPath": "/codex/cache/vidxp", } + else: + return subprocess.CompletedProcess(command, 0, "Added MCP server", "") return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") with TemporaryDirectory() as directory: @@ -117,9 +108,81 @@ def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: "vidxp@vidxp-local", "--json", ] + assert calls[2][0:5] == ["codex-test", "mcp", "add", "vidxp", "--"] + assert Path(calls[2][5]).name.lower() in {"vidxp-mcp", "vidxp-mcp.exe"} + assert calls[2][6:] == ["--repository", "default"] assert result.plugin_id == "vidxp@vidxp-local" assert result.installed_path == "/codex/cache/vidxp" - assert "MCP server and skills" in result.detail + assert "skills and local MCP server" in result.detail + + +def test_install_codex_plugin_uses_git_marketplace_and_migrates_local_source() -> None: + calls: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + arguments = command[1:] + if arguments[:3] == ["plugin", "marketplace", "add"]: + payload = {"marketplaceName": "vidxp"} + elif arguments[:2] == ["plugin", "add"]: + payload = { + "pluginId": "vidxp@vidxp", + "name": "vidxp", + "marketplaceName": "vidxp", + "version": "0.4.0-b.3", + } + elif arguments == ["plugin", "list", "--json"]: + payload = {"installed": [{"pluginId": "vidxp@vidxp-local"}]} + elif arguments == ["plugin", "marketplace", "list", "--json"]: + payload = {"marketplaces": [{"name": "vidxp-local"}]} + elif "--json" in arguments: + payload = {} + else: + return subprocess.CompletedProcess(command, 0, "ok", "") + return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") + + result = install_codex_plugin( + None, + marketplace_source="grayhatdevelopers/vidxp", + marketplace_ref="main", + marketplace_sparse=(".agents/plugins", "plugins/vidxp"), + codex_command="codex-test", + runner=runner, + ) + + assert calls[0] == [ + "codex-test", + "plugin", + "marketplace", + "add", + "grayhatdevelopers/vidxp", + "--ref", + "main", + "--sparse", + ".agents/plugins", + "--sparse", + "plugins/vidxp", + "--json", + ] + assert calls[1] == ["codex-test", "plugin", "add", "vidxp@vidxp", "--json"] + assert calls[2][1:5] == ["mcp", "add", "vidxp", "--"] + assert calls[4] == [ + "codex-test", + "plugin", + "remove", + "vidxp@vidxp-local", + "--json", + ] + assert calls[6] == [ + "codex-test", + "plugin", + "marketplace", + "remove", + "vidxp-local", + "--json", + ] + assert result.marketplace_name == "vidxp" + assert result.marketplace_path == "grayhatdevelopers/vidxp@main" def test_export_refuses_to_replace_an_unmanaged_marketplace() -> None: diff --git a/tests/test_packaging.py b/tests/test_packaging.py index f23ddd2..c2de7f5 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -20,7 +20,7 @@ class PackagingTests(unittest.TestCase): - def test_wheel_contains_mcp_app_and_versioned_plugin_bundle(self): + def test_wheel_contains_mcp_app_and_canonical_plugin_bundle(self): with TemporaryDirectory() as directory: subprocess.run( [ @@ -48,12 +48,14 @@ def test_wheel_contains_mcp_app_and_versioned_plugin_bundle(self): required = { "vidxp/assets/mcp_app/index.html", - "vidxp/bundled_plugins/vidxp/.mcp.json", "vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "vidxp/bundled_plugins/vidxp/assets/logo.png", "vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md", "vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml", "vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md", "vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml", + "vidxp/bundled_plugins/vidxp/skills/vidxp-install/SKILL.md", + "vidxp/bundled_plugins/vidxp/skills/vidxp-install/agents/openai.yaml", } self.assertLessEqual(required, members) self.assertIn( @@ -61,15 +63,19 @@ def test_wheel_contains_mcp_app_and_versioned_plugin_bundle(self): entry_points, ) - def test_bundled_plugin_is_valid_and_skills_match_canonical_sources(self): + def test_repository_marketplace_uses_the_canonical_branded_plugin(self): project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - plugin_root = ROOT / "src" / "vidxp" / "bundled_plugins" / "vidxp" + plugin_root = ROOT / "plugins" / "vidxp" manifest = json.loads( (plugin_root / ".codex-plugin" / "plugin.json").read_text( encoding="utf-8" ) ) - mcp = json.loads((plugin_root / ".mcp.json").read_text(encoding="utf-8")) + marketplace = json.loads( + (ROOT / ".agents" / "plugins" / "marketplace.json").read_text( + encoding="utf-8" + ) + ) self.assertEqual(manifest["name"], "vidxp") self.assertEqual( @@ -77,28 +83,18 @@ def test_bundled_plugin_is_valid_and_skills_match_canonical_sources(self): Version(project["project"]["version"]), ) self.assertEqual(manifest["skills"], "./skills/") - self.assertEqual(manifest["mcpServers"], "./.mcp.json") - self.assertEqual(mcp["mcpServers"]["vidxp"]["command"], "vidxp-mcp") - - canonical = ROOT / "skills" - bundled = plugin_root / "skills" - canonical_files = { - path.relative_to(canonical) - for path in canonical.rglob("*") - if path.is_file() - } - bundled_files = { - path.relative_to(bundled) - for path in bundled.rglob("*") - if path.is_file() - } - self.assertEqual(bundled_files, canonical_files) - for relative in canonical_files: - self.assertEqual( - (bundled / relative).read_text(encoding="utf-8"), - (canonical / relative).read_text(encoding="utf-8"), - relative.as_posix(), - ) + self.assertNotIn("mcpServers", manifest) + self.assertEqual(manifest["interface"]["logo"], "./assets/logo.png") + self.assertEqual( + manifest["interface"]["composerIcon"], "./assets/logo.png" + ) + self.assertTrue((plugin_root / "assets" / "logo.png").is_file()) + self.assertEqual(marketplace["name"], "vidxp") + self.assertEqual( + marketplace["plugins"][0]["source"]["path"], + "./plugins/vidxp", + ) + self.assertFalse((ROOT / "skills").exists()) def test_sdist_contains_every_upload_page_build_input(self): with TemporaryDirectory() as directory: @@ -343,8 +339,15 @@ def test_canonical_icon_is_packaged_and_desktop_derivatives_are_wired(self): (ROOT / "README.md").read_text(encoding="utf-8"), ) build_hook = (ROOT / "setup.py").read_text(encoding="utf-8") - self.assertIn('"docs" / "images" / "logo.png"', build_hook) + self.assertIn( + '"docs" / "images" / "logo.png"', + build_hook, + ) self.assertIn('"vidxp" / "assets" / "icon.png"', build_hook) + self.assertIn( + 'copyfile(source, plugin_target / "assets" / "logo.png")', + build_hook, + ) package = json.loads( (ROOT / "desktop" / "package.json").read_text(encoding="utf-8") @@ -364,17 +367,27 @@ def test_canonical_icon_is_packaged_and_desktop_derivatives_are_wired(self): self.assertEqual( package["scripts"]["icons"], ( - "npm run sync:branding && " "tauri icon ../docs/images/logo.png " - "--output src-tauri/icons" + "--output src-tauri/icons && npm run sync:branding" ), ) sync_script = ( ROOT / "desktop" / "scripts" / "sync-branding.mjs" ).read_text(encoding="utf-8") self.assertIn("../docs/images/logo.png", sync_script) + self.assertIn("../plugins/vidxp/assets/logo.png", sync_script) + self.assertIn( + "../src/vidxp/assets/artifact_download/vidxp-logo.png", + sync_script, + ) + self.assertIn("src-tauri/icons/128x128.png", sync_script) self.assertIn('resolve(desktopRoot, "public")', sync_script) self.assertIn('resolve(publicDirectory, "icon.png")', sync_script) + self.assertIn("copyFileSync(source, favicon)", sync_script) + self.assertEqual( + (ROOT / "plugins" / "vidxp" / "assets" / "logo.png").read_bytes(), + icon.read_bytes(), + ) self.assertIn( 'href="/icon.png"', (ROOT / "desktop" / "index.html").read_text( @@ -671,7 +684,7 @@ def test_desktop_manifest_matches_published_package_contract(self): def test_combined_release_version_contract(self): expected_extra_files = { "uv.lock", - "src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "plugins/vidxp/.codex-plugin/plugin.json", "desktop/src-tauri/Cargo.toml", "desktop/src-tauri/Cargo.lock", "desktop/package.json", diff --git a/utils/ci_scope.py b/utils/ci_scope.py index 68d5582..ba590cb 100644 --- a/utils/ci_scope.py +++ b/utils/ci_scope.py @@ -21,15 +21,17 @@ def _normalize(path: str) -> str: def _is_documentation(path: str) -> bool: - return path.startswith("docs/") or path.endswith(".md") + return path.startswith("docs/") or ( + path.endswith(".md") and not path.startswith("plugins/") + ) def _is_container_neutral(path: str) -> bool: - return path.startswith(("desktop/", "skills/", "tests/")) + return path.startswith((".agents/", "desktop/", "plugins/", "tests/")) def _affects_desktop(path: str) -> bool: - return path.startswith(("desktop/", "src/", "tests/", "utils/")) or path in { + return path.startswith(("desktop/", "plugins/", "src/", "tests/", "utils/")) or path in { ".github/workflows/ci.yml", ".github/workflows/desktop.yml", "LICENSE", @@ -42,7 +44,16 @@ def _affects_desktop(path: str) -> bool: def _is_unknown_product_path(path: str) -> bool: return not path.startswith( - (".github/", "desktop/", "skills/", "src/", "tests/", "utils/", "web/") + ( + ".agents/", + ".github/", + "desktop/", + "plugins/", + "src/", + "tests/", + "utils/", + "web/", + ) ) and path not in { ".dockerignore", "compose.coolify.yaml",