Skip to content

Plugin Development ​

This guide covers everything you need to know about developing plugins for kist.

Overview ​

Kist uses a plugin-based architecture that allows you to extend its functionality with custom actions. Plugins are npm packages that export action classes implementing the ActionPlugin interface.

Plugin Architecture ​

┌─────────────────────────────────────────────────┐
│                  kist Core                       │
├─────────────────────────────────────────────────┤
│  PluginManager                                   │
│  ├── loadPlugins() - Discovers and loads plugins │
│  ├── getAction() - Retrieves action by name     │
│  └── listActions() - Lists all available actions │
├─────────────────────────────────────────────────┤
│  Core Actions (Built-in)                         │
│  ├── directory_clean                             │
│  ├── directory_copy                              │
│  ├── directory_create                            │
│  ├── file_copy                                   │
│  ├── file_rename                                 │
│  ├── template_render                             │
│  └── version_write                               │
├─────────────────────────────────────────────────┤
│  Plugin Actions (External)                       │
│  ├── kist-action-sass                            │
│  ├── kist-action-typescript                      │
│  ├── kist-action-jinja                           │
│  └── ... (community plugins)                     │
└─────────────────────────────────────────────────┘

Creating a Plugin ​

1. Set Up Your Project ​

Start by creating a new npm package:

bash
mkdir kist-action-myaction
cd kist-action-myaction
npm init -y

2. Install Dependencies ​

bash
npm install typescript --save-dev
npm install @types/node --save-dev

3. Configure TypeScript ​

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

4. Create the Plugin Class ​

Create src/index.ts:

typescript
import * as fs from 'fs';
import * as path from 'path';

/**
 * Plugin metadata interface
 */
export interface PluginInfo {
  name: string;
  version: string;
  description: string;
  author?: string;
}

/**
 * Action execution context
 */
export interface ActionContext {
  workingDir: string;
  config: Record<string, any>;
  logger: {
    info: (msg: string) => void;
    warn: (msg: string) => void;
    error: (msg: string) => void;
    debug: (msg: string) => void;
  };
}

/**
 * Action execution result
 */
export interface ActionResult {
  success: boolean;
  message?: string;
  data?: Record<string, any>;
}

/**
 * Action configuration for your plugin
 */
export interface MyActionConfig {
  source: string;
  destination: string;
  options?: {
    verbose?: boolean;
    // Add your custom options here
  };
}

/**
 * My Custom Action Plugin
 * 
 * This action does something useful with files.
 */
export class MyAction {
  /**
   * Unique identifier for the action
   */
  static readonly actionName = 'my_action';

  /**
   * Human-readable description
   */
  static readonly description = 'Performs a custom action on files';

  /**
   * Plugin metadata
   */
  static readonly pluginInfo: PluginInfo = {
    name: 'kist-action-myaction',
    version: '1.0.0',
    description: 'A custom kist action plugin'
  };

  /**
   * Configuration schema for validation
   */
  static readonly configSchema = {
    type: 'object',
    required: ['source', 'destination'],
    properties: {
      source: {
        type: 'string',
        description: 'Source file or directory'
      },
      destination: {
        type: 'string',
        description: 'Destination file or directory'
      },
      options: {
        type: 'object',
        properties: {
          verbose: {
            type: 'boolean',
            description: 'Enable verbose output'
          }
        }
      }
    }
  };

  /**
   * Execute the action
   * 
   * @param config - Action configuration from kist.yml
   * @param context - Execution context with working directory and logger
   * @returns Promise resolving to action result
   */
  async execute(
    config: MyActionConfig,
    context: ActionContext
  ): Promise<ActionResult> {
    const { source, destination, options = {} } = config;
    const { workingDir, logger } = context;

    try {
      // Resolve paths relative to working directory
      const sourcePath = path.resolve(workingDir, source);
      const destPath = path.resolve(workingDir, destination);

      // Validate source exists
      if (!fs.existsSync(sourcePath)) {
        return {
          success: false,
          message: `Source not found: ${sourcePath}`
        };
      }

      if (options.verbose) {
        logger.info(`Processing: ${sourcePath}`);
      }

      // Your action logic here
      // ...

      logger.info(`Successfully processed ${source} → ${destination}`);

      return {
        success: true,
        message: 'Action completed successfully',
        data: {
          source: sourcePath,
          destination: destPath
        }
      };
    } catch (error) {
      const errorMessage = error instanceof Error 
        ? error.message 
        : String(error);

      logger.error(`Action failed: ${errorMessage}`);

      return {
        success: false,
        message: errorMessage
      };
    }
  }
}

// Default export for plugin discovery
export default MyAction;

5. Configure Package.json ​

Update your package.json:

json
{
  "name": "kist-action-myaction",
  "version": "1.0.0",
  "description": "A custom kist action plugin",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "keywords": [
    "kist",
    "kist-plugin",
    "kist-action"
  ],
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "kist": ">=0.1.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0"
  }
}

Configuration Schema ​

Validation ​

Kist validates action configurations against the configSchema. Use JSON Schema format:

typescript
static readonly configSchema = {
  type: 'object',
  required: ['inputFile', 'outputFile'],
  properties: {
    inputFile: {
      type: 'string',
      description: 'Input file path'
    },
    outputFile: {
      type: 'string',
      description: 'Output file path'
    },
    minify: {
      type: 'boolean',
      default: false,
      description: 'Enable minification'
    },
    sourcemap: {
      type: 'boolean',
      default: true,
      description: 'Generate source maps'
    },
    advanced: {
      type: 'object',
      properties: {
        optimization: {
          type: 'string',
          enum: ['none', 'basic', 'full'],
          default: 'basic'
        }
      }
    }
  }
};

Complex Validation Example ​

typescript
static readonly configSchema = {
  type: 'object',
  required: ['source'],
  properties: {
    source: {
      oneOf: [
        { type: 'string' },
        { 
          type: 'array',
          items: { type: 'string' }
        }
      ],
      description: 'Source file(s) - can be string or array'
    },
    output: {
      type: 'object',
      required: ['directory'],
      properties: {
        directory: { type: 'string' },
        filename: { 
          type: 'string',
          pattern: '^[a-zA-Z0-9_-]+\\.[a-z]+$'
        }
      }
    },
    transforms: {
      type: 'array',
      items: {
        type: 'object',
        required: ['type'],
        properties: {
          type: { 
            type: 'string',
            enum: ['minify', 'compress', 'optimize']
          },
          options: { type: 'object' }
        }
      }
    }
  }
};

Testing Your Plugin ​

Unit Testing with Jest ​

Create jest.config.js:

javascript
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/*.test.ts'],
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov']
};

Create src/index.test.ts:

typescript
import { MyAction } from './index';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

describe('MyAction', () => {
  let tempDir: string;
  let mockLogger: any;

  beforeEach(() => {
    // Create temp directory for tests
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'myaction-test-'));
    
    // Mock logger
    mockLogger = {
      info: jest.fn(),
      warn: jest.fn(),
      error: jest.fn(),
      debug: jest.fn()
    };
  });

  afterEach(() => {
    // Clean up temp directory
    fs.rmSync(tempDir, { recursive: true, force: true });
  });

  it('should have correct action name', () => {
    expect(MyAction.actionName).toBe('my_action');
  });

  it('should have valid config schema', () => {
    expect(MyAction.configSchema).toBeDefined();
    expect(MyAction.configSchema.type).toBe('object');
    expect(MyAction.configSchema.required).toContain('source');
  });

  it('should execute successfully with valid config', async () => {
    // Setup test file
    const sourceFile = path.join(tempDir, 'input.txt');
    fs.writeFileSync(sourceFile, 'test content');

    const action = new MyAction();
    const result = await action.execute(
      {
        source: 'input.txt',
        destination: 'output.txt'
      },
      {
        workingDir: tempDir,
        config: {},
        logger: mockLogger
      }
    );

    expect(result.success).toBe(true);
  });

  it('should fail when source does not exist', async () => {
    const action = new MyAction();
    const result = await action.execute(
      {
        source: 'nonexistent.txt',
        destination: 'output.txt'
      },
      {
        workingDir: tempDir,
        config: {},
        logger: mockLogger
      }
    );

    expect(result.success).toBe(false);
    expect(result.message).toContain('Source not found');
  });

  it('should log verbose output when enabled', async () => {
    const sourceFile = path.join(tempDir, 'input.txt');
    fs.writeFileSync(sourceFile, 'test content');

    const action = new MyAction();
    await action.execute(
      {
        source: 'input.txt',
        destination: 'output.txt',
        options: { verbose: true }
      },
      {
        workingDir: tempDir,
        config: {},
        logger: mockLogger
      }
    );

    expect(mockLogger.info).toHaveBeenCalled();
  });
});

Run Tests ​

bash
npm install jest ts-jest @types/jest --save-dev
npm test

Integration Testing ​

Test your plugin with kist in a real project:

bash
# Link your plugin locally
cd kist-action-myaction
npm link

# In a test project
cd ../test-project
npm link kist-action-myaction

# Create kist.yml
cat > kist.yml << 'EOF'
project:
  name: test-project
  version: 1.0.0

actions:
  - name: my_action
    source: src/input.txt
    destination: dist/output.txt
    options:
      verbose: true
EOF

# Run kist
npx kist run

Best Practices ​

Error Handling ​

Always wrap your action logic in try-catch and return meaningful error messages:

typescript
async execute(config: Config, context: ActionContext): Promise<ActionResult> {
  try {
    // Validate inputs
    if (!config.source) {
      return {
        success: false,
        message: 'Missing required configuration: source'
      };
    }

    // Perform action
    await this.processFile(config, context);

    return { success: true };
  } catch (error) {
    // Handle specific error types
    if (error instanceof FileNotFoundError) {
      return {
        success: false,
        message: `File not found: ${error.path}`
      };
    }

    // Log full error for debugging
    context.logger.error(`Unexpected error: ${error}`);
    
    return {
      success: false,
      message: error instanceof Error ? error.message : 'Unknown error'
    };
  }
}

Logging ​

Use the provided logger for consistent output:

typescript
// Information about progress
context.logger.info('Starting file processing...');

// Warnings that don't stop execution
context.logger.warn('File already exists, will be overwritten');

// Errors that cause failure
context.logger.error('Failed to read configuration file');

// Debug info (only shown in verbose mode)
context.logger.debug(`Processing options: ${JSON.stringify(options)}`);

Path Handling ​

Always resolve paths relative to the working directory:

typescript
// Good - resolved to working directory
const sourcePath = path.resolve(context.workingDir, config.source);

// Bad - relative to current process directory
const sourcePath = config.source;

Async Operations ​

Use async/await for file operations:

typescript
// Good - async with proper error handling
async execute(config: Config, context: ActionContext): Promise<ActionResult> {
  const content = await fs.promises.readFile(sourcePath, 'utf-8');
  await fs.promises.writeFile(destPath, processedContent);
  return { success: true };
}

// Avoid - synchronous operations block the event loop
execute(config: Config, context: ActionContext): ActionResult {
  const content = fs.readFileSync(sourcePath, 'utf-8');
  fs.writeFileSync(destPath, processedContent);
  return { success: true };
}

Publishing Your Plugin ​

1. Prepare for Publication ​

bash
# Build your plugin
npm run build

# Run tests
npm test

# Check what will be published
npm pack --dry-run

2. Update Documentation ​

Create a comprehensive README.md:

markdown
# kist-action-myaction

A kist plugin for doing X.

## Installation

\`\`\`bash
npm install kist-action-myaction
\`\`\`

## Usage

In your `kist.yml`:

\`\`\`yaml
actions:
  - name: my_action
    source: src/input.txt
    destination: dist/output.txt
    options:
      verbose: true
\`\`\`

## Configuration

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| source | string | Yes | - | Source file path |
| destination | string | Yes | - | Destination file path |
| options.verbose | boolean | No | false | Enable verbose output |

## Examples

### Basic Usage

\`\`\`yaml
actions:
  - name: my_action
    source: input.txt
    destination: output.txt
\`\`\`

### With All Options

\`\`\`yaml
actions:
  - name: my_action
    source: src/data.json
    destination: dist/data.min.json
    options:
      verbose: true
\`\`\`

## License

MIT

3. Publish to npm ​

bash
# Login to npm
npm login

# Publish
npm publish

4. Add to Community Plugins ​

Submit a pull request to add your plugin to the kist community plugins list.

Official Plugins ​

Kist maintains several official plugins that serve as excellent references:

PluginDescriptionSource
kist-action-sassCompile SASS/SCSS to CSSGitHub
kist-action-typescriptCompile TypeScriptGitHub
kist-action-jinjaProcess Jinja2 templatesGitHub
kist-action-svgOptimize and package SVGsGitHub
kist-action-testRun tests with jest/vitestGitHub

Troubleshooting ​

Plugin Not Found ​

If kist can't find your plugin:

  1. Ensure it's installed: npm list kist-action-myaction
  2. Check the package name in package.json starts with kist-action-
  3. Verify the main entry point exports the action class

Configuration Validation Errors ​

If you get schema validation errors:

  1. Check your configSchema matches the expected format
  2. Ensure all required fields are defined in the schema
  3. Verify types match (string vs array, etc.)

Action Not Executing ​

If your action isn't running:

  1. Check the action name in kist.yml matches actionName
  2. Look for errors in the kist output
  3. Enable verbose mode: kist run --verbose

Next Steps ​

Released under the MIT License.