Skip to content
Back to skills

Plugin Loader Design

ASecurity

Looked at the existing structure — it's a minimal `Command` interface (`{ description, run(args) }`) statically registered in `src/commands/index.ts` and dispatched from `src/cli.ts`. Here's the design I'd propose for letting third-party packages register commands via node_modules.

  • 17 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 25, 2026
ai-agentsnodedebugging

Works with

  • cli

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned September 25, 2026

npx -y skills add bendrucker/claude --skill plugin-loader-design --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Plugin Loader Design?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Plugin Loader Design
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/bendrucker-plugin-loader-design/badge)](https://www.skillsdirectory.com/skills/bendrucker-plugin-loader-design)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
Looked at the existing structure — it's a minimal `Command` interface (`{ description, run(args) }`) statically registered in `src/commands/index.ts` and dispatched from `src/cli.ts`. Here's the design I'd propose for letting third-party packages register commands via node_modules.

## Discovery: convention name + explicit manifest, not a full node_modules walk

Don't crawl the entire `node_modules` tree — walk the *declared* dependencies from the nearest `package.json` (cwd upward) and filter by a naming convention, same pattern as `eslint-plugin-*`/`babel-plugin-*`:

- Candidate packages: keys in `dependencies`/`devDependencies` matching `<cli-name>-plugin-*` or `@*/​<cli-name>-plugin-*`.
- For each candidate, resolve it with Node's resolver and dynamic `import()` it.

This keeps discovery to O(declared deps) instead of O(everything nested in node_modules), and it means a package only gets loaded if the project owner actually installed it — no surprise execution from transitive deps.

## Plugin contract

Ship the `Command` type (already in `commands/index.ts`) as a tiny published type-only entry point (e.g. `my-cli/plugin`), so third parties get typing without a runtime dependency on the CLI:

```ts
// my-cli/plugin (types only)
export interface Command {
  description: string;
  run(args: string[]): Promise<void>;
}
export interface CliPlugin {
  name: string;                    // namespace, e.g. "deploy"
  commands: Record<string, Command>;
}
```

A third-party package's entry point default-exports a `CliPlugin`:

```ts fragment
// my-cli-plugin-deploy/src/index.ts
const plugin: CliPlugin = {
  name: "deploy",
  commands: { deploy: { description: "Deploy the project", run: async (args) => { ... } } },
};
export default plugin;
```

## Namespacing

Plugin commands get exposed as `<pluginName>:<commandName>` (e.g. `deploy:run`), never as bare names. Built-ins keep bare names. This avoids silent shadowing — two plugins can't stomp on each other or on a built-in, and there's no ambiguity about where a command came from. Trade-off: slightly more typing for plugin users, but collisions in an open ecosystem are inevitable and silent overrides are worse.

## Loading & failure isolation

Each plugin is imported and validated independently, in parallel, wrapped so one broken plugin can't take down the CLI:

```ts
async function discoverPlugins(): Promise<Record<string, Command>> {
  const candidates = getDeclaredDeps().filter(isPluginName);
  const results = await Promise.allSettled(candidates.map(loadPlugin));
  const commands: Record<string, Command> = {};
  for (const [pkg, result] of zip(candidates, results)) {
    if (result.status === "rejected" || !isValidPlugin(result.value)) {
      console.error(`warning: skipping invalid plugin "${pkg}"`);
      continue;
    }
    const plugin = result.value;
    for (const [cmd, impl] of Object.entries(plugin.commands)) {
      commands[`${plugin.name}:${cmd}`] = impl;
    }
  }
  return commands;
}
```

## Integration into existing files

```
src/
  cli.ts                 # await loadCommands() instead of static import
  commands/index.ts       # built-ins only, unchanged
  plugins/discover.ts     # new: discoverPlugins()
  plugins/types.ts        # new: CliPlugin (published as my-cli/plugin)
```

`cli.ts` changes minimally since it already top-level-awaits:

```ts
const commands = { ...builtinCommands, ...(await discoverPlugins()) };
```

## Escape hatch

Add a `--no-plugins` flag / `MY_CLI_NO_PLUGINS` env var that skips discovery entirely — useful for CI reliability, debugging "which command came from where," and as defense-in-depth given this runs arbitrary installed code on every invocation.

---

I'd skip caching the discovered plugin list for now — the candidate-filtering step is already cheap and it's unnecessary complexity until you see real startup-time pain.

Want me to implement this against the current repo?

Files in this skill

  • sonnet-plain.md3 KB
  • sonnet-skill.md3.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.