feat: implement multi-tool package architecture with manual tool registry

BREAKING CHANGE: Complete refactoring from single-tool to multi-tool package support

Database Schema:
- Split Tool model into Package (1) and Tool (many) with one-to-many relationship
- Package stores npm metadata and package-level tpmjs fields (category, env, frameworks, tier)
- Tool stores individual tool exports with tool-level metadata (exportName, description, parameters, returns, aiAgent)
- Unique constraint on (packageId, exportName) to prevent duplicate tools
- Cascade deletes when packages are removed

Type System:
- Updated tpmjs field schema to support tools array
- Each tool has exportName, description, parameters, returns, aiAgent
- Package-level fields: category, env, frameworks shared across all tools
- Backward compatible with legacy single-tool format (auto-migrates to exportName: "default")

API Updates:
- Updated all /api/tools routes to query Tool model with Package relations
- Updated /api/tools/[slug] to accept package/export path segments
- Updated tool-executor-agent to use actual exportName instead of hardcoded "default"
- Updated metrics sync to calculate quality scores per Tool

Frontend Updates:
- Updated tool search page to display exportName as primary heading
- Updated tool detail pages to show package name as secondary info
- Removed tag-based filtering (tags moved to package level)

Manual Tool Registry:
- Added manual-tools.ts with 23 curated tools from major providers
- Created sync-manual-tools.ts script to sync manual tools to database
- Added MANUAL_TOOLS.md documentation for manual tool system
- Added GitHub workflow for automated daily sync
- Includes tools from: Vercel, Exa, Firecrawl, AWS Bedrock, Perplexity, Tavily, Superagent, Valyu

Playground Updates:
- Updated tool loader to load multiple tools per package
- Added sanitizeToolName for OpenAI API compatibility

Sync System Updates:
- Updated changes feed sync to handle multi-tool packages
- Updated keyword sync to upsert multiple tools per package
- Added orphaned tool deletion when tools removed from package.json

Migration Strategy:
- Database uses same Neon instance for dev and prod
- Schema updated via prisma db push (no migration files yet)
- All data repopulates from npm via sync system

🤖 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 06:39:58 +10:00
parent fdd1b2c304
commit 0612eac5e2
31 changed files with 3783 additions and 786 deletions

97
.github/workflows/sync-manual.yml vendored Normal file
View file

@ -0,0 +1,97 @@
name: Sync Manual Tools
on:
schedule:
# Run daily at midnight UTC
- cron: '0 0 * * *'
workflow_dispatch:
# Run on pushes to main that modify manual-tools.ts
push:
branches:
- main
paths:
- 'manual-tools.ts'
- 'sync-manual-tools.ts'
jobs:
sync-manual:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma Client
run: pnpm --filter=@tpmjs/db db:generate
- name: Run manual tools sync
id: sync
run: |
# Run the sync script and capture output
output=$(pnpm tsx sync-manual-tools.ts 2>&1)
echo "$output"
# Extract statistics from output
processed=$(echo "$output" | grep "Processed:" | awk '{print $2}')
skipped=$(echo "$output" | grep "Skipped:" | awk '{print $2}')
errors=$(echo "$output" | grep "Errors:" | awk '{print $2}')
total=$(echo "$output" | grep "Total manual tools:" | awk '{print $4}')
# Set outputs for Discord notification
echo "processed=${processed:-0}" >> $GITHUB_OUTPUT
echo "skipped=${skipped:-0}" >> $GITHUB_OUTPUT
echo "errors=${errors:-0}" >> $GITHUB_OUTPUT
echo "total=${total:-0}" >> $GITHUB_OUTPUT
# Determine status
if [ "${errors:-0}" -gt 0 ]; then
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
else
echo "status_emoji=✅" >> $GITHUB_OUTPUT
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
fi
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Send Discord notification
if: always()
run: |
# Build Discord payload
payload=$(jq -n \
--arg title "${{ steps.sync.outputs.status_emoji }} Manual Tools Sync" \
--argjson color ${{ steps.sync.outputs.status_color }} \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'
{
embeds: [{
title: $title,
color: $color,
fields: [
{ name: "📦 Total Tools", value: "${{ steps.sync.outputs.total }}", inline: true },
{ name: "✨ Processed", value: "${{ steps.sync.outputs.processed }}", inline: true },
{ name: "⏭️ Skipped", value: "${{ steps.sync.outputs.skipped }}", inline: true },
{ name: "❌ Errors", value: "${{ steps.sync.outputs.errors }}", inline: true },
{ name: "🔗 Run", value: "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", inline: true }
],
timestamp: $timestamp
}]
}')
# Send to Discord
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d "$payload"

293
MANUAL_TOOLS.md Normal file
View file

@ -0,0 +1,293 @@
# Manual Tools Registry
## Overview
This system allows TPMJS to include high-quality tools that don't follow the standard `tpmjs` field specification in their package.json. These tools are manually curated and synced to the database.
## Why Manual Tools?
Some excellent tools (like Vercel's code execution, Exa search, Firecrawl, etc.) don't include the `tpmjs` field in their package.json. Rather than wait for these package maintainers to adopt the spec, we manually curate metadata for these tools.
## Architecture
### Files
1. **`manual-tools.ts`** - The registry of manually curated tools
2. **`sync-manual-tools.ts`** - Script to sync manual tools to database
3. **`MANUAL_TOOLS.md`** - This documentation
### How It Works
1. **Manual Tool Registry** (`manual-tools.ts`)
- Exports a `manualTools` array with metadata for each tool
- Each entry includes npm package name, export name, category, description, parameters, etc.
- Follows the same schema as the standard `tpmjs` field
2. **Sync Script** (`sync-manual-tools.ts`)
- Fetches latest package metadata from npm
- Combines npm metadata with manual metadata
- Upserts Package + Tool records to database
- Marks tools with `discoveryMethod: 'manual'`
3. **Database Storage**
- Manual tools stored in same `packages` and `tools` tables as auto-discovered tools
- No special handling needed in API or frontend
- `discoveryMethod: 'manual'` field distinguishes them
## Adding a New Manual Tool
### Step 1: Add to Registry
Edit `manual-tools.ts` and add a new entry:
```typescript
{
npmPackageName: 'example-package',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'exampleTool',
description: 'A clear, concise description of what this tool does',
// Optional but recommended for 'rich' tier
parameters: [
{
name: 'query',
type: 'string',
description: 'The search query',
required: true,
},
],
returns: {
type: 'array',
description: 'Array of search results',
},
aiAgent: {
useCase: 'Use when you need to search for X',
limitations: 'Rate limits apply',
examples: [
'Search for current news',
'Find specific information',
],
},
// Environment variables
env: [
{
name: 'EXAMPLE_API_KEY',
description: 'API key for the service',
required: true,
},
],
// Additional metadata
tags: ['search', 'web'],
docsUrl: 'https://example.com/docs',
apiKeyUrl: 'https://example.com/api-keys',
websiteUrl: 'https://example.com',
}
```
### Step 2: Run Sync Script
```bash
# From repository root
pnpm tsx sync-manual-tools.ts
```
This will:
1. Fetch the package from npm
2. Create/update Package record
3. Create/update Tool record(s)
4. Set `discoveryMethod: 'manual'`
### Step 3: Verify
Check that the tool appears on tpmjs.com:
```bash
# Start dev server
pnpm dev --filter=@tpmjs/web
# Visit http://localhost:3000/tool/tool-search
# Search for your package name
```
## Multi-Tool Packages
If a package exports multiple tools, add multiple entries with the same `npmPackageName` but different `exportName`:
```typescript
{
npmPackageName: 'firecrawl-aisdk',
exportName: 'scrapeTool',
description: 'Scrape websites...',
// ...
},
{
npmPackageName: 'firecrawl-aisdk',
exportName: 'searchTool',
description: 'Search the web...',
// ...
},
{
npmPackageName: 'firecrawl-aisdk',
exportName: 'crawlTool',
description: 'Crawl entire websites...',
// ...
},
```
## Tier Calculation
Tools are automatically assigned a tier:
- **Rich tier**: Has `parameters` OR `returns` OR `aiAgent` fields
- **Minimal tier**: Only has basic metadata
Rich tier tools get 4x quality score multiplier, so add detailed metadata when possible.
## Maintenance
### Updating Manual Tools
1. Edit the entry in `manual-tools.ts`
2. Run `pnpm tsx sync-manual-tools.ts`
3. The upsert will update existing records
### Removing Manual Tools
1. Remove the entry from `manual-tools.ts`
2. Manually delete from database OR wait for metrics sync to mark as stale
### Version Updates
The sync script automatically fetches the latest version from npm unless you specify `npmVersion` in the manual tool entry.
## Production Deployment
### Option 1: Manual Sync on Deploy
Add to your deployment workflow:
```yaml
# .github/workflows/deploy.yml
- name: Sync manual tools
run: pnpm tsx sync-manual-tools.ts
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
```
### Option 2: Scheduled Sync
Create a cron job or GitHub Action to sync periodically:
```yaml
# .github/workflows/sync-manual.yml
name: Sync Manual Tools
on:
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday
workflow_dispatch: # Manual trigger
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install
- run: pnpm tsx sync-manual-tools.ts
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
```
### Option 3: API Endpoint
Create a sync endpoint (similar to keyword/changes sync):
```typescript
// apps/web/src/app/api/sync/manual/route.ts
import { manualTools } from '@/manual-tools';
// ... sync logic
export async function POST(request: Request) {
// Verify CRON_SECRET
// Run manual sync
// Return results
}
```
## Currently Included Manual Tools
As of this documentation:
- **ai-sdk-tool-code-execution** - Vercel Sandbox code execution
- **@exalabs/ai-sdk** - Exa web search
- **@parallel-web/ai-sdk-tools** - Parallel search and extraction (2 tools)
- **ctx-zip** - MCP + Vercel Sandbox integration
- **@perplexity-ai/ai-sdk** - Perplexity search
- **@tavily/ai-sdk** - Tavily web research
- **firecrawl-aisdk** - Firecrawl scraping, search, crawling (3 tools)
- **bedrock-agentcore** - AWS Bedrock code interpreter and browser (2 tools)
- **@superagent-ai/ai-sdk** - Superagent security tools (3 tools)
- **@valyu/ai-sdk** - Valyu domain-specific search tools (8 tools)
**Total: 24 manually curated tools across 10 packages**
## FAQ
### Why not just ask package maintainers to add the tpmjs field?
We should! But:
1. Some packages are from large companies (Vercel, AWS, etc.) with slow adoption cycles
2. We want these tools available on TPMJS now
3. Manual curation lets us provide better metadata than package authors might
### Will manual tools be replaced by auto-discovered ones?
Yes! If a package adds a proper `tpmjs` field, the auto-discovery sync will update it with `discoveryMethod: 'keyword'` or `'changes-feed'`. Manual entries can then be removed from `manual-tools.ts`.
### Can I mix manual and auto-discovered tools from the same package?
Yes. If a package has some tools in the `tpmjs` field but is missing others, you can manually add the missing ones. The sync scripts will coexist peacefully.
### How do I know if a tool is manually curated?
Check the `discoveryMethod` field in the database:
- `'manual'` = Manually curated
- `'keyword'` = Auto-discovered via keyword search
- `'changes-feed'` = Auto-discovered via npm changes feed
## Best Practices
1. **Complete Metadata** - Provide as much metadata as possible for rich tier
2. **Accurate Descriptions** - Tool descriptions should be clear and specific
3. **AI-Friendly** - Write `aiAgent.useCase` as guidance for LLMs
4. **Keep Updated** - Periodically check if packages have added native `tpmjs` support
5. **Link to Docs** - Always include `docsUrl` when available
6. **API Key URLs** - Include `apiKeyUrl` for tools requiring authentication
## Contributing
To contribute new manual tools:
1. Fork the repository
2. Add your tool to `manual-tools.ts`
3. Test with `pnpm tsx sync-manual-tools.ts`
4. Open a pull request with:
- Why this tool should be included
- Link to the npm package
- Screenshot of it working in TPMJS
## Related Documentation
- [HOW_TO_PUBLISH_A_TOOL.md](./HOW_TO_PUBLISH_A_TOOL.md) - Standard tpmjs field spec
- [CLAUDE.md](./CLAUDE.md) - General project documentation
- [packages/types/src/tpmjs.ts](./packages/types/src/tpmjs.ts) - TypeScript schema definitions

View file

@ -20,12 +20,12 @@
"@tpmjs/utils": "workspace:*",
"ai": "6.0.0-beta.124",
"firecrawl-aisdk": "^0.7.2",
"fish-joke-generator": "^1.1.0",
"next": "^16.0.4",
"next-themes": "^0.4.6",
"openai": "^6.9.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"streamdown": "^1.6.9",
"zod": "^4.0.0"
},
"devDependencies": {

View file

@ -25,7 +25,7 @@ export function ChatInterface(): React.ReactElement {
return (
<div className="flex flex-1 flex-col">
<ChatMessages messages={messages} />
<ChatMessages messages={messages} isStreaming={isLoading} />
<ChatInput
input={input}
isLoading={isLoading}

View file

@ -17,9 +17,13 @@ interface Message {
interface ChatMessagesProps {
messages: Message[];
isStreaming?: boolean;
}
export function ChatMessages({ messages }: ChatMessagesProps): React.ReactElement {
export function ChatMessages({
messages,
isStreaming = false,
}: ChatMessagesProps): React.ReactElement {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
@ -41,9 +45,17 @@ export function ChatMessages({ messages }: ChatMessagesProps): React.ReactElemen
return (
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{messages.map((message, idx) => {
// Only animate the last message if it's streaming
const isLastMessage = idx === messages.length - 1;
return (
<MessageBubble
key={message.id}
message={message}
isStreaming={isStreaming && isLastMessage}
/>
);
})}
<div ref={messagesEndRef} />
</div>
);

View file

@ -2,6 +2,7 @@
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import { Streamdown } from 'streamdown';
interface MessagePart {
type: string; // Can be 'text', 'tool-{toolName}', 'step-start', etc.
@ -23,9 +24,13 @@ interface Message {
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
}
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
export function MessageBubble({
message,
isStreaming = false,
}: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user';
// Debug: Log message structure
@ -59,11 +64,13 @@ export function MessageBubble({ message }: MessageBubbleProps): React.ReactEleme
return null;
}
// Render text parts
// Render text parts with markdown support
if (part.type === 'text') {
return (
<div key={`text-${message.id}-${idx}`} className="whitespace-pre-wrap text-sm">
{part.text}
<div key={`text-${message.id}-${idx}`} className="text-sm">
<Streamdown isAnimating={isStreaming && !isUser}>
{part.text || ''}
</Streamdown>
</div>
);
}
@ -116,7 +123,11 @@ export function MessageBubble({ message }: MessageBubbleProps): React.ReactEleme
</div>
) : (
// Fallback to content if no parts
<div className="whitespace-pre-wrap text-sm">{message.content || '...'}</div>
<div className="text-sm">
<Streamdown isAnimating={isStreaming && !isUser}>
{message.content || '...'}
</Streamdown>
</div>
)}
</CardContent>
</Card>

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -18,25 +18,14 @@ export const dynamic = 'force-dynamic';
export async function GET() {
try {
// Run all aggregations in parallel
const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([
const [totalTools, officialTools, recentCount, packages] = await Promise.all([
// Total tools count
prisma.tool.count(),
// Official tools count
// Official tools count (isOfficial is at package level)
prisma.tool.count({
where: { isOfficial: true },
}),
// Group by category
prisma.tool.groupBy({
by: ['category'],
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
where: {
package: { isOfficial: true }
},
}),
@ -49,21 +38,31 @@ export async function GET() {
},
}),
// Sum of all downloads
prisma.tool.aggregate({
_sum: {
// Get all packages with their tool counts and download stats
prisma.package.findMany({
select: {
category: true,
npmDownloadsLastMonth: true,
_count: {
select: { tools: true },
},
},
}),
]);
// Format category stats
const categories = categoryStats.reduce<Record<string, number>>((acc, stat) => {
if (stat.category) {
acc[stat.category] = stat._count.id;
// Calculate stats from packages
const categories: Record<string, number> = {};
let totalDownloads = 0;
for (const pkg of packages) {
// Count tools by category
if (pkg.category) {
categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools;
}
return acc;
}, {});
// Sum downloads
totalDownloads += pkg.npmDownloadsLastMonth || 0;
}
return NextResponse.json({
success: true,
@ -72,7 +71,7 @@ export async function GET() {
officialTools,
categories,
recentTools: recentCount,
totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0,
totalDownloads,
},
});
} catch (error) {

View file

@ -66,74 +66,119 @@ export async function POST(request: NextRequest) {
continue;
}
// Validate tpmjs field
// Validate tpmjs field (supports both new multi-tool and legacy formats)
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.data) {
if (!validation.valid || !validation.packageData || !validation.tools) {
skipped++;
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
const tpmjsData = validation.data as {
category: string;
description: string;
example: string;
parameters?: unknown;
returns?: unknown;
authentication?: unknown;
pricing?: unknown;
frameworks?: string[];
links?: unknown;
tags?: string[];
status?: string;
aiAgent?: unknown;
};
// Prepare data for upsert
const toolData = {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: tpmjsData.category,
description: tpmjsData.description,
example: tpmjsData.example,
parameters: tpmjsData.parameters ?? undefined,
returns: tpmjsData.returns ?? undefined,
authentication: tpmjsData.authentication ?? undefined,
pricing: tpmjsData.pricing ?? undefined,
frameworks: tpmjsData.frameworks || [],
links: tpmjsData.links ?? undefined,
tags: tpmjsData.tags || [],
status: tpmjsData.status ?? undefined,
aiAgent: tpmjsData.aiAgent ?? undefined,
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
tier: validation.tier || 'minimal',
};
// Upsert tool to database
await prisma.tool.upsert({
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: {
npmPackageName: pkg.name,
...toolData,
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
discoveryMethod: 'changes-feed',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
qualityScore: null, // Will be calculated by metrics sync
},
update: toolData,
update: {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
},
});
// Get existing tools for this package
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Upsert each tool in the tools array
for (const toolDef of validation.tools) {
await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
},
update: {
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
},
});
}
// Delete orphaned tools (tools removed from package.json)
const orphanedTools = existingTools.filter(
(existingTool) =>
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
);
if (orphanedTools.length > 0) {
await prisma.tool.deleteMany({
where: {
id: { in: orphanedTools.map((t) => t.id) },
},
});
console.log(
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
);
}
processed++;
} catch (error) {
errors++;

View file

@ -75,9 +75,9 @@ export async function POST(request: NextRequest) {
continue;
}
// Validate tpmjs field
// Validate tpmjs field (supports both new multi-tool and legacy formats)
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.data) {
if (!validation.valid || !validation.packageData || !validation.tools) {
skipped++;
skippedPackages.push({
name: pkg.name,
@ -87,67 +87,112 @@ export async function POST(request: NextRequest) {
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
const tpmjsData = validation.data as {
category: string;
description: string;
example: string;
parameters?: unknown;
returns?: unknown;
authentication?: unknown;
pricing?: unknown;
frameworks?: string[];
links?: unknown;
tags?: string[];
status?: string;
aiAgent?: unknown;
};
// Prepare data for upsert
const toolData = {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: tpmjsData.category,
description: tpmjsData.description,
example: tpmjsData.example,
parameters: tpmjsData.parameters ?? undefined,
returns: tpmjsData.returns ?? undefined,
authentication: tpmjsData.authentication ?? undefined,
pricing: tpmjsData.pricing ?? undefined,
frameworks: tpmjsData.frameworks || [],
links: tpmjsData.links ?? undefined,
tags: tpmjsData.tags || [],
status: tpmjsData.status ?? undefined,
aiAgent: tpmjsData.aiAgent ?? undefined,
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
tier: validation.tier || 'minimal',
};
// Upsert tool to database
await prisma.tool.upsert({
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: {
npmPackageName: pkg.name,
...toolData,
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
discoveryMethod: 'keyword',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
qualityScore: null, // Will be calculated by metrics sync
},
update: toolData,
update: {
npmVersion: pkg.version,
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
npmDescription: pkg.description ?? undefined,
npmRepository: pkg.repository ?? undefined,
npmHomepage: pkg.homepage ?? undefined,
npmLicense: pkg.license ?? undefined,
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
npmReadme: pkg.readme ?? undefined,
npmAuthor: pkg.author ?? undefined,
npmMaintainers: pkg.maintainers ?? undefined,
category: validation.packageData.category,
env: validation.packageData.env ?? undefined,
frameworks: validation.packageData.frameworks || [],
tier: validation.tier || 'minimal',
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
},
});
// Get existing tools for this package
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Upsert each tool in the tools array
for (const toolDef of validation.tools) {
await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
},
update: {
description: toolDef.description,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
},
});
}
// Delete orphaned tools (tools removed from package.json)
const orphanedTools = existingTools.filter(
(existingTool) =>
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
);
if (orphanedTools.length > 0) {
await prisma.tool.deleteMany({
where: {
id: { in: orphanedTools.map((t) => t.id) },
},
});
console.log(
`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`
);
}
processed++;
} catch (error) {
errors++;

View file

@ -9,7 +9,7 @@ export const maxDuration = 300; // 5 minutes max for cron jobs
/**
* POST /api/sync/metrics
* Update download stats and quality scores for all tools
* Update download stats and quality scores for all packages and tools
*
* This endpoint is called by Vercel Cron (every hour)
* Requires Authorization: Bearer <CRON_SECRET>
@ -31,43 +31,51 @@ export async function POST(request: NextRequest) {
const errorMessages: string[] = [];
try {
// Get all tools from database
const tools = await prisma.tool.findMany({
select: {
id: true,
npmPackageName: true,
tier: true,
npmDownloadsLastMonth: true,
githubStars: true,
// Get all packages with their tools from database
const packages = await prisma.package.findMany({
include: {
tools: true,
},
});
// Process each tool
for (const tool of tools) {
// Process each package
for (const pkg of packages) {
try {
// Fetch download stats from NPM
const downloads = await fetchDownloadStats(tool.npmPackageName);
// Fetch download stats from NPM (package-level metric)
const downloads = await fetchDownloadStats(pkg.npmPackageName);
// Calculate quality score (0.00 to 1.00)
const qualityScore = calculateQualityScore({
tier: tool.tier,
downloads,
githubStars: tool.githubStars || 0,
});
// Update tool metrics
await prisma.tool.update({
where: { id: tool.id },
// Update package metrics
await prisma.package.update({
where: { id: pkg.id },
data: {
npmDownloadsLastMonth: downloads,
qualityScore,
// githubStars would be updated here if we had GitHub API integration
},
});
// Calculate and update quality score for each tool in this package
for (const tool of pkg.tools) {
const qualityScore = calculateQualityScore({
tier: pkg.tier, // Tier is at package level
downloads, // Package downloads
githubStars: pkg.githubStars || 0, // Package stars
hasParameters: !!tool.parameters,
hasReturns: !!tool.returns,
hasAiAgent: !!tool.aiAgent,
});
await prisma.tool.update({
where: { id: tool.id },
data: {
qualityScore,
},
});
}
processed++;
} catch (error) {
errors++;
const errorMsg = `Failed to process ${tool.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(errorMsg);
}
@ -80,13 +88,15 @@ export async function POST(request: NextRequest) {
source: 'metrics',
checkpoint: {
lastRun: new Date().toISOString(),
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
update: {
checkpoint: {
lastRun: new Date().toISOString(),
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
});
@ -102,10 +112,11 @@ export async function POST(request: NextRequest) {
message:
errors > 0
? `Processed with errors: ${errorMessages.slice(0, 3).join('; ')}`
: `Successfully updated metrics for ${processed} tools`,
: `Successfully updated metrics for ${processed} packages`,
metadata: {
durationMs: Date.now() - startTime,
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
},
},
});
@ -116,7 +127,8 @@ export async function POST(request: NextRequest) {
processed,
skipped,
errors,
totalTools: tools.length,
totalPackages: packages.length,
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
durationMs: Date.now() - startTime,
},
});
@ -152,25 +164,40 @@ export async function POST(request: NextRequest) {
/**
* Calculate quality score based on multiple factors
* Returns a value between 0.00 and 1.00
*
* Score components:
* - Tier (0.4 minimal, 0.6 rich)
* - Downloads (logarithmic, max 0.2)
* - GitHub stars (logarithmic, max 0.1)
* - Tool metadata richness (0.1 for each: parameters, returns, aiAgent)
*/
function calculateQualityScore(params: {
tier: string;
downloads: number;
githubStars: number;
hasParameters: boolean;
hasReturns: boolean;
hasAiAgent: boolean;
}): number {
const { tier, downloads, githubStars } = params;
const { tier, downloads, githubStars, hasParameters, hasReturns, hasAiAgent } = params;
// Base score from tier
const tierScore = tier === 'rich' ? 0.6 : 0.4;
// Downloads score (logarithmic scale, max 0.3)
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
// Downloads score (logarithmic scale, max 0.2)
const downloadsScore = Math.min(0.2, Math.log10(downloads + 1) / 15);
// GitHub stars score (logarithmic scale, max 0.1)
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
// Tool metadata richness score (max 0.1)
let richnessScore = 0;
if (hasParameters) richnessScore += 0.04;
if (hasReturns) richnessScore += 0.03;
if (hasAiAgent) richnessScore += 0.03;
// Total score (capped at 1.00)
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore);
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore + richnessScore);
// Round to 2 decimal places
return Math.round(totalScore * 100) / 100;

View file

@ -18,31 +18,83 @@ export async function GET(
try {
const { slug } = await params;
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
const packageName = slug.join('/');
// Slug can be:
// - ['@scope', 'package'] -> Get all tools for @scope/package
// - ['@scope', 'package', 'exportName'] -> Get specific tool @scope/package/exportName
// - ['package'] -> Get all tools for package
// - ['package', 'exportName'] -> Get specific tool package/exportName
// Find the tool by npmPackageName
const tool = await prisma.tool.findUnique({
where: {
npmPackageName: packageName,
},
});
let packageName: string;
let exportName: string | undefined;
if (!tool) {
return NextResponse.json(
{
success: false,
error: 'Tool not found',
},
{ status: 404 }
);
if (slug.length === 1) {
// Single slug - package name without scope
packageName = slug[0] || '';
} else if (slug.length === 2) {
// Could be: @scope/package OR package/exportName
if (slug[0]?.startsWith('@')) {
// @scope/package
packageName = slug.join('/');
} else {
// package + exportName
packageName = slug[0] || '';
exportName = slug[1];
}
} else {
// 3+ slugs: @scope/package/exportName
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
exportName = slug[slug.length - 1];
}
// Return the tool data
return NextResponse.json({
success: true,
data: tool,
});
if (exportName) {
// Find specific tool by package name and export name
const tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
include: { package: true },
});
if (!tool) {
return NextResponse.json(
{
success: false,
error: 'Tool not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: tool,
});
} else {
// Find all tools for the package
const pkg = await prisma.package.findUnique({
where: { npmPackageName: packageName },
include: { tools: true },
});
if (!pkg) {
return NextResponse.json(
{
success: false,
error: 'Package not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
package: pkg,
tools: pkg.tools,
},
});
}
} catch (error) {
console.error('Error fetching tool:', error);
return NextResponse.json(

View file

@ -18,15 +18,19 @@ interface ExecuteRequest {
}
/**
* POST /api/tools/[...slug]/execute
* POST /api/tools/execute/[...slug]
* Executes a tool with an AI agent and streams the response via SSE
*
* Slug format: [toolId] or [packageName, exportName]
* Examples:
* /api/tools/execute/clx123abc (by tool ID)
* /api/tools/execute/@tpmjs/hello/helloWorldTool (by package and export name)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
const packageName = decodeURIComponent(slug.join('/'));
try {
// Parse request body
@ -63,10 +67,29 @@ export async function POST(
);
}
// Fetch tool from database
const tool = await prisma.tool.findUnique({
where: { npmPackageName: packageName },
});
// Fetch tool from database with package relation
// Support both ID-based lookup and packageName/exportName lookup
let tool;
if (slug.length === 1) {
// Single slug - treat as tool ID
tool = await prisma.tool.findUnique({
where: { id: slug[0] || '' },
include: { package: true },
});
} else {
// Multiple slugs - treat as packageName/exportName
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
include: { package: true },
});
}
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });

View file

@ -10,10 +10,10 @@ export const maxDuration = 60;
* Search and list tools with filtering, sorting, and pagination
*
* Query params:
* - q: Search query (searches name, description, tags)
* - q: Search query (searches package name, tool description)
* - category: Filter by category
* - official: Filter by official status (true/false)
* - limit: Results per page (default: 20, max: 100)
* - limit: Results per page (default: 20, max: 50)
* - offset: Pagination offset (default: 0)
*/
export async function GET(request: NextRequest) {
@ -27,44 +27,52 @@ export async function GET(request: NextRequest) {
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and set defaults (reduced max from 100 to 50 for faster queries)
// Validate and set defaults
const limit = Math.min(
Number.parseInt(limitParam || '20', 10),
50 // Reduced from 100 for better performance
50 // Max 50 for better performance
);
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
// Build where clause
// Build where clause for Tool table
const where: Prisma.ToolWhereInput = {};
// Search filter (case-insensitive partial match)
// Build package filter separately
const packageFilter: Prisma.PackageWhereInput = {};
// Category filter (category is at package level)
if (category) {
packageFilter.category = category;
}
// Official filter (isOfficial is at package level)
if (officialParam !== null) {
packageFilter.isOfficial = officialParam === 'true';
}
// Search filter (searches tool description and package name)
if (query) {
where.OR = [
{ npmPackageName: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{
tags: {
hasSome: [query],
},
},
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
];
} else if (Object.keys(packageFilter).length > 0) {
// Apply package filter if no search query
where.package = packageFilter;
}
// Category filter
if (category) {
where.category = category;
}
// Official filter
if (officialParam !== null) {
where.isOfficial = officialParam === 'true';
}
// Execute queries - run count separately only if needed for pagination
// For first page, we can skip count if we don't need total pages
// Execute query - fetch tools with package relation
// We fetch limit+1 to check if there are more results (avoid expensive count)
const tools = await prisma.tool.findMany({
where,
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }, { createdAt: 'desc' }],
include: {
package: true, // Include package data for each tool
},
orderBy: [
{ qualityScore: 'desc' }, // Tool quality score
{ package: { npmDownloadsLastMonth: 'desc' } }, // Package downloads
{ createdAt: 'desc' }, // Tool creation time
],
take: limit + 1, // Fetch one extra to check if there are more
skip: offset,
});
@ -81,7 +89,6 @@ export async function GET(request: NextRequest) {
offset,
hasMore,
// Note: total count omitted for performance (can be expensive)
// Only return count if explicitly requested
},
});
} catch (error) {

View file

@ -7,22 +7,41 @@ import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
/**
* GET /api/tools/[...slug]/simulations
* GET /api/tools/simulations/[...slug]
* Returns the last 10 simulations for a tool
*
* Slug can be:
* - Tool ID (single slug)
* - Package name + export name (multiple slugs)
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
const packageName = decodeURIComponent(slug.join('/'));
try {
// Fetch tool
const tool = await prisma.tool.findUnique({
where: { npmPackageName: packageName },
select: { id: true },
});
let tool;
if (slug.length === 1) {
// Single slug - treat as tool ID
tool = await prisma.tool.findUnique({
where: { id: slug[0] || '' },
select: { id: true },
});
} else {
// Multiple slugs - treat as packageName/exportName
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
tool = await prisma.tool.findFirst({
where: {
package: { npmPackageName: packageName },
exportName: exportName,
},
select: { id: true },
});
}
if (!tool) {
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });

View file

@ -20,24 +20,33 @@ async function getHomePageData() {
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }],
orderBy: [
{ qualityScore: 'desc' },
{ package: { npmDownloadsLastMonth: 'desc' } },
],
take: 6,
select: {
id: true,
npmPackageName: true,
exportName: true,
description: true,
category: true,
tags: true,
qualityScore: true,
npmDownloadsLastMonth: true,
isOfficial: true,
package: {
select: {
npmPackageName: true,
category: true,
npmDownloadsLastMonth: true,
isOfficial: true,
},
},
},
}),
// Category distribution for stats
prisma.tool.groupBy({
// Category distribution for stats (group by package category)
prisma.package.groupBy({
by: ['category'],
_count: true,
_count: {
_all: true,
},
}),
]);
@ -70,7 +79,7 @@ async function getHomePageData() {
featuredTools,
categories: categoryStats.slice(0, 5).map((c) => ({
name: c.category,
count: c._count,
count: c._count._all,
})),
};
} catch (error) {
@ -113,13 +122,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
{data.featuredTools.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{data.featuredTools.map((tool) => (
<Link key={tool.id} href={`/tool/${tool.npmPackageName}`} className="group">
<Link
key={tool.id}
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
className="group"
>
<div className="p-6 border border-border rounded-lg bg-surface hover:border-foreground transition-colors h-full flex flex-col">
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors">
{tool.npmPackageName}
{tool.package.npmPackageName}
<span className="text-xs text-foreground-tertiary ml-2">
({tool.exportName})
</span>
</h3>
{tool.isOfficial && (
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
@ -132,13 +148,8 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" size="sm">
{tool.category}
{tool.package.category}
</Badge>
{tool.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" size="sm">
{tag}
</Badge>
))}
</div>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
@ -147,7 +158,7 @@ export default async function HomePage(): Promise<React.ReactElement> {
{tool.qualityScore ? Number(tool.qualityScore).toFixed(2) : 'N/A'}
</span>
<span>
{tool.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
{tool.package.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
</span>
</div>
</div>

View file

@ -96,7 +96,13 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "A concise description of what your tool does"
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "myTool",
"description": "A concise description of what your tool does"
}
]
}
}`}
/>
@ -118,26 +124,32 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text",
"parameters": [
"frameworks": ["vercel-ai"],
"tools": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en')",
"required": false,
"default": "en"
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text",
"parameters": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en')",
"required": false,
"default": "en"
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score and label"
}
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score and label"
}
]
}
}`}
/>
@ -159,9 +171,7 @@ export default function PublishPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"frameworks": ["vercel-ai", "langchain"],
"env": [
{
"name": "SENTIMENT_API_KEY",
@ -169,15 +179,22 @@ export default function PublishPage(): React.ReactElement {
"required": true
}
],
"frameworks": ["vercel-ai", "langchain"],
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions",
"limitations": "English and Spanish only. Max 10,000 characters",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in feedback"
]
}
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions",
"limitations": "English and Spanish only. Max 10,000 characters",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in feedback"
]
}
}
]
}
}`}
/>
@ -279,26 +296,31 @@ npm publish --access public
"keywords": ["tpmjs-tool", "blog", "content"],
"tpmjs": {
"category": "text-analysis",
"description": "Creates structured blog posts with frontmatter and SEO metadata",
"parameters": [
"frameworks": ["vercel-ai", "langchain"],
"tools": [
{
"name": "title",
"type": "string",
"description": "The title of the blog post",
"required": true
},
{
"name": "content",
"type": "string",
"description": "The main content",
"required": true
"exportName": "createBlogPostTool",
"description": "Creates structured blog posts with frontmatter and SEO metadata",
"parameters": [
{
"name": "title",
"type": "string",
"description": "The title of the blog post",
"required": true
},
{
"name": "content",
"type": "string",
"description": "The main content",
"required": true
}
],
"returns": {
"type": "BlogPost",
"description": "Structured blog post with frontmatter"
}
}
],
"returns": {
"type": "BlogPost",
"description": "Structured blog post with frontmatter"
},
"frameworks": ["vercel-ai", "langchain"]
]
}
}`}
/>

View file

@ -163,7 +163,13 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
}
]
}
}`}
/>
@ -237,26 +243,32 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Analyzes sentiment in text",
"parameters": [
"frameworks": ["vercel-ai"],
"tools": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en', 'es')",
"required": false,
"default": "en"
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text",
"parameters": [
{
"name": "text",
"type": "string",
"description": "The text to analyze",
"required": true
},
{
"name": "language",
"type": "string",
"description": "Language code (e.g., 'en', 'es')",
"required": false,
"default": "en"
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
}
}
],
"returns": {
"type": "SentimentResult",
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
}
]
}
}`}
/>
@ -361,9 +373,7 @@ export default function SpecPage(): React.ReactElement {
code={`{
"tpmjs": {
"category": "text-analysis",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"frameworks": ["vercel-ai", "langchain"],
"env": [
{
"name": "SENTIMENT_API_KEY",
@ -371,15 +381,22 @@ export default function SpecPage(): React.ReactElement {
"required": true
}
],
"frameworks": ["vercel-ai", "langchain"],
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
"limitations": "English and Spanish only. Max 10,000 characters per request.",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in user feedback"
]
}
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection",
"parameters": [...],
"returns": {...},
"aiAgent": {
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
"limitations": "English and Spanish only. Max 10,000 characters per request.",
"examples": [
"Analyze customer review sentiment",
"Detect emotions in user feedback"
]
}
}
]
}
}`}
/>

View file

@ -23,15 +23,17 @@ import { AppHeader } from '~/components/AppHeader';
interface Tool {
id: string;
npmPackageName: string;
npmVersion: string;
exportName: string;
description: string;
category: string;
tags: string[];
npmRepository: { url: string; type: string } | null;
qualityScore: string;
isOfficial: boolean;
npmDownloadsLastMonth: number;
package: {
npmPackageName: string;
npmVersion: string;
category: string;
npmRepository: { url: string; type: string } | null;
isOfficial: boolean;
npmDownloadsLastMonth: number;
};
}
/**
@ -43,12 +45,10 @@ export default function ToolSearchPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
// Fetch tools from API
useEffect(() => {
@ -77,21 +77,16 @@ export default function ToolSearchPage(): React.ReactElement {
setTools(fetchedTools);
setError(null);
// Extract unique categories and tags from all tools
// Extract unique categories from all tools
const categories = new Set<string>();
const tags = new Set<string>();
for (const tool of fetchedTools) {
if (tool.category) {
categories.add(tool.category);
}
for (const tag of tool.tags) {
tags.add(tag);
if (tool.package.category) {
categories.add(tool.package.category);
}
}
setAvailableCategories(Array.from(categories).sort());
setAvailableTags(Array.from(tags).sort());
} else {
setError(data.error || 'Failed to fetch tools');
}
@ -105,12 +100,6 @@ export default function ToolSearchPage(): React.ReactElement {
fetchTools();
}, [searchQuery, activeTab, categoryFilter]);
// Filter tools by selected tags (client-side)
const displayedTools =
selectedTags.length > 0
? tools.filter((tool) => selectedTags.some((tag) => tool.tags.includes(tag)))
: tools;
return (
<div className="min-h-screen bg-background">
<AppHeader />
@ -154,43 +143,18 @@ export default function ToolSearchPage(): React.ReactElement {
</div>
{/* Clear filters button */}
{(categoryFilter !== 'all' || selectedTags.length > 0) && (
{categoryFilter !== 'all' && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setCategoryFilter('all');
setSelectedTags([]);
}}
>
Clear Filters
</Button>
)}
</div>
{/* Popular tags */}
{availableTags.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="text-sm font-medium text-foreground-secondary mr-2">
Filter by tag:
</span>
{availableTags.slice(0, 10).map((tag) => (
<Badge
key={tag}
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
size="sm"
className="cursor-pointer hover:bg-foreground/10 transition-colors"
onClick={() => {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}}
>
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Tabs */}
@ -200,7 +164,7 @@ export default function ToolSearchPage(): React.ReactElement {
{
id: 'featured',
label: 'Official',
count: tools.filter((t) => t.isOfficial).length,
count: tools.filter((t) => t.package.isOfficial).length,
},
]}
activeTab={activeTab}
@ -220,15 +184,22 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Tool grid */}
{!loading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedTools.length > 0 ? (
displayedTools.map((tool) => (
{tools.length > 0 ? (
tools.map((tool) => (
<Card key={tool.id} className="flex flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle>{tool.npmPackageName}</CardTitle>
{tool.npmRepository && (
<div className="flex-1">
<CardTitle>
{tool.exportName !== 'default' ? tool.exportName : tool.package.npmPackageName}
</CardTitle>
<div className="text-sm text-foreground-secondary mt-1">
{tool.package.npmPackageName}
</div>
</div>
{tool.package.npmRepository && (
<a
href={tool.npmRepository.url.replace('git+', '').replace('.git', '')}
href={tool.package.npmRepository.url.replace('git+', '').replace('.git', '')}
target="_blank"
rel="noopener noreferrer"
className="text-foreground-secondary hover:text-foreground transition-colors"
@ -244,33 +215,22 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Category badge and version */}
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" size="sm">
{tool.category}
{tool.package.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.npmVersion}</span>
{tool.isOfficial && (
<span className="text-xs text-foreground-tertiary">v{tool.package.npmVersion}</span>
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
)}
</div>
{/* Tags */}
{tool.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tool.tags.slice(0, 5).map((tag) => (
<Badge key={tag} variant="outline" size="sm">
{tag}
</Badge>
))}
</div>
)}
{/* Quality score and downloads */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground-secondary">Quality Score</span>
<span className="text-foreground-tertiary">
{tool.npmDownloadsLastMonth.toLocaleString()} downloads/mo
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
</span>
</div>
<ProgressBar
@ -289,7 +249,7 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Install command */}
<CodeBlock
code={`npm install ${tool.npmPackageName}`}
code={`npm install ${tool.package.npmPackageName}`}
language="bash"
size="sm"
showCopy={true}
@ -297,7 +257,7 @@ export default function ToolSearchPage(): React.ReactElement {
</CardContent>
<CardFooter>
<Link href={`/tool/${tool.npmPackageName}`}>
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
<Button variant="outline" size="sm" className="w-full">
View Details
</Button>

View file

@ -6,14 +6,14 @@
*/
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
import type { Tool } from '@tpmjs/db';
import type { Package, Tool } from '@tpmjs/db';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TokenBreakdown } from './TokenBreakdown';
interface ToolPlaygroundProps {
tool: Tool;
tool: Tool & { package: Package };
}
type Tab = 'input' | 'output' | 'logs' | 'tokens';
@ -47,7 +47,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
try {
const response = await fetch(
`/api/tools/execute/${encodeURIComponent(tool.npmPackageName)}`,
`/api/tools/execute/${encodeURIComponent(tool.package.npmPackageName)}/${encodeURIComponent(tool.exportName)}`,
{
method: 'POST',
headers: {
@ -194,7 +194,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
<div>
<h2 className="text-xl font-semibold text-foreground">Interactive Playground</h2>
<p className="text-sm text-foreground-secondary mt-1">
Test {tool.npmPackageName} with AI-powered execution
Test {tool.package.npmPackageName} ({tool.exportName}) with AI-powered execution
</p>
</div>
{rateLimitInfo && (

View file

@ -4,7 +4,7 @@
*/
import { openai } from '@ai-sdk/openai';
import type { Tool } from '@tpmjs/db';
import type { Package, Tool } from '@tpmjs/db';
import { executePackage } from '@tpmjs/package-executor';
import { type CoreMessage, generateText } from 'ai';
import { z } from 'zod';
@ -90,13 +90,14 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
/**
* Create AI SDK v6 tool definition from TPMJS Tool
* Requires Tool with Package relation
*/
export function createToolDefinition(tool: Tool) {
export function createToolDefinition(tool: Tool & { package: Package }) {
const parameters = Array.isArray(tool.parameters)
? (tool.parameters as unknown as TPMJSParameter[])
: [];
console.log('[createToolDefinition] Tool:', tool.npmPackageName);
console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.exportName);
console.log('[createToolDefinition] Parameters array:', JSON.stringify(parameters));
console.log('[createToolDefinition] Parameters length:', parameters.length);
@ -108,7 +109,9 @@ export function createToolDefinition(tool: Tool) {
console.log('[createToolDefinition] Created Zod schema:', inputSchema);
const sanitizedName = sanitizeToolName(tool.npmPackageName);
const sanitizedName = sanitizeToolName(
`${tool.package.npmPackageName}-${tool.exportName}`
);
// AI SDK v6 tool definition
return {
@ -118,9 +121,10 @@ export function createToolDefinition(tool: Tool) {
console.log('[Tool execute] Running:', sanitizedName, params);
// Execute the actual npm package in a sandbox
// Use the actual export name from the Tool record
const result = await executePackage(
tool.npmPackageName,
'default', // Most TPMJS packages export a default function
tool.package.npmPackageName,
tool.exportName, // Use actual export name (e.g., "helloWorldTool", "default")
params,
{ timeout: 5000 }
);
@ -186,15 +190,18 @@ function sanitizeToolName(npmPackageName: string): string {
/**
* Execute tool with AI agent using AI SDK v6
* Requires Tool with Package relation
*/
export async function executeToolWithAgent(
tool: Tool,
tool: Tool & { package: Package },
userPrompt: string,
onChunk?: (chunk: string) => void,
onTokenUpdate?: (tokens: Partial<TokenBreakdown>) => void
) {
const toolDef = createToolDefinition(tool);
const sanitizedToolName = sanitizeToolName(tool.npmPackageName);
const sanitizedToolName = sanitizeToolName(
`${tool.package.npmPackageName}-${tool.exportName}`
);
console.log('[executeToolWithAgent] Tool name:', sanitizedToolName);

762
manual-tools.ts Normal file
View file

@ -0,0 +1,762 @@
/**
* Manual Tools Registry
*
* This file contains tools that should be included in TPMJS but don't follow
* the standard tpmjs field specification in their package.json.
*
* These tools are manually curated and synced to the database via the
* manual sync script.
*/
export interface ManualTool {
// Package metadata
npmPackageName: string;
npmVersion?: string; // Optional - will fetch latest if not specified
category: 'text-analysis' | 'code-generation' | 'data-processing' | 'image-generation' | 'audio-processing' | 'search' | 'integration' | 'other';
frameworks: Array<'vercel-ai' | 'langchain' | 'llamaindex' | 'other'>;
// Tool definition
exportName: string;
description: string;
// Optional rich metadata
parameters?: Array<{
name: string;
type: string;
description: string;
required: boolean;
default?: string;
}>;
returns?: {
type: string;
description: string;
};
aiAgent?: {
useCase: string;
limitations?: string;
examples?: string[];
};
// Environment variables
env?: Array<{
name: string;
description: string;
required: boolean;
}>;
// Additional metadata not in npm
tags?: string[];
docsUrl?: string;
apiKeyUrl?: string;
websiteUrl?: string;
}
export const manualTools: ManualTool[] = [
{
npmPackageName: 'ai-sdk-tool-code-execution',
category: 'code-generation',
frameworks: ['vercel-ai'],
exportName: 'executeCode',
description: 'Execute Python code in a sandboxed environment using Vercel Sandbox. Run calculations, data processing, and other computational tasks safely in an isolated environment with Python 3.13.',
tags: ['code-execution', 'sandbox'],
env: [
{
name: 'VERCEL_OIDC_TOKEN',
description: 'Vercel OIDC token for sandbox authentication',
required: true,
},
],
parameters: [
{
name: 'code',
type: 'string',
description: 'Python code to execute in the sandbox',
required: true,
},
],
returns: {
type: 'object',
description: 'Execution result with stdout, stderr, and return value',
},
aiAgent: {
useCase: 'Use when you need to perform calculations, data processing, or execute Python code safely',
limitations: 'Python 3.13 only. No network access. 30 second execution timeout.',
examples: [
'Perform complex mathematical calculations',
'Process data with pandas/numpy',
'Generate plots with matplotlib',
],
},
docsUrl: 'https://vercel.com/docs/vercel-sandbox',
apiKeyUrl: 'https://vercel.com/docs/vercel-sandbox#authentication',
websiteUrl: 'https://vercel.com/docs/vercel-sandbox',
},
{
npmPackageName: '@exalabs/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'webSearch',
description: 'Search the web for current information using Exa\'s AI-powered search API. Returns high-quality, relevant results optimized for LLM consumption.',
tags: ['search', 'web', 'extraction'],
env: [
{
name: 'EXA_API_KEY',
description: 'API key for Exa search service',
required: true,
},
],
parameters: [
{
name: 'query',
type: 'string',
description: 'Search query',
required: true,
},
{
name: 'numResults',
type: 'number',
description: 'Number of results to return (1-10)',
required: false,
default: '5',
},
],
returns: {
type: 'array',
description: 'Array of search results with title, URL, and content',
},
aiAgent: {
useCase: 'Use when you need current information, news, research papers, or web content',
limitations: 'Requires API key. Rate limits apply based on plan.',
examples: [
'Find latest AI developments',
'Search for technical documentation',
'Research current events',
],
},
docsUrl: 'https://docs.exa.ai/reference/vercel',
apiKeyUrl: 'https://dashboard.exa.ai/api-keys',
websiteUrl: 'https://exa.ai',
},
{
npmPackageName: '@parallel-web/ai-sdk-tools',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'searchTool',
description: 'Search and extract context from the web with token-optimized results. Parallel compresses web results for optimal inference efficiency.',
tags: ['search', 'web', 'extraction'],
env: [
{
name: 'PARALLEL_API_KEY',
description: 'API key for Parallel search service',
required: true,
},
],
aiAgent: {
useCase: 'Use when you need web search with minimal token usage',
examples: [
'Search for current information',
'Extract structured data from websites',
],
},
apiKeyUrl: 'https://platform.parallel.ai',
websiteUrl: 'https://parallel.ai',
},
{
npmPackageName: '@parallel-web/ai-sdk-tools',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'extractTool',
description: 'Extract structured content from web pages with token-optimized compression.',
tags: ['extraction', 'web'],
env: [
{
name: 'PARALLEL_API_KEY',
description: 'API key for Parallel search service',
required: true,
},
],
parameters: [
{
name: 'url',
type: 'string',
description: 'URL of the page to extract content from',
required: true,
},
],
returns: {
type: 'object',
description: 'Extracted and compressed page content',
},
aiAgent: {
useCase: 'Use to extract clean content from specific URLs',
examples: [
'Extract article content',
'Parse documentation pages',
],
},
websiteUrl: 'https://parallel.ai',
},
{
npmPackageName: 'ctx-zip',
category: 'code-generation',
frameworks: ['vercel-ai'],
exportName: 'createVercelSandboxCodeMode',
description: 'Transform MCP tools and AI SDK tools into code, write to Vercel sandbox filesystem, and execute in isolated environment.',
tags: ['code-execution', 'sandbox', 'mcp', 'code-mode'],
env: [
{
name: 'VERCEL_OIDC_TOKEN',
description: 'Vercel OIDC token for sandbox authentication',
required: true,
},
],
aiAgent: {
useCase: 'Use when you need to combine MCP tools with code execution in a sandbox',
limitations: 'Requires Vercel Sandbox access',
examples: [
'Execute code using MCP server tools',
'Combine multiple tool sources',
],
},
docsUrl: 'https://github.com/karthikscale3/ctx-zip/blob/main/README.md',
apiKeyUrl: 'https://vercel.com/docs/vercel-sandbox#authentication',
websiteUrl: 'https://github.com/karthikscale3/ctx-zip',
},
{
npmPackageName: '@perplexity-ai/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'perplexitySearch',
description: 'Search the web with real-time results and advanced filtering powered by Perplexity Search API. Supports ranked results with domain, language, date range, and recency filters.',
tags: ['search', 'web'],
env: [
{
name: 'PERPLEXITY_API_KEY',
description: 'API key for Perplexity search service',
required: true,
},
],
parameters: [
{
name: 'query',
type: 'string',
description: 'Search query',
required: true,
},
{
name: 'filters',
type: 'object',
description: 'Optional filters for domain, language, date range, etc.',
required: false,
},
],
returns: {
type: 'array',
description: 'Ranked search results with metadata',
},
aiAgent: {
useCase: 'Use for comprehensive web search with filtering capabilities',
examples: [
'Find latest AI developments',
'Search with date range filters',
'Domain-specific searches',
],
},
docsUrl: 'https://docs.perplexity.ai/guides/search-quickstart',
apiKeyUrl: 'https://www.perplexity.ai/account/api/keys',
websiteUrl: 'https://www.perplexity.ai',
},
{
npmPackageName: '@tavily/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'tavilySearch',
description: 'Real-time web search optimized for AI applications. Provides comprehensive web research including search, content extraction, website crawling, and site mapping.',
tags: ['search', 'extract', 'crawl'],
env: [
{
name: 'TAVILY_API_KEY',
description: 'API key for Tavily search service',
required: true,
},
],
aiAgent: {
useCase: 'Use for comprehensive web research and content extraction',
examples: [
'Research complex topics',
'Extract structured information',
'Crawl entire websites',
],
},
docsUrl: 'https://docs.tavily.com/documentation/integrations/vercel',
apiKeyUrl: 'https://app.tavily.com/home',
websiteUrl: 'https://tavily.com',
},
{
npmPackageName: 'firecrawl-aisdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'scrapeTool',
description: 'Scrape any website into clean markdown format. Convert web pages to LLM-friendly markdown with automatic cleaning and formatting.',
tags: ['scraping', 'web', 'extraction'],
env: [
{
name: 'FIRECRAWL_API_KEY',
description: 'API key for Firecrawl service',
required: true,
},
],
parameters: [
{
name: 'url',
type: 'string',
description: 'URL of the website to scrape',
required: true,
},
],
returns: {
type: 'object',
description: 'Scraped content in clean markdown format',
},
aiAgent: {
useCase: 'Use when you need to convert web pages to clean, structured markdown',
examples: [
'Scrape documentation pages',
'Extract article content',
'Convert web pages to markdown',
],
},
docsUrl: 'https://docs.firecrawl.dev/integrations/ai-sdk',
apiKeyUrl: 'https://firecrawl.dev/app/api-keys',
websiteUrl: 'https://firecrawl.dev',
},
{
npmPackageName: 'firecrawl-aisdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'searchTool',
description: 'Search the web and get results in clean markdown format optimized for AI consumption.',
tags: ['search', 'web'],
env: [
{
name: 'FIRECRAWL_API_KEY',
description: 'API key for Firecrawl service',
required: true,
},
],
websiteUrl: 'https://firecrawl.dev',
},
{
npmPackageName: 'firecrawl-aisdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'crawlTool',
description: 'Crawl entire websites and extract structured data. Automatically discover and process multiple pages.',
tags: ['crawling', 'web', 'extraction'],
env: [
{
name: 'FIRECRAWL_API_KEY',
description: 'API key for Firecrawl service',
required: true,
},
],
parameters: [
{
name: 'url',
type: 'string',
description: 'Starting URL to begin crawling',
required: true,
},
{
name: 'maxPages',
type: 'number',
description: 'Maximum number of pages to crawl',
required: false,
default: '10',
},
],
aiAgent: {
useCase: 'Use when you need to crawl and extract data from entire websites',
examples: [
'Crawl documentation sites',
'Extract all articles from a blog',
'Build knowledge base from website',
],
},
websiteUrl: 'https://firecrawl.dev',
},
{
npmPackageName: 'bedrock-agentcore',
category: 'code-generation',
frameworks: ['vercel-ai'],
exportName: 'CodeInterpreterTools',
description: 'Isolated sandbox for executing Python, JavaScript, and TypeScript code to solve complex tasks. Fully managed by Amazon Bedrock.',
tags: ['code-execution', 'sandbox'],
env: [
{
name: 'AWS_ROLE_ARN',
description: 'AWS IAM role ARN for Bedrock access',
required: true,
},
],
aiAgent: {
useCase: 'Use for secure code execution in AWS-managed sandbox',
limitations: 'Requires AWS credentials and Bedrock access',
examples: [
'Execute Python data analysis',
'Run JavaScript computations',
'TypeScript code execution',
],
},
docsUrl: 'https://github.com/aws/bedrock-agentcore-sdk-typescript',
apiKeyUrl: 'https://vercel.com/docs/oidc/aws',
websiteUrl: 'https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/built-in-tools.html',
},
{
npmPackageName: 'bedrock-agentcore',
category: 'integration',
frameworks: ['vercel-ai'],
exportName: 'BrowserTools',
description: 'Fast and secure cloud-based browser runtime for web automation. Fill forms, navigate websites, and extract information in managed environment.',
tags: ['browser-automation', 'web'],
env: [
{
name: 'AWS_ROLE_ARN',
description: 'AWS IAM role ARN for Bedrock access',
required: true,
},
],
aiAgent: {
useCase: 'Use for browser automation and web interaction tasks',
examples: [
'Fill web forms',
'Navigate websites',
'Extract dynamic content',
],
},
websiteUrl: 'https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/built-in-tools.html',
},
{
npmPackageName: '@superagent-ai/ai-sdk',
category: 'other',
frameworks: ['vercel-ai'],
exportName: 'guard',
description: 'Protect AI apps from prompt injection and security threats. Detect and block malicious inputs before they reach your LLM.',
tags: ['security', 'guardrails', 'prompt-injection'],
env: [
{
name: 'SUPERAGENT_API_KEY',
description: 'API key for Superagent security service',
required: true,
},
],
parameters: [
{
name: 'input',
type: 'string',
description: 'User input to check for security threats',
required: true,
},
],
returns: {
type: 'object',
description: 'Security analysis with threat detection results',
},
aiAgent: {
useCase: 'Use to validate user inputs for security threats before processing',
examples: [
'Detect prompt injection attempts',
'Block malicious inputs',
'Security validation',
],
},
docsUrl: 'https://docs.superagent.sh',
apiKeyUrl: 'https://dashboard.superagent.sh',
websiteUrl: 'https://superagent.sh',
},
{
npmPackageName: '@superagent-ai/ai-sdk',
category: 'other',
frameworks: ['vercel-ai'],
exportName: 'redact',
description: 'Redact PII/PHI from text including SSNs, emails, phone numbers, and other sensitive information.',
tags: ['security', 'pii', 'redaction'],
env: [
{
name: 'SUPERAGENT_API_KEY',
description: 'API key for Superagent security service',
required: true,
},
],
parameters: [
{
name: 'text',
type: 'string',
description: 'Text containing potentially sensitive information',
required: true,
},
],
returns: {
type: 'object',
description: 'Redacted text with PII/PHI removed or masked',
},
aiAgent: {
useCase: 'Use to remove sensitive information from text before processing',
examples: [
'Redact SSNs from documents',
'Remove email addresses',
'Mask phone numbers',
],
},
websiteUrl: 'https://superagent.sh',
},
{
npmPackageName: '@superagent-ai/ai-sdk',
category: 'other',
frameworks: ['vercel-ai'],
exportName: 'verify',
description: 'Verify AI-generated claims against source materials. Fact-check and validate LLM outputs for accuracy.',
tags: ['verification', 'fact-checking'],
env: [
{
name: 'SUPERAGENT_API_KEY',
description: 'API key for Superagent security service',
required: true,
},
],
parameters: [
{
name: 'claim',
type: 'string',
description: 'Claim to verify',
required: true,
},
{
name: 'sources',
type: 'array',
description: 'Source materials to verify against',
required: true,
},
],
returns: {
type: 'object',
description: 'Verification result with confidence score',
},
aiAgent: {
useCase: 'Use to fact-check LLM outputs against trusted sources',
examples: [
'Verify factual claims',
'Check citations',
'Validate information accuracy',
],
},
websiteUrl: 'https://superagent.sh',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'webSearch',
description: 'Real-time web search for current information and news.',
tags: ['search', 'web'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use for general web search and current information',
examples: [
'Find latest news',
'Search for current events',
],
},
docsUrl: 'https://docs.valyu.ai/integrations/vercel-ai-sdk',
apiKeyUrl: 'https://platform.valyu.ai',
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'financeSearch',
description: 'Search financial data including stock prices, earnings, income statements, cash flows, and market data.',
tags: ['search', 'finance', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use for financial data and market research',
examples: [
'Get stock prices',
'Find earnings reports',
'Search financial statements',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'paperSearch',
description: 'Full-text search across PubMed, arXiv, bioRxiv, and medRxiv research papers.',
tags: ['search', 'research', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use to search academic and research papers',
examples: [
'Find medical research',
'Search arXiv papers',
'Look up scientific publications',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'bioSearch',
description: 'Search biomedical information including clinical trials, FDA drug labels, PubMed, medRxiv, and bioRxiv.',
tags: ['search', 'biomedical', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use for biomedical and clinical research',
examples: [
'Search clinical trials',
'Find FDA drug information',
'Research medical studies',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'patentSearch',
description: 'Search USPTO patent database for patents and patent applications.',
tags: ['search', 'patents', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use to search and research patents',
examples: [
'Find existing patents',
'Research patent applications',
'Prior art search',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'secSearch',
description: 'Search SEC filings including 10-K, 10-Q, and 8-K reports.',
tags: ['search', 'sec', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use to search SEC filings and corporate reports',
examples: [
'Find 10-K annual reports',
'Search quarterly filings',
'Research corporate disclosures',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'economicsSearch',
description: 'Search economic data from BLS, FRED, and World Bank databases.',
tags: ['search', 'economics', 'domain-search'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
aiAgent: {
useCase: 'Use to search economic indicators and statistics',
examples: [
'Find employment data',
'Search GDP statistics',
'Research economic indicators',
],
},
websiteUrl: 'https://valyu.ai',
},
{
npmPackageName: '@valyu/ai-sdk',
category: 'search',
frameworks: ['vercel-ai'],
exportName: 'companyResearch',
description: 'Generate comprehensive company research reports with financial data, news, and analysis.',
tags: ['search', 'research', 'company-analysis'],
env: [
{
name: 'VALYU_API_KEY',
description: 'API key for Valyu search service',
required: true,
},
],
parameters: [
{
name: 'company',
type: 'string',
description: 'Company name or ticker symbol',
required: true,
},
],
returns: {
type: 'object',
description: 'Comprehensive research report with financial and market data',
},
aiAgent: {
useCase: 'Use to generate detailed company research and analysis',
examples: [
'Company financial analysis',
'Competitive research',
'Market position analysis',
],
},
websiteUrl: 'https://valyu.ai',
},
];

View file

@ -34,6 +34,7 @@
"dependency-cruiser": "^17.3.1",
"knip": "^5.70.2",
"lefthook": "^1.10.1",
"tsx": "^4.21.0",
"turbo": "^2.6.1",
"type-coverage": "^2.29.7",
"typescript": "^5.9.3"

View file

@ -10,46 +10,68 @@ datasource db {
url = env("DATABASE_URL")
}
/// Main tools table - stores all discovered NPM packages with TPMJS metadata
model Tool {
/// Package table - stores NPM package metadata (package-level)
model Package {
id String @id @default(cuid())
// NPM Metadata
npmPackageName String @unique @map("npm_package_name") @db.VarChar(214)
npmVersion String @map("npm_version") @db.VarChar(50)
npmPublishedAt DateTime @map("npm_published_at")
npmDescription String? @map("npm_description") @db.Text
npmRepository Json? @map("npm_repository") @db.JsonB
npmHomepage String? @map("npm_homepage") @db.Text
npmLicense String? @map("npm_license") @db.VarChar(50)
npmKeywords String[] @default([]) @map("npm_keywords") @db.Text
npmReadme String? @map("npm_readme") @db.Text
npmAuthor Json? @map("npm_author") @db.JsonB
npmMaintainers Json? @map("npm_maintainers") @db.JsonB
npmPackageName String @unique @map("npm_package_name") @db.VarChar(214)
npmVersion String @map("npm_version") @db.VarChar(50)
npmPublishedAt DateTime @map("npm_published_at")
npmDescription String? @map("npm_description") @db.Text
npmRepository Json? @map("npm_repository") @db.JsonB
npmHomepage String? @map("npm_homepage") @db.Text
npmLicense String? @map("npm_license") @db.VarChar(50)
npmKeywords String[] @default([]) @map("npm_keywords") @db.Text
npmReadme String? @map("npm_readme") @db.Text
npmAuthor Json? @map("npm_author") @db.JsonB
npmMaintainers Json? @map("npm_maintainers") @db.JsonB
// TPMJS Metadata (from package.json tpmjs field)
// TPMJS Package-Level Metadata
category String @db.VarChar(50)
description String @db.Text
example String? @db.Text
parameters Json? @db.JsonB
returns Json? @db.JsonB
authentication Json? @db.JsonB
pricing Json? @db.JsonB
env Json? @db.JsonB // Environment variables (shared across tools)
frameworks String[] @default([]) @db.Text
links Json? @db.JsonB
tags String[] @default([]) @db.Text
status String? @db.VarChar(20)
aiAgent Json? @map("ai_agent") @db.JsonB
// Discovery Metadata
tier String @db.VarChar(20) // 'minimal' | 'rich'
discoveryMethod String @map("discovery_method") @db.VarChar(20) // 'keyword' | 'changes-feed'
isOfficial Boolean @default(false) @map("is_official")
tier String @db.VarChar(20) // 'minimal' | 'rich'
isOfficial Boolean @default(false) @map("is_official")
// Metrics
npmDownloadsLastMonth Int? @default(0) @map("npm_downloads_last_month")
githubStars Int? @default(0) @map("github_stars")
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00
// Package Metrics
npmDownloadsLastMonth Int? @default(0) @map("npm_downloads_last_month")
githubStars Int? @default(0) @map("github_stars")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
tools Tool[]
@@index([category])
@@index([isOfficial])
@@index([npmDownloadsLastMonth])
@@index([createdAt])
@@map("packages")
}
/// Tool table - stores individual tools within packages
model Tool {
id String @id @default(cuid())
// Package Relation
packageId String @map("package_id")
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
// Tool Identity
exportName String @map("export_name") @db.VarChar(100) // e.g., "helloWorldTool", "default"
// Tool Metadata
description String @db.Text
parameters Json? @db.JsonB
returns Json? @db.JsonB
aiAgent Json? @map("ai_agent") @db.JsonB
// Tool Metrics
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@ -58,11 +80,8 @@ model Tool {
// Relations
simulations Simulation[]
@@index([category])
@@index([isOfficial])
@@unique([packageId, exportName])
@@index([qualityScore])
@@index([npmDownloadsLastMonth])
@@index([createdAt])
@@map("tools")
}
@ -81,14 +100,14 @@ model SyncCheckpoint {
model SyncLog {
id String @id @default(cuid())
source String @db.VarChar(50) // 'changes-feed' | 'keyword-search' | 'metrics'
status String @db.VarChar(20) // 'success' | 'error' | 'partial'
processed Int @default(0) // Number of packages processed
skipped Int @default(0) // Number of packages skipped
errors Int @default(0) // Number of errors encountered
message String? @db.Text // Error message or summary
metadata Json? @db.JsonB // Additional context
createdAt DateTime @default(now()) @map("created_at")
source String @db.VarChar(50) // 'changes-feed' | 'keyword-search' | 'metrics'
status String @db.VarChar(20) // 'success' | 'error' | 'partial'
processed Int @default(0) // Number of packages processed
skipped Int @default(0) // Number of packages skipped
errors Int @default(0) // Number of errors encountered
message String? @db.Text // Error message or summary
metadata Json? @db.JsonB // Additional context
createdAt DateTime @default(now()) @map("created_at")
@@index([source])
@@index([status])
@ -121,8 +140,8 @@ model Simulation {
model String? @db.VarChar(50)
// Relations
tokenUsage TokenUsage?
logs ExecutionLog[]
tokenUsage TokenUsage?
logs ExecutionLog[]
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
@ -140,12 +159,12 @@ model TokenUsage {
simulationId String @unique @map("simulation_id")
simulation Simulation @relation(fields: [simulationId], references: [id], onDelete: Cascade)
inputTokens Int @default(0) @map("input_tokens")
toolDescTokens Int @default(0) @map("tool_desc_tokens")
schemaTokens Int @default(0) @map("schema_tokens")
outputTokens Int @default(0) @map("output_tokens")
totalTokens Int @default(0) @map("total_tokens")
estimatedCost Decimal? @map("estimated_cost") @db.Decimal(10, 6)
inputTokens Int @default(0) @map("input_tokens")
toolDescTokens Int @default(0) @map("tool_desc_tokens")
schemaTokens Int @default(0) @map("schema_tokens")
outputTokens Int @default(0) @map("output_tokens")
totalTokens Int @default(0) @map("total_tokens")
estimatedCost Decimal? @map("estimated_cost") @db.Decimal(10, 6)
createdAt DateTime @default(now()) @map("created_at")
@@map("token_usage")

View file

@ -0,0 +1,9 @@
# @tpmjs/hello
## 0.0.2
### Patch Changes
- feat: add multi-tool example package with helloWorldTool and helloNameTool
This is the first TPMJS package published with the new multi-tool format, demonstrating support for multiple tool exports in a single package.

View file

@ -1,7 +1,6 @@
{
"name": "@tpmjs/hello",
"version": "0.0.1",
"private": true,
"version": "0.0.2",
"description": "Example TPMJS tools - Hello World and Hello Name",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -11,17 +10,68 @@
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"keywords": ["tpmjs-tool", "ai-sdk", "hello", "example"],
"keywords": [
"tpmjs-tool",
"ai-sdk",
"hello",
"example"
],
"tpmjs": {
"category": "text-analysis",
"description": "Simple greeting tools - Hello World and personalized Hello Name greetings"
"frameworks": [
"vercel-ai"
],
"tools": [
{
"exportName": "helloWorldTool",
"description": "Returns a simple 'Hello, World!' greeting with optional timestamp and customizable message",
"parameters": [
{
"name": "includeTimestamp",
"type": "boolean",
"description": "Whether to include the current timestamp in the greeting",
"required": false
}
],
"returns": {
"type": "string",
"description": "A greeting message, optionally with timestamp"
}
},
{
"exportName": "helloNameTool",
"description": "Returns a personalized greeting with the provided name",
"parameters": [
{
"name": "name",
"type": "string",
"description": "The name to greet",
"required": true
},
{
"name": "formal",
"type": "boolean",
"description": "Whether to use formal greeting (Mr./Ms.)",
"required": false
}
],
"returns": {
"type": "string",
"description": "A personalized greeting message"
}
}
]
},
"dependencies": {
"ai": "6.0.0-beta.124"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
},
"files": ["dist", "README.md"]
"files": [
"dist",
"README.md"
]
}

View file

@ -67,23 +67,53 @@ export const TpmjsAiAgentSchema = z.object({
export type TpmjsAiAgent = z.infer<typeof TpmjsAiAgentSchema>;
/**
* Minimal tier schema - required fields only
* This is the minimum required to publish a tool to TPMJS
* Individual tool definition within a multi-tool package
*/
export const TpmjsMinimalSchema = z.object({
export const TpmjsToolDefinitionSchema = z.object({
exportName: z.string().min(1, 'Export name is required'),
description: z.string().min(20, 'Description must be at least 20 characters').max(500),
parameters: z.array(TpmjsParameterSchema).optional(),
returns: TpmjsReturnsSchema.optional(),
aiAgent: TpmjsAiAgentSchema.optional(),
});
export type TpmjsToolDefinition = z.infer<typeof TpmjsToolDefinitionSchema>;
/**
* Multi-tool format - NEW SCHEMA
* Package-level metadata with array of tools
*/
export const TpmjsMultiToolSchema = z.object({
category: z.enum(TPMJS_CATEGORIES, {
message: `Category must be one of: ${TPMJS_CATEGORIES.join(', ')}`,
}),
tools: z.array(TpmjsToolDefinitionSchema).min(1, 'At least one tool is required'),
env: z.array(TpmjsEnvSchema).optional(),
frameworks: z
.array(z.enum(['vercel-ai', 'langchain', 'llamaindex', 'haystack', 'semantic-kernel']))
.optional(),
});
export type TpmjsMultiTool = z.infer<typeof TpmjsMultiToolSchema>;
/**
* Legacy minimal tier schema - DEPRECATED
* Kept for backward compatibility with auto-migration
*/
export const TpmjsLegacyMinimalSchema = z.object({
category: z.enum(TPMJS_CATEGORIES, {
message: `Category must be one of: ${TPMJS_CATEGORIES.join(', ')}`,
}),
description: z.string().min(20, 'Description must be at least 20 characters').max(500),
});
export type TpmjsMinimal = z.infer<typeof TpmjsMinimalSchema>;
export type TpmjsLegacyMinimal = z.infer<typeof TpmjsLegacyMinimalSchema>;
/**
* Rich tier schema - includes optional enhanced metadata
* Tools with these fields get better visibility and quality scores
* Legacy rich tier schema - DEPRECATED
* Kept for backward compatibility with auto-migration
*/
export const TpmjsRichSchema = TpmjsMinimalSchema.extend({
export const TpmjsLegacyRichSchema = TpmjsLegacyMinimalSchema.extend({
parameters: z.array(TpmjsParameterSchema).optional(),
returns: TpmjsReturnsSchema.optional(),
env: z.array(TpmjsEnvSchema).optional(),
@ -93,57 +123,129 @@ export const TpmjsRichSchema = TpmjsMinimalSchema.extend({
aiAgent: TpmjsAiAgentSchema.optional(),
});
export type TpmjsRich = z.infer<typeof TpmjsRichSchema>;
export type TpmjsLegacyRich = z.infer<typeof TpmjsLegacyRichSchema>;
/**
* Union type for either tier
* Union type for legacy formats
*/
export type TpmjsField = TpmjsMinimal | TpmjsRich;
export type TpmjsLegacy = TpmjsLegacyMinimal | TpmjsLegacyRich;
/**
* Validation result type
* Union type for all formats (new multi-tool + legacy)
*/
export type TpmjsField = TpmjsMultiTool | TpmjsLegacy;
// Backward compatibility aliases
export type TpmjsMinimal = TpmjsLegacyMinimal;
export type TpmjsRich = TpmjsLegacyRich;
export const TpmjsMinimalSchema = TpmjsLegacyMinimalSchema;
export const TpmjsRichSchema = TpmjsLegacyRichSchema;
/**
* Extended validation result type for multi-tool support
*/
export interface ValidationResult {
valid: boolean;
tier: 'minimal' | 'rich' | null;
data?: TpmjsField;
errors?: z.ZodError;
// New fields for multi-tool support
packageData?: {
category: TpmjsCategory;
env?: TpmjsEnv[];
frameworks?: string[];
};
tools?: TpmjsToolDefinition[];
wasLegacyFormat?: boolean;
}
/**
* Validates a tpmjs field and determines its tier
* Supports both new multi-tool format and legacy single-tool format with auto-migration
*/
export function validateTpmjsField(tpmjs: unknown): ValidationResult {
// Try rich tier first
const richResult = TpmjsRichSchema.safeParse(tpmjs);
if (richResult.success) {
// Check if it has any rich-tier fields
const data = richResult.data;
const hasRichFields =
data.parameters || data.returns || data.env || data.frameworks || data.aiAgent;
// Try new multi-tool format first
const multiResult = TpmjsMultiToolSchema.safeParse(tpmjs);
if (multiResult.success) {
const data = multiResult.data;
// Determine tier based on tool richness
const hasRichFields = data.tools.some(
(tool) => tool.parameters || tool.returns || tool.aiAgent
) || data.env || data.frameworks;
return {
valid: true,
tier: hasRichFields ? 'rich' : 'minimal',
data: richResult.data,
data: data,
packageData: {
category: data.category,
env: data.env,
frameworks: data.frameworks,
},
tools: data.tools,
wasLegacyFormat: false,
};
}
// Try minimal tier
const minimalResult = TpmjsMinimalSchema.safeParse(tpmjs);
// Try legacy rich tier format with auto-migration
const richResult = TpmjsLegacyRichSchema.safeParse(tpmjs);
if (richResult.success) {
const legacyData = richResult.data;
// Auto-migrate to multi-tool format
const tool: TpmjsToolDefinition = {
exportName: 'default',
description: legacyData.description,
parameters: legacyData.parameters,
returns: legacyData.returns,
aiAgent: legacyData.aiAgent,
};
const hasRichFields =
legacyData.parameters || legacyData.returns || legacyData.env ||
legacyData.frameworks || legacyData.aiAgent;
return {
valid: true,
tier: hasRichFields ? 'rich' : 'minimal',
data: legacyData,
packageData: {
category: legacyData.category,
env: legacyData.env,
frameworks: legacyData.frameworks,
},
tools: [tool],
wasLegacyFormat: true,
};
}
// Try legacy minimal tier format with auto-migration
const minimalResult = TpmjsLegacyMinimalSchema.safeParse(tpmjs);
if (minimalResult.success) {
// Auto-migrate to multi-tool format
const tool: TpmjsToolDefinition = {
exportName: 'default',
description: minimalResult.data.description,
};
return {
valid: true,
tier: 'minimal',
data: minimalResult.data,
packageData: {
category: minimalResult.data.category,
},
tools: [tool],
wasLegacyFormat: true,
};
}
// Invalid
// Invalid - return error from multi-tool schema (most informative)
return {
valid: false,
tier: null,
errors: minimalResult.error,
errors: multiResult.error,
};
}

1643
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

116
sync-hello.ts Normal file
View file

@ -0,0 +1,116 @@
import { PrismaClient } from '@prisma/client';
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
const prisma = new PrismaClient();
async function syncHello() {
try {
console.log('Fetching @tpmjs/hello from npm...');
const response = await fetch('https://registry.npmjs.org/@tpmjs/hello');
const data = await response.json();
const latest = data['dist-tags'].latest;
const pkg = data.versions[latest];
console.log(`\nPackage: ${pkg.name}@${pkg.version}`);
console.log(`Keywords: ${pkg.keywords.join(', ')}`);
console.log(`\ntpmjs field:`, JSON.stringify(pkg.tpmjs, null, 2));
// Validate
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid) {
console.error('\n❌ Invalid tpmjs field:', validation.errors);
process.exit(1);
}
console.log(`\n✅ Valid tpmjs field (${validation.tier} tier)`);
console.log(`Tools to create: ${validation.tools?.length || 0}`);
// Upsert Package
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: {
npmPackageName: pkg.name,
npmVersion: pkg.version,
npmPublishedAt: new Date(pkg.time[pkg.version]),
npmDownloadsLastMonth: 0,
category: validation.packageData!.category,
env: validation.packageData!.env || null,
frameworks: validation.packageData!.frameworks || [],
tier: validation.tier!,
discoveryMethod: 'manual',
isOfficial: pkg.name.startsWith('@tpmjs/'),
},
update: {
npmVersion: pkg.version,
npmPublishedAt: new Date(pkg.time[pkg.version]),
category: validation.packageData!.category,
env: validation.packageData!.env || null,
frameworks: validation.packageData!.frameworks || [],
tier: validation.tier!,
},
});
console.log(`\n✅ Package upserted: ${packageRecord.id}`);
// Get existing tools
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
console.log(`\nExisting tools: ${existingTools.length}`);
// Upsert each tool
for (const toolDef of validation.tools || []) {
const tool = await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: toolDef.exportName,
description: toolDef.description,
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null,
},
update: {
description: toolDef.description,
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
},
});
console.log(`✅ Tool upserted: ${tool.exportName} (${tool.id})`);
}
// Delete orphaned tools
const orphanedTools = existingTools.filter(
(existingTool) =>
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
);
if (orphanedTools.length > 0) {
await prisma.tool.deleteMany({
where: { id: { in: orphanedTools.map((t) => t.id) } },
});
console.log(`\n🗑 Deleted ${orphanedTools.length} orphaned tools`);
}
console.log('\n✅ Sync complete!');
} catch (error) {
console.error('\n❌ Sync failed:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
syncHello();

138
sync-manual-tools.ts Normal file
View file

@ -0,0 +1,138 @@
import { prisma } from './packages/db/src/index.js';
import { manualTools } from './manual-tools.js';
import { fetchLatestPackageWithMetadata } from './packages/npm-client/src/package.js';
async function syncManualTools() {
console.log('\n🔧 Starting manual tools sync...\n');
let processed = 0;
let skipped = 0;
let errors = 0;
for (const manualTool of manualTools) {
try {
console.log(`Processing: ${manualTool.npmPackageName} (${manualTool.exportName})`);
// Fetch package metadata from npm
const npmData = await fetchLatestPackageWithMetadata(manualTool.npmPackageName);
if (!npmData) {
console.error(` ❌ Package not found on npm: ${manualTool.npmPackageName}`);
errors++;
continue;
}
// Use manual version if specified, otherwise use latest from npm
const version = manualTool.npmVersion || npmData.version;
// Get published date
const publishedAt = npmData.time?.[version] || npmData.time?.modified || new Date().toISOString();
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: manualTool.npmPackageName },
create: {
npmPackageName: manualTool.npmPackageName,
npmVersion: version,
npmPublishedAt: new Date(publishedAt),
npmDescription: npmData.description || null,
npmRepository: npmData.repository || null,
npmHomepage: npmData.homepage || manualTool.websiteUrl || null,
npmLicense: npmData.license || null,
npmKeywords: npmData.keywords || [],
npmReadme: npmData.readme || null,
npmAuthor: npmData.author || null,
npmMaintainers: npmData.maintainers || null,
category: manualTool.category,
env: manualTool.env ? (manualTool.env as any) : null,
frameworks: manualTool.frameworks,
tier: calculateTier(manualTool),
discoveryMethod: 'manual',
isOfficial: manualTool.npmPackageName.startsWith('@tpmjs/'),
npmDownloadsLastMonth: 0,
},
update: {
npmVersion: version,
npmPublishedAt: new Date(publishedAt),
npmDescription: npmData.description || null,
npmRepository: npmData.repository || null,
npmHomepage: npmData.homepage || manualTool.websiteUrl || null,
npmLicense: npmData.license || null,
npmKeywords: npmData.keywords || [],
npmReadme: npmData.readme || null,
npmAuthor: npmData.author || null,
npmMaintainers: npmData.maintainers || null,
category: manualTool.category,
env: manualTool.env ? (manualTool.env as any) : null,
frameworks: manualTool.frameworks,
tier: calculateTier(manualTool),
isOfficial: manualTool.npmPackageName.startsWith('@tpmjs/'),
},
});
console.log(` ✅ Package upserted: ${packageRecord.id}`);
// Get existing tools for this package
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Upsert the tool
const tool = await prisma.tool.upsert({
where: {
packageId_exportName: {
packageId: packageRecord.id,
exportName: manualTool.exportName,
},
},
create: {
packageId: packageRecord.id,
exportName: manualTool.exportName,
description: manualTool.description,
parameters: manualTool.parameters ? (manualTool.parameters as any) : null,
returns: manualTool.returns ? (manualTool.returns as any) : null,
aiAgent: manualTool.aiAgent ? (manualTool.aiAgent as any) : null,
},
update: {
description: manualTool.description,
parameters: manualTool.parameters ? (manualTool.parameters as any) : null,
returns: manualTool.returns ? (manualTool.returns as any) : null,
aiAgent: manualTool.aiAgent ? (manualTool.aiAgent as any) : null,
},
});
console.log(` ✅ Tool upserted: ${tool.exportName} (${tool.id})`);
processed++;
} catch (error) {
console.error(
` ❌ Error processing ${manualTool.npmPackageName} (${manualTool.exportName}):`,
error
);
errors++;
}
}
console.log('\n📊 Manual sync complete!');
console.log(` Processed: ${processed}`);
console.log(` Skipped: ${skipped}`);
console.log(` Errors: ${errors}`);
console.log(` Total manual tools: ${manualTools.length}\n`);
}
function calculateTier(tool: typeof manualTools[0]): 'minimal' | 'rich' {
// Tier is 'rich' if tool has parameters OR returns OR aiAgent
if (tool.parameters || tool.returns || tool.aiAgent) {
return 'rich';
}
return 'minimal';
}
syncManualTools()
.then(() => process.exit(0))
.catch((error) => {
console.error('❌ Manual sync failed:', error);
process.exit(1);
})
.finally(() => {
prisma.$disconnect();
});

88
test-schema.ts Normal file
View file

@ -0,0 +1,88 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function testSchema() {
console.log('Testing new Package + Tool schema...\n');
// Test 1: Create a package
console.log('1. Creating test package...');
const pkg = await prisma.package.create({
data: {
npmPackageName: '@test/hello',
npmVersion: '1.0.0',
npmPublishedAt: new Date(),
category: 'text-analysis',
tier: 'rich',
discoveryMethod: 'test',
isOfficial: false,
frameworks: ['vercel-ai'],
npmDownloadsLastMonth: 100,
},
});
console.log(`✅ Package created: ${pkg.npmPackageName} (${pkg.id})\n`);
// Test 2: Create multiple tools for the package
console.log('2. Creating tools for package...');
const tool1 = await prisma.tool.create({
data: {
packageId: pkg.id,
exportName: 'helloWorldTool',
description: 'Returns a simple Hello World greeting',
},
});
console.log(`✅ Tool 1 created: ${tool1.exportName}`);
const tool2 = await prisma.tool.create({
data: {
packageId: pkg.id,
exportName: 'helloNameTool',
description: 'Returns a personalized greeting with name',
},
});
console.log(`✅ Tool 2 created: ${tool2.exportName}\n`);
// Test 3: Query package with tools
console.log('3. Querying package with tools...');
const packageWithTools = await prisma.package.findUnique({
where: { npmPackageName: '@test/hello' },
include: { tools: true },
});
console.log(`✅ Found package with ${packageWithTools?.tools.length} tools:`);
packageWithTools?.tools.forEach((t) => {
console.log(` - ${t.exportName}: ${t.description}`);
});
console.log();
// Test 4: Query tool with package
console.log('4. Querying tool with package...');
const toolWithPackage = await prisma.tool.findFirst({
where: {
package: { npmPackageName: '@test/hello' },
exportName: 'helloWorldTool',
},
include: { package: true },
});
console.log(`✅ Found tool: ${toolWithPackage?.exportName}`);
console.log(` Package: ${toolWithPackage?.package.npmPackageName}`);
console.log(` Category: ${toolWithPackage?.package.category}\n`);
// Test 5: Delete package (should cascade to tools)
console.log('5. Testing cascade delete...');
await prisma.package.delete({
where: { id: pkg.id },
});
const remainingTools = await prisma.tool.count({
where: { packageId: pkg.id },
});
console.log(`✅ Package deleted, remaining tools: ${remainingTools}`);
console.log(` (Should be 0 due to cascade delete)\n`);
console.log('✅ All schema tests passed!');
}
testSchema()
.then(() => process.exit(0))
.catch((error) => {
console.error('❌ Test failed:', error);
process.exit(1);
});