Guide

Plugin Development

An osn. plugin is a standard npm package that exports a plugin definition created with the `definePlugin()` factory function from @osndot/sdk. Plugins can register CLI commands, hook into task lifecycle events, and access a shared context.

osn. Banner

Project Setup

From Scratch

mkdir osn-plugin-example && cd osn-plugin-example
pnpm init
pnpm add @osndot/sdk
pnpm add -D typescript tsup vitest @types/node
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "isolatedModules": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
import { defineConfig } from "tsup";

export default defineConfig({
    entry: ["src/index.ts"],
    format: ["esm"],
    dts: true,
    clean: true,
    sourcemap: true,
    target: "node20",
});

Package Conventions

Your package.json should follow these conventions:

{
  "name": "@yourscope/plugin-example",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "dependencies": {
    "@osndot/sdk": "latest"
  }
}

For official plugins within the osn. monorepo, use the @osndot scope and the workspace:* protocol for the SDK dependency.

Creating a Plugin

Basic Structure

// src/index.ts
import { definePlugin } from "@osndot/sdk";

export default definePlugin({
  name: "@yourscope/plugin-example",
  version: "1.0.0",
  description: "An example plugin for OSN",
  setup(ctx) {
    return {
      commands: [
        {
          name: "example:hello",
          description: "Print a greeting",
          handler: async () => {
            ctx.logger.info("Hello from the example plugin.");
          },
        },
      ],
      hooks: {
        onLoad: () => ctx.logger.debug("Plugin loaded."),
        onUnload: () => ctx.logger.debug("Plugin unloaded."),
      },
    };
  },
});

Async Setup

The setup function can be asynchronous. This is useful when plugin initialization requires network requests, file system reads, or other async operations:

export default definePlugin({
  name: "@yourscope/plugin-db",
  version: "1.0.0",
  description: "Database plugin",
  async setup(ctx) {
    // Perform async initialization
    const schema = await loadDatabaseSchema(ctx.cwd);

    return {
      commands: [
        {
          name: "db:migrate",
          description: "Run database migrations",
          handler: async () => {
            await runMigrations(schema);
            ctx.logger.success("Migrations complete.");
          },
        },
      ],
    };
  },
});

When using async setup, the CLI plugin loader awaits the setup completion before registering commands and hooks. Both sync and async plugins work transparently.

Plugin Context

The setup function receives a PluginContext object:

PropertyTypeDescription
ctx.cwdstringCurrent working directory. Uses a getter, so the value is always current rather than captured at import time.
ctx.configRecord<string, unknown>Plugin-specific configuration from the plugins entry in project.json.
ctx.loggerLoggerLogging interface with methods: info, warn, error, debug, success.

Using the Logger

ctx.logger.info("Processing files...");
ctx.logger.warn("Deprecation notice: use the new API.");
ctx.logger.error("Connection failed.");
ctx.logger.debug("Internal state: ready");
ctx.logger.success("Operation completed.");

Log output respects the global --verbose and --debug flags. Debug messages are only visible when the user runs with --debug.

Accessing the Working Directory

const configPath = `${ctx.cwd}/.env`;

The cwd property dynamically resolves to the current working directory on every access, so it always reflects the user's actual location.

Lifecycle Hooks

Hooks allow plugins to react to events in the CLI lifecycle.

HookSignatureWhen It Runs
onLoad() => void | Promise<void>After the plugin is imported and its setup completes. Called once during CLI startup.
onUnload() => void | Promise<void>When the CLI process is shutting down. Handles SIGINT, SIGTERM, and normal exit. Use for cleanup: closing connections, flushing buffers.
onBeforeTask(taskName: string) => void | Promise<void>Before any task command is executed via osn run. Called for every task, including dependencies.
onAfterTask(taskName: string, success: boolean) => void | Promise<void>After a task command completes. The success parameter indicates whether the command exited successfully.

Hook Example

hooks: {
  onLoad: () => {
    ctx.logger.debug("Initializing resources...");
  },
  onUnload: async () => {
    await closeDatabase();
    ctx.logger.debug("Cleanup complete.");
  },
  onBeforeTask: (taskName) => {
    ctx.logger.info(`Preparing for task: ${taskName}`);
  },
  onAfterTask: (taskName, success) => {
    if (!success) {
      ctx.logger.warn(`Task ${taskName} failed. Check the output above.`);
    }
  },
},

Error Handling in Hooks

Hook errors are caught by the CLI and logged as warnings. They do not prevent task execution or other hooks from running. This ensures that a misbehaving plugin does not break the user's workflow.

Registering Commands

Commands are the primary way plugins add functionality to the CLI.

Command Structure

{
  name: "prefix:action",
  description: "What this command does",
  handler: async () => {
    // Implementation
  },
}

Naming Convention

Command names use the prefix:action format. The CLI automatically maps this to a subcommand structure:

Plugin NameCommand NameCLI Command
@osndot/plugin-gitgit:statusosn git status
@osndot/plugin-gitgit:logosn git log
@myorg/plugin-deploydeploy:stagingosn deploy staging

The prefix is derived from the plugin name by removing @scope/plugin-.

Running Shell Commands

Plugins commonly execute shell commands. Use Node.js built-in modules:

import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

// Inside a command handler:
handler: async () => {
  try {
    const { stdout } = await execAsync("git status --short", {
      cwd: ctx.cwd,
    });
    ctx.logger.info(stdout || "Working directory clean.");
  } catch (error) {
    ctx.logger.error("Git is not available.");
  }
},

File System Operations

For file-based plugins, use node:fs/promises:

import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

handler: async () => {
  const envPath = join(ctx.cwd, ".env");
  const content = await readFile(envPath, "utf-8");
  ctx.logger.info(content);
},

Testing Plugins

Test Setup

Add a vitest.config.ts to your plugin:

import { defineConfig } from "vitest/config";

export default defineConfig({
    test: {
        globals: true,
        environment: "node",
        include: ["src/**/*.test.ts"],
    },
});

Structure Tests

Verify that the plugin exports the correct metadata, commands, and hooks:

import { describe, it, expect } from "vitest";
import plugin from "../index.js";

describe("my-plugin", () => {
  it("should have correct metadata", () => {
    expect(plugin.name).toBe("@yourscope/plugin-example");
    expect(plugin.version).toBe("1.0.0");
  });

  it("should register expected commands", () => {
    expect(plugin.commands).toHaveLength(1);
    expect(plugin.commands![0].name).toBe("example:hello");
  });

  it("should define lifecycle hooks", () => {
    expect(typeof plugin.onLoad).toBe("function");
    expect(typeof plugin.onUnload).toBe("function");
  });
});

Handler Tests

For handlers that interact with the file system or shell, use temporary directories and mocks:

import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

let testDir: string;

beforeEach(async () => {
  testDir = await mkdtemp(join(tmpdir(), "plugin-test-"));
});

afterEach(async () => {
  await rm(testDir, { recursive: true });
});

Publishing

Build and Verify

pnpm build
pnpm test

Publish to npm

npm publish --access public

Peer Dependencies

When publishing your plugin, make sure your build output references the OSN SDK properly in your package.json. You should set it as a peerDependency:

{
  "name": "@my-org/plugin-custom",
  "peerDependencies": {
    "@osndot/sdk": ">=0.1.0"
  }
}

Plugin Naming Convention

  • Official plugins: @osndot/plugin-<name>
  • Third-party plugins: @yourscope/plugin-<name> or osn-plugin-<name>

Installation by Users

osn plugin add @yourscope/plugin-example

The CLI validates the installed package, reads its version, and registers it in the user's project.json.

Type Reference

All types are exported from @osndot/sdk:

import type {
  OsnPlugin,
  PluginContext,
  PluginCommand,
  LifecycleHooks,
  DefinePluginOptions,
  OsnPluginSetupResult,
} from "@osndot/sdk";
TypeDescription
OsnPluginThe fully resolved plugin object with metadata, commands, and hooks
PluginContextContext passed to the setup function
PluginCommandA single command definition with name, description, and handler
LifecycleHooksInterface for all supported lifecycle hooks
DefinePluginOptionsOptions accepted by definePlugin()
OsnPluginSetupResultReturn type of the setup function
← PreviousArchitectureNext →Configuration