Skip to content
Open
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
137 changes: 137 additions & 0 deletions packages/host/agent-adapter/src/__tests__/claude-code-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ClaudePluginCommand } from '../plugins/claude-code';
import { ClaudeCodePluginAdapter } from '../plugins/claude-code';

const tempRoots: string[] = [];

afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});

describe('ClaudeCodePluginAdapter', () => {
it('normalizes native marketplaces, install scopes, manifests, and component files', async () => {
const marketplaceRoot = await mkdtemp(join(tmpdir(), 'linkcode-claude-marketplace-'));
tempRoots.push(marketplaceRoot);
const packageRoot = join(marketplaceRoot, 'plugins', 'latex');
await Promise.all([
mkdir(join(packageRoot, '.claude-plugin'), { recursive: true }),
mkdir(join(packageRoot, 'skills', 'latex'), { recursive: true }),
mkdir(join(packageRoot, 'commands'), { recursive: true }),
mkdir(join(packageRoot, 'agents'), { recursive: true }),
mkdir(join(packageRoot, 'hooks'), { recursive: true }),
]);
await Promise.all([
writeFile(
join(packageRoot, '.claude-plugin', 'plugin.json'),
JSON.stringify({
name: 'latex',
version: '1.2.3',
description: 'Compile LaTeX documents',
author: { name: 'LinkCode' },
category: 'documents',
keywords: ['latex', 'pdf'],
}),
),
writeFile(join(packageRoot, 'commands', 'render.md'), '# Render'),
writeFile(join(packageRoot, 'agents', 'reviewer.md'), '# Review'),
writeFile(
join(packageRoot, 'hooks', 'hooks.json'),
JSON.stringify({ hooks: { PostToolUse: [] } }),
),
writeFile(join(packageRoot, '.mcp.json'), JSON.stringify({ mcpServers: { documents: {} } })),
]);
const command: ClaudePluginCommand = vi.fn((args) => {
if (args[1] === 'marketplace') {
return Promise.resolve([
{
name: 'team-tools',
source: 'directory',
path: marketplaceRoot,
installLocation: marketplaceRoot,
},
]);
}
return Promise.resolve({
installed: [
{
id: 'latex@team-tools',
version: '1.2.3',
scope: 'user',
enabled: true,
installPath: packageRoot,
},
{
id: 'latex@team-tools',
version: '1.2.3',
scope: 'project',
enabled: false,
installPath: packageRoot,
},
],
available: [],
});
});

const plugins = await new ClaudeCodePluginAdapter(command).list({ cwd: '/workspace/demo' });

expect(plugins).toEqual([
expect.objectContaining({
provider: 'claude-code',
id: 'latex@team-tools',
name: 'latex',
version: '1.2.3',
description: 'Compile LaTeX documents',
author: { name: 'LinkCode' },
category: 'documents',
keywords: ['latex', 'pdf'],
marketplace: {
name: 'team-tools',
path: marketplaceRoot,
},
source: { type: 'local', path: packageRoot },
installations: [
{
enabled: true,
version: '1.2.3',
scope: 'user',
path: packageRoot,
},
{
enabled: false,
version: '1.2.3',
scope: 'project',
path: packageRoot,
},
],
components: [
{ kind: 'agent', name: 'reviewer' },
{ kind: 'command', name: 'render' },
{ kind: 'hook', name: 'PostToolUse' },
{ kind: 'mcp-server', name: 'documents' },
{ kind: 'skill', name: 'latex' },
],
assets: [],
managementCapabilities: {
install: false,
uninstall: false,
update: false,
enable: false,
disable: false,
},
}),
]);
expect(command).toHaveBeenCalledWith(['plugin', 'list', '--available', '--json'], {
cwd: '/workspace/demo',
});
});

it('rejects malformed provider output at the boundary', async () => {
const command: ClaudePluginCommand = (args) =>
Promise.resolve(args[1] === 'marketplace' ? [] : { installed: 'invalid', available: [] });

await expect(new ClaudeCodePluginAdapter(command).list()).rejects.toThrow();
});
});
214 changes: 214 additions & 0 deletions packages/host/agent-adapter/src/__tests__/codex-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { noop } from 'foxts/noop';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CodexPluginServer, StartCodexPluginServer } from '../plugins/codex';
import { CodexPluginAdapter } from '../plugins/codex';

function codexInterface(displayName: string) {
return {
displayName,
shortDescription: 'Compile LaTeX documents',
longDescription: null,
developerName: 'LinkCode',
category: 'documents',
capabilities: ['skills', 'mcp'],
websiteUrl: 'https://example.com/latex',
privacyPolicyUrl: null,
termsOfServiceUrl: null,
};
}

afterEach(() => {
vi.useRealTimers();
});

describe('CodexPluginAdapter', () => {
it('maps local and remote marketplaces through plugin/list and plugin/read', async () => {
const request = vi.fn((method: string, params: unknown) => {
if (method === 'plugin/list') {
return Promise.resolve({
marketplaces: [
{
name: 'local-tools',
path: '/marketplaces/local-tools',
interface: { displayName: 'Local Tools' },
plugins: [
{
id: 'latex@local-tools',
remotePluginId: null,
version: '2.0.0',
localVersion: '1.9.0',
name: 'latex',
source: { type: 'local', path: '/plugins/latex' },
installed: true,
enabled: true,
availability: 'AVAILABLE',
interface: codexInterface('LaTeX'),
keywords: ['latex'],
},
],
},
{
name: 'cloud-tools',
path: null,
interface: null,
plugins: [
{
id: 'review@cloud-tools',
remotePluginId: 'remote-review-123',
version: '1.0.0',
localVersion: null,
name: 'review',
source: { type: 'remote' },
installed: false,
enabled: false,
availability: 'DISABLED_BY_ADMIN',
interface: {
...codexInterface('Review'),
websiteUrl: 'not-a-url',
},
keywords: [],
},
],
},
],
});
}
const pluginName =
typeof params === 'object' && params !== null && 'pluginName' in params
? params.pluginName
: undefined;
if (pluginName === 'latex') {
return Promise.resolve({
plugin: {
description: 'Compile documents with Tectonic',
skills: [
{
name: 'latex',
description: 'Compile a document',
enabled: true,
},
],
hooks: [{ key: 'render-complete', eventName: 'AfterTool' }],
apps: [],
appTemplates: [],
mcpServers: ['documents'],
},
});
}
return Promise.reject(new Error('remote detail unavailable'));
});
const close = vi.fn();
const server: CodexPluginServer = { request, close };
const startServer: StartCodexPluginServer = () => Promise.resolve(server);

const plugins = await new CodexPluginAdapter(startServer).list({ cwd: '/workspace/demo' });

expect(plugins).toEqual([
expect.objectContaining({
provider: 'codex',
id: 'latex@local-tools',
displayName: 'LaTeX',
description: 'Compile documents with Tectonic',
author: { name: 'LinkCode' },
marketplace: {
name: 'local-tools',
displayName: 'Local Tools',
path: '/marketplaces/local-tools',
},
source: { type: 'local', path: '/plugins/latex' },
installations: [
{
enabled: true,
version: '1.9.0',
path: '/plugins/latex',
},
],
components: [
{ kind: 'hook', name: 'render-complete', description: 'AfterTool' },
{ kind: 'mcp-server', name: 'documents' },
{
kind: 'skill',
name: 'latex',
description: 'Compile a document',
enabled: true,
},
],
managementCapabilities: {
install: false,
uninstall: false,
update: false,
enable: false,
disable: false,
},
}),
expect.objectContaining({
id: 'review@cloud-tools',
displayName: 'Review',
description: 'Compile LaTeX documents',
availability: 'blocked',
installations: [],
source: { type: 'remote' },
components: [],
}),
]);
expect(request).toHaveBeenCalledWith('plugin/list', { cwds: ['/workspace/demo'] });
expect(request).toHaveBeenCalledWith('plugin/read', {
pluginName: 'latex',
marketplacePath: '/marketplaces/local-tools',
});
expect(request).toHaveBeenCalledWith('plugin/read', {
pluginName: 'remote-review-123',
remoteMarketplaceName: 'cloud-tools',
});
expect(close).toHaveBeenCalledOnce();
});

it('closes the app-server when provider output is malformed', async () => {
const close = vi.fn();
const server: CodexPluginServer = {
request: () => Promise.resolve({ marketplaces: 'invalid' }),
close,
};

await expect(new CodexPluginAdapter(() => Promise.resolve(server)).list()).rejects.toThrow();
expect(close).toHaveBeenCalledOnce();
});

it('closes the app-server when plugin discovery exceeds its deadline', async () => {
vi.useFakeTimers();
let rejectRequest: (reason?: unknown) => void = noop;
const request = vi.fn(
() =>
new Promise<unknown>((_resolve, reject) => {
rejectRequest = reject;
}),
);
const close = vi.fn(() => rejectRequest(new Error('app-server closed')));
const server: CodexPluginServer = { request, close };

const discovery = new CodexPluginAdapter(() => Promise.resolve(server)).list();
const rejection = expect(discovery).rejects.toThrow('app-server closed');
await vi.advanceTimersByTimeAsync(30000);

await rejection;
expect(close).toHaveBeenCalledOnce();
});

it('applies the discovery deadline while the app-server is starting', async () => {
vi.useFakeTimers();
const startServer: StartCodexPluginServer = (signal) =>
new Promise((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(new Error('codex: plugin discovery timed out')),
{ once: true },
);
});

const discovery = new CodexPluginAdapter(startServer).list();
const rejection = expect(discovery).rejects.toThrow('plugin discovery timed out');
await vi.advanceTimersByTimeAsync(30000);

await rejection;
});
});
1 change: 1 addition & 0 deletions packages/host/agent-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { CodexAdapter } from './native/codex';
export { GrokBuildAdapter } from './native/grok-build';
export { OpenCodeAdapter } from './native/opencode';
export { PiAdapter } from './native/pi';
export * from './plugins';
export * from './probe';
export * from './registry';
export * from './util';
3 changes: 3 additions & 0 deletions packages/host/agent-adapter/src/native/codex/app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export interface CodexAppServerOptions {
/** Absolute path of the `codex` binary to spawn — resolved by the caller (runtime prober
* first, node_modules fallback) so this client stays free of resolution policy. */
binaryPath: string;
/** Abort the spawned process, including while the initialize handshake is still pending. */
signal?: AbortSignal;
/** Extra environment for the subprocess (e.g. CODEX_API_KEY); merged over the inherited env. */
env?: Record<string, string>;
onNotification: (method: string, params: unknown) => void;
Expand Down Expand Up @@ -138,6 +140,7 @@ export class CodexAppServer {
const child = spawn(opts.binaryPath, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...processEnv, ...opts.env },
signal: opts.signal,
windowsHide: true,
});
return CodexAppServer.attach(child, opts);
Expand Down
14 changes: 14 additions & 0 deletions packages/host/agent-adapter/src/plugins/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Plugin, PluginProvider } from '@linkcode/schema';

export interface PluginDiscoveryOptions {
/** Project root used by providers that expose repository-scoped marketplaces. */
cwd?: string;
}

/** Read-only provider boundary for discovering native plugin catalogs. */
export interface PluginProviderAdapter {
readonly provider: PluginProvider;
list(opts?: PluginDiscoveryOptions): Promise<Plugin[]>;
}

export type PluginProviderAdapterFactory = (provider: PluginProvider) => PluginProviderAdapter;
Loading