diff --git a/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx b/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx index e9644a15..fe0ec234 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/miner-panel.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import { Link } from "@tanstack/react-router"; +import { Check, Copy } from "lucide-react"; import { KeyValueGrid, StatusPill, type Status } from "@/components/site/control-primitives"; import { McpVersionBadge } from "@/components/site/mcp-version-badge"; @@ -6,6 +8,12 @@ import { StatCard } from "@/components/site/primitives"; import { StateBoundary } from "@/components/site/state-views"; import { useApiResource } from "@/lib/api/use-api-resource"; import { useSession } from "@/lib/api/session"; +import { + buildMinerCommandActions, + type MinerCommandAction, + type MinerCommandState, +} from "@/lib/miner-commands"; +import { cn } from "@/lib/utils"; const LANE_TONE: Record = { pursue: "ready", @@ -51,210 +59,324 @@ export function MinerPanel() { data.nextActions.length === 0 && blockerCount === 0 && data.repoFit.length === 0; + const commandActions = buildMinerCommandActions({ + login: login || null, + repoFullName: data ? minerCommandRepoCandidate(data) : null, + }); return ( - - {dashboard.status === "error" ? ( -
- Miner dashboard is unavailable right now ({dashboard.error}). -
- ) : data ? ( -
-
- - - - -
+
+ + + {dashboard.status === "error" ? ( +
+ Miner dashboard is unavailable right now ({dashboard.error}). +
+ ) : data ? ( +
+
+ + + + +
-
-
-
-

Next actions

- - live - +
+
+
+

Next actions

+ + live + +
+
    + {data.nextActions.map((action, index) => ( +
  1. +
    + + {index + 1} + +
    +
    +

    + {stringField(action, "actionKind", "Next action")} +

    + + {stringField(action, "recommendation", "recommended")} + +
    +

    + {stringField( + action, + "rationale", + stringField(action, "why", "No rationale recorded."), + )} +

    +
    + {stringField(action, "repoFullName", "repo pending")} +
    +
    +
    +
  2. + ))} +
-
    - {data.nextActions.map((action, index) => ( -
  1. -
    - - {index + 1} - -
    -
    -

    - {stringField(action, "actionKind", "Next action")} -

    - - {stringField(action, "recommendation", "recommended")} - + +
    +
    +

    + Scoreability projections +

    +

    + Priority weight from the live decision pack. Not a payout estimate. +

    +
    + {data.projections.map((projection) => ( +
    +
    + {projection.label} + + {Math.round(projection.weight * 100)} + +
    +
    +
    -

    - {stringField( - action, - "rationale", - stringField(action, "why", "No rationale recorded."), - )} -

    -
    - {stringField(action, "repoFullName", "repo pending")} +
    + {projection.note}
    -
    -
  2. - ))} -
-
+ ))} +
+
-
+
+

MCP status

+
+ + + {data.status} + +
+ +
+
+ + +
-

- Scoreability projections -

+

Scoreability blockers

- Priority weight from the live decision pack. Not a payout estimate. + Each blocker links to how to clear it.{" "} + + See scoreability docs → +

-
- {data.projections.map((projection) => ( -
-
- {projection.label} - - {Math.round(projection.weight * 100)} - -
-
-
-
-
- {projection.note} +
+ {data.blockers.map((group) => ( +
+
+ {group.group}
+
    + {group.items.map((item) => ( +
  • +
    + {item.title} + + {item.code} + +
    +

    + {item.howToClear} +

    +
  • + ))} +
))}
-

MCP status

-
- - - {data.status} - -
- +

Repo fit

+

+ Where to spend time, and where not to. +

+ + + + + + + + + + {data.repoFit.map((repo, index) => { + const lane = repo.lane ?? "pursue"; + return ( + + + + + + ); + })} + +
RepoLaneWhy
+ {repo.repoFullName ?? "repo pending"} + + {lane} + + {repo.why ?? + repo.rationale ?? + repo.recommendation ?? + "No rationale recorded."} +
-
-
+ +
+ ) : null} + +
+ ); +} + +const COMMAND_STATE_TONE: Record = { + setup: "info", + ready: "ready", + needs_login: "warn", + needs_repo: "warn", +}; + +const COMMAND_STATE_LABEL: Record = { + setup: "setup", + ready: "ready", + needs_login: "login", + needs_repo: "repo", +}; + +function MinerCommandActions({ commands }: { commands: MinerCommandAction[] }) { + const [copiedId, setCopiedId] = useState(null); + const [failedId, setFailedId] = useState(null); + + const copyCommand = async (command: MinerCommandAction) => { + if (!command.copyable) return; + try { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + throw new Error("clipboard_unavailable"); + } + await navigator.clipboard.writeText(command.command); + setFailedId(null); + setCopiedId(command.id); + window.setTimeout( + () => setCopiedId((current) => (current === command.id ? null : current)), + 1600, + ); + } catch { + setCopiedId(null); + setFailedId(command.id); + } + }; -
-
-

Scoreability blockers

-

- Each blocker links to how to clear it.{" "} - +

+
+

MCP commands

+
+ Local snippets for the active miner state. +
+
+ local MCP +
+
+ {commands.map((command) => { + const copied = copiedId === command.id; + const failed = failedId === command.id; + return ( +
+
+ {command.label} + + {COMMAND_STATE_LABEL[command.state]} + + +
+ + {command.command} + +
+ {copied + ? "Copied" + : failed + ? "Copy failed" + : command.copyable + ? "Ready" + : "Needs context"}
+ ); + })} +
+
+ ); +} -
-

Repo fit

-

- Where to spend time, and where not to. -

- - - - - - - - - - {data.repoFit.map((repo, index) => { - const lane = repo.lane ?? "pursue"; - return ( - - - - - - ); - })} - -
RepoLaneWhy
- {repo.repoFullName ?? "repo pending"} - - {lane} - - {repo.why ?? - repo.rationale ?? - repo.recommendation ?? - "No rationale recorded."} -
-
- - - ) : null} -
+function minerCommandRepoCandidate(data: MinerDashboard): string | null { + const candidates = [...data.nextActions, ...data.repoFit].map((record) => + stringField(record, "repoFullName", ""), ); + return candidates.find((candidate) => /^[^/\s]+\/[^/\s]+$/.test(candidate)) ?? null; } function stringField(record: Record, key: string, fallback: string): string { diff --git a/apps/gittensory-ui/src/lib/miner-commands.ts b/apps/gittensory-ui/src/lib/miner-commands.ts new file mode 100644 index 00000000..1a52e809 --- /dev/null +++ b/apps/gittensory-ui/src/lib/miner-commands.ts @@ -0,0 +1,98 @@ +export type MinerCommandState = "ready" | "setup" | "needs_login" | "needs_repo"; + +export type MinerCommandAction = { + id: "install" | "status" | "doctor" | "plan" | "preflight" | "packet"; + label: string; + command: string; + state: MinerCommandState; + copyable: boolean; + boundary: "local-mcp"; +}; + +const MCP_PACKAGE = "@jsonbored/gittensory-mcp"; +const FALLBACK_LOGIN = "your-login"; +const FALLBACK_REPO = "owner/repo"; + +export function buildMinerCommandActions(input: { + login?: string | null; + repoFullName?: string | null; +}): MinerCommandAction[] { + const login = safeGitHubLogin(input.login) ?? FALLBACK_LOGIN; + const repoFullName = safeRepoFullName(input.repoFullName) ?? FALLBACK_REPO; + const hasLogin = login !== FALLBACK_LOGIN; + const hasRepo = repoFullName !== FALLBACK_REPO; + const actions: MinerCommandAction[] = [ + { + id: "install", + label: "Install", + command: `npm install -g ${MCP_PACKAGE}@latest`, + state: "setup", + copyable: true, + boundary: "local-mcp", + }, + { + id: "status", + label: "Status", + command: "gittensory-mcp status --json", + state: "ready", + copyable: true, + boundary: "local-mcp", + }, + { + id: "doctor", + label: "Doctor", + command: "gittensory-mcp doctor --json", + state: "ready", + copyable: true, + boundary: "local-mcp", + }, + { + id: "plan", + label: "Plan", + command: `gittensory-mcp agent plan --login ${login} --json`, + state: hasLogin ? "ready" : "needs_login", + copyable: hasLogin, + boundary: "local-mcp", + }, + { + id: "preflight", + label: "Preflight", + command: `gittensory-mcp preflight --login ${login} --repo ${repoFullName} --base origin/main --json`, + state: hasLogin && hasRepo ? "ready" : hasLogin ? "needs_repo" : "needs_login", + copyable: hasLogin && hasRepo, + boundary: "local-mcp", + }, + { + id: "packet", + label: "Packet", + command: `gittensory-mcp agent packet --login ${login} --repo ${repoFullName} --base origin/main --json`, + state: hasLogin && hasRepo ? "ready" : hasLogin ? "needs_repo" : "needs_login", + copyable: hasLogin && hasRepo, + boundary: "local-mcp", + }, + ]; + return actions.map((action) => ({ ...action, command: sanitizeMinerCommand(action.command) })); +} + +function safeGitHubLogin(value: string | null | undefined): string | null { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(text) ? text : null; +} + +function safeRepoFullName(value: string | null | undefined): string | null { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : null; +} + +function sanitizeMinerCommand(command: string): string { + return command + .replace(/(?:~\/|[A-Za-z]:\\)[^\s"'`,;)]+/g, "") + .replace( + /(^|[\s"'`=])\/(?:[^\s"'`,;)]+(?:\/[^\s"'`,;)]+)*)/g, + (_, prefix) => `${prefix}`, + ) + .replace( + /\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b(?:\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'`,;)]+))?/gi, + "[redacted]", + ); +} diff --git a/test/unit/miner-dashboard-commands.test.ts b/test/unit/miner-dashboard-commands.test.ts new file mode 100644 index 00000000..35ff31c4 --- /dev/null +++ b/test/unit/miner-dashboard-commands.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { buildMinerCommandActions } from "../../apps/gittensory-ui/src/lib/miner-commands"; + +describe("miner dashboard command actions", () => { + it("builds copyable miner commands for an authenticated repo context", () => { + const commands = buildMinerCommandActions({ + login: "JSONbored", + repoFullName: "JSONbored/gittensory", + }); + + expect(commands.map((command) => command.id)).toEqual([ + "install", + "status", + "doctor", + "plan", + "preflight", + "packet", + ]); + expect(commands.every((command) => command.boundary === "local-mcp")).toBe( + true, + ); + expect(commands.find((command) => command.id === "plan")).toMatchObject({ + command: "gittensory-mcp agent plan --login JSONbored --json", + state: "ready", + copyable: true, + }); + expect( + commands.find((command) => command.id === "preflight"), + ).toMatchObject({ + command: + "gittensory-mcp preflight --login JSONbored --repo JSONbored/gittensory --base origin/main --json", + state: "ready", + copyable: true, + }); + expect(commands.find((command) => command.id === "packet")).toMatchObject({ + command: + "gittensory-mcp agent packet --login JSONbored --repo JSONbored/gittensory --base origin/main --json", + state: "ready", + copyable: true, + }); + }); + + it("keeps repo-bound commands visible but not copyable when repo context is missing", () => { + const commands = buildMinerCommandActions({ login: "oktofeesh1" }); + + expect(commands.find((command) => command.id === "install")).toMatchObject({ + copyable: true, + state: "setup", + }); + expect(commands.find((command) => command.id === "plan")).toMatchObject({ + copyable: true, + state: "ready", + }); + expect( + commands.find((command) => command.id === "preflight"), + ).toMatchObject({ + command: expect.stringContaining("--repo owner/repo"), + copyable: false, + state: "needs_repo", + }); + expect(commands.find((command) => command.id === "packet")).toMatchObject({ + command: expect.stringContaining("--repo owner/repo"), + copyable: false, + state: "needs_repo", + }); + }); + + it("does not leak local paths or unsafe private terms into command snippets", () => { + const commands = buildMinerCommandActions({ + login: "/Users/private/hotkey", + repoFullName: "/home/private/wallet/repo", + }); + const serialized = JSON.stringify(commands); + + expect(commands.find((command) => command.id === "plan")).toMatchObject({ + command: "gittensory-mcp agent plan --login your-login --json", + copyable: false, + state: "needs_login", + }); + expect( + commands.find((command) => command.id === "preflight"), + ).toMatchObject({ + command: expect.stringContaining("--repo owner/repo"), + copyable: false, + state: "needs_login", + }); + expect(serialized).not.toMatch( + /\/Users|\/home|wallet|hotkey|raw trust|private reviewability/i, + ); + }); +});