Spry LogoOpsfolio
Contributing and Support

Custom Extensions

Build plugins and extensions for Spry

Creating Custom Extensions

Build plugins and extensions for Spry.

Overview

Spry can be extended at multiple levels:

  1. Remark Plugins - Transform the Markdown AST
  2. Edge Rules - Add new relationship types
  3. Projections - Create domain-specific views
  4. Executors - Custom task execution strategies
  5. CLI Commands - Add new command-line interfaces

Remark Plugins

Remark plugins transform the MDAST before graph construction.

Plugin Structure

import type { Plugin } from "unified";
import type { Root } from "mdast";
import { visit } from "unist-util-visit";

export const myPlugin: Plugin<[MyOptions?], Root> = (options = {}) => {
  return (tree: Root) => {
    visit(tree, "code", (node) => {
      // Transform code nodes
      if (node.lang === "myLanguage") {
        node.data = node.data || {};
        node.data.myCustomField = processNode(node);
      }
    });
  };
};

Example: Auto-Description Plugin

Automatically generate descriptions from code content:

import type { Plugin } from "unified";
import type { Root, Code } from "mdast";
import { visit } from "unist-util-visit";

interface AutoDescribeOptions {
  languages?: string[];
  maxLength?: number;
}

export const autoDescribe: Plugin<[AutoDescribeOptions?], Root> = (
  options = {}
) => {
  const { languages = ["bash", "sh"], maxLength = 50 } = options;

  return (tree: Root) => {
    visit<Root, "code">(tree, "code", (node: Code) => {
      if (!languages.includes(node.lang || "")) return;

      const fm = node.data?.codeFM;
      if (fm?.spawnableArgs?.descr) return; // Already has description

      // Generate description from first comment
      const firstLine = node.value.split("\n")[0];
      if (firstLine.startsWith("#")) {
        const description = firstLine.slice(1).trim().slice(0, maxLength);
        node.data = node.data || {};
        node.data.codeFM = node.data.codeFM || {};
        node.data.codeFM.spawnableArgs = node.data.codeFM.spawnableArgs || {};
        node.data.codeFM.spawnableArgs.descr = description;
      }
    });
  };
};

Using Custom Plugins

import { unified } from "unified";
import remarkParse from "remark-parse";
import { autoDescribe } from "./plugins/auto-describe.ts";

const processor = unified()
  .use(remarkParse)
  .use(autoDescribe, { languages: ["bash", "python"] });

Edge Rules

Add new relationship types to the graph.

Rule Structure

import type { EdgeRule } from "@spry/axiom";

export const myRule: EdgeRule<"myRelationship"> = {
  rel: "myRelationship",
  apply: function* (root) {
    // Traverse AST and yield edges
  },
};

Example: External Reference Rule

Track references to external URLs:

import type { EdgeRule, GraphEdge } from "@spry/axiom";
import type { Root, Link } from "mdast";
import { visit } from "unist-util-visit";

interface ExternalRef {
  type: "externalRef";
  url: string;
}

export const externalReferencesRule: EdgeRule<"referencesExternal"> = {
  rel: "referencesExternal",
  apply: function* (root: Root): Generator<GraphEdge<"referencesExternal">> {
    visit<Root, "link">(root, "link", (node: Link, index, parent) => {
      if (node.url.startsWith("http://") || node.url.startsWith("https://")) {
        const refNode: ExternalRef = {
          type: "externalRef",
          url: node.url,
        };
        yield {
          rel: "referencesExternal",
          from: node,
          to: refNode as any, // Virtual node
        };
      }
    });
  },
};

Registering Custom Rules

import { graph, typicalRules } from "@spry/axiom";
import { externalReferencesRule } from "./rules/external-refs.ts";

const customRules = [...typicalRules(), externalReferencesRule];
const g = graph(root, customRules);

Custom Projections

Create domain-specific views of the graph.

Projection Structure

import type { Graph, GraphEdge } from "@spry/axiom";

interface MyProjection {
  items: MyItem[];
  relationships: MyRelationship[];
}

export function buildMyProjection(graph: Graph): MyProjection {
  const items: MyItem[] = [];
  const relationships: MyRelationship[] = [];

  // Transform graph into domain view
  for (const edge of graph.edges) {
    if (edge.rel === "myRelationship") {
      // Process edge
    }
  }

  return { items, relationships };
}

Example: Documentation Projection

View for generating API documentation:

interface DocItem {
  id: string;
  title: string;
  description: string;
  examples: string[];
  section: string;
}

interface DocumentationProjection {
  items: DocItem[];
  toc: { title: string; items: DocItem[] }[];
}

export function buildDocProjection(graph: Graph): DocumentationProjection {
  const items: DocItem[] = [];

  // Find all code cells with documentation
  visit(graph.root, "code", (node) => {
    const fm = node.data?.codeFM;
    if (!fm?.identity) return;

    // Find containing section
    const sectionEdge = graph.edges.find(
      e => e.from === node && e.rel === "containedInSection"
    );
    const section = sectionEdge ? toString(sectionEdge.to) : "Uncategorized";

    items.push({
      id: fm.identity,
      title: fm.identity,
      description: fm.spawnableArgs?.descr || "",
      examples: [node.value],
      section,
    });
  });

  // Group by section
  const bySection = groupBy(items, i => i.section);
  const toc = Object.entries(bySection).map(([title, items]) => ({
    title,
    items,
  }));

  return { items, toc };
}

Custom Executors

Create new ways to execute tasks.

Executor Interface

interface TaskExecutor<T extends Task> {
  (task: T, context: ExecutionContext): Promise<TaskResult>;
}

interface TaskResult {
  exitCode: number;
  stdout?: string;
  stderr?: string;
  duration?: number;
}

Example: Docker Executor

Run tasks in Docker containers:

import { spawn } from "@spry/universal/shell";

interface DockerOptions {
  image: string;
  volumes?: string[];
  environment?: Record<string, string>;
}

export function dockerExecutor(options: DockerOptions): TaskExecutor {
  return async (task, context) => {
    const args = [
      "run",
      "--rm",
      ...Object.entries(options.environment || {}).flatMap(([k, v]) => [
        "-e",
        `${k}=${v}`,
      ]),
      ...(options.volumes || []).flatMap(v => ["-v", v]),
      options.image,
      "sh",
      "-c",
      task.code,
    ];

    const result = await spawn("docker", args);

    return {
      exitCode: result.exitCode,
      stdout: result.stdout,
      stderr: result.stderr,
    };
  };
}

Using Custom Executors

const executor = dockerExecutor({
  image: "node:18-alpine",
  volumes: ["./src:/app/src"],
});

await executeDAG(plan, { executor });

CLI Extensions

Add new commands to the Spry CLI.

Command Structure

import { Command } from "cliffy/command";

export function myCommand() {
  return new Command()
    .name("my-command")
    .description("My custom command")
    .option("-v, --verbose", "Verbose output")
    .arguments("<file:string>")
    .action(async (options, file) => {
      // Command implementation
    });
}

Registering Commands

import { CLI } from "@spry/axiom/text-ui/cli";
import { myCommand } from "./commands/my-command.ts";

const cli = new CLI();
cli.rootCmd().command("my", myCommand());
cli.run(Deno.args);

Publishing Extensions

Package Structure

my-spry-extension/
├── mod.ts           # Main exports
├── plugins/
│   └── my-plugin.ts
├── rules/
│   └── my-rule.ts
├── projections/
│   └── my-projection.ts
├── deno.jsonc
└── README.md

Export Pattern

mod.ts
export { myPlugin } from "./plugins/my-plugin.ts";
export { myRule } from "./rules/my-rule.ts";
export { buildMyProjection } from "./projections/my-projection.ts";
export type { MyProjection, MyOptions } from "./types.ts";

Usage by Consumers

import { myPlugin, myRule, buildMyProjection } from "my-spry-extension";

Best Practices

Type Safety

Use TypeScript for all extensions to catch errors early and improve maintainability.

Single Purpose

Each extension should do one thing well and be composable with others.

Documentation

Include clear examples and API documentation for all public interfaces.

Testing

Write comprehensive tests for all extension points to ensure reliability.

Composability

Design extensions to work seamlessly with other extensions and built-in features.

📚 Need Help?

Check the source code for built-in plugins and rules to see real-world examples of extension patterns. The Spry codebase follows consistent conventions that make it easy to learn by example.

How is this guide?

Last updated on

On this page