feat: add fork-based ownership and fix MCP non-scoped package names

Fork-based ownership:
- Add forkedFromId, forkCount fields to Collection and Agent models
- Add fork-status API endpoints for collections and agents
- Add ForkButton and ForkedFromBadge UI components
- Update clone endpoints to set forkedFromId and increment forkCount
- Add COLLECTION_FORKED and AGENT_FORKED activity types
- Enforce owner-only access for MCP tool execution and agent chat

MCP fix:
- Fix parseToolName to handle non-scoped packages like firecrawl-aisdk
- Try both scoped (@scope/name) and literal (name-with-hyphens) interpretations
- Pass collection envVars to tool executor for API key support
This commit is contained in:
Ajax Davis 2026-01-13 10:23:28 +10:00
parent e2bb06df60
commit 95cb0a96f0
22 changed files with 925 additions and 79 deletions

View file

@ -7,8 +7,10 @@ import Link from 'next/link';
import { notFound, useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { CloneButton } from '~/components/CloneButton';
import { ForkButton } from '~/components/ForkButton';
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
import { LikeButton } from '~/components/LikeButton';
import { useSession } from '~/lib/auth-client';
interface AgentTool {
id: string;
@ -46,6 +48,7 @@ interface PublicAgent {
systemPrompt: string | null;
temperature: number;
likeCount: number;
forkCount: number;
toolCount: number;
collectionCount: number;
createdAt: string;
@ -57,6 +60,15 @@ interface PublicAgent {
};
tools: AgentTool[];
collections: AgentCollection[];
forkedFromId: string | null;
forkedFrom: {
id: string;
name: string;
uid: string;
user: {
username: string;
};
} | null;
}
export default function PrettyAgentDetailPage(): React.ReactElement {
@ -64,11 +76,15 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
const rawUsername = params.username as string;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
const uid = params.uid as string;
const { data: session } = useSession();
const [agent, setAgent] = useState<PublicAgent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Check if current user is the owner
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;
const fetchAgent = useCallback(async () => {
try {
const response = await fetch(`/api/public/users/${username}/agents/${uid}`);
@ -123,22 +139,29 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
{agent.description && (
<p className="text-foreground-secondary">{agent.description}</p>
)}
<Link
href={`/${username}`}
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
>
by @{agent.createdBy.username}
</Link>
<div className="flex items-center gap-3 mt-2">
<Link
href={`/${username}`}
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
>
by @{agent.createdBy.username}
</Link>
{agent.forkedFrom && (
<ForkedFromBadge type="agent" forkedFrom={agent.forkedFrom} />
)}
</div>
</div>
<div className="flex items-center gap-2">
<LikeButton entityType="agent" entityId={agent.id} initialCount={agent.likeCount} />
<CloneButton type="agent" sourceId={agent.id} sourceName={agent.name} />
<Link href={`/${username}/agents/${uid}/chat`}>
<Button>
<Icon icon="message" className="w-4 h-4 mr-2" />
Chat
</Button>
</Link>
<ForkButton type="agent" sourceId={agent.id} sourceName={agent.name} />
{isOwner && (
<Link href={`/${username}/agents/${uid}/chat`}>
<Button>
<Icon icon="message" className="w-4 h-4 mr-2" />
Chat
</Button>
</Link>
)}
</div>
</div>
@ -152,10 +175,21 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
<Icon icon="folder" className="w-4 h-4" />
{agent.collectionCount} collections
</span>
{agent.forkCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="gitFork" className="w-4 h-4" />
{agent.forkCount} forks
</span>
)}
<span>Model: {agent.modelId}</span>
<span>Temperature: {agent.temperature}</span>
</div>
{/* Fork CTA for non-owners */}
{!isOwner && (
<ForkButton type="agent" sourceId={agent.id} sourceName={agent.name} variant="full" />
)}
{/* System Prompt */}
{agent.systemPrompt && (
<section>

View file

@ -8,8 +8,10 @@ import Link from 'next/link';
import { notFound, useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { CloneButton } from '~/components/CloneButton';
import { ForkButton } from '~/components/ForkButton';
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
import { LikeButton } from '~/components/LikeButton';
import { useSession } from '~/lib/auth-client';
interface CollectionTool {
id: string;
@ -35,6 +37,7 @@ interface PublicCollection {
description: string | null;
likeCount: number;
toolCount: number;
forkCount: number;
createdAt: string;
createdBy: {
id: string;
@ -43,6 +46,15 @@ interface PublicCollection {
image: string | null;
};
tools: CollectionTool[];
forkedFromId: string | null;
forkedFrom: {
id: string;
name: string;
slug: string;
user: {
username: string;
};
} | null;
}
function McpUrlSection({ username, slug }: { username: string; slug: string }) {
@ -163,11 +175,15 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
const rawUsername = params.username as string;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
const slug = params.slug as string;
const { data: session } = useSession();
const [collection, setCollection] = useState<PublicCollection | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Check if current user is the owner
const isOwner = session?.user?.id && collection?.createdBy?.id === session.user.id;
const fetchCollection = useCallback(async () => {
try {
const response = await fetch(`/api/public/users/${username}/collections/${slug}`);
@ -219,12 +235,17 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
{collection.description && (
<p className="text-foreground-secondary mt-2">{collection.description}</p>
)}
<Link
href={`/${username}`}
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
>
by @{collection.createdBy.username}
</Link>
<div className="flex items-center gap-3 mt-2">
<Link
href={`/${username}`}
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
>
by @{collection.createdBy.username}
</Link>
{collection.forkedFrom && (
<ForkedFromBadge type="collection" forkedFrom={collection.forkedFrom} />
)}
</div>
</div>
<div className="flex items-center gap-2">
<LikeButton
@ -232,7 +253,7 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
entityId={collection.id}
initialCount={collection.likeCount}
/>
<CloneButton
<ForkButton
type="collection"
sourceId={collection.id}
sourceName={collection.name}
@ -250,10 +271,25 @@ export default function PrettyCollectionDetailPage(): React.ReactElement {
<Icon icon="heart" className="w-4 h-4" />
{collection.likeCount} likes
</span>
{collection.forkCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="gitFork" className="w-4 h-4" />
{collection.forkCount} forks
</span>
)}
</div>
{/* MCP Server URLs */}
<McpUrlSection username={username} slug={collection.slug} />
{/* MCP Server URLs - Only shown for owner */}
{isOwner ? (
<McpUrlSection username={username} slug={collection.slug} />
) : (
<ForkButton
type="collection"
sourceId={collection.id}
sourceName={collection.name}
variant="full"
/>
)}
{/* Tools */}
{collection.tools.length > 0 ? (

View file

@ -151,9 +151,10 @@ export async function POST(request: NextRequest, context: RouteContext) {
}
}
// Create the cloned agent with all its relationships
const clonedAgent = await prisma.$transaction(async (tx) => {
// Create the agent
// Create the forked agent with all its relationships
// NOTE: envVars and executorConfig are NOT copied - user must add their own
const forkedAgent = await prisma.$transaction(async (tx) => {
// Create the agent with fork reference
const newAgent = await tx.agent.create({
data: {
userId: session.user.id,
@ -166,11 +167,20 @@ export async function POST(request: NextRequest, context: RouteContext) {
temperature: sourceAgent.temperature,
maxToolCallsPerTurn: sourceAgent.maxToolCallsPerTurn,
maxMessagesInContext: sourceAgent.maxMessagesInContext,
isPublic: false, // Cloned agents start as private
isPublic: false, // Forked agents start as private
likeCount: 1, // Start with 1 like (from owner)
forkedFromId: sourceAgent.id, // Track fork origin
// NOTE: envVars is intentionally NOT copied - user adds their own API keys
// NOTE: executorConfig is intentionally NOT copied - user configures their own
},
});
// Increment fork count on source agent
await tx.agent.update({
where: { id: sourceAgent.id },
data: { forkCount: { increment: 1 } },
});
// Auto-like the agent
await tx.agentLike.create({
data: {
@ -215,24 +225,28 @@ export async function POST(request: NextRequest, context: RouteContext) {
return newAgent;
});
// Log activity
// Log activity as FORK
logActivity({
userId: session.user.id,
type: 'AGENT_CLONED',
targetName: clonedAgent.name,
type: 'AGENT_FORKED',
targetName: forkedAgent.name,
targetType: 'agent',
agentId: clonedAgent.id,
metadata: { sourceAgentId: sourceAgent.id },
agentId: forkedAgent.id,
metadata: {
sourceAgentId: sourceAgent.id,
sourceAgentName: sourceAgent.name,
},
});
return apiSuccess(
{
id: clonedAgent.id,
uid: clonedAgent.uid,
name: clonedAgent.name,
description: clonedAgent.description,
isPublic: clonedAgent.isPublic,
createdAt: clonedAgent.createdAt,
id: forkedAgent.id,
uid: forkedAgent.uid,
name: forkedAgent.name,
description: forkedAgent.description,
isPublic: forkedAgent.isPublic,
forkedFromId: forkedAgent.forkedFromId,
createdAt: forkedAgent.createdAt,
},
{ requestId, status: 201 }
);

View file

@ -122,6 +122,19 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
}
// Owner-only enforcement: Only the agent owner can chat with the agent
if (authResult.userId !== agent.userId) {
return NextResponse.json(
{
success: false,
error:
'Fork this agent to use it. Only the agent owner can chat with agents. ' +
'Visit the agent page to fork it to your account.',
},
{ status: 403 }
);
}
// Map provider to expected key name format
const providerKeyNames: Record<string, string> = {
OPENAI: 'OPENAI_API_KEY',

View file

@ -0,0 +1,88 @@
import { prisma } from '@tpmjs/db';
import { AGENT_LIMITS } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string }>;
};
/**
* GET /api/agents/[id]/fork-status
* Check if the current user has forked this agent
*/
export async function GET(_request: NextRequest, context: RouteContext) {
const requestId = crypto.randomUUID();
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return apiUnauthorized('Authentication required', requestId);
}
const { id } = await context.params;
// Get the source agent
const agent = await prisma.agent.findUnique({
where: { id },
select: { id: true, userId: true, isPublic: true },
});
if (!agent) {
return apiNotFound('Agent', requestId);
}
const isOwner = agent.userId === session.user.id;
// If owner, they can't fork their own agent
if (isOwner) {
return apiSuccess(
{
hasFork: false,
fork: null,
isOwner: true,
canFork: false,
},
{ requestId }
);
}
// Check if user already has a fork of this agent
const existingFork = await prisma.agent.findFirst({
where: {
userId: session.user.id,
forkedFromId: id,
},
select: { id: true, uid: true, name: true },
});
// Check if user can fork (within limits)
let canFork = false;
if (!existingFork && agent.isPublic) {
const existingCount = await prisma.agent.count({
where: { userId: session.user.id },
});
canFork = existingCount < AGENT_LIMITS.MAX_AGENTS_PER_USER;
}
return apiSuccess(
{
hasFork: !!existingFork,
fork: existingFork
? {
id: existingFork.id,
uid: existingFork.uid,
name: existingFork.name,
}
: null,
isOwner: false,
canFork,
},
{ requestId }
);
}

View file

@ -1,5 +1,5 @@
import { prisma } from '@tpmjs/db';
import { COLLECTION_LIMITS, CloneCollectionSchema } from '@tpmjs/types/collection';
import { CloneCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
@ -135,20 +135,30 @@ export async function POST(request: NextRequest, context: RouteContext) {
const name = customName || `${sourceCollection.name} (copy)`;
const slug = await generateUniqueSlug(session.user.id, name);
// Create the cloned collection with all its tools
const clonedCollection = await prisma.$transaction(async (tx) => {
// Create the collection
// Create the forked collection with all its tools
// NOTE: envVars and executorConfig are NOT copied - user must add their own
const forkedCollection = await prisma.$transaction(async (tx) => {
// Create the collection with fork reference
const newCollection = await tx.collection.create({
data: {
userId: session.user.id,
name,
slug,
description: sourceCollection.description,
isPublic: false, // Cloned collections start as private
isPublic: false, // Forked collections start as private
likeCount: 1, // Start with 1 like (from owner)
forkedFromId: sourceCollection.id, // Track fork origin
// NOTE: envVars is intentionally NOT copied - user adds their own API keys
// NOTE: executorConfig is intentionally NOT copied - user configures their own
},
});
// Increment fork count on source collection
await tx.collection.update({
where: { id: sourceCollection.id },
data: { forkCount: { increment: 1 } },
});
// Auto-like the collection
await tx.collectionLike.create({
data: {
@ -172,25 +182,29 @@ export async function POST(request: NextRequest, context: RouteContext) {
return newCollection;
});
// Log activity
// Log activity as FORK
logActivity({
userId: session.user.id,
type: 'COLLECTION_CLONED',
targetName: clonedCollection.name,
type: 'COLLECTION_FORKED',
targetName: forkedCollection.name,
targetType: 'collection',
collectionId: clonedCollection.id,
metadata: { sourceCollectionId: sourceCollection.id },
collectionId: forkedCollection.id,
metadata: {
sourceCollectionId: sourceCollection.id,
sourceCollectionName: sourceCollection.name,
},
});
return apiSuccess(
{
id: clonedCollection.id,
name: clonedCollection.name,
slug: clonedCollection.slug,
description: clonedCollection.description,
isPublic: clonedCollection.isPublic,
id: forkedCollection.id,
name: forkedCollection.name,
slug: forkedCollection.slug,
description: forkedCollection.description,
isPublic: forkedCollection.isPublic,
forkedFromId: forkedCollection.forkedFromId,
toolCount: sourceCollection.tools.length,
createdAt: clonedCollection.createdAt,
createdAt: forkedCollection.createdAt,
},
{ requestId, status: 201 }
);

View file

@ -0,0 +1,88 @@
import { prisma } from '@tpmjs/db';
import { COLLECTION_LIMITS } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ id: string }>;
};
/**
* GET /api/collections/[id]/fork-status
* Check if the current user has forked this collection
*/
export async function GET(_request: NextRequest, context: RouteContext) {
const requestId = crypto.randomUUID();
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return apiUnauthorized('Authentication required', requestId);
}
const { id } = await context.params;
// Get the source collection
const collection = await prisma.collection.findUnique({
where: { id },
select: { id: true, userId: true, isPublic: true },
});
if (!collection) {
return apiNotFound('Collection', requestId);
}
const isOwner = collection.userId === session.user.id;
// If owner, they can't fork their own collection
if (isOwner) {
return apiSuccess(
{
hasFork: false,
fork: null,
isOwner: true,
canFork: false,
},
{ requestId }
);
}
// Check if user already has a fork of this collection
const existingFork = await prisma.collection.findFirst({
where: {
userId: session.user.id,
forkedFromId: id,
},
select: { id: true, slug: true, name: true },
});
// Check if user can fork (within limits)
let canFork = false;
if (!existingFork && collection.isPublic) {
const existingCount = await prisma.collection.count({
where: { userId: session.user.id },
});
canFork = existingCount < COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER;
}
return apiSuccess(
{
hasFork: !!existingFork,
fork: existingFork
? {
id: existingFork.id,
slug: existingFork.slug,
name: existingFork.name,
}
: null,
isOwner: false,
canFork,
},
{ requestId }
);
}

View file

@ -52,7 +52,7 @@ async function getPublicCollectionByUsernameAndSlug(username: string, slug: stri
isPublic: true,
user: { username },
},
select: { id: true, name: true, description: true },
select: { id: true, name: true, description: true, userId: true },
}),
DB_TIMEOUT_MS,
`Database query timed out after ${DB_TIMEOUT_MS}ms`
@ -289,6 +289,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
);
}
// Owner-only enforcement: Only the collection owner can execute tools via MCP
if (authResult.userId !== collection.userId) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: {
code: -32403,
message:
'Fork this collection to use it. Only the collection owner can execute tools via MCP. ' +
'Visit the collection page to fork it to your account.',
},
id: null,
},
{ status: 403 }
);
}
let response: Response;
if (transport === 'sse') {
response = await handleSseTransport(request, collection.id, collection.name);

View file

@ -71,6 +71,16 @@ export async function GET(_request: NextRequest, context: RouteContext) {
orderBy: { position: 'asc' },
take: 20,
},
forkedFrom: {
select: {
id: true,
name: true,
uid: true,
user: {
select: { username: true },
},
},
},
_count: {
select: { tools: true, collections: true },
},
@ -97,6 +107,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
systemPrompt: agent.systemPrompt,
temperature: agent.temperature,
likeCount: agent.likeCount,
forkCount: agent.forkCount,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
createdAt: agent.createdAt.toISOString(),
@ -122,6 +133,8 @@ export async function GET(_request: NextRequest, context: RouteContext) {
toolCount: ac.collection._count.tools,
},
})),
forkedFromId: agent.forkedFromId,
forkedFrom: agent.forkedFrom,
},
{ requestId }
);

View file

@ -58,6 +58,16 @@ export async function GET(_request: NextRequest, context: RouteContext) {
orderBy: { position: 'asc' },
take: 100,
},
forkedFrom: {
select: {
id: true,
name: true,
slug: true,
user: {
select: { username: true },
},
},
},
_count: {
select: { tools: true },
},
@ -80,6 +90,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
forkCount: collection.forkCount,
toolCount: collection._count.tools,
createdAt: collection.createdAt.toISOString(),
createdBy: {
@ -95,6 +106,8 @@ export async function GET(_request: NextRequest, context: RouteContext) {
note: ct.note,
tool: ct.tool,
})),
forkedFromId: collection.forkedFromId,
forkedFrom: collection.forkedFrom,
},
{ requestId }
);

View file

@ -179,7 +179,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
{/* Generator Highlight Box */}
<div className="mb-12 p-6 border-2 border-primary/50 rounded-lg bg-primary/5 text-left">
<div className="flex flex-col sm:flex-row items-start gap-4">
<div className="text-3xl sm:text-4xl"></div>
<div className="text-3xl sm:text-4xl" aria-hidden="true">
</div>
<div className="flex-1">
<h3 className="text-xl font-bold mb-2 text-foreground">
Start with Our Package Generator
@ -208,21 +210,27 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 mb-8">
<div className="p-4">
<div className="text-3xl mb-2">🚀</div>
<div className="text-3xl mb-2" aria-hidden="true">
🚀
</div>
<h3 className="font-semibold mb-1 text-foreground">Quick Setup</h3>
<p className="text-sm text-foreground-secondary">
Add one keyword to package.json and publish to NPM
</p>
</div>
<div className="p-4">
<div className="text-3xl mb-2"></div>
<div className="text-3xl mb-2" aria-hidden="true">
</div>
<h3 className="font-semibold mb-1 text-foreground">Auto Discovery</h3>
<p className="text-sm text-foreground-secondary">
Your tool appears on tpmjs.com within 15 minutes
</p>
</div>
<div className="p-4">
<div className="text-3xl mb-2">📊</div>
<div className="text-3xl mb-2" aria-hidden="true">
📊
</div>
<h3 className="font-semibold mb-1 text-foreground">Quality Metrics</h3>
<p className="text-sm text-foreground-secondary">
Automatic scoring based on docs, downloads, and stars

View file

@ -23,27 +23,80 @@ interface NavDropdownProps {
function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setFocusedIndex(-1);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus menu item when focusedIndex changes
useEffect(() => {
if (isOpen && focusedIndex >= 0 && menuRef.current) {
const menuItems = menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]');
menuItems[focusedIndex]?.focus();
}
}, [focusedIndex, isOpen]);
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
buttonRef.current?.focus();
event.preventDefault();
break;
case 'ArrowDown':
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
setFocusedIndex(0);
} else {
setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
}
break;
case 'ArrowUp':
event.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
}
break;
case 'Tab':
if (isOpen) {
setIsOpen(false);
setFocusedIndex(-1);
}
break;
}
};
const handleItemClick = () => {
setIsOpen(false);
setFocusedIndex(-1);
};
return (
<div className="relative" ref={dropdownRef}>
<div className="relative" ref={dropdownRef} onKeyDown={handleKeyDown} role="menu">
<Button
ref={buttonRef}
variant="ghost"
size="sm"
className="text-foreground hover:text-foreground flex items-center gap-1"
onClick={() => setIsOpen(!isOpen)}
onClick={() => {
setIsOpen(!isOpen);
if (!isOpen) setFocusedIndex(-1);
}}
aria-expanded={isOpen}
aria-haspopup="true"
aria-haspopup="menu"
>
{label}
<Icon
@ -53,16 +106,23 @@ function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
/>
</Button>
{isOpen && (
<div className="absolute top-full left-0 mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
{items.map((item) =>
<div
ref={menuRef}
role="menu"
aria-label={label}
className="absolute top-full left-0 mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50"
>
{items.map((item, index) =>
item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between px-4 py-2 text-sm text-foreground hover:bg-surface transition-colors"
onClick={() => setIsOpen(false)}
role="menuitem"
tabIndex={focusedIndex === index ? 0 : -1}
className="flex items-center justify-between px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors"
onClick={handleItemClick}
>
<div>
<div className="font-medium">{item.label}</div>
@ -76,8 +136,10 @@ function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement {
<Link
key={item.href}
href={item.href}
className="block px-4 py-2 text-sm text-foreground hover:bg-surface transition-colors"
onClick={() => setIsOpen(false)}
role="menuitem"
tabIndex={focusedIndex === index ? 0 : -1}
className="block px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors"
onClick={handleItemClick}
>
<div className="font-medium">{item.label}</div>
{item.description && (

View file

@ -0,0 +1,312 @@
'use client';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useSession } from '~/lib/auth-client';
interface ForkStatus {
hasFork: boolean;
fork: { id: string; slug?: string; uid?: string; name: string } | null;
isOwner: boolean;
canFork: boolean;
}
interface ForkButtonProps {
type: 'agent' | 'collection';
sourceId: string;
sourceName: string;
className?: string;
/** Show full-width variant with description text */
variant?: 'compact' | 'full';
}
export function ForkButton({
type,
sourceId,
sourceName,
className,
variant = 'compact',
}: ForkButtonProps): React.ReactElement {
void sourceName;
const { data: session } = useSession();
const router = useRouter();
const [isForking, setIsForking] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [forkStatus, setForkStatus] = useState<ForkStatus | null>(null);
// Fetch fork status on mount
useEffect(() => {
if (!session?.user) {
setIsLoading(false);
return;
}
async function checkForkStatus() {
try {
const endpoint =
type === 'agent'
? `/api/agents/${sourceId}/fork-status`
: `/api/collections/${sourceId}/fork-status`;
const response = await fetch(endpoint);
const data = await response.json();
if (data.success) {
setForkStatus(data.data);
}
} catch {
// Silently fail - will show fork button as fallback
} finally {
setIsLoading(false);
}
}
checkForkStatus();
}, [session?.user, sourceId, type]);
async function handleFork() {
if (!session?.user) {
router.push(`/sign-in?redirect=${encodeURIComponent(window.location.pathname)}`);
return;
}
setIsForking(true);
setError(null);
try {
const endpoint =
type === 'agent' ? `/api/agents/${sourceId}/clone` : `/api/collections/${sourceId}/clone`;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
if (data.success) {
// Redirect to the forked item in dashboard
if (type === 'agent') {
router.push(`/dashboard/agents/${data.data.id}`);
} else {
router.push(`/dashboard/collections/${data.data.id}`);
}
} else {
setError(data.error?.message || 'Failed to fork');
}
} catch {
setError('Failed to fork');
} finally {
setIsForking(false);
}
}
// Not logged in - show sign in prompt
if (!session?.user) {
if (variant === 'full') {
return (
<div className={`p-4 bg-surface border border-border rounded-lg ${className || ''}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon icon="gitFork" className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground">Fork to Use</h3>
<p className="text-sm text-foreground-secondary">
Sign in to fork this {type} to your account
</p>
</div>
</div>
<Link
href={`/sign-in?redirect=${encodeURIComponent(typeof window !== 'undefined' ? window.location.pathname : '')}`}
>
<Button className="w-full mt-2">
<Icon icon="user" className="w-4 h-4 mr-2" />
Sign In to Fork
</Button>
</Link>
</div>
);
}
return (
<div className={className}>
<Link href="/sign-in">
<Button variant="outline" title={`Sign in to fork this ${type}`}>
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
Fork
</Button>
</Link>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className={className}>
<Button variant="outline" disabled>
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
{variant === 'full' ? 'Loading...' : ''}
</Button>
</div>
);
}
// Owner - show "Your" badge
if (forkStatus?.isOwner) {
if (variant === 'full') {
return (
<div
className={`p-4 bg-green-500/10 border border-green-500/20 rounded-lg ${className || ''}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/20 rounded-lg">
<Icon icon="check" className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="font-medium text-foreground">
Your {type === 'agent' ? 'Agent' : 'Collection'}
</h3>
<p className="text-sm text-foreground-secondary">You own this {type}</p>
</div>
</div>
</div>
);
}
return (
<div className={className}>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-700 rounded-lg text-sm font-medium">
<Icon icon="check" className="w-4 h-4" />
Your {type === 'agent' ? 'Agent' : 'Collection'}
</span>
</div>
);
}
// Already forked - show link to fork
if (forkStatus?.hasFork && forkStatus.fork) {
const forkUrl =
type === 'agent'
? `/dashboard/agents/${forkStatus.fork.id}`
: `/dashboard/collections/${forkStatus.fork.id}`;
if (variant === 'full') {
return (
<div
className={`p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg ${className || ''}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-500/20 rounded-lg">
<Icon icon="gitFork" className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-medium text-foreground">Already Forked</h3>
<p className="text-sm text-foreground-secondary">
You have a fork: &ldquo;{forkStatus.fork.name}&rdquo;
</p>
</div>
</div>
<Link href={forkUrl}>
<Button variant="secondary" className="w-full mt-2">
<Icon icon="externalLink" className="w-4 h-4 mr-2" />
View Your Fork
</Button>
</Link>
</div>
);
}
return (
<div className={className}>
<Link href={forkUrl}>
<Button variant="outline" title="View your forked version">
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
View Fork
</Button>
</Link>
</div>
);
}
// Can't fork (over limit)
if (!forkStatus?.canFork) {
if (variant === 'full') {
return (
<div
className={`p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg ${className || ''}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-500/20 rounded-lg">
<Icon icon="alertTriangle" className="w-5 h-5 text-amber-600" />
</div>
<div>
<h3 className="font-medium text-foreground">Limit Reached</h3>
<p className="text-sm text-foreground-secondary">
You&apos;ve reached the maximum number of{' '}
{type === 'agent' ? 'agents' : 'collections'}
</p>
</div>
</div>
</div>
);
}
return (
<div className={className}>
<Button variant="outline" disabled title={`${type} limit reached`}>
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
Limit Reached
</Button>
</div>
);
}
// Can fork - show fork button
if (variant === 'full') {
return (
<div className={`p-4 bg-surface border border-border rounded-lg ${className || ''}`}>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon icon="gitFork" className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground">Fork to Use</h3>
<p className="text-sm text-foreground-secondary">
Fork this {type} to your account to use it with your own API keys
</p>
</div>
</div>
<Button onClick={handleFork} disabled={isForking} className="w-full mt-2">
{isForking ? (
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
) : (
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
)}
Fork {type === 'agent' ? 'Agent' : 'Collection'}
</Button>
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
</div>
);
}
return (
<div className={className}>
<Button
variant="outline"
onClick={handleFork}
disabled={isForking}
title={`Fork this ${type} to your account`}
>
{isForking ? (
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
) : (
<Icon icon="gitFork" className="w-4 h-4 mr-2" />
)}
Fork
</Button>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
interface ForkedFromInfo {
id: string;
name: string;
slug?: string;
uid?: string;
user?: {
username: string;
};
}
interface ForkedFromBadgeProps {
type: 'agent' | 'collection';
forkedFrom: ForkedFromInfo | null;
className?: string;
}
export function ForkedFromBadge({
type,
forkedFrom,
className,
}: ForkedFromBadgeProps): React.ReactElement | null {
if (!forkedFrom) {
return null;
}
// Build the link to the original
let href: string;
if (forkedFrom.user?.username) {
if (type === 'agent' && forkedFrom.uid) {
href = `/${forkedFrom.user.username}/agents/${forkedFrom.uid}`;
} else if (type === 'collection' && forkedFrom.slug) {
href = `/${forkedFrom.user.username}/collections/${forkedFrom.slug}`;
} else {
// Fallback to ID-based URL
href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`;
}
} else {
// No username available, use ID-based URL
href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`;
}
return (
<Link
href={href}
className={`inline-flex items-center gap-1.5 text-sm text-foreground-secondary hover:text-foreground transition-colors ${className || ''}`}
>
<Icon icon="gitFork" className="w-3.5 h-3.5" />
<span>
Forked from{' '}
<span className="font-medium text-foreground-secondary hover:text-foreground">
{forkedFrom.name}
</span>
</span>
</Link>
);
}

View file

@ -90,7 +90,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
<div className="max-w-3xl">
<div className="relative">
{/* Command Line Prompt */}
<div className="absolute left-0 top-0 bottom-0 flex items-center pl-6 font-mono text-brutalist-accent text-lg font-bold pointer-events-none">
<div
aria-hidden="true"
className="absolute left-0 top-0 bottom-0 flex items-center pl-6 font-mono text-brutalist-accent text-lg font-bold pointer-events-none"
>
$
</div>
@ -100,6 +103,7 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="search tools..."
aria-label="Search tools"
className="brutalist-border h-16 md:h-20 pl-14 pr-36 md:pr-40 text-lg md:text-xl font-mono placeholder:text-foreground-tertiary placeholder:uppercase focus:ring-4 focus:ring-brutalist-accent focus:ring-offset-0 bg-background"
style={{ borderRadius: 0 }}
/>
@ -126,7 +130,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse">
<div
aria-hidden="true"
className="absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 animate-pulse motion-reduce:animate-none"
>
<span className="font-mono text-xs uppercase tracking-widest text-foreground-tertiary">
Scroll
</span>

View file

@ -47,6 +47,10 @@ export const ACTIVITY_MESSAGES: Record<
AGENT_UPDATED: (name) => `Updated agent "${name}"`,
AGENT_DELETED: (name) => `Deleted agent "${name}"`,
AGENT_CLONED: (name) => `Cloned agent "${name}"`,
AGENT_FORKED: (name, meta) =>
meta?.sourceAgentName
? `Forked agent "${meta.sourceAgentName}" as "${name}"`
: `Forked agent as "${name}"`,
AGENT_TOOL_ADDED: (name, meta) =>
meta?.toolName
? `Added tool "${meta.toolName}" to agent "${name}"`
@ -67,6 +71,10 @@ export const ACTIVITY_MESSAGES: Record<
COLLECTION_UPDATED: (name) => `Updated collection "${name}"`,
COLLECTION_DELETED: (name) => `Deleted collection "${name}"`,
COLLECTION_CLONED: (name) => `Cloned collection "${name}"`,
COLLECTION_FORKED: (name, meta) =>
meta?.sourceCollectionName
? `Forked collection "${meta.sourceCollectionName}" as "${name}"`
: `Forked collection as "${name}"`,
COLLECTION_TOOL_ADDED: (name, meta) =>
meta?.toolName
? `Added tool "${meta.toolName}" to collection "${name}"`
@ -91,6 +99,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
AGENT_UPDATED: 'pencil',
AGENT_DELETED: 'trash',
AGENT_CLONED: 'copy',
AGENT_FORKED: 'gitFork',
AGENT_TOOL_ADDED: 'link',
AGENT_TOOL_REMOVED: 'unlink',
AGENT_COLLECTION_ADDED: 'folderPlus',
@ -99,6 +108,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
COLLECTION_UPDATED: 'pencil',
COLLECTION_DELETED: 'trash',
COLLECTION_CLONED: 'copy',
COLLECTION_FORKED: 'gitFork',
COLLECTION_TOOL_ADDED: 'link',
COLLECTION_TOOL_REMOVED: 'unlink',
TOOL_LIKED: 'heart',

View file

@ -159,6 +159,7 @@ export async function handleToolsCall(
select: {
executorType: true,
executorConfig: true,
envVars: true,
tools: {
include: { tool: { include: { package: true } } },
},
@ -168,11 +169,24 @@ export async function handleToolsCall(
'Database query timed out'
);
const collectionTool = collection?.tools.find(
// Try both scoped (@scope/name) and literal (name-with-hyphens) package name interpretations
// This handles cases like:
// - @tpmjs/hello (scoped) → sanitized as tpmjs-hello → parsed back as @tpmjs/hello
// - firecrawl-aisdk (not scoped) → sanitized as firecrawl-aisdk → should match as-is
let collectionTool = collection?.tools.find(
(ct) =>
ct.tool.package.npmPackageName === parsed.packageName && ct.tool.name === parsed.toolName
);
// If not found with scoped name, try the literal package name
if (!collectionTool && parsed.literalPackageName !== parsed.packageName) {
collectionTool = collection?.tools.find(
(ct) =>
ct.tool.package.npmPackageName === parsed.literalPackageName &&
ct.tool.name === parsed.toolName
);
}
if (!collectionTool) {
return {
jsonrpc: '2.0',
@ -181,17 +195,21 @@ export async function handleToolsCall(
};
}
// Get the actual package name that matched
const actualPackageName = collectionTool.tool.package.npmPackageName;
// Resolve executor configuration (collection config only for MCP - no agent context)
const executorConfig = parseExecutorConfig(
collection?.executorType,
collection?.executorConfig
);
// Execute via resolved executor
// Execute via resolved executor with collection's environment variables
const result = await executeWithExecutor(executorConfig, {
packageName: parsed.packageName,
packageName: actualPackageName,
name: parsed.toolName,
params: params.arguments ?? {},
env: (collection?.envVars as Record<string, string>) ?? undefined,
});
if (!result.success) {

View file

@ -57,7 +57,7 @@ export function convertToMcpTool(tool: Tool & { package: Package }): McpToolDefi
* Parsed tool name result - either a registry tool or a bridge tool
*/
export type ParsedToolName =
| { type: 'registry'; packageName: string; toolName: string }
| { type: 'registry'; packageName: string; literalPackageName: string; toolName: string }
| { type: 'bridge'; serverId: string; toolName: string };
/**
@ -70,7 +70,7 @@ export type ParsedToolName =
export function parseToolName(mcpName: string): ParsedToolName | null {
// Check if it's a bridge tool
const bridgeMatch = mcpName.match(/^bridge--([^-]+(?:-[^-]+)*)--(.+)$/);
if (bridgeMatch && bridgeMatch[1] && bridgeMatch[2]) {
if (bridgeMatch?.[1] && bridgeMatch[2]) {
return {
type: 'bridge',
serverId: bridgeMatch[1],
@ -85,11 +85,19 @@ export function parseToolName(mcpName: string): ParsedToolName | null {
const pkg = match[1];
const toolName = match[2];
// Reconstruct @scope/name format if it looks scoped
// Try to reconstruct @scope/name format if it looks scoped
// tpmjs-hello → @tpmjs/hello (first dash becomes @scope/)
const packageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
// But also keep the original for non-scoped packages like firecrawl-aisdk
const scopedPackageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
const literalPackageName = pkg;
return { type: 'registry', packageName, toolName };
// Return both possible interpretations - the handler will try both
return {
type: 'registry',
packageName: scopedPackageName,
literalPackageName,
toolName,
};
}
/**

View file

@ -436,6 +436,12 @@ model Collection {
// These are passed to tools when executed
envVars Json? @map("env_vars") @db.JsonB
// Fork tracking - for "fork to use" model
forkedFromId String? @map("forked_from_id")
forkedFrom Collection? @relation("CollectionForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
forks Collection[] @relation("CollectionForks")
forkCount Int @default(0) @map("fork_count")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -453,6 +459,8 @@ model Collection {
@@index([isPublic])
@@index([likeCount])
@@index([createdAt])
@@index([forkedFromId])
@@index([forkCount])
@@map("collections")
}
@ -537,6 +545,12 @@ model Agent {
// Agent env vars override collection env vars, otherwise merged
envVars Json? @map("env_vars") @db.JsonB
// Fork tracking - for "fork to use" model
forkedFromId String? @map("forked_from_id")
forkedFrom Agent? @relation("AgentForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
forks Agent[] @relation("AgentForks")
forkCount Int @default(0) @map("fork_count")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -553,6 +567,8 @@ model Agent {
@@index([isPublic])
@@index([likeCount])
@@index([createdAt])
@@index([forkedFromId])
@@index([forkCount])
@@map("agents")
}
@ -755,6 +771,7 @@ enum ActivityType {
AGENT_UPDATED
AGENT_DELETED
AGENT_CLONED
AGENT_FORKED
AGENT_TOOL_ADDED
AGENT_TOOL_REMOVED
AGENT_COLLECTION_ADDED
@ -763,6 +780,7 @@ enum ActivityType {
COLLECTION_UPDATED
COLLECTION_DELETED
COLLECTION_CLONED
COLLECTION_FORKED
COLLECTION_TOOL_ADDED
COLLECTION_TOOL_REMOVED
TOOL_LIKED

View file

@ -169,6 +169,8 @@ export const AgentSchema = z.object({
isPublic: z.boolean(),
toolCount: z.number(),
collectionCount: z.number(),
forkCount: z.number().default(0),
forkedFromId: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});

View file

@ -107,6 +107,8 @@ export const CollectionSchema = z.object({
description: z.string().nullable(),
isPublic: z.boolean(),
toolCount: z.number(),
forkCount: z.number().default(0),
forkedFromId: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
});

View file

@ -140,6 +140,14 @@ export const icons = {
viewBox: '0 0 24 24',
path: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
},
gitFork: {
viewBox: '0 0 24 24',
path: 'M6 3a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm12-2a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6zm0 2a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM7 8v5a3 3 0 0 0 3 3h4a1 1 0 0 1 1 1v1h-2v-1H9a5 5 0 0 1-5-5V8h3zm10 0v4a1 1 0 0 1-1 1h-4v-2h3V8h2z',
},
alertTriangle: {
viewBox: '0 0 24 24',
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
},
} as const;
export type IconName = keyof typeof icons;