Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/plugins/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { buildPageMetadata } from "@/lib/seo";

export const metadata: Metadata = buildPageMetadata({
title: "Agent Plugins for Music | Recoup",
description:
"Install Recoup plugins into Claude Code, Cowork, Codex, or Cursor. Artist research, content creation, catalog deals, and more — powered by the Recoup API.",
path: "/plugins",
});

export default function PluginsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
234 changes: 234 additions & 0 deletions app/plugins/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"use client";

import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { ArrowRight, Copy, Check, Terminal } from "lucide-react";
import { pluginsCopy } from "@/lib/copy/plugins";
import type { Plugin } from "@/lib/copy/plugins";

/* ── reveal-on-scroll ── */
function useReveal() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The useReveal hook is duplicated from app/results/page.tsx (same IntersectionObserver logic, same animation class string, same cubic-bezier timing). Since this is used by at least two pages now, consider extracting it into a shared hook at components/hooks/useReveal.ts (or similar) and importing it in both places to keep animations consistent and avoid drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/plugins/page.tsx, line 10:

<comment>The `useReveal` hook is duplicated from `app/results/page.tsx` (same IntersectionObserver logic, same animation class string, same cubic-bezier timing). Since this is used by at least two pages now, consider extracting it into a shared hook at `components/hooks/useReveal.ts` (or similar) and importing it in both places to keep animations consistent and avoid drift.</comment>

<file context>
@@ -0,0 +1,234 @@
+import type { Plugin } from "@/lib/copy/plugins";
+
+/* ── reveal-on-scroll ── */
+function useReveal() {
+  const ref = useRef<HTMLDivElement>(null);
+  const [v, setV] = useState(false);
</file context>

const ref = useRef<HTMLDivElement>(null);
const [v, setV] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setV(true);
io.disconnect();
}
},
{ threshold: 0.08 },
);
io.observe(el);
return () => io.disconnect();
}, []);
return {
ref,
cls: `transition-all duration-[900ms] ease-[cubic-bezier(.16,1,.3,1)] ${
v ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`,
};
}

/* ── copy button ── */
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Wrap navigator.clipboard.writeText in a try/catch or check navigator.clipboard?.writeText — the call throws in insecure contexts (HTTP pages, some iframe sandbox modes) and older browsers, silently breaking the copy action with no user feedback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/plugins/page.tsx, line 42:

<comment>Wrap `navigator.clipboard.writeText` in a try/catch or check `navigator.clipboard?.writeText` — the call throws in insecure contexts (HTTP pages, some iframe sandbox modes) and older browsers, silently breaking the copy action with no user feedback.</comment>

<file context>
@@ -0,0 +1,234 @@
+  return (
+    <button
+      onClick={() => {
+        navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
</file context>

setCopied(true);
setTimeout(() => setCopied(false), 2000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Clean up the 2000ms timeout when CopyButton unmounts. If the component unmounts while the "copied" feedback is shown, the stale timeout calls setCopied(false) on an unmounted component.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/plugins/page.tsx, line 44:

<comment>Clean up the 2000ms timeout when `CopyButton` unmounts. If the component unmounts while the "copied" feedback is shown, the stale timeout calls `setCopied(false)` on an unmounted component.</comment>

<file context>
@@ -0,0 +1,234 @@
+      onClick={() => {
+        navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      }}
+      className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"
</file context>

}}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Add aria-label to the copy button — the title attribute alone isn't reliably exposed by all screen readers as an accessible name. An aria-label="Copy install command" ensures assistive tech users can identify the button target.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/plugins/page.tsx, line 46:

<comment>Add `aria-label` to the copy button — the `title` attribute alone isn't reliably exposed by all screen readers as an accessible name. An `aria-label="Copy install command"` ensures assistive tech users can identify the button target.</comment>

<file context>
@@ -0,0 +1,234 @@
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      }}
+      className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"
+      title="Copy install command"
+    >
</file context>

title="Copy install command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5 text-neutral-500" />
)}
</button>
);
}
Comment on lines +37 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

CopyButton: unhandled clipboard rejection and weak a11y label.

navigator.clipboard.writeText returns a promise that can reject (e.g., insecure context, permission denial) — currently unhandled, so copied state is set optimistically even on failure. Also, title alone isn't reliably announced by screen readers; consider aria-label for the icon-only button.

🛡️ Proposed fix
     <button
       onClick={() => {
-        navigator.clipboard.writeText(text);
-        setCopied(true);
-        setTimeout(() => setCopied(false), 2000);
+        navigator.clipboard
+          .writeText(text)
+          .then(() => {
+            setCopied(true);
+            setTimeout(() => setCopied(false), 2000);
+          })
+          .catch(() => {});
       }}
       className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"
       title="Copy install command"
+      aria-label="Copy install command"
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"
title="Copy install command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5 text-neutral-500" />
)}
</button>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors shrink-0"
title="Copy install command"
aria-label="Copy install command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5 text-neutral-500" />
)}
</button>
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/plugins/page.tsx` around lines 37 - 56, The CopyButton component is
optimistically setting copied state without handling clipboard failures, and the
icon-only button relies on title instead of an accessible label. Update the
onClick handler in CopyButton to await navigator.clipboard.writeText and only
set copied when it succeeds, with failure handling that avoids false success
state. Also add an aria-label to the button so screen readers can announce the
action clearly, while keeping the existing visual icon behavior.


/* ── plugin card ── */
function PluginCard({ plugin, index }: { plugin: Plugin; index: number }) {
const { ref, cls } = useReveal();
return (
<div
ref={ref}
className={cls}
style={{ transitionDelay: `${index * 120}ms` }}
>
<div className="h-full rounded-xl bg-neutral-900/50 shadow-[0_0_0_1px_rgba(255,255,255,0.06)] p-6 sm:p-8 flex flex-col">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-semibold text-white">{plugin.name}</h3>
<span className="text-xs text-neutral-500 mt-1 block">
{plugin.skillCount} skill{plugin.skillCount !== 1 ? "s" : ""}
{" · "}
{plugin.audience}
</span>
</div>
<a
href={plugin.repo}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
GitHub →
</a>
</div>

{/* Description */}
<p className="text-sm text-neutral-400 leading-relaxed mb-5">
{plugin.description}
</p>

{/* Capabilities */}
<ul className="space-y-1.5 mb-6 flex-1">
{plugin.capabilities.map((cap) => (
<li key={cap} className="flex items-start gap-2 text-sm">
<Check className="w-3.5 h-3.5 text-green-400 mt-0.5 shrink-0" />
<span className="text-neutral-300">{cap}</span>
</li>
))}
</ul>

{/* Install command */}
<div className="flex items-center gap-2 rounded-lg bg-black/60 px-3 py-2.5 font-mono text-xs text-neutral-400 overflow-hidden">
<Terminal className="w-3.5 h-3.5 shrink-0 text-neutral-600" />
<code className="truncate flex-1">{plugin.installCommand}</code>
<CopyButton text={plugin.installCommand} />
</div>
</div>
</div>
);
}

/* ── step card ── */
function StepCard({
step,
index,
}: {
step: (typeof pluginsCopy.steps)[number];
index: number;
}) {
const { ref, cls } = useReveal();
return (
<div
ref={ref}
className={cls}
style={{ transitionDelay: `${index * 120}ms` }}
>
<div className="text-center">
<div
className="text-4xl font-bold text-white/10 mb-3"
style={{ fontFamily: "var(--font-bitmap), monospace" }}
>
{step.number}
</div>
<h3 className="text-lg font-semibold text-white mb-2">{step.title}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
);
}

/* ── page ── */
export default function PluginsPage() {
const c = pluginsCopy;

return (
<div className="max-w-5xl mx-auto px-6 py-20">
{/* Hero */}
<header className="text-center mb-20">
<div className="inline-block rounded-full border border-white/10 px-4 py-1.5 text-xs font-medium tracking-wide uppercase mb-6 text-neutral-400">
Plugin Marketplace
</div>
<h1
className="text-[clamp(2rem,5vw,3.5rem)] font-bold tracking-tight leading-[1.1] mb-4 text-white"
style={{ fontFamily: "var(--font-bitmap), monospace" }}
>
{c.title}
</h1>
<p className="text-[15px] text-neutral-400 max-w-2xl mx-auto leading-relaxed">
{c.subtitle}
</p>

{/* Compatible-with badges */}
<div className="flex items-center justify-center gap-3 mt-8 flex-wrap">
{c.compatibleWith.map((tool) => (
<span
key={tool}
className="rounded-full bg-white/5 px-3 py-1 text-xs text-neutral-400 border border-white/5"
>
{tool}
</span>
))}
</div>
</header>

{/* Plugin grid */}
<section className="grid gap-6 sm:grid-cols-2 mb-24">
{c.plugins.map((plugin, i) => (
<PluginCard key={plugin.id} plugin={plugin} index={i} />
))}
</section>

{/* How it works */}
<section className="mb-24">
<h2
className="text-2xl font-bold text-white text-center mb-12"
style={{ fontFamily: "var(--font-bitmap), monospace" }}
>
How it works
</h2>
<div className="grid gap-12 sm:grid-cols-3">
{c.steps.map((step, i) => (
<StepCard key={step.number} step={step} index={i} />
))}
</div>
</section>

{/* CTA */}
<section className="text-center">
<div className="rounded-xl bg-neutral-900/50 shadow-[0_0_0_1px_rgba(255,255,255,0.06)] px-8 py-12">
<h2
className="text-2xl font-bold text-white mb-3"
style={{ fontFamily: "var(--font-bitmap), monospace" }}
>
Ready to plug in?
</h2>
<p className="text-sm text-neutral-400 mb-8 max-w-md mx-auto">
Get your API key, install a plugin, and let your AI agent run your
music business.
</p>
<div className="flex items-center justify-center gap-4 flex-wrap">
<a
href="https://developers.recoupable.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-white text-black px-5 py-2.5 rounded-md text-sm font-medium hover:bg-neutral-200 transition-colors"
>
Get API Key <ArrowRight className="w-4 h-4" />
</a>
<Link
href="/pricing"
className="inline-flex items-center gap-2 border border-white/10 text-white px-5 py-2.5 rounded-md text-sm font-medium hover:bg-white/5 transition-colors"
>
View Pricing
</Link>
</div>
</div>
</section>
</div>
);
}
Comment on lines +1 to +234

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Multiple functions in a single file violates the one-function-per-file rule.

This file defines useReveal, CopyButton, PluginCard, StepCard, and PluginsPage — five distinct responsibilities in one file. Extracting each into its own module (e.g., a useReveal hook file, CopyButton.tsx, PluginCard.tsx, StepCard.tsx) would align with the project's SRP convention and make each piece independently testable/reusable.

As per coding guidelines, **/*.{ts,tsx,js,jsx}: "Implement Single Responsibility Principle: one function per file."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/plugins/page.tsx` around lines 1 - 234, The page file currently mixes
five separate responsibilities (`useReveal`, `CopyButton`, `PluginCard`,
`StepCard`, and `PluginsPage`), which violates the one-function-per-file rule.
Split each symbol into its own module, then import them back into the page
component so `PluginsPage` only composes the UI. Keep the hook and each
card/button component in dedicated files to preserve single responsibility and
make them easier to reuse and test.

Source: Coding guidelines

125 changes: 125 additions & 0 deletions lib/copy/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Plugins page copy — single source of truth for the plugin showcase.
*/

export interface Plugin {
id: string;
name: string;
description: string;
skillCount: number;
capabilities: string[];
installCommand: string;
repo: string;
/** Which tier of customer this targets */
audience: string;
}

export const pluginsCopy = {
title: "Agent Plugins for Music",
subtitle:
"Install a plugin. Your AI agent gets music industry superpowers. Works with Claude Code, Cowork, Codex, and Cursor.",

plugins: [
{
id: "research",
name: "Research",
description:
"Artist analytics, audience insights, competitive analysis, playlist intelligence, and trend detection. The foundation for any music business decision.",
skillCount: 7,
capabilities: [
"Artist research & briefs",
"Audience demographics & cities",
"Competitive landscape",
"Playlist intelligence",
"Trend detection",
"Web intelligence",
"People outreach",
],
installCommand:
"claude plugin install https://github.com/recoupable/recoup-research-plugin",
repo: "https://github.com/recoupable/recoup-research-plugin",
audience: "Labels, managers, distributors",
},
{
id: "content",
name: "Content",
description:
"Generate release strategy content, social posts, and short-form video scripts. Your agent becomes your marketing team.",
skillCount: 3,
capabilities: [
"Content creation workflows",
"Social post generation",
"Short-form video scripts",
],
installCommand:
"claude plugin install https://github.com/recoupable/recoup-content-plugin",
repo: "https://github.com/recoupable/recoup-content-plugin",
audience: "Labels, managers, artists",
},
{
id: "catalogs",
name: "Catalog Deals",
description:
"Turn a messy seller data room into a source-cited deal package. Royalty normalization, rights review, valuation analysis, financing underwriting, and IC memos — all agent-driven.",
skillCount: 17,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Wrong skillCount for the Catalog Deals plugin: declared as 17, but the capabilities array only has 8 entries. Visitors will see "17 skills" on the card and then scroll through 8 listed items — this creates a trust issue and suggests the data is stale or the count was aspirational. Either update the capabilities list to match the true count, or correct skillCount to match the displayed list.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/copy/plugins.ts, line 64:

<comment>Wrong `skillCount` for the Catalog Deals plugin: declared as 17, but the `capabilities` array only has 8 entries. Visitors will see "17 skills" on the card and then scroll through 8 listed items — this creates a trust issue and suggests the data is stale or the count was aspirational. Either update the capabilities list to match the true count, or correct skillCount to match the displayed list.</comment>

<file context>
@@ -0,0 +1,125 @@
+      name: "Catalog Deals",
+      description:
+        "Turn a messy seller data room into a source-cited deal package. Royalty normalization, rights review, valuation analysis, financing underwriting, and IC memos — all agent-driven.",
+      skillCount: 17,
+      capabilities: [
+        "Data room ingestion & normalization",
</file context>

capabilities: [
"Data room ingestion & normalization",
"Catalog valuation & analysis",
"Rights & ownership review",
"Royalty audit",
"Seller prep & cleanup",
"Financing underwriting",
"IC memo generation",
"Deal dashboard (HTML)",
],
installCommand:
"claude plugin install https://github.com/recoupable/recoup-catalogs-plugin",
repo: "https://github.com/recoupable/recoup-catalogs-plugin",
audience: "Catalog acquirers, investors, lenders",
},
{
id: "platform",
name: "Platform",
description:
"Onboarding and diagnostics. Your agent's starting point — validates credentials, discovers capabilities, and runs health checks.",
skillCount: 2,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Wrong skillCount for the Platform plugin: declared as 2, but its capabilities list has 4 items (Guided onboarding flow, API key validation, Plugin discovery & recommendations, Health check & diagnostics). This value is shown directly on the card as "4 skills" — so the page will display "2 skills" while listing 4 capabilities, which looks inconsistent. Same issue exists for Catalog Deals (declared 17, listed 8).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/copy/plugins.ts, line 85:

<comment>Wrong `skillCount` for the Platform plugin: declared as 2, but its `capabilities` list has 4 items (Guided onboarding flow, API key validation, Plugin discovery & recommendations, Health check & diagnostics). This value is shown directly on the card as "4 skills" — so the page will display "2 skills" while listing 4 capabilities, which looks inconsistent. Same issue exists for Catalog Deals (declared 17, listed 8).</comment>

<file context>
@@ -0,0 +1,125 @@
+      name: "Platform",
+      description:
+        "Onboarding and diagnostics. Your agent's starting point — validates credentials, discovers capabilities, and runs health checks.",
+      skillCount: 2,
+      capabilities: [
+        "Guided onboarding flow",
</file context>

capabilities: [
"Guided onboarding flow",
"API key validation",
"Plugin discovery & recommendations",
"Health check & diagnostics",
],
installCommand:
"claude plugin install https://github.com/recoupable/recoup-platform-plugin",
repo: "https://github.com/recoupable/recoup-platform-plugin",
audience: "Everyone — start here",
},
] satisfies Plugin[],

compatibleWith: [
"Claude Code",
"Claude Cowork",
"Codex",
"Cursor",
],

steps: [
{
number: "1",
title: "Install",
description: "One command. Plugin loads into your AI tool of choice.",
},
{
number: "2",
title: "Connect",
description:
"Set your Recoup API key. Get one free at developers.recoupable.com.",
},
{
number: "3",
title: "Go",
description:
"Ask your agent anything about music. It now has the data and workflows to deliver.",
},
],
} as const;
2 changes: 2 additions & 0 deletions lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { siteConfig } from "@/lib/config";

export const nav: readonly { label: string; href: string; external?: boolean }[] = [
{ label: "Plugins", href: "/plugins" },
{ label: "Results", href: "/results" },
{ label: "Pricing", href: "/pricing" },
{ label: "Docs", href: siteConfig.docsUrl, external: true },
Expand All @@ -17,6 +18,7 @@ export const footerNav = {
items: [
{ label: "Platform", href: "/platform" },
{ label: "Pricing", href: "/pricing" },
{ label: "Plugins", href: "/plugins" },
{ label: "Docs", href: siteConfig.docsUrl, external: true },
],
},
Expand Down