From agent-almanac
Builds plugins/adapters for CLI tools using abstract base class pattern: defines contracts (static fields/methods), installation strategies (symlink/copy/append-to-file), detection, idempotent install/uninstall, listing, auditing, registration. For new framework support or plugin systems.
npx claudepluginhub pjt222/agent-almanacThis skill is limited to using the following tools:
Add a new plugin or adapter to a CLI tool's pluggable architecture using the abstract base class pattern.
Guides designing Claude Code plugins: architecture patterns, component selection, file structures, manifest configuration, marketplace publishing. Use when planning, creating, or reviewing plugins.
Documents Claude Code plugin structure including directory layout, plugin.json manifest, commands/agents/skills/hooks organization, naming conventions, and auto-discovery.
Guides full Claude Code plugin lifecycle: scoping, initializing plugin.json, adding commands/agents/hooks/skills, validating with audits, and distributing to marketplaces.
Share bugs, ideas, or general feedback.
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 results