@openchamber/web 1.4.6 → 1.4.7
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/dist/assets/{ToolOutputDialog-Bk6Uzhck.js → ToolOutputDialog-oDE1UvDN.js} +1 -1
- package/dist/assets/{index-_QJSNcFo.js → index-BqCwlsig.js} +2 -2
- package/dist/assets/{index-Cxzt1pIT.css → index-CFHNKWvn.css} +1 -1
- package/dist/assets/{main-COr_R8wu.js → main-Bi1ZnDPY.js} +31 -31
- package/dist/index.html +2 -2
- package/package.json +6 -4
- package/server/index.js +104 -1
- package/server/lib/skills-catalog/clawdhub/api.js +129 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +200 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +73 -0
- package/server/lib/skills-catalog/curated-sources.js +9 -1
package/dist/index.html
CHANGED
|
@@ -160,10 +160,10 @@
|
|
|
160
160
|
pointer-events: none;
|
|
161
161
|
}
|
|
162
162
|
</style>
|
|
163
|
-
<script type="module" crossorigin src="/assets/index-
|
|
163
|
+
<script type="module" crossorigin src="/assets/index-BqCwlsig.js"></script>
|
|
164
164
|
<link rel="modulepreload" crossorigin href="/assets/vendor-.bun-C07YQe9X.js">
|
|
165
165
|
<link rel="stylesheet" crossorigin href="/assets/vendor--Jn2c0Clh.css">
|
|
166
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
166
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CFHNKWvn.css">
|
|
167
167
|
</head>
|
|
168
168
|
<body class="h-full bg-background text-foreground">
|
|
169
169
|
<div id="root" class="h-full">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openchamber/web",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./server/index.js",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"start": "node bin/cli.js serve"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"qrcode-terminal": "^0.12.0",
|
|
26
25
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
|
27
26
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
|
28
27
|
"@ibm/plex": "^6.4.1",
|
|
@@ -38,16 +37,18 @@
|
|
|
38
37
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
39
38
|
"@remixicon/react": "^4.7.0",
|
|
40
39
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
41
|
-
"
|
|
40
|
+
"adm-zip": "^0.5.16",
|
|
41
|
+
"bun-pty": "^0.4.5",
|
|
42
42
|
"class-variance-authority": "^0.7.1",
|
|
43
43
|
"clsx": "^2.1.1",
|
|
44
44
|
"cmdk": "^1.1.1",
|
|
45
45
|
"express": "^5.1.0",
|
|
46
|
+
"ghostty-web": "0.3.0",
|
|
46
47
|
"http-proxy-middleware": "^3.0.5",
|
|
47
48
|
"jsonc-parser": "^3.3.1",
|
|
48
49
|
"next-themes": "^0.4.6",
|
|
49
|
-
"bun-pty": "^0.4.5",
|
|
50
50
|
"node-pty": "^1.1.0",
|
|
51
|
+
"qrcode-terminal": "^0.12.0",
|
|
51
52
|
"react": "^19.1.1",
|
|
52
53
|
"react-dom": "^19.1.1",
|
|
53
54
|
"react-markdown": "^10.1.0",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@eslint/js": "^9.33.0",
|
|
65
66
|
"@tailwindcss/postcss": "^4.0.0",
|
|
67
|
+
"@types/adm-zip": "^0.5.7",
|
|
66
68
|
"@types/node": "^24.3.1",
|
|
67
69
|
"@types/react": "^19.1.10",
|
|
68
70
|
"@types/react-dom": "^19.1.7",
|
package/server/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { spawn } from 'child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'child_process';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import http from 'http';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
@@ -945,6 +945,54 @@ function setOpenCodePort(port) {
|
|
|
945
945
|
lastOpenCodeError = null;
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
+
function getLoginShellPath() {
|
|
949
|
+
if (process.platform === 'win32') {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
954
|
+
const shellName = path.basename(shell);
|
|
955
|
+
|
|
956
|
+
// Nushell requires different flag syntax and PATH access
|
|
957
|
+
const isNushell = shellName === 'nu' || shellName === 'nushell';
|
|
958
|
+
const args = isNushell
|
|
959
|
+
? ['-l', '-i', '-c', '$env.PATH | str join (char esep)']
|
|
960
|
+
: ['-lic', 'echo -n "$PATH"'];
|
|
961
|
+
|
|
962
|
+
try {
|
|
963
|
+
const result = spawnSync(shell, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
964
|
+
if (result.status === 0 && typeof result.stdout === 'string') {
|
|
965
|
+
const value = result.stdout.trim();
|
|
966
|
+
if (value) {
|
|
967
|
+
return value;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
} catch (error) {
|
|
971
|
+
// ignore
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function buildAugmentedPath() {
|
|
977
|
+
const augmented = new Set();
|
|
978
|
+
|
|
979
|
+
const loginShellPath = getLoginShellPath();
|
|
980
|
+
if (loginShellPath) {
|
|
981
|
+
for (const segment of loginShellPath.split(path.delimiter)) {
|
|
982
|
+
if (segment) {
|
|
983
|
+
augmented.add(segment);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
989
|
+
for (const segment of current) {
|
|
990
|
+
augmented.add(segment);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return Array.from(augmented).join(path.delimiter);
|
|
994
|
+
}
|
|
995
|
+
|
|
948
996
|
const API_PREFIX_CANDIDATES = ['', '/api']; // Simplified - only check root and /api
|
|
949
997
|
|
|
950
998
|
async function waitForReady(url, timeoutMs = 10000) {
|
|
@@ -2643,6 +2691,7 @@ async function main(options = {}) {
|
|
|
2643
2691
|
const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js');
|
|
2644
2692
|
const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js');
|
|
2645
2693
|
const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js');
|
|
2694
|
+
const { scanClawdHub, installSkillsFromClawdHub, isClawdHubSource } = await import('./lib/skills-catalog/clawdhub/index.js');
|
|
2646
2695
|
const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js');
|
|
2647
2696
|
|
|
2648
2697
|
const listGitIdentitiesForResponse = () => {
|
|
@@ -2699,6 +2748,37 @@ async function main(options = {}) {
|
|
|
2699
2748
|
const itemsBySource = {};
|
|
2700
2749
|
|
|
2701
2750
|
for (const src of sources) {
|
|
2751
|
+
// Handle ClawdHub sources separately (API-based, not git-based)
|
|
2752
|
+
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
|
2753
|
+
const cacheKey = 'clawdhub:registry';
|
|
2754
|
+
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
|
|
2755
|
+
|
|
2756
|
+
if (!scanResult) {
|
|
2757
|
+
const scanned = await scanClawdHub();
|
|
2758
|
+
if (!scanned.ok) {
|
|
2759
|
+
itemsBySource[src.id] = [];
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
scanResult = scanned;
|
|
2763
|
+
setCachedScan(cacheKey, scanResult);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
const items = (scanResult.items || []).map((item) => {
|
|
2767
|
+
const installed = installedByName.get(item.skillName);
|
|
2768
|
+
return {
|
|
2769
|
+
...item,
|
|
2770
|
+
sourceId: src.id,
|
|
2771
|
+
installed: installed
|
|
2772
|
+
? { isInstalled: true, scope: installed.scope }
|
|
2773
|
+
: { isInstalled: false },
|
|
2774
|
+
};
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
itemsBySource[src.id] = items;
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// Handle GitHub sources (git clone based)
|
|
2702
2782
|
const parsed = parseSkillRepoSource(src.source);
|
|
2703
2783
|
if (!parsed.ok) {
|
|
2704
2784
|
itemsBySource[src.id] = [];
|
|
@@ -2808,6 +2888,29 @@ async function main(options = {}) {
|
|
|
2808
2888
|
}
|
|
2809
2889
|
workingDirectory = resolved.directory;
|
|
2810
2890
|
}
|
|
2891
|
+
|
|
2892
|
+
// Handle ClawdHub sources (ZIP download based)
|
|
2893
|
+
if (isClawdHubSource(source)) {
|
|
2894
|
+
const result = await installSkillsFromClawdHub({
|
|
2895
|
+
scope,
|
|
2896
|
+
workingDirectory,
|
|
2897
|
+
userSkillDir: SKILL_DIR,
|
|
2898
|
+
selections,
|
|
2899
|
+
conflictPolicy,
|
|
2900
|
+
conflictDecisions,
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
if (!result.ok) {
|
|
2904
|
+
if (result.error?.kind === 'conflicts') {
|
|
2905
|
+
return res.status(409).json({ ok: false, error: result.error });
|
|
2906
|
+
}
|
|
2907
|
+
return res.status(400).json({ ok: false, error: result.error });
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
return res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// Handle GitHub sources (git clone based)
|
|
2811
2914
|
const identity = resolveGitIdentity(gitIdentityId);
|
|
2812
2915
|
|
|
2813
2916
|
const result = await installSkillsFromRepository({
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
|
|
10
|
+
// Rate limiting: ClawdHub allows 120 requests/minute
|
|
11
|
+
const RATE_LIMIT_DELAY_MS = 100;
|
|
12
|
+
let lastRequestTime = 0;
|
|
13
|
+
|
|
14
|
+
async function rateLimitedFetch(url, options = {}) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const elapsed = now - lastRequestTime;
|
|
17
|
+
if (elapsed < RATE_LIMIT_DELAY_MS) {
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed));
|
|
19
|
+
}
|
|
20
|
+
lastRequestTime = Date.now();
|
|
21
|
+
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
...options,
|
|
24
|
+
headers: {
|
|
25
|
+
Accept: 'application/json',
|
|
26
|
+
'User-Agent': 'OpenChamber/1.0',
|
|
27
|
+
...options.headers,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return response;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch paginated list of skills from ClawdHub
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
* @param {string} [options.cursor] - Pagination cursor from previous response
|
|
38
|
+
* @returns {Promise<{ items: Array, nextCursor?: string }>}
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchClawdHubSkills({ cursor } = {}) {
|
|
41
|
+
const url = cursor
|
|
42
|
+
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}`
|
|
43
|
+
: `${CLAWDHUB_API_BASE}/skills`;
|
|
44
|
+
|
|
45
|
+
const response = await rateLimitedFetch(url);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text = await response.text().catch(() => '');
|
|
49
|
+
throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
return {
|
|
54
|
+
items: data.items || [],
|
|
55
|
+
nextCursor: data.nextCursor || null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fetch details for a specific skill version
|
|
61
|
+
* @param {string} slug - Skill slug/identifier
|
|
62
|
+
* @param {string} [version='latest'] - Version string or 'latest'
|
|
63
|
+
* @returns {Promise<{ skill: Object, version: Object }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function fetchClawdHubSkillVersion(slug, version = 'latest') {
|
|
66
|
+
// For 'latest', we need to first get the skill metadata to find the latest version
|
|
67
|
+
if (version === 'latest') {
|
|
68
|
+
const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`);
|
|
69
|
+
if (!skillResponse.ok) {
|
|
70
|
+
throw new Error(`ClawdHub skill not found: ${slug}`);
|
|
71
|
+
}
|
|
72
|
+
const skillData = await skillResponse.json();
|
|
73
|
+
const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version;
|
|
74
|
+
if (!latestVersion) {
|
|
75
|
+
throw new Error(`No latest version found for skill: ${slug}`);
|
|
76
|
+
}
|
|
77
|
+
version = latestVersion;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
|
|
81
|
+
const response = await rateLimitedFetch(url);
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const text = await response.text().catch(() => '');
|
|
85
|
+
throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return response.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Download a skill package as a ZIP buffer
|
|
93
|
+
* @param {string} slug - Skill slug/identifier
|
|
94
|
+
* @param {string} version - Specific version string
|
|
95
|
+
* @returns {Promise<ArrayBuffer>} - ZIP file contents
|
|
96
|
+
*/
|
|
97
|
+
export async function downloadClawdHubSkill(slug, version) {
|
|
98
|
+
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}&version=${encodeURIComponent(version)}`;
|
|
99
|
+
|
|
100
|
+
const response = await rateLimitedFetch(url, {
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: 'application/zip',
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const text = await response.text().catch(() => '');
|
|
108
|
+
throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return response.arrayBuffer();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get skill metadata without version details
|
|
116
|
+
* @param {string} slug - Skill slug/identifier
|
|
117
|
+
* @returns {Promise<Object>}
|
|
118
|
+
*/
|
|
119
|
+
export async function fetchClawdHubSkillInfo(slug) {
|
|
120
|
+
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
|
|
121
|
+
const response = await rateLimitedFetch(url);
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
const text = await response.text().catch(() => '');
|
|
125
|
+
throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return response.json();
|
|
129
|
+
}
|
|
@@ -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 } 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,200 @@
|
|
|
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 validateSkillName(skillName) {
|
|
18
|
+
if (typeof skillName !== 'string') return false;
|
|
19
|
+
if (skillName.length < 1 || skillName.length > 64) return false;
|
|
20
|
+
return SKILL_NAME_PATTERN.test(skillName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function safeRm(dir) {
|
|
24
|
+
try {
|
|
25
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensureDir(dirPath) {
|
|
32
|
+
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
|
|
36
|
+
if (scope === 'user') {
|
|
37
|
+
return path.join(userSkillDir, skillName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!workingDirectory) {
|
|
41
|
+
throw new Error('workingDirectory is required for project installs');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return path.join(workingDirectory, '.opencode', 'skill', skillName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Install skills from ClawdHub registry
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {string} options.scope - 'user' or 'project'
|
|
51
|
+
* @param {string} [options.workingDirectory] - Required for project scope
|
|
52
|
+
* @param {string} options.userSkillDir - User skills directory
|
|
53
|
+
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
|
|
54
|
+
* @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll'
|
|
55
|
+
* @param {Object} [options.conflictDecisions] - Per-skill conflict decisions
|
|
56
|
+
* @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function installSkillsFromClawdHub({
|
|
59
|
+
scope,
|
|
60
|
+
workingDirectory,
|
|
61
|
+
userSkillDir,
|
|
62
|
+
selections,
|
|
63
|
+
conflictPolicy,
|
|
64
|
+
conflictDecisions,
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (scope !== 'user' && scope !== 'project') {
|
|
67
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!userSkillDir) {
|
|
71
|
+
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (scope === 'project' && !workingDirectory) {
|
|
75
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const requestedSkills = Array.isArray(selections) ? selections : [];
|
|
79
|
+
if (requestedSkills.length === 0) {
|
|
80
|
+
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build installation plans
|
|
84
|
+
const skillPlans = requestedSkills.map((sel) => {
|
|
85
|
+
const slug = sel.clawdhub?.slug || sel.skillDir;
|
|
86
|
+
const version = sel.clawdhub?.version || 'latest';
|
|
87
|
+
return {
|
|
88
|
+
slug,
|
|
89
|
+
version,
|
|
90
|
+
installable: validateSkillName(slug),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Check for conflicts before downloading
|
|
95
|
+
const conflicts = [];
|
|
96
|
+
for (const plan of skillPlans) {
|
|
97
|
+
if (!plan.installable) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
|
102
|
+
if (fs.existsSync(targetDir)) {
|
|
103
|
+
const decision = conflictDecisions?.[plan.slug];
|
|
104
|
+
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
|
|
105
|
+
if (!decision && !hasAutoPolicy) {
|
|
106
|
+
conflicts.push({ skillName: plan.slug, scope });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (conflicts.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: {
|
|
115
|
+
kind: 'conflicts',
|
|
116
|
+
message: 'Some skills already exist in the selected scope',
|
|
117
|
+
conflicts,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const installed = [];
|
|
123
|
+
const skipped = [];
|
|
124
|
+
|
|
125
|
+
for (const plan of skillPlans) {
|
|
126
|
+
if (!plan.installable) {
|
|
127
|
+
skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// Resolve 'latest' version if needed
|
|
133
|
+
let resolvedVersion = plan.version;
|
|
134
|
+
if (resolvedVersion === 'latest') {
|
|
135
|
+
try {
|
|
136
|
+
const info = await fetchClawdHubSkillInfo(plan.slug);
|
|
137
|
+
resolvedVersion = info.skill?.tags?.latest || info.latestVersion?.version || plan.version;
|
|
138
|
+
} catch {
|
|
139
|
+
// Fall back to 'latest' tag if info fetch fails
|
|
140
|
+
resolvedVersion = 'latest';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
|
145
|
+
const exists = fs.existsSync(targetDir);
|
|
146
|
+
|
|
147
|
+
// Determine conflict resolution
|
|
148
|
+
let decision = conflictDecisions?.[plan.slug] || null;
|
|
149
|
+
if (!decision) {
|
|
150
|
+
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
|
|
151
|
+
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
|
152
|
+
if (!exists) decision = 'overwrite'; // No conflict, proceed
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (exists && decision === 'skip') {
|
|
156
|
+
skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (exists && decision === 'overwrite') {
|
|
161
|
+
await safeRm(targetDir);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Download the skill ZIP
|
|
165
|
+
const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion);
|
|
166
|
+
|
|
167
|
+
// Extract to a temp directory first for validation
|
|
168
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`));
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const zip = new AdmZip(Buffer.from(zipBuffer));
|
|
172
|
+
zip.extractAllTo(tempDir, true);
|
|
173
|
+
|
|
174
|
+
// Verify SKILL.md exists
|
|
175
|
+
const skillMdPath = path.join(tempDir, 'SKILL.md');
|
|
176
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
177
|
+
skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Move to target directory
|
|
182
|
+
await ensureDir(path.dirname(targetDir));
|
|
183
|
+
await fs.promises.rename(tempDir, targetDir);
|
|
184
|
+
|
|
185
|
+
installed.push({ skillName: plan.slug, scope });
|
|
186
|
+
} catch (extractError) {
|
|
187
|
+
await safeRm(tempDir);
|
|
188
|
+
throw extractError;
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error);
|
|
192
|
+
skipped.push({
|
|
193
|
+
skillName: plan.slug,
|
|
194
|
+
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { ok: true, installed, skipped };
|
|
200
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawdHub skill scanning
|
|
3
|
+
*
|
|
4
|
+
* Fetches all available skills from the ClawdHub registry
|
|
5
|
+
* and transforms them into SkillsCatalogItem format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fetchClawdHubSkills } from './api.js';
|
|
9
|
+
|
|
10
|
+
const MAX_PAGES = 20; // Safety limit to prevent infinite loops
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scan ClawdHub registry for all available skills
|
|
14
|
+
* @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>}
|
|
15
|
+
*/
|
|
16
|
+
export async function scanClawdHub() {
|
|
17
|
+
try {
|
|
18
|
+
const allItems = [];
|
|
19
|
+
let cursor = null;
|
|
20
|
+
|
|
21
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
22
|
+
const { items, nextCursor } = await fetchClawdHubSkills({ cursor });
|
|
23
|
+
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
|
|
26
|
+
|
|
27
|
+
allItems.push({
|
|
28
|
+
sourceId: 'clawdhub',
|
|
29
|
+
repoSource: 'clawdhub:registry',
|
|
30
|
+
repoSubpath: null,
|
|
31
|
+
gitIdentityId: null,
|
|
32
|
+
skillDir: item.slug,
|
|
33
|
+
skillName: item.slug,
|
|
34
|
+
frontmatterName: item.displayName || item.slug,
|
|
35
|
+
description: item.summary || null,
|
|
36
|
+
installable: true,
|
|
37
|
+
warnings: [],
|
|
38
|
+
// ClawdHub-specific metadata
|
|
39
|
+
clawdhub: {
|
|
40
|
+
slug: item.slug,
|
|
41
|
+
version: latestVersion,
|
|
42
|
+
displayName: item.displayName,
|
|
43
|
+
owner: item.owner?.handle || null,
|
|
44
|
+
downloads: item.stats?.downloads || 0,
|
|
45
|
+
stars: item.stats?.stars || 0,
|
|
46
|
+
versionsCount: item.stats?.versions || 1,
|
|
47
|
+
createdAt: item.createdAt,
|
|
48
|
+
updatedAt: item.updatedAt,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!nextCursor) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
cursor = nextCursor;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sort by downloads (most popular first)
|
|
60
|
+
allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
|
|
61
|
+
|
|
62
|
+
return { ok: true, items: allItems };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('ClawdHub scan error:', error);
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: {
|
|
68
|
+
kind: 'networkError',
|
|
69
|
+
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -2,9 +2,17 @@ export const CURATED_SKILLS_SOURCES = [
|
|
|
2
2
|
{
|
|
3
3
|
id: 'anthropic',
|
|
4
4
|
label: 'Anthropic',
|
|
5
|
-
description: "Anthropic
|
|
5
|
+
description: "Anthropic's public skills repository",
|
|
6
6
|
source: 'anthropics/skills',
|
|
7
7
|
defaultSubpath: 'skills',
|
|
8
|
+
sourceType: 'github',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: 'clawdhub',
|
|
12
|
+
label: 'ClawdHub',
|
|
13
|
+
description: 'Community skill registry with vector search',
|
|
14
|
+
source: 'clawdhub:registry',
|
|
15
|
+
sourceType: 'clawdhub',
|
|
8
16
|
},
|
|
9
17
|
];
|
|
10
18
|
|