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:
- Remark Plugins - Transform the Markdown AST
- Edge Rules - Add new relationship types
- Projections - Create domain-specific views
- Executors - Custom task execution strategies
- 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.mdExport Pattern
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