Plugins
Tray has a TypeScript plugin system inspired by Vite. Plugins can add custom CLI commands and hook into lifecycle events like part creation, stock changes, and build completions.
Plugins are registered in a TypeScript config file. They run in-process with full access to the database. Hook errors are caught and logged — a buggy plugin never crashes Tray.
Quick Start
1. Create a Plugin
// ~/.tray/plugins/logger.ts
import type { TrayPlugin } from "@tray/core";
export default function logger(): TrayPlugin {
return {
name: "logger",
onPartCreated: async (ctx, part) => {
ctx.log(`[logger] Part created: ${part.name} (stock: ${part.stock})`);
},
onStockChanged: async (ctx, partId, oldStock, newStock) => {
ctx.log(`[logger] Stock changed for part #${partId}: ${oldStock} -> ${newStock}`);
},
onLowStock: async (ctx, part) => {
ctx.log(`[logger] LOW STOCK: ${part.name} has ${part.stock}, min is ${part.min_stock}`);
},
};
}
2. Register in Config
// ~/.tray/config.ts
import type { TrayConfig } from "@tray/core";
import logger from "./plugins/logger.ts";
export default {
plugins: [
logger(),
],
} satisfies TrayConfig;
3. Use Tray Normally
Plugins activate automatically. When you add a part, the logger fires:
tray add "NE555" --stock 25
# [logger] Part created: NE555 (stock: 25)
Plugin Interface
interface TrayPlugin {
name: string;
commands?: Record<string, CommandHandler>;
onPartCreated?: (ctx: PluginContext, part: PartWithDetails) => Promise<void>;
onPartUpdated?: (ctx: PluginContext, part: PartWithDetails, old: Part) => Promise<void>;
onPartDeleted?: (ctx: PluginContext, partId: number) => Promise<void>;
onStockChanged?: (ctx: PluginContext, partId: number, oldStock: number, newStock: number) => Promise<void>;
onLowStock?: (ctx: PluginContext, part: PartWithDetails) => Promise<void>;
onBuildCompleted?: (ctx: PluginContext, buildOrder: BuildOrder) => Promise<void>;
}
All hooks are optional. Implement only what you need.
Plugin Context
Every hook and command receives a PluginContext:
interface PluginContext {
db: Kysely<Database>; // Direct database access (read/write)
log: (message: string) => void; // Log output visible to the user
}
The db field gives you full Kysely query access to the Tray database. You can read any table, insert data, run raw SQL — anything the core can do.
Custom Commands
Plugins can register CLI commands:
export default function digikey(config: { apiKey: string }): TrayPlugin {
return {
name: "digikey",
commands: {
"digikey-search": async (ctx, args) => {
const query = args.join(" ");
const response = await fetch(
`https://api.digikey.com/Search/v3/Products/Keyword?keywords=${query}`,
{ headers: { "X-IBMM-Client-Id": config.apiKey } },
);
const data = await response.json();
ctx.log(JSON.stringify(data, null, 2));
},
"digikey-import": async (ctx, args) => {
const partNumber = args[0];
// Fetch part data from DigiKey, add to inventory
// ...
},
},
};
}
Usage:
tray digikey-search "NE555"
tray digikey-import "296-1411-5-ND"
Lifecycle Hooks
onPartCreated
Fired after a part is created. Receives the full PartWithDetails (includes tags, category path, parameters).
Use cases: auto-enrich with manufacturer data, assign default category, send notification.
onPartUpdated
Fired after a part is updated. Receives both the new state and a snapshot of the old state.
Use cases: sync changes to external system, log field-level diffs.
onPartDeleted
Fired after a part is deleted. Receives the part ID (the part itself no longer exists in the database).
Use cases: clean up external references, log deletion.
onStockChanged
Fired when a part’s stock level changes (after any lot insert/update/delete). Receives the part ID, old stock, and new stock.
Use cases: real-time dashboard updates, integration with external systems.
onLowStock
Fired automatically when onStockChanged detects that stock has dropped to or below min_stock. Only fires when stock is decreasing (not when stock increases through a threshold).
Use cases: Slack/email alerts, auto-generate purchase order, trigger reorder script.
onBuildCompleted
Fired after a build order is marked complete and stock has been deducted.
Use cases: notify team, update project tracker, generate build report.
Error Handling
Plugin errors are caught and logged, never propagated. A crashing plugin cannot break Tray:
{
name: "buggy",
onPartCreated: async () => {
throw new Error("oops!");
// This is logged as: [plugin] Error in 'buggy.onPartCreated': oops!
// Other plugins and the main operation continue normally.
},
}
Multiple plugins with errors don’t stop each other. If plugins A, B, and C all have onPartCreated hooks, and B throws, A and C still run.
Configuration Patterns
Passing Secrets
Use environment variables, not hardcoded strings:
export default {
plugins: [
digikey({ apiKey: Deno.env.get("DIGIKEY_API_KEY")! }),
slack({ webhook: Deno.env.get("SLACK_WEBHOOK")! }),
],
} satisfies TrayConfig;
Conditional Plugins
const plugins: TrayPlugin[] = [
logger(),
];
if (Deno.env.get("DIGIKEY_API_KEY")) {
plugins.push(digikey({ apiKey: Deno.env.get("DIGIKEY_API_KEY")! }));
}
export default { plugins } satisfies TrayConfig;
Plugin Composition
Plugins are just objects. You can combine them:
function allNotifications(config: { slack?: string; email?: string }): TrayPlugin {
return {
name: "notifications",
onLowStock: async (ctx, part) => {
if (config.slack) {
await fetch(config.slack, {
method: "POST",
body: JSON.stringify({ text: `Low stock: ${part.name} (${part.stock} left)` }),
});
}
if (config.email) {
// send email...
}
},
};
}
Example: Slack Low Stock Alert
// ~/.tray/plugins/slack-alert.ts
import type { TrayPlugin } from "@tray/core";
export default function slackAlert(webhookUrl: string): TrayPlugin {
return {
name: "slack-alert",
onLowStock: async (ctx, part) => {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `:warning: Low stock alert: *${part.name}* has ${part.stock} units (minimum: ${part.min_stock})`,
}),
});
ctx.log(`[slack] Alert sent for ${part.name}`);
},
};
}