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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ include LICENSE
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 *
include web/upload-page/package.json
include web/upload-page/package-lock.json
include web/upload-page/scripts/build.mjs
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,16 @@ VidXP includes reusable skill source folders for the two common 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)

Download a skill folder and add it through a supported ChatGPT desktop or Codex
Skills surface. The skills require a connected VidXP MCP server; installable
plugin packaging for additional ChatGPT surfaces will follow separately.
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.

- [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md)
- [ChatGPT and Codex plugin integration](docs/integrations/openai-plugin.md)
- [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras)
- [Coolify server setup](docs/deployment/coolify.md)

Expand Down
55 changes: 55 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,17 @@ struct LocalWorkerStatus {
detail: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
struct CodexPluginInstallResult {
plugin_name: String,
plugin_id: Option<String>,
plugin_version: String,
marketplace_name: String,
marketplace_path: String,
installed_path: Option<String>,
detail: String,
}

#[derive(Clone)]
struct TrayMenuItems {
installation: MenuItem<tauri::Wry>,
Expand Down Expand Up @@ -3641,6 +3652,49 @@ async fn mcp_client_config(
.map_err(|error| format!("MCP configuration stopped unexpectedly: {error}"))?
}

#[tauri::command]
async fn install_codex_plugin(
app: AppHandle,
state: tauri::State<'_, DesktopState>,
) -> Result<CodexPluginInstallResult, String> {
let _active = state.active_operations.register()?;
tauri::async_runtime::spawn_blocking(move || {
let (profile, paths) = selected_target_context(&app)?;
if !profile
.surfaces
.iter()
.any(|surface| surface == "mcp" || surface == "server")
{
return Err(
"The selected VidXP installation does not expose an installed MCP surface.".into(),
);
}
let installer_path = target_companion_executable(&profile, "vidxp-codex-plugin");
if !installer_path.is_file() {
return Err(format!(
"The selected installation did not provide {}. Update VidXP and try again.",
installer_path.display()
));
}
let marketplace_root = paths.private_data.join("codex-marketplace");
let mut command = target_command(&profile, &paths, &installer_path);
command
.arg("--marketplace-root")
.arg(&marketplace_root)
.arg("--repository")
.arg("default")
.arg("--index-directory")
.arg(&profile.repository_root)
.arg("--data-dir")
.arg(&profile.data_root);
let output = checked_output(command, "VidXP Codex plugin setup")?;
serde_json::from_slice(&output.stdout)
.map_err(|error| format!("VidXP returned invalid Codex setup details: {error}"))
})
.await
.map_err(|error| format!("Codex plugin setup stopped unexpectedly: {error}"))?
}

fn execute_worker_action(app: &AppHandle, action: &str) -> Result<LocalWorkerStatus, String> {
let (profile, paths) = selected_target_context(app)?;
if !profile.surfaces.iter().any(|surface| surface == "worker") {
Expand Down Expand Up @@ -4601,6 +4655,7 @@ pub fn run() {
target_doctor,
configure_external_installation,
mcp_client_config,
install_codex_plugin,
local_worker_status,
start_local_worker,
stop_local_worker,
Expand Down
17 changes: 14 additions & 3 deletions desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
prepareManagedModels: vi.fn(), onManagedSetupProgress: vi.fn(),
runtimeManifest: vi.fn(), runtimeStatus: vi.fn(), launchUi: vi.fn(),
chooseModelDirectory: vi.fn(), modelDirectoryInventory: vi.fn(),
targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(),
targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), installCodexPlugin: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(),
startLocalServer: vi.fn(), startSharedServer: vi.fn(), stopLocalServer: vi.fn(), startSharedBrowser: vi.fn(), stopBrowserService: vi.fn(), startLocalWorker: vi.fn(), stopLocalWorker: vi.fn(), configureExternalInstallation: vi.fn(),
}));

Expand Down Expand Up @@ -107,6 +107,12 @@ describe('desktop target lifecycle', () => {
mocks.launchUi.mockResolvedValue(undefined);
mocks.targetDoctor.mockResolvedValue({ ok: true, modalities: ['scene'], checks: [{ capability: 'media', kind: 'distribution', name: 'ffmpeg', ok: true }] });
mocks.mcpClientConfig.mockResolvedValue('{"mcpServers":{"vidxp":{"command":"vidxp-mcp"}}}');
mocks.installCodexPlugin.mockResolvedValue({
plugin_name: 'vidxp', plugin_id: 'vidxp@vidxp-local', plugin_version: '0.4.0+codex.1234',
marketplace_name: 'vidxp-local', marketplace_path: 'C:\\Data\\codex-marketplace\\.agents\\plugins\\marketplace.json',
installed_path: 'C:\\Users\\test\\.codex\\plugins\\vidxp',
detail: 'VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.',
});
mocks.browserServiceStatus.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' });
mocks.startSharedBrowser.mockResolvedValue({ state: 'ready', running: true, shared: true, port: 8501, local_url: 'http://127.0.0.1:8501', network_url: 'http://192.168.1.20:8501', detail: 'Shared.' });
mocks.stopBrowserService.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' });
Expand Down Expand Up @@ -176,7 +182,11 @@ describe('desktop target lifecycle', () => {
expect(await screen.findByText('VidXP is ready')).toBeVisible();
expect(mocks.targetDoctor).toHaveBeenCalledTimes(1);

await user.click(screen.getByRole('button', { name: 'Set up connection' }));
await user.click(screen.getByRole('button', { name: 'Set up in Codex' }));
expect(await screen.findByText('VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.')).toBeVisible();
expect(mocks.installCodexPlugin).toHaveBeenCalledTimes(1);

await user.click(screen.getByRole('button', { name: 'Copy MCP setup' }));
expect(await screen.findByRole('heading', { name: 'Connect an AI assistant' })).toBeVisible();
expect(mocks.mcpClientConfig).toHaveBeenCalledTimes(1);

Expand Down Expand Up @@ -211,7 +221,8 @@ describe('desktop target lifecycle', () => {
await user.click(screen.getByRole('button', { name: 'Apply changes' }));

await waitFor(() => expect(mocks.configureExternalInstallation).toHaveBeenCalledWith([], ['worker', 'browser', 'mcp']));
expect(await screen.findByRole('button', { name: 'Set up connection' })).toBeVisible();
expect(await screen.findByRole('button', { name: 'Set up in Codex' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Copy MCP setup' })).toBeVisible();
});

it('offers an in-place manifest update when the selected runtime contract is too old', async () => {
Expand Down
33 changes: 29 additions & 4 deletions desktop/src/components/TargetSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Alert, Badge, Button, Checkbox, Code, Group, Loader, Modal, Stack, Text, Title } from '@mantine/core';
import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react';
import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlugConnected, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react';
import { useEffect, useState } from 'react';

import {
errorMessage,
browserServiceStatus,
configureExternalInstallation,
installCodexPlugin,
localServerStatus,
localWorkerStatus,
mcpClientConfig,
Expand All @@ -20,6 +21,7 @@ import {
targetDoctor,
type DoctorReport,
type BrowserServiceStatus,
type CodexPluginInstallResult,
type LocalServerStatus,
type LocalWorkerStatus,
type RuntimeManifest,
Expand Down Expand Up @@ -55,7 +57,8 @@ export function TargetSummary({ profile, validationError, checking, operationPen
const [browser, setBrowser] = useState<BrowserServiceStatus | null>(null);
const [worker, setWorker] = useState<LocalWorkerStatus | null>(null);
const [mcpConfig, setMcpConfig] = useState<string | null>(null);
const [busy, setBusy] = useState<'doctor' | 'config' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null);
const [codexSetup, setCodexSetup] = useState<CodexPluginInstallResult | null>(null);
const [busy, setBusy] = useState<'doctor' | 'config' | 'codex' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null);
const [runtimeFailure, setRuntimeFailure] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
Expand All @@ -81,6 +84,7 @@ export function TargetSummary({ profile, validationError, checking, operationPen
useEffect(() => {
setDoctor(null);
setMcpConfig(null);
setCodexSetup(null);
setRuntimeFailure(null);
if (!serverAvailable) {
setServer(null);
Expand Down Expand Up @@ -178,6 +182,19 @@ export function TargetSummary({ profile, validationError, checking, operationPen
}
}

async function setupCodex() {
setBusy('codex');
setRuntimeFailure(null);
setCodexSetup(null);
try {
setCodexSetup(await installCodexPlugin());
} catch (error) {
setRuntimeFailure(errorMessage(error, 'VidXP could not install the Codex plugin.'));
} finally {
setBusy(null);
}
}

async function openExternalSetup() {
setBusy('features');
setRuntimeFailure(null);
Expand Down Expand Up @@ -379,10 +396,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen
</Group>}

{mcpAvailable && <Group justify="space-between" className="runtimeControlRow">
<div><Text fw={650}>AI assistant integration</Text><Text size="sm" className="mutedText">Create the MCP setup needed to use VidXP from a compatible AI assistant.</Text></div>
<Button variant="light" leftSection={<IconTerminal2 size={17} />} loading={busy === 'config'} disabled={operationPending || busy !== null} onClick={() => void loadMcpConfig()}>Set up connection</Button>
<div><Text fw={650}>AI assistant integration</Text><Text size="sm" className="mutedText">Install VidXP's MCP server and skills in Codex, or copy the MCP setup for another compatible assistant.</Text></div>
<Group gap="xs" justify="flex-end">
<Button leftSection={<IconPlugConnected size={17} />} loading={busy === 'codex'} disabled={operationPending || busy !== null} onClick={() => void setupCodex()}>Set up in Codex</Button>
<Button variant="light" leftSection={<IconTerminal2 size={17} />} loading={busy === 'config'} disabled={operationPending || busy !== null} onClick={() => void loadMcpConfig()}>Copy MCP setup</Button>
</Group>
</Group>}

{codexSetup && <Alert color="teal" title="VidXP is installed in Codex">
<Text size="sm">{codexSetup.detail}</Text>
<details className="technicalDetails"><summary>Installation details</summary><Text size="xs">Plugin {codexSetup.plugin_version} from {codexSetup.marketplace_name}</Text>{codexSetup.installed_path && <Code className="pathCode">{codexSetup.installed_path}</Code>}</details>
</Alert>}

{serverAvailable && <Group justify="space-between" className="runtimeControlRow">
<div>
<Text fw={650}>App integration service</Text>
Expand Down
23 changes: 13 additions & 10 deletions desktop/src/tauri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
beginManagedSetup,
displayPath,
installRuntime,
installCodexPlugin,
configureExternalInstallation,
browserServiceStatus,
localServerStatus,
Expand Down Expand Up @@ -108,6 +109,7 @@ describe('desktop IPC adapter', () => {

await targetDoctor();
await mcpClientConfig();
await installCodexPlugin();
await localWorkerStatus();
await startLocalWorker();
await stopLocalWorker();
Expand All @@ -121,16 +123,17 @@ describe('desktop IPC adapter', () => {

expect(invoke).toHaveBeenNthCalledWith(1, 'target_doctor');
expect(invoke).toHaveBeenNthCalledWith(2, 'mcp_client_config');
expect(invoke).toHaveBeenNthCalledWith(3, 'local_worker_status');
expect(invoke).toHaveBeenNthCalledWith(4, 'start_local_worker');
expect(invoke).toHaveBeenNthCalledWith(5, 'stop_local_worker');
expect(invoke).toHaveBeenNthCalledWith(6, 'browser_service_status');
expect(invoke).toHaveBeenNthCalledWith(7, 'start_shared_browser');
expect(invoke).toHaveBeenNthCalledWith(8, 'stop_browser_service');
expect(invoke).toHaveBeenNthCalledWith(9, 'local_server_status');
expect(invoke).toHaveBeenNthCalledWith(10, 'start_local_server');
expect(invoke).toHaveBeenNthCalledWith(11, 'start_shared_server');
expect(invoke).toHaveBeenNthCalledWith(12, 'stop_local_server');
expect(invoke).toHaveBeenNthCalledWith(3, 'install_codex_plugin');
expect(invoke).toHaveBeenNthCalledWith(4, 'local_worker_status');
expect(invoke).toHaveBeenNthCalledWith(5, 'start_local_worker');
expect(invoke).toHaveBeenNthCalledWith(6, 'stop_local_worker');
expect(invoke).toHaveBeenNthCalledWith(7, 'browser_service_status');
expect(invoke).toHaveBeenNthCalledWith(8, 'start_shared_browser');
expect(invoke).toHaveBeenNthCalledWith(9, 'stop_browser_service');
expect(invoke).toHaveBeenNthCalledWith(10, 'local_server_status');
expect(invoke).toHaveBeenNthCalledWith(11, 'start_local_server');
expect(invoke).toHaveBeenNthCalledWith(12, 'start_shared_server');
expect(invoke).toHaveBeenNthCalledWith(13, 'stop_local_server');
});

it('adds optional surfaces to the selected existing installation', async () => {
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ export interface LocalWorkerStatus {
detail: string;
}

export interface CodexPluginInstallResult {
plugin_name: string;
plugin_id: string | null;
plugin_version: string;
marketplace_name: string;
marketplace_path: string;
installed_path: string | null;
detail: string;
}

interface WireInstallTransitionResult {
install: InstallRuntimeResult;
setup: WireTargetState;
Expand Down Expand Up @@ -374,6 +384,7 @@ export function configureExternalInstallation(capabilities: string[], surfaces:
return invoke<WireTargetState>('configure_external_installation', { capabilities, surfaces }).then(normalizeState);
}
export function mcpClientConfig(): Promise<string> { return invoke('mcp_client_config'); }
export function installCodexPlugin(): Promise<CodexPluginInstallResult> { return invoke('install_codex_plugin'); }
export function localWorkerStatus(): Promise<LocalWorkerStatus> { return invoke('local_worker_status'); }
export function startLocalWorker(): Promise<LocalWorkerStatus> { return invoke('start_local_worker'); }
export function stopLocalWorker(): Promise<LocalWorkerStatus> { return invoke('stop_local_worker'); }
Expand Down
14 changes: 12 additions & 2 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,18 @@ one browser tab. Closing a configured control panel hides it to the tray.
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 AI-assistant MCP config,
and presents the existing doctor result as product health rather than raw output.
lifecycle, reports the app integration service, creates copyable AI-assistant
MCP config, installs the bundled MCP-and-skills plugin directly 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.
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
Expand Down
Loading
Loading