This commit is contained in:
20
packages/sdk/package.json
Normal file
20
packages/sdk/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@waggle/sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "Waggle SDK — skill and plugin authoring toolkit",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/sdk/tests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "decision-framework",
|
||||
"name": "Decision Framework",
|
||||
"description": "Structured decision making with risk assessment and retrospectives",
|
||||
"skills": ["decision-matrix", "risk-assessment", "retrospective"]
|
||||
}
|
||||
51
packages/sdk/src/capability-packs/index.ts
Normal file
51
packages/sdk/src/capability-packs/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface CapabilityPack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the directory containing capability pack JSON manifests.
|
||||
* Resolves to the source directory containing .json files.
|
||||
*/
|
||||
export function getCapabilityPacksDir(): string {
|
||||
// When running from compiled output (dist/), go up to package root then into src/
|
||||
const srcDir = path.resolve(__dirname, '..', '..', 'src', 'capability-packs');
|
||||
if (fs.existsSync(srcDir) && fs.readdirSync(srcDir).some(f => f.endsWith('.json'))) {
|
||||
return srcDir;
|
||||
}
|
||||
// Fallback: same directory (running directly from source)
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all capability packs, sorted by name.
|
||||
*/
|
||||
export function listCapabilityPacks(): CapabilityPack[] {
|
||||
const dir = getCapabilityPacksDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => {
|
||||
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
return JSON.parse(content) as CapabilityPack;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single pack manifest by ID.
|
||||
*/
|
||||
export function getPackManifest(packId: string): CapabilityPack | null {
|
||||
const dir = getCapabilityPacksDir();
|
||||
const filePath = path.join(dir, `${packId}.json`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
6
packages/sdk/src/capability-packs/planning-master.json
Normal file
6
packages/sdk/src/capability-packs/planning-master.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "planning-master",
|
||||
"name": "Planning Master",
|
||||
"description": "Daily planning, task breakdown, and execution workflows",
|
||||
"skills": ["daily-plan", "task-breakdown", "plan-execute", "decision-matrix"]
|
||||
}
|
||||
6
packages/sdk/src/capability-packs/research-workflow.json
Normal file
6
packages/sdk/src/capability-packs/research-workflow.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "research-workflow",
|
||||
"name": "Research Workflow",
|
||||
"description": "Complete research pipeline: investigate, synthesize, explain",
|
||||
"skills": ["research-synthesis", "explain-concept", "research-team"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "team-collaboration",
|
||||
"name": "Team Collaboration",
|
||||
"description": "Catch up, share status, prep meetings, and pair review",
|
||||
"skills": ["catch-up", "status-update", "meeting-prep", "review-pair"]
|
||||
}
|
||||
6
packages/sdk/src/capability-packs/writing-suite.json
Normal file
6
packages/sdk/src/capability-packs/writing-suite.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "writing-suite",
|
||||
"name": "Writing Suite",
|
||||
"description": "Draft, compare, and extract actions from documents",
|
||||
"skills": ["draft-memo", "compare-docs", "extract-actions"]
|
||||
}
|
||||
16
packages/sdk/src/index.ts
Normal file
16
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { validateSkillMd, checkSkillDependencies, checkVersionDowngrade, isValidSemver, compareSemver } from './validate-skill.js';
|
||||
export type { SkillMetadata, ValidationResult } from './validate-skill.js';
|
||||
export { initSkill } from './init-skill.js';
|
||||
export { validatePluginManifest } from './plugin-manifest.js';
|
||||
export type { PluginManifest, ManifestValidation } from './plugin-manifest.js';
|
||||
export { PluginManager } from './plugin-manager.js';
|
||||
export { PluginRuntime, PluginRuntimeManager, webResearchPluginManifest } from './plugin-runtime.js';
|
||||
export type {
|
||||
PluginLifecycleState,
|
||||
PluginToolDef,
|
||||
PluginTool,
|
||||
PluginManifestWithTools,
|
||||
ActivationDependencies,
|
||||
} from './plugin-runtime.js';
|
||||
export { listStarterSkills, installStarterSkills, getStarterSkillsDir } from './starter-skills/index.js';
|
||||
export { listCapabilityPacks, getCapabilityPacksDir, getPackManifest, type CapabilityPack } from './capability-packs/index.js';
|
||||
38
packages/sdk/src/init-skill.ts
Normal file
38
packages/sdk/src/init-skill.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Scaffold a new skill directory with a SKILL.md template and package.json.
|
||||
*
|
||||
* @param dir - Parent directory where the skill folder will be created
|
||||
* @param name - Name of the skill (used as folder name and in metadata)
|
||||
*/
|
||||
export function initSkill(dir: string, name: string): void {
|
||||
const skillDir = join(dir, name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
|
||||
const skillMd = `---
|
||||
name: ${name}
|
||||
description: TODO — describe what this skill does
|
||||
version: 0.1.0
|
||||
author: anonymous
|
||||
---
|
||||
|
||||
You are a helpful assistant with the "${name}" skill.
|
||||
`;
|
||||
|
||||
const packageJson = JSON.stringify(
|
||||
{
|
||||
name: `@waggle-skill/${name}`,
|
||||
version: '0.1.0',
|
||||
description: `Waggle skill: ${name}`,
|
||||
type: 'module',
|
||||
license: 'MIT',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), skillMd, 'utf-8');
|
||||
writeFileSync(join(skillDir, 'package.json'), packageJson + '\n', 'utf-8');
|
||||
}
|
||||
127
packages/sdk/src/plugin-manager.ts
Normal file
127
packages/sdk/src/plugin-manager.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Plugin manager for installing, listing, and uninstalling Waggle plugins.
|
||||
* Uses a registry.json file in the plugins directory to track installed plugins.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { type PluginManifest, validatePluginManifest } from './plugin-manifest.js';
|
||||
import { PluginRuntimeManager } from './plugin-runtime.js';
|
||||
|
||||
interface PluginRegistry {
|
||||
plugins: Record<string, PluginManifest>;
|
||||
}
|
||||
|
||||
export class PluginManager {
|
||||
private readonly pluginsDir: string;
|
||||
private readonly registryPath: string;
|
||||
|
||||
constructor(pluginsDir: string) {
|
||||
this.pluginsDir = pluginsDir;
|
||||
this.registryPath = path.join(pluginsDir, 'registry.json');
|
||||
this.ensurePluginsDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all installed plugins.
|
||||
*/
|
||||
list(): PluginManifest[] {
|
||||
const registry = this.readRegistry();
|
||||
return Object.values(registry.plugins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a plugin from a local directory.
|
||||
* The source directory must contain a valid plugin.json manifest.
|
||||
* Copies the plugin into the plugins directory and registers it.
|
||||
*/
|
||||
installLocal(sourceDir: string): void {
|
||||
const manifestPath = path.join(sourceDir, 'plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`No plugin.json found in ${sourceDir}`);
|
||||
}
|
||||
|
||||
const rawManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>;
|
||||
const validation = validatePluginManifest(rawManifest);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid plugin manifest: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const manifest = rawManifest as unknown as PluginManifest;
|
||||
const destDir = path.join(this.pluginsDir, manifest.name);
|
||||
|
||||
// Copy plugin directory to plugins dir
|
||||
this.copyDirSync(sourceDir, destDir);
|
||||
|
||||
// Update registry
|
||||
const registry = this.readRegistry();
|
||||
registry.plugins[manifest.name] = manifest;
|
||||
this.writeRegistry(registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstalls a plugin by name.
|
||||
* Removes the plugin directory and its registry entry.
|
||||
*/
|
||||
uninstall(name: string): void {
|
||||
const registry = this.readRegistry();
|
||||
if (!(name in registry.plugins)) {
|
||||
throw new Error(`Plugin "${name}" is not installed`);
|
||||
}
|
||||
|
||||
// Remove plugin directory
|
||||
const pluginDir = path.join(this.pluginsDir, name);
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Update registry
|
||||
delete registry.plugins[name];
|
||||
this.writeRegistry(registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginRuntimeManager seeded with all installed plugins.
|
||||
* Each plugin is registered in the 'installed' state — call enable() to activate.
|
||||
*/
|
||||
toRuntimeManager(): PluginRuntimeManager {
|
||||
const manager = new PluginRuntimeManager();
|
||||
for (const manifest of this.list()) {
|
||||
manager.register(manifest);
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
private ensurePluginsDir(): void {
|
||||
if (!fs.existsSync(this.pluginsDir)) {
|
||||
fs.mkdirSync(this.pluginsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private readRegistry(): PluginRegistry {
|
||||
if (!fs.existsSync(this.registryPath)) {
|
||||
return { plugins: {} };
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(this.registryPath, 'utf-8')) as PluginRegistry;
|
||||
}
|
||||
|
||||
private writeRegistry(registry: PluginRegistry): void {
|
||||
fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
private copyDirSync(src: string, dest: string): void {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
this.copyDirSync(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
packages/sdk/src/plugin-manifest.ts
Normal file
108
packages/sdk/src/plugin-manifest.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Plugin manifest types and validation for the Waggle plugin system.
|
||||
*/
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
skills?: string[];
|
||||
mcpServers?: Array<{ name: string; command: string; args?: string[] }>;
|
||||
settingsSchema?: Record<string, unknown>;
|
||||
tools?: Array<{ name: string; description: string; parameters: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export interface ManifestValidation {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a plugin manifest object, ensuring required fields are present
|
||||
* and have the correct types.
|
||||
*/
|
||||
export function validatePluginManifest(manifest: Record<string, unknown>): ManifestValidation {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required string fields
|
||||
if (typeof manifest.name !== 'string' || manifest.name.trim() === '') {
|
||||
errors.push('name is required and must be a non-empty string');
|
||||
} else if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(manifest.name)) {
|
||||
errors.push('name must be a filesystem-safe plugin id (letters, numbers, dots, hyphens, and underscores only)');
|
||||
}
|
||||
|
||||
if (typeof manifest.version !== 'string' || manifest.version.trim() === '') {
|
||||
errors.push('version is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
if (typeof manifest.description !== 'string' || manifest.description.trim() === '') {
|
||||
errors.push('description is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
// Optional fields type checks
|
||||
if (manifest.skills !== undefined) {
|
||||
if (!Array.isArray(manifest.skills)) {
|
||||
errors.push('skills must be an array of strings');
|
||||
} else if (!manifest.skills.every((s: unknown) => typeof s === 'string')) {
|
||||
errors.push('skills must be an array of strings');
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.mcpServers !== undefined) {
|
||||
if (!Array.isArray(manifest.mcpServers)) {
|
||||
errors.push('mcpServers must be an array');
|
||||
} else {
|
||||
for (let i = 0; i < manifest.mcpServers.length; i++) {
|
||||
const server = manifest.mcpServers[i] as Record<string, unknown>;
|
||||
if (typeof server !== 'object' || server === null) {
|
||||
errors.push(`mcpServers[${i}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof server.name !== 'string' || (server.name as string).trim() === '') {
|
||||
errors.push(`mcpServers[${i}].name is required and must be a non-empty string`);
|
||||
}
|
||||
if (typeof server.command !== 'string' || (server.command as string).trim() === '') {
|
||||
errors.push(`mcpServers[${i}].command is required and must be a non-empty string`);
|
||||
}
|
||||
if (server.args !== undefined) {
|
||||
if (!Array.isArray(server.args) || !server.args.every((a: unknown) => typeof a === 'string')) {
|
||||
errors.push(`mcpServers[${i}].args must be an array of strings`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.settingsSchema !== undefined) {
|
||||
if (typeof manifest.settingsSchema !== 'object' || manifest.settingsSchema === null || Array.isArray(manifest.settingsSchema)) {
|
||||
errors.push('settingsSchema must be an object');
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.tools !== undefined) {
|
||||
if (!Array.isArray(manifest.tools)) {
|
||||
errors.push('tools must be an array');
|
||||
} else {
|
||||
for (let i = 0; i < manifest.tools.length; i++) {
|
||||
const tool = manifest.tools[i] as Record<string, unknown>;
|
||||
if (typeof tool !== 'object' || tool === null) {
|
||||
errors.push(`tools[${i}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (typeof tool.name !== 'string' || !(tool.name as string).trim()) {
|
||||
errors.push(`tools[${i}].name is required`);
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(tool.name as string)) {
|
||||
errors.push(`tools[${i}].name must be alphanumeric/hyphens/underscores only`);
|
||||
}
|
||||
if (typeof tool.description !== 'string' || !(tool.description as string).trim()) {
|
||||
errors.push(`tools[${i}].description is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
325
packages/sdk/src/plugin-runtime.ts
Normal file
325
packages/sdk/src/plugin-runtime.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Plugin runtime lifecycle management for Waggle plugins.
|
||||
*
|
||||
* Provides an in-memory state machine for plugin lifecycle (installed → enabled → active → disabled)
|
||||
* and tool/skill auto-registration. No filesystem interaction — that's PluginManager's job.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { PluginManifest } from './plugin-manifest.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Lifecycle states for a plugin runtime */
|
||||
export type PluginLifecycleState = 'installed' | 'enabled' | 'active' | 'disabled' | 'error';
|
||||
|
||||
/** Tool definition contributed by a plugin (mirrors agent ToolDefinition shape) */
|
||||
export interface PluginToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A fully-hydrated tool with an execute function, created during activation */
|
||||
export interface PluginTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute: (args: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Extended manifest that includes tool declarations */
|
||||
export interface PluginManifestWithTools extends PluginManifest {
|
||||
tools?: PluginToolDef[];
|
||||
}
|
||||
|
||||
/** Optional dependencies passed to activation */
|
||||
export interface ActivationDependencies {
|
||||
/** Custom tool executor factory — given a PluginToolDef, return an execute function */
|
||||
toolExecutor?: (def: PluginToolDef) => (args: Record<string, unknown>) => Promise<string>;
|
||||
/** Required capabilities that must be present for activation to succeed */
|
||||
requiredCapabilities?: string[];
|
||||
/** Capabilities available in the current environment */
|
||||
availableCapabilities?: string[];
|
||||
/** Path to the plugin directory (e.g., ~/.waggle/plugins/<name>/) for resolving tool implementations */
|
||||
pluginDir?: string;
|
||||
}
|
||||
|
||||
/** Events emitted by PluginRuntime */
|
||||
export interface PluginRuntimeEvents {
|
||||
stateChange: (event: { plugin: string; from: PluginLifecycleState; to: PluginLifecycleState }) => void;
|
||||
error: (event: { plugin: string; error: Error }) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntime — single plugin lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PluginRuntime extends EventEmitter {
|
||||
private state: PluginLifecycleState = 'installed';
|
||||
private readonly manifest: PluginManifestWithTools;
|
||||
private readonly deps: ActivationDependencies;
|
||||
private contributedTools: PluginTool[] = [];
|
||||
private contributedSkills: string[] = [];
|
||||
|
||||
constructor(manifest: PluginManifestWithTools, deps?: ActivationDependencies) {
|
||||
super();
|
||||
this.manifest = manifest;
|
||||
this.deps = deps ?? {};
|
||||
}
|
||||
|
||||
/** Current lifecycle state */
|
||||
getState(): PluginLifecycleState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** The plugin manifest */
|
||||
getManifest(): PluginManifestWithTools {
|
||||
return this.manifest;
|
||||
}
|
||||
|
||||
/** Tools contributed by this plugin (only populated when active) */
|
||||
getContributedTools(): PluginTool[] {
|
||||
return [...this.contributedTools];
|
||||
}
|
||||
|
||||
/** Skills contributed by this plugin (only populated when active) */
|
||||
getContributedSkills(): string[] {
|
||||
return [...this.contributedSkills];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the plugin and attempt activation.
|
||||
* Transitions: installed|disabled|error → enabled → active (or error).
|
||||
*/
|
||||
async enable(): Promise<void> {
|
||||
if (this.state === 'active' || this.state === 'enabled') {
|
||||
// Already active or mid-activation — no-op
|
||||
return;
|
||||
}
|
||||
if (this.state !== 'installed' && this.state !== 'disabled' && this.state !== 'error') {
|
||||
throw new Error(`Cannot enable plugin "${this.manifest.name}" from state "${this.state}"`);
|
||||
}
|
||||
this.transition('enabled');
|
||||
await this.activate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the plugin — register tools and skills.
|
||||
* Called internally by enable(). Transitions enabled → active or enabled → error.
|
||||
*/
|
||||
private async activate(): Promise<void> {
|
||||
try {
|
||||
// Check required capabilities
|
||||
const required = this.deps.requiredCapabilities ?? [];
|
||||
const available = this.deps.availableCapabilities ?? [];
|
||||
for (const cap of required) {
|
||||
if (!available.includes(cap)) {
|
||||
throw new Error(`Missing required capability: ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build contributed tools
|
||||
const toolDefs = this.manifest.tools ?? [];
|
||||
const executor = this.deps.toolExecutor ?? ((d: PluginToolDef) => makePluginToolExecutor(d, this.deps.pluginDir));
|
||||
this.contributedTools = toolDefs.map((def) => ({
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
execute: executor(def),
|
||||
}));
|
||||
|
||||
// Register contributed skills
|
||||
this.contributedSkills = [...(this.manifest.skills ?? [])];
|
||||
|
||||
this.transition('active');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.transition('error');
|
||||
this.emit('error', { plugin: this.manifest.name, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the plugin — remove tools and skills.
|
||||
* Transitions: active|enabled|error → disabled.
|
||||
*/
|
||||
disable(): void {
|
||||
if (this.state === 'disabled' || this.state === 'installed') {
|
||||
// Already disabled or never enabled — no-op
|
||||
return;
|
||||
}
|
||||
this.contributedTools = [];
|
||||
this.contributedSkills = [];
|
||||
this.transition('disabled');
|
||||
}
|
||||
|
||||
private transition(to: PluginLifecycleState): void {
|
||||
const from = this.state;
|
||||
this.state = to;
|
||||
this.emit('stateChange', { plugin: this.manifest.name, from, to });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default tool executor (stub — returns plugin-name-prefixed acknowledgement)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a real tool executor for a plugin.
|
||||
* Resolution: pluginDir/tools/<tool-name>.js → .cjs → stub fallback.
|
||||
*/
|
||||
function makePluginToolExecutor(
|
||||
def: PluginToolDef,
|
||||
pluginDir?: string,
|
||||
): (args: Record<string, unknown>) => Promise<string> {
|
||||
if (pluginDir) {
|
||||
const slug = def.name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const candidates = [
|
||||
join(pluginDir, 'tools', `${slug}.js`),
|
||||
join(pluginDir, 'tools', `${slug}.cjs`),
|
||||
join(pluginDir, 'tools', `${def.name}.js`),
|
||||
];
|
||||
const toolFile = candidates.find(f => existsSync(f));
|
||||
if (toolFile) {
|
||||
return async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const mod = await import(toolFile) as { execute?: (a: Record<string, unknown>) => Promise<string | unknown> };
|
||||
if (typeof mod.execute !== 'function') return JSON.stringify({ error: `Tool "${def.name}" does not export execute()` });
|
||||
const result = await mod.execute(args);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
} catch (err) {
|
||||
return JSON.stringify({ error: `Tool "${def.name}" failed: ${(err as Error).message}` });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
// Stub fallback
|
||||
return async (args) => JSON.stringify({ tool: def.name, args, status: 'executed' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntimeManager — manages multiple plugin runtimes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PluginRuntimeManager extends EventEmitter {
|
||||
private readonly plugins = new Map<string, PluginRuntime>();
|
||||
|
||||
/**
|
||||
* Register a plugin in the `installed` state.
|
||||
* Returns the created PluginRuntime.
|
||||
*/
|
||||
register(manifest: PluginManifestWithTools, deps?: ActivationDependencies): PluginRuntime {
|
||||
if (this.plugins.has(manifest.name)) {
|
||||
throw new Error(`Plugin "${manifest.name}" is already registered`);
|
||||
}
|
||||
const runtime = new PluginRuntime(manifest, deps);
|
||||
// Forward events
|
||||
runtime.on('stateChange', (event) => this.emit('stateChange', event));
|
||||
runtime.on('error', (event) => this.emit('error', event));
|
||||
this.plugins.set(manifest.name, runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a plugin by name.
|
||||
*/
|
||||
async enable(name: string): Promise<void> {
|
||||
const runtime = this.getRuntime(name);
|
||||
await runtime.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a plugin by name.
|
||||
*/
|
||||
disable(name: string): void {
|
||||
const runtime = this.getRuntime(name);
|
||||
runtime.disable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all active plugin runtimes.
|
||||
*/
|
||||
getActive(): PluginRuntime[] {
|
||||
return [...this.plugins.values()].filter((p) => p.getState() === 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate tools from all active plugins.
|
||||
*/
|
||||
getAllTools(): PluginTool[] {
|
||||
return this.getActive().flatMap((p) => p.getContributedTools());
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate skills from all active plugins.
|
||||
*/
|
||||
getAllSkills(): string[] {
|
||||
return this.getActive().flatMap((p) => p.getContributedSkills());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return state summary for all registered plugins.
|
||||
*/
|
||||
getPluginStates(): Record<string, PluginLifecycleState> {
|
||||
const states: Record<string, PluginLifecycleState> = {};
|
||||
for (const [name, runtime] of this.plugins) {
|
||||
states[name] = runtime.getState();
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific plugin runtime by name.
|
||||
*/
|
||||
getRuntime(name: string): PluginRuntime {
|
||||
const runtime = this.plugins.get(name);
|
||||
if (!runtime) {
|
||||
throw new Error(`Plugin "${name}" is not registered`);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flagship plugin fixture — web-research
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Web research plugin manifest (test fixture / example) */
|
||||
export const webResearchPluginManifest: PluginManifestWithTools = {
|
||||
name: 'web-research',
|
||||
version: '1.0.0',
|
||||
description: 'Web research tools for scraping and summarizing web content',
|
||||
skills: ['web-research'],
|
||||
tools: [
|
||||
{
|
||||
name: 'web_scrape',
|
||||
description: 'Scrape content from a given URL and return the text',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The URL to scrape' },
|
||||
selector: { type: 'string', description: 'Optional CSS selector to extract specific content' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'web_summarize',
|
||||
description: 'Summarize the content of a web page given its URL',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'The URL to summarize' },
|
||||
maxLength: { type: 'number', description: 'Maximum summary length in characters' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
39
packages/sdk/src/starter-skills/brainstorm.md
Normal file
39
packages/sdk/src/starter-skills/brainstorm.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Brainstorm — Structured Ideation with Convergence
|
||||
|
||||
Facilitate a structured brainstorming session that moves from wide-open ideation to prioritized, actionable ideas.
|
||||
|
||||
## What to do
|
||||
|
||||
Run three distinct phases. Announce each phase transition clearly.
|
||||
|
||||
### Phase 1: Diverge (Generate)
|
||||
|
||||
- Generate as many ideas as possible related to the topic
|
||||
- No filtering, no judgment — quantity over quality
|
||||
- Include obvious ideas, wild ideas, and combinations
|
||||
- Build on workspace context and memory for relevant starting points
|
||||
- Aim for 15-25 ideas minimum
|
||||
- Present as a rapid-fire numbered list
|
||||
|
||||
### Phase 2: Cluster (Organize)
|
||||
|
||||
- Group related ideas into 3-6 themes
|
||||
- Name each cluster with a clear label
|
||||
- Identify which ideas overlap or reinforce each other
|
||||
- Note outlier ideas that do not fit any cluster — these are sometimes the most interesting
|
||||
- Present clusters with their member ideas
|
||||
|
||||
### Phase 3: Converge (Prioritize)
|
||||
|
||||
- Evaluate each cluster on feasibility and impact
|
||||
- Rank the top 3-5 ideas overall with brief rationale
|
||||
- For the top idea, sketch a rough next step
|
||||
- Ask the user which direction resonates most
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep the energy high in Phase 1 — do not self-censor
|
||||
- In Phase 2, let the user adjust clusters before moving on
|
||||
- In Phase 3, be honest about tradeoffs — do not oversell any idea
|
||||
- Pull from workspace context to ground ideas in what the user is actually working on
|
||||
- If the topic is vague, ask one clarifying question before starting Phase 1
|
||||
28
packages/sdk/src/starter-skills/catch-up.md
Normal file
28
packages/sdk/src/starter-skills/catch-up.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Catch-Up — Workspace Restart Summary
|
||||
|
||||
When the user returns to a workspace after time away, provide an instant orientation summary.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Check session history** — Review the most recent sessions in this workspace. Identify what was being worked on, where things left off, and any unfinished threads.
|
||||
|
||||
2. **Surface key decisions** — Pull any decisions made in recent sessions. State each decision clearly: what was decided, why, and what alternatives were considered.
|
||||
|
||||
3. **List open threads** — Identify work that was started but not completed. For each thread: what it is, where it stands, and what the next step would be.
|
||||
|
||||
4. **Suggest next actions** — Based on open threads and recent momentum, recommend 2-3 concrete next steps the user could take right now.
|
||||
|
||||
## Output format
|
||||
|
||||
Structure your response as:
|
||||
|
||||
- **Last active**: When the workspace was last used
|
||||
- **Context**: 2-3 sentence summary of what this workspace is about
|
||||
- **Recent progress**: Bullet list of what was accomplished
|
||||
- **Key decisions**: Any decisions made with brief rationale
|
||||
- **Open threads**: Unfinished work with status
|
||||
- **Suggested next steps**: 2-3 actionable items, ordered by priority
|
||||
|
||||
## Tone
|
||||
|
||||
Be concise and oriented toward action. The user wants to get back into flow quickly, not read a novel. Lead with the most important information. If nothing significant has happened, say so briefly.
|
||||
44
packages/sdk/src/starter-skills/code-review.md
Normal file
44
packages/sdk/src/starter-skills/code-review.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
permissions:
|
||||
fileSystem: true
|
||||
---
|
||||
|
||||
# Code Review — Structured Code Analysis
|
||||
|
||||
Perform a thorough, structured code review with actionable feedback organized by priority.
|
||||
|
||||
## What to do
|
||||
|
||||
When the user shares code or points to files, review systematically across these dimensions:
|
||||
|
||||
1. **Correctness** — Does the code do what it claims to do? Check logic errors, off-by-one errors, null/undefined handling, async/await correctness, error propagation.
|
||||
|
||||
2. **Edge cases** — What inputs or conditions could break this? Empty arrays, null values, concurrent access, very large inputs, network failures, timeout scenarios.
|
||||
|
||||
3. **Naming and clarity** — Are variables, functions, and types named clearly? Could someone unfamiliar with the codebase understand the intent?
|
||||
|
||||
4. **Tests** — Are there tests? Do they cover the important paths? Are edge cases tested? Suggest specific test cases that are missing.
|
||||
|
||||
5. **Security** — Input validation, injection risks, authentication checks, sensitive data exposure, dependency vulnerabilities.
|
||||
|
||||
6. **Performance** — Unnecessary loops, N+1 queries, missing indexes, large allocations, blocking operations in async contexts.
|
||||
|
||||
## Output structure
|
||||
|
||||
Organize findings by severity:
|
||||
|
||||
- **Critical**: Bugs, security issues, data loss risks — must fix
|
||||
- **Important**: Logic issues, missing error handling, test gaps — should fix
|
||||
- **Suggestion**: Style improvements, refactoring opportunities, alternative approaches — nice to fix
|
||||
|
||||
For each finding:
|
||||
- **Location**: File and line reference
|
||||
- **Issue**: What is wrong
|
||||
- **Fix**: Specific suggestion for how to resolve it
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be specific — reference exact lines and provide concrete fix suggestions
|
||||
- Praise good patterns too, not just problems
|
||||
- Keep suggestions proportional — do not nitpick style in code with logic bugs
|
||||
- If the code is solid, say so confidently
|
||||
35
packages/sdk/src/starter-skills/compare-docs.md
Normal file
35
packages/sdk/src/starter-skills/compare-docs.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Compare Docs — Side-by-Side Document Comparison
|
||||
|
||||
Identify and highlight differences between two documents or versions of content.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Accept two inputs** — Ask the user for the two documents, text blocks, or file references to compare. These can be different versions of the same document, competing proposals, or any two pieces of text.
|
||||
|
||||
2. **Analyze differences** — Compare the documents and categorize changes:
|
||||
- **Additions**: Content present in the second but not the first
|
||||
- **Removals**: Content present in the first but not the second
|
||||
- **Changes**: Content that exists in both but was modified
|
||||
- **Moved**: Content that appears in both but in different locations
|
||||
|
||||
3. **Highlight significance** — Not all changes matter equally. Flag:
|
||||
- **Substantive changes**: Meaning, facts, or commitments that changed
|
||||
- **Structural changes**: Reorganization, new/removed sections
|
||||
- **Minor edits**: Wording, formatting, typos
|
||||
|
||||
4. **Identify conflicts** — If the documents represent parallel edits, note where changes conflict and suggest a resolution.
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Overview**: Summary of change magnitude (minor edits / significant revision / major rewrite)
|
||||
- **Key changes**: The most important differences, explained
|
||||
- **Detailed comparison**: Section-by-section breakdown
|
||||
- **Conflicts**: Any contradictions between the documents
|
||||
- **Recommendation**: If applicable, which version is stronger and why
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Present the most significant changes first
|
||||
- Use clear formatting to distinguish additions, removals, and changes
|
||||
- If documents are very similar, focus on the few meaningful differences
|
||||
- If documents are very different, summarize themes rather than listing every change
|
||||
40
packages/sdk/src/starter-skills/daily-plan.md
Normal file
40
packages/sdk/src/starter-skills/daily-plan.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Daily Plan — Morning Planning from Context
|
||||
|
||||
Help the user plan their day by reviewing workspace context, recent progress, and priorities.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Review yesterday** — Check recent session history and memory. Summarize what was accomplished and what was left unfinished.
|
||||
|
||||
2. **Surface priorities** — Identify the most important items based on:
|
||||
- Unfinished tasks from yesterday
|
||||
- Upcoming deadlines mentioned in workspace context
|
||||
- Open threads that need attention
|
||||
- Blockers that need resolution
|
||||
|
||||
3. **Build today's plan** — Create a prioritized task list for the day. For each item:
|
||||
- What to do (specific and actionable)
|
||||
- Why it matters (priority rationale)
|
||||
- Estimated time needed
|
||||
- Best time to tackle it (deep work vs. quick win)
|
||||
|
||||
4. **Suggest time allocation** — Recommend how to structure the day:
|
||||
- Start with a quick win for momentum, or
|
||||
- Start with the hardest item while energy is high
|
||||
- Group related tasks together
|
||||
- Leave buffer time for unexpected items
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Yesterday's progress**: Brief recap (3-5 bullets)
|
||||
- **Carry-over items**: Unfinished work from yesterday
|
||||
- **Today's priorities**: Ranked list with time estimates
|
||||
- **Suggested schedule**: Rough time blocks
|
||||
- **End-of-day goal**: What "a good day" looks like
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep the plan realistic — do not overload the day
|
||||
- Account for meetings and interruptions (ask if the user has any)
|
||||
- Highlight the single most important thing to accomplish today
|
||||
- If the workspace is sparse, ask the user what they want to focus on
|
||||
34
packages/sdk/src/starter-skills/decision-matrix.md
Normal file
34
packages/sdk/src/starter-skills/decision-matrix.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Decision Matrix — Weighted Option Comparison
|
||||
|
||||
Help the user compare options systematically using weighted criteria and structured scoring.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Identify the decision** — Clarify what is being decided. What are the options? If the user is vague, ask focused questions to surface the real choices.
|
||||
|
||||
2. **Define criteria** — Work with the user to list evaluation criteria (e.g., cost, speed, quality, risk, effort). Aim for 4-8 criteria.
|
||||
|
||||
3. **Weight criteria** — Ask the user to assign importance weights (1-5 or percentages). If they are unsure, suggest reasonable defaults based on the context.
|
||||
|
||||
4. **Score each option** — Rate each option against each criterion on a 1-5 scale. Explain the rationale for each score briefly.
|
||||
|
||||
5. **Calculate and present** — Compute weighted scores. Present as a table with options as rows and criteria as columns. Show raw scores, weighted scores, and totals.
|
||||
|
||||
6. **Make a recommendation** — State which option scores highest and why. Flag any criteria where the top option is notably weak. Mention if scores are very close.
|
||||
|
||||
## Output format
|
||||
|
||||
```
|
||||
| Criteria (weight) | Option A | Option B | Option C |
|
||||
|-------------------|----------|----------|----------|
|
||||
| Cost (5) | 4 (20) | 2 (10) | 3 (15) |
|
||||
| Speed (3) | 3 (9) | 5 (15) | 4 (12) |
|
||||
| Total | 29 | 25 | 27 |
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Surface hidden criteria the user may not have considered
|
||||
- Call out when a decision is clear-cut vs. genuinely close
|
||||
- Note if any option has a dealbreaker score on a critical criterion
|
||||
- Offer sensitivity analysis: "If you weighted X higher, Option B wins instead"
|
||||
30
packages/sdk/src/starter-skills/draft-memo.md
Normal file
30
packages/sdk/src/starter-skills/draft-memo.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Draft Memo — Structured Document from Context
|
||||
|
||||
Turn accumulated workspace context into a polished, structured memo ready for sharing or review.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Gather context** — Pull from workspace memory, recent conversations, files, and session history. Identify the core topic and key information.
|
||||
|
||||
2. **Organize into sections** — Structure the memo with clear headings. Default structure:
|
||||
- **Summary** — 2-3 sentence executive overview
|
||||
- **Key Points** — The essential takeaways (bulleted)
|
||||
- **Details** — Supporting information, evidence, data
|
||||
- **Next Steps** — Recommended actions with owners if known
|
||||
|
||||
3. **Synthesize, don't just copy** — Combine information from multiple sources into a coherent narrative. Resolve contradictions. Highlight where information is uncertain or incomplete.
|
||||
|
||||
4. **Match the audience** — Ask who the memo is for if unclear. Adjust tone and detail level: executive (brief, decision-focused), team (action-focused), technical (detail-rich).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep sentences short and direct
|
||||
- Use bullet points for lists of 3+ items
|
||||
- Bold key terms and decisions
|
||||
- Include dates and specifics where available
|
||||
- Flag assumptions explicitly
|
||||
- If workspace context is thin, say what additional information would strengthen the memo
|
||||
|
||||
## Output
|
||||
|
||||
Produce the memo in clean markdown. Offer to adjust tone, length, or structure after the first draft.
|
||||
30
packages/sdk/src/starter-skills/explain-concept.md
Normal file
30
packages/sdk/src/starter-skills/explain-concept.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Explain Concept — Adjustable-Depth Explanation
|
||||
|
||||
Explain concepts clearly at the depth level the user needs, with examples and connections to existing knowledge.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Identify the concept** — Clarify exactly what the user wants to understand. If the topic is broad, ask which aspect they care about most.
|
||||
|
||||
2. **Choose depth level** — Ask the user or infer from context:
|
||||
- **ELI5**: Simple analogy, no jargon, core idea only. Suitable for complete beginners or quick orientation.
|
||||
- **Technical**: Accurate terminology, how it works, tradeoffs, practical usage. Suitable for practitioners.
|
||||
- **Expert**: Deep mechanics, edge cases, comparison with alternatives, academic or engineering depth.
|
||||
|
||||
3. **Explain** — Structure the explanation as:
|
||||
- **One-sentence summary**: The concept in a single clear sentence
|
||||
- **Core explanation**: The main explanation at the chosen depth
|
||||
- **Example**: A concrete, relatable example
|
||||
- **Analogy**: A comparison to something familiar (especially for ELI5)
|
||||
- **Common misconceptions**: What people often get wrong about this
|
||||
|
||||
4. **Connect to context** — Check workspace memory for related concepts the user has explored. Link this concept to things they already know.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Start simple, then add complexity — do not front-load jargon
|
||||
- Use concrete examples before abstract definitions
|
||||
- If the user asks a follow-up, adjust depth accordingly
|
||||
- Admit when a concept is genuinely complex — do not oversimplify to the point of being wrong
|
||||
- For technical concepts, include when and why you would use this in practice
|
||||
- Offer to go deeper on any sub-topic
|
||||
42
packages/sdk/src/starter-skills/extract-actions.md
Normal file
42
packages/sdk/src/starter-skills/extract-actions.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Extract Actions — Pull Action Items from Text
|
||||
|
||||
Extract actionable tasks from unstructured text such as meeting notes, emails, conversations, or documents.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Accept the text** — Take meeting notes, email threads, conversation logs, or any text containing implicit or explicit action items.
|
||||
|
||||
2. **Identify action items** — Scan for:
|
||||
- Explicit commitments ("I will...", "We need to...", "Action: ...")
|
||||
- Implicit tasks ("We should consider...", "It would be good to...")
|
||||
- Follow-ups ("Let's revisit...", "Circle back on...")
|
||||
- Decisions that require implementation
|
||||
- Questions that need answers
|
||||
|
||||
3. **Structure each action** — For every action item, extract:
|
||||
- **Action**: Clear, specific task description (start with a verb)
|
||||
- **Owner**: Who is responsible (name or role, or "Unassigned" if unclear)
|
||||
- **Deadline**: When it is due (or "No deadline" if not specified)
|
||||
- **Priority**: High / Medium / Low based on context and urgency
|
||||
- **Context**: The original quote or reference that generated this action
|
||||
|
||||
4. **Organize by priority** — Group actions by priority level, then by owner.
|
||||
|
||||
## Output format
|
||||
|
||||
```
|
||||
## High Priority
|
||||
- [ ] [Action] — Owner: [name] — Due: [date]
|
||||
Context: "[relevant quote]"
|
||||
|
||||
## Medium Priority
|
||||
...
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be thorough — catch implicit actions, not just explicit ones
|
||||
- Do not invent actions that are not supported by the text
|
||||
- If ownership is ambiguous, flag it and suggest who might own it
|
||||
- Merge duplicate or overlapping actions
|
||||
- Ask the user to confirm owners and deadlines for ambiguous items
|
||||
56
packages/sdk/src/starter-skills/index.ts
Normal file
56
packages/sdk/src/starter-skills/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Get the directory containing starter skill files.
|
||||
* Resolves to the source directory containing .md files.
|
||||
*/
|
||||
export function getStarterSkillsDir(): string {
|
||||
// When running from compiled output (dist/), go up to package root then into src/
|
||||
// When running from source (src/), we're already in the right place
|
||||
const srcDir = path.resolve(__dirname, '..', '..', 'src', 'starter-skills');
|
||||
if (fs.existsSync(srcDir) && fs.readdirSync(srcDir).some(f => f.endsWith('.md'))) {
|
||||
return srcDir;
|
||||
}
|
||||
// Fallback: same directory (running directly from source)
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all starter skill names (without .md extension).
|
||||
*/
|
||||
export function listStarterSkills(): string[] {
|
||||
const dir = getStarterSkillsDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => f.replace(/\.md$/, ''))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install starter skills into a target directory (e.g., ~/.waggle/skills/).
|
||||
* Only installs skills that don't already exist (no overwrite).
|
||||
* Returns list of installed skill names.
|
||||
*/
|
||||
export function installStarterSkills(targetDir: string): string[] {
|
||||
const sourceDir = getStarterSkillsDir();
|
||||
if (!fs.existsSync(sourceDir)) return [];
|
||||
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const installed: string[] = [];
|
||||
const files = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
|
||||
|
||||
for (const file of files) {
|
||||
const targetPath = path.join(targetDir, file);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
fs.copyFileSync(path.join(sourceDir, file), targetPath);
|
||||
installed.push(file.replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
|
||||
return installed;
|
||||
}
|
||||
34
packages/sdk/src/starter-skills/meeting-prep.md
Normal file
34
packages/sdk/src/starter-skills/meeting-prep.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Meeting Prep — Context-Aware Meeting Preparation
|
||||
|
||||
Prepare for meetings by pulling relevant context from the workspace and generating structured preparation materials.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Identify the meeting** — Ask for meeting topic, attendees, and purpose if not provided. Check workspace memory for related prior discussions.
|
||||
|
||||
2. **Generate agenda items** — Based on workspace context, suggest 3-7 agenda items ordered by priority. Include time estimates if the user specifies meeting duration.
|
||||
|
||||
3. **Build talking points** — For each agenda item, provide:
|
||||
- Key point to make (1-2 sentences)
|
||||
- Supporting data or context from workspace
|
||||
- Potential questions others might raise
|
||||
|
||||
4. **Prepare questions to ask** — Generate 3-5 questions the user should ask, based on gaps in current knowledge or decisions that need to be made.
|
||||
|
||||
5. **Pull relevant background** — Search workspace memory for related decisions, open threads, and prior meeting outcomes that provide context.
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Meeting**: Topic and purpose
|
||||
- **Suggested agenda**: Numbered items with time estimates
|
||||
- **Talking points**: Per agenda item
|
||||
- **Questions to ask**: With rationale for each
|
||||
- **Background context**: Relevant prior decisions and open items
|
||||
- **Pre-meeting actions**: Anything to prepare or send beforehand
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Prioritize actionable items over informational ones
|
||||
- Flag decisions that need to be made in this meeting
|
||||
- Note any conflicts or tensions that may arise based on workspace context
|
||||
- Keep talking points concise — the user needs glanceable notes, not scripts
|
||||
27
packages/sdk/src/starter-skills/plan-execute.md
Normal file
27
packages/sdk/src/starter-skills/plan-execute.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
permissions:
|
||||
codeExecution: true
|
||||
fileSystem: true
|
||||
---
|
||||
|
||||
# Plan & Execute — Structured Task Decomposition
|
||||
|
||||
A multi-agent workflow: planner breaks down the task, executors work on sub-tasks, summarizer consolidates results.
|
||||
|
||||
## When to use
|
||||
- Complex tasks that benefit from decomposition
|
||||
- Work that has multiple independent sub-tasks
|
||||
- When you want structured progress through a large task
|
||||
|
||||
## How it works
|
||||
This skill uses the `orchestrate_workflow` tool with the `plan-execute` template:
|
||||
1. **Planner** — Analyzes the task and breaks it into concrete sub-tasks
|
||||
2. **Executor** — Works through each sub-task methodically
|
||||
3. **Summarizer** — Consolidates all executor results into a final deliverable
|
||||
|
||||
## Usage
|
||||
Tell the agent: "Use the plan-execute workflow to handle [complex task]"
|
||||
Or directly: `orchestrate_workflow` with template `plan-execute` and the task description.
|
||||
|
||||
## Output
|
||||
A comprehensive result combining all sub-task outputs with a structured summary.
|
||||
35
packages/sdk/src/starter-skills/research-synthesis.md
Normal file
35
packages/sdk/src/starter-skills/research-synthesis.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Research Synthesis — Multi-Source Investigation
|
||||
|
||||
Conduct structured research across available sources, organize findings, and provide a coherent synthesis.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Clarify the research question** — Restate the question precisely. Identify what the user actually needs to know vs. what they asked literally. Confirm scope boundaries.
|
||||
|
||||
2. **Gather from all sources** — Search workspace memory for prior context. Check files in the workspace. Use web search if available. Note what each source contributes.
|
||||
|
||||
3. **Organize findings** — Group information by theme or sub-question, not by source. For each theme:
|
||||
- Key findings
|
||||
- Supporting evidence
|
||||
- Source attribution
|
||||
- Confidence level (strong, moderate, weak)
|
||||
|
||||
4. **Identify gaps** — Explicitly state what you could not find or verify. Note where sources conflict and which seems more reliable.
|
||||
|
||||
5. **Synthesize** — Provide a unified answer that integrates all sources. Do not just list what each source says. Draw conclusions where the evidence supports them.
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Research question**: Restated clearly
|
||||
- **Key findings**: The most important discoveries (3-5 bullets)
|
||||
- **Detailed findings**: Organized by theme with source attribution
|
||||
- **Gaps and limitations**: What is missing or uncertain
|
||||
- **Synthesis**: Your integrated conclusion
|
||||
- **Suggested follow-ups**: What to research next if needed
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always cite which source each finding came from
|
||||
- Distinguish between facts, expert opinions, and your inferences
|
||||
- Be honest about uncertainty — do not present weak evidence as strong
|
||||
- Prioritize recency and reliability of sources
|
||||
26
packages/sdk/src/starter-skills/research-team.md
Normal file
26
packages/sdk/src/starter-skills/research-team.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
permissions:
|
||||
network: true
|
||||
---
|
||||
|
||||
# Research Team — Multi-Agent Investigation
|
||||
|
||||
A coordinated 3-agent research workflow: researcher gathers information, synthesizer combines findings, reviewer validates quality.
|
||||
|
||||
## When to use
|
||||
- Complex research questions requiring multiple source types
|
||||
- Topics where quality validation matters
|
||||
- When you need structured, verified research output
|
||||
|
||||
## How it works
|
||||
This skill uses the `orchestrate_workflow` tool with the `research-team` template:
|
||||
1. **Researcher** — Searches web, memory, and files for relevant information
|
||||
2. **Synthesizer** — Combines the researcher's raw findings into a coherent report
|
||||
3. **Reviewer** — Validates accuracy, identifies gaps, and suggests improvements
|
||||
|
||||
## Usage
|
||||
Tell the agent: "Use the research team workflow to investigate [topic]"
|
||||
Or directly: `orchestrate_workflow` with template `research-team` and topic as the task.
|
||||
|
||||
## Output
|
||||
A validated research report with findings organized by theme, source attribution, and identified gaps.
|
||||
48
packages/sdk/src/starter-skills/retrospective.md
Normal file
48
packages/sdk/src/starter-skills/retrospective.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Retrospective — Team or Solo Reflection
|
||||
|
||||
Facilitate a structured retrospective to learn from recent work and generate improvement actions.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Set the scope** — Clarify the time period or project being reviewed. Check workspace memory for relevant context about what happened during this period.
|
||||
|
||||
2. **Gather observations** — Walk through three categories:
|
||||
|
||||
**What went well**
|
||||
- Successes, good decisions, effective processes
|
||||
- Things to continue doing
|
||||
- Unexpected wins
|
||||
|
||||
**What did not go well**
|
||||
- Problems encountered, delays, frustrations
|
||||
- Process breakdowns, communication gaps
|
||||
- Things that took longer than expected
|
||||
|
||||
**What to change**
|
||||
- Specific process improvements
|
||||
- Tools or workflows to adopt or drop
|
||||
- Habits to build or break
|
||||
|
||||
3. **Generate action items** — For each "what to change" item, create a concrete action:
|
||||
- What specifically will change
|
||||
- Who owns it
|
||||
- When it starts
|
||||
- How to measure if it worked
|
||||
|
||||
4. **Identify patterns** — Look across retrospective items for recurring themes. If the same issues keep appearing, escalate them as systemic problems.
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Period reviewed**: Date range and context
|
||||
- **Went well**: Bulleted list with brief context
|
||||
- **Did not go well**: Bulleted list with brief context
|
||||
- **Changes**: Bulleted list of proposed improvements
|
||||
- **Action items**: Specific, owned, time-bound commitments
|
||||
- **Patterns**: Recurring themes worth addressing structurally
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep the tone constructive — focus on improvement, not blame
|
||||
- Be specific — "communication was bad" is not useful; "stakeholder updates were missed in weeks 2-3" is
|
||||
- Limit action items to 3-5 — too many means none get done
|
||||
- Reference workspace history to ground observations in facts
|
||||
21
packages/sdk/src/starter-skills/review-pair.md
Normal file
21
packages/sdk/src/starter-skills/review-pair.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Review Pair — Writer + Reviewer Cycle
|
||||
|
||||
A 2-agent workflow with revision: writer produces a draft, reviewer critiques, writer revises.
|
||||
|
||||
## When to use
|
||||
- Document drafting that benefits from review
|
||||
- When quality and accuracy matter more than speed
|
||||
- Any writing task where a second opinion improves output
|
||||
|
||||
## How it works
|
||||
This skill uses the `orchestrate_workflow` tool with the `review-pair` template:
|
||||
1. **Writer** — Creates an initial draft based on the task
|
||||
2. **Reviewer** — Critiques the draft for accuracy, clarity, completeness
|
||||
3. **Reviser** — Incorporates reviewer feedback into a final version
|
||||
|
||||
## Usage
|
||||
Tell the agent: "Use the review pair workflow to write [document]"
|
||||
Or directly: `orchestrate_workflow` with template `review-pair` and the writing task.
|
||||
|
||||
## Output
|
||||
A polished document that has been through a draft-review-revise cycle.
|
||||
41
packages/sdk/src/starter-skills/risk-assessment.md
Normal file
41
packages/sdk/src/starter-skills/risk-assessment.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Risk Assessment — Project Risk Identification and Ranking
|
||||
|
||||
Systematically identify, evaluate, and plan mitigations for project risks.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Understand the project context** — Review workspace memory and recent sessions to understand the project scope, timeline, dependencies, and goals.
|
||||
|
||||
2. **Identify risks** — Scan for risks across these categories:
|
||||
- **Technical**: Technology failures, integration issues, performance problems, security vulnerabilities
|
||||
- **Schedule**: Delays, dependencies, resource constraints, scope creep
|
||||
- **External**: Vendor dependencies, market changes, regulatory issues, third-party API changes
|
||||
- **People**: Key-person dependency, skill gaps, availability, communication breakdowns
|
||||
- **Scope**: Unclear requirements, changing priorities, feature creep
|
||||
|
||||
3. **Evaluate each risk** — For every identified risk:
|
||||
- **Likelihood**: Low (unlikely) / Medium (possible) / High (probable)
|
||||
- **Impact**: Low (minor inconvenience) / Medium (significant delay or cost) / High (project failure or major setback)
|
||||
- **Risk score**: Likelihood x Impact (use 1/2/3 scale, so max score is 9)
|
||||
|
||||
4. **Plan mitigations** — For each medium and high risk:
|
||||
- **Mitigation strategy**: How to reduce likelihood or impact
|
||||
- **Owner**: Who is responsible for monitoring and acting
|
||||
- **Trigger**: What signals that this risk is materializing
|
||||
- **Contingency**: What to do if the risk occurs despite mitigation
|
||||
|
||||
5. **Build risk matrix** — Present as a visual grid:
|
||||
|
||||
```
|
||||
| Low Impact | Medium Impact | High Impact |
|
||||
High Likely | | | [Risk 1] |
|
||||
Med Likely | | [Risk 3] | [Risk 2] |
|
||||
Low Likely | [Risk 5] | [Risk 4] | |
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be thorough but realistic — do not list catastrophic scenarios with negligible likelihood
|
||||
- Focus mitigation effort on high-score risks
|
||||
- Review workspace context for risks already identified or encountered
|
||||
- Revisit the assessment periodically — risks change as the project evolves
|
||||
31
packages/sdk/src/starter-skills/status-update.md
Normal file
31
packages/sdk/src/starter-skills/status-update.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Status Update — Project Progress Report
|
||||
|
||||
Generate a project status update from recent workspace activity, memory, and session history.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Scan recent activity** — Review sessions, memory entries, and files from the reporting period. Default to the last week if no timeframe is specified.
|
||||
|
||||
2. **Categorize progress** — Group accomplishments into logical categories (features, fixes, decisions, research, etc.). Be specific about what was done, not vague.
|
||||
|
||||
3. **Identify blockers** — Surface anything that is preventing progress. Distinguish between active blockers (stuck now) and risks (might block soon).
|
||||
|
||||
4. **Project next steps** — Based on current momentum and open threads, list what comes next in priority order.
|
||||
|
||||
## Output structure
|
||||
|
||||
- **Period**: Date range covered
|
||||
- **Summary**: 1-2 sentence overall status (on track / at risk / behind)
|
||||
- **Progress**: Bulleted list of accomplishments, grouped by category
|
||||
- **Blockers**: Active blockers with impact description
|
||||
- **Risks**: Potential future blockers
|
||||
- **Next steps**: Prioritized list of upcoming work
|
||||
- **Timeline**: Any relevant deadlines or milestones
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be factual and specific — "Completed API auth module" not "Made progress on backend"
|
||||
- Quantify where possible — number of tasks, percentage complete, time spent
|
||||
- Do not pad thin progress with filler language
|
||||
- If the workspace has little recent activity, say so directly
|
||||
- Match the tone to the audience: brief for executives, detailed for team leads
|
||||
38
packages/sdk/src/starter-skills/task-breakdown.md
Normal file
38
packages/sdk/src/starter-skills/task-breakdown.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Task Breakdown — Large Task Decomposition
|
||||
|
||||
Break a large, ambiguous task into concrete, actionable steps with clear deliverables and dependencies.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Understand the goal** — Restate the task and its definition of done. If the goal is vague, ask clarifying questions before proceeding.
|
||||
|
||||
2. **Identify major phases** — Break the work into 2-5 logical phases that represent distinct stages of progress.
|
||||
|
||||
3. **Decompose into steps** — For each phase, create specific tasks. Each task must have:
|
||||
- **Title**: Clear, action-oriented (starts with a verb)
|
||||
- **Deliverable**: What artifact or outcome marks this as done
|
||||
- **Effort estimate**: Small (< 1 hour), Medium (1-4 hours), Large (4-8 hours), XL (multiple days)
|
||||
- **Dependencies**: Which other tasks must be done first (if any)
|
||||
- **Acceptance criteria**: How to verify the task is complete
|
||||
|
||||
4. **Identify the critical path** — Highlight which sequence of tasks determines the minimum total time.
|
||||
|
||||
5. **Flag risks** — Note tasks that are uncertain, have external dependencies, or could take longer than estimated.
|
||||
|
||||
## Output format
|
||||
|
||||
```
|
||||
## Phase 1: [Name]
|
||||
- [ ] Task 1.1: [Title] — [Effort] — Depends: none
|
||||
Deliverable: [what]
|
||||
Acceptance: [criteria]
|
||||
- [ ] Task 1.2: [Title] — [Effort] — Depends: 1.1
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Tasks should be completable in one sitting (max 1 day)
|
||||
- If a task is larger than XL, break it down further
|
||||
- Order tasks so the user can start immediately with task 1.1
|
||||
- Check workspace memory for prior context on the work
|
||||
- Be realistic about effort — pad estimates for uncertainty
|
||||
179
packages/sdk/src/validate-skill.ts
Normal file
179
packages/sdk/src/validate-skill.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Skill validator — parses and validates SKILL.md files.
|
||||
*
|
||||
* SKILL.md format:
|
||||
* ```
|
||||
* ---
|
||||
* name: my-skill
|
||||
* description: What this skill does
|
||||
* version: 1.0.0
|
||||
* author: Someone
|
||||
* dependencies: [search_memory, save_memory]
|
||||
* ---
|
||||
*
|
||||
* System prompt content goes here...
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
/** F18: List of tool names this skill requires to function. */
|
||||
dependencies?: string[];
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
metadata?: SkillMetadata;
|
||||
errors: string[];
|
||||
/** F18: Non-fatal warnings (e.g., invalid semver, missing dependency tools). */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** F18: Validate semver format (x.y.z with optional pre-release/build). */
|
||||
export function isValidSemver(version: string): boolean {
|
||||
return /^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/.test(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* F18: Compare two semver version strings.
|
||||
* Returns: -1 if a < b, 0 if a == b, 1 if a > b.
|
||||
* Only compares major.minor.patch (ignores pre-release/build metadata).
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
|
||||
const parse = (v: string) => v.replace(/-.*$/, '').replace(/\+.*$/, '').split('.').map(Number);
|
||||
const pa = parse(a);
|
||||
const pb = parse(b);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] < pb[i]) return -1;
|
||||
if (pa[i] > pb[i]) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from a string delimited by `---`.
|
||||
* Returns key-value pairs (all values as strings).
|
||||
*/
|
||||
function parseFrontmatter(raw: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
const value = trimmed.slice(colonIdx + 1).trim();
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* F18: Parse a YAML-style array value: `[item1, item2]` or `item1, item2`.
|
||||
*/
|
||||
function parseArrayField(value: string): string[] {
|
||||
// Strip surrounding brackets if present
|
||||
const stripped = value.replace(/^\[/, '').replace(/\]$/, '');
|
||||
return stripped
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a SKILL.md file content.
|
||||
*
|
||||
* Extracts YAML frontmatter (name, description, version, author, dependencies)
|
||||
* and the body as the system prompt.
|
||||
*/
|
||||
export function validateSkillMd(content: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for frontmatter delimiters
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
errors.push('Missing YAML frontmatter (must be wrapped in --- delimiters)');
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const frontmatterRaw = fmMatch[1];
|
||||
const body = fmMatch[2];
|
||||
const fields = parseFrontmatter(frontmatterRaw);
|
||||
|
||||
if (!fields.name) {
|
||||
errors.push('Missing required field: name');
|
||||
}
|
||||
if (!fields.description) {
|
||||
errors.push('Missing required field: description');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// F18: Validate version if present
|
||||
if (fields.version && !isValidSemver(fields.version)) {
|
||||
warnings.push(`Invalid semver version "${fields.version}" — expected format: x.y.z (e.g., 1.0.0)`);
|
||||
}
|
||||
|
||||
// F18: Parse dependencies
|
||||
const dependencies = fields.dependencies ? parseArrayField(fields.dependencies) : undefined;
|
||||
|
||||
const metadata: SkillMetadata = {
|
||||
name: fields.name,
|
||||
description: fields.description,
|
||||
version: fields.version || undefined,
|
||||
author: fields.author || undefined,
|
||||
dependencies,
|
||||
systemPrompt: body.trim(),
|
||||
};
|
||||
|
||||
return { valid: true, metadata, errors: [], warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* F18: Check that all dependency tools listed by a skill are available.
|
||||
* Returns warnings for any missing dependencies (does not block installation).
|
||||
*/
|
||||
export function checkSkillDependencies(
|
||||
metadata: SkillMetadata,
|
||||
availableTools: string[],
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (!metadata.dependencies || metadata.dependencies.length === 0) return warnings;
|
||||
|
||||
const toolSet = new Set(availableTools);
|
||||
for (const dep of metadata.dependencies) {
|
||||
if (!toolSet.has(dep)) {
|
||||
warnings.push(`Skill "${metadata.name}" requires tool "${dep}" which is not available`);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* F18: Check for version downgrade when updating an existing skill.
|
||||
* Returns a warning string if newVersion <= existingVersion.
|
||||
*/
|
||||
export function checkVersionDowngrade(
|
||||
skillName: string,
|
||||
existingVersion: string | undefined,
|
||||
newVersion: string | undefined,
|
||||
): string | null {
|
||||
if (!existingVersion || !newVersion) return null;
|
||||
if (!isValidSemver(existingVersion) || !isValidSemver(newVersion)) return null;
|
||||
|
||||
const cmp = compareSemver(newVersion, existingVersion);
|
||||
if (cmp < 0) {
|
||||
return `Downgrade detected for skill "${skillName}": ${newVersion} < ${existingVersion}`;
|
||||
}
|
||||
if (cmp === 0) {
|
||||
return `Same version re-install for skill "${skillName}": ${newVersion} == ${existingVersion}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
177
packages/sdk/tests/plugin-manager.test.ts
Normal file
177
packages/sdk/tests/plugin-manager.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { validatePluginManifest } from '../src/plugin-manifest.js';
|
||||
import { PluginManager } from '../src/plugin-manager.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-plugin-test-'));
|
||||
}
|
||||
|
||||
function writePluginJson(dir: string, manifest: Record<string, unknown>): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plugin.json'), JSON.stringify(manifest), 'utf-8');
|
||||
}
|
||||
|
||||
const VALID_MANIFEST = {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
skills: ['summarize'],
|
||||
};
|
||||
|
||||
describe('validatePluginManifest', () => {
|
||||
it('validates a correct manifest', () => {
|
||||
const result = validatePluginManifest(VALID_MANIFEST);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing name', () => {
|
||||
const result = validatePluginManifest({
|
||||
version: '1.0.0',
|
||||
description: 'No name',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('name'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing version', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'test',
|
||||
description: 'No version',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('version'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects manifest with missing description', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('description'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal and separator characters in plugin names', () => {
|
||||
for (const name of ['../escape', 'nested/plugin', 'C:\\escape', '..']) {
|
||||
const result = validatePluginManifest({
|
||||
name,
|
||||
version: '1.0.0',
|
||||
description: 'Unsafe name',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('filesystem-safe'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates manifest with mcpServers', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'mcp-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Has MCP servers',
|
||||
mcpServers: [{ name: 'server1', command: 'npx', args: ['serve'] }],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid mcpServers entries', () => {
|
||||
const result = validatePluginManifest({
|
||||
name: 'bad-mcp',
|
||||
version: '1.0.0',
|
||||
description: 'Bad MCP',
|
||||
mcpServers: [{ name: '', command: '' }],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginManager', () => {
|
||||
let pluginsDir: string;
|
||||
let manager: PluginManager;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
pluginsDir = makeTempDir();
|
||||
tempDirs.push(pluginsDir);
|
||||
manager = new PluginManager(pluginsDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('starts with no plugins', () => {
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('installs a local plugin', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
|
||||
const plugins = manager.list();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].name).toBe('test-plugin');
|
||||
expect(plugins[0].version).toBe('1.0.0');
|
||||
|
||||
// Verify plugin files were copied
|
||||
const copiedManifest = path.join(pluginsDir, 'test-plugin', 'plugin.json');
|
||||
expect(fs.existsSync(copiedManifest)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects installing a plugin with invalid manifest', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, { name: '', version: '1.0.0', description: '' });
|
||||
|
||||
expect(() => manager.installLocal(sourceDir)).toThrow('Invalid plugin manifest');
|
||||
});
|
||||
|
||||
it('rejects installing from directory without plugin.json', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
|
||||
expect(() => manager.installLocal(sourceDir)).toThrow('No plugin.json found');
|
||||
});
|
||||
|
||||
it('uninstalls a plugin', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
expect(manager.list()).toHaveLength(1);
|
||||
|
||||
manager.uninstall('test-plugin');
|
||||
expect(manager.list()).toHaveLength(0);
|
||||
|
||||
// Verify plugin directory was removed
|
||||
expect(fs.existsSync(path.join(pluginsDir, 'test-plugin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when uninstalling a plugin that is not installed', () => {
|
||||
expect(() => manager.uninstall('nonexistent')).toThrow('not installed');
|
||||
});
|
||||
|
||||
it('persists registry across instances', () => {
|
||||
const sourceDir = makeTempDir();
|
||||
tempDirs.push(sourceDir);
|
||||
writePluginJson(sourceDir, VALID_MANIFEST);
|
||||
|
||||
manager.installLocal(sourceDir);
|
||||
|
||||
// Create a new manager instance pointing at the same directory
|
||||
const manager2 = new PluginManager(pluginsDir);
|
||||
expect(manager2.list()).toHaveLength(1);
|
||||
expect(manager2.list()[0].name).toBe('test-plugin');
|
||||
});
|
||||
});
|
||||
440
packages/sdk/tests/plugin-runtime.test.ts
Normal file
440
packages/sdk/tests/plugin-runtime.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
PluginRuntime,
|
||||
PluginRuntimeManager,
|
||||
webResearchPluginManifest,
|
||||
type PluginManifestWithTools,
|
||||
type PluginLifecycleState,
|
||||
} from '../src/plugin-runtime.js';
|
||||
import { PluginManager } from '../src/plugin-manager.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function simpleManifest(overrides: Partial<PluginManifestWithTools> = {}): PluginManifestWithTools {
|
||||
return {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'A test plugin',
|
||||
skills: ['test-skill'],
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntime — single plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginRuntime', () => {
|
||||
it('starts in installed state after construction', () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
expect(runtime.getState()).toBe('installed');
|
||||
});
|
||||
|
||||
it('enable() transitions to enabled then active', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
const states: PluginLifecycleState[] = [];
|
||||
runtime.on('stateChange', (e: { to: PluginLifecycleState }) => states.push(e.to));
|
||||
|
||||
await runtime.enable();
|
||||
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(states).toEqual(['enabled', 'active']);
|
||||
});
|
||||
|
||||
it('active plugin exposes contributed tools', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const tools = runtime.getContributedTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('test_tool');
|
||||
expect(tools[0].description).toBe('A test tool');
|
||||
expect(typeof tools[0].execute).toBe('function');
|
||||
});
|
||||
|
||||
it('active plugin exposes contributed skills', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const skills = runtime.getContributedSkills();
|
||||
expect(skills).toEqual(['test-skill']);
|
||||
});
|
||||
|
||||
it('disable() removes tools and skills, transitions to disabled', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
runtime.disable();
|
||||
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
expect(runtime.getContributedTools()).toEqual([]);
|
||||
expect(runtime.getContributedSkills()).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-enable after disable works', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
runtime.disable();
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(runtime.getContributedTools()).toHaveLength(1);
|
||||
expect(runtime.getContributedSkills()).toEqual(['test-skill']);
|
||||
});
|
||||
|
||||
it('enters error state on activation failure (missing capability)', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
requiredCapabilities: ['network'],
|
||||
availableCapabilities: [],
|
||||
});
|
||||
|
||||
await expect(runtime.enable()).rejects.toThrow('Missing required capability: network');
|
||||
expect(runtime.getState()).toBe('error');
|
||||
});
|
||||
|
||||
it('can re-enable from error state', async () => {
|
||||
// Use a counter-based executor that throws on first activation, succeeds on second
|
||||
let callCount = 0;
|
||||
const flakyExecutor = (def: { name: string; description: string; parameters: Record<string, unknown> }) => {
|
||||
callCount++;
|
||||
if (callCount <= 1) {
|
||||
throw new Error('Transient activation failure');
|
||||
}
|
||||
return async () => 'ok';
|
||||
};
|
||||
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
toolExecutor: flakyExecutor,
|
||||
});
|
||||
|
||||
// First enable() fails — toolExecutor throws during activation
|
||||
await expect(runtime.enable()).rejects.toThrow('Transient activation failure');
|
||||
expect(runtime.getState()).toBe('error');
|
||||
|
||||
// Second enable() succeeds — same runtime, error → enabled → active
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(runtime.getContributedTools()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('enable() on already-active plugin is a no-op', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
expect(runtime.getState()).toBe('active');
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
runtime.on('stateChange', (e: { to: string }) => stateChanges.push(e.to));
|
||||
|
||||
await runtime.enable(); // no-op
|
||||
expect(runtime.getState()).toBe('active');
|
||||
expect(stateChanges).toEqual([]); // no transitions fired
|
||||
});
|
||||
|
||||
it('disable() on already-disabled plugin is a no-op', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
runtime.disable();
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
runtime.on('stateChange', (e: { to: string }) => stateChanges.push(e.to));
|
||||
|
||||
runtime.disable(); // no-op
|
||||
expect(runtime.getState()).toBe('disabled');
|
||||
expect(stateChanges).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits stateChange events with from/to', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
const events: Array<{ plugin: string; from: string; to: string }> = [];
|
||||
runtime.on('stateChange', (e) => events.push(e));
|
||||
|
||||
await runtime.enable();
|
||||
|
||||
expect(events).toEqual([
|
||||
{ plugin: 'test-plugin', from: 'installed', to: 'enabled' },
|
||||
{ plugin: 'test-plugin', from: 'enabled', to: 'active' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits error event on activation failure', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest(), {
|
||||
requiredCapabilities: ['gpu'],
|
||||
availableCapabilities: [],
|
||||
});
|
||||
|
||||
const errors: Array<{ plugin: string; error: Error }> = [];
|
||||
runtime.on('error', (e) => errors.push(e));
|
||||
|
||||
await runtime.enable().catch(() => {}); // swallow throw
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].plugin).toBe('test-plugin');
|
||||
expect(errors[0].error.message).toContain('gpu');
|
||||
});
|
||||
|
||||
it('uses custom tool executor when provided', async () => {
|
||||
const customExecutor = vi.fn(() => async () => 'custom-result');
|
||||
const runtime = new PluginRuntime(simpleManifest(), { toolExecutor: customExecutor });
|
||||
await runtime.enable();
|
||||
|
||||
const tools = runtime.getContributedTools();
|
||||
expect(customExecutor).toHaveBeenCalledTimes(1);
|
||||
const result = await tools[0].execute({});
|
||||
expect(result).toBe('custom-result');
|
||||
});
|
||||
|
||||
it('default executor returns JSON with tool name and args', async () => {
|
||||
const runtime = new PluginRuntime(simpleManifest());
|
||||
await runtime.enable();
|
||||
|
||||
const result = await runtime.getContributedTools()[0].execute({ foo: 'bar' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ tool: 'test_tool', args: { foo: 'bar' }, status: 'executed' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginRuntimeManager — multi-plugin management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginRuntimeManager', () => {
|
||||
it('registers a plugin in installed state', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
const states = mgr.getPluginStates();
|
||||
expect(states['test-plugin']).toBe('installed');
|
||||
});
|
||||
|
||||
it('registers multiple plugins', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'plugin-a' }));
|
||||
mgr.register(simpleManifest({ name: 'plugin-b' }));
|
||||
|
||||
const states = mgr.getPluginStates();
|
||||
expect(Object.keys(states)).toHaveLength(2);
|
||||
expect(states['plugin-a']).toBe('installed');
|
||||
expect(states['plugin-b']).toBe('installed');
|
||||
});
|
||||
|
||||
it('throws on duplicate registration', () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
expect(() => mgr.register(simpleManifest())).toThrow('already registered');
|
||||
});
|
||||
|
||||
it('enables and activates a plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
await mgr.enable('test-plugin');
|
||||
expect(mgr.getPluginStates()['test-plugin']).toBe('active');
|
||||
});
|
||||
|
||||
it('disables a plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
await mgr.enable('test-plugin');
|
||||
|
||||
mgr.disable('test-plugin');
|
||||
expect(mgr.getPluginStates()['test-plugin']).toBe('disabled');
|
||||
});
|
||||
|
||||
it('getActive() returns only active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'active-one' }));
|
||||
mgr.register(simpleManifest({ name: 'inactive-one' }));
|
||||
|
||||
await mgr.enable('active-one');
|
||||
|
||||
const active = mgr.getActive();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].getManifest().name).toBe('active-one');
|
||||
});
|
||||
|
||||
it('getAllTools() aggregates tools from all active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(
|
||||
simpleManifest({
|
||||
name: 'plugin-a',
|
||||
tools: [{ name: 'tool_a', description: 'Tool A', parameters: {} }],
|
||||
})
|
||||
);
|
||||
mgr.register(
|
||||
simpleManifest({
|
||||
name: 'plugin-b',
|
||||
tools: [{ name: 'tool_b', description: 'Tool B', parameters: {} }],
|
||||
})
|
||||
);
|
||||
|
||||
await mgr.enable('plugin-a');
|
||||
await mgr.enable('plugin-b');
|
||||
|
||||
const tools = mgr.getAllTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool_a', 'tool_b']);
|
||||
});
|
||||
|
||||
it('getAllSkills() aggregates skills from all active plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'p1', skills: ['skill-a'] }));
|
||||
mgr.register(simpleManifest({ name: 'p2', skills: ['skill-b', 'skill-c'] }));
|
||||
|
||||
await mgr.enable('p1');
|
||||
await mgr.enable('p2');
|
||||
|
||||
const skills = mgr.getAllSkills();
|
||||
expect(skills.sort()).toEqual(['skill-a', 'skill-b', 'skill-c']);
|
||||
});
|
||||
|
||||
it('getPluginStates() returns all plugin states', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest({ name: 'a' }));
|
||||
mgr.register(simpleManifest({ name: 'b' }));
|
||||
mgr.register(simpleManifest({ name: 'c' }));
|
||||
|
||||
await mgr.enable('a');
|
||||
await mgr.enable('b');
|
||||
mgr.disable('b');
|
||||
|
||||
expect(mgr.getPluginStates()).toEqual({
|
||||
a: 'active',
|
||||
b: 'disabled',
|
||||
c: 'installed',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards stateChange events from plugins', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(simpleManifest());
|
||||
|
||||
const events: Array<{ plugin: string; to: string }> = [];
|
||||
mgr.on('stateChange', (e) => events.push(e));
|
||||
|
||||
await mgr.enable('test-plugin');
|
||||
|
||||
expect(events).toHaveLength(2); // enabled, active
|
||||
expect(events[0].to).toBe('enabled');
|
||||
expect(events[1].to).toBe('active');
|
||||
});
|
||||
|
||||
it('throws when enabling unregistered plugin', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
await expect(mgr.enable('nonexistent')).rejects.toThrow('not registered');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flagship plugin fixture — web-research
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('webResearchPluginManifest', () => {
|
||||
it('has correct name and tools', () => {
|
||||
expect(webResearchPluginManifest.name).toBe('web-research');
|
||||
expect(webResearchPluginManifest.tools).toHaveLength(2);
|
||||
expect(webResearchPluginManifest.tools!.map((t) => t.name)).toEqual(['web_scrape', 'web_summarize']);
|
||||
});
|
||||
|
||||
it('can be registered and activated via PluginRuntimeManager', async () => {
|
||||
const mgr = new PluginRuntimeManager();
|
||||
mgr.register(webResearchPluginManifest);
|
||||
await mgr.enable('web-research');
|
||||
|
||||
const tools = mgr.getAllTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(mgr.getAllSkills()).toEqual(['web-research']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManager.toRuntimeManager — bridge from filesystem to runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PluginManager.toRuntimeManager', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
function createTestPlugin(pluginsDir: string, name: string): void {
|
||||
const pluginDir = path.join(pluginsDir, name);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const manifest = {
|
||||
name,
|
||||
version: '1.0.0',
|
||||
description: `Test plugin ${name}`,
|
||||
skills: [`${name}-skill`],
|
||||
tools: [
|
||||
{ name: `${name}_tool`, description: `Tool from ${name}`, parameters: { type: 'object', properties: {} } },
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(manifest));
|
||||
}
|
||||
|
||||
it('creates a PluginRuntimeManager with all installed plugins', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-bridge-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
// Install two test plugins
|
||||
const srcA = path.join(tmpDir, '_src_a');
|
||||
const srcB = path.join(tmpDir, '_src_b');
|
||||
createTestPlugin(tmpDir, '_src_a');
|
||||
createTestPlugin(tmpDir, '_src_b');
|
||||
|
||||
// installLocal copies and registers
|
||||
pm.installLocal(srcA);
|
||||
pm.installLocal(srcB);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
const states = rtm.getPluginStates();
|
||||
|
||||
expect(Object.keys(states)).toHaveLength(2);
|
||||
expect(states['_src_a']).toBe('installed');
|
||||
expect(states['_src_b']).toBe('installed');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runtime manager plugins can be enabled and contribute tools', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-bridge2-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
const srcDir = path.join(tmpDir, '_src_plug');
|
||||
createTestPlugin(tmpDir, '_src_plug');
|
||||
pm.installLocal(srcDir);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
await rtm.enable('_src_plug');
|
||||
|
||||
const tools = rtm.getAllTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('_src_plug_tool');
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns empty manager when no plugins installed', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-pm-empty-'));
|
||||
const pm = new PluginManager(tmpDir);
|
||||
|
||||
const rtm = pm.toRuntimeManager();
|
||||
expect(Object.keys(rtm.getPluginStates())).toHaveLength(0);
|
||||
expect(rtm.getAllTools()).toEqual([]);
|
||||
|
||||
// Clean up
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
110
packages/sdk/tests/starter-skills.test.ts
Normal file
110
packages/sdk/tests/starter-skills.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
listStarterSkills,
|
||||
installStarterSkills,
|
||||
getStarterSkillsDir,
|
||||
} from '../src/starter-skills/index.js';
|
||||
|
||||
describe('starter-skills', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-starter-skills-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getStarterSkillsDir() returns a directory that exists', () => {
|
||||
const dir = getStarterSkillsDir();
|
||||
expect(fs.existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('listStarterSkills() returns 18 skill names', () => {
|
||||
const skills = listStarterSkills();
|
||||
expect(skills).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('all expected skill names are present', () => {
|
||||
const skills = listStarterSkills();
|
||||
const expected = [
|
||||
'brainstorm',
|
||||
'catch-up',
|
||||
'code-review',
|
||||
'compare-docs',
|
||||
'daily-plan',
|
||||
'decision-matrix',
|
||||
'draft-memo',
|
||||
'explain-concept',
|
||||
'extract-actions',
|
||||
'meeting-prep',
|
||||
'plan-execute',
|
||||
'research-synthesis',
|
||||
'research-team',
|
||||
'retrospective',
|
||||
'review-pair',
|
||||
'risk-assessment',
|
||||
'status-update',
|
||||
'task-breakdown',
|
||||
];
|
||||
expect(skills).toEqual(expected);
|
||||
});
|
||||
|
||||
it('each starter skill file exists and has content (> 50 chars)', () => {
|
||||
const dir = getStarterSkillsDir();
|
||||
const skills = listStarterSkills();
|
||||
for (const name of skills) {
|
||||
const filePath = path.join(dir, `${name}.md`);
|
||||
expect(fs.existsSync(filePath), `${name}.md should exist`).toBe(true);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content.length, `${name}.md should have > 50 chars`).toBeGreaterThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
it('all skill names are valid (alphanumeric + hyphens only)', () => {
|
||||
const skills = listStarterSkills();
|
||||
for (const name of skills) {
|
||||
expect(name, `${name} should match [a-z0-9-]+`).toMatch(/^[a-z0-9-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('installStarterSkills() copies files to target directory', () => {
|
||||
const installed = installStarterSkills(tmpDir);
|
||||
expect(installed).toHaveLength(18);
|
||||
|
||||
// Verify files exist in target
|
||||
for (const name of installed) {
|
||||
const filePath = path.join(tmpDir, `${name}.md`);
|
||||
expect(fs.existsSync(filePath), `${name}.md should be installed`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('installStarterSkills() does NOT overwrite existing skills', () => {
|
||||
// Pre-create a skill with custom content
|
||||
const customContent = '# Custom catch-up skill\nMy custom version.';
|
||||
fs.writeFileSync(path.join(tmpDir, 'catch-up.md'), customContent, 'utf-8');
|
||||
|
||||
const installed = installStarterSkills(tmpDir);
|
||||
|
||||
// catch-up should NOT be in the installed list (was skipped)
|
||||
expect(installed).not.toContain('catch-up');
|
||||
expect(installed).toHaveLength(17);
|
||||
|
||||
// Verify custom content was preserved
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'catch-up.md'), 'utf-8');
|
||||
expect(content).toBe(customContent);
|
||||
});
|
||||
|
||||
it('installStarterSkills() creates target directory if missing', () => {
|
||||
const nestedDir = path.join(tmpDir, 'nested', 'skills');
|
||||
expect(fs.existsSync(nestedDir)).toBe(false);
|
||||
|
||||
const installed = installStarterSkills(nestedDir);
|
||||
expect(installed).toHaveLength(18);
|
||||
expect(fs.existsSync(nestedDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
284
packages/sdk/tests/validate-skill.test.ts
Normal file
284
packages/sdk/tests/validate-skill.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateSkillMd,
|
||||
isValidSemver,
|
||||
compareSemver,
|
||||
checkSkillDependencies,
|
||||
checkVersionDowngrade,
|
||||
} from '../src/validate-skill.js';
|
||||
|
||||
describe('validateSkillMd', () => {
|
||||
it('parses valid SKILL.md', () => {
|
||||
const content = `---
|
||||
name: summarizer
|
||||
description: Summarizes long documents
|
||||
version: 1.0.0
|
||||
author: waggle-team
|
||||
---
|
||||
|
||||
You are an expert summarizer. Given a document, produce a concise summary.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata!.name).toBe('summarizer');
|
||||
expect(result.metadata!.description).toBe('Summarizes long documents');
|
||||
expect(result.metadata!.version).toBe('1.0.0');
|
||||
expect(result.metadata!.author).toBe('waggle-team');
|
||||
expect(result.metadata!.systemPrompt).toBe(
|
||||
'You are an expert summarizer. Given a document, produce a concise summary.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing name', () => {
|
||||
const content = `---
|
||||
description: A skill without a name
|
||||
---
|
||||
|
||||
Some prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: name');
|
||||
});
|
||||
|
||||
it('rejects missing description', () => {
|
||||
const content = `---
|
||||
name: no-desc
|
||||
---
|
||||
|
||||
Some prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required field: description');
|
||||
});
|
||||
|
||||
it('rejects missing frontmatter', () => {
|
||||
const content = `Just a plain markdown file with no frontmatter.`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Missing YAML frontmatter (must be wrapped in --- delimiters)',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts system prompt from body', () => {
|
||||
const content = `---
|
||||
name: coder
|
||||
description: Writes code
|
||||
---
|
||||
|
||||
You are a coding assistant.
|
||||
|
||||
Always use TypeScript.
|
||||
Write clean, tested code.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.systemPrompt).toBe(
|
||||
'You are a coding assistant.\n\nAlways use TypeScript.\nWrite clean, tested code.',
|
||||
);
|
||||
});
|
||||
|
||||
// F18: Version validation tests
|
||||
it('warns on invalid semver version', () => {
|
||||
const content = `---
|
||||
name: bad-version
|
||||
description: Has invalid version
|
||||
version: abc
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true); // still valid, just warns
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain('Invalid semver version');
|
||||
expect(result.warnings[0]).toContain('abc');
|
||||
});
|
||||
|
||||
it('accepts valid semver with pre-release', () => {
|
||||
const content = `---
|
||||
name: prerelease
|
||||
description: Has pre-release version
|
||||
version: 2.0.0-beta.1
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(result.metadata!.version).toBe('2.0.0-beta.1');
|
||||
});
|
||||
|
||||
// F18: Dependency parsing tests
|
||||
it('parses dependencies from bracket syntax', () => {
|
||||
const content = `---
|
||||
name: researcher
|
||||
description: Research skill
|
||||
dependencies: [search_memory, save_memory, query_knowledge]
|
||||
---
|
||||
|
||||
Research prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toEqual(['search_memory', 'save_memory', 'query_knowledge']);
|
||||
});
|
||||
|
||||
it('parses dependencies from comma-separated syntax', () => {
|
||||
const content = `---
|
||||
name: researcher
|
||||
description: Research skill
|
||||
dependencies: search_memory, save_memory
|
||||
---
|
||||
|
||||
Research prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toEqual(['search_memory', 'save_memory']);
|
||||
});
|
||||
|
||||
it('returns undefined dependencies when field is absent', () => {
|
||||
const content = `---
|
||||
name: no-deps
|
||||
description: No dependencies
|
||||
---
|
||||
|
||||
Prompt.
|
||||
`;
|
||||
|
||||
const result = validateSkillMd(content);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.metadata!.dependencies).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSemver', () => {
|
||||
it('accepts standard semver', () => {
|
||||
expect(isValidSemver('1.0.0')).toBe(true);
|
||||
expect(isValidSemver('0.1.0')).toBe(true);
|
||||
expect(isValidSemver('12.34.56')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts semver with pre-release', () => {
|
||||
expect(isValidSemver('1.0.0-alpha')).toBe(true);
|
||||
expect(isValidSemver('1.0.0-beta.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts semver with build metadata', () => {
|
||||
expect(isValidSemver('1.0.0+build.123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid semver', () => {
|
||||
expect(isValidSemver('1.0')).toBe(false);
|
||||
expect(isValidSemver('abc')).toBe(false);
|
||||
expect(isValidSemver('v1.0.0')).toBe(false);
|
||||
expect(isValidSemver('1')).toBe(false);
|
||||
expect(isValidSemver('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSemver', () => {
|
||||
it('compares equal versions', () => {
|
||||
expect(compareSemver('1.0.0', '1.0.0')).toBe(0);
|
||||
});
|
||||
|
||||
it('compares different major versions', () => {
|
||||
expect(compareSemver('2.0.0', '1.0.0')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '2.0.0')).toBe(-1);
|
||||
});
|
||||
|
||||
it('compares different minor versions', () => {
|
||||
expect(compareSemver('1.2.0', '1.1.0')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '1.1.0')).toBe(-1);
|
||||
});
|
||||
|
||||
it('compares different patch versions', () => {
|
||||
expect(compareSemver('1.0.2', '1.0.1')).toBe(1);
|
||||
expect(compareSemver('1.0.0', '1.0.1')).toBe(-1);
|
||||
});
|
||||
|
||||
it('ignores pre-release when comparing', () => {
|
||||
expect(compareSemver('1.0.0-alpha', '1.0.0-beta')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSkillDependencies', () => {
|
||||
it('returns no warnings when all dependencies are available', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
dependencies: ['search_memory', 'save_memory'],
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory', 'save_memory', 'get_identity']);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns about missing dependency tools', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
dependencies: ['search_memory', 'nonexistent_tool'],
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory', 'save_memory']);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('nonexistent_tool');
|
||||
expect(warnings[0]).toContain('not available');
|
||||
});
|
||||
|
||||
it('returns no warnings when no dependencies declared', () => {
|
||||
const metadata = {
|
||||
name: 'test-skill',
|
||||
description: 'test',
|
||||
systemPrompt: '',
|
||||
};
|
||||
const warnings = checkSkillDependencies(metadata, ['search_memory']);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVersionDowngrade', () => {
|
||||
it('detects downgrade', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '2.0.0', '1.0.0');
|
||||
expect(warning).toContain('Downgrade detected');
|
||||
expect(warning).toContain('1.0.0 < 2.0.0');
|
||||
});
|
||||
|
||||
it('detects same version re-install', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '1.0.0', '1.0.0');
|
||||
expect(warning).toContain('Same version re-install');
|
||||
});
|
||||
|
||||
it('returns null for upgrade', () => {
|
||||
const warning = checkVersionDowngrade('my-skill', '1.0.0', '2.0.0');
|
||||
expect(warning).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when versions are missing', () => {
|
||||
expect(checkVersionDowngrade('s', undefined, '1.0.0')).toBeNull();
|
||||
expect(checkVersionDowngrade('s', '1.0.0', undefined)).toBeNull();
|
||||
expect(checkVersionDowngrade('s', undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid semver', () => {
|
||||
expect(checkVersionDowngrade('s', 'abc', '1.0.0')).toBeNull();
|
||||
expect(checkVersionDowngrade('s', '1.0.0', 'xyz')).toBeNull();
|
||||
});
|
||||
});
|
||||
124
packages/sdk/tests/wave-g-capability-surface.test.ts
Normal file
124
packages/sdk/tests/wave-g-capability-surface.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Wave G — Capability Surface Productization tests.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Pack catalog structure and completeness
|
||||
* 2. Pack-to-skill relationships are valid
|
||||
* 3. Product language consistency (no internal categories leaked)
|
||||
* 4. Capability summary data shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { listCapabilityPacks, getPackManifest } from '../src/capability-packs/index.js';
|
||||
|
||||
describe('Wave G: Capability Surface', () => {
|
||||
describe('Pack Catalog', () => {
|
||||
it('has exactly 5 curated packs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
expect(packs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('all packs have required fields', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
expect(pack.id).toBeTruthy();
|
||||
expect(pack.name).toBeTruthy();
|
||||
expect(pack.description).toBeTruthy();
|
||||
expect(pack.skills.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the 5 core pack IDs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
const ids = packs.map(p => p.id);
|
||||
expect(ids).toContain('writing-suite');
|
||||
expect(ids).toContain('research-workflow');
|
||||
expect(ids).toContain('planning-master');
|
||||
expect(ids).toContain('decision-framework');
|
||||
expect(ids).toContain('team-collaboration');
|
||||
});
|
||||
|
||||
it('each pack has user-facing name (not slug)', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
// Names should have spaces and be title-cased, not slugs
|
||||
expect(pack.name).toMatch(/[A-Z]/); // Contains uppercase
|
||||
expect(pack.name).not.toMatch(/^[a-z-]+$/); // Not a slug
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pack-to-Skill Relationships', () => {
|
||||
it('each pack references valid skill IDs', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
for (const skillId of pack.skills) {
|
||||
expect(typeof skillId).toBe('string');
|
||||
expect(skillId.length).toBeGreaterThan(0);
|
||||
// Skill IDs should be kebab-case
|
||||
expect(skillId).toMatch(/^[a-z][a-z0-9-]*$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('getPackManifest returns correct pack by ID', () => {
|
||||
const pack = getPackManifest('writing-suite');
|
||||
expect(pack).not.toBeNull();
|
||||
expect(pack!.name).toBe('Writing Suite');
|
||||
expect(pack!.skills).toContain('draft-memo');
|
||||
});
|
||||
|
||||
it('getPackManifest returns null for unknown pack', () => {
|
||||
const pack = getPackManifest('nonexistent-pack');
|
||||
expect(pack).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product Language', () => {
|
||||
it('pack descriptions use user-facing language (no internal terms)', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
const internalTerms = ['plugin', 'MCP', 'hook', 'mcp_server'];
|
||||
for (const pack of packs) {
|
||||
for (const term of internalTerms) {
|
||||
expect(pack.description.toLowerCase()).not.toContain(term.toLowerCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('pack names do not reference implementation details', () => {
|
||||
const packs = listCapabilityPacks();
|
||||
for (const pack of packs) {
|
||||
expect(pack.name.toLowerCase()).not.toContain('plugin');
|
||||
expect(pack.name.toLowerCase()).not.toContain('mcp');
|
||||
expect(pack.name.toLowerCase()).not.toContain('hook');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capability Summary Shape', () => {
|
||||
it('can construct a unified capability count from component data', () => {
|
||||
// Simulates what the UI does: merge implementation counts into product counts
|
||||
const mockCapabilities = {
|
||||
tools: { count: 25, native: 20, plugin: 3, mcp: 2 },
|
||||
skills: [{ name: 'draft-memo' }, { name: 'research-synthesis' }],
|
||||
workflows: [{ name: 'research-team' }],
|
||||
plugins: [{ name: 'p1', state: 'active' }],
|
||||
mcpServers: [{ name: 'm1', healthy: true }],
|
||||
commands: [{ name: '/help' }, { name: '/cost' }],
|
||||
};
|
||||
|
||||
// Product-level counts (what user sees)
|
||||
const toolCount = mockCapabilities.tools.count;
|
||||
const skillCount = mockCapabilities.skills.length;
|
||||
const workflowCount = mockCapabilities.workflows.length;
|
||||
const extensionCount = mockCapabilities.plugins.length + mockCapabilities.mcpServers.length;
|
||||
const commandCount = mockCapabilities.commands.length;
|
||||
|
||||
expect(toolCount).toBe(25);
|
||||
expect(skillCount).toBe(2);
|
||||
expect(workflowCount).toBe(1);
|
||||
expect(extensionCount).toBe(2); // unified plugins + MCP
|
||||
expect(commandCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/sdk/tsconfig.json
Normal file
21
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
9
packages/sdk/vitest.config.ts
Normal file
9
packages/sdk/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 30_000,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user