pilothub 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.local.example +19 -0
- package/.github/workflows/ci.yml +40 -0
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +138 -0
- package/DEPRECATIONS.md +7 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/biome.json +41 -0
- package/convex/_generated/api.d.ts +153 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/auth.config.ts +8 -0
- package/convex/auth.ts +19 -0
- package/convex/comments.ts +88 -0
- package/convex/crons.ts +34 -0
- package/convex/devSeed.ts +459 -0
- package/convex/devSeedExtra.ts +541 -0
- package/convex/downloads.ts +78 -0
- package/convex/githubBackups.ts +170 -0
- package/convex/githubBackupsNode.ts +183 -0
- package/convex/githubImport.ts +317 -0
- package/convex/githubSoulBackups.ts +170 -0
- package/convex/githubSoulBackupsNode.ts +186 -0
- package/convex/http.ts +194 -0
- package/convex/httpApi.handlers.test.ts +488 -0
- package/convex/httpApi.test.ts +70 -0
- package/convex/httpApi.ts +305 -0
- package/convex/httpApiV1.handlers.test.ts +584 -0
- package/convex/httpApiV1.ts +1172 -0
- package/convex/leaderboards.ts +39 -0
- package/convex/lib/access.ts +36 -0
- package/convex/lib/apiTokenAuth.ts +36 -0
- package/convex/lib/badges.ts +50 -0
- package/convex/lib/changelog.test.ts +34 -0
- package/convex/lib/changelog.ts +278 -0
- package/convex/lib/embeddings.ts +38 -0
- package/convex/lib/githubBackup.ts +443 -0
- package/convex/lib/githubImport.test.ts +247 -0
- package/convex/lib/githubImport.ts +425 -0
- package/convex/lib/githubSoulBackup.ts +443 -0
- package/convex/lib/leaderboards.ts +103 -0
- package/convex/lib/moderation.ts +42 -0
- package/convex/lib/public.ts +89 -0
- package/convex/lib/searchText.test.ts +46 -0
- package/convex/lib/searchText.ts +27 -0
- package/convex/lib/skillBackfill.test.ts +34 -0
- package/convex/lib/skillBackfill.ts +67 -0
- package/convex/lib/skillPublish.test.ts +28 -0
- package/convex/lib/skillPublish.ts +284 -0
- package/convex/lib/skillStats.ts +80 -0
- package/convex/lib/skills.test.ts +197 -0
- package/convex/lib/skills.ts +273 -0
- package/convex/lib/soulChangelog.ts +273 -0
- package/convex/lib/soulPublish.ts +236 -0
- package/convex/lib/tokens.test.ts +33 -0
- package/convex/lib/tokens.ts +51 -0
- package/convex/lib/webhooks.test.ts +91 -0
- package/convex/lib/webhooks.ts +112 -0
- package/convex/maintenance.test.ts +270 -0
- package/convex/maintenance.ts +840 -0
- package/convex/rateLimits.ts +50 -0
- package/convex/schema.ts +472 -0
- package/convex/search.test.ts +12 -0
- package/convex/search.ts +254 -0
- package/convex/seed.test.ts +37 -0
- package/convex/seed.ts +254 -0
- package/convex/seedSouls.ts +111 -0
- package/convex/skillStatEvents.ts +568 -0
- package/convex/skills.ts +1606 -0
- package/convex/soulComments.ts +88 -0
- package/convex/soulDownloads.ts +14 -0
- package/convex/soulStars.ts +71 -0
- package/convex/souls.ts +570 -0
- package/convex/stars.ts +108 -0
- package/convex/statsMaintenance.ts +205 -0
- package/convex/telemetry.ts +434 -0
- package/convex/tokens.ts +88 -0
- package/convex/tsconfig.json +7 -0
- package/convex/uploads.ts +20 -0
- package/convex/users.ts +122 -0
- package/convex/webhooks.ts +50 -0
- package/convex.json +3 -0
- package/docs/README.md +32 -0
- package/docs/api.md +51 -0
- package/docs/architecture.md +61 -0
- package/docs/auth.md +54 -0
- package/docs/cli.md +117 -0
- package/docs/deploy.md +78 -0
- package/docs/diffing.md +84 -0
- package/docs/github-import.md +171 -0
- package/docs/http-api.md +187 -0
- package/docs/manual-testing.md +64 -0
- package/docs/mintlify.md +43 -0
- package/docs/quickstart.md +120 -0
- package/docs/skill-format.md +58 -0
- package/docs/soul-format.md +37 -0
- package/docs/spec.md +177 -0
- package/docs/telemetry.md +91 -0
- package/docs/troubleshooting.md +49 -0
- package/docs/webhook.md +51 -0
- package/e2e/menu-smoke.pw.test.ts +49 -0
- package/e2e/pilothub.e2e.test.ts +494 -0
- package/e2e/search-exact.pw.test.ts +97 -0
- package/package.json +84 -0
- package/packages/pilothub/LICENSE +22 -0
- package/packages/pilothub/README.md +57 -0
- package/packages/pilothub/bin/pilothub.js +2 -0
- package/packages/pilothub/package.json +41 -0
- package/packages/pilothub/src/browserAuth.test.ts +96 -0
- package/packages/pilothub/src/browserAuth.ts +174 -0
- package/packages/pilothub/src/cli/buildInfo.ts +94 -0
- package/packages/pilothub/src/cli/commands/auth.ts +97 -0
- package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
- package/packages/pilothub/src/cli/commands/delete.ts +83 -0
- package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
- package/packages/pilothub/src/cli/commands/publish.ts +108 -0
- package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
- package/packages/pilothub/src/cli/commands/skills.ts +380 -0
- package/packages/pilothub/src/cli/commands/star.ts +46 -0
- package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
- package/packages/pilothub/src/cli/commands/sync.ts +200 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
- package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
- package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
- package/packages/pilothub/src/cli/helpStyle.ts +45 -0
- package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
- package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
- package/packages/pilothub/src/cli/registry.test.ts +63 -0
- package/packages/pilothub/src/cli/registry.ts +43 -0
- package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
- package/packages/pilothub/src/cli/scanSkills.ts +84 -0
- package/packages/pilothub/src/cli/slug.ts +16 -0
- package/packages/pilothub/src/cli/types.ts +12 -0
- package/packages/pilothub/src/cli/ui.ts +75 -0
- package/packages/pilothub/src/cli.ts +311 -0
- package/packages/pilothub/src/config.ts +36 -0
- package/packages/pilothub/src/discovery.test.ts +75 -0
- package/packages/pilothub/src/discovery.ts +19 -0
- package/packages/pilothub/src/http.test.ts +156 -0
- package/packages/pilothub/src/http.ts +301 -0
- package/packages/pilothub/src/schema/ark.ts +29 -0
- package/packages/pilothub/src/schema/index.ts +5 -0
- package/packages/pilothub/src/schema/routes.ts +22 -0
- package/packages/pilothub/src/schema/schemas.ts +260 -0
- package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
- package/packages/pilothub/src/schema/textFiles.ts +66 -0
- package/packages/pilothub/src/skills.test.ts +191 -0
- package/packages/pilothub/src/skills.ts +172 -0
- package/packages/pilothub/src/types.ts +10 -0
- package/packages/pilothub/tsconfig.json +14 -0
- package/packages/schema/README.md +3 -0
- package/packages/schema/dist/ark.d.ts +4 -0
- package/packages/schema/dist/ark.js +26 -0
- package/packages/schema/dist/ark.js.map +1 -0
- package/packages/schema/dist/index.d.ts +5 -0
- package/packages/schema/dist/index.js +5 -0
- package/packages/schema/dist/index.js.map +1 -0
- package/packages/schema/dist/routes.d.ts +21 -0
- package/packages/schema/dist/routes.js +22 -0
- package/packages/schema/dist/routes.js.map +1 -0
- package/packages/schema/dist/schemas.d.ts +297 -0
- package/packages/schema/dist/schemas.js +243 -0
- package/packages/schema/dist/schemas.js.map +1 -0
- package/packages/schema/dist/textFiles.d.ts +5 -0
- package/packages/schema/dist/textFiles.js +66 -0
- package/packages/schema/dist/textFiles.js.map +1 -0
- package/packages/schema/package.json +26 -0
- package/packages/schema/src/ark.ts +29 -0
- package/packages/schema/src/index.ts +5 -0
- package/packages/schema/src/routes.ts +22 -0
- package/packages/schema/src/schemas.test.ts +123 -0
- package/packages/schema/src/schemas.ts +287 -0
- package/packages/schema/src/textFiles.test.ts +23 -0
- package/packages/schema/src/textFiles.ts +66 -0
- package/packages/schema/tsconfig.json +15 -0
- package/pilothub +46 -0
- package/playwright.config.ts +33 -0
- package/public/.well-known/pilothub.json +6 -0
- package/public/api/v1/openapi.json +379 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/og.png +0 -0
- package/public/og.svg +98 -0
- package/public/pilot-logo.png +0 -0
- package/public/pilot-mark.png +0 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/check-peer-deps.ts +56 -0
- package/scripts/docs-list.ts +148 -0
- package/scripts/run-playwright-local.sh +14 -0
- package/server/og/fetchSkillOgMeta.ts +27 -0
- package/server/og/fetchSoulOgMeta.ts +27 -0
- package/server/og/ogAssets.ts +80 -0
- package/server/og/skillOgSvg.test.ts +59 -0
- package/server/og/skillOgSvg.ts +258 -0
- package/server/og/soulOgSvg.ts +209 -0
- package/server/routes/og/skill.png.ts +103 -0
- package/server/routes/og/soul.png.ts +111 -0
- package/src/__tests__/skill-detail-page.test.tsx +86 -0
- package/src/__tests__/skills-index.test.tsx +145 -0
- package/src/__tests__/upload.route.test.tsx +228 -0
- package/src/components/AppProviders.tsx +19 -0
- package/src/components/ClientOnly.tsx +18 -0
- package/src/components/Footer.tsx +29 -0
- package/src/components/Header.tsx +295 -0
- package/src/components/InstallSwitcher.tsx +53 -0
- package/src/components/SkillCard.tsx +36 -0
- package/src/components/SkillDetailPage.tsx +817 -0
- package/src/components/SkillDiffCard.tsx +485 -0
- package/src/components/SoulCard.tsx +19 -0
- package/src/components/SoulDetailPage.tsx +263 -0
- package/src/components/UserBootstrap.tsx +18 -0
- package/src/components/ui/dropdown-menu.tsx +67 -0
- package/src/components/ui/toggle-group.tsx +35 -0
- package/src/convex/client.ts +3 -0
- package/src/lib/badges.ts +29 -0
- package/src/lib/diffing.test.ts +163 -0
- package/src/lib/diffing.ts +106 -0
- package/src/lib/gravatar.test.ts +9 -0
- package/src/lib/gravatar.ts +158 -0
- package/src/lib/og.test.ts +142 -0
- package/src/lib/og.ts +156 -0
- package/src/lib/publicUser.ts +39 -0
- package/src/lib/roles.ts +19 -0
- package/src/lib/site.test.ts +130 -0
- package/src/lib/site.ts +84 -0
- package/src/lib/theme-transition.test.ts +134 -0
- package/src/lib/theme-transition.ts +134 -0
- package/src/lib/theme.test.tsx +88 -0
- package/src/lib/theme.ts +43 -0
- package/src/lib/uploadFiles.jsdom.test.ts +33 -0
- package/src/lib/uploadFiles.test.ts +123 -0
- package/src/lib/uploadFiles.ts +245 -0
- package/src/lib/uploadUtils.test.ts +78 -0
- package/src/lib/uploadUtils.ts +93 -0
- package/src/lib/useAuthStatus.ts +12 -0
- package/src/lib/utils.test.ts +9 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +345 -0
- package/src/router.tsx +17 -0
- package/src/routes/$owner/$slug.tsx +55 -0
- package/src/routes/__root.tsx +136 -0
- package/src/routes/admin.tsx +11 -0
- package/src/routes/cli/auth.tsx +168 -0
- package/src/routes/dashboard.tsx +97 -0
- package/src/routes/import.tsx +415 -0
- package/src/routes/index.tsx +252 -0
- package/src/routes/management.tsx +529 -0
- package/src/routes/settings.tsx +203 -0
- package/src/routes/skills/index.tsx +422 -0
- package/src/routes/souls/$slug.tsx +55 -0
- package/src/routes/souls/index.tsx +243 -0
- package/src/routes/stars.tsx +68 -0
- package/src/routes/u/$handle.tsx +307 -0
- package/src/routes/upload/utils.ts +81 -0
- package/src/routes/upload.tsx +499 -0
- package/src/styles.css +2718 -0
- package/tsconfig.json +24 -0
- package/tsconfig.oxlint.json +16 -0
- package/vercel.json +8 -0
- package/vite.config.ts +48 -0
- package/vitest.config.ts +47 -0
- package/vitest.e2e.config.ts +11 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import { Link, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { useAction, useMutation, useQuery } from 'convex/react'
|
|
3
|
+
import type { PilotbotSkillMetadata, SkillInstallSpec } from 'pilothub-schema'
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
|
+
import ReactMarkdown from 'react-markdown'
|
|
6
|
+
import remarkGfm from 'remark-gfm'
|
|
7
|
+
import { api } from '../../convex/_generated/api'
|
|
8
|
+
import type { Doc, Id } from '../../convex/_generated/dataModel'
|
|
9
|
+
import { getSkillBadges } from '../lib/badges'
|
|
10
|
+
import type { PublicSkill, PublicUser } from '../lib/publicUser'
|
|
11
|
+
import { canManageSkill, isModerator } from '../lib/roles'
|
|
12
|
+
import { useAuthStatus } from '../lib/useAuthStatus'
|
|
13
|
+
import { SkillDiffCard } from './SkillDiffCard'
|
|
14
|
+
|
|
15
|
+
type SkillDetailPageProps = {
|
|
16
|
+
slug: string
|
|
17
|
+
canonicalOwner?: string
|
|
18
|
+
redirectToCanonical?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SkillBySlugResult = {
|
|
22
|
+
skill: PublicSkill
|
|
23
|
+
latestVersion: Doc<'skillVersions'> | null
|
|
24
|
+
owner: PublicUser | null
|
|
25
|
+
forkOf: {
|
|
26
|
+
kind: 'fork' | 'duplicate'
|
|
27
|
+
version: string | null
|
|
28
|
+
skill: { slug: string; displayName: string }
|
|
29
|
+
owner: { handle: string | null; userId: Id<'users'> | null }
|
|
30
|
+
} | null
|
|
31
|
+
canonical: {
|
|
32
|
+
skill: { slug: string; displayName: string }
|
|
33
|
+
owner: { handle: string | null; userId: Id<'users'> | null }
|
|
34
|
+
} | null
|
|
35
|
+
} | null
|
|
36
|
+
|
|
37
|
+
type SkillFile = Doc<'skillVersions'>['files'][number]
|
|
38
|
+
|
|
39
|
+
export function SkillDetailPage({
|
|
40
|
+
slug,
|
|
41
|
+
canonicalOwner,
|
|
42
|
+
redirectToCanonical,
|
|
43
|
+
}: SkillDetailPageProps) {
|
|
44
|
+
const navigate = useNavigate()
|
|
45
|
+
const { isAuthenticated, me } = useAuthStatus()
|
|
46
|
+
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
|
|
47
|
+
const toggleStar = useMutation(api.stars.toggle)
|
|
48
|
+
const reportSkill = useMutation(api.skills.report)
|
|
49
|
+
const addComment = useMutation(api.comments.add)
|
|
50
|
+
const removeComment = useMutation(api.comments.remove)
|
|
51
|
+
const updateTags = useMutation(api.skills.updateTags)
|
|
52
|
+
const getReadme = useAction(api.skills.getReadme)
|
|
53
|
+
const [readme, setReadme] = useState<string | null>(null)
|
|
54
|
+
const [readmeError, setReadmeError] = useState<string | null>(null)
|
|
55
|
+
const [comment, setComment] = useState('')
|
|
56
|
+
const [tagName, setTagName] = useState('latest')
|
|
57
|
+
const [tagVersionId, setTagVersionId] = useState<Id<'skillVersions'> | ''>('')
|
|
58
|
+
const [activeTab, setActiveTab] = useState<'files' | 'compare' | 'versions'>('files')
|
|
59
|
+
|
|
60
|
+
const isLoadingSkill = result === undefined
|
|
61
|
+
const skill = result?.skill
|
|
62
|
+
const owner = result?.owner
|
|
63
|
+
const latestVersion = result?.latestVersion
|
|
64
|
+
const versions = useQuery(
|
|
65
|
+
api.skills.listVersions,
|
|
66
|
+
skill ? { skillId: skill._id, limit: 50 } : 'skip',
|
|
67
|
+
) as Doc<'skillVersions'>[] | undefined
|
|
68
|
+
const diffVersions = useQuery(
|
|
69
|
+
api.skills.listVersions,
|
|
70
|
+
skill ? { skillId: skill._id, limit: 200 } : 'skip',
|
|
71
|
+
) as Doc<'skillVersions'>[] | undefined
|
|
72
|
+
|
|
73
|
+
const isStarred = useQuery(
|
|
74
|
+
api.stars.isStarred,
|
|
75
|
+
isAuthenticated && skill ? { skillId: skill._id } : 'skip',
|
|
76
|
+
)
|
|
77
|
+
const comments = useQuery(
|
|
78
|
+
api.comments.listBySkill,
|
|
79
|
+
skill ? { skillId: skill._id, limit: 50 } : 'skip',
|
|
80
|
+
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
|
|
81
|
+
|
|
82
|
+
const canManage = canManageSkill(me, skill)
|
|
83
|
+
const isStaff = isModerator(me)
|
|
84
|
+
|
|
85
|
+
const ownerHandle = owner?.handle ?? owner?.name ?? null
|
|
86
|
+
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
|
|
87
|
+
const wantsCanonicalRedirect = Boolean(
|
|
88
|
+
ownerParam &&
|
|
89
|
+
(redirectToCanonical ||
|
|
90
|
+
(typeof canonicalOwner === 'string' && canonicalOwner && canonicalOwner !== ownerParam)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const forkOf = result?.forkOf ?? null
|
|
94
|
+
const canonical = result?.canonical ?? null
|
|
95
|
+
const forkOfLabel = forkOf?.kind === 'duplicate' ? 'duplicate of' : 'fork of'
|
|
96
|
+
const forkOfOwnerHandle = forkOf?.owner?.handle ?? null
|
|
97
|
+
const forkOfOwnerId = forkOf?.owner?.userId ?? null
|
|
98
|
+
const canonicalOwnerHandle = canonical?.owner?.handle ?? null
|
|
99
|
+
const canonicalOwnerId = canonical?.owner?.userId ?? null
|
|
100
|
+
const forkOfHref = forkOf?.skill?.slug
|
|
101
|
+
? buildSkillHref(forkOfOwnerHandle, forkOfOwnerId, forkOf.skill.slug)
|
|
102
|
+
: null
|
|
103
|
+
const canonicalHref =
|
|
104
|
+
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
|
|
105
|
+
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
|
|
106
|
+
: null
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!wantsCanonicalRedirect || !ownerParam) return
|
|
110
|
+
void navigate({
|
|
111
|
+
to: '/$owner/$slug',
|
|
112
|
+
params: { owner: ownerParam, slug },
|
|
113
|
+
replace: true,
|
|
114
|
+
})
|
|
115
|
+
}, [navigate, ownerParam, slug, wantsCanonicalRedirect])
|
|
116
|
+
|
|
117
|
+
const versionById = new Map<Id<'skillVersions'>, Doc<'skillVersions'>>(
|
|
118
|
+
(diffVersions ?? versions ?? []).map((version) => [version._id, version]),
|
|
119
|
+
)
|
|
120
|
+
const pilotbot = (latestVersion?.parsed as { pilotbot?: PilotbotSkillMetadata } | undefined)?.pilotbot
|
|
121
|
+
const osLabels = useMemo(() => formatOsList(pilotbot?.os), [pilotbot?.os])
|
|
122
|
+
const requirements = pilotbot?.requires
|
|
123
|
+
const installSpecs = pilotbot?.install ?? []
|
|
124
|
+
const nixPlugin = pilotbot?.nix?.plugin
|
|
125
|
+
const nixSystems = pilotbot?.nix?.systems ?? []
|
|
126
|
+
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null
|
|
127
|
+
const configRequirements = pilotbot?.config
|
|
128
|
+
const configExample = configRequirements?.example
|
|
129
|
+
? formatConfigSnippet(configRequirements.example)
|
|
130
|
+
: null
|
|
131
|
+
const cliHelp = pilotbot?.cliHelp
|
|
132
|
+
const hasRuntimeRequirements = Boolean(
|
|
133
|
+
pilotbot?.emoji ||
|
|
134
|
+
osLabels.length ||
|
|
135
|
+
requirements?.bins?.length ||
|
|
136
|
+
requirements?.anyBins?.length ||
|
|
137
|
+
requirements?.env?.length ||
|
|
138
|
+
requirements?.config?.length ||
|
|
139
|
+
pilotbot?.primaryEnv,
|
|
140
|
+
)
|
|
141
|
+
const hasInstallSpecs = installSpecs.length > 0
|
|
142
|
+
const hasPluginBundle = Boolean(nixSnippet || configRequirements || cliHelp)
|
|
143
|
+
const readmeContent = useMemo(() => {
|
|
144
|
+
if (!readme) return null
|
|
145
|
+
return stripFrontmatter(readme)
|
|
146
|
+
}, [readme])
|
|
147
|
+
const latestFiles: SkillFile[] = latestVersion?.files ?? []
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!latestVersion) return
|
|
151
|
+
setReadme(null)
|
|
152
|
+
setReadmeError(null)
|
|
153
|
+
let cancelled = false
|
|
154
|
+
void getReadme({ versionId: latestVersion._id })
|
|
155
|
+
.then((data) => {
|
|
156
|
+
if (cancelled) return
|
|
157
|
+
setReadme(data.text)
|
|
158
|
+
})
|
|
159
|
+
.catch((error) => {
|
|
160
|
+
if (cancelled) return
|
|
161
|
+
setReadmeError(error instanceof Error ? error.message : 'Failed to load README')
|
|
162
|
+
setReadme(null)
|
|
163
|
+
})
|
|
164
|
+
return () => {
|
|
165
|
+
cancelled = true
|
|
166
|
+
}
|
|
167
|
+
}, [latestVersion, getReadme])
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!tagVersionId && latestVersion) {
|
|
171
|
+
setTagVersionId(latestVersion._id)
|
|
172
|
+
}
|
|
173
|
+
}, [latestVersion, tagVersionId])
|
|
174
|
+
|
|
175
|
+
if (isLoadingSkill || wantsCanonicalRedirect) {
|
|
176
|
+
return (
|
|
177
|
+
<main className="section">
|
|
178
|
+
<div className="card">
|
|
179
|
+
<div className="loading-indicator">Loading skill…</div>
|
|
180
|
+
</div>
|
|
181
|
+
</main>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (result === null || !skill) {
|
|
186
|
+
return (
|
|
187
|
+
<main className="section">
|
|
188
|
+
<div className="card">Skill not found.</div>
|
|
189
|
+
</main>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<'skillVersions'>]>
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<main className="section">
|
|
197
|
+
<div className="skill-detail-stack">
|
|
198
|
+
<div className="card skill-hero">
|
|
199
|
+
<div className={`skill-hero-top${hasPluginBundle ? ' has-plugin' : ''}`}>
|
|
200
|
+
<div className="skill-hero-header">
|
|
201
|
+
<div className="skill-hero-title">
|
|
202
|
+
<div className="skill-hero-title-row">
|
|
203
|
+
<h1 className="section-title" style={{ margin: 0 }}>
|
|
204
|
+
{skill.displayName}
|
|
205
|
+
</h1>
|
|
206
|
+
{nixPlugin ? <span className="tag tag-accent">Plugin bundle (nix)</span> : null}
|
|
207
|
+
</div>
|
|
208
|
+
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
|
|
209
|
+
|
|
210
|
+
{nixPlugin ? (
|
|
211
|
+
<div className="skill-hero-note">
|
|
212
|
+
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
|
213
|
+
</div>
|
|
214
|
+
) : null}
|
|
215
|
+
<div className="stat">
|
|
216
|
+
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
|
217
|
+
{skill.stats.installsCurrent ?? 0} current · {skill.stats.installsAllTime ?? 0}{' '}
|
|
218
|
+
all-time
|
|
219
|
+
</div>
|
|
220
|
+
{owner?.handle ? (
|
|
221
|
+
<div className="stat">
|
|
222
|
+
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
|
|
223
|
+
</div>
|
|
224
|
+
) : null}
|
|
225
|
+
{forkOf && forkOfHref ? (
|
|
226
|
+
<div className="stat">
|
|
227
|
+
{forkOfLabel}{' '}
|
|
228
|
+
<a href={forkOfHref}>
|
|
229
|
+
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ''}
|
|
230
|
+
{forkOf.skill.slug}
|
|
231
|
+
</a>
|
|
232
|
+
{forkOf.version ? ` (based on ${forkOf.version})` : null}
|
|
233
|
+
</div>
|
|
234
|
+
) : null}
|
|
235
|
+
{canonicalHref ? (
|
|
236
|
+
<div className="stat">
|
|
237
|
+
canonical:{' '}
|
|
238
|
+
<a href={canonicalHref}>
|
|
239
|
+
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ''}
|
|
240
|
+
{canonical?.skill?.slug}
|
|
241
|
+
</a>
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
{getSkillBadges(skill).map((badge) => (
|
|
245
|
+
<div key={badge} className="tag">
|
|
246
|
+
{badge}
|
|
247
|
+
</div>
|
|
248
|
+
))}
|
|
249
|
+
<div className="skill-actions">
|
|
250
|
+
{isAuthenticated ? (
|
|
251
|
+
<button
|
|
252
|
+
className={`star-toggle${isStarred ? ' is-active' : ''}`}
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={() => void toggleStar({ skillId: skill._id })}
|
|
255
|
+
aria-label={isStarred ? 'Unstar skill' : 'Star skill'}
|
|
256
|
+
>
|
|
257
|
+
<span aria-hidden="true">★</span>
|
|
258
|
+
</button>
|
|
259
|
+
) : null}
|
|
260
|
+
{isAuthenticated ? (
|
|
261
|
+
<button
|
|
262
|
+
className="btn btn-ghost"
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={async () => {
|
|
265
|
+
const reason = window.prompt('Report this skill? Add a reason if you want.')
|
|
266
|
+
if (reason === null) return
|
|
267
|
+
try {
|
|
268
|
+
const result = await reportSkill({
|
|
269
|
+
skillId: skill._id,
|
|
270
|
+
reason: reason.trim() || undefined,
|
|
271
|
+
})
|
|
272
|
+
if (result.reported) {
|
|
273
|
+
window.alert('Thanks — your report has been submitted.')
|
|
274
|
+
} else {
|
|
275
|
+
window.alert('You have already reported this skill.')
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error('Failed to report skill', error)
|
|
279
|
+
window.alert('Unable to submit report. Please try again.')
|
|
280
|
+
}
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
Report
|
|
284
|
+
</button>
|
|
285
|
+
) : null}
|
|
286
|
+
{isStaff ? (
|
|
287
|
+
<Link className="btn" to="/management" search={{ skill: skill.slug }}>
|
|
288
|
+
Manage
|
|
289
|
+
</Link>
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="skill-hero-cta">
|
|
294
|
+
<div className="skill-version-pill">
|
|
295
|
+
<span className="skill-version-label">Current version</span>
|
|
296
|
+
<strong>v{latestVersion?.version ?? '—'}</strong>
|
|
297
|
+
</div>
|
|
298
|
+
{!nixPlugin ? (
|
|
299
|
+
<a
|
|
300
|
+
className="btn btn-primary"
|
|
301
|
+
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}`}
|
|
302
|
+
>
|
|
303
|
+
Download zip
|
|
304
|
+
</a>
|
|
305
|
+
) : null}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
{hasPluginBundle ? (
|
|
309
|
+
<div className="skill-panel bundle-card">
|
|
310
|
+
<div className="bundle-header">
|
|
311
|
+
<div className="bundle-title">Plugin bundle (nix)</div>
|
|
312
|
+
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div className="bundle-includes">
|
|
315
|
+
<span>SKILL.md</span>
|
|
316
|
+
<span>CLI</span>
|
|
317
|
+
<span>Config</span>
|
|
318
|
+
</div>
|
|
319
|
+
{configRequirements ? (
|
|
320
|
+
<div className="bundle-section">
|
|
321
|
+
<div className="bundle-section-title">Config requirements</div>
|
|
322
|
+
<div className="bundle-meta">
|
|
323
|
+
{configRequirements.requiredEnv?.length ? (
|
|
324
|
+
<div className="stat">
|
|
325
|
+
<strong>Required env</strong>
|
|
326
|
+
<span>{configRequirements.requiredEnv.join(', ')}</span>
|
|
327
|
+
</div>
|
|
328
|
+
) : null}
|
|
329
|
+
{configRequirements.stateDirs?.length ? (
|
|
330
|
+
<div className="stat">
|
|
331
|
+
<strong>State dirs</strong>
|
|
332
|
+
<span>{configRequirements.stateDirs.join(', ')}</span>
|
|
333
|
+
</div>
|
|
334
|
+
) : null}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
) : null}
|
|
338
|
+
{cliHelp ? (
|
|
339
|
+
<details className="bundle-section bundle-details">
|
|
340
|
+
<summary>CLI help (from plugin)</summary>
|
|
341
|
+
<pre className="hero-install-code mono">{cliHelp}</pre>
|
|
342
|
+
</details>
|
|
343
|
+
) : null}
|
|
344
|
+
</div>
|
|
345
|
+
) : null}
|
|
346
|
+
</div>
|
|
347
|
+
<div className="skill-tag-row">
|
|
348
|
+
{tagEntries.length === 0 ? (
|
|
349
|
+
<span className="section-subtitle" style={{ margin: 0 }}>
|
|
350
|
+
No tags yet.
|
|
351
|
+
</span>
|
|
352
|
+
) : (
|
|
353
|
+
tagEntries.map(([tag, versionId]) => (
|
|
354
|
+
<span key={tag} className="tag">
|
|
355
|
+
{tag}
|
|
356
|
+
<span className="tag-meta">
|
|
357
|
+
v{versionById.get(versionId)?.version ?? versionId}
|
|
358
|
+
</span>
|
|
359
|
+
</span>
|
|
360
|
+
))
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
{canManage ? (
|
|
364
|
+
<form
|
|
365
|
+
onSubmit={(event) => {
|
|
366
|
+
event.preventDefault()
|
|
367
|
+
if (!tagName.trim() || !tagVersionId) return
|
|
368
|
+
void updateTags({
|
|
369
|
+
skillId: skill._id,
|
|
370
|
+
tags: [{ tag: tagName.trim(), versionId: tagVersionId }],
|
|
371
|
+
})
|
|
372
|
+
}}
|
|
373
|
+
className="tag-form"
|
|
374
|
+
>
|
|
375
|
+
<input
|
|
376
|
+
className="search-input"
|
|
377
|
+
value={tagName}
|
|
378
|
+
onChange={(event) => setTagName(event.target.value)}
|
|
379
|
+
placeholder="latest"
|
|
380
|
+
/>
|
|
381
|
+
<select
|
|
382
|
+
className="search-input"
|
|
383
|
+
value={tagVersionId ?? ''}
|
|
384
|
+
onChange={(event) => setTagVersionId(event.target.value as Id<'skillVersions'>)}
|
|
385
|
+
>
|
|
386
|
+
{(diffVersions ?? []).map((version) => (
|
|
387
|
+
<option key={version._id} value={version._id}>
|
|
388
|
+
v{version.version}
|
|
389
|
+
</option>
|
|
390
|
+
))}
|
|
391
|
+
</select>
|
|
392
|
+
<button className="btn" type="submit">
|
|
393
|
+
Update tag
|
|
394
|
+
</button>
|
|
395
|
+
</form>
|
|
396
|
+
) : null}
|
|
397
|
+
{hasRuntimeRequirements || hasInstallSpecs ? (
|
|
398
|
+
<div className="skill-hero-content">
|
|
399
|
+
<div className="skill-hero-panels">
|
|
400
|
+
{hasRuntimeRequirements ? (
|
|
401
|
+
<div className="skill-panel">
|
|
402
|
+
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
|
403
|
+
Runtime requirements
|
|
404
|
+
</h3>
|
|
405
|
+
<div className="skill-panel-body">
|
|
406
|
+
{pilotbot?.emoji ? <div className="tag">{pilotbot.emoji} Pilotbot</div> : null}
|
|
407
|
+
{osLabels.length ? (
|
|
408
|
+
<div className="stat">
|
|
409
|
+
<strong>OS</strong>
|
|
410
|
+
<span>{osLabels.join(' · ')}</span>
|
|
411
|
+
</div>
|
|
412
|
+
) : null}
|
|
413
|
+
{requirements?.bins?.length ? (
|
|
414
|
+
<div className="stat">
|
|
415
|
+
<strong>Bins</strong>
|
|
416
|
+
<span>{requirements.bins.join(', ')}</span>
|
|
417
|
+
</div>
|
|
418
|
+
) : null}
|
|
419
|
+
{requirements?.anyBins?.length ? (
|
|
420
|
+
<div className="stat">
|
|
421
|
+
<strong>Any bin</strong>
|
|
422
|
+
<span>{requirements.anyBins.join(', ')}</span>
|
|
423
|
+
</div>
|
|
424
|
+
) : null}
|
|
425
|
+
{requirements?.env?.length ? (
|
|
426
|
+
<div className="stat">
|
|
427
|
+
<strong>Env</strong>
|
|
428
|
+
<span>{requirements.env.join(', ')}</span>
|
|
429
|
+
</div>
|
|
430
|
+
) : null}
|
|
431
|
+
{requirements?.config?.length ? (
|
|
432
|
+
<div className="stat">
|
|
433
|
+
<strong>Config</strong>
|
|
434
|
+
<span>{requirements.config.join(', ')}</span>
|
|
435
|
+
</div>
|
|
436
|
+
) : null}
|
|
437
|
+
{pilotbot?.primaryEnv ? (
|
|
438
|
+
<div className="stat">
|
|
439
|
+
<strong>Primary env</strong>
|
|
440
|
+
<span>{pilotbot.primaryEnv}</span>
|
|
441
|
+
</div>
|
|
442
|
+
) : null}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
) : null}
|
|
446
|
+
{hasInstallSpecs ? (
|
|
447
|
+
<div className="skill-panel">
|
|
448
|
+
<h3 className="section-title" style={{ fontSize: '1rem', margin: 0 }}>
|
|
449
|
+
Install
|
|
450
|
+
</h3>
|
|
451
|
+
<div className="skill-panel-body">
|
|
452
|
+
{installSpecs.map((spec, index) => {
|
|
453
|
+
const command = formatInstallCommand(spec)
|
|
454
|
+
return (
|
|
455
|
+
<div key={`${spec.id ?? spec.kind}-${index}`} className="stat">
|
|
456
|
+
<div>
|
|
457
|
+
<strong>{spec.label ?? formatInstallLabel(spec)}</strong>
|
|
458
|
+
{spec.bins?.length ? (
|
|
459
|
+
<div style={{ color: 'var(--ink-soft)', fontSize: '0.85rem' }}>
|
|
460
|
+
Bins: {spec.bins.join(', ')}
|
|
461
|
+
</div>
|
|
462
|
+
) : null}
|
|
463
|
+
{command ? <code>{command}</code> : null}
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
)
|
|
467
|
+
})}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
) : null}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
) : null}
|
|
474
|
+
</div>
|
|
475
|
+
{nixSnippet ? (
|
|
476
|
+
<div className="card">
|
|
477
|
+
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
478
|
+
Install via Nix
|
|
479
|
+
</h2>
|
|
480
|
+
<p className="section-subtitle" style={{ margin: 0 }}>
|
|
481
|
+
{nixSystems.length ? `Systems: ${nixSystems.join(', ')}` : 'nix-pilotbot'}
|
|
482
|
+
</p>
|
|
483
|
+
<pre className="hero-install-code" style={{ marginTop: 12 }}>
|
|
484
|
+
{nixSnippet}
|
|
485
|
+
</pre>
|
|
486
|
+
</div>
|
|
487
|
+
) : null}
|
|
488
|
+
{configExample ? (
|
|
489
|
+
<div className="card">
|
|
490
|
+
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
491
|
+
Config example
|
|
492
|
+
</h2>
|
|
493
|
+
<p className="section-subtitle" style={{ margin: 0 }}>
|
|
494
|
+
Starter config for this plugin bundle.
|
|
495
|
+
</p>
|
|
496
|
+
<pre className="hero-install-code" style={{ marginTop: 12 }}>
|
|
497
|
+
{configExample}
|
|
498
|
+
</pre>
|
|
499
|
+
</div>
|
|
500
|
+
) : null}
|
|
501
|
+
<div className="card tab-card">
|
|
502
|
+
<div className="tab-header">
|
|
503
|
+
<button
|
|
504
|
+
className={`tab-button${activeTab === 'files' ? ' is-active' : ''}`}
|
|
505
|
+
type="button"
|
|
506
|
+
onClick={() => setActiveTab('files')}
|
|
507
|
+
>
|
|
508
|
+
Files
|
|
509
|
+
</button>
|
|
510
|
+
<button
|
|
511
|
+
className={`tab-button${activeTab === 'compare' ? ' is-active' : ''}`}
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => setActiveTab('compare')}
|
|
514
|
+
>
|
|
515
|
+
Compare
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
className={`tab-button${activeTab === 'versions' ? ' is-active' : ''}`}
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={() => setActiveTab('versions')}
|
|
521
|
+
>
|
|
522
|
+
Versions
|
|
523
|
+
</button>
|
|
524
|
+
</div>
|
|
525
|
+
{activeTab === 'files' ? (
|
|
526
|
+
<div className="tab-body">
|
|
527
|
+
<div>
|
|
528
|
+
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
529
|
+
SKILL.md
|
|
530
|
+
</h2>
|
|
531
|
+
<div className="markdown">
|
|
532
|
+
{readmeContent ? (
|
|
533
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
|
534
|
+
) : readmeError ? (
|
|
535
|
+
<div className="stat">Failed to load SKILL.md: {readmeError}</div>
|
|
536
|
+
) : (
|
|
537
|
+
<div>Loading…</div>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
<div className="file-list">
|
|
542
|
+
<div className="file-list-header">
|
|
543
|
+
<h3 className="section-title" style={{ fontSize: '1.05rem', margin: 0 }}>
|
|
544
|
+
Files
|
|
545
|
+
</h3>
|
|
546
|
+
<span className="section-subtitle" style={{ margin: 0 }}>
|
|
547
|
+
{latestFiles.length} total
|
|
548
|
+
</span>
|
|
549
|
+
</div>
|
|
550
|
+
<div className="file-list-body">
|
|
551
|
+
{latestFiles.length === 0 ? (
|
|
552
|
+
<div className="stat">No files available.</div>
|
|
553
|
+
) : (
|
|
554
|
+
latestFiles.map((file) => (
|
|
555
|
+
<div key={file.path} className="file-row">
|
|
556
|
+
<span className="file-path">{file.path}</span>
|
|
557
|
+
<span className="file-meta">{formatBytes(file.size)}</span>
|
|
558
|
+
</div>
|
|
559
|
+
))
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
) : null}
|
|
565
|
+
{activeTab === 'compare' && skill ? (
|
|
566
|
+
<div className="tab-body">
|
|
567
|
+
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
|
568
|
+
</div>
|
|
569
|
+
) : null}
|
|
570
|
+
{activeTab === 'versions' ? (
|
|
571
|
+
<div className="tab-body">
|
|
572
|
+
<div>
|
|
573
|
+
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
574
|
+
Versions
|
|
575
|
+
</h2>
|
|
576
|
+
<p className="section-subtitle" style={{ margin: 0 }}>
|
|
577
|
+
{nixPlugin
|
|
578
|
+
? 'Review release history and changelog.'
|
|
579
|
+
: 'Download older releases or scan the changelog.'}
|
|
580
|
+
</p>
|
|
581
|
+
</div>
|
|
582
|
+
<div className="version-scroll">
|
|
583
|
+
<div className="version-list">
|
|
584
|
+
{(versions ?? []).map((version) => (
|
|
585
|
+
<div key={version._id} className="version-row">
|
|
586
|
+
<div className="version-info">
|
|
587
|
+
<div>
|
|
588
|
+
v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
|
|
589
|
+
{version.changelogSource === 'auto' ? (
|
|
590
|
+
<span style={{ color: 'var(--ink-soft)' }}> · auto</span>
|
|
591
|
+
) : null}
|
|
592
|
+
</div>
|
|
593
|
+
<div style={{ color: '#5c554e', whiteSpace: 'pre-wrap' }}>
|
|
594
|
+
{version.changelog}
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
{!nixPlugin ? (
|
|
598
|
+
<div className="version-actions">
|
|
599
|
+
<a
|
|
600
|
+
className="btn version-zip"
|
|
601
|
+
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}&version=${version.version}`}
|
|
602
|
+
>
|
|
603
|
+
Zip
|
|
604
|
+
</a>
|
|
605
|
+
</div>
|
|
606
|
+
) : null}
|
|
607
|
+
</div>
|
|
608
|
+
))}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
) : null}
|
|
613
|
+
</div>
|
|
614
|
+
<div className="card">
|
|
615
|
+
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
|
616
|
+
Comments
|
|
617
|
+
</h2>
|
|
618
|
+
{isAuthenticated ? (
|
|
619
|
+
<form
|
|
620
|
+
onSubmit={(event) => {
|
|
621
|
+
event.preventDefault()
|
|
622
|
+
if (!comment.trim()) return
|
|
623
|
+
void addComment({ skillId: skill._id, body: comment.trim() }).then(() =>
|
|
624
|
+
setComment(''),
|
|
625
|
+
)
|
|
626
|
+
}}
|
|
627
|
+
className="comment-form"
|
|
628
|
+
>
|
|
629
|
+
<textarea
|
|
630
|
+
className="comment-input"
|
|
631
|
+
rows={4}
|
|
632
|
+
value={comment}
|
|
633
|
+
onChange={(event) => setComment(event.target.value)}
|
|
634
|
+
placeholder="Leave a note…"
|
|
635
|
+
/>
|
|
636
|
+
<button className="btn comment-submit" type="submit">
|
|
637
|
+
Post comment
|
|
638
|
+
</button>
|
|
639
|
+
</form>
|
|
640
|
+
) : (
|
|
641
|
+
<p className="section-subtitle">Sign in to comment.</p>
|
|
642
|
+
)}
|
|
643
|
+
<div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
|
|
644
|
+
{(comments ?? []).length === 0 ? (
|
|
645
|
+
<div className="stat">No comments yet.</div>
|
|
646
|
+
) : (
|
|
647
|
+
(comments ?? []).map((entry) => (
|
|
648
|
+
<div key={entry.comment._id} className="stat" style={{ alignItems: 'flex-start' }}>
|
|
649
|
+
<div>
|
|
650
|
+
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
|
|
651
|
+
<div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
|
|
652
|
+
</div>
|
|
653
|
+
{isAuthenticated && me && (me._id === entry.comment.userId || isModerator(me)) ? (
|
|
654
|
+
<button
|
|
655
|
+
className="btn"
|
|
656
|
+
type="button"
|
|
657
|
+
onClick={() => void removeComment({ commentId: entry.comment._id })}
|
|
658
|
+
>
|
|
659
|
+
Delete
|
|
660
|
+
</button>
|
|
661
|
+
) : null}
|
|
662
|
+
</div>
|
|
663
|
+
))
|
|
664
|
+
)}
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
</main>
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function buildSkillHref(ownerHandle: string | null, ownerId: Id<'users'> | null, slug: string) {
|
|
673
|
+
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : 'unknown')
|
|
674
|
+
return `/${owner}/${slug}`
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatConfigSnippet(raw: string) {
|
|
678
|
+
const trimmed = raw.trim()
|
|
679
|
+
if (!trimmed || raw.includes('\n')) return raw
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(raw)
|
|
682
|
+
return JSON.stringify(parsed, null, 2)
|
|
683
|
+
} catch {
|
|
684
|
+
// fall through
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
let out = ''
|
|
688
|
+
let indent = 0
|
|
689
|
+
let inString = false
|
|
690
|
+
let isEscaped = false
|
|
691
|
+
|
|
692
|
+
const newline = () => {
|
|
693
|
+
out = out.replace(/[ \t]+$/u, '')
|
|
694
|
+
out += `\n${' '.repeat(indent * 2)}`
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
698
|
+
const ch = raw[i]
|
|
699
|
+
if (inString) {
|
|
700
|
+
out += ch
|
|
701
|
+
if (isEscaped) {
|
|
702
|
+
isEscaped = false
|
|
703
|
+
} else if (ch === '\\') {
|
|
704
|
+
isEscaped = true
|
|
705
|
+
} else if (ch === '"') {
|
|
706
|
+
inString = false
|
|
707
|
+
}
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (ch === '"') {
|
|
712
|
+
inString = true
|
|
713
|
+
out += ch
|
|
714
|
+
continue
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (ch === '{' || ch === '[') {
|
|
718
|
+
out += ch
|
|
719
|
+
indent += 1
|
|
720
|
+
newline()
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (ch === '}' || ch === ']') {
|
|
725
|
+
indent = Math.max(0, indent - 1)
|
|
726
|
+
newline()
|
|
727
|
+
out += ch
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (ch === ';' || ch === ',') {
|
|
732
|
+
out += ch
|
|
733
|
+
newline()
|
|
734
|
+
continue
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (ch === '\n' || ch === '\r' || ch === '\t') {
|
|
738
|
+
continue
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (ch === ' ') {
|
|
742
|
+
if (out.endsWith(' ') || out.endsWith('\n')) {
|
|
743
|
+
continue
|
|
744
|
+
}
|
|
745
|
+
out += ' '
|
|
746
|
+
continue
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
out += ch
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return out.trim()
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function stripFrontmatter(content: string) {
|
|
756
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
757
|
+
if (!normalized.startsWith('---')) return content
|
|
758
|
+
const endIndex = normalized.indexOf('\n---', 3)
|
|
759
|
+
if (endIndex === -1) return content
|
|
760
|
+
return normalized.slice(endIndex + 4).replace(/^\n+/, '')
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function formatOsList(os?: string[]) {
|
|
764
|
+
if (!os?.length) return []
|
|
765
|
+
return os.map((entry) => {
|
|
766
|
+
const key = entry.trim().toLowerCase()
|
|
767
|
+
if (key === 'darwin' || key === 'macos' || key === 'mac') return 'macOS'
|
|
768
|
+
if (key === 'linux') return 'Linux'
|
|
769
|
+
if (key === 'windows' || key === 'win32') return 'Windows'
|
|
770
|
+
return entry
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function formatInstallLabel(spec: SkillInstallSpec) {
|
|
775
|
+
if (spec.kind === 'brew') return 'Homebrew'
|
|
776
|
+
if (spec.kind === 'node') return 'Node'
|
|
777
|
+
if (spec.kind === 'go') return 'Go'
|
|
778
|
+
if (spec.kind === 'uv') return 'uv'
|
|
779
|
+
return 'Install'
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function formatInstallCommand(spec: SkillInstallSpec) {
|
|
783
|
+
if (spec.kind === 'brew' && spec.formula) {
|
|
784
|
+
if (spec.tap && !spec.formula.includes('/')) {
|
|
785
|
+
return `brew install ${spec.tap}/${spec.formula}`
|
|
786
|
+
}
|
|
787
|
+
return `brew install ${spec.formula}`
|
|
788
|
+
}
|
|
789
|
+
if (spec.kind === 'node' && spec.package) {
|
|
790
|
+
return `npm i -g ${spec.package}`
|
|
791
|
+
}
|
|
792
|
+
if (spec.kind === 'go' && spec.module) {
|
|
793
|
+
return `go install ${spec.module}`
|
|
794
|
+
}
|
|
795
|
+
if (spec.kind === 'uv' && spec.package) {
|
|
796
|
+
return `uv tool install ${spec.package}`
|
|
797
|
+
}
|
|
798
|
+
return null
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function formatBytes(bytes: number) {
|
|
802
|
+
if (!Number.isFinite(bytes)) return '—'
|
|
803
|
+
if (bytes < 1024) return `${bytes} B`
|
|
804
|
+
const units = ['KB', 'MB', 'GB']
|
|
805
|
+
let value = bytes / 1024
|
|
806
|
+
let unitIndex = 0
|
|
807
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
808
|
+
value /= 1024
|
|
809
|
+
unitIndex += 1
|
|
810
|
+
}
|
|
811
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function formatNixInstallSnippet(plugin: string) {
|
|
815
|
+
const snippet = `programs.pilotbot.plugins = [ { source = "${plugin}"; } ];`
|
|
816
|
+
return formatConfigSnippet(snippet)
|
|
817
|
+
}
|