From agent-almanac
Generates a plugin or adapter for a CLI tool using an abstract base class pattern with symlink/copy/append-to-file strategies and idempotent install/uninstall.
How this skill is triggered — by the user, by Claude, or both
Slash command
/agent-almanac:build-cli-pluginThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Add a new plugin or adapter to a CLI tool's pluggable architecture using the abstract base class pattern.
Add a new plugin or adapter to a CLI tool's pluggable architecture using the abstract base class pattern.
symlink, copy, file-per-item, or append-to-fileThe 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 receivesIf the base class does not exist yet, create it first. The pattern scales to any number of plugins.
Expected: A base class with static identity fields and abstract methods.
On failure: 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.
| 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:
symlinkSync(source, target) — handle relative vs. absolute pathscpSync(source, target, { recursive: true }) — handle overwriteswriteFileSync(target, transform(content)) — may need format conversionExpected: Strategy selected with clear rationale based on how the target framework discovers content.
On failure: If unsure, check the framework's documentation for how it discovers configuration or instruction files. Default to symlink if the framework reads arbitrary directories.
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:
.claude/, .cursor/, .gemini/opencode.json, .aider.conf.ymlAGENTS.md, CONVENTIONS.md~/.openclaw/, ~/.hermes/Always return the marker in the detection result so users can understand why a framework was detected.
Expected: A detection rule that reliably identifies the framework without false positives.
On failure: If the framework has no unique marker (generic directory name), use a combination of markers or require explicit --framework specification.
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:
--force is not set--force is set (remove first, then install)action: 'created'{ action, path, details? }Expected: Install creates content at the target path, skips if already present, respects --force and --dry-run.
On failure: If symlink creation fails on Windows/NTFS, fall back to directory junction or copy. Log the fallback.
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:
Expected: Uninstall removes only the plugin's content and nothing else.
On failure: If removal fails (permissions, locked file), return an error result instead of throwing.
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}`),
};
}
Expected: Listing returns all installed items with broken-link detection. Audit summarizes health.
On failure: If the target directory doesn't exist, return empty results (not an error — the framework just has nothing installed).
// In adapters/index.js
import { MyFrameworkAdapter } from './my-framework.js';
register(MyFrameworkAdapter);
Registration makes the adapter available to:
detectFrameworks() → getAdaptersForDetections())--framework my-framework)listAdapters())Expected: The adapter appears in tool detect output and can be targeted with --framework.
On failure: If the adapter doesn't appear, verify static id matches the detection rule's id and that register() was called.
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.
Expected: Adapter-specific tests confirm the installation path and behavior.
On failure: If the framework isn't detected in CI (no marker directory), use --framework explicitly in tests.
id, displayName, strategy, contentTypes) are setinstall() is idempotent (skip if exists, respect --force)uninstall() removes only plugin-created contentlistInstalled() detects broken symlinksaudit() reports health accuratelytool detectmkdirSync(dir, { recursive: true }) before creating content.<!-- start:id --> / <!-- end:id -->), repeated installs duplicate content. Always wrap appended content..config/) may match multiple frameworks. Use specific file markers inside the directory.supports() check: The installer calls supports(item.type) before dispatching. If contentTypes is wrong, the adapter silently skips items.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 resultsnpx claudepluginhub pjt222/agent-almanacGuides designing and building plugins for Claude Code and compatible tools, covering architecture, component selection, file structure, manifest configuration, and marketplace publishing.
Documents Claude Code plugin structure including directory layout, plugin.json manifest, commands/agents/skills/hooks organization, naming conventions, and auto-discovery.
Guides creation and organization of Claude Code plugins: directory layout, plugin.json manifest, component structure for commands, agents, skills, and hooks.