diff --git a/__tests__/next-best-action-panel.test.tsx b/__tests__/next-best-action-panel.test.tsx
new file mode 100644
index 00000000..8a5d148e
--- /dev/null
+++ b/__tests__/next-best-action-panel.test.tsx
@@ -0,0 +1,54 @@
+/** @jest-environment jsdom */
+
+import React from "react";
+import { render, screen } from "@testing-library/react";
+
+import { NextBestActionPanel } from "@/components/next-best-action-panel";
+
+describe("NextBestActionPanel", () => {
+ test("shows copy install + report issue when install command + github repo exist", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument();
+
+ const report = screen.getByRole("link", { name: /report issue/i });
+ expect(report).toBeInTheDocument();
+ expect(report.getAttribute("href")).toMatch(/github\.com\/acme\/foo\/issues\/new/);
+ });
+
+ test("shows copy install when install command begins with comment lines", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument();
+ });
+
+ test("falls back to view repository when no install command", () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole("button", { name: /copy install command/i })).toBeNull();
+
+ const view = screen.getByRole("link", { name: /view on github|view repository/i });
+ expect(view.getAttribute("href")).toBe("https://github.com/acme/foo");
+ });
+});
diff --git a/__tests__/reportIssue.test.ts b/__tests__/reportIssue.test.ts
new file mode 100644
index 00000000..99cbac82
--- /dev/null
+++ b/__tests__/reportIssue.test.ts
@@ -0,0 +1,49 @@
+import { parseGitHubRepo } from "@/lib/reportIssue";
+
+describe("parseGitHubRepo", () => {
+ test("parses https://github.com/owner/repo", () => {
+ expect(parseGitHubRepo("https://github.com/acme/foo")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("parses https://www.github.com/owner/repo", () => {
+ expect(parseGitHubRepo("https://www.github.com/acme/foo")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("parses git+https://github.com/owner/repo.git", () => {
+ expect(parseGitHubRepo("git+https://github.com/acme/foo.git")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("parses ssh://git@github.com/owner/repo.git", () => {
+ expect(parseGitHubRepo("ssh://git@github.com/acme/foo.git")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("parses git+ssh://git@github.com/owner/repo.git", () => {
+ expect(parseGitHubRepo("git+ssh://git@github.com/acme/foo.git")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("parses git@github.com:owner/repo.git", () => {
+ expect(parseGitHubRepo("git@github.com:acme/foo.git")).toEqual({
+ owner: "acme",
+ repo: "foo",
+ });
+ });
+
+ test("returns null for non-github urls", () => {
+ expect(parseGitHubRepo("https://gitlab.com/acme/foo")).toBeNull();
+ });
+});
diff --git a/src/app/skills/[slug]/page.tsx b/src/app/skills/[slug]/page.tsx
index 9285abb4..925f6cfd 100644
--- a/src/app/skills/[slug]/page.tsx
+++ b/src/app/skills/[slug]/page.tsx
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { getSkills, getSkillBySlug } from "@/lib/data";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
-import { ReportIssueButton } from "@/components/report-issue-button";
+import { NextBestActionPanel } from "@/components/next-best-action-panel";
import { buildSkillIssueBodyTemplate } from "@/lib/reportIssue";
import Link from "next/link";
@@ -95,6 +95,16 @@ export default async function SkillPage({
+ {/* Next Best Action */}
+
+
{/* Install */}
@@ -129,13 +139,6 @@ export default async function SkillPage({
>
📄 GET /api/skill/{skill.slug}
-
- {/* Report issue actions */}
-
diff --git a/src/components/next-best-action-panel.tsx b/src/components/next-best-action-panel.tsx
new file mode 100644
index 00000000..7a66ad85
--- /dev/null
+++ b/src/components/next-best-action-panel.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { buildGitHubNewIssueUrl, parseGitHubRepo } from "@/lib/reportIssue";
+
+type Props = {
+ installCmd?: string | null;
+ repoUrl?: string | null;
+ issueTitle: string;
+ issueBody: string;
+};
+
+async function copyToClipboard(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ try {
+ const el = document.createElement("textarea");
+ el.value = text;
+ el.setAttribute("readonly", "");
+ el.style.position = "fixed";
+ el.style.top = "-1000px";
+ el.style.left = "-1000px";
+ document.body.appendChild(el);
+ el.focus();
+ el.select();
+ const ok = document.execCommand("copy");
+ document.body.removeChild(el);
+ return ok;
+ } catch {
+ return false;
+ }
+ }
+}
+
+function extractPrimaryInstallCmd(cmd: string | null | undefined): string | null {
+ const raw0 = (cmd ?? "").trim();
+ if (!raw0) return null;
+
+ // Accept both real newlines and literal "\\n" sequences.
+ const raw = raw0.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n");
+
+ // Support install_cmd strings that start with one or more comment lines.
+ // We copy the first non-empty, non-comment line.
+ const lines = raw.split(/\r?\n/);
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ if (trimmed.startsWith("#")) continue;
+ return trimmed;
+ }
+
+ return null;
+}
+
+export function NextBestActionPanel({
+ installCmd,
+ repoUrl,
+ issueTitle,
+ issueBody,
+}: Props) {
+ const repo = React.useMemo(() => parseGitHubRepo(repoUrl), [repoUrl]);
+
+ const issueUrl = React.useMemo(() => {
+ if (!repo) return null;
+ return buildGitHubNewIssueUrl({ repo, title: issueTitle, body: issueBody });
+ }, [repo, issueTitle, issueBody]);
+
+ const primaryInstallCmd = extractPrimaryInstallCmd(installCmd);
+
+ const hasInstall = Boolean(primaryInstallCmd);
+ const primaryHref = !hasInstall ? repoUrl?.trim() : null;
+ const primaryLabel = repo ? "View on GitHub ↗" : "View repository ↗";
+
+ const [copyLabel, setCopyLabel] = React.useState("Copy install command");
+
+ const onCopyInstall = async () => {
+ if (!primaryInstallCmd) return;
+ const ok = await copyToClipboard(primaryInstallCmd);
+ setCopyLabel(ok ? "Copied" : "Copy failed");
+ window.setTimeout(() => setCopyLabel("Copy install command"), 1500);
+ if (!ok) window.prompt("Copy the install command:", primaryInstallCmd);
+ };
+
+ return (
+
+
+ Next best action
+
+ Do the most useful thing in one click.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/report-issue-button.tsx b/src/components/report-issue-button.tsx
deleted file mode 100644
index 65796927..00000000
--- a/src/components/report-issue-button.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { Button } from "@/components/ui/button";
-import { buildGitHubNewIssueUrl, parseGitHubRepo } from "@/lib/reportIssue";
-
-type Props = {
- repoUrl?: string;
- issueTitle: string;
- issueBody: string;
-};
-
-async function copyToClipboard(text: string): Promise {
- try {
- await navigator.clipboard.writeText(text);
- return true;
- } catch {
- // Fallback: textarea + execCommand
- try {
- const el = document.createElement("textarea");
- el.value = text;
- el.setAttribute("readonly", "");
- el.style.position = "fixed";
- el.style.top = "-1000px";
- el.style.left = "-1000px";
- document.body.appendChild(el);
- el.focus();
- el.select();
- const ok = document.execCommand("copy");
- document.body.removeChild(el);
- return ok;
- } catch {
- return false;
- }
- }
-}
-
-export function ReportIssueButton({ repoUrl, issueTitle, issueBody }: Props) {
- const repo = React.useMemo(() => parseGitHubRepo(repoUrl), [repoUrl]);
-
- const issueUrl = React.useMemo(() => {
- if (!repo) return null;
- return buildGitHubNewIssueUrl({ repo, title: issueTitle, body: issueBody });
- }, [repo, issueTitle, issueBody]);
-
- const [templateLabel, setTemplateLabel] = React.useState("Copy issue template");
- const [linkLabel, setLinkLabel] = React.useState("Copy issue link");
-
- const onCopyTemplate = async () => {
- const ok = await copyToClipboard(issueBody);
- setTemplateLabel(ok ? "Copied" : "Copy failed");
- window.setTimeout(() => setTemplateLabel("Copy issue template"), 1500);
- if (!ok) window.prompt("Copy the issue template:", issueBody);
- };
-
- const onCopyLink = async () => {
- if (!issueUrl) return;
- const ok = await copyToClipboard(issueUrl);
- setLinkLabel(ok ? "Copied" : "Copy failed");
- window.setTimeout(() => setLinkLabel("Copy issue link"), 1500);
- if (!ok) window.prompt("Copy the issue link:", issueUrl);
- };
-
- return (
-
- {issueUrl ? (
-
- ) : null}
-
-
-
- {issueUrl ? (
-
- ) : null}
-
- );
-}
diff --git a/src/lib/reportIssue.ts b/src/lib/reportIssue.ts
index 1fd15de1..c583f7b2 100644
--- a/src/lib/reportIssue.ts
+++ b/src/lib/reportIssue.ts
@@ -19,8 +19,11 @@ export function parseGitHubRepo(
): GitHubRepoInfo | null {
if (!url) return null;
- const raw = url.trim();
- if (!raw) return null;
+ const raw0 = url.trim();
+ if (!raw0) return null;
+
+ // Support git+https://... and git+ssh://... forms
+ const raw = raw0.startsWith("git+") ? raw0.slice(4) : raw0;
// SSH form
const sshMatch = raw.match(
@@ -35,7 +38,7 @@ export function parseGitHubRepo(
// HTTPS/HTTP form
try {
const u = new URL(raw);
- if (u.hostname !== "github.com") return null;
+ if (u.hostname !== "github.com" && u.hostname !== "www.github.com") return null;
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length < 2) return null;