diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 3dfa38c..2d8fe85 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -7,6 +7,14 @@
"description": "Claude Code skills, agents, and configuration — the parts worth sharing.",
"pluginRoot": "./plugins"
},
+ "categories": [
+ { "slug": "research-knowledge", "name": "Research & Knowledge", "display_order": 1 },
+ { "slug": "code-quality", "name": "Code Quality", "display_order": 2 },
+ { "slug": "data-engineering", "name": "Data Engineering", "display_order": 3 },
+ { "slug": "documentation", "name": "Documentation", "display_order": 4 },
+ { "slug": "devops", "name": "DevOps & Shipping", "display_order": 5 },
+ { "slug": "productivity", "name": "Productivity", "display_order": 6 }
+ ],
"plugins": [
{
"name": "research",
@@ -14,7 +22,9 @@
"description": "Process any source into a structured, compounding knowledge base",
"version": "0.3.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "research-knowledge",
+ "tags": ["research", "knowledge-base", "web-scraping", "pdf", "github", "sources"]
},
{
"name": "explain",
@@ -22,7 +32,9 @@
"description": "Structured code explainer — layered explanations of files, functions, directories, or concepts",
"version": "0.2.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "code-quality",
+ "tags": ["code-explanation", "learning", "walkthrough", "codebase-understanding"]
},
{
"name": "data-lineage",
@@ -30,7 +42,9 @@
"description": "Trace column-level data lineage through SQL, Kafka, Spark, and JDBC codebases",
"version": "0.2.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "data-engineering",
+ "tags": ["sql", "data-lineage", "kafka", "spark", "jdbc", "column-level"]
},
{
"name": "orient",
@@ -38,7 +52,9 @@
"description": "Topic-focused session orientation — search graph, knowledge, journal, and research for a specific topic",
"version": "0.2.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "research-knowledge",
+ "tags": ["orientation", "knowledge-graph", "session", "topic-search"]
},
{
"name": "capture-session",
@@ -46,7 +62,9 @@
"description": "Capture session information into a staging file for later reflection and knowledge graph processing",
"version": "0.2.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "research-knowledge",
+ "tags": ["session-capture", "knowledge-graph", "reflection", "staging"]
},
{
"name": "review",
@@ -54,7 +72,9 @@
"description": "Code review for a branch, PR, or path — structured output with severity labels and cross-file analysis",
"version": "0.3.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "code-quality",
+ "tags": ["code-review", "pull-request", "static-analysis", "github"]
},
{
"name": "docgen",
@@ -62,15 +82,19 @@
"description": "Generate or update README, API docs, architecture overview, or changelog — always confirms before writing",
"version": "0.2.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "documentation",
+ "tags": ["documentation", "readme", "api-docs", "changelog", "architecture"]
},
{
"name": "harness-share",
"source": "./harness-share",
- "description": "Export, share, and import your harness-kit plugin configuration — capture your setup into harness.yaml and restore it anywhere",
- "version": "0.2.0",
+ "description": "Compile, export, import, and sync harness configurations across Claude Code, Cursor, and GitHub Copilot",
+ "version": "0.3.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "productivity",
+ "tags": ["configuration", "export", "import", "yaml", "cross-platform"]
},
{
"name": "ship-pr",
@@ -78,7 +102,9 @@
"description": "End-of-task shipping workflow: run tests, open a PR, code review, fix CI, sync base, then squash merge",
"version": "0.1.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "devops",
+ "tags": ["pull-request", "ci-cd", "merge", "testing", "workflow"]
},
{
"name": "pull-request-sweep",
@@ -86,7 +112,9 @@
"description": "Cross-repo PR sweep: triage all open PRs, run code reviews, merge what's ready, fix quick CI blockers, and report",
"version": "0.1.0",
"author": { "name": "harnessprotocol" },
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "category": "devops",
+ "tags": ["pull-request", "triage", "ci-cd", "code-review", "multi-repo"]
}
]
}
diff --git a/.gitignore b/.gitignore
index 9e64cfd..beff742 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,8 +9,26 @@ venv/
private/
docs/plans/
-# Docusaurus
-website/node_modules/
+# Node
+node_modules/
+
+# Website (Fumadocs)
website/build/
website/.docusaurus/
website/.cache-loader/
+website/.next/
+website/out/
+website/.source/
+
+# Marketplace
+marketplace/.next/
+marketplace/out/
+
+# Supabase local
+marketplace/supabase/.temp/
+
+# Shared package build output
+packages/shared/dist/
+
+# Playwright MCP
+.playwright-mcp/
diff --git a/README.md b/README.md
index 63d25d0..a011172 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,11 @@ A configuration framework for AI coding tools.
[](https://github.com/harnessprotocol/harness-kit/actions/workflows/validate.yml)
[](LICENSE)
-Requires [Claude Code](https://claude.ai/claude-code)
+Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.com), and [GitHub Copilot](https://github.com/features/copilot)
-Building a good AI setup takes real work. harness-kit makes it portable. Package your plugins, skills, MCP servers, hooks, and conventions into a config you can apply to any harness on any machine — and share with your team in one file. Works with Claude Code today. Designed to travel.
+Building a good AI setup takes real work. harness-kit makes it portable. Package your plugins, skills, MCP servers, hooks, and conventions into a config you can apply to any harness on any machine — and share with your team in one file. Works with Claude Code, Cursor, and GitHub Copilot. Designed to travel.
## Install
@@ -63,7 +63,7 @@ Produces a structured explanation: summary, key components, how it connects, pat
| [`data-lineage`](plugins/data-lineage/skills/data-lineage/README.md) | Trace column-level data lineage through SQL, Kafka, Spark, and JDBC codebases | `/data-lineage orders.amount` |
| [`orient`](plugins/orient/skills/orient/README.md) ¹ | Topic-focused session orientation across graph, knowledge, journal, and research | `/orient auth` |
| [`capture-session`](plugins/capture-session/skills/capture-session/README.md) ¹ | Capture session information into a staging file for later reflection | `/capture-session` |
-| [`harness-share`](plugins/harness-share/skills/harness-export/README.md) | Export your plugin setup to `harness.yaml`, import it anywhere, and validate against the Harness Protocol v1 spec | `/harness-export` · `/harness-import` · `/harness-validate` |
+| [`harness-share`](plugins/harness-share/skills/harness-export/README.md) | Export your plugin setup to `harness.yaml`, compile to native configs for Claude Code, Cursor, and Copilot, and keep them in sync | `/harness-export` · `/harness-import` · `/harness-validate` · `/harness-compile` · `/harness-sync` |
¹ Personal-workflow plugins designed for projects using the [knowledge graph + journal pattern](docs/claude-md-conventions.md).
@@ -88,6 +88,8 @@ Capture your installed plugins into a `harness.yaml` file, commit it to your dot
/harness-export # write harness.yaml from your current setup
/harness-import harness.yaml # interactive wizard to pick what to install
/harness-validate # validate harness.yaml against the Harness Protocol v1 schema
+/harness-compile # compile harness.yaml to native config files for Claude Code, Cursor, and Copilot
+/harness-sync # keep Claude Code, Cursor, and Copilot configuration in sync
```
The import wizard shows each plugin with its description and lets you pick a subset — your config is a starting point, not a mandate.
@@ -104,7 +106,9 @@ See [`harness.yaml.example`](harness.yaml.example) for the config format.
## Using with Other Tools
-harness-kit targets Claude Code, but SKILL.md files are plain markdown — copy them into any tool's instruction system. VS Code Copilot reads `CLAUDE.md` natively via the `chat.useClaudeMdFile` setting, so the conventions guide works without modification. For per-tool setup (Copilot, Cursor, Windsurf, MCP), see the [Cross-Harness setup guide](https://harnesskit.ai/docs/cross-harness/setup-guide).
+harness-kit natively supports Claude Code, Cursor, and GitHub Copilot. Use `/harness-compile` to generate native config files for each tool from a single `harness.yaml`, and `/harness-sync` to keep them aligned as your setup evolves.
+
+SKILL.md files are plain markdown — they work in any tool's instruction system. VS Code Copilot reads `CLAUDE.md` natively via the `chat.useClaudeMdFile` setting, so the conventions guide works without modification. The [Harness Protocol spec](https://harnessprotocol.io) documents the full cross-platform target mapping.
## Conventions Guide
diff --git a/marketplace/app/api/install/route.ts b/marketplace/app/api/install/route.ts
new file mode 100644
index 0000000..4afb41e
--- /dev/null
+++ b/marketplace/app/api/install/route.ts
@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createClient } from "@supabase/supabase-js";
+
+function getServiceSupabase() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!url || !key) throw new Error("Supabase not configured");
+ return createClient(url, key);
+}
+
+interface InstallPayload {
+ slug: string;
+}
+
+export async function POST(request: NextRequest) {
+ const supabase = getServiceSupabase();
+ let payload: InstallPayload;
+ try {
+ payload = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: "Invalid JSON body" },
+ { status: 400 },
+ );
+ }
+
+ const { slug } = payload;
+
+ if (!slug) {
+ return NextResponse.json(
+ { error: "Missing required field: slug" },
+ { status: 400 },
+ );
+ }
+
+ // Atomically increment install_count to avoid race conditions
+ const { data: updated, error: updateError } = await supabase
+ .rpc("increment_install_count", { component_slug: slug });
+
+ if (updateError) {
+ // Fallback: try direct update if RPC not available
+ const { data: component, error: fetchError } = await supabase
+ .from("components")
+ .select("id, install_count")
+ .eq("slug", slug)
+ .single();
+
+ if (fetchError || !component) {
+ return NextResponse.json(
+ { error: `Plugin not found: ${slug}` },
+ { status: 404 },
+ );
+ }
+
+ const { error: fallbackError } = await supabase
+ .from("components")
+ .update({ install_count: (component.install_count ?? 0) + 1 })
+ .eq("id", component.id);
+
+ if (fallbackError) {
+ return NextResponse.json(
+ { error: `Failed to update install count: ${fallbackError.message}` },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({
+ slug,
+ install_count: (component.install_count ?? 0) + 1,
+ });
+ }
+
+ return NextResponse.json({
+ slug,
+ install_count: updated,
+ });
+}
diff --git a/marketplace/app/api/register/route.ts b/marketplace/app/api/register/route.ts
new file mode 100644
index 0000000..1e9e5b6
--- /dev/null
+++ b/marketplace/app/api/register/route.ts
@@ -0,0 +1,184 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createClient } from "@supabase/supabase-js";
+
+function getServiceSupabase() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!url || !key) throw new Error("Supabase not configured");
+ return createClient(url, key);
+}
+
+interface RegisterPayload {
+ repo_url: string;
+ plugin_path: string;
+}
+
+interface PluginManifest {
+ name: string;
+ description: string;
+ version: string;
+}
+
+/** Fetch raw file from a GitHub repo URL + path. */
+async function fetchGitHubFile(
+ repoUrl: string,
+ filePath: string,
+): Promise {
+ // Parse "https://github.com/owner/repo" into API URL
+ const match = repoUrl.match(
+ /github\.com\/([^/]+)\/([^/]+)/,
+ );
+ if (!match) return null;
+
+ const [, owner, repo] = match;
+ const cleanRepo = repo.replace(/\.git$/, "");
+ const url = `https://api.github.com/repos/${owner}/${cleanRepo}/contents/${filePath}`;
+
+ const headers: Record = {
+ Accept: "application/vnd.github.v3.raw",
+ "User-Agent": "harness-kit-marketplace",
+ };
+ const token = process.env.GITHUB_TOKEN;
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const res = await fetch(url, { headers });
+ if (!res.ok) return null;
+ return res.text();
+}
+
+export async function POST(request: NextRequest) {
+ // Require API key for registration to prevent spam
+ const apiKey = process.env.REGISTER_API_KEY;
+ if (!apiKey) {
+ return NextResponse.json(
+ { error: "Registration not configured" },
+ { status: 503 },
+ );
+ }
+
+ const authHeader = request.headers.get("authorization") ?? "";
+ if (authHeader !== `Bearer ${apiKey}`) {
+ return NextResponse.json(
+ { error: "Invalid or missing API key" },
+ { status: 401 },
+ );
+ }
+
+ const supabase = getServiceSupabase();
+ let payload: RegisterPayload;
+ try {
+ payload = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: "Invalid JSON body" },
+ { status: 400 },
+ );
+ }
+
+ const { repo_url, plugin_path } = payload;
+
+ if (!repo_url || !plugin_path) {
+ return NextResponse.json(
+ { error: "Missing required fields: repo_url, plugin_path" },
+ { status: 400 },
+ );
+ }
+
+ // Validate repo structure: must have plugin.json
+ const manifestPath = `${plugin_path}/.claude-plugin/plugin.json`;
+ const manifestRaw = await fetchGitHubFile(repo_url, manifestPath);
+
+ if (!manifestRaw) {
+ return NextResponse.json(
+ {
+ error: `Could not find plugin manifest at ${manifestPath}. Ensure the repo is public and the path is correct.`,
+ },
+ { status: 422 },
+ );
+ }
+
+ let manifest: PluginManifest;
+ try {
+ manifest = JSON.parse(manifestRaw);
+ } catch {
+ return NextResponse.json(
+ { error: "Could not parse plugin.json" },
+ { status: 422 },
+ );
+ }
+
+ if (!manifest.name || !manifest.description || !manifest.version) {
+ return NextResponse.json(
+ {
+ error:
+ "plugin.json must include name, description, and version fields",
+ },
+ { status: 422 },
+ );
+ }
+
+ // Check for duplicate slug
+ const { data: existing } = await supabase
+ .from("components")
+ .select("id")
+ .eq("slug", manifest.name)
+ .single();
+
+ if (existing) {
+ return NextResponse.json(
+ { error: `Component with slug "${manifest.name}" already exists` },
+ { status: 409 },
+ );
+ }
+
+ // Try to fetch SKILL.md and README.md
+ const skillMd = await fetchGitHubFile(
+ repo_url,
+ `${plugin_path}/skills/${manifest.name}/SKILL.md`,
+ );
+ const readmeMd = await fetchGitHubFile(
+ repo_url,
+ `${plugin_path}/skills/${manifest.name}/README.md`,
+ );
+
+ // Parse author from repo URL
+ const ownerMatch = repo_url.match(/github\.com\/([^/]+)/);
+ const authorName = ownerMatch ? ownerMatch[1] : "unknown";
+
+ // Create component with community trust tier
+ const { data: component, error } = await supabase
+ .from("components")
+ .insert({
+ slug: manifest.name,
+ name: manifest.name,
+ type: "skill",
+ description: manifest.description,
+ trust_tier: "community",
+ version: manifest.version,
+ author: { name: authorName },
+ license: "unknown",
+ skill_md: skillMd,
+ readme_md: readmeMd,
+ repo_url: repo_url,
+ install_count: 0,
+ })
+ .select()
+ .single();
+
+ if (error) {
+ return NextResponse.json(
+ { error: `Failed to create component: ${error.message}` },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json(
+ {
+ message: "Component registered successfully",
+ component,
+ },
+ { status: 201 },
+ );
+}
diff --git a/marketplace/app/api/search/route.ts b/marketplace/app/api/search/route.ts
new file mode 100644
index 0000000..378efc9
--- /dev/null
+++ b/marketplace/app/api/search/route.ts
@@ -0,0 +1,90 @@
+import { NextRequest, NextResponse } from "next/server";
+import { supabase } from "@/lib/supabase";
+
+type SearchType = "component" | "profile";
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+ const query = searchParams.get("q") ?? "";
+ const type = (searchParams.get("type") as SearchType) ?? "component";
+
+ if (!query) {
+ return NextResponse.json(
+ { error: "Missing required query parameter: q" },
+ { status: 400 },
+ );
+ }
+
+ // Sanitize and convert query to tsquery format for full-text search
+ const sanitizedTokens = query
+ .trim()
+ .split(/\s+/)
+ .map((token) => token.replace(/[!&|():*\\]/g, ""))
+ .filter(Boolean);
+
+ if (sanitizedTokens.length === 0) {
+ return NextResponse.json(
+ { error: "Query contains no searchable terms" },
+ { status: 400 },
+ );
+ }
+
+ const tsquery = sanitizedTokens.join(" & ");
+
+ if (type === "profile") {
+ const { data, error } = await supabase
+ .from("profiles")
+ .select("*")
+ .textSearch("fts", tsquery)
+ .order("name", { ascending: true })
+ .limit(20);
+
+ if (error) {
+ // Fallback to ilike if FTS fails — use parameterized filters
+ const { data: fallback, error: fallbackError } = await supabase
+ .from("profiles")
+ .select("*")
+ .or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`)
+ .order("name", { ascending: true })
+ .limit(20);
+
+ if (fallbackError) {
+ return NextResponse.json(
+ { error: `Search failed: ${fallbackError.message}` },
+ { status: 500 },
+ );
+ }
+ return NextResponse.json({ type: "profile", results: fallback ?? [] });
+ }
+
+ return NextResponse.json({ type: "profile", results: data ?? [] });
+ }
+
+ // Default: search components using full-text search
+ const { data, error } = await supabase
+ .from("components")
+ .select("*")
+ .textSearch("fts", tsquery)
+ .order("install_count", { ascending: false })
+ .limit(20);
+
+ if (error) {
+ // Fallback to ilike if FTS fails — sanitize PostgREST filter metacharacters
+ const { data: fallback, error: fallbackError } = await supabase
+ .from("components")
+ .select("*")
+ .or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`)
+ .order("install_count", { ascending: false })
+ .limit(20);
+
+ if (fallbackError) {
+ return NextResponse.json(
+ { error: `Search failed: ${fallbackError.message}` },
+ { status: 500 },
+ );
+ }
+ return NextResponse.json({ type: "component", results: fallback ?? [] });
+ }
+
+ return NextResponse.json({ type: "component", results: data ?? [] });
+}
diff --git a/marketplace/app/api/sync/route.ts b/marketplace/app/api/sync/route.ts
new file mode 100644
index 0000000..3c9e0a6
--- /dev/null
+++ b/marketplace/app/api/sync/route.ts
@@ -0,0 +1,215 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createClient } from "@supabase/supabase-js";
+
+function getServiceSupabase() {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!url || !key) throw new Error("Supabase not configured");
+ return createClient(url, key);
+}
+
+const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET ?? "";
+const REPO_OWNER = "harnessprotocol";
+const REPO_NAME = "harness-kit";
+
+/** Verify GitHub webhook signature using Web Crypto API. */
+async function verifySignature(
+ payload: string,
+ signature: string,
+): Promise {
+ const encoder = new TextEncoder();
+ const key = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(GITHUB_WEBHOOK_SECRET),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+ );
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
+ const digest = Array.from(new Uint8Array(sig))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ const expected = `sha256=${digest}`;
+ // Constant-time comparison to prevent timing attacks
+ if (signature.length !== expected.length) return false;
+ let result = 0;
+ for (let i = 0; i < signature.length; i++) {
+ result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
+ }
+ return result === 0;
+}
+
+/** Fetch a file from the GitHub repo. */
+async function fetchRepoFile(path: string): Promise {
+ const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${path}`;
+ const headers: Record = {
+ Accept: "application/vnd.github.v3.raw",
+ "User-Agent": "harness-kit-marketplace",
+ };
+ const token = process.env.GITHUB_TOKEN;
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const res = await fetch(url, { headers });
+ if (!res.ok) return null;
+ return res.text();
+}
+
+interface MarketplacePlugin {
+ name: string;
+ source: string;
+ description: string;
+ version: string;
+ author: { name: string; url?: string };
+ license: string;
+ category?: string;
+ tags?: string[];
+}
+
+interface MarketplaceManifest {
+ plugins: MarketplacePlugin[];
+}
+
+export async function POST(request: NextRequest) {
+ const supabase = getServiceSupabase();
+ // Verify webhook signature
+ const body = await request.text();
+ const signature = request.headers.get("x-hub-signature-256") ?? "";
+
+ if (!GITHUB_WEBHOOK_SECRET) {
+ console.warn("GITHUB_WEBHOOK_SECRET not set — rejecting webhook request");
+ return NextResponse.json(
+ { error: "Webhook secret not configured" },
+ { status: 500 },
+ );
+ }
+
+ const valid = await verifySignature(body, signature);
+ if (!valid) {
+ return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
+ }
+
+ const event = JSON.parse(body);
+
+ // Only process pushes to main branch
+ if (event.ref !== "refs/heads/main") {
+ return NextResponse.json({ message: "Ignored: not main branch" });
+ }
+
+ // Read marketplace.json from the repo
+ const manifestRaw = await fetchRepoFile(".claude-plugin/marketplace.json");
+ if (!manifestRaw) {
+ return NextResponse.json(
+ { error: "Could not read marketplace.json" },
+ { status: 500 },
+ );
+ }
+
+ const manifest: MarketplaceManifest = JSON.parse(manifestRaw);
+
+ // Process each plugin
+ const results: Array<{ name: string; status: string }> = [];
+
+ for (const plugin of manifest.plugins) {
+ // Resolve source path: "./research" -> "plugins/research"
+ const pluginDir = `plugins/${plugin.source.replace("./", "")}`;
+
+ // Try to fetch SKILL.md and README.md
+ const skillMd = await fetchRepoFile(
+ `${pluginDir}/skills/${plugin.name}/SKILL.md`,
+ );
+ const readmeMd = await fetchRepoFile(
+ `${pluginDir}/skills/${plugin.name}/README.md`,
+ );
+
+ // Auto-extract tags from plugin manifest + description
+ const autoTags = new Set(plugin.tags ?? []);
+ // Add type-based tag
+ autoTags.add("skill");
+
+ // Upsert component
+ const { error } = await supabase
+ .from("components")
+ .upsert(
+ {
+ slug: plugin.name,
+ name: plugin.name,
+ type: "skill",
+ description: plugin.description,
+ trust_tier: "official",
+ version: plugin.version,
+ author: plugin.author,
+ license: plugin.license,
+ skill_md: skillMd,
+ readme_md: readmeMd,
+ repo_url: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/main/${pluginDir}`,
+ updated_at: new Date().toISOString(),
+ },
+ { onConflict: "slug" },
+ );
+
+ if (error) {
+ results.push({ name: plugin.name, status: `error: ${error.message}` });
+ } else {
+ // Upsert tags
+ for (const tagSlug of autoTags) {
+ await supabase
+ .from("tags")
+ .upsert({ slug: tagSlug }, { onConflict: "slug" });
+
+ const { data: tagRow } = await supabase
+ .from("tags")
+ .select("id")
+ .eq("slug", tagSlug)
+ .single();
+
+ const { data: compRow } = await supabase
+ .from("components")
+ .select("id")
+ .eq("slug", plugin.name)
+ .single();
+
+ if (tagRow && compRow) {
+ await supabase
+ .from("component_tags")
+ .upsert(
+ { component_id: compRow.id, tag_id: tagRow.id },
+ { onConflict: "component_id,tag_id" },
+ );
+ }
+ }
+
+ // Link component to category
+ if (plugin.category) {
+ const { data: catRow } = await supabase
+ .from("categories")
+ .select("id")
+ .eq("slug", plugin.category)
+ .single();
+
+ const { data: compRow2 } = await supabase
+ .from("components")
+ .select("id")
+ .eq("slug", plugin.name)
+ .single();
+
+ if (catRow && compRow2) {
+ await supabase
+ .from("component_categories")
+ .upsert(
+ { component_id: compRow2.id, category_id: catRow.id },
+ { onConflict: "component_id,category_id" },
+ );
+ }
+ }
+
+ results.push({ name: plugin.name, status: "synced" });
+ }
+ }
+
+ return NextResponse.json({
+ message: "Sync complete",
+ results,
+ });
+}
diff --git a/marketplace/app/globals.css b/marketplace/app/globals.css
new file mode 100644
index 0000000..f1d8c73
--- /dev/null
+++ b/marketplace/app/globals.css
@@ -0,0 +1 @@
+@import "tailwindcss";
diff --git a/marketplace/app/layout.tsx b/marketplace/app/layout.tsx
new file mode 100644
index 0000000..03b7f23
--- /dev/null
+++ b/marketplace/app/layout.tsx
@@ -0,0 +1,56 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "Harness Kit Marketplace",
+ description:
+ "Skills, agents, and configuration for Claude Code — browse, search, and install plugins.",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/marketplace/app/page.tsx b/marketplace/app/page.tsx
new file mode 100644
index 0000000..910a70f
--- /dev/null
+++ b/marketplace/app/page.tsx
@@ -0,0 +1,179 @@
+import Link from "next/link";
+import { supabase } from "@/lib/supabase";
+import type { Component } from "@/lib/types";
+
+const CATEGORIES = [
+ {
+ slug: "research-knowledge",
+ name: "Research & Knowledge",
+ description: "Build compounding knowledge bases from any source",
+ },
+ {
+ slug: "code-quality",
+ name: "Code Quality",
+ description: "Reviews, explanations, and static analysis",
+ },
+ {
+ slug: "data-engineering",
+ name: "Data Engineering",
+ description: "Lineage tracing, SQL analysis, and pipeline tools",
+ },
+ {
+ slug: "documentation",
+ name: "Documentation",
+ description: "Generate READMEs, API docs, and changelogs",
+ },
+ {
+ slug: "devops",
+ name: "DevOps & Shipping",
+ description: "CI/CD, PR workflows, and deployment automation",
+ },
+ {
+ slug: "productivity",
+ name: "Productivity",
+ description: "Configuration sharing, session capture, and workflow tools",
+ },
+];
+
+function TrustBadge({ tier }: { tier: string }) {
+ const colors: Record = {
+ official: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ verified: "bg-green-500/20 text-green-400 border-green-500/30",
+ community: "bg-gray-500/20 text-gray-400 border-gray-500/30",
+ };
+ return (
+
+ {tier}
+
+ );
+}
+
+function PluginRow({ component }: { component: Component }) {
+ const updatedDate = component.updated_at
+ ? new Date(component.updated_at).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : null;
+
+ return (
+
+
+
+
+ {component.name}
+
+
+
+ {component.install_count.toLocaleString()} installs
+
+
+
+ {component.description}
+
+
+
+
v{component.version}
+ {updatedDate &&
{updatedDate}
}
+
+
+ );
+}
+
+export default async function HomePage() {
+ let trending: Component[] = [];
+ try {
+ const { data } = await supabase
+ .from("components")
+ .select("*")
+ .order("install_count", { ascending: false })
+ .limit(6);
+ trending = (data as Component[]) ?? [];
+ } catch {
+ // Supabase not configured yet — render with empty data
+ }
+
+ return (
+
+ {/* Hero */}
+
+
+ Harness Kit Marketplace
+
+
+ Skills, agents, and configuration for Claude Code
+
+
+
+
+
+ Browse Plugins
+
+
+ Discover skills, agents, hooks, and more
+
+
+
+
+ Explore Profiles
+
+
+ Pre-configured setups for your role
+
+
+
+
+
+ {/* Trending Plugins */}
+ {trending.length > 0 && (
+
+
+
Trending Plugins
+
+ View all
+
+
+
+ {trending.map((c) => (
+
+ ))}
+
+
+ )}
+
+ {/* Featured Categories */}
+
+ Featured Categories
+
+ {CATEGORIES.map((cat) => (
+
+
+ {cat.name}
+
+
{cat.description}
+
+ ))}
+
+
+
+ );
+}
diff --git a/marketplace/app/plugins/[slug]/page.tsx b/marketplace/app/plugins/[slug]/page.tsx
new file mode 100644
index 0000000..72e750c
--- /dev/null
+++ b/marketplace/app/plugins/[slug]/page.tsx
@@ -0,0 +1,355 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { supabase } from "@/lib/supabase";
+import type { Component, Profile, TrustTier } from "@/lib/types";
+
+function TrustBadge({ tier }: { tier: TrustTier }) {
+ const colors: Record = {
+ official: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ verified: "bg-green-500/20 text-green-400 border-green-500/30",
+ community: "bg-gray-500/20 text-gray-400 border-gray-500/30",
+ };
+ return (
+
+ {tier}
+
+ );
+}
+
+/**
+ * Minimal server-side markdown-to-HTML renderer.
+ * Content is trusted (from our own Supabase database, not user input).
+ */
+function renderMarkdown(md: string): string {
+ const html = md
+ // Fenced code blocks
+ .replace(
+ /```(\w*)\n([\s\S]*?)```/g,
+ '$2
',
+ )
+ // Headings
+ .replace(
+ /^#### (.+)$/gm,
+ '$1
',
+ )
+ .replace(
+ /^### (.+)$/gm,
+ '$1
',
+ )
+ .replace(
+ /^## (.+)$/gm,
+ '$1
',
+ )
+ .replace(
+ /^# (.+)$/gm,
+ '$1
',
+ )
+ // Bold
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ // Inline code
+ .replace(
+ /`([^`]+)`/g,
+ '$1',
+ )
+ // Links
+ .replace(
+ /\[([^\]]+)\]\(([^)]+)\)/g,
+ '$1',
+ )
+ // Unordered lists
+ .replace(/^- (.+)$/gm, '$1')
+ // Wrap loose lines in paragraphs (skip tags)
+ .replace(
+ /^(?!<[hluop]|$1
',
+ );
+
+ return html;
+}
+
+export default async function PluginDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+
+ let component: Component | null = null;
+ let tags: string[] = [];
+ let relatedComponents: Component[] = [];
+ let includedInProfiles: Profile[] = [];
+
+ try {
+ // Fetch the component
+ const { data } = await supabase
+ .from("components")
+ .select("*")
+ .eq("slug", slug)
+ .single();
+ component = data as Component | null;
+
+ if (component) {
+ // Fetch tags for this component
+ const { data: tagRows } = await supabase
+ .from("component_tags")
+ .select("tag_id, tags(slug)")
+ .eq("component_id", component.id);
+
+ if (tagRows) {
+ tags = tagRows
+ .map(
+ (row: Record) =>
+ (row.tags as { slug: string })?.slug ?? "",
+ )
+ .filter(Boolean);
+ }
+
+ // Fetch related components (same type, excluding self)
+ const { data: related } = await supabase
+ .from("components")
+ .select("*")
+ .eq("type", component.type)
+ .neq("id", component.id)
+ .order("install_count", { ascending: false })
+ .limit(5);
+ relatedComponents = (related as Component[]) ?? [];
+
+ // Fetch profiles that include this component
+ const { data: profileRows } = await supabase
+ .from("profile_components")
+ .select("profile_id, profiles(id, slug, name, description)")
+ .eq("component_id", component.id);
+
+ if (profileRows) {
+ includedInProfiles = profileRows
+ .map(
+ (row: Record) => row.profiles as Profile,
+ )
+ .filter(Boolean);
+ }
+ }
+ } catch {
+ // Supabase not configured yet
+ }
+
+ if (!component) {
+ notFound();
+ }
+
+ // Trusted content from our own database -- see renderMarkdown comment above
+ const skillHtml = component.skill_md
+ ? renderMarkdown(component.skill_md)
+ : null;
+ const readmeHtml = component.readme_md
+ ? renderMarkdown(component.readme_md)
+ : null;
+
+ const updatedDate = component.updated_at
+ ? new Date(component.updated_at).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : null;
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+
+ {/* Main column */}
+
+ {/* Header */}
+
+
+
{component.name}
+
+
+ {component.type}
+
+
+
+ {component.description}
+
+
+
+ {/* Stats bar */}
+
+
+
+ {component.install_count.toLocaleString()} installs
+
+
v{component.version}
+ {component.license &&
{component.license}}
+ {updatedDate &&
Updated {updatedDate}}
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {/* SKILL.md content -- trusted content from our own Supabase database */}
+ {skillHtml && (
+
+ )}
+
+ {/* README.md content -- trusted content from our own Supabase database */}
+ {readmeHtml && (
+
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+ );
+}
diff --git a/marketplace/app/plugins/page.tsx b/marketplace/app/plugins/page.tsx
new file mode 100644
index 0000000..1681850
--- /dev/null
+++ b/marketplace/app/plugins/page.tsx
@@ -0,0 +1,351 @@
+import Link from "next/link";
+import { supabase } from "@/lib/supabase";
+import type { Component, ComponentType, TrustTier } from "@/lib/types";
+
+const CATEGORIES = [
+ { slug: "research-knowledge", name: "Research & Knowledge" },
+ { slug: "code-quality", name: "Code Quality" },
+ { slug: "data-engineering", name: "Data Engineering" },
+ { slug: "documentation", name: "Documentation" },
+ { slug: "devops", name: "DevOps & Shipping" },
+ { slug: "productivity", name: "Productivity" },
+];
+
+const COMPONENT_TYPES: ComponentType[] = [
+ "skill",
+ "agent",
+ "hook",
+ "script",
+ "knowledge",
+ "rules",
+];
+
+function TrustBadge({ tier }: { tier: TrustTier }) {
+ const colors: Record = {
+ official: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ verified: "bg-green-500/20 text-green-400 border-green-500/30",
+ community: "bg-gray-500/20 text-gray-400 border-gray-500/30",
+ };
+ return (
+
+ {tier}
+
+ );
+}
+
+interface SearchParams {
+ q?: string;
+ category?: string;
+ type?: string;
+ trust?: string;
+ tag?: string;
+ sort?: string;
+}
+
+export default async function PluginsPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const query = params.q ?? "";
+ const selectedCategory = params.category ?? "";
+ const selectedType = params.type ?? "";
+ const selectedTrust = params.trust ?? "";
+ const selectedTag = params.tag ?? "";
+ const sortBy = params.sort ?? "installs";
+
+ let components: Component[] = [];
+ try {
+ let q = supabase.from("components").select("*");
+
+ if (query) {
+ q = q.ilike("name", `%${query}%`);
+ }
+ if (selectedType) {
+ q = q.eq("type", selectedType);
+ }
+ if (selectedTrust) {
+ q = q.eq("trust_tier", selectedTrust);
+ }
+
+ if (sortBy === "recent") {
+ q = q.order("updated_at", { ascending: false });
+ } else {
+ q = q.order("install_count", { ascending: false });
+ }
+
+ const { data } = await q;
+ let results = (data as Component[]) ?? [];
+
+ // Apply category filter via join table
+ if (selectedCategory && results.length > 0) {
+ const { data: catRow } = await supabase
+ .from("categories")
+ .select("id")
+ .eq("slug", selectedCategory)
+ .single();
+
+ if (catRow) {
+ const { data: linked } = await supabase
+ .from("component_categories")
+ .select("component_id")
+ .eq("category_id", catRow.id);
+
+ const ids = new Set((linked ?? []).map((r: { component_id: string }) => r.component_id));
+ results = results.filter((c) => ids.has(c.id));
+ }
+ }
+
+ // Apply tag filter via join table
+ if (selectedTag && results.length > 0) {
+ const { data: tagRow } = await supabase
+ .from("tags")
+ .select("id")
+ .eq("slug", selectedTag)
+ .single();
+
+ if (tagRow) {
+ const { data: linked } = await supabase
+ .from("component_tags")
+ .select("component_id")
+ .eq("tag_id", tagRow.id);
+
+ const ids = new Set((linked ?? []).map((r: { component_id: string }) => r.component_id));
+ results = results.filter((c) => ids.has(c.id));
+ }
+ }
+
+ components = results;
+ } catch {
+ // Supabase not configured yet
+ }
+
+ function buildUrl(overrides: Record) {
+ const merged = { ...params, ...overrides };
+ const qs = Object.entries(merged)
+ .filter(([, v]) => v)
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
+ .join("&");
+ return `/plugins${qs ? `?${qs}` : ""}`;
+ }
+
+ return (
+
+
Plugins
+
+ {/* Search bar */}
+
+
+
+ {/* Sidebar filters */}
+
+
+ {/* Plugin list */}
+
+ {/* Sort controls */}
+
+ Sort:
+
+ Installs
+
+
+ Recent
+
+
+
+ {components.length === 0 ? (
+
+
+ No plugins found. Connect Supabase to load data.
+
+
+ ) : (
+
+ {components.map((component) => {
+ const updatedDate = component.updated_at
+ ? new Date(component.updated_at).toLocaleDateString(
+ "en-US",
+ { month: "short", day: "numeric" },
+ )
+ : null;
+
+ return (
+
+
+
+
+ {component.name}
+
+
+
+ {component.install_count.toLocaleString()}
+
+ {component.author && (
+
+ {component.author.name}
+
+ )}
+
+
+ {component.type}
+
+
+
+ {component.description}
+
+
+
+
v{component.version}
+ {updatedDate && (
+
{updatedDate}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/marketplace/app/profiles/[slug]/page.tsx b/marketplace/app/profiles/[slug]/page.tsx
new file mode 100644
index 0000000..de9c070
--- /dev/null
+++ b/marketplace/app/profiles/[slug]/page.tsx
@@ -0,0 +1,237 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { supabase } from "@/lib/supabase";
+import type { Component, Profile, TrustTier } from "@/lib/types";
+
+function TrustBadge({ tier }: { tier: TrustTier }) {
+ const colors: Record = {
+ official: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ verified: "bg-green-500/20 text-green-400 border-green-500/30",
+ community: "bg-gray-500/20 text-gray-400 border-gray-500/30",
+ };
+ return (
+
+ {tier}
+
+ );
+}
+
+export default async function ProfileDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+
+ let profile: Profile | null = null;
+ let components: Component[] = [];
+ let tags: string[] = [];
+ let relatedProfiles: Profile[] = [];
+
+ try {
+ // Fetch the profile
+ const { data } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("slug", slug)
+ .single();
+ profile = data as Profile | null;
+
+ if (profile) {
+ // Fetch components in this profile
+ const { data: componentRows } = await supabase
+ .from("profile_components")
+ .select(
+ "pinned_version, components(id, slug, name, description, type, trust_tier, version, install_count)",
+ )
+ .eq("profile_id", profile.id);
+
+ if (componentRows) {
+ components = componentRows
+ .map(
+ (row: Record) => row.components as Component,
+ )
+ .filter(Boolean);
+ }
+
+ // Fetch tags for this profile
+ const { data: tagRows } = await supabase
+ .from("profile_tags")
+ .select("tag_id, tags(slug)")
+ .eq("profile_id", profile.id);
+
+ if (tagRows) {
+ tags = tagRows
+ .map(
+ (row: Record) =>
+ (row.tags as { slug: string })?.slug ?? "",
+ )
+ .filter(Boolean);
+ }
+
+ // Fetch related profiles (excluding self)
+ const { data: related } = await supabase
+ .from("profiles")
+ .select("*")
+ .neq("id", profile.id)
+ .order("name", { ascending: true })
+ .limit(3);
+ relatedProfiles = (related as Profile[]) ?? [];
+ }
+ } catch {
+ // Supabase not configured yet
+ }
+
+ if (!profile) {
+ notFound();
+ }
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+ {/* Header */}
+
+
+
{profile.name}
+
+
+
{profile.description}
+ {profile.author && (
+
+ by{" "}
+ {profile.author.url ? (
+
+ {profile.author.name}
+
+ ) : (
+ profile.author.name
+ )}
+
+ )}
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {/* Components in this profile */}
+
+
+ Plugins ({components.length})
+
+ {components.length === 0 ? (
+
+ No plugins listed for this profile yet.
+
+ ) : (
+
+ {components.map((component) => (
+
+
+
+
+ {component.name}
+
+
+
+ {component.type}
+
+
+
+ {component.description}
+
+
+
+ v{component.version}
+
+
+ ))}
+
+ )}
+
+
+ {/* harness.yaml preview */}
+ {profile.harness_yaml_template && (
+
+ harness.yaml
+
+ {profile.harness_yaml_template}
+
+
+ )}
+
+ {/* Install Profile */}
+
+
+ Install Profile
+
+
+ Copy the harness.yaml above into your project, or install components
+ individually:
+
+
+ {components.map((component) => (
+
+ /plugin install {component.slug}@harness-kit
+
+ ))}
+
+
+
+ {/* Related Profiles */}
+ {relatedProfiles.length > 0 && (
+
+ Other Profiles
+
+ {relatedProfiles.map((related) => (
+
+
+ {related.name}
+
+
+ {related.description}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/marketplace/app/profiles/page.tsx b/marketplace/app/profiles/page.tsx
new file mode 100644
index 0000000..7be0be1
--- /dev/null
+++ b/marketplace/app/profiles/page.tsx
@@ -0,0 +1,118 @@
+import Link from "next/link";
+import { supabase } from "@/lib/supabase";
+import type { Profile, TrustTier } from "@/lib/types";
+
+function TrustBadge({ tier }: { tier: TrustTier }) {
+ const colors: Record = {
+ official: "bg-blue-500/20 text-blue-400 border-blue-500/30",
+ verified: "bg-green-500/20 text-green-400 border-green-500/30",
+ community: "bg-gray-500/20 text-gray-400 border-gray-500/30",
+ };
+ return (
+
+ {tier}
+
+ );
+}
+
+interface ProfileWithCounts extends Profile {
+ component_count?: number;
+}
+
+interface SearchParams {
+ q?: string;
+}
+
+export default async function ProfilesPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const params = await searchParams;
+ const query = params.q ?? "";
+
+ let profiles: ProfileWithCounts[] = [];
+ try {
+ let q = supabase.from("profiles").select("*");
+
+ if (query) {
+ q = q.ilike("name", `%${query}%`);
+ }
+
+ q = q.order("name", { ascending: true });
+
+ const { data } = await q;
+ profiles = (data as ProfileWithCounts[]) ?? [];
+ } catch {
+ // Supabase not configured yet
+ }
+
+ return (
+
+
Profiles
+
+ {/* Search bar */}
+
+
+ {/* Profile list */}
+ {profiles.length === 0 ? (
+
+
+ No profiles found. Connect Supabase to load data.
+
+
+ ) : (
+
+ {profiles.map((profile) => (
+
+
+
+
+ {profile.name}
+
+
+ {profile.author && (
+
+ {profile.author.name}
+
+ )}
+ {profile.component_count !== undefined && (
+
+ {profile.component_count} plugin
+ {profile.component_count !== 1 ? "s" : ""} included
+
+ )}
+
+
+ {profile.description}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/marketplace/lib/supabase.ts b/marketplace/lib/supabase.ts
new file mode 100644
index 0000000..b5327f2
--- /dev/null
+++ b/marketplace/lib/supabase.ts
@@ -0,0 +1,25 @@
+import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+
+let _supabase: SupabaseClient | null = null;
+
+export function getSupabase(): SupabaseClient {
+ if (!_supabase) {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+ if (!url || !key) {
+ throw new Error("Supabase not configured");
+ }
+ _supabase = createClient(url, key);
+ }
+ return _supabase;
+}
+
+/**
+ * Convenience alias — pages should wrap calls in try/catch
+ * since Supabase may not be configured during build or development.
+ */
+export const supabase = new Proxy({} as SupabaseClient, {
+ get(_target, prop) {
+ return getSupabase()[prop as keyof SupabaseClient];
+ },
+});
diff --git a/marketplace/lib/types.ts b/marketplace/lib/types.ts
new file mode 100644
index 0000000..89cc654
--- /dev/null
+++ b/marketplace/lib/types.ts
@@ -0,0 +1,8 @@
+export type {
+ Component,
+ Profile,
+ Category,
+ Tag,
+ TrustTier,
+ ComponentType,
+} from "@harness-kit/shared";
diff --git a/marketplace/next-env.d.ts b/marketplace/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/marketplace/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/marketplace/next.config.ts b/marketplace/next.config.ts
new file mode 100644
index 0000000..dbedcdb
--- /dev/null
+++ b/marketplace/next.config.ts
@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const config: NextConfig = {};
+
+export default config;
diff --git a/marketplace/package.json b/marketplace/package.json
new file mode 100644
index 0000000..a1054fe
--- /dev/null
+++ b/marketplace/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "harness-kit-marketplace",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --port 3001",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "^15.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "@supabase/supabase-js": "^2.0.0",
+ "@harness-kit/shared": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.0.0",
+ "tailwindcss": "^4.0.0",
+ "@tailwindcss/postcss": "^4.0.0",
+ "postcss": "^8.0.0"
+ }
+}
diff --git a/marketplace/postcss.config.mjs b/marketplace/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/marketplace/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/marketplace/supabase/migrations/00001_initial_schema.sql b/marketplace/supabase/migrations/00001_initial_schema.sql
new file mode 100644
index 0000000..27455d3
--- /dev/null
+++ b/marketplace/supabase/migrations/00001_initial_schema.sql
@@ -0,0 +1,137 @@
+-- Enable extensions
+create extension if not exists "uuid-ossp";
+
+-- Categories (curated, small list)
+create table categories (
+ id uuid primary key default uuid_generate_v4(),
+ slug text unique not null,
+ name text not null,
+ display_order int not null default 0
+);
+
+-- Tags (freeform, auto-populated)
+create table tags (
+ id uuid primary key default uuid_generate_v4(),
+ slug text unique not null
+);
+
+-- Components (the atomic distributable unit)
+create table components (
+ id uuid primary key default uuid_generate_v4(),
+ slug text unique not null,
+ name text not null,
+ type text not null check (type in ('skill', 'agent', 'hook', 'script', 'knowledge', 'rules')),
+ description text not null,
+ trust_tier text not null default 'community' check (trust_tier in ('official', 'verified', 'community')),
+ version text not null,
+ author jsonb not null default '{}',
+ license text not null default 'Apache-2.0',
+ skill_md text,
+ readme_md text,
+ repo_url text,
+ install_count int not null default 0,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Profiles (named harness.yaml templates)
+create table profiles (
+ id uuid primary key default uuid_generate_v4(),
+ slug text unique not null,
+ name text not null,
+ description text not null,
+ author jsonb not null default '{}',
+ trust_tier text not null default 'community' check (trust_tier in ('official', 'verified', 'community')),
+ harness_yaml_template text not null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Join tables
+create table component_categories (
+ component_id uuid not null references components(id) on delete cascade,
+ category_id uuid not null references categories(id) on delete cascade,
+ primary key (component_id, category_id)
+);
+
+create table component_tags (
+ component_id uuid not null references components(id) on delete cascade,
+ tag_id uuid not null references tags(id) on delete cascade,
+ primary key (component_id, tag_id)
+);
+
+create table profile_components (
+ profile_id uuid not null references profiles(id) on delete cascade,
+ component_id uuid not null references components(id) on delete cascade,
+ pinned_version text not null,
+ primary key (profile_id, component_id)
+);
+
+create table profile_categories (
+ profile_id uuid not null references profiles(id) on delete cascade,
+ category_id uuid not null references categories(id) on delete cascade,
+ primary key (profile_id, category_id)
+);
+
+create table profile_tags (
+ profile_id uuid not null references profiles(id) on delete cascade,
+ tag_id uuid not null references tags(id) on delete cascade,
+ primary key (profile_id, tag_id)
+);
+
+-- Full-text search index on components
+alter table components add column fts tsvector
+ generated always as (
+ setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
+ setweight(to_tsvector('english', coalesce(skill_md, '')), 'C') ||
+ setweight(to_tsvector('english', coalesce(readme_md, '')), 'D')
+ ) stored;
+
+create index idx_components_fts on components using gin(fts);
+
+-- Full-text search index on profiles
+alter table profiles add column fts tsvector
+ generated always as (
+ setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
+ setweight(to_tsvector('english', coalesce(description, '')), 'B')
+ ) stored;
+
+create index idx_profiles_fts on profiles using gin(fts);
+
+-- Index for common queries
+create index idx_components_type on components(type);
+create index idx_components_trust_tier on components(trust_tier);
+create index idx_components_slug on components(slug);
+create index idx_profiles_slug on profiles(slug);
+
+-- Updated_at trigger
+create or replace function update_updated_at()
+returns trigger as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$ language plpgsql;
+
+create trigger components_updated_at
+ before update on components
+ for each row execute function update_updated_at();
+
+create trigger profiles_updated_at
+ before update on profiles
+ for each row execute function update_updated_at();
+
+-- Atomic install count increment (avoids read-then-write race condition)
+create or replace function increment_install_count(component_slug text)
+returns int as $$
+declare
+ new_count int;
+begin
+ update components
+ set install_count = install_count + 1
+ where slug = component_slug
+ returning install_count into new_count;
+ return new_count;
+end;
+$$ language plpgsql;
diff --git a/marketplace/supabase/seed.ts b/marketplace/supabase/seed.ts
new file mode 100644
index 0000000..87ff4dd
--- /dev/null
+++ b/marketplace/supabase/seed.ts
@@ -0,0 +1,289 @@
+/**
+ * Seed script for the Harness Kit marketplace Supabase database.
+ *
+ * Reads marketplace.json and plugin SKILL.md / README.md files from the repo,
+ * then upserts categories, tags, components, and join records.
+ *
+ * Usage:
+ * SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... npx tsx seed.ts
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { createClient } from "@supabase/supabase-js";
+
+// ---------------------------------------------------------------------------
+// Config
+// ---------------------------------------------------------------------------
+
+const SUPABASE_URL = process.env.SUPABASE_URL;
+const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
+ console.error(
+ "Missing required environment variables: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY"
+ );
+ process.exit(1);
+}
+
+const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
+
+// Paths are relative to this file (marketplace/supabase/seed.ts)
+const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
+const MARKETPLACE_JSON_PATH = path.join(
+ REPO_ROOT,
+ ".claude-plugin/marketplace.json"
+);
+const PLUGINS_DIR = path.join(REPO_ROOT, "plugins");
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+interface MarketplaceJson {
+ categories: { slug: string; name: string; display_order: number }[];
+ plugins: {
+ name: string;
+ source: string;
+ description: string;
+ version: string;
+ author: { name: string };
+ license: string;
+ category: string;
+ tags: string[];
+ }[];
+}
+
+function readFileOrNull(filePath: string): string | null {
+ try {
+ return fs.readFileSync(filePath, "utf-8");
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Reads and concatenates all SKILL.md files found under a plugin's skills/ directory.
+ * Most plugins have a single skill; harness-share has several.
+ */
+function readSkillMds(pluginName: string): string | null {
+ const skillsDir = path.join(PLUGINS_DIR, pluginName, "skills");
+ if (!fs.existsSync(skillsDir)) return null;
+
+ const skillDirs = fs
+ .readdirSync(skillsDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ .sort();
+
+ const parts: string[] = [];
+ for (const dir of skillDirs) {
+ const content = readFileOrNull(path.join(skillsDir, dir, "SKILL.md"));
+ if (content) parts.push(content);
+ }
+ return parts.length > 0 ? parts.join("\n\n---\n\n") : null;
+}
+
+/**
+ * Reads and concatenates all README.md files found under a plugin's skills/ directory.
+ */
+function readReadmeMds(pluginName: string): string | null {
+ const skillsDir = path.join(PLUGINS_DIR, pluginName, "skills");
+ if (!fs.existsSync(skillsDir)) return null;
+
+ const skillDirs = fs
+ .readdirSync(skillsDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ .sort();
+
+ const parts: string[] = [];
+ for (const dir of skillDirs) {
+ const content = readFileOrNull(path.join(skillsDir, dir, "README.md"));
+ if (content) parts.push(content);
+ }
+ return parts.length > 0 ? parts.join("\n\n---\n\n") : null;
+}
+
+// ---------------------------------------------------------------------------
+// Seed steps
+// ---------------------------------------------------------------------------
+
+async function seedCategories(
+ categories: MarketplaceJson["categories"]
+): Promise