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,427 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { realpath } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { resolve } from 'node:path'
5
+ import { isCancel, multiselect } from '@clack/prompts'
6
+ import semver from 'semver'
7
+ import { apiRequest, downloadZip } from '../../http.js'
8
+ import {
9
+ ApiCliTelemetrySyncResponseSchema,
10
+ ApiRoutes,
11
+ ApiV1SkillResolveResponseSchema,
12
+ ApiV1SkillResponseSchema,
13
+ ApiV1WhoamiResponseSchema,
14
+ LegacyApiRoutes,
15
+ } from '../../schema/index.js'
16
+ import { hashSkillZip } from '../../skills.js'
17
+ import { getRegistry } from '../registry.js'
18
+ import { findSkillFolders, type SkillFolder } from '../scanSkills.js'
19
+ import type { GlobalOpts } from '../types.js'
20
+ import { fail, formatError } from '../ui.js'
21
+ import type { Candidate, LocalSkill } from './syncTypes.js'
22
+
23
+ export async function reportTelemetryIfEnabled(params: {
24
+ token: string
25
+ registry: string
26
+ scan: { roots: string[]; skillsByRoot: Record<string, SkillFolder[]> }
27
+ candidates: Candidate[]
28
+ }) {
29
+ if (isTelemetryDisabled()) return
30
+ const versionBySlug = new Map<string, string | null>()
31
+ for (const candidate of params.candidates) {
32
+ versionBySlug.set(candidate.slug, candidate.matchVersion ?? null)
33
+ }
34
+
35
+ const roots = params.scan.roots.map((root) => ({
36
+ rootId: rootTelemetryId(root),
37
+ label: formatRootLabel(root),
38
+ skills: (params.scan.skillsByRoot[root] ?? []).map((skill) => ({
39
+ slug: skill.slug,
40
+ version: versionBySlug.get(skill.slug) ?? null,
41
+ })),
42
+ }))
43
+
44
+ try {
45
+ await apiRequest(
46
+ params.registry,
47
+ {
48
+ method: 'POST',
49
+ path: LegacyApiRoutes.cliTelemetrySync,
50
+ token: params.token,
51
+ body: { roots },
52
+ },
53
+ ApiCliTelemetrySyncResponseSchema,
54
+ )
55
+ } catch {
56
+ // ignore telemetry failures
57
+ }
58
+ }
59
+
60
+ function isTelemetryDisabled() {
61
+ const raw = process.env.PILOTHUB_DISABLE_TELEMETRY
62
+ if (!raw) return false
63
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())
64
+ }
65
+
66
+ export function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) {
67
+ const roots = [opts.workdir, opts.dir, ...(extraRoots ?? [])]
68
+ return Array.from(new Set(roots.map((root) => resolve(root))))
69
+ }
70
+
71
+ export function normalizeConcurrency(value: number | undefined) {
72
+ const raw = typeof value === 'number' ? value : 4
73
+ const rounded = Number.isFinite(raw) ? Math.round(raw) : 4
74
+ return Math.min(32, Math.max(1, rounded))
75
+ }
76
+
77
+ export async function mapWithConcurrency<T, R>(
78
+ items: T[],
79
+ limit: number,
80
+ fn: (item: T) => Promise<R>,
81
+ ) {
82
+ const results = Array.from({ length: items.length }) as R[]
83
+ let nextIndex = 0
84
+ const workerCount = Math.min(Math.max(1, limit), items.length || 1)
85
+
86
+ async function worker() {
87
+ while (true) {
88
+ const index = nextIndex
89
+ nextIndex += 1
90
+ if (index >= items.length) return
91
+ results[index] = await fn(items[index] as T)
92
+ }
93
+ }
94
+
95
+ await Promise.all(Array.from({ length: workerCount }, () => worker()))
96
+ return results
97
+ }
98
+
99
+ export async function checkRegistrySyncState(
100
+ registry: string,
101
+ skill: LocalSkill,
102
+ resolveSupport: { value: boolean | null },
103
+ ): Promise<Candidate> {
104
+ if (resolveSupport.value !== false) {
105
+ try {
106
+ const resolved = await apiRequest(
107
+ registry,
108
+ {
109
+ method: 'GET',
110
+ path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
111
+ },
112
+ ApiV1SkillResolveResponseSchema,
113
+ )
114
+ resolveSupport.value = true
115
+ const latestVersion = resolved.latestVersion?.version ?? null
116
+ const matchVersion = resolved.match?.version ?? null
117
+ if (!latestVersion) {
118
+ return {
119
+ ...skill,
120
+ status: 'new',
121
+ matchVersion: null,
122
+ latestVersion: null,
123
+ }
124
+ }
125
+ return {
126
+ ...skill,
127
+ status: matchVersion ? 'synced' : 'update',
128
+ matchVersion,
129
+ latestVersion,
130
+ }
131
+ } catch (error) {
132
+ const message = formatError(error)
133
+ if (/skill not found/i.test(message) || /HTTP 404/i.test(message)) {
134
+ resolveSupport.value = true
135
+ return {
136
+ ...skill,
137
+ status: 'new',
138
+ matchVersion: null,
139
+ latestVersion: null,
140
+ }
141
+ }
142
+ if (/no matching routes found/i.test(message)) {
143
+ resolveSupport.value = false
144
+ } else {
145
+ throw error
146
+ }
147
+ }
148
+ }
149
+
150
+ const meta = await apiRequest(
151
+ registry,
152
+ { method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
153
+ ApiV1SkillResponseSchema,
154
+ ).catch(() => null)
155
+
156
+ const latestVersion = meta?.latestVersion?.version ?? null
157
+ if (!latestVersion) {
158
+ return {
159
+ ...skill,
160
+ status: 'new',
161
+ matchVersion: null,
162
+ latestVersion: null,
163
+ }
164
+ }
165
+
166
+ const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
167
+ const remote = hashSkillZip(zip).fingerprint
168
+ const matchVersion = remote === skill.fingerprint ? latestVersion : null
169
+
170
+ return {
171
+ ...skill,
172
+ status: matchVersion ? 'synced' : 'update',
173
+ matchVersion,
174
+ latestVersion,
175
+ }
176
+ }
177
+
178
+ export async function scanRoots(roots: string[]) {
179
+ const result = await scanRootsWithLabels(roots)
180
+ return {
181
+ roots: result.roots,
182
+ skillsByRoot: result.skillsByRoot,
183
+ skills: result.skills,
184
+ rootsWithSkills: result.rootsWithSkills,
185
+ }
186
+ }
187
+
188
+ export async function scanRootsWithLabels(roots: string[], labels?: Record<string, string>) {
189
+ const all: SkillFolder[] = []
190
+ const rootsWithSkills: string[] = []
191
+ const uniqueRoots = await dedupeRoots(roots)
192
+ const skillsByRoot: Record<string, SkillFolder[]> = {}
193
+ const rootLabels: Record<string, string> = {}
194
+ for (const root of uniqueRoots) {
195
+ const found = await findSkillFolders(root)
196
+ skillsByRoot[root] = found
197
+ if (found.length > 0) rootsWithSkills.push(root)
198
+ all.push(...found)
199
+ if (labels?.[root]) rootLabels[root] = labels[root] as string
200
+ }
201
+ const byFolder = new Map<string, SkillFolder>()
202
+ for (const folder of all) {
203
+ byFolder.set(folder.folder, folder)
204
+ }
205
+ return {
206
+ roots: uniqueRoots,
207
+ skillsByRoot,
208
+ skills: Array.from(byFolder.values()),
209
+ rootsWithSkills,
210
+ rootLabels,
211
+ }
212
+ }
213
+
214
+ export function mergeScan(
215
+ left: {
216
+ roots: string[]
217
+ skillsByRoot: Record<string, SkillFolder[]>
218
+ skills: SkillFolder[]
219
+ rootsWithSkills: string[]
220
+ rootLabels: Record<string, string>
221
+ },
222
+ right: {
223
+ roots: string[]
224
+ skillsByRoot: Record<string, SkillFolder[]>
225
+ skills: SkillFolder[]
226
+ rootsWithSkills: string[]
227
+ rootLabels: Record<string, string>
228
+ },
229
+ ) {
230
+ const mergedRoots = Array.from(new Set([...left.roots, ...right.roots]))
231
+ const skillsByRoot: Record<string, SkillFolder[]> = {}
232
+ for (const root of mergedRoots) {
233
+ skillsByRoot[root] = right.skillsByRoot[root] ?? left.skillsByRoot[root] ?? []
234
+ }
235
+ const rootLabels: Record<string, string> = { ...left.rootLabels, ...right.rootLabels }
236
+ const byFolder = new Map<string, SkillFolder>()
237
+ for (const entry of [...left.skills, ...right.skills]) {
238
+ byFolder.set(entry.folder, entry)
239
+ }
240
+ const skills = Array.from(byFolder.values())
241
+ const rootsWithSkills = mergedRoots.filter((root) => (skillsByRoot[root]?.length ?? 0) > 0)
242
+ return { roots: mergedRoots, skillsByRoot, skills, rootsWithSkills, rootLabels }
243
+ }
244
+
245
+ async function dedupeRoots(roots: string[]) {
246
+ const seen = new Set<string>()
247
+ const unique: string[] = []
248
+ for (const root of roots) {
249
+ const resolved = resolve(root)
250
+ const canonical = await realpath(resolved).catch(() => null)
251
+ const key = canonical ?? resolved
252
+ if (seen.has(key)) continue
253
+ seen.add(key)
254
+ unique.push(key)
255
+ }
256
+ return unique
257
+ }
258
+
259
+ export async function selectToUpload(
260
+ candidates: Candidate[],
261
+ params: { allowPrompt: boolean; all: boolean; bump: 'patch' | 'minor' | 'major' },
262
+ ): Promise<Candidate[]> {
263
+ if (params.all || !params.allowPrompt) return candidates
264
+
265
+ const valueByKey = new Map<string, Candidate>()
266
+ const choices = candidates.map((candidate) => {
267
+ const key = candidate.folder
268
+ valueByKey.set(key, candidate)
269
+ return {
270
+ value: key,
271
+ label: `${candidate.slug} ${formatActionableStatus(candidate, params.bump)}`,
272
+ hint: `${abbreviatePath(candidate.folder)} | ${candidate.fileCount} files`,
273
+ }
274
+ })
275
+
276
+ const picked = await multiselect({
277
+ message: 'Select skills to upload',
278
+ options: choices,
279
+ initialValues: choices.map((choice) => choice.value),
280
+ required: false,
281
+ })
282
+ if (isCancel(picked)) fail('Canceled')
283
+ const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[]
284
+ return selected
285
+ }
286
+
287
+ export async function resolvePublishMeta(
288
+ skill: Candidate,
289
+ params: { bump: 'patch' | 'minor' | 'major'; allowPrompt: boolean; changelogFlag?: string },
290
+ ) {
291
+ if (skill.status === 'new') {
292
+ return { publishVersion: '1.0.0', changelog: '' }
293
+ }
294
+
295
+ const latest = skill.latestVersion
296
+ if (!latest) fail(`Could not resolve latest version for ${skill.slug}`)
297
+ const publishVersion = semver.inc(latest, params.bump)
298
+ if (!publishVersion) fail(`Could not bump version for ${skill.slug}`)
299
+
300
+ const fromFlag = params.changelogFlag?.trim()
301
+ if (fromFlag) return { publishVersion, changelog: fromFlag }
302
+
303
+ return { publishVersion, changelog: '' }
304
+ }
305
+
306
+ export async function getRegistryWithAuth(opts: GlobalOpts, token: string) {
307
+ const registry = await getRegistry(opts, { cache: true })
308
+ await apiRequest(
309
+ registry,
310
+ { method: 'GET', path: ApiRoutes.whoami, token },
311
+ ApiV1WhoamiResponseSchema,
312
+ )
313
+ return registry
314
+ }
315
+
316
+ export function formatList(values: string[], max: number) {
317
+ if (values.length === 0) return ''
318
+ const shown = values.map(abbreviatePath)
319
+ if (shown.length <= max) return shown.join('\n')
320
+ const head = shown.slice(0, Math.max(1, max - 1))
321
+ const rest = values.length - head.length
322
+ return [...head, `… +${rest} more`].join('\n')
323
+ }
324
+
325
+ export function printSection(title: string, body?: string) {
326
+ const trimmed = body?.trim()
327
+ if (!trimmed) {
328
+ console.log(title)
329
+ return
330
+ }
331
+ if (trimmed.includes('\n')) {
332
+ console.log(`\n${title}\n${trimmed}`)
333
+ return
334
+ }
335
+ console.log(`${title}: ${trimmed}`)
336
+ }
337
+
338
+ function abbreviatePath(value: string) {
339
+ const home = homedir()
340
+ if (value.startsWith(home)) return `~${value.slice(home.length)}`
341
+ return value
342
+ }
343
+
344
+ function rootTelemetryId(value: string) {
345
+ return createHash('sha256').update(value).digest('hex')
346
+ }
347
+
348
+ function formatRootLabel(value: string) {
349
+ const home = homedir()
350
+ if (value === home) return '~'
351
+
352
+ const normalized = value.replaceAll('\\', '/')
353
+ const normalizedHome = home.replaceAll('\\', '/')
354
+ const isHome = normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`)
355
+
356
+ const stripped = isHome ? normalized.slice(normalizedHome.length).replace(/^\//, '') : normalized
357
+ const parts = stripped.split('/').filter(Boolean)
358
+ const tail = parts.slice(-2).join('/')
359
+
360
+ if (!tail) return isHome ? '~' : '…'
361
+ return isHome ? `~/${tail}` : `…/${tail}`
362
+ }
363
+
364
+ export function dedupeSkillsBySlug(skills: SkillFolder[]) {
365
+ const bySlug = new Map<string, SkillFolder[]>()
366
+ for (const skill of skills) {
367
+ const existing = bySlug.get(skill.slug)
368
+ if (existing) existing.push(skill)
369
+ else bySlug.set(skill.slug, [skill])
370
+ }
371
+ const unique: SkillFolder[] = []
372
+ const duplicates: string[] = []
373
+ for (const [slug, entries] of bySlug.entries()) {
374
+ unique.push(entries[0] as SkillFolder)
375
+ if (entries.length > 1) duplicates.push(`${slug} (${entries.length})`)
376
+ }
377
+ return { skills: unique, duplicates }
378
+ }
379
+
380
+ export function formatActionableStatus(
381
+ candidate: Candidate,
382
+ bump: 'patch' | 'minor' | 'major',
383
+ ): string {
384
+ if (candidate.status === 'new') return 'NEW'
385
+ const latest = candidate.latestVersion
386
+ const next = latest ? semver.inc(latest, bump) : null
387
+ if (latest && next) return `UPDATE ${latest} → ${next}`
388
+ return 'UPDATE'
389
+ }
390
+
391
+ export function formatActionableLine(
392
+ candidate: Candidate,
393
+ bump: 'patch' | 'minor' | 'major',
394
+ ): string {
395
+ return `${candidate.slug} ${formatActionableStatus(candidate, bump)} (${candidate.fileCount} files)`
396
+ }
397
+
398
+ function formatSyncedLine(candidate: Candidate): string {
399
+ const version = candidate.matchVersion ?? candidate.latestVersion ?? 'unknown'
400
+ return `${candidate.slug} synced (${version})`
401
+ }
402
+
403
+ export function formatSyncedSummary(candidate: Candidate): string {
404
+ const version = candidate.matchVersion ?? candidate.latestVersion
405
+ return version ? `${candidate.slug}@${version}` : candidate.slug
406
+ }
407
+
408
+ export function formatBulletList(lines: string[], max: number): string {
409
+ if (lines.length <= max) return lines.map((line) => `- ${line}`).join('\n')
410
+ const head = lines.slice(0, max)
411
+ const rest = lines.length - head.length
412
+ return [...head, `... +${rest} more`].map((line) => `- ${line}`).join('\n')
413
+ }
414
+
415
+ export function formatSyncedDisplay(synced: Candidate[]) {
416
+ const lines = synced.map(formatSyncedLine)
417
+ if (lines.length <= 12) return formatBulletList(lines, 12)
418
+ return formatCommaList(synced.map(formatSyncedSummary), 24)
419
+ }
420
+
421
+ export function formatCommaList(values: string[], max: number) {
422
+ if (values.length === 0) return ''
423
+ if (values.length <= max) return values.join(', ')
424
+ const head = values.slice(0, Math.max(1, max - 1))
425
+ const rest = values.length - head.length
426
+ return `${head.join(', ')}, ... +${rest} more`
427
+ }
@@ -0,0 +1,27 @@
1
+ import type { SkillOrigin } from '../../skills.js'
2
+ import type { SkillFolder } from '../scanSkills.js'
3
+
4
+ export type SyncOptions = {
5
+ root?: string[]
6
+ all?: boolean
7
+ dryRun?: boolean
8
+ bump?: 'patch' | 'minor' | 'major'
9
+ changelog?: string
10
+ tags?: string
11
+ concurrency?: number
12
+ }
13
+
14
+ export type Candidate = SkillFolder & {
15
+ fingerprint: string
16
+ fileCount: number
17
+ origin: SkillOrigin | null
18
+ status: 'synced' | 'new' | 'update'
19
+ matchVersion: string | null
20
+ latestVersion: string | null
21
+ }
22
+
23
+ export type LocalSkill = SkillFolder & {
24
+ fingerprint: string
25
+ fileCount: number
26
+ origin: SkillOrigin | null
27
+ }
@@ -0,0 +1,48 @@
1
+ import { readGlobalConfig } from '../../config.js'
2
+ import { apiRequest } from '../../http.js'
3
+ import { ApiRoutes, ApiV1UnstarResponseSchema } from '../../schema/index.js'
4
+ import { getRegistry } from '../registry.js'
5
+ import type { GlobalOpts } from '../types.js'
6
+ import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
7
+
8
+ async function requireToken() {
9
+ const cfg = await readGlobalConfig()
10
+ const token = cfg?.token
11
+ if (!token) fail('Not logged in. Run: pilothub login')
12
+ return token
13
+ }
14
+
15
+ export async function cmdUnstarSkill(
16
+ opts: GlobalOpts,
17
+ slugArg: string,
18
+ options: { yes?: boolean },
19
+ inputAllowed: boolean,
20
+ ) {
21
+ const slug = slugArg.trim().toLowerCase()
22
+ if (!slug) fail('Slug required')
23
+ const allowPrompt = isInteractive() && inputAllowed !== false
24
+
25
+ if (!options.yes) {
26
+ if (!allowPrompt) fail('Pass --yes (no input)')
27
+ const ok = await promptConfirm(`Unstar ${slug}?`)
28
+ if (!ok) return
29
+ }
30
+
31
+ const token = await requireToken()
32
+ const registry = await getRegistry(opts, { cache: true })
33
+ const spinner = createSpinner(`Unstarring ${slug}`)
34
+ try {
35
+ const result = await apiRequest(
36
+ registry,
37
+ { method: 'DELETE', path: `${ApiRoutes.stars}/${encodeURIComponent(slug)}`, token },
38
+ ApiV1UnstarResponseSchema,
39
+ )
40
+ spinner.succeed(
41
+ result.alreadyUnstarred ? `OK. ${slug} already unstarred.` : `OK. Unstarred ${slug}`,
42
+ )
43
+ return result
44
+ } catch (error) {
45
+ spinner.fail(formatError(error))
46
+ throw error
47
+ }
48
+ }
@@ -0,0 +1,45 @@
1
+ type Color = (value: string) => string
2
+
3
+ function wrap(start: string, end = '\x1b[0m'): Color {
4
+ return (value) => `${start}${value}${end}`
5
+ }
6
+
7
+ const ansi = {
8
+ reset: '\x1b[0m',
9
+ bold: wrap('\x1b[1m'),
10
+ dim: wrap('\x1b[2m'),
11
+ cyan: wrap('\x1b[36m'),
12
+ green: wrap('\x1b[32m'),
13
+ yellow: wrap('\x1b[33m'),
14
+ }
15
+
16
+ function isColorEnabled() {
17
+ if (!process.stdout.isTTY) return false
18
+ if (process.env.NO_COLOR) return false
19
+ return true
20
+ }
21
+
22
+ export function styleTitle(value: string) {
23
+ if (!isColorEnabled()) return value
24
+ return `${ansi.bold(ansi.cyan(value))}${ansi.reset}`
25
+ }
26
+
27
+ export function configureCommanderHelp(program: {
28
+ configureHelp: (config: {
29
+ sectionTitle?: (title: string) => string
30
+ optionTerm?: (option: { flags: string }) => string
31
+ commandTerm?: (cmd: { name: () => string }) => string
32
+ }) => unknown
33
+ }) {
34
+ if (!isColorEnabled()) return
35
+ program.configureHelp({
36
+ sectionTitle: (title) => ansi.bold(ansi.cyan(title)),
37
+ optionTerm: (option) => ansi.yellow(option.flags),
38
+ commandTerm: (cmd) => ansi.green(cmd.name()),
39
+ })
40
+ }
41
+
42
+ export function styleEnvBlock(value: string) {
43
+ if (!isColorEnabled()) return value
44
+ return `${ansi.dim(value)}${ansi.reset}`
45
+ }
@@ -0,0 +1,159 @@
1
+ /* @vitest-environment node */
2
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { resolvePilotbotDefaultWorkspace, resolvePilotbotSkillRoots } from './pilotbotConfig.js'
7
+
8
+ const originalEnv = { ...process.env }
9
+
10
+ afterEach(() => {
11
+ process.env = { ...originalEnv }
12
+ })
13
+
14
+ describe('resolvePilotbotSkillRoots', () => {
15
+ it('reads JSON5 config and resolves per-agent + shared skill roots', async () => {
16
+ const base = await mkdtemp(join(tmpdir(), 'pilothub-pilotbot-'))
17
+ const home = join(base, 'home')
18
+ const stateDir = join(base, 'state')
19
+ const configPath = join(base, 'pilotbot.json')
20
+
21
+ process.env.HOME = home
22
+ process.env.PILOTBOT_STATE_DIR = stateDir
23
+ process.env.PILOTBOT_CONFIG_PATH = configPath
24
+
25
+ const config = `{
26
+ // JSON5 comments + trailing commas supported
27
+ agents: {
28
+ defaults: { workspace: '~/pilot-main', },
29
+ list: [
30
+ { id: 'work', name: 'Work Bot', workspace: '~/pilot-work', },
31
+ { id: 'family', workspace: '~/pilot-family', },
32
+ ],
33
+ },
34
+ // legacy entries still supported
35
+ agent: { workspace: '~/pilot-legacy', },
36
+ routing: {
37
+ agents: {
38
+ work: { name: 'Work Bot', workspace: '~/pilot-work', },
39
+ family: { workspace: '~/pilot-family' },
40
+ },
41
+ },
42
+ skills: {
43
+ load: { extraDirs: ['~/shared/skills', '/opt/skills',], },
44
+ },
45
+ }`
46
+ await writeFile(configPath, config, 'utf8')
47
+
48
+ const { roots, labels } = await resolvePilotbotSkillRoots()
49
+
50
+ const expectedRoots = [
51
+ resolve(stateDir, 'skills'),
52
+ resolve(home, 'pilot-main', 'skills'),
53
+ resolve(home, 'pilot-work', 'skills'),
54
+ resolve(home, 'pilot-family', 'skills'),
55
+ resolve(home, 'shared', 'skills'),
56
+ resolve('/opt/skills'),
57
+ ]
58
+
59
+ expect(roots).toEqual(expect.arrayContaining(expectedRoots))
60
+ expect(labels[resolve(stateDir, 'skills')]).toBe('Shared skills')
61
+ expect(labels[resolve(home, 'pilot-main', 'skills')]).toBe('Agent: main')
62
+ expect(labels[resolve(home, 'pilot-work', 'skills')]).toBe('Agent: Work Bot')
63
+ expect(labels[resolve(home, 'pilot-family', 'skills')]).toBe('Agent: family')
64
+ expect(labels[resolve(home, 'shared', 'skills')]).toBe('Extra: skills')
65
+ expect(labels[resolve('/opt/skills')]).toBe('Extra: skills')
66
+ })
67
+
68
+ it('resolves default workspace from agents.defaults and agents.list', async () => {
69
+ const base = await mkdtemp(join(tmpdir(), 'pilothub-pilotbot-default-'))
70
+ const home = join(base, 'home')
71
+ const stateDir = join(base, 'state')
72
+ const configPath = join(base, 'pilotbot.json')
73
+ const workspaceMain = join(base, 'workspace-main')
74
+ const workspaceList = join(base, 'workspace-list')
75
+
76
+ process.env.HOME = home
77
+ process.env.PILOTBOT_STATE_DIR = stateDir
78
+ process.env.PILOTBOT_CONFIG_PATH = configPath
79
+
80
+ const config = `{
81
+ agents: {
82
+ defaults: { workspace: "${workspaceMain}", },
83
+ list: [
84
+ { id: 'main', workspace: "${workspaceList}", default: true },
85
+ ],
86
+ },
87
+ }`
88
+ await writeFile(configPath, config, 'utf8')
89
+
90
+ const workspace = await resolvePilotbotDefaultWorkspace()
91
+ expect(workspace).toBe(resolve(workspaceMain))
92
+ })
93
+
94
+ it('falls back to default agent in agents.list when defaults missing', async () => {
95
+ const base = await mkdtemp(join(tmpdir(), 'pilothub-pilotbot-list-'))
96
+ const home = join(base, 'home')
97
+ const configPath = join(base, 'pilotbot.json')
98
+ const workspaceMain = join(base, 'workspace-main')
99
+ const workspaceWork = join(base, 'workspace-work')
100
+
101
+ process.env.HOME = home
102
+ process.env.PILOTBOT_CONFIG_PATH = configPath
103
+
104
+ const config = `{
105
+ agents: {
106
+ list: [
107
+ { id: 'main', workspace: "${workspaceMain}", default: true },
108
+ { id: 'work', workspace: "${workspaceWork}" },
109
+ ],
110
+ },
111
+ }`
112
+ await writeFile(configPath, config, 'utf8')
113
+
114
+ const workspace = await resolvePilotbotDefaultWorkspace()
115
+ expect(workspace).toBe(resolve(workspaceMain))
116
+ })
117
+
118
+ it('respects PILOTBOT_STATE_DIR and PILOTBOT_CONFIG_PATH overrides', async () => {
119
+ const base = await mkdtemp(join(tmpdir(), 'pilothub-pilotbot-override-'))
120
+ const home = join(base, 'home')
121
+ const stateDir = join(base, 'custom-state')
122
+ const configPath = join(base, 'config', 'pilotbot.json')
123
+
124
+ process.env.HOME = home
125
+ process.env.PILOTBOT_STATE_DIR = stateDir
126
+ process.env.PILOTBOT_CONFIG_PATH = configPath
127
+
128
+ const config = `{
129
+ agent: { workspace: "${join(base, 'workspace-main')}" },
130
+ }`
131
+ await mkdir(join(base, 'config'), { recursive: true })
132
+ await writeFile(configPath, config, 'utf8')
133
+
134
+ const { roots, labels } = await resolvePilotbotSkillRoots()
135
+
136
+ expect(roots).toEqual(
137
+ expect.arrayContaining([
138
+ resolve(stateDir, 'skills'),
139
+ resolve(join(base, 'workspace-main'), 'skills'),
140
+ ]),
141
+ )
142
+ expect(labels[resolve(stateDir, 'skills')]).toBe('Shared skills')
143
+ expect(labels[resolve(join(base, 'workspace-main'), 'skills')]).toBe('Agent: main')
144
+ })
145
+
146
+ it('returns shared skills root when config is missing', async () => {
147
+ const base = await mkdtemp(join(tmpdir(), 'pilothub-pilotbot-missing-'))
148
+ const stateDir = join(base, 'state')
149
+ const configPath = join(base, 'missing', 'pilotbot.json')
150
+
151
+ process.env.PILOTBOT_STATE_DIR = stateDir
152
+ process.env.PILOTBOT_CONFIG_PATH = configPath
153
+
154
+ const { roots, labels } = await resolvePilotbotSkillRoots()
155
+
156
+ expect(roots).toEqual([resolve(stateDir, 'skills')])
157
+ expect(labels[resolve(stateDir, 'skills')]).toBe('Shared skills')
158
+ })
159
+ })