ikanban-web 0.1.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 (175) hide show
  1. package/README.md +65 -0
  2. package/bin/cli.js +1055 -0
  3. package/dist/apple-touch-icon-120x120.png +0 -0
  4. package/dist/apple-touch-icon-152x152.png +0 -0
  5. package/dist/apple-touch-icon-167x167.png +0 -0
  6. package/dist/apple-touch-icon-180x180.png +0 -0
  7. package/dist/apple-touch-icon.png +0 -0
  8. package/dist/apple-touch-icon.svg +16 -0
  9. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
  69. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  70. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  71. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  72. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  73. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  74. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  75. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  76. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  77. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  78. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  79. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  80. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  81. package/dist/assets/index-BQJPAP5f.css +1 -0
  82. package/dist/assets/index-MBuF9PXk.js +2 -0
  83. package/dist/assets/main-C1GggQif.css +1 -0
  84. package/dist/assets/main-Dyy8WvPJ.js +311 -0
  85. package/dist/assets/vendor--DEobTDja.css +1 -0
  86. package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
  87. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  88. package/dist/assets/worker-suRQxAtO.js +155 -0
  89. package/dist/favicon-16.png +0 -0
  90. package/dist/favicon-32.png +0 -0
  91. package/dist/favicon.png +0 -0
  92. package/dist/favicon.svg +15 -0
  93. package/dist/index.html +325 -0
  94. package/dist/logo-dark-192x192.png +0 -0
  95. package/dist/logo-dark-512x512.svg +16 -0
  96. package/dist/logo-light-192x192.png +0 -0
  97. package/dist/logo-light-512x512.svg +16 -0
  98. package/dist/pwa-192.png +0 -0
  99. package/dist/pwa-512.png +0 -0
  100. package/dist/pwa-maskable-192.png +0 -0
  101. package/dist/pwa-maskable-512.png +0 -0
  102. package/dist/site.webmanifest +22 -0
  103. package/dist/sw.js +1 -0
  104. package/package.json +102 -0
  105. package/public/apple-touch-icon-120x120.png +0 -0
  106. package/public/apple-touch-icon-152x152.png +0 -0
  107. package/public/apple-touch-icon-167x167.png +0 -0
  108. package/public/apple-touch-icon-180x180.png +0 -0
  109. package/public/apple-touch-icon.png +0 -0
  110. package/public/apple-touch-icon.svg +16 -0
  111. package/public/favicon-16.png +0 -0
  112. package/public/favicon-32.png +0 -0
  113. package/public/favicon.png +0 -0
  114. package/public/favicon.svg +15 -0
  115. package/public/logo-dark-192x192.png +0 -0
  116. package/public/logo-dark-512x512.svg +16 -0
  117. package/public/logo-light-192x192.png +0 -0
  118. package/public/logo-light-512x512.svg +16 -0
  119. package/public/pwa-192.png +0 -0
  120. package/public/pwa-512.png +0 -0
  121. package/public/pwa-maskable-192.png +0 -0
  122. package/public/pwa-maskable-512.png +0 -0
  123. package/public/site.webmanifest +22 -0
  124. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  125. package/server/index.d.ts +28 -0
  126. package/server/index.js +11181 -0
  127. package/server/lib/cloudflare-tunnel.js +196 -0
  128. package/server/lib/git-credentials.js +74 -0
  129. package/server/lib/git-identity-storage.js +110 -0
  130. package/server/lib/git-service.js +2690 -0
  131. package/server/lib/github-auth.js +307 -0
  132. package/server/lib/github-device-flow.js +50 -0
  133. package/server/lib/github-octokit.js +10 -0
  134. package/server/lib/github-repo.js +55 -0
  135. package/server/lib/notification-message.js +49 -0
  136. package/server/lib/notification-message.test.js +59 -0
  137. package/server/lib/opencode-auth.js +81 -0
  138. package/server/lib/opencode-config.js +1840 -0
  139. package/server/lib/opencode-config.js.d.ts +12 -0
  140. package/server/lib/package-manager.js +255 -0
  141. package/server/lib/quota/DOCUMENTATION.md +52 -0
  142. package/server/lib/quota/index.js +21 -0
  143. package/server/lib/quota/providers/claude.js +107 -0
  144. package/server/lib/quota/providers/codex.js +111 -0
  145. package/server/lib/quota/providers/copilot.js +165 -0
  146. package/server/lib/quota/providers/google/api.js +92 -0
  147. package/server/lib/quota/providers/google/auth.js +108 -0
  148. package/server/lib/quota/providers/google/index.js +124 -0
  149. package/server/lib/quota/providers/google/transforms.js +109 -0
  150. package/server/lib/quota/providers/index.js +128 -0
  151. package/server/lib/quota/providers/interface.js +55 -0
  152. package/server/lib/quota/providers/kimi.js +108 -0
  153. package/server/lib/quota/providers/nanogpt.js +124 -0
  154. package/server/lib/quota/providers/openai.js +91 -0
  155. package/server/lib/quota/providers/openrouter.js +92 -0
  156. package/server/lib/quota/providers/zai.js +91 -0
  157. package/server/lib/quota/utils/auth.js +46 -0
  158. package/server/lib/quota/utils/formatters.js +76 -0
  159. package/server/lib/quota/utils/index.js +10 -0
  160. package/server/lib/quota/utils/transformers.js +55 -0
  161. package/server/lib/skills-catalog/cache.js +29 -0
  162. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  163. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  164. package/server/lib/skills-catalog/clawdhub/install.js +223 -0
  165. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  166. package/server/lib/skills-catalog/curated-sources.js +21 -0
  167. package/server/lib/skills-catalog/git.js +76 -0
  168. package/server/lib/skills-catalog/install.js +280 -0
  169. package/server/lib/skills-catalog/scan.js +221 -0
  170. package/server/lib/skills-catalog/source.js +85 -0
  171. package/server/lib/summarization-service.js +171 -0
  172. package/server/lib/terminal-input-ws-protocol.js +66 -0
  173. package/server/lib/terminal-input-ws-protocol.test.js +138 -0
  174. package/server/lib/tts-service.js +162 -0
  175. package/server/lib/ui-auth.js +518 -0
@@ -0,0 +1,46 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
6
+ const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
7
+
8
+ export const ANTIGRAVITY_ACCOUNTS_PATHS = [
9
+ path.join(OPENCODE_CONFIG_DIR, 'antigravity-accounts.json'),
10
+ path.join(OPENCODE_DATA_DIR, 'antigravity-accounts.json')
11
+ ];
12
+
13
+ export const readJsonFile = (filePath) => {
14
+ if (!fs.existsSync(filePath)) {
15
+ return null;
16
+ }
17
+ try {
18
+ const raw = fs.readFileSync(filePath, 'utf8');
19
+ const trimmed = raw.trim();
20
+ if (!trimmed) return null;
21
+ return JSON.parse(trimmed);
22
+ } catch (error) {
23
+ console.warn(`Failed to read JSON file: ${filePath}`, error);
24
+ return null;
25
+ }
26
+ };
27
+
28
+ export const getAuthEntry = (auth, aliases) => {
29
+ for (const alias of aliases) {
30
+ if (auth[alias]) {
31
+ return auth[alias];
32
+ }
33
+ }
34
+ return null;
35
+ };
36
+
37
+ export const normalizeAuthEntry = (entry) => {
38
+ if (!entry) return null;
39
+ if (typeof entry === 'string') {
40
+ return { token: entry };
41
+ }
42
+ if (typeof entry === 'object') {
43
+ return entry;
44
+ }
45
+ return null;
46
+ };
@@ -0,0 +1,76 @@
1
+ export const formatResetTime = (timestamp) => {
2
+ try {
3
+ const resetDate = new Date(timestamp);
4
+ const now = new Date();
5
+ const isToday = resetDate.toDateString() === now.toDateString();
6
+
7
+ if (isToday) {
8
+ return resetDate.toLocaleTimeString(undefined, {
9
+ hour: 'numeric',
10
+ minute: '2-digit'
11
+ });
12
+ }
13
+
14
+ return resetDate.toLocaleString(undefined, {
15
+ month: 'short',
16
+ day: 'numeric',
17
+ weekday: 'short',
18
+ hour: 'numeric',
19
+ minute: '2-digit'
20
+ });
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ export const calculateResetAfterSeconds = (resetAt) => {
27
+ if (!resetAt) return null;
28
+ const delta = Math.floor((resetAt - Date.now()) / 1000);
29
+ return delta < 0 ? 0 : delta;
30
+ };
31
+
32
+ export const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel }) => {
33
+ const resetAfterSeconds = calculateResetAfterSeconds(resetAt);
34
+ const resetFormatted = resetAt ? formatResetTime(resetAt) : null;
35
+ return {
36
+ usedPercent,
37
+ remainingPercent: usedPercent !== null ? Math.max(0, 100 - usedPercent) : null,
38
+ windowSeconds: windowSeconds ?? null,
39
+ resetAfterSeconds,
40
+ resetAt,
41
+ resetAtFormatted: resetFormatted,
42
+ resetAfterFormatted: resetFormatted,
43
+ ...(valueLabel ? { valueLabel } : {})
44
+ };
45
+ };
46
+
47
+ export const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({
48
+ providerId,
49
+ providerName,
50
+ ok,
51
+ configured,
52
+ usage: usage ?? null,
53
+ ...(error ? { error } : {}),
54
+ fetchedAt: Date.now()
55
+ });
56
+
57
+ export const durationToLabel = (duration, unit) => {
58
+ if (!duration || !unit) return 'limit';
59
+ if (unit === 'TIME_UNIT_MINUTE') return `${duration}m`;
60
+ if (unit === 'TIME_UNIT_HOUR') return `${duration}h`;
61
+ if (unit === 'TIME_UNIT_DAY') return `${duration}d`;
62
+ return 'limit';
63
+ };
64
+
65
+ export const durationToSeconds = (duration, unit) => {
66
+ if (!duration || !unit) return null;
67
+ if (unit === 'TIME_UNIT_MINUTE') return duration * 60;
68
+ if (unit === 'TIME_UNIT_HOUR') return duration * 3600;
69
+ if (unit === 'TIME_UNIT_DAY') return duration * 86400;
70
+ return null;
71
+ };
72
+
73
+ export const formatMoney = (value) => {
74
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
75
+ return value.toFixed(2);
76
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Quota Utilities
3
+ *
4
+ * Shared utility functions for quota calculations and formatting.
5
+ * @module quota/utils
6
+ */
7
+
8
+ export * from './auth.js';
9
+ export * from './transformers.js';
10
+ export * from './formatters.js';
@@ -0,0 +1,55 @@
1
+ export const asObject = (value) => (value && typeof value === 'object' ? value : null);
2
+
3
+ export const asNonEmptyString = (value) => {
4
+ if (typeof value !== 'string') return null;
5
+ const trimmed = value.trim();
6
+ return trimmed ? trimmed : null;
7
+ };
8
+
9
+ export const toNumber = (value) => {
10
+ if (typeof value === 'number' && Number.isFinite(value)) {
11
+ return value;
12
+ }
13
+ if (typeof value === 'string') {
14
+ const parsed = Number(value);
15
+ return Number.isFinite(parsed) ? parsed : null;
16
+ }
17
+ return null;
18
+ };
19
+
20
+ export const toTimestamp = (value) => {
21
+ if (!value) return null;
22
+ if (typeof value === 'number') {
23
+ return value < 1_000_000_000_000 ? value * 1000 : value;
24
+ }
25
+ if (typeof value === 'string') {
26
+ const parsed = Date.parse(value);
27
+ return Number.isNaN(parsed) ? null : parsed;
28
+ }
29
+ return null;
30
+ };
31
+
32
+ export const normalizeTimestamp = (value) => {
33
+ if (typeof value !== 'number') return null;
34
+ return value < 1_000_000_000_000 ? value * 1000 : value;
35
+ };
36
+
37
+ export const resolveWindowSeconds = (limit) => {
38
+ const ZAI_TOKEN_WINDOW_SECONDS = { 3: 3600 };
39
+ if (!limit || !limit.number) return null;
40
+ const unitSeconds = ZAI_TOKEN_WINDOW_SECONDS[limit.unit];
41
+ if (!unitSeconds) return null;
42
+ return unitSeconds * limit.number;
43
+ };
44
+
45
+ export const resolveWindowLabel = (windowSeconds) => {
46
+ if (!windowSeconds) return 'tokens';
47
+ if (windowSeconds % 86400 === 0) {
48
+ const days = windowSeconds / 86400;
49
+ return days === 7 ? 'weekly' : `${days}d`;
50
+ }
51
+ if (windowSeconds % 3600 === 0) {
52
+ return `${windowSeconds / 3600}h`;
53
+ }
54
+ return `${windowSeconds}s`;
55
+ };
@@ -0,0 +1,29 @@
1
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
2
+
3
+ const cache = new Map();
4
+
5
+ export function getCacheKey({ normalizedRepo, subpath, identityId }) {
6
+ const safeRepo = String(normalizedRepo || '').trim();
7
+ const safeSubpath = String(subpath || '').trim();
8
+ const safeIdentity = String(identityId || '').trim();
9
+ return `${safeRepo}::${safeSubpath}::${safeIdentity}`;
10
+ }
11
+
12
+ export function getCachedScan(key) {
13
+ const entry = cache.get(key);
14
+ if (!entry) return null;
15
+ if (Date.now() >= entry.expiresAt) {
16
+ cache.delete(key);
17
+ return null;
18
+ }
19
+ return entry.value;
20
+ }
21
+
22
+ export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
23
+ const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
24
+ cache.set(key, { expiresAt: Date.now() + ttl, value });
25
+ }
26
+
27
+ export function clearCache() {
28
+ cache.clear();
29
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * ClawdHub API client
3
+ *
4
+ * ClawdHub is a public skill registry at https://clawdhub.com
5
+ * This client provides methods to fetch skills list and download skill packages.
6
+ */
7
+
8
+ const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
9
+ const CLAWDHUB_PAGE_LIMIT = 25;
10
+
11
+ // Rate limiting: ClawdHub allows 120 requests/minute
12
+ const RATE_LIMIT_DELAY_MS = 100;
13
+ let lastRequestTime = 0;
14
+
15
+ async function rateLimitedFetch(url, options = {}) {
16
+ const maxAttempts = 10;
17
+
18
+ let lastResponse = null;
19
+
20
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
21
+ const now = Date.now();
22
+ const elapsed = now - lastRequestTime;
23
+ if (elapsed < RATE_LIMIT_DELAY_MS) {
24
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed));
25
+ }
26
+ lastRequestTime = Date.now();
27
+
28
+ const response = await fetch(url, {
29
+ ...options,
30
+ headers: {
31
+ Accept: 'application/json',
32
+ 'User-Agent': 'IKanban/1.0',
33
+ ...options.headers,
34
+ },
35
+ });
36
+
37
+ lastResponse = response;
38
+
39
+ if (response.status === 429 || response.status >= 500) {
40
+ if (attempt < maxAttempts - 1) {
41
+ const waitMs = 50 * (attempt + 1);
42
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
43
+ continue;
44
+ }
45
+ }
46
+
47
+ return response;
48
+ }
49
+
50
+ return lastResponse;
51
+ }
52
+
53
+ /**
54
+ * Fetch paginated list of skills from ClawdHub
55
+ * @param {Object} options
56
+ * @param {string} [options.cursor] - Pagination cursor from previous response
57
+ * @returns {Promise<{ items: Array, nextCursor?: string }>}
58
+ */
59
+ export async function fetchClawdHubSkills({ cursor } = {}) {
60
+ const url = cursor
61
+ ? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
62
+ : `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
63
+
64
+ const response = await rateLimitedFetch(url);
65
+
66
+ if (!response.ok) {
67
+ const text = await response.text().catch(() => '');
68
+ throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`);
69
+ }
70
+
71
+ const data = await response.json();
72
+ const nextCursor =
73
+ (typeof data.nextCursor === 'string' && data.nextCursor) ||
74
+ (typeof data.next_cursor === 'string' && data.next_cursor) ||
75
+ (typeof data.next === 'string' && data.next) ||
76
+ (typeof data.cursor === 'string' && data.cursor) ||
77
+ null;
78
+
79
+ return {
80
+ items: data.items || [],
81
+ nextCursor,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Fetch details for a specific skill version
87
+ * @param {string} slug - Skill slug/identifier
88
+ * @param {string} [version='latest'] - Version string or 'latest'
89
+ * @returns {Promise<{ skill: Object, version: Object }>}
90
+ */
91
+ export async function fetchClawdHubSkillVersion(slug, version = 'latest') {
92
+ // For 'latest', we need to first get the skill metadata to find the latest version
93
+ if (version === 'latest') {
94
+ const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`);
95
+ if (!skillResponse.ok) {
96
+ throw new Error(`ClawdHub skill not found: ${slug}`);
97
+ }
98
+ const skillData = await skillResponse.json();
99
+ const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version;
100
+ if (!latestVersion) {
101
+ throw new Error(`No latest version found for skill: ${slug}`);
102
+ }
103
+ version = latestVersion;
104
+ }
105
+
106
+ const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
107
+ const response = await rateLimitedFetch(url);
108
+
109
+ if (!response.ok) {
110
+ const text = await response.text().catch(() => '');
111
+ throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`);
112
+ }
113
+
114
+ return response.json();
115
+ }
116
+
117
+ /**
118
+ * Download a skill package as a ZIP buffer
119
+ * @param {string} slug - Skill slug/identifier
120
+ * @param {string} version - Specific version string
121
+ * @returns {Promise<ArrayBuffer>} - ZIP file contents
122
+ */
123
+ export async function downloadClawdHubSkill(slug, version) {
124
+ const versionParam = typeof version === 'string' && version !== 'latest'
125
+ ? `&version=${encodeURIComponent(version)}`
126
+ : '&tag=latest';
127
+ const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`;
128
+
129
+ const response = await rateLimitedFetch(url, {
130
+ headers: {
131
+ Accept: 'application/zip',
132
+ },
133
+ });
134
+
135
+ if (!response.ok) {
136
+ const text = await response.text().catch(() => '');
137
+ throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`);
138
+ }
139
+
140
+ return response.arrayBuffer();
141
+ }
142
+
143
+ /**
144
+ * Get skill metadata without version details
145
+ * @param {string} slug - Skill slug/identifier
146
+ * @returns {Promise<Object>}
147
+ */
148
+ export async function fetchClawdHubSkillInfo(slug) {
149
+ const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
150
+ const response = await rateLimitedFetch(url);
151
+
152
+ if (!response.ok) {
153
+ const text = await response.text().catch(() => '');
154
+ throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`);
155
+ }
156
+
157
+ return response.json();
158
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ClawdHub integration module
3
+ *
4
+ * Provides skill browsing and installation from the ClawdHub registry.
5
+ * https://clawdhub.com
6
+ */
7
+
8
+ export { scanClawdHub, scanClawdHubPage } from './scan.js';
9
+ export { installSkillsFromClawdHub } from './install.js';
10
+ export {
11
+ fetchClawdHubSkills,
12
+ fetchClawdHubSkillVersion,
13
+ fetchClawdHubSkillInfo,
14
+ downloadClawdHubSkill,
15
+ } from './api.js';
16
+
17
+ /**
18
+ * Check if a source string refers to ClawdHub
19
+ * @param {string} source
20
+ * @returns {boolean}
21
+ */
22
+ export function isClawdHubSource(source) {
23
+ return typeof source === 'string' && source.startsWith('clawdhub:');
24
+ }
25
+
26
+ /**
27
+ * ClawdHub source identifier used in curated sources
28
+ */
29
+ export const CLAWDHUB_SOURCE_ID = 'clawdhub';
30
+ export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry';
@@ -0,0 +1,223 @@
1
+ /**
2
+ * ClawdHub skill installation
3
+ *
4
+ * Downloads skills from ClawdHub as ZIP files and extracts them
5
+ * to the appropriate skill directory.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import AdmZip from 'adm-zip';
12
+
13
+ import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js';
14
+
15
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
16
+
17
+ function normalizeUserSkillDir(userSkillDir) {
18
+ if (!userSkillDir) return null;
19
+ const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
20
+ const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
21
+ if (userSkillDir === legacySkillDir) {
22
+ if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
23
+ return pluralSkillDir;
24
+ }
25
+ return userSkillDir;
26
+ }
27
+
28
+ function validateSkillName(skillName) {
29
+ if (typeof skillName !== 'string') return false;
30
+ if (skillName.length < 1 || skillName.length > 64) return false;
31
+ return SKILL_NAME_PATTERN.test(skillName);
32
+ }
33
+
34
+ async function safeRm(dir) {
35
+ try {
36
+ await fs.promises.rm(dir, { recursive: true, force: true });
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+
42
+ async function ensureDir(dirPath) {
43
+ await fs.promises.mkdir(dirPath, { recursive: true });
44
+ }
45
+
46
+ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
47
+ if (scope === 'user') {
48
+ return path.join(userSkillDir, skillName);
49
+ }
50
+
51
+ if (!workingDirectory) {
52
+ throw new Error('workingDirectory is required for project installs');
53
+ }
54
+
55
+ return path.join(workingDirectory, '.opencode', 'skills', skillName);
56
+ }
57
+
58
+ /**
59
+ * Install skills from ClawdHub registry
60
+ * @param {Object} options
61
+ * @param {string} options.scope - 'user' or 'project'
62
+ * @param {string} [options.workingDirectory] - Required for project scope
63
+ * @param {string} options.userSkillDir - User skills directory
64
+ * @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
65
+ * @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll'
66
+ * @param {Object} [options.conflictDecisions] - Per-skill conflict decisions
67
+ * @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>}
68
+ */
69
+ export async function installSkillsFromClawdHub({
70
+ scope,
71
+ workingDirectory,
72
+ userSkillDir,
73
+ selections,
74
+ conflictPolicy,
75
+ conflictDecisions,
76
+ } = {}) {
77
+ if (scope !== 'user' && scope !== 'project') {
78
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
79
+ }
80
+
81
+ if (!userSkillDir) {
82
+ return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
83
+ }
84
+
85
+ const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
86
+ if (normalizedUserSkillDir) {
87
+ userSkillDir = normalizedUserSkillDir;
88
+ }
89
+
90
+ if (scope === 'project' && !workingDirectory) {
91
+ return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
92
+ }
93
+
94
+ const requestedSkills = Array.isArray(selections) ? selections : [];
95
+ if (requestedSkills.length === 0) {
96
+ return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
97
+ }
98
+
99
+ // Build installation plans
100
+ const skillPlans = requestedSkills.map((sel) => {
101
+ const slug = sel.clawdhub?.slug || sel.skillDir;
102
+ const version = sel.clawdhub?.version || 'latest';
103
+ return {
104
+ slug,
105
+ version,
106
+ installable: validateSkillName(slug),
107
+ };
108
+ });
109
+
110
+ // Check for conflicts before downloading
111
+ const conflicts = [];
112
+ for (const plan of skillPlans) {
113
+ if (!plan.installable) {
114
+ continue;
115
+ }
116
+
117
+ const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
118
+ if (fs.existsSync(targetDir)) {
119
+ const decision = conflictDecisions?.[plan.slug];
120
+ const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
121
+ if (!decision && !hasAutoPolicy) {
122
+ conflicts.push({ skillName: plan.slug, scope });
123
+ }
124
+ }
125
+ }
126
+
127
+ if (conflicts.length > 0) {
128
+ return {
129
+ ok: false,
130
+ error: {
131
+ kind: 'conflicts',
132
+ message: 'Some skills already exist in the selected scope',
133
+ conflicts,
134
+ },
135
+ };
136
+ }
137
+
138
+ const installed = [];
139
+ const skipped = [];
140
+
141
+ for (const plan of skillPlans) {
142
+ if (!plan.installable) {
143
+ skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' });
144
+ continue;
145
+ }
146
+
147
+ try {
148
+ // Resolve 'latest' version if needed
149
+ let resolvedVersion = plan.version;
150
+ if (resolvedVersion === 'latest') {
151
+ try {
152
+ const info = await fetchClawdHubSkillInfo(plan.slug);
153
+ const latest = info.skill?.tags?.latest || info.latestVersion?.version || null;
154
+ if (latest) {
155
+ resolvedVersion = latest;
156
+ }
157
+ } catch {
158
+ // ignore
159
+ }
160
+
161
+ if (resolvedVersion === 'latest') {
162
+ skipped.push({ skillName: plan.slug, reason: 'Unable to resolve latest version' });
163
+ continue;
164
+ }
165
+ }
166
+
167
+ const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
168
+ const exists = fs.existsSync(targetDir);
169
+
170
+ // Determine conflict resolution
171
+ let decision = conflictDecisions?.[plan.slug] || null;
172
+ if (!decision) {
173
+ if (exists && conflictPolicy === 'skipAll') decision = 'skip';
174
+ if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
175
+ if (!exists) decision = 'overwrite'; // No conflict, proceed
176
+ }
177
+
178
+ if (exists && decision === 'skip') {
179
+ skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' });
180
+ continue;
181
+ }
182
+
183
+ if (exists && decision === 'overwrite') {
184
+ await safeRm(targetDir);
185
+ }
186
+
187
+ // Download the skill ZIP
188
+ const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion);
189
+
190
+ // Extract to a temp directory first for validation
191
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`));
192
+
193
+ try {
194
+ const zip = new AdmZip(Buffer.from(zipBuffer));
195
+ zip.extractAllTo(tempDir, true);
196
+
197
+ // Verify SKILL.md exists
198
+ const skillMdPath = path.join(tempDir, 'SKILL.md');
199
+ if (!fs.existsSync(skillMdPath)) {
200
+ skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' });
201
+ continue;
202
+ }
203
+
204
+ // Move to target directory
205
+ await ensureDir(path.dirname(targetDir));
206
+ await fs.promises.rename(tempDir, targetDir);
207
+
208
+ installed.push({ skillName: plan.slug, scope });
209
+ } catch (extractError) {
210
+ await safeRm(tempDir);
211
+ throw extractError;
212
+ }
213
+ } catch (error) {
214
+ console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error);
215
+ skipped.push({
216
+ skillName: plan.slug,
217
+ reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
218
+ });
219
+ }
220
+ }
221
+
222
+ return { ok: true, installed, skipped };
223
+ }