docs: update README for plugin system, add proxyModel tests

- Rewrite README Content Pipeline section as Plugin System section
  documenting built-in plugins (default, gate, content-pipeline),
  plugin hooks, and the relationship between gating and proxyModel
- Update all README examples to use --proxy-model instead of --gated
- Add unit tests: proxyModel normalization in JSON/YAML output (4 tests),
  Plugin Config section in describe output (2 tests)
- Add smoke tests: yaml/json output shows resolved proxyModel without
  gated field, round-trip compatibility (4 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 01:24:47 +00:00
parent f60d40a25b
commit d9d0a7a374
4 changed files with 224 additions and 40 deletions

View File

@@ -89,6 +89,44 @@ describe('describe command', () => {
expect(text).toContain('user-1');
});
it('shows project Plugin Config with proxyModel', async () => {
const deps = makeDeps({
id: 'proj-1',
name: 'gated-project',
description: 'A gated project',
ownerId: 'user-1',
proxyModel: 'default',
proxyMode: 'direct',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('Plugin Config:');
expect(text).toContain('Plugin:');
expect(text).toContain('default');
expect(text).not.toContain('Gated:');
});
it('shows project Plugin Config defaulting to "default" when proxyModel is empty', async () => {
const deps = makeDeps({
id: 'proj-1',
name: 'old-project',
description: '',
ownerId: 'user-1',
proxyModel: '',
gated: true,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('Plugin Config:');
expect(text).toContain('default');
});
it('shows secret detail with masked values', async () => {
const deps = makeDeps({
id: 'sec-1',

View File

@@ -335,4 +335,82 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'prompts']);
expect(deps.output[0]).toContain('No prompts found');
});
it('lists projects with PLUGIN column showing resolved proxyModel', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'home',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
ownerId: 'usr-1',
servers: [],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('PLUGIN');
expect(text).not.toContain('GATED');
// proxyModel is empty but gated=true, table shows 'default'
expect(text).toContain('default');
});
it('project JSON output resolves proxyModel from gated=true', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'home',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
ownerId: 'usr-1',
servers: [],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
expect(parsed[0]!.proxyModel).toBe('default');
expect(parsed[0]).not.toHaveProperty('gated');
});
it('project JSON output resolves proxyModel from gated=false', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'tools',
description: '',
proxyMode: 'direct',
proxyModel: '',
gated: false,
ownerId: 'usr-1',
servers: [],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
expect(parsed[0]!.proxyModel).toBe('content-pipeline');
expect(parsed[0]).not.toHaveProperty('gated');
});
it('project JSON output preserves explicit proxyModel and drops gated', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'custom',
description: '',
proxyMode: 'direct',
proxyModel: 'gate',
gated: true,
ownerId: 'usr-1',
servers: [],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '') as Array<Record<string, unknown>>;
expect(parsed[0]!.proxyModel).toBe('gate');
expect(parsed[0]).not.toHaveProperty('gated');
});
});

View File

@@ -142,5 +142,40 @@ describe('ProxyModel smoke tests', () => {
expect(output).toContain('Plugin Config');
expect(output).toContain('Plugin:');
});
it('mcpctl get projects -o yaml shows proxyModel and no gated field', async () => {
if (!available) return;
const output = await mcpctl('get projects -o yaml');
// proxyModel should be resolved (not empty)
expect(output).toContain('proxyModel:');
expect(output).not.toContain('gated:');
});
it('mcpctl get projects -o json shows proxyModel and no gated field', async () => {
if (!available) return;
const json = await mcpctl('get projects -o json');
const projects = JSON.parse(json) as Array<{ proxyModel?: string; gated?: boolean }>;
expect(projects.length).toBeGreaterThan(0);
for (const project of projects) {
expect(project.proxyModel).toBeDefined();
expect(project.proxyModel).not.toBe('');
expect(project).not.toHaveProperty('gated');
}
});
it('mcpctl get projects -o yaml is round-trip compatible with apply', async () => {
if (!available) return;
const yaml = await mcpctl('get projects -o yaml');
// Should contain kind and proxyModel (apply-compatible fields)
expect(yaml).toContain('kind: project');
expect(yaml).toContain('proxyModel:');
// Should not contain internal fields
expect(yaml).not.toContain('ownerId:');
expect(yaml).not.toContain('createdAt:');
});
});
});