feat: add @tpmjs/create-basic-tools CLI generator

- Interactive CLI generator for scaffolding TPMJS tool packages
- Generates packages with minimum 2 tools (ideally 2-3)
- Zod 4 schemas - uses Zod directly (not jsonSchema wrapper)
- One file per tool in src/tools/<toolName>.ts
- TPMJS validated against official schemas from @tpmjs/types
- Complete package generation ready to publish to npm
- Works both standalone and in monorepo packages/ folders

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 23:34:37 +10:00
parent 8b000cf0ca
commit 38c85bd904
28 changed files with 1426 additions and 2 deletions

View file

@ -0,0 +1,180 @@
import * as path from 'node:path';
import * as clack from '@clack/prompts';
import { generateTsConfig, generateTsupConfig } from './generators/config-files.js';
import { generatePackageJson } from './generators/package-json.js';
import { generateReadme } from './generators/readme.js';
import { generateIndexFile, generateToolFile } from './generators/source-code.js';
import { promptCategoryAndMode, promptConfirmation, promptOutputPath } from './prompts/advanced.js';
import { promptBasicInfo } from './prompts/basic.js';
import { promptTools } from './prompts/tools.js';
import type { GenerationResult, GeneratorConfig } from './types.js';
import {
copyTemplate,
ensureDir,
getAbsolutePath,
pathExists,
writeFile,
} from './utils/file-writer.js';
import * as logger from './utils/logger.js';
/**
* Runs the interactive CLI workflow
*/
export async function runInteractiveCLI(): Promise<GenerationResult> {
clack.intro(logger.bold('create-tpmjs-tool'));
// Step 1: Get basic package info
const packageInfo = await promptBasicInfo();
if (!packageInfo) {
clack.cancel('Operation cancelled');
process.exit(0);
}
// Step 2: Get tool definitions (minimum 2)
const tools = await promptTools();
if (!tools || tools.length < 2) {
clack.cancel('Operation cancelled');
process.exit(0);
}
// Step 3: Get category and mode
const categoryAndMode = await promptCategoryAndMode();
if (!categoryAndMode) {
clack.cancel('Operation cancelled');
process.exit(0);
}
packageInfo.category = categoryAndMode.category;
// Step 4: Get output path
const packageNameWithoutScope = packageInfo.name.split('/').pop() || packageInfo.name;
const defaultPath = `./${packageNameWithoutScope}`;
const outputPath = await promptOutputPath(defaultPath);
if (!outputPath) {
clack.cancel('Operation cancelled');
process.exit(0);
}
const absoluteOutputPath = getAbsolutePath(outputPath);
// Check if directory exists
if (await pathExists(absoluteOutputPath)) {
clack.log.error(`Directory already exists: ${absoluteOutputPath}`);
clack.cancel('Operation cancelled');
process.exit(1);
}
// Step 5: Confirm generation
const confirmed = await promptConfirmation(packageInfo.name, tools.length, outputPath);
if (!confirmed) {
clack.cancel('Operation cancelled');
process.exit(0);
}
// Step 6: Generate the package
const spinner = clack.spinner();
spinner.start('Generating package...');
const config: GeneratorConfig = {
packageInfo,
tools,
outputPath: absoluteOutputPath,
mode: categoryAndMode.mode,
};
try {
const result = await generatePackage(config);
spinner.stop('Package generated successfully!');
clack.outro(`
${logger.green('✓')} Success! Created ${logger.bold(packageInfo.name)} at ${logger.cyan(outputPath)}
Files created:
${result.filesCreated.map((f) => ` ${f}`).join('\n')}
Next steps:
${logger.cyan(`cd ${outputPath}`)}
${logger.cyan('pnpm install')}
${logger.cyan('pnpm build')}
${logger.cyan('pnpm type-check')}
${logger.cyan('pnpm publish')}
`);
return result;
} catch (error) {
spinner.stop('Generation failed');
clack.log.error(error instanceof Error ? error.message : 'Unknown error occurred');
clack.cancel('Operation failed');
process.exit(1);
}
}
/**
* Generates the package files
*/
async function generatePackage(config: GeneratorConfig): Promise<GenerationResult> {
const { packageInfo, tools, outputPath } = config;
const filesCreated: string[] = [];
// Create directory structure
await ensureDir(outputPath);
await ensureDir(path.join(outputPath, 'src'));
await ensureDir(path.join(outputPath, 'src', 'tools'));
// Generate package.json
const packageJsonPath = path.join(outputPath, 'package.json');
await writeFile(packageJsonPath, generatePackageJson(config));
filesCreated.push('package.json');
// Generate tool files
for (const tool of tools) {
const toolPath = path.join(outputPath, 'src', 'tools', `${tool.exportName}.ts`);
await writeFile(toolPath, generateToolFile(tool));
filesCreated.push(`src/tools/${tool.exportName}.ts`);
}
// Generate index.ts
const indexPath = path.join(outputPath, 'src', 'index.ts');
await writeFile(indexPath, generateIndexFile(tools));
filesCreated.push('src/index.ts');
// Generate tsconfig.json
const tsconfigPath = path.join(outputPath, 'tsconfig.json');
await writeFile(tsconfigPath, generateTsConfig());
filesCreated.push('tsconfig.json');
// Generate tsup.config.ts
const tsupConfigPath = path.join(outputPath, 'tsup.config.ts');
await writeFile(tsupConfigPath, generateTsupConfig());
filesCreated.push('tsup.config.ts');
// Generate README.md
const readmePath = path.join(outputPath, 'README.md');
await writeFile(readmePath, generateReadme(config));
filesCreated.push('README.md');
// Copy static templates
const gitignorePath = path.join(outputPath, '.gitignore');
await copyTemplate('gitignore.txt', gitignorePath);
filesCreated.push('.gitignore');
const npmignorePath = path.join(outputPath, '.npmignore');
await copyTemplate('npmignore.txt', npmignorePath);
filesCreated.push('.npmignore');
const licensePath = path.join(outputPath, 'LICENSE');
await copyTemplate('license-mit.txt', licensePath, {
YEAR: new Date().getFullYear().toString(),
AUTHOR: packageInfo.author || 'Author Name',
});
filesCreated.push('LICENSE');
return {
success: true,
outputPath,
filesCreated,
};
}

View file

@ -0,0 +1,45 @@
/**
* Generates tsconfig.json content
*/
export function generateTsConfig(): string {
const config = {
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2022'],
outDir: './dist',
rootDir: './src',
declaration: true,
declarationMap: true,
sourceMap: true,
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
resolveJsonModule: true,
allowSyntheticDefaultImports: true,
},
include: ['src/**/*'],
exclude: ['node_modules', 'dist'],
};
return JSON.stringify(config, null, 2);
}
/**
* Generates tsup.config.ts content
*/
export function generateTsupConfig(): string {
return `import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
treeshake: true,
splitting: false,
});
`;
}

View file

@ -0,0 +1,52 @@
import type { GeneratorConfig } from '../types.js';
/**
* Generates the package.json content for a TPMJS tool package
*/
export function generatePackageJson(config: GeneratorConfig): string {
const { packageInfo, tools } = config;
const pkg = {
name: packageInfo.name,
version: '0.0.1',
description: packageInfo.description,
type: 'module',
main: 'dist/index.js',
types: 'dist/index.d.ts',
scripts: {
build: 'tsup',
dev: 'tsup --watch',
'type-check': 'tsc --noEmit',
},
keywords: ['tpmjs-tool', 'ai-sdk', packageInfo.category],
author: packageInfo.author || '',
license: packageInfo.license,
tpmjs: {
category: packageInfo.category,
tools: tools.map((tool) => ({
exportName: tool.exportName,
description: tool.description,
...(tool.parameters && { parameters: tool.parameters }),
...(tool.returns && { returns: tool.returns }),
...(tool.aiAgent && { aiAgent: tool.aiAgent }),
})),
...(tools.some((t) => t.env) && {
env: tools.flatMap((t) => t.env || []),
}),
...(tools.some((t) => t.frameworks) && {
frameworks: Array.from(new Set(tools.flatMap((t) => t.frameworks || []))),
}),
},
dependencies: {
ai: '^6.0.0',
zod: '^4.1.13',
},
devDependencies: {
tsup: '^8.3.5',
typescript: '^5.9.3',
},
files: ['dist', 'README.md'],
};
return JSON.stringify(pkg, null, 2);
}

View file

@ -0,0 +1,77 @@
import type { GeneratorConfig } from '../types.js';
/**
* Generates README.md content for the tool package
*/
export function generateReadme(config: GeneratorConfig): string {
const { packageInfo, tools } = config;
const toolsList = tools.map((tool) => `- **${tool.exportName}**: ${tool.description}`).join('\n');
const usageExample = tools[0];
if (!usageExample) {
throw new Error('At least one tool is required');
}
return `# ${packageInfo.name}
${packageInfo.description}
## Installation
\`\`\`bash
pnpm add ${packageInfo.name}
\`\`\`
## Tools
This package provides ${tools.length} tool${tools.length > 1 ? 's' : ''} for the AI SDK:
${toolsList}
## Usage
\`\`\`typescript
import { ${usageExample.exportName} } from '${packageInfo.name}';
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
const result = await generateText({
model: openai('gpt-4'),
prompt: 'Process this text for me',
tools: {
${usageExample.exportName},
},
});
console.log(result.text);
\`\`\`
## Development
\`\`\`bash
# Install dependencies
pnpm install
# Build the package
pnpm build
# Type-check
pnpm type-check
# Watch mode
pnpm dev
\`\`\`
## Publishing
1. Update the version in \`package.json\`
2. Build the package: \`pnpm build\`
3. Publish to npm: \`pnpm publish --access public\`
4. Your tools will appear on [tpmjs.com](https://tpmjs.com) within 2-15 minutes
## License
${packageInfo.license}
`;
}

View file

@ -0,0 +1,65 @@
import type { ToolDefinition } from '../types.js';
/**
* Generates a single tool file with Zod schema
*/
export function generateToolFile(tool: ToolDefinition): string {
const { exportName, description } = tool;
// Generate schema name (capitalize first letter + "Schema")
const schemaName = `${exportName.charAt(0).toUpperCase()}${exportName.slice(1)}Schema`;
// Generate simple Zod schema (can be enhanced in advanced mode)
const schemaContent = generateSimpleSchema();
return `import { tool } from 'ai';
import { z } from 'zod';
const ${schemaName} = z.object({
${schemaContent}
});
export const ${exportName} = tool({
description: '${description}',
inputSchema: ${schemaName},
async execute(input: z.infer<typeof ${schemaName}>) {
// TODO: Implement the tool logic here
console.log('${exportName} called with:', input);
return {
success: true,
message: 'Tool executed successfully. Replace this with your implementation.',
input,
};
},
});
`;
}
/**
* Generates a simple Zod schema with example fields
*/
function generateSimpleSchema(): string {
return ` text: z.string().min(1, 'Text cannot be empty').describe('The input text to process.'),
options: z.object({
language: z.string().default('en').describe('Language code (e.g., en, es, fr).'),
maxLength: z.number().int().positive().default(100).describe('Maximum length of output.'),
}).default({ language: 'en', maxLength: 100 }).describe('Optional configuration.'),`;
}
/**
* Generates the main index.ts file that re-exports all tools
*/
export function generateIndexFile(tools: ToolDefinition[]): string {
const exports = tools
.map((tool) => `export { ${tool.exportName} } from './tools/${tool.exportName}.js';`)
.join('\n');
return `/**
* TPMJS Tool Package
* Auto-generated by @tpmjs/create-basic-tools
*/
${exports}
`;
}

View file

@ -0,0 +1,30 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { runInteractiveCLI } from './cli.js';
const program = new Command();
program
.name('create-basic-tools')
.description('CLI generator for scaffolding production-ready TPMJS tool packages')
.version('0.0.1')
.option('--name <name>', 'Package name (e.g., @myorg/content-tools)')
.option('--description <description>', 'Package description')
.option('--category <category>', 'Tool category')
.option('--tool <tool...>', 'Tool definition (format: "exportName:description")')
.option('--output <path>', 'Output path')
.option('--yes', 'Skip confirmation prompt')
.action(async (options) => {
// For now, only support interactive mode
// CLI flags mode can be added later
if (options.name || options.description || options.category || options.tool) {
console.log('CLI flags mode is not yet implemented.');
console.log('Please run without flags for interactive mode.');
process.exit(1);
}
await runInteractiveCLI();
});
program.parse();

View file

@ -0,0 +1,91 @@
import * as clack from '@clack/prompts';
import { getAvailableCategories } from '../validation/tool-metadata.js';
/**
* Prompts for category and mode selection
*/
export async function promptCategoryAndMode(): Promise<{
category: string;
mode: 'simple' | 'advanced';
} | null> {
const categories = getAvailableCategories();
const category = await clack.select({
message: 'Tool category',
options: categories.map((cat) => ({
value: cat,
label: cat,
})),
});
if (clack.isCancel(category)) {
return null;
}
const modeResult = await clack.select({
message: 'Generation mode',
options: [
{
value: 'simple',
label: 'Simple Mode - Basic Zod schemas (recommended)',
hint: 'Generates simple string/number parameters with defaults',
},
{
value: 'advanced',
label: 'Advanced Mode - Full control',
hint: 'Configure parameters, returns, env vars, and AI agent guidance',
},
],
});
if (clack.isCancel(modeResult)) {
return null;
}
const mode = modeResult as 'simple' | 'advanced';
return {
category: category as string,
mode,
};
}
/**
* Prompts for output path
*/
export async function promptOutputPath(defaultPath: string): Promise<string | null> {
const outputPath = await clack.text({
message: 'Where should we create the package?',
placeholder: defaultPath,
initialValue: defaultPath,
validate: (value) => {
if (!value) return 'Output path is required';
},
});
if (clack.isCancel(outputPath)) {
return null;
}
return outputPath as string;
}
/**
* Prompts for final confirmation
*/
export async function promptConfirmation(
packageName: string,
toolCount: number,
outputPath: string
): Promise<boolean> {
const confirmed = await clack.confirm({
message: `Ready to generate ${packageName} with ${toolCount} tools at ${outputPath}?`,
initialValue: true,
});
if (clack.isCancel(confirmed)) {
return false;
}
return confirmed;
}

View file

@ -0,0 +1,70 @@
import * as clack from '@clack/prompts';
import type { PackageInfo } from '../types.js';
import { validateNpmPackageName } from '../validation/package-name.js';
/**
* Prompts for basic package information
*/
export async function promptBasicInfo(): Promise<PackageInfo | null> {
const name = await clack.text({
message: 'Package name',
placeholder: '@myorg/content-tools',
validate: (value) => {
if (!value) return 'Package name is required';
const validation = validateNpmPackageName(value);
if (!validation.valid) {
return validation.errors?.[0] || 'Invalid package name';
}
},
});
if (clack.isCancel(name)) {
return null;
}
const description = await clack.text({
message: 'Package description',
placeholder: 'AI SDK tools for content processing',
validate: (value) => {
if (!value) return 'Description is required';
if (value.length < 10) return 'Description must be at least 10 characters';
},
});
if (clack.isCancel(description)) {
return null;
}
const author = await clack.text({
message: 'Author name',
placeholder: 'Your Name',
initialValue: '',
});
if (clack.isCancel(author)) {
return null;
}
const license = await clack.select({
message: 'License',
options: [
{ value: 'MIT', label: 'MIT' },
{ value: 'Apache-2.0', label: 'Apache-2.0' },
{ value: 'ISC', label: 'ISC' },
{ value: 'BSD-3-Clause', label: 'BSD-3-Clause' },
],
initialValue: 'MIT',
});
if (clack.isCancel(license)) {
return null;
}
return {
name: name as string,
description: description as string,
author: author as string,
license: license as string,
category: '', // Will be set later
};
}

View file

@ -0,0 +1,99 @@
import * as clack from '@clack/prompts';
import type { ToolDefinition } from '../types.js';
import { validateExportName } from '../validation/package-name.js';
import { validateDescription } from '../validation/tool-metadata.js';
/**
* Prompts for tool definitions (minimum 2 tools required)
*/
export async function promptTools(): Promise<ToolDefinition[] | null> {
const tools: ToolDefinition[] = [];
clack.note(
'Define at least 2 tools for your package.\nExamples: summarizeText, extractKeywords, classifySentiment',
'Tool Definitions'
);
// First tool (required)
const tool1 = await promptSingleTool(1);
if (!tool1) return null;
tools.push(tool1);
// Second tool (required)
const tool2 = await promptSingleTool(2);
if (!tool2) return null;
tools.push(tool2);
// Additional tools (optional)
let continueAdding = true;
let toolNumber = 3;
while (continueAdding) {
const addMore = await clack.confirm({
message: `Add tool #${toolNumber}? (already have ${tools.length})`,
initialValue: toolNumber === 3, // Default yes for the 3rd tool
});
if (clack.isCancel(addMore)) {
return null;
}
if (!addMore) {
continueAdding = false;
} else {
const tool = await promptSingleTool(toolNumber);
if (!tool) return null;
tools.push(tool);
toolNumber++;
}
}
return tools;
}
/**
* Prompts for a single tool definition
*/
async function promptSingleTool(number: number): Promise<ToolDefinition | null> {
const exportName = await clack.text({
message: `Tool #${number} export name`,
placeholder: number === 1 ? 'summarizeText' : number === 2 ? 'extractKeywords' : 'myTool',
validate: (value) => {
if (!value) return 'Export name is required';
const validation = validateExportName(value as string);
if (!validation.valid) {
return validation.error || 'Invalid export name';
}
},
});
if (clack.isCancel(exportName)) {
return null;
}
const description = await clack.text({
message: `Tool #${number} description`,
placeholder:
number === 1
? 'Summarize a block of text into a concise overview.'
: number === 2
? 'Extract important keywords from text.'
: 'Description of what this tool does.',
validate: (value) => {
if (!value) return 'Description is required';
const validation = validateDescription(value as string);
if (!validation.valid) {
return validation.error || 'Invalid description';
}
},
});
if (clack.isCancel(description)) {
return null;
}
return {
exportName: exportName as string,
description: description as string,
};
}

View file

@ -0,0 +1,60 @@
/**
* Shared TypeScript types for the TPMJS tool generator
*/
export interface ToolDefinition {
exportName: string;
description: string;
parameters?: ParameterDefinition[];
returns?: ReturnDefinition;
env?: string[];
frameworks?: string[];
aiAgent?: {
systemPrompt?: string;
model?: string;
};
}
export interface ParameterDefinition {
name: string;
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
description: string;
required: boolean;
default?: unknown;
}
export interface ReturnDefinition {
type: string;
description: string;
}
export interface PackageInfo {
name: string;
description: string;
author: string;
license: string;
category: string;
}
export interface GeneratorConfig {
packageInfo: PackageInfo;
tools: ToolDefinition[];
outputPath: string;
mode: 'simple' | 'advanced';
}
export interface CLIOptions {
name?: string;
description?: string;
category?: string;
tool?: string[];
output?: string;
yes?: boolean;
}
export interface GenerationResult {
success: boolean;
outputPath: string;
filesCreated: string[];
error?: string;
}

View file

@ -0,0 +1,60 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Ensures a directory exists, creating it if necessary
*/
export async function ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
}
/**
* Writes a file with the given content
*/
export async function writeFile(filePath: string, content: string): Promise<void> {
await fs.writeFile(filePath, content, 'utf-8');
}
/**
* Copies a template file to the destination
*/
export async function copyTemplate(
templateName: string,
destPath: string,
replacements?: Record<string, string>
): Promise<void> {
const templatePath = path.join(__dirname, '../../templates', templateName);
let content = await fs.readFile(templatePath, 'utf-8');
// Apply replacements if provided
if (replacements) {
for (const [key, value] of Object.entries(replacements)) {
content = content.replaceAll(`{{${key}}}`, value);
}
}
await writeFile(destPath, content);
}
/**
* Checks if a path exists
*/
export async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Gets the absolute path, resolving relative paths
*/
export function getAbsolutePath(inputPath: string): string {
return path.resolve(process.cwd(), inputPath);
}

View file

@ -0,0 +1,41 @@
import pc from 'picocolors';
/**
* Logger utilities with colored output
*/
export function success(message: string): void {
console.log(pc.green(`✓ ${message}`));
}
export function error(message: string): void {
console.error(pc.red(`✗ ${message}`));
}
export function warn(message: string): void {
console.warn(pc.yellow(`⚠ ${message}`));
}
export function info(message: string): void {
console.log(pc.blue(`ℹ ${message}`));
}
export function dim(message: string): void {
console.log(pc.dim(message));
}
export function bold(message: string): string {
return pc.bold(message);
}
export function cyan(message: string): string {
return pc.cyan(message);
}
export function yellow(message: string): string {
return pc.yellow(message);
}
export function green(message: string): string {
return pc.green(message);
}

View file

@ -0,0 +1,89 @@
import validatePackageName from 'validate-npm-package-name';
export interface PackageNameValidation {
valid: boolean;
errors?: string[];
warnings?: string[];
}
/**
* Validates an npm package name
*/
export function validateNpmPackageName(name: string): PackageNameValidation {
const result = validatePackageName(name);
if (result.validForNewPackages) {
return {
valid: true,
warnings: result.warnings,
};
}
return {
valid: false,
errors: result.errors,
warnings: result.warnings,
};
}
/**
* Validates that export name is a valid JavaScript identifier
*/
export function validateExportName(name: string): { valid: boolean; error?: string } {
if (!name || name.length === 0) {
return { valid: false, error: 'Export name cannot be empty' };
}
// JavaScript identifier regex: must start with letter, $, or _, followed by letters, digits, $, or _
const identifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
if (!identifierRegex.test(name)) {
return {
valid: false,
error: 'Export name must be a valid JavaScript identifier (e.g., myTool, summarizeText)',
};
}
// Check for reserved words
const reservedWords = [
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
];
if (reservedWords.includes(name)) {
return { valid: false, error: `"${name}" is a reserved JavaScript keyword` };
}
return { valid: true };
}

View file

@ -0,0 +1,72 @@
import { TPMJS_CATEGORIES, TpmjsToolDefinitionSchema } from '@tpmjs/types/tpmjs';
import type { TpmjsCategory } from '@tpmjs/types/tpmjs';
import type { ToolDefinition } from '../types.js';
/**
* Validates a tool definition against TPMJS schema
*/
export function validateToolDefinition(tool: ToolDefinition): {
valid: boolean;
errors?: string[];
} {
const result = TpmjsToolDefinitionSchema.safeParse(tool);
if (result.success) {
return { valid: true };
}
const errors = result.error.issues.map((issue) => {
const path = issue.path.join('.');
return `${path}: ${issue.message}`;
});
return { valid: false, errors };
}
/**
* Validates that category is one of the allowed TPMJS categories
*/
export function validateCategory(category: string): {
valid: boolean;
error?: string;
} {
if (!TPMJS_CATEGORIES.includes(category as TpmjsCategory)) {
return {
valid: false,
error: `Category must be one of: ${TPMJS_CATEGORIES.join(', ')}`,
};
}
return { valid: true };
}
/**
* Validates tool description length
*/
export function validateDescription(description: string): {
valid: boolean;
error?: string;
} {
if (description.length < 20) {
return {
valid: false,
error: 'Description must be at least 20 characters',
};
}
if (description.length > 500) {
return {
valid: false,
error: 'Description must be at most 500 characters',
};
}
return { valid: true };
}
/**
* Get all available TPMJS categories
*/
export function getAvailableCategories(): readonly TpmjsCategory[] {
return TPMJS_CATEGORIES;
}