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.
Files changed (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. 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
+ }