build-cli-plugin
About
This skill guides developers in building CLI plugins or adapters using the abstract base class pattern. It covers defining the plugin contract, implementing idempotent install/uninstall operations, and choosing installation strategies like symlink or copy. Use it to add support for new frameworks to a CLI tool or to extend an existing plugin architecture.
Quick Install
Claude Code
Recommendednpx skills add pjt222/agent-almanac -a claude-code/plugin add https://github.com/pjt222/agent-almanacgit clone https://github.com/pjt222/agent-almanac.git ~/.claude/skills/build-cli-pluginCopy and paste this command in Claude Code to install this skill
Documentation
Build a CLI Plugin
Add new plugin or adapter to CLI tool's pluggable architecture using abstract base class pattern.
When Use
- Adding support for new target framework to CLI installer
- Building plugin system for multi-target command-line tool
- Extending existing adapter architecture with new strategy variant
- Porting content delivery to framework using different file layout
Inputs
- Required: Framework or target plugin supports (name, config paths, conventions)
- Required: Path to base class or plugin contract
- Required: Installation strategy:
symlink,copy,file-per-item, orappend-to-file - Optional: Content types plugin handles (e.g., skills only, skills + agents, full support)
- Optional: Scope support (project-level, global, both)
Steps
Step 1: Define the Contract
Base class establishes interface all plugins must implement:
export class FrameworkAdapter {
static id = 'base'; // Unique identifier
static displayName = 'Base'; // Human-readable name
static strategy = 'symlink'; // Installation strategy
static contentTypes = ['skill']; // What this adapter handles
async detect(projectDir) { return false; }
getTargetPath(projectDir, scope) { throw new Error('Not implemented'); }
async install(item, projectDir, scope, options) { throw new Error('Not implemented'); }
async uninstall(item, projectDir, scope, options) { throw new Error('Not implemented'); }
async listInstalled(projectDir, scope) { return []; }
async audit(projectDir, scope) { return { framework: this.constructor.displayName, ok: [], warnings: [], errors: [] }; }
supports(contentType) { return this.constructor.contentTypes.includes(contentType); }
}
Static fields define plugin's identity and capabilities:
id: Used in--framework <id>option and result reportingdisplayName: Shown in human-readable outputstrategy: Determines how content reaches targetcontentTypes: Filters which items this adapter receives
Base class doesn't exist yet? Create it first. Pattern scales to any number of plugins.
Got: Base class with static identity fields and abstract methods.
If fail: Base class has methods that don't apply to all plugins (e.g., not all frameworks support audit)? Provide default implementations returning sensible no-ops.
Step 2: Choose the Installation Strategy
| Strategy | When to use | Example |
|---|---|---|
| symlink | Target reads source files directly. Cheapest, stays in sync. | Claude Code reads .claude/skills/<name>/ symlinks |
| copy | Target needs files in its own directory. Modifications don't propagate. | Some IDEs index only their own dirs |
| file-per-item | Target expects one file per item with specific format. | Cursor .mdc rules files |
| append-to-file | Target reads a single instructions file. | Aider CONVENTIONS.md, Codex AGENTS.md |
Strategy determines implementation shape:
- Symlink:
symlinkSync(source, target)— handle relative vs absolute paths - Copy:
cpSync(source, target, { recursive: true })— handle overwrites - File-per-item:
writeFileSync(target, transform(content))— may need format conversion - Append-to-file: Wrap content in markers for idempotent insert/replace/remove
Got: Strategy selected with clear rationale based on how target framework discovers content.
If fail: Unsure? Check framework's documentation for how it discovers configuration or instruction files. Default to symlink if framework reads arbitrary directories.
Step 3: Implement Detection
Detection tells CLI which frameworks present in project:
// In detector.js — each rule checks for a filesystem marker
const RULES = [
{
id: 'my-framework',
displayName: 'My Framework',
check: (dir) => existsSync(resolve(dir, '.myframework/')),
marker: '.myframework/',
scope: 'project',
},
];
Detection strategies:
- Directory presence:
.claude/,.cursor/,.gemini/ - Config file:
opencode.json,.aider.conf.yml - Instruction file:
AGENTS.md,CONVENTIONS.md - Global markers:
~/.openclaw/,~/.hermes/
Always return marker in detection result so users can understand why framework was detected.
Got: Detection rule reliably identifies framework without false positives.
If fail: Framework has no unique marker (generic directory name)? Use combination of markers or require explicit --framework specification.
Step 4: Implement Install with Idempotency
async install(item, projectDir, scope, options) {
const targetDir = this.getTargetPath(projectDir, scope);
const targetPath = resolve(targetDir, item.id);
// Idempotency: skip if already installed (unless force)
if (existsSync(targetPath) && !options.force) {
return { action: 'skipped', path: targetPath };
}
if (options.dryRun) {
return { action: 'created', path: targetPath, details: 'dry-run' };
}
// Ensure parent directory exists
mkdirSync(targetDir, { recursive: true });
// Strategy-specific installation
if (this.constructor.strategy === 'symlink') {
const relPath = relative(targetDir, item.sourceDir);
symlinkSync(relPath, targetPath);
} else if (this.constructor.strategy === 'copy') {
cpSync(item.sourceDir, targetPath, { recursive: true });
}
return { action: 'created', path: targetPath };
}
Idempotency rules:
- Skip if target exists and
--forcenot set - Overwrite if
--forceset (remove first, then install) - Dry-run always succeeds with
action: 'created' - Return value must always be
{ action, path, details? }
Got: Install creates content at target path, skips if already present, respects --force and --dry-run.
If fail: Symlink creation fails on Windows/NTFS? Fall back to directory junction or copy. Log the fallback.
Step 5: Implement Uninstall with Cleanup
async uninstall(item, projectDir, scope, options) {
const targetDir = this.getTargetPath(projectDir, scope);
const targetPath = resolve(targetDir, item.id);
if (!existsSync(targetPath)) {
return { action: 'skipped', path: targetPath };
}
if (options.dryRun) {
return { action: 'removed', path: targetPath };
}
// Remove the installed content
rmSync(targetPath, { recursive: true });
return { action: 'removed', path: targetPath };
}
Cleanup considerations:
- Remove only what plugin installed — never delete user-created files
- For append-to-file: remove marked section, not entire file
- Leave parent directories intact (other plugins may use them)
Got: Uninstall removes only plugin's content and nothing else.
If fail: Removal fails (permissions, locked file)? Return error result instead of throwing.
Step 6: Implement Listing and Audit
async listInstalled(projectDir, scope) {
const targetDir = this.getTargetPath(projectDir, scope);
if (!existsSync(targetDir)) return [];
const entries = readdirSync(targetDir);
return entries.map(name => {
const fullPath = resolve(targetDir, name);
const broken = lstatSync(fullPath).isSymbolicLink()
&& !existsSync(fullPath);
return { id: name, type: 'skill', broken };
});
}
async audit(projectDir, scope) {
const items = await this.listInstalled(projectDir, scope);
const ok = items.filter(i => !i.broken);
const broken = items.filter(i => i.broken);
return {
framework: this.constructor.displayName,
ok: [`${ok.length} skills installed`],
warnings: [],
errors: broken.map(i => `Broken: ${i.id}`),
};
}
Got: Listing returns all installed items with broken-link detection. Audit summarizes health.
If fail: Target directory doesn't exist? Return empty results (not error — framework just has nothing installed).
Step 7: Register the Plugin
// In adapters/index.js
import { MyFrameworkAdapter } from './my-framework.js';
register(MyFrameworkAdapter);
Registration makes adapter available to:
- Auto-detection (
detectFrameworks()→getAdaptersForDetections()) - Explicit selection (
--framework my-framework) - Listing (
listAdapters())
Got: Adapter appears in tool detect output, can be targeted with --framework.
If fail: Adapter doesn't appear? Verify static id matches detection rule's id and that register() was called.
Step 8: Write Tests
describe('adapter: my-framework (dry-run)', () => {
it('targets the correct path', () => {
const out = run('install create-skill --framework my-framework --dry-run');
assert.match(out, /\.myframework/i);
});
});
Test at minimum: dry-run path, detection presence, content type support.
Got: Adapter-specific tests confirm installation path and behavior.
If fail: Framework isn't detected in CI (no marker directory)? Use --framework explicitly in tests.
Checks
- Plugin extends base class correctly
- Static fields (
id,displayName,strategy,contentTypes) set - Detection rule identifies framework without false positives
-
install()idempotent (skip if exists, respect--force) -
uninstall()removes only plugin-created content -
listInstalled()detects broken symlinks -
audit()reports health accurately - Plugin registered, appears in
tool detect - Dry-run tests pass
Pitfalls
- Forgetting relative vs absolute symlinks: Project-scope symlinks should be relative (portable). Global-scope symlinks should be absolute (not dependent on cwd).
- Not handling missing parent directories: Always
mkdirSync(dir, { recursive: true })before creating content. - Append-to-file without markers: Without idempotent markers (
<!-- start:id -->/<!-- end:id -->), repeated installs duplicate content. Always wrap appended content. - Detection false positives: Generic directory name (e.g.,
.config/) may match multiple frameworks. Use specific file markers inside directory. - Forgetting
supports()check: Installer callssupports(item.type)before dispatching. WrongcontentTypes? Adapter silently skips items.
See Also
scaffold-cli-command— build CLI commands using this plugintest-cli-application— testing patterns for CLI tools including adapter testsdesign-cli-output— terminal output for install/uninstall results
GitHub Repository
Related Skills
content-collections
MetaThis skill provides a production-tested setup for Content Collections, a TypeScript-first tool that transforms Markdown/MDX files into type-safe data collections with Zod validation. Use it when building blogs, documentation sites, or content-heavy Vite + React applications to ensure type safety and automatic content validation. It covers everything from Vite plugin configuration and MDX compilation to deployment optimization and schema validation.
polymarket
MetaThis skill enables developers to build applications with the Polymarket prediction markets platform, including API integration for trading and market data. It also provides real-time data streaming via WebSocket to monitor live trades and market activity. Use it for implementing trading strategies or creating tools that process live market updates.
creating-opencode-plugins
MetaThis skill helps developers create OpenCode plugins that hook into 25+ event types like commands, files, and LSP operations. It provides the plugin structure, event API specifications, and implementation patterns for JavaScript/TypeScript modules. Use it when you need to intercept, monitor, or extend the OpenCode AI assistant's lifecycle with custom event-driven logic.
sglang
MetaSGLang is a high-performance LLM serving framework that specializes in fast, structured generation for JSON, regex, and agentic workflows using its RadixAttention prefix caching. It delivers significantly faster inference, especially for tasks with repeated prefixes, making it ideal for complex, structured outputs and multi-turn conversations. Choose SGLang over alternatives like vLLM when you need constrained decoding or are building applications with extensive prefix sharing.
