build-cli-plugin
关于
This skill provides a template for building CLI plugins or adapters using the abstract base class pattern. It covers defining plugin contracts, implementing installation strategies (symlink/copy/append), and handling detection, registration, and idempotent operations. Use it when extending CLI tools with new framework support or creating plugin systems for multi-target applications.
快速安装
Claude Code
推荐npx 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-plugin在 Claude Code 中复制并粘贴此命令以安装该技能
技能文档
Build a CLI Plugin
Add a new plugin or adapter to a CLI tool's pluggable architecture using the abstract base class pattern.
When to Use
- Adding support for a new target framework to a CLI installer
- Building a plugin system for a multi-target command-line tool
- Extending an existing adapter architecture with a new strategy variant
- Porting content delivery to a framework that uses a different file layout
Inputs
- Required: Framework or target the plugin supports (name, config paths, conventions)
- Required: Path to the base class or plugin contract
- Required: Installation strategy:
symlink,copy,file-per-item, orappend-to-file - Optional: Content types the plugin handles (e.g., skills only, skills + agents, full support)
- Optional: Scope support (project-level, global, both)
Procedure
Step 1: Define the Contract
The base class establishes the 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 the plugin's identity and capabilities:
id: Used in--framework <id>option and result reportingdisplayName: Shown in human-readable outputstrategy: Determines how content reaches the targetcontentTypes: Filters which items this adapter receives
If the base class does not exist yet, create it first. The pattern scales to any number of plugins.
Got: A base class with static identity fields and abstract methods.
If fail: If the base class has methods that don't apply to all plugins (e.g., not all frameworks support audit), provide default implementations that return 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 the 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 the target framework discovers content.
If fail: If unsure, check the framework's documentation for how it discovers configuration or instruction files. Default to symlink if the framework reads arbitrary directories.
Step 3: Implement Detection
Detection tells the CLI which frameworks are present in a 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 the marker in the detection result so users can understand why a framework was detected.
Got: A detection rule that reliably identifies the framework without false positives.
If fail: If the framework has no unique marker (generic directory name), use a 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
--forceis not set - Overwrite if
--forceis set (remove first, then install) - Dry-run always succeeds with
action: 'created' - Return value must always be
{ action, path, details? }
Got: Install creates content at the target path, skips if already present, respects --force and --dry-run.
If fail: If 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 the plugin installed — never delete user-created files
- For append-to-file: remove the marked section, not the entire file
- Leave parent directories intact (other plugins may use them)
Got: Uninstall removes only the plugin's content and nothing else.
If fail: If removal fails (permissions, locked file), return an 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: If the target directory doesn't exist, return empty results (not an error — the framework has nothing installed).
Step 7: Register the Plugin
// In adapters/index.js
import { MyFrameworkAdapter } from './my-framework.js';
register(MyFrameworkAdapter);
Registration makes the adapter available to:
- Auto-detection (
detectFrameworks()→getAdaptersForDetections()) - Explicit selection (
--framework my-framework) - Listing (
listAdapters())
Got: The adapter appears in tool detect output and can be targeted with --framework.
If fail: If the adapter doesn't appear, verify static id matches the 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, and content type support.
Got: Adapter-specific tests confirm the installation path and behavior.
If fail: If the framework isn't detected in CI (no marker directory), use --framework explicitly in tests.
Validation
- Plugin extends the base class correctly
- Static fields (
id,displayName,strategy,contentTypes) are set - Detection rule identifies the framework without false positives
-
install()is idempotent (skip if exists, respect--force) -
uninstall()removes only plugin-created content -
listInstalled()detects broken symlinks -
audit()reports health accurately - Plugin is registered and 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: A generic directory name (e.g.,
.config/) may match multiple frameworks. Use specific file markers inside the directory. - Forgetting
supports()check: The installer callssupports(item.type)before dispatching. IfcontentTypesis wrong, the adapter silently skips items.
Related Skills
scaffold-cli-command— build the CLI commands that use this plugintest-cli-application— testing patterns for CLI tools including adapter testsdesign-cli-output— terminal output for install/uninstall results
GitHub 仓库
相关推荐技能
content-collections
元Content Collections 是一个 TypeScript 优先的构建工具,可将本地 Markdown/MDX 文件转换为类型安全的数据集合。它专为构建博客、文档站和内容密集型 Vite+React 应用而设计,提供基于 Zod 的自动模式验证。该工具涵盖从 Vite 插件配置、MDX 编译到生产环境部署的完整工作流。
polymarket
元这个Claude Skill为开发者提供完整的Polymarket预测市场开发支持,涵盖API调用、交易执行和市场数据分析。关键特性包括实时WebSocket数据流,可监控实时交易、订单和市场动态。开发者可用它构建预测市场应用、实施交易策略并集成实时市场预测功能。
creating-opencode-plugins
元该Skill帮助开发者创建OpenCode插件,用于接入命令、文件、LSP等25+种事件。它提供了插件结构、事件API规范和JavaScript/TypeScript实现模式,适合需要拦截操作、扩展功能或自定义事件处理的场景。开发者可通过它快速构建响应式模块来增强OpenCode AI助手的能力。
sglang
元SGLang是一个专为LLM设计的高性能推理框架,特别适用于需要结构化输出的场景。它通过RadixAttention前缀缓存技术,在处理JSON、正则表达式、工具调用等具有重复前缀的复杂工作流时,能实现极速生成。如果你正在构建智能体或多轮对话系统,并追求远超vLLM的推理性能,SGLang是理想选择。
