Skip to content
Closed
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
9 changes: 9 additions & 0 deletions skills/webcmd-adapter-author/references/adapter-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,24 @@ Then add `<site>` to the root `webcmd-plugin.json` `plugins` map:
}
```

This step is required, not optional — nothing in `webcmd plugin create`, `webcmd plugin install`, or `webcmd validate <site>` fails if it's skipped, so confirm it actually landed:

```bash
grep -q '"<site>"' webcmd-plugin.json || echo "MISSING: <site> not registered in root webcmd-plugin.json"
```

Before handing off, remove the private shadow and prove the plugin path works:

```bash
rm -rf ~/.webcmd/clis/<site>
webcmd plugin install file://$PWD/plugins/<site>
webcmd validate <site>
webcmd validate
webcmd <site> <command> --help
```

The bare `webcmd validate` (no target) also warns if `plugins/<site>` exists but was never added to the root `webcmd-plugin.json` `plugins` map.

## Minimal Registry Shape

Adapters register commands with `cli` and `Strategy` from `@agentrhq/webcmd/registry`.
Expand Down
67 changes: 67 additions & 0 deletions src/plugin-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
_getEnabledPlugins as getEnabledPlugins,
_parseVersion as parseVersion,
_satisfiesRange as satisfiesRange,
_findUnregisteredPlugins as findUnregisteredPlugins,
MANIFEST_FILENAME,
validatePluginAuthor,
type PluginManifest,
Expand Down Expand Up @@ -166,6 +167,72 @@ describe('getEnabledPlugins', () => {
});
});

// ── findUnregisteredPlugins ─────────────────────────────────────────────────

describe('findUnregisteredPlugins', () => {
let repoRoot: string;

beforeEach(() => {
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-repo-test-'));
});

afterEach(() => {
fs.rmSync(repoRoot, { recursive: true, force: true });
});

function writeRootManifest(manifest: PluginManifest): void {
fs.writeFileSync(path.join(repoRoot, MANIFEST_FILENAME), JSON.stringify(manifest));
}

function writePluginDir(name: string, manifest: Partial<PluginManifest> = {}): void {
const dir = path.join(repoRoot, 'plugins', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, MANIFEST_FILENAME), JSON.stringify({ name, version: '0.1.0', ...manifest }));
}

it('returns empty when there is no root manifest', () => {
expect(findUnregisteredPlugins(repoRoot)).toEqual([]);
});

it('returns empty when the root manifest is single-plugin (not a monorepo)', () => {
writeRootManifest({ name: 'solo', version: '1.0.0' });
writePluginDir('foo');
expect(findUnregisteredPlugins(repoRoot)).toEqual([]);
});

it('returns empty when plugins/ does not exist', () => {
writeRootManifest({ plugins: { foo: { path: 'plugins/foo' } } });
expect(findUnregisteredPlugins(repoRoot)).toEqual([]);
});

it('flags a plugin directory with its own manifest that is missing from the root map', () => {
writeRootManifest({ plugins: { foo: { path: 'plugins/foo' } } });
writePluginDir('foo');
writePluginDir('bar'); // on disk, never registered — the #222 scenario
expect(findUnregisteredPlugins(repoRoot)).toEqual(['bar']);
});

it('does not flag a plugin dir with no webcmd-plugin.json of its own', () => {
writeRootManifest({ plugins: { foo: { path: 'plugins/foo' } } });
fs.mkdirSync(path.join(repoRoot, 'plugins', 'scratch'), { recursive: true }); // no manifest inside
expect(findUnregisteredPlugins(repoRoot)).toEqual([]);
});

it('does not flag a disabled-but-registered plugin', () => {
writeRootManifest({ plugins: { foo: { path: 'plugins/foo', disabled: true } } });
writePluginDir('foo');
expect(findUnregisteredPlugins(repoRoot)).toEqual([]);
});

it('sorts multiple unregistered plugins', () => {
writeRootManifest({ plugins: { registered: { path: 'plugins/registered' } } });
writePluginDir('registered');
writePluginDir('zeta');
writePluginDir('alpha');
expect(findUnregisteredPlugins(repoRoot)).toEqual(['alpha', 'zeta']);
});
});

// ── parseVersion ────────────────────────────────────────────────────────────

describe('parseVersion', () => {
Expand Down
33 changes: 33 additions & 0 deletions src/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,38 @@ export function getEnabledPlugins(
.sort((a, b) => a.name.localeCompare(b.name));
}

/**
* Find plugin directories under `<repoRoot>/plugins` that have their own
* `webcmd-plugin.json` but aren't registered in the root manifest's `plugins`
* map. Registration is silently skippable today (see #222) — nothing in
* `webcmd plugin create`, `webcmd plugin install`, or `webcmd validate` warns
* when a promoted plugin never lands in the root manifest.
*
* Returns an empty array when the repo root has no monorepo manifest (e.g.
* inside an npm install, which doesn't ship `plugins/` or a root manifest).
*/
export function findUnregisteredPlugins(repoRoot: string): string[] {
const rootManifest = readPluginManifest(repoRoot);
if (!rootManifest || !isMonorepo(rootManifest)) return [];

const pluginsDir = path.join(repoRoot, 'plugins');
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
} catch {
return [];
}

const registered = new Set(Object.keys(rootManifest.plugins ?? {}));
const unregistered: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (readPluginManifest(path.join(pluginsDir, entry.name)) === null) continue;
if (!registered.has(entry.name)) unregistered.push(entry.name);
}
return unregistered.sort();
}

// ── Version compatibility ───────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -233,4 +265,5 @@ export {
checkCompatibility as _checkCompatibility,
parseVersion as _parseVersion,
satisfiesRange as _satisfiesRange,
findUnregisteredPlugins as _findUnregisteredPlugins,
};
16 changes: 16 additions & 0 deletions src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ describe('validate.ts pipeline step allowlist', () => {
}
});

it('untargeted validate does not flag the checked-out repo (currently in sync)', () => {
// This repo's plugins/*/webcmd-plugin.json are all registered in the
// root webcmd-plugin.json today — regression guard for #222: an
// untargeted `webcmd validate` should stay clean, and only add a
// "(webcmd-plugin.json)" row if drift is introduced.
const report = validateClisWithTarget([]);
const pluginRow = report.results.find(r => r.label === '(webcmd-plugin.json)');
expect(pluginRow).toBeUndefined();
});

it('targeted validate does not run the repo-wide plugin-registration check', () => {
const report = validateClisWithTarget([], 'validate-allowlist-test/all-steps');
const pluginRow = report.results.find(r => r.label === '(webcmd-plugin.json)');
expect(pluginRow).toBeUndefined();
});

it('newly registered step automatically appears in validator allowlist', () => {
const customStep = '__test_custom_step__';
expect(getRegisteredStepNames()).not.toContain(customStep);
Expand Down
95 changes: 64 additions & 31 deletions src/validate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/** Validate CLI definitions from the registry (JS-first). */
import { fileURLToPath } from 'node:url';
import { getRegistry, fullName, type CliCommand, type InternalCliCommand } from './registry.js';
import { getRegisteredStepNames } from './pipeline/registry.js';
import { findPackageRoot } from './package-paths.js';
import { findUnregisteredPlugins } from './plugin-manifest.js';
import { CLI_COMMAND } from './brand.js';

const MODULE_FILE = fileURLToPath(import.meta.url);

/**
* Pipeline step names — derived from the live pipeline registry on each
* validate call so a new step registered in src/pipeline/registry.ts (or by
Expand Down Expand Up @@ -38,48 +43,76 @@ export function validateClisWithTarget(_dirs: string[], target?: string): Valida
const registry = getRegistry();
const results: CommandValidationResult[] = [];
let errors = 0; let warnings = 0;
let commands = 0;

if (registry.size === 0) {
const r: CommandValidationResult = {
results.push({
label: '(registry)',
errors: [],
warnings: ['Registry is empty — no commands discovered. Did discoverClis() run?'],
};
return { ok: true, results: [r], errors: 0, warnings: 1, commands: 0 };
}

// Resolve alias target: if target is "site/alias", resolve to canonical "site/name"
let resolvedTarget = target;
if (target?.includes('/')) {
const cmd = registry.get(target);
if (cmd) resolvedTarget = fullName(cmd);
}
});
warnings += 1;
} else {
// Resolve alias target: if target is "site/alias", resolve to canonical "site/name"
let resolvedTarget = target;
if (target?.includes('/')) {
const cmd = registry.get(target);
if (cmd) resolvedTarget = fullName(cmd);
}

// Deduplicate: registry maps both canonical "site/name" and aliases to the same command
const seen = new Set<CliCommand>();

for (const [key, cmd] of registry) {
if (seen.has(cmd)) continue;
// Only validate via canonical key to avoid duplicates from aliases
if (key !== fullName(cmd)) continue;
seen.add(cmd);

// Target filter: "site" or "site/name"
if (resolvedTarget) {
if (resolvedTarget.includes('/')) {
if (key !== resolvedTarget) continue;
} else {
if (cmd.site !== resolvedTarget) continue;
// Deduplicate: registry maps both canonical "site/name" and aliases to the same command
const seen = new Set<CliCommand>();

for (const [key, cmd] of registry) {
if (seen.has(cmd)) continue;
// Only validate via canonical key to avoid duplicates from aliases
if (key !== fullName(cmd)) continue;
seen.add(cmd);

// Target filter: "site" or "site/name"
if (resolvedTarget) {
if (resolvedTarget.includes('/')) {
if (key !== resolvedTarget) continue;
} else {
if (cmd.site !== resolvedTarget) continue;
}
}

const r = validateCommand(cmd);
results.push(r);
errors += r.errors.length;
warnings += r.warnings.length;
commands += 1;
}
}

const r = validateCommand(cmd);
results.push(r);
errors += r.errors.length;
warnings += r.warnings.length;
// Repo-wide check, not scoped to a specific site — only run for the
// untargeted `webcmd validate` and only inside a monorepo checkout (no-op
// for npm installs, which ship neither `plugins/` nor a root manifest).
if (!target) {
const pluginResult = validateRootPluginRegistration();
if (pluginResult) {
results.push(pluginResult);
warnings += pluginResult.warnings.length;
}
}

return { ok: errors === 0, results, errors, warnings, commands: results.length };
return { ok: errors === 0, results, errors, warnings, commands };
}

function validateRootPluginRegistration(): CommandValidationResult | undefined {
const repoRoot = findPackageRoot(MODULE_FILE);
const unregistered = findUnregisteredPlugins(repoRoot);
if (unregistered.length === 0) return undefined;

return {
label: '(webcmd-plugin.json)',
errors: [],
warnings: unregistered.map(
(name) =>
`plugins/${name} has its own webcmd-plugin.json but is not registered in the root webcmd-plugin.json "plugins" map`,
),
};
}

function validateCommand(cmd: CliCommand): CommandValidationResult {
Expand Down