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,168 @@
1
+ import { useAuthActions } from '@convex-dev/auth/react'
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import { useMutation } from 'convex/react'
4
+ import { useEffect, useMemo, useRef, useState } from 'react'
5
+ import { api } from '../../../convex/_generated/api'
6
+ import { useAuthStatus } from '../../lib/useAuthStatus'
7
+
8
+ export const Route = createFileRoute('/cli/auth')({
9
+ component: CliAuth,
10
+ })
11
+
12
+ function CliAuth() {
13
+ const { isAuthenticated, isLoading, me } = useAuthStatus()
14
+ const { signIn } = useAuthActions()
15
+ const createToken = useMutation(api.tokens.create)
16
+
17
+ const search = Route.useSearch() as {
18
+ redirect_uri?: string
19
+ label?: string
20
+ label_b64?: string
21
+ state?: string
22
+ }
23
+ const [status, setStatus] = useState<string>('Preparing…')
24
+ const [token, setToken] = useState<string | null>(null)
25
+ const hasRun = useRef(false)
26
+
27
+ const redirectUri = search.redirect_uri ?? ''
28
+ const label = (decodeLabel(search.label_b64) ?? search.label ?? 'CLI token').trim() || 'CLI token'
29
+ const state = typeof search.state === 'string' ? search.state.trim() : ''
30
+
31
+ const safeRedirect = useMemo(() => isAllowedRedirectUri(redirectUri), [redirectUri])
32
+ const registry = import.meta.env.VITE_CONVEX_SITE_URL as string | undefined
33
+
34
+ useEffect(() => {
35
+ if (hasRun.current) return
36
+ if (!safeRedirect) return
37
+ if (!state) return
38
+ if (!registry) return
39
+ if (!isAuthenticated || !me) return
40
+ hasRun.current = true
41
+
42
+ const run = async () => {
43
+ setStatus('Creating token…')
44
+ const result = await createToken({ label })
45
+ setToken(result.token)
46
+ setStatus('Redirecting to CLI…')
47
+ const hash = new URLSearchParams()
48
+ hash.set('token', result.token)
49
+ hash.set('registry', registry)
50
+ hash.set('state', state)
51
+ window.location.assign(`${redirectUri}#${hash.toString()}`)
52
+ }
53
+
54
+ void run().catch((error) => {
55
+ const message = error instanceof Error ? error.message : 'Failed to create token'
56
+ setStatus(message)
57
+ setToken(null)
58
+ })
59
+ }, [createToken, isAuthenticated, label, me, redirectUri, safeRedirect, state])
60
+
61
+ if (!safeRedirect) {
62
+ return (
63
+ <main className="section">
64
+ <div className="card">
65
+ <h1 className="section-title" style={{ marginTop: 0 }}>
66
+ CLI login
67
+ </h1>
68
+ <p className="section-subtitle">Invalid redirect URL.</p>
69
+ <p className="section-subtitle" style={{ marginBottom: 0 }}>
70
+ Run the CLI again to start a fresh login.
71
+ </p>
72
+ </div>
73
+ </main>
74
+ )
75
+ }
76
+
77
+ if (!state) {
78
+ return (
79
+ <main className="section">
80
+ <div className="card">
81
+ <h1 className="section-title" style={{ marginTop: 0 }}>
82
+ CLI login
83
+ </h1>
84
+ <p className="section-subtitle">Missing state.</p>
85
+ <p className="section-subtitle" style={{ marginBottom: 0 }}>
86
+ Run the CLI again to start a fresh login.
87
+ </p>
88
+ </div>
89
+ </main>
90
+ )
91
+ }
92
+
93
+ if (!registry) {
94
+ return (
95
+ <main className="section">
96
+ <div className="card">Missing VITE_CONVEX_SITE_URL configuration.</div>
97
+ </main>
98
+ )
99
+ }
100
+
101
+ if (!isAuthenticated || !me) {
102
+ return (
103
+ <main className="section">
104
+ <div className="card">
105
+ <h1 className="section-title" style={{ marginTop: 0 }}>
106
+ CLI login
107
+ </h1>
108
+ <p className="section-subtitle">Sign in to create an API token for the CLI.</p>
109
+ <button
110
+ className="btn btn-primary"
111
+ type="button"
112
+ disabled={isLoading}
113
+ onClick={() => void signIn('github')}
114
+ >
115
+ Sign in with GitHub
116
+ </button>
117
+ </div>
118
+ </main>
119
+ )
120
+ }
121
+
122
+ return (
123
+ <main className="section">
124
+ <div className="card">
125
+ <h1 className="section-title" style={{ marginTop: 0 }}>
126
+ CLI login
127
+ </h1>
128
+ <p className="section-subtitle">{status}</p>
129
+ {token ? (
130
+ <div className="stat" style={{ overflowX: 'auto' }}>
131
+ <div style={{ marginBottom: 8 }}>If redirect fails, copy this token:</div>
132
+ <code>{token}</code>
133
+ </div>
134
+ ) : null}
135
+ </div>
136
+ </main>
137
+ )
138
+ }
139
+
140
+ function isAllowedRedirectUri(value: string) {
141
+ if (!value) return false
142
+ let url: URL
143
+ try {
144
+ url = new URL(value)
145
+ } catch {
146
+ return false
147
+ }
148
+ if (url.protocol !== 'http:') return false
149
+ const host = url.hostname.toLowerCase()
150
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]'
151
+ }
152
+
153
+ function decodeLabel(value: string | undefined) {
154
+ if (!value) return null
155
+ try {
156
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
157
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
158
+ const binary = atob(padded)
159
+ const bytes = new Uint8Array(binary.length)
160
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
161
+ const decoded = new TextDecoder().decode(bytes)
162
+ const label = decoded.trim()
163
+ if (!label) return null
164
+ return label.slice(0, 80)
165
+ } catch {
166
+ return null
167
+ }
168
+ }
@@ -0,0 +1,97 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useQuery } from 'convex/react'
3
+ import { Package, Plus, Upload } from 'lucide-react'
4
+ import { api } from '../../convex/_generated/api'
5
+ import type { Doc } from '../../convex/_generated/dataModel'
6
+ import type { PublicSkill } from '../lib/publicUser'
7
+
8
+ export const Route = createFileRoute('/dashboard')({
9
+ component: Dashboard,
10
+ })
11
+
12
+ function Dashboard() {
13
+ const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
14
+ const mySkills = useQuery(
15
+ api.skills.list,
16
+ me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
17
+ ) as PublicSkill[] | undefined
18
+
19
+ if (!me) {
20
+ return (
21
+ <main className="section">
22
+ <div className="card">Sign in to access your dashboard.</div>
23
+ </main>
24
+ )
25
+ }
26
+
27
+ const skills = mySkills ?? []
28
+ const ownerHandle = me.handle ?? me.name ?? me.displayName ?? me._id
29
+
30
+ return (
31
+ <main className="section">
32
+ <div className="dashboard-header">
33
+ <h1 className="section-title" style={{ margin: 0 }}>
34
+ My Skills
35
+ </h1>
36
+ <Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
37
+ <Plus className="h-4 w-4" aria-hidden="true" />
38
+ Upload New Skill
39
+ </Link>
40
+ </div>
41
+
42
+ {skills.length === 0 ? (
43
+ <div className="card dashboard-empty">
44
+ <Package className="dashboard-empty-icon" aria-hidden="true" />
45
+ <h2>No skills yet</h2>
46
+ <p>Upload your first skill to share it with the community.</p>
47
+ <Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
48
+ <Upload className="h-4 w-4" aria-hidden="true" />
49
+ Upload a Skill
50
+ </Link>
51
+ </div>
52
+ ) : (
53
+ <div className="dashboard-grid">
54
+ {skills.map((skill) => (
55
+ <SkillCard key={skill._id} skill={skill} ownerHandle={ownerHandle} />
56
+ ))}
57
+ </div>
58
+ )}
59
+ </main>
60
+ )
61
+ }
62
+
63
+ function SkillCard({ skill, ownerHandle }: { skill: PublicSkill; ownerHandle: string | null }) {
64
+ return (
65
+ <div className="dashboard-skill-card">
66
+ <div className="dashboard-skill-info">
67
+ <Link
68
+ to="/$owner/$slug"
69
+ params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
70
+ className="dashboard-skill-name"
71
+ >
72
+ {skill.displayName}
73
+ </Link>
74
+ <span className="dashboard-skill-slug">/{skill.slug}</span>
75
+ {skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
76
+ <div className="dashboard-skill-stats">
77
+ <span>⤓ {skill.stats.downloads}</span>
78
+ <span>★ {skill.stats.stars}</span>
79
+ <span>{skill.stats.versions} v</span>
80
+ </div>
81
+ </div>
82
+ <div className="dashboard-skill-actions">
83
+ <Link to="/upload" search={{ updateSlug: skill.slug }} className="btn btn-sm">
84
+ <Upload className="h-3 w-3" aria-hidden="true" />
85
+ New Version
86
+ </Link>
87
+ <Link
88
+ to="/$owner/$slug"
89
+ params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
90
+ className="btn btn-ghost btn-sm"
91
+ >
92
+ View
93
+ </Link>
94
+ </div>
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,415 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { useAction } from 'convex/react'
3
+ import { useMemo, useState } from 'react'
4
+ import { api } from '../../convex/_generated/api'
5
+ import { formatBytes } from '../lib/uploadUtils'
6
+ import { useAuthStatus } from '../lib/useAuthStatus'
7
+
8
+ export const Route = createFileRoute('/import')({
9
+ component: ImportGitHub,
10
+ })
11
+
12
+ type Candidate = {
13
+ path: string
14
+ readmePath: string
15
+ name: string | null
16
+ description: string | null
17
+ }
18
+
19
+ type CandidatePreview = {
20
+ resolved: {
21
+ owner: string
22
+ repo: string
23
+ ref: string
24
+ commit: string
25
+ path: string
26
+ repoUrl: string
27
+ originalUrl: string
28
+ }
29
+ candidate: Candidate
30
+ defaults: {
31
+ selectedPaths: string[]
32
+ slug: string
33
+ displayName: string
34
+ version: string
35
+ tags: string[]
36
+ }
37
+ files: Array<{ path: string; size: number; defaultSelected: boolean }>
38
+ }
39
+
40
+ function ImportGitHub() {
41
+ const { isAuthenticated, isLoading, me } = useAuthStatus()
42
+ const previewImport = useAction(api.githubImport.previewGitHubImport)
43
+ const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
44
+ const importSkill = useAction(api.githubImport.importGitHubSkill)
45
+ const navigate = useNavigate()
46
+
47
+ const [url, setUrl] = useState('')
48
+ const [candidates, setCandidates] = useState<Candidate[]>([])
49
+ const [selectedCandidatePath, setSelectedCandidatePath] = useState<string | null>(null)
50
+ const [preview, setPreview] = useState<CandidatePreview | null>(null)
51
+ const [selected, setSelected] = useState<Record<string, boolean>>({})
52
+
53
+ const [slug, setSlug] = useState('')
54
+ const [displayName, setDisplayName] = useState('')
55
+ const [version, setVersion] = useState('0.1.0')
56
+ const [tags, setTags] = useState('latest')
57
+
58
+ const [status, setStatus] = useState<string | null>(null)
59
+ const [error, setError] = useState<string | null>(null)
60
+ const [isBusy, setIsBusy] = useState(false)
61
+
62
+ const selectedCount = useMemo(() => Object.values(selected).filter(Boolean).length, [selected])
63
+ const selectedBytes = useMemo(() => {
64
+ if (!preview) return 0
65
+ let total = 0
66
+ for (const file of preview.files) {
67
+ if (selected[file.path]) total += file.size
68
+ }
69
+ return total
70
+ }, [preview, selected])
71
+
72
+ const detect = async () => {
73
+ setError(null)
74
+ setStatus(null)
75
+ setPreview(null)
76
+ setCandidates([])
77
+ setSelectedCandidatePath(null)
78
+ setSelected({})
79
+ setIsBusy(true)
80
+ try {
81
+ const result = await previewImport({ url: url.trim() })
82
+ const items = (result.candidates ?? []) as Candidate[]
83
+ setCandidates(items)
84
+ if (items.length === 1) {
85
+ const only = items[0]
86
+ if (only) await loadCandidate(only.path)
87
+ } else {
88
+ setStatus(`Found ${items.length} skills. Pick one.`)
89
+ }
90
+ } catch (e) {
91
+ setError(e instanceof Error ? e.message : 'Preview failed')
92
+ } finally {
93
+ setIsBusy(false)
94
+ }
95
+ }
96
+
97
+ const loadCandidate = async (candidatePath: string) => {
98
+ setError(null)
99
+ setStatus(null)
100
+ setPreview(null)
101
+ setSelected({})
102
+ setSelectedCandidatePath(candidatePath)
103
+ setIsBusy(true)
104
+ try {
105
+ const result = (await previewCandidate({
106
+ url: url.trim(),
107
+ candidatePath,
108
+ })) as CandidatePreview
109
+ setPreview(result)
110
+ setSlug(result.defaults.slug)
111
+ setDisplayName(result.defaults.displayName)
112
+ setVersion(result.defaults.version)
113
+ setTags((result.defaults.tags ?? ['latest']).join(','))
114
+ const nextSelected: Record<string, boolean> = {}
115
+ for (const file of result.files) nextSelected[file.path] = file.defaultSelected
116
+ setSelected(nextSelected)
117
+ setStatus('Ready to import.')
118
+ } catch (e) {
119
+ setError(e instanceof Error ? e.message : 'Preview failed')
120
+ } finally {
121
+ setIsBusy(false)
122
+ }
123
+ }
124
+
125
+ const applyDefaultSelection = () => {
126
+ if (!preview) return
127
+ const set = new Set(preview.defaults.selectedPaths)
128
+ const next: Record<string, boolean> = {}
129
+ for (const file of preview.files) next[file.path] = set.has(file.path)
130
+ setSelected(next)
131
+ }
132
+
133
+ const selectAll = () => {
134
+ if (!preview) return
135
+ const next: Record<string, boolean> = {}
136
+ for (const file of preview.files) next[file.path] = true
137
+ setSelected(next)
138
+ }
139
+
140
+ const clearAll = () => {
141
+ if (!preview) return
142
+ const next: Record<string, boolean> = {}
143
+ for (const file of preview.files) next[file.path] = false
144
+ setSelected(next)
145
+ }
146
+
147
+ const doImport = async () => {
148
+ if (!preview) return
149
+ setIsBusy(true)
150
+ setError(null)
151
+ setStatus('Importing…')
152
+ try {
153
+ const selectedPaths = preview.files.map((file) => file.path).filter((path) => selected[path])
154
+ const tagList = tags
155
+ .split(',')
156
+ .map((tag) => tag.trim())
157
+ .filter(Boolean)
158
+ const result = await importSkill({
159
+ url: url.trim(),
160
+ commit: preview.resolved.commit,
161
+ candidatePath: preview.candidate.path,
162
+ selectedPaths,
163
+ slug: slug.trim(),
164
+ displayName: displayName.trim(),
165
+ version: version.trim(),
166
+ tags: tagList,
167
+ })
168
+ const nextSlug = result.slug
169
+ setStatus('Imported.')
170
+ const ownerParam = me?.handle ?? (me?._id ? String(me._id) : 'unknown')
171
+ await navigate({ to: '/$owner/$slug', params: { owner: ownerParam, slug: nextSlug } })
172
+ } catch (e) {
173
+ setError(e instanceof Error ? e.message : 'Import failed')
174
+ setStatus(null)
175
+ } finally {
176
+ setIsBusy(false)
177
+ }
178
+ }
179
+
180
+ if (!isAuthenticated) {
181
+ return (
182
+ <main className="section">
183
+ <div className="card">
184
+ {isLoading ? 'Loading…' : 'Sign in to import and publish skills.'}
185
+ </div>
186
+ </main>
187
+ )
188
+ }
189
+
190
+ return (
191
+ <main className="section upload-shell">
192
+ <div className="upload-header">
193
+ <div>
194
+ <div className="upload-kicker">GitHub import</div>
195
+ <h1 className="upload-title">Import from GitHub</h1>
196
+ <p className="upload-subtitle">Public repos only. Detects SKILL.md automatically.</p>
197
+ </div>
198
+ <div className="upload-badge">
199
+ <div>Public only</div>
200
+ <div className="upload-badge-sub">Commit pinned</div>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="upload-card">
205
+ <div className="upload-fields">
206
+ <label className="upload-field" htmlFor="github-url">
207
+ <div className="upload-field-header">
208
+ <strong>GitHub URL</strong>
209
+ <span className="upload-field-hint">Repo, tree path, or blob</span>
210
+ </div>
211
+ <input
212
+ id="github-url"
213
+ className="upload-input"
214
+ value={url}
215
+ onChange={(e) => setUrl(e.target.value)}
216
+ placeholder="https://github.com/owner/repo"
217
+ autoCapitalize="none"
218
+ autoCorrect="off"
219
+ spellCheck={false}
220
+ />
221
+ </label>
222
+ </div>
223
+
224
+ <div className="upload-footer">
225
+ <button
226
+ className="btn btn-primary"
227
+ type="button"
228
+ disabled={!url.trim() || isBusy}
229
+ onClick={() => void detect()}
230
+ >
231
+ Detect
232
+ </button>
233
+ {status ? <p className="upload-muted">{status}</p> : null}
234
+ </div>
235
+
236
+ {error ? (
237
+ <div className="upload-validation">
238
+ <div className="upload-validation-item upload-error">{error}</div>
239
+ </div>
240
+ ) : null}
241
+ </div>
242
+
243
+ {candidates.length > 1 ? (
244
+ <div className="card">
245
+ <h2 style={{ margin: 0 }}>Pick a skill</h2>
246
+ <div className="upload-filelist">
247
+ {candidates.map((candidate) => (
248
+ <label key={candidate.path} className="upload-file">
249
+ <input
250
+ type="radio"
251
+ name="candidate"
252
+ checked={selectedCandidatePath === candidate.path}
253
+ onChange={() => void loadCandidate(candidate.path)}
254
+ disabled={isBusy}
255
+ />
256
+ <span className="mono">{candidate.path || '(repo root)'}</span>
257
+ <span>
258
+ {candidate.name
259
+ ? candidate.name
260
+ : candidate.description
261
+ ? candidate.description
262
+ : ''}
263
+ </span>
264
+ </label>
265
+ ))}
266
+ </div>
267
+ </div>
268
+ ) : null}
269
+
270
+ {preview ? (
271
+ <>
272
+ <div className="upload-card">
273
+ <div className="upload-grid">
274
+ <div className="upload-fields">
275
+ <label className="upload-field" htmlFor="slug">
276
+ <div className="upload-field-header">
277
+ <strong>Slug</strong>
278
+ <span className="upload-field-hint">Unique, lowercase</span>
279
+ </div>
280
+ <input
281
+ id="slug"
282
+ className="upload-input"
283
+ value={slug}
284
+ onChange={(e) => setSlug(e.target.value)}
285
+ autoCapitalize="none"
286
+ autoCorrect="off"
287
+ spellCheck={false}
288
+ />
289
+ </label>
290
+ <label className="upload-field" htmlFor="name">
291
+ <div className="upload-field-header">
292
+ <strong>Display name</strong>
293
+ <span className="upload-field-hint">Shown in listings</span>
294
+ </div>
295
+ <input
296
+ id="name"
297
+ className="upload-input"
298
+ value={displayName}
299
+ onChange={(e) => setDisplayName(e.target.value)}
300
+ />
301
+ </label>
302
+ <div className="upload-row">
303
+ <label className="upload-field" htmlFor="version">
304
+ <div className="upload-field-header">
305
+ <strong>Version</strong>
306
+ <span className="upload-field-hint">Semver</span>
307
+ </div>
308
+ <input
309
+ id="version"
310
+ className="upload-input"
311
+ value={version}
312
+ onChange={(e) => setVersion(e.target.value)}
313
+ autoCapitalize="none"
314
+ autoCorrect="off"
315
+ spellCheck={false}
316
+ />
317
+ </label>
318
+ <label className="upload-field" htmlFor="tags">
319
+ <div className="upload-field-header">
320
+ <strong>Tags</strong>
321
+ <span className="upload-field-hint">Comma-separated</span>
322
+ </div>
323
+ <input
324
+ id="tags"
325
+ className="upload-input"
326
+ value={tags}
327
+ onChange={(e) => setTags(e.target.value)}
328
+ autoCapitalize="none"
329
+ autoCorrect="off"
330
+ spellCheck={false}
331
+ />
332
+ </label>
333
+ </div>
334
+ </div>
335
+ <aside className="upload-side">
336
+ <div className="upload-summary">
337
+ <div className="upload-requirement ok">Commit pinned</div>
338
+ <div className="upload-muted">
339
+ {preview.resolved.owner}/{preview.resolved.repo}@
340
+ {preview.resolved.commit.slice(0, 7)}
341
+ </div>
342
+ <div className="upload-muted mono">{preview.candidate.path || 'repo root'}</div>
343
+ </div>
344
+ </aside>
345
+ </div>
346
+ </div>
347
+
348
+ <div className="card">
349
+ <div
350
+ style={{
351
+ display: 'flex',
352
+ justifyContent: 'space-between',
353
+ gap: 12,
354
+ flexWrap: 'wrap',
355
+ }}
356
+ >
357
+ <h2 style={{ margin: 0 }}>Files</h2>
358
+ <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
359
+ <button
360
+ className="btn"
361
+ type="button"
362
+ disabled={isBusy}
363
+ onClick={applyDefaultSelection}
364
+ >
365
+ Select referenced
366
+ </button>
367
+ <button className="btn" type="button" disabled={isBusy} onClick={selectAll}>
368
+ Select all
369
+ </button>
370
+ <button className="btn" type="button" disabled={isBusy} onClick={clearAll}>
371
+ Clear
372
+ </button>
373
+ </div>
374
+ </div>
375
+ <div className="upload-muted">
376
+ Selected: {selectedCount}/{preview.files.length} • {formatBytes(selectedBytes)}
377
+ </div>
378
+ <div className="file-list">
379
+ {preview.files.map((file) => (
380
+ <label key={file.path} className="file-row">
381
+ <input
382
+ type="checkbox"
383
+ checked={Boolean(selected[file.path])}
384
+ onChange={() =>
385
+ setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
386
+ }
387
+ disabled={isBusy}
388
+ />
389
+ <span className="mono file-path">{file.path}</span>
390
+ <span className="file-meta">{formatBytes(file.size)}</span>
391
+ </label>
392
+ ))}
393
+ </div>
394
+ <div className="upload-footer">
395
+ <button
396
+ className="btn btn-primary"
397
+ type="button"
398
+ disabled={
399
+ isBusy ||
400
+ !slug.trim() ||
401
+ !displayName.trim() ||
402
+ !version.trim() ||
403
+ selectedCount === 0
404
+ }
405
+ onClick={() => void doImport()}
406
+ >
407
+ Import + publish
408
+ </button>
409
+ </div>
410
+ </div>
411
+ </>
412
+ ) : null}
413
+ </main>
414
+ )
415
+ }