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.
- package/README.md +65 -0
- package/bin/cli.js +1055 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +16 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-BQJPAP5f.css +1 -0
- package/dist/assets/index-MBuF9PXk.js +2 -0
- package/dist/assets/main-C1GggQif.css +1 -0
- package/dist/assets/main-Dyy8WvPJ.js +311 -0
- package/dist/assets/vendor--DEobTDja.css +1 -0
- package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-suRQxAtO.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +325 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +102 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +16 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +15 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +11181 -0
- package/server/lib/cloudflare-tunnel.js +196 -0
- package/server/lib/git-credentials.js +74 -0
- package/server/lib/git-identity-storage.js +110 -0
- package/server/lib/git-service.js +2690 -0
- package/server/lib/github-auth.js +307 -0
- package/server/lib/github-device-flow.js +50 -0
- package/server/lib/github-octokit.js +10 -0
- package/server/lib/github-repo.js +55 -0
- package/server/lib/notification-message.js +49 -0
- package/server/lib/notification-message.test.js +59 -0
- package/server/lib/opencode-auth.js +81 -0
- package/server/lib/opencode-config.js +1840 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/package-manager.js +255 -0
- package/server/lib/quota/DOCUMENTATION.md +52 -0
- package/server/lib/quota/index.js +21 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +111 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +128 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +223 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +76 -0
- package/server/lib/skills-catalog/install.js +280 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/summarization-service.js +171 -0
- package/server/lib/terminal-input-ws-protocol.js +66 -0
- package/server/lib/terminal-input-ws-protocol.test.js +138 -0
- package/server/lib/tts-service.js +162 -0
- 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,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
|
+
}
|