---
url: /guide/plugins.md
---
# mdspec Plugin System

mdspec supports custom plugins to enable in-process command execution, replacing the default bash subprocess execution.

## Overview

By default, mdspec executes console blocks by spawning bash subprocesses for each command. This is flexible but adds ~200ms overhead per command. For test suites with hundreds of commands, this adds up to 30-40 seconds of pure subprocess overhead.

**Plugins solve this** by allowing custom execution engines that can run commands in-process, reducing test time from ~41s to ~5s (8x speedup).

## Plugin Interface

A plugin is a TypeScript module that exports a factory function:

```typescript
import type { Plugin, FileOpts, BlockOpts, ReplResult } from "mdspec/types"

export default function myPlugin(fileOpts: FileOpts): Plugin {
  // File-level initialization

  return {
    // Required: decide whether to handle a block
    block(blockOpts: BlockOpts): ((cmd: string) => Promise<ReplResult>) | null {
      // Return null to fall back to bash
      if (blockOpts.type !== "console") return null

      // Return executor function to handle this block
      return async (cmd: string) => {
        // Execute command and return result
        return {
          stdout: "...",
          stderr: "",
          exitCode: 0,
        }
      }
    },

    // Optional: lifecycle hooks
    async beforeAll(): Promise<void> {},
    async afterAll(): Promise<void> {},
    async beforeEach(): Promise<void> {},
    async afterEach(): Promise<void> {},
  }
}
```

### Types

```typescript
// File-level options passed to plugin factory
interface FileOpts {
  path: string // Test file path
  files: Map<string, string> // file= blocks
  [key: string]: unknown // Frontmatter options
}

// Block-level options passed to block() method
interface BlockOpts {
  type: string // Block language (console, sh, bash)
  content: string // Raw block text
  heading: string[] // Heading path
  [key: string]: unknown // Merged options (frontmatter + heading + fence)
}

// Command execution result
interface ReplResult {
  stdout: string // Command output
  stderr: string // Error output
  exitCode: number | null // Exit code (0 = success, null = unknown)
}
```

## Using Plugins

### Via Frontmatter

Add frontmatter to your test file:

```markdown
---
mdspec:
  plugin: ./my-plugin.ts
  customOption: value
---

# My Tests

\`\`\`console
$ my-command arg1 arg2
expected output
\`\`\`
```

Plugin resolution:

* **Relative path** (starts with `./` or `../`): resolved relative to test file
* **Built-in** name (`bash`): uses built-in plugin
* **Bare specifier** (`@scope/package`): resolved from node\_modules

### Options Merging

Options cascade from multiple levels:

```markdown
---
mdspec:
  plugin: ./plugin.ts
  fixture: default # File-level
---

## Test Suite {fixture=two-columns} # Heading-level

\`\`\`console fixture=custom reset # Fence-level
$ command
\`\`\`
```

Priority: **frontmatter** → **heading** → **fence** (later overrides earlier)

## Built-in Bash Plugin

The default `bash` plugin extracts mdspec's current bash execution logic:

```typescript
import { bash } from "mdspec/plugins/bash"

export default function myPlugin(opts: FileOpts): Plugin {
  // Use bash for mixed commands
  if (hasMixedCommands) {
    return bash(opts)
  }

  // Custom handling for pure commands
  return {
    /* ... */
  }
}
```

Features:

* State persistence (env vars, cwd, bash functions)
* Hook support (beforeAll, afterEach, etc.)
* Continuation lines (`>`)
* Reset flag support

## Example: Custom CLI Plugin

Real-world example of a plugin that intercepts commands for a specific CLI tool:

```typescript
// tests/my-cli-plugin.ts
import { $ } from "bun"
import type { Plugin, FileOpts, BlockOpts, ReplResult } from "mdspec/types"

export default function myCliPlugin(_opts: FileOpts): Plugin {
  return {
    block(blockOpts: BlockOpts) {
      // Only handle console blocks with mycli commands
      if (blockOpts.type !== "console") return null

      const commands = extractCommands(blockOpts.content)
      const hasMyCommands = commands.some((c) => c.startsWith("mycli "))
      const hasOtherCommands = commands.some((c) => !c.startsWith("mycli "))

      // Only handle pure mycli command blocks
      if (!hasMyCommands || hasOtherCommands) return null

      // Return executor using bunShell
      return async (cmd: string): Promise<ReplResult> => {
        const cliPath = `${process.env.ROOT}/src/cli/index.ts`
        const result = await $\`bash -c ${setupCliFunction(cliPath)} && ${cmd}\`\`.quiet()

        return {
          stdout: result.stdout.toString().trimEnd(),
          stderr: result.stderr.toString().trimEnd(),
          exitCode: result.exitCode,
        }
      }
    },
  }
}
```

Usage in test file:

```markdown
---
mdspec:
  plugin: ./tests/my-cli-plugin.ts
---

# CLI Tests

\`\`\`console
$ mycli init .
Created .mycli directory

$ mycli list
inbox.md
\`\`\`
```

## Performance Benefits

**Before (bash subprocess per command):**

```
222 commands × 200ms overhead = ~44 seconds
Actual test logic: ~3 seconds
Total: ~47 seconds
```

**After (in-process execution):**

```
222 commands × ~20ms (bun shell) = ~4 seconds
Actual test logic: ~3 seconds
Total: ~7 seconds (~7x speedup)
```

**With true in-process (planned):**

```
222 commands × ~1ms = ~0.2 seconds
Actual test logic: ~3 seconds
Total: ~3 seconds (~15x speedup)
```

## Lifecycle Hooks

Plugins can provide lifecycle hooks for setup/teardown:

```typescript
export default function myPlugin(opts: FileOpts): Plugin {
  let sharedResource: any

  return {
    async beforeAll() {
      // Run once before any blocks
      sharedResource = await initialize()
    },

    async beforeEach() {
      // Run before each block
      await sharedResource.reset()
    },

    async afterEach() {
      // Run after each block (even on failure)
      await sharedResource.cleanup()
    },

    async afterAll() {
      // Run once after all blocks (even on failure)
      await sharedResource.dispose()
    },

    block(opts) {
      /* ... */
    },
  }
}
```

Note: Hook-defining blocks are pre-scanned and executed before any test blocks run, so `beforeAll()` can be defined in any block.

## State Management

Plugins can maintain state across blocks:

```typescript
export default function statefulPlugin(opts: FileOpts): Plugin {
  // File-level state (persists across blocks)
  let repoPath: string | null = null
  let connection: any = null

  return {
    block(blockOpts) {
      // Reset state if requested
      if (blockOpts.reset) {
        repoPath = null
        connection?.close()
        connection = null
      }

      return async (cmd: string) => {
        // Use/update state
        if (!connection) {
          connection = await connect(repoPath ?? ".")
        }

        return executeWithConnection(connection, cmd)
      }
    },
  }
}
```

## Mixing Plugins and Bash

You can fall back to bash for specific blocks:

```typescript
export default function selectivePlugin(opts: FileOpts): Plugin {
  return {
    block(blockOpts) {
      // Let bash handle setup blocks
      if (blockOpts.heading.includes("Setup")) {
        return null // Fall back to bash
      }

      // Handle application blocks in-process
      return async (cmd) => executeInProcess(cmd)
    },
  }
}
```

## Best Practices

1. **Start simple:** Use bunShell or similar before building full in-process execution
2. **Test isolation:** Reset state between blocks unless explicitly shared
3. **Graceful fallback:** Return `null` from `block()` to let bash handle edge cases
4. **Handle errors:** Wrap execution in try/catch and return proper exit codes
5. **Document options:** Clearly document what frontmatter options your plugin accepts

## Debugging

Enable debug logging:

```bash
DEBUG=mdspec:* bun run mdspec test.md
```

This shows:

* Plugin loading
* Block handling decisions
* Command execution
* State changes

## Future Enhancements

Planned features:

* Plugin composition (chain multiple plugins)
* Built-in plugins for common tools (npm, git, etc.)
* Snapshot testing support
* Watch mode with fast re-runs
