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,243 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useAction, useQuery } from 'convex/react'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { api } from '../../../convex/_generated/api'
5
+ import { SoulCard } from '../../components/SoulCard'
6
+ import type { PublicSoul } from '../../lib/publicUser'
7
+
8
+ const sortKeys = ['newest', 'downloads', 'stars', 'name', 'updated'] as const
9
+ type SortKey = (typeof sortKeys)[number]
10
+ type SortDir = 'asc' | 'desc'
11
+
12
+ function parseSort(value: unknown): SortKey {
13
+ if (typeof value !== 'string') return 'newest'
14
+ if ((sortKeys as readonly string[]).includes(value)) return value as SortKey
15
+ return 'newest'
16
+ }
17
+
18
+ function parseDir(value: unknown, sort: SortKey): SortDir {
19
+ if (value === 'asc' || value === 'desc') return value
20
+ return sort === 'name' ? 'asc' : 'desc'
21
+ }
22
+
23
+ export const Route = createFileRoute('/souls/')({
24
+ validateSearch: (search) => {
25
+ return {
26
+ q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
27
+ sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
28
+ dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
29
+ view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
30
+ focus: search.focus === 'search' ? 'search' : undefined,
31
+ }
32
+ },
33
+ component: SoulsIndex,
34
+ })
35
+
36
+ function SoulsIndex() {
37
+ const navigate = Route.useNavigate()
38
+ const search = Route.useSearch()
39
+ const sort = search.sort ?? 'newest'
40
+ const dir = parseDir(search.dir, sort)
41
+ const view = search.view ?? 'list'
42
+ const [query, setQuery] = useState(search.q ?? '')
43
+
44
+ const souls = useQuery(api.souls.list, { limit: 500 }) as PublicSoul[] | undefined
45
+ const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds)
46
+ const seedEnsuredRef = useRef(false)
47
+ const searchInputRef = useRef<HTMLInputElement>(null)
48
+ const isLoadingSouls = souls === undefined
49
+
50
+ useEffect(() => {
51
+ setQuery(search.q ?? '')
52
+ }, [search.q])
53
+
54
+ // Auto-focus search input when focus=search param is present
55
+ useEffect(() => {
56
+ if (search.focus === 'search' && searchInputRef.current) {
57
+ searchInputRef.current.focus()
58
+ // Clear the focus param from URL to avoid re-focusing on navigation
59
+ void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true })
60
+ }
61
+ }, [search.focus, navigate])
62
+
63
+ useEffect(() => {
64
+ if (seedEnsuredRef.current) return
65
+ seedEnsuredRef.current = true
66
+ void ensureSoulSeeds({})
67
+ }, [ensureSoulSeeds])
68
+
69
+ const filtered = useMemo(() => {
70
+ const value = query.trim().toLowerCase()
71
+ const all = souls ?? []
72
+ if (!value) return all
73
+ return all.filter((soul) => {
74
+ if (soul.slug.toLowerCase().includes(value)) return true
75
+ if (soul.displayName.toLowerCase().includes(value)) return true
76
+ return (soul.summary ?? '').toLowerCase().includes(value)
77
+ })
78
+ }, [query, souls])
79
+
80
+ const sorted = useMemo(() => {
81
+ const multiplier = dir === 'asc' ? 1 : -1
82
+ const results = [...filtered]
83
+ results.sort((a, b) => {
84
+ switch (sort) {
85
+ case 'downloads':
86
+ return (a.stats.downloads - b.stats.downloads) * multiplier
87
+ case 'stars':
88
+ return (a.stats.stars - b.stats.stars) * multiplier
89
+ case 'updated':
90
+ return (a.updatedAt - b.updatedAt) * multiplier
91
+ case 'name':
92
+ return (
93
+ (a.displayName.localeCompare(b.displayName) || a.slug.localeCompare(b.slug)) *
94
+ multiplier
95
+ )
96
+ default:
97
+ return (a.createdAt - b.createdAt) * multiplier
98
+ }
99
+ })
100
+ return results
101
+ }, [dir, filtered, sort])
102
+
103
+ const showing = sorted.length
104
+ const total = souls?.length
105
+
106
+ return (
107
+ <main className="section">
108
+ <header className="skills-header">
109
+ <div>
110
+ <h1 className="section-title" style={{ marginBottom: 8 }}>
111
+ Souls
112
+ </h1>
113
+ <p className="section-subtitle" style={{ marginBottom: 0 }}>
114
+ {isLoadingSouls
115
+ ? 'Loading souls…'
116
+ : `${showing}${typeof total === 'number' ? ` of ${total}` : ''} souls.`}
117
+ </p>
118
+ </div>
119
+ <div className="skills-toolbar">
120
+ <div className="skills-search">
121
+ <input
122
+ ref={searchInputRef}
123
+ className="skills-search-input"
124
+ value={query}
125
+ onChange={(event) => {
126
+ const next = event.target.value
127
+ const trimmed = next.trim()
128
+ setQuery(next)
129
+ void navigate({
130
+ search: (prev) => ({ ...prev, q: trimmed ? next : undefined }),
131
+ replace: true,
132
+ })
133
+ }}
134
+ placeholder="Filter by name, slug, or summary…"
135
+ />
136
+ </div>
137
+ <div className="skills-toolbar-row">
138
+ <select
139
+ className="skills-sort"
140
+ value={sort}
141
+ onChange={(event) => {
142
+ const sort = parseSort(event.target.value)
143
+ void navigate({
144
+ search: (prev) => ({
145
+ ...prev,
146
+ sort,
147
+ dir: parseDir(prev.dir, sort),
148
+ }),
149
+ replace: true,
150
+ })
151
+ }}
152
+ aria-label="Sort souls"
153
+ >
154
+ <option value="newest">Newest</option>
155
+ <option value="updated">Recently updated</option>
156
+ <option value="downloads">Downloads</option>
157
+ <option value="stars">Stars</option>
158
+ <option value="name">Name</option>
159
+ </select>
160
+ <button
161
+ className="skills-dir"
162
+ type="button"
163
+ aria-label={`Sort direction ${dir}`}
164
+ onClick={() => {
165
+ void navigate({
166
+ search: (prev) => ({
167
+ ...prev,
168
+ dir: parseDir(prev.dir, sort) === 'asc' ? 'desc' : 'asc',
169
+ }),
170
+ replace: true,
171
+ })
172
+ }}
173
+ >
174
+ {dir === 'asc' ? '↑' : '↓'}
175
+ </button>
176
+ <button
177
+ className={`skills-view${view === 'cards' ? ' is-active' : ''}`}
178
+ type="button"
179
+ onClick={() => {
180
+ void navigate({
181
+ search: (prev) => ({
182
+ ...prev,
183
+ view: prev.view === 'cards' ? undefined : 'cards',
184
+ }),
185
+ replace: true,
186
+ })
187
+ }}
188
+ >
189
+ {view === 'cards' ? 'List' : 'Cards'}
190
+ </button>
191
+ </div>
192
+ </div>
193
+ </header>
194
+
195
+ {isLoadingSouls ? (
196
+ <div className="card">
197
+ <div className="loading-indicator">Loading souls…</div>
198
+ </div>
199
+ ) : showing === 0 ? (
200
+ <div className="card">No souls match that filter.</div>
201
+ ) : view === 'cards' ? (
202
+ <div className="grid">
203
+ {sorted.map((soul) => (
204
+ <SoulCard
205
+ key={soul._id}
206
+ soul={soul}
207
+ summaryFallback="A SOUL.md bundle."
208
+ meta={
209
+ <div className="stat">
210
+ ⭐ {soul.stats.stars} · ⤓ {soul.stats.downloads} · {soul.stats.versions} v
211
+ </div>
212
+ }
213
+ />
214
+ ))}
215
+ </div>
216
+ ) : (
217
+ <div className="skills-list">
218
+ {sorted.map((soul) => (
219
+ <Link
220
+ key={soul._id}
221
+ className="skills-row"
222
+ to="/souls/$slug"
223
+ params={{ slug: soul.slug }}
224
+ >
225
+ <div className="skills-row-main">
226
+ <div className="skills-row-title">
227
+ <span>{soul.displayName}</span>
228
+ <span className="skills-row-slug">/{soul.slug}</span>
229
+ </div>
230
+ <div className="skills-row-summary">{soul.summary ?? 'SOUL.md bundle.'}</div>
231
+ </div>
232
+ <div className="skills-row-metrics">
233
+ <span>⤓ {soul.stats.downloads}</span>
234
+ <span>★ {soul.stats.stars}</span>
235
+ <span>{soul.stats.versions} v</span>
236
+ </div>
237
+ </Link>
238
+ ))}
239
+ </div>
240
+ )}
241
+ </main>
242
+ )
243
+ }
@@ -0,0 +1,68 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useMutation, useQuery } from 'convex/react'
3
+ import { api } from '../../convex/_generated/api'
4
+ import type { Doc } from '../../convex/_generated/dataModel'
5
+ import type { PublicSkill } from '../lib/publicUser'
6
+
7
+ export const Route = createFileRoute('/stars')({
8
+ component: Stars,
9
+ })
10
+
11
+ function Stars() {
12
+ const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
13
+ const skills =
14
+ (useQuery(api.stars.listByUser, me ? { userId: me._id, limit: 50 } : 'skip') as
15
+ | PublicSkill[]
16
+ | undefined) ?? []
17
+
18
+ const toggleStar = useMutation(api.stars.toggle)
19
+
20
+ if (!me) {
21
+ return (
22
+ <main className="section">
23
+ <div className="card">Sign in to see your highlights.</div>
24
+ </main>
25
+ )
26
+ }
27
+
28
+ return (
29
+ <main className="section">
30
+ <h1 className="section-title">Your highlights</h1>
31
+ <p className="section-subtitle">Skills you’ve starred for quick access.</p>
32
+ <div className="grid">
33
+ {skills.length === 0 ? (
34
+ <div className="card">No stars yet.</div>
35
+ ) : (
36
+ skills.map((skill) => {
37
+ const owner = encodeURIComponent(String(skill.ownerUserId))
38
+ return (
39
+ <div key={skill._id} className="card skill-card">
40
+ <Link to="/$owner/$slug" params={{ owner, slug: skill.slug }}>
41
+ <h3 className="skill-card-title">{skill.displayName}</h3>
42
+ </Link>
43
+ <div className="skill-card-footer skill-card-footer-inline">
44
+ <span className="stat">⭐ {skill.stats.stars}</span>
45
+ <button
46
+ className="star-toggle is-active"
47
+ type="button"
48
+ onClick={async () => {
49
+ try {
50
+ await toggleStar({ skillId: skill._id })
51
+ } catch (error) {
52
+ console.error('Failed to unstar skill:', error)
53
+ window.alert('Unable to unstar this skill. Please try again.')
54
+ }
55
+ }}
56
+ aria-label={`Unstar ${skill.displayName}`}
57
+ >
58
+ <span aria-hidden="true">★</span>
59
+ </button>
60
+ </div>
61
+ </div>
62
+ )
63
+ })
64
+ )}
65
+ </div>
66
+ </main>
67
+ )
68
+ }
@@ -0,0 +1,307 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { useMutation, useQuery } from 'convex/react'
3
+ import { useEffect, useState } from 'react'
4
+ import { api } from '../../../convex/_generated/api'
5
+ import type { Doc } from '../../../convex/_generated/dataModel'
6
+ import { SkillCard } from '../../components/SkillCard'
7
+ import { getSkillBadges } from '../../lib/badges'
8
+ import type { PublicSkill, PublicUser } from '../../lib/publicUser'
9
+
10
+ export const Route = createFileRoute('/u/$handle')({
11
+ component: UserProfile,
12
+ })
13
+
14
+ function UserProfile() {
15
+ const { handle } = Route.useParams()
16
+ const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
17
+ const user = useQuery(api.users.getByHandle, { handle }) as PublicUser | null | undefined
18
+ const publishedSkills = useQuery(
19
+ api.skills.list,
20
+ user ? { ownerUserId: user._id, limit: 50 } : 'skip',
21
+ ) as PublicSkill[] | undefined
22
+ const starredSkills = useQuery(
23
+ api.stars.listByUser,
24
+ user ? { userId: user._id, limit: 50 } : 'skip',
25
+ ) as PublicSkill[] | undefined
26
+
27
+ const isSelf = Boolean(me && user && me._id === user._id)
28
+ const [tab, setTab] = useState<'stars' | 'installed'>('stars')
29
+ const [includeRemoved, setIncludeRemoved] = useState(false)
30
+ const installed = useQuery(
31
+ api.telemetry.getMyInstalled,
32
+ isSelf && tab === 'installed' ? { includeRemoved } : 'skip',
33
+ ) as TelemetryResponse | null | undefined
34
+
35
+ useEffect(() => {
36
+ if (!isSelf && tab === 'installed') setTab('stars')
37
+ }, [isSelf, tab])
38
+
39
+ if (user === undefined) {
40
+ return (
41
+ <main className="section">
42
+ <div className="card">
43
+ <div className="loading-indicator">Loading user…</div>
44
+ </div>
45
+ </main>
46
+ )
47
+ }
48
+
49
+ if (user === null) {
50
+ return (
51
+ <main className="section">
52
+ <div className="card">User not found.</div>
53
+ </main>
54
+ )
55
+ }
56
+
57
+ const avatar = user.image
58
+ const displayName = user.displayName ?? user.name ?? user.handle ?? 'User'
59
+ const displayHandle = user.handle ?? user.name ?? handle
60
+ const initial = displayName.charAt(0).toUpperCase()
61
+ const isLoadingSkills = starredSkills === undefined
62
+ const skills = starredSkills ?? []
63
+ const isLoadingPublished = publishedSkills === undefined
64
+ const published = publishedSkills ?? []
65
+
66
+ return (
67
+ <main className="section">
68
+ <div className="card settings-profile" style={{ marginBottom: 22 }}>
69
+ <div className="settings-avatar" aria-hidden="true">
70
+ {avatar ? <img src={avatar} alt="" /> : <span>{initial}</span>}
71
+ </div>
72
+ <div className="settings-profile-body">
73
+ <div className="settings-name">{displayName}</div>
74
+ <div className="settings-handle">@{displayHandle}</div>
75
+ </div>
76
+ </div>
77
+
78
+ {isSelf ? (
79
+ <div className="profile-tabs" role="tablist" aria-label="Profile tabs">
80
+ <button
81
+ className={tab === 'stars' ? 'profile-tab is-active' : 'profile-tab'}
82
+ type="button"
83
+ role="tab"
84
+ aria-selected={tab === 'stars'}
85
+ onClick={() => setTab('stars')}
86
+ >
87
+ Stars
88
+ </button>
89
+ <button
90
+ className={tab === 'installed' ? 'profile-tab is-active' : 'profile-tab'}
91
+ type="button"
92
+ role="tab"
93
+ aria-selected={tab === 'installed'}
94
+ onClick={() => setTab('installed')}
95
+ >
96
+ Installed
97
+ </button>
98
+ </div>
99
+ ) : null}
100
+
101
+ {tab === 'installed' && isSelf ? (
102
+ <InstalledSection
103
+ includeRemoved={includeRemoved}
104
+ onToggleRemoved={() => setIncludeRemoved((value) => !value)}
105
+ data={installed}
106
+ />
107
+ ) : (
108
+ <>
109
+ <h2 className="section-title" style={{ fontSize: '1.3rem' }}>
110
+ Published
111
+ </h2>
112
+ <p className="section-subtitle">Skills published by this user.</p>
113
+
114
+ {isLoadingPublished ? (
115
+ <div className="card">
116
+ <div className="loading-indicator">Loading published skills…</div>
117
+ </div>
118
+ ) : published.length > 0 ? (
119
+ <div className="grid" style={{ marginBottom: 18 }}>
120
+ {published.map((skill) => (
121
+ <SkillCard
122
+ key={skill._id}
123
+ skill={skill}
124
+ badge={getSkillBadges(skill)}
125
+ summaryFallback="Agent-ready skill pack."
126
+ meta={
127
+ <div className="stat">
128
+ ⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
129
+ {skill.stats.installsAllTime ?? 0}
130
+ </div>
131
+ }
132
+ />
133
+ ))}
134
+ </div>
135
+ ) : null}
136
+
137
+ <h2 className="section-title" style={{ fontSize: '1.3rem' }}>
138
+ Stars
139
+ </h2>
140
+ <p className="section-subtitle">Skills this user has starred.</p>
141
+
142
+ {isLoadingSkills ? (
143
+ <div className="card">
144
+ <div className="loading-indicator">Loading stars…</div>
145
+ </div>
146
+ ) : skills.length === 0 ? (
147
+ <div className="card">No stars yet.</div>
148
+ ) : (
149
+ <div className="grid">
150
+ {skills.map((skill) => (
151
+ <SkillCard
152
+ key={skill._id}
153
+ skill={skill}
154
+ badge={getSkillBadges(skill)}
155
+ summaryFallback="Agent-ready skill pack."
156
+ meta={
157
+ <div className="stat">
158
+ ⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
159
+ {skill.stats.installsAllTime ?? 0}
160
+ </div>
161
+ }
162
+ />
163
+ ))}
164
+ </div>
165
+ )}
166
+ </>
167
+ )}
168
+ </main>
169
+ )
170
+ }
171
+
172
+ function InstalledSection(props: {
173
+ includeRemoved: boolean
174
+ onToggleRemoved: () => void
175
+ data: TelemetryResponse | null | undefined
176
+ }) {
177
+ const clearTelemetry = useMutation(api.telemetry.clearMyTelemetry)
178
+ const [showRaw, setShowRaw] = useState(false)
179
+ const data = props.data
180
+ if (data === undefined) {
181
+ return (
182
+ <>
183
+ <h2 className="section-title" style={{ fontSize: '1.3rem' }}>
184
+ Installed
185
+ </h2>
186
+ <div className="card">
187
+ <div className="loading-indicator">Loading telemetry…</div>
188
+ </div>
189
+ </>
190
+ )
191
+ }
192
+
193
+ if (data === null) {
194
+ return (
195
+ <>
196
+ <h2 className="section-title" style={{ fontSize: '1.3rem' }}>
197
+ Installed
198
+ </h2>
199
+ <div className="card">Sign in to view your installed skills.</div>
200
+ </>
201
+ )
202
+ }
203
+
204
+ return (
205
+ <>
206
+ <h2 className="section-title" style={{ fontSize: '1.3rem' }}>
207
+ Installed
208
+ </h2>
209
+ <p className="section-subtitle" style={{ maxWidth: 760 }}>
210
+ Private view. Only you can see your folders/roots. Everyone else only sees aggregated
211
+ install counts per skill.
212
+ </p>
213
+ <div className="profile-actions">
214
+ <button className="btn" type="button" onClick={props.onToggleRemoved}>
215
+ {props.includeRemoved ? 'Hide removed' : 'Show removed'}
216
+ </button>
217
+ <button className="btn" type="button" onClick={() => setShowRaw((value) => !value)}>
218
+ {showRaw ? 'Hide JSON' : 'Show JSON'}
219
+ </button>
220
+ <button
221
+ className="btn"
222
+ type="button"
223
+ onClick={() => {
224
+ if (!window.confirm('Delete all telemetry data?')) return
225
+ void clearTelemetry()
226
+ }}
227
+ >
228
+ Delete telemetry
229
+ </button>
230
+ </div>
231
+
232
+ {showRaw ? (
233
+ <div className="card telemetry-json" style={{ marginBottom: 18 }}>
234
+ <pre className="mono" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
235
+ {JSON.stringify(data, null, 2)}
236
+ </pre>
237
+ </div>
238
+ ) : null}
239
+
240
+ {data.roots.length === 0 ? (
241
+ <div className="card">No telemetry yet. Run `pilothub sync` from the CLI.</div>
242
+ ) : (
243
+ <div style={{ display: 'grid', gap: 16 }}>
244
+ {data.roots.map((root) => (
245
+ <div key={root.rootId} className="card telemetry-root">
246
+ <div className="telemetry-root-header">
247
+ <div>
248
+ <div className="telemetry-root-title">{root.label}</div>
249
+ <div className="telemetry-root-meta">
250
+ Last sync {new Date(root.lastSeenAt).toLocaleString()}
251
+ {root.expiredAt ? ' · stale' : ''}
252
+ </div>
253
+ </div>
254
+ <div className="tag">{root.skills.length} skills</div>
255
+ </div>
256
+ {root.skills.length === 0 ? (
257
+ <div className="stat">No skills found in this root.</div>
258
+ ) : (
259
+ <div className="telemetry-skill-list">
260
+ {root.skills.map((entry) => (
261
+ <div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
262
+ <a
263
+ className="telemetry-skill-link"
264
+ href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
265
+ >
266
+ <span>{entry.skill.displayName}</span>
267
+ <span className="telemetry-skill-slug">/{entry.skill.slug}</span>
268
+ </a>
269
+ <div className="telemetry-skill-meta mono">
270
+ {entry.lastVersion ? `v${entry.lastVersion}` : 'v?'}{' '}
271
+ {entry.removedAt ? '· removed' : ''}
272
+ </div>
273
+ </div>
274
+ ))}
275
+ </div>
276
+ )}
277
+ </div>
278
+ ))}
279
+ </div>
280
+ )}
281
+ </>
282
+ )
283
+ }
284
+
285
+ type TelemetryResponse = {
286
+ roots: Array<{
287
+ rootId: string
288
+ label: string
289
+ firstSeenAt: number
290
+ lastSeenAt: number
291
+ expiredAt?: number
292
+ skills: Array<{
293
+ skill: {
294
+ slug: string
295
+ displayName: string
296
+ summary?: string
297
+ stats: unknown
298
+ ownerUserId: string
299
+ }
300
+ firstSeenAt: number
301
+ lastSeenAt: number
302
+ lastVersion?: string
303
+ removedAt?: number
304
+ }>
305
+ }>
306
+ cutoffDays: number
307
+ }
@@ -0,0 +1,81 @@
1
+ import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'pilothub-schema'
2
+
3
+ export async function uploadFile(uploadUrl: string, file: File) {
4
+ const response = await fetch(uploadUrl, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
7
+ body: file,
8
+ })
9
+ if (!response.ok) {
10
+ throw new Error(`Upload failed: ${await response.text()}`)
11
+ }
12
+ const payload = (await response.json()) as { storageId: string }
13
+ return payload.storageId
14
+ }
15
+
16
+ export async function hashFile(file: File) {
17
+ const buffer =
18
+ typeof file.arrayBuffer === 'function'
19
+ ? await file.arrayBuffer()
20
+ : await new Response(file).arrayBuffer()
21
+ const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(buffer))
22
+ const bytes = new Uint8Array(hash)
23
+ return Array.from(bytes)
24
+ .map((byte) => byte.toString(16).padStart(2, '0'))
25
+ .join('')
26
+ }
27
+
28
+ export function formatBytes(bytes: number) {
29
+ if (!Number.isFinite(bytes)) return '0 B'
30
+ const units = ['B', 'KB', 'MB', 'GB']
31
+ let size = bytes
32
+ let unit = 0
33
+ while (size >= 1024 && unit < units.length - 1) {
34
+ size /= 1024
35
+ unit += 1
36
+ }
37
+ return `${size.toFixed(size < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
38
+ }
39
+
40
+ export function formatPublishError(error: unknown) {
41
+ if (error && typeof error === 'object' && 'data' in error) {
42
+ const data = (error as { data?: unknown }).data
43
+ if (typeof data === 'string' && data.trim()) return data.trim()
44
+ if (
45
+ data &&
46
+ typeof data === 'object' &&
47
+ 'message' in data &&
48
+ typeof (data as { message?: unknown }).message === 'string'
49
+ ) {
50
+ const message = (data as { message?: string }).message?.trim()
51
+ if (message) return message
52
+ }
53
+ }
54
+ if (error instanceof Error) {
55
+ const cleaned = error.message
56
+ .replace(/\[CONVEX[^\]]*\]\s*/g, '')
57
+ .replace(/\[Request ID:[^\]]*\]\s*/g, '')
58
+ .replace(/^Server Error Called by client\s*/i, '')
59
+ .replace(/^ConvexError:\s*/i, '')
60
+ .trim()
61
+ if (cleaned && cleaned !== 'Server Error') return cleaned
62
+ }
63
+ return 'Publish failed. Please try again.'
64
+ }
65
+
66
+ export function isTextFile(file: File) {
67
+ const path = (file.webkitRelativePath || file.name).trim().toLowerCase()
68
+ if (!path) return false
69
+ const parts = path.split('.')
70
+ const extension = parts.length > 1 ? (parts.at(-1) ?? '') : ''
71
+ if (file.type && isTextContentType(file.type)) return true
72
+ if (extension && TEXT_FILE_EXTENSION_SET.has(extension)) return true
73
+ return false
74
+ }
75
+
76
+ export async function readText(blob: Blob) {
77
+ if (typeof (blob as Blob & { text?: unknown }).text === 'function') {
78
+ return (blob as Blob & { text: () => Promise<string> }).text()
79
+ }
80
+ return new Response(blob as BodyInit).text()
81
+ }