@openchamber/web 1.4.6 → 1.4.8

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.
@@ -117,6 +117,17 @@ export async function getGlobalIdentity() {
117
117
  }
118
118
  }
119
119
 
120
+ export async function getRemoteUrl(directory, remoteName = 'origin') {
121
+ const git = simpleGit(normalizeDirectoryPath(directory));
122
+
123
+ try {
124
+ const url = await git.remote(['get-url', remoteName]);
125
+ return url?.trim() || null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
120
131
  export async function getCurrentIdentity(directory) {
121
132
  const git = simpleGit(normalizeDirectoryPath(directory));
122
133
 
@@ -157,13 +168,24 @@ export async function setLocalIdentity(directory, profile) {
157
168
  await git.addConfig('user.name', profile.userName, false, 'local');
158
169
  await git.addConfig('user.email', profile.userEmail, false, 'local');
159
170
 
160
- if (profile.sshKey) {
171
+ const authType = profile.authType || 'ssh';
172
+
173
+ if (authType === 'ssh' && profile.sshKey) {
161
174
  await git.addConfig(
162
175
  'core.sshCommand',
163
176
  `ssh -i ${profile.sshKey}`,
164
177
  false,
165
178
  'local'
166
179
  );
180
+ await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
181
+ } else if (authType === 'token' && profile.host) {
182
+ await git.addConfig(
183
+ 'credential.helper',
184
+ 'store',
185
+ false,
186
+ 'local'
187
+ );
188
+ await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
167
189
  }
168
190
 
169
191
  return true;
@@ -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: "Anthropics public skills repository",
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