@openchamber/web 1.4.5 → 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/server/index.js CHANGED
@@ -9,12 +9,12 @@ import os from 'os';
9
9
  import crypto from 'crypto';
10
10
  import { createUiAuth } from './lib/ui-auth.js';
11
11
  import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
12
+ import { createOpencodeServer } from '@opencode-ai/sdk/server';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = path.dirname(__filename);
15
16
 
16
17
  const DEFAULT_PORT = 3000;
17
- const DEFAULT_OPENCODE_PORT = 0;
18
18
  const HEALTH_CHECK_INTERVAL = 30000;
19
19
  const SHUTDOWN_TIMEOUT = 10000;
20
20
  const MODELS_DEV_API_URL = 'https://models.dev/api.json';
@@ -848,7 +848,6 @@ let openCodeApiDetectionTimer = null;
848
848
  let isDetectingApiPrefix = false;
849
849
  let openCodeApiDetectionPromise = null;
850
850
  let lastOpenCodeError = null;
851
- let openCodePortWaiters = [];
852
851
  let isOpenCodeReady = false;
853
852
  let openCodeNotReadySince = 0;
854
853
  let exitOnShutdown = true;
@@ -890,12 +889,7 @@ async function isOpenCodeProcessHealthy() {
890
889
  return false;
891
890
  }
892
891
 
893
- // Check if process is still running
894
- if (openCodeProcess.exitCode !== null || openCodeProcess.signalCode !== null) {
895
- return false;
896
- }
897
-
898
- // Health check via HTTP
892
+ // Health check via HTTP since SDK object doesn't expose exitCode
899
893
  try {
900
894
  const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, {
901
895
  method: 'GET',
@@ -907,105 +901,6 @@ async function isOpenCodeProcessHealthy() {
907
901
  }
908
902
  }
909
903
 
910
- const OPENCODE_BINARY_ENV =
911
- process.env.OPENCODE_BINARY ||
912
- process.env.OPENCHAMBER_BINARY ||
913
- process.env.OPENCODE_PATH ||
914
- process.env.OPENCHAMBER_OPENCODE_PATH ||
915
- null;
916
-
917
- function buildAugmentedPath() {
918
- const augmented = new Set();
919
-
920
- const loginShellPath = getLoginShellPath();
921
- if (loginShellPath) {
922
- for (const segment of loginShellPath.split(path.delimiter)) {
923
- if (segment) {
924
- augmented.add(segment);
925
- }
926
- }
927
- }
928
-
929
- const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
930
- for (const segment of current) {
931
- augmented.add(segment);
932
- }
933
-
934
- return Array.from(augmented).join(path.delimiter);
935
- }
936
-
937
- function getLoginShellPath() {
938
- if (process.platform === 'win32') {
939
- return null;
940
- }
941
-
942
- const shell = process.env.SHELL || '/bin/zsh';
943
- const shellName = path.basename(shell);
944
-
945
- // Nushell requires different flag syntax and PATH access
946
- const isNushell = shellName === 'nu' || shellName === 'nushell';
947
- const args = isNushell
948
- ? ['-l', '-i', '-c', '$env.PATH | str join (char esep)']
949
- : ['-lic', 'echo -n "$PATH"'];
950
-
951
- try {
952
- const result = spawnSync(shell, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
953
- if (result.status === 0 && typeof result.stdout === 'string') {
954
- const value = result.stdout.trim();
955
- if (value) {
956
- return value;
957
- }
958
- } else if (result.stderr) {
959
- console.warn(`Failed to read PATH from login shell (${shell}): ${result.stderr}`);
960
- }
961
- } catch (error) {
962
- console.warn(`Error executing login shell (${shell}) for PATH detection: ${error.message}`);
963
- }
964
- return null;
965
- }
966
-
967
- function resolveBinaryFromPath(binaryName, searchPath) {
968
- if (!binaryName) {
969
- return null;
970
- }
971
- if (path.isAbsolute(binaryName)) {
972
- return fs.existsSync(binaryName) ? binaryName : null;
973
- }
974
- const directories = searchPath.split(path.delimiter).filter(Boolean);
975
- for (const directory of directories) {
976
- try {
977
- const candidate = path.join(directory, binaryName);
978
- if (fs.existsSync(candidate)) {
979
- const stats = fs.statSync(candidate);
980
- if (stats.isFile()) {
981
- return candidate;
982
- }
983
- }
984
- } catch {
985
- // Ignore resolution errors, continue searching
986
- }
987
- }
988
- return null;
989
- }
990
-
991
- function getOpencodeSpawnConfig() {
992
- const envPath = buildAugmentedPath();
993
- const resolvedEnv = { ...process.env, PATH: envPath };
994
-
995
- if (OPENCODE_BINARY_ENV) {
996
- const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
997
- if (explicit) {
998
- console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
999
- return { command: explicit, env: resolvedEnv };
1000
- }
1001
- console.warn(
1002
- `OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
1003
- );
1004
- }
1005
-
1006
- return { command: 'opencode', env: resolvedEnv };
1007
- }
1008
-
1009
904
  const ENV_CONFIGURED_OPENCODE_PORT = (() => {
1010
905
  const raw =
1011
906
  process.env.OPENCODE_PORT ||
@@ -1048,42 +943,79 @@ function setOpenCodePort(port) {
1048
943
  }
1049
944
 
1050
945
  lastOpenCodeError = null;
946
+ }
1051
947
 
1052
- if (openCodePortWaiters.length > 0) {
1053
- const waiters = openCodePortWaiters;
1054
- openCodePortWaiters = [];
1055
- for (const notify of waiters) {
1056
- try {
1057
- notify(numericPort);
1058
- } catch (error) {
1059
- console.warn('Failed to notify OpenCode port waiter:', error);
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;
1060
968
  }
1061
969
  }
970
+ } catch (error) {
971
+ // ignore
1062
972
  }
973
+ return null;
1063
974
  }
1064
975
 
1065
- async function waitForOpenCodePort(timeoutMs = 15000) {
1066
- if (openCodePort !== null) {
1067
- return openCodePort;
1068
- }
976
+ function buildAugmentedPath() {
977
+ const augmented = new Set();
1069
978
 
1070
- return new Promise((resolve, reject) => {
1071
- const onPortDetected = (port) => {
1072
- clearTimeout(timeout);
1073
- resolve(port);
1074
- };
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
+ }
1075
987
 
1076
- const timeout = setTimeout(() => {
1077
- openCodePortWaiters = openCodePortWaiters.filter((cb) => cb !== onPortDetected);
1078
- reject(new Error('Timed out waiting for OpenCode port'));
1079
- }, timeoutMs);
988
+ const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
989
+ for (const segment of current) {
990
+ augmented.add(segment);
991
+ }
1080
992
 
1081
- openCodePortWaiters.push(onPortDetected);
1082
- });
993
+ return Array.from(augmented).join(path.delimiter);
1083
994
  }
1084
995
 
1085
996
  const API_PREFIX_CANDIDATES = ['', '/api']; // Simplified - only check root and /api
1086
997
 
998
+ async function waitForReady(url, timeoutMs = 10000) {
999
+ const start = Date.now();
1000
+ while (Date.now() - start < timeoutMs) {
1001
+ try {
1002
+ const controller = new AbortController();
1003
+ const timeout = setTimeout(() => controller.abort(), 3000);
1004
+ const res = await fetch(`${url.replace(/\/+$/, '')}/config`, {
1005
+ method: 'GET',
1006
+ headers: { Accept: 'application/json' },
1007
+ signal: controller.signal
1008
+ });
1009
+ clearTimeout(timeout);
1010
+ if (res.ok) return true;
1011
+ } catch {
1012
+ // ignore
1013
+ }
1014
+ await new Promise(r => setTimeout(r, 100));
1015
+ }
1016
+ return false;
1017
+ }
1018
+
1087
1019
  function normalizeApiPrefix(prefix) {
1088
1020
  if (!prefix) {
1089
1021
  return '';
@@ -1119,51 +1051,6 @@ function setDetectedOpenCodeApiPrefix(prefix) {
1119
1051
  }
1120
1052
  }
1121
1053
 
1122
- function detectPortFromLogMessage(message) {
1123
- if (openCodePort && ENV_CONFIGURED_OPENCODE_PORT) {
1124
- return;
1125
- }
1126
-
1127
- const regex = /https?:\/\/[^:\s]+:(\d+)/gi;
1128
- let match;
1129
- while ((match = regex.exec(message)) !== null) {
1130
- const port = parseInt(match[1], 10);
1131
- if (Number.isFinite(port) && port > 0) {
1132
- setOpenCodePort(port);
1133
- return;
1134
- }
1135
- }
1136
-
1137
- const fallbackMatch = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i.exec(message);
1138
- if (fallbackMatch) {
1139
- const port = parseInt(fallbackMatch[1], 10);
1140
- if (Number.isFinite(port) && port > 0) {
1141
- setOpenCodePort(port);
1142
- }
1143
- }
1144
- }
1145
-
1146
- function detectPrefixFromLogMessage(message) {
1147
- if (!openCodePort) {
1148
- return;
1149
- }
1150
-
1151
- const urlRegex = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
1152
- let match;
1153
-
1154
- while ((match = urlRegex.exec(message)) !== null) {
1155
- const portMatch = parseInt(match[1], 10);
1156
- if (portMatch !== openCodePort) {
1157
- continue;
1158
- }
1159
-
1160
- const path = match[2] || '';
1161
- const normalized = normalizeApiPrefix(path);
1162
- setDetectedOpenCodeApiPrefix(normalized);
1163
- return;
1164
- }
1165
- }
1166
-
1167
1054
  function getCandidateApiPrefixes() {
1168
1055
  if (openCodeApiPrefixDetected) {
1169
1056
  return [openCodeApiPrefix];
@@ -1495,136 +1382,57 @@ function parseArgs(argv = process.argv.slice(2)) {
1495
1382
  }
1496
1383
 
1497
1384
  async function startOpenCode() {
1498
- const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? DEFAULT_OPENCODE_PORT;
1385
+ const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
1499
1386
  console.log(
1500
- desiredPort
1387
+ desiredPort > 0
1501
1388
  ? `Starting OpenCode on requested port ${desiredPort}...`
1502
1389
  : 'Starting OpenCode with dynamic port assignment...'
1503
1390
  );
1504
- console.log(`Starting OpenCode in working directory: ${openCodeWorkingDirectory}`);
1505
-
1506
- const { command, env } = getOpencodeSpawnConfig();
1507
- const args = ['serve', '--port', desiredPort.toString()];
1508
- console.log(`Launching OpenCode via "${command}" with args ${args.join(' ')}`);
1391
+ // Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK.
1509
1392
 
1510
- const child = spawn(command, args, {
1511
- stdio: 'pipe',
1512
- env,
1513
- cwd: openCodeWorkingDirectory
1514
- });
1515
- isOpenCodeReady = false;
1516
- openCodeNotReadySince = Date.now();
1393
+ try {
1394
+ const serverInstance = await createOpencodeServer({
1395
+ hostname: '127.0.0.1',
1396
+ port: desiredPort,
1397
+ timeout: 30000,
1398
+ env: {
1399
+ ...process.env,
1400
+ // Pass minimal config to avoid pollution, but inherit PATH etc
1401
+ }
1402
+ });
1517
1403
 
1518
- let firstSignalResolver;
1519
- const firstSignalPromise = new Promise((resolve) => {
1520
- firstSignalResolver = resolve;
1521
- });
1522
- let firstSignalSettled = false;
1523
- const settleFirstSignal = () => {
1524
- if (firstSignalSettled) {
1525
- return;
1526
- }
1527
- firstSignalSettled = true;
1528
- clearTimeout(firstSignalTimer);
1529
- child.stdout.off('data', settleFirstSignal);
1530
- child.stderr.off('data', settleFirstSignal);
1531
- child.off('exit', settleFirstSignal);
1532
- if (firstSignalResolver) {
1533
- firstSignalResolver();
1404
+ if (!serverInstance || !serverInstance.url) {
1405
+ throw new Error('OpenCode server started but URL is missing');
1534
1406
  }
1535
- };
1536
- const firstSignalTimer = setTimeout(settleFirstSignal, 750);
1537
-
1538
- child.stdout.once('data', settleFirstSignal);
1539
- child.stderr.once('data', settleFirstSignal);
1540
- child.once('exit', settleFirstSignal);
1541
-
1542
- child.stdout.on('data', (data) => {
1543
- const text = data.toString();
1544
- console.log(`OpenCode: ${text.trim()}`);
1545
- detectPortFromLogMessage(text);
1546
- detectPrefixFromLogMessage(text);
1547
- settleFirstSignal();
1548
- });
1549
1407
 
1550
- child.stderr.on('data', (data) => {
1551
- const text = data.toString();
1552
- lastOpenCodeError = text.trim();
1553
- console.error(`OpenCode Error: ${lastOpenCodeError}`);
1554
- detectPortFromLogMessage(text);
1555
- detectPrefixFromLogMessage(text);
1556
- settleFirstSignal();
1557
- });
1408
+ const url = new URL(serverInstance.url);
1409
+ const port = parseInt(url.port, 10);
1410
+ const prefix = normalizeApiPrefix(url.pathname);
1558
1411
 
1559
- let startupError = await new Promise((resolve, reject) => {
1560
- const onSpawn = () => {
1561
- setOpenCodePort(desiredPort);
1562
- child.off('error', onError);
1563
- resolve(null);
1564
- };
1565
- const onError = (error) => {
1566
- child.off('spawn', onSpawn);
1567
- reject(error);
1568
- };
1412
+ if (await waitForReady(serverInstance.url, 10000)) {
1413
+ setOpenCodePort(port);
1414
+ setDetectedOpenCodeApiPrefix(prefix); // SDK URL typically includes the prefix if any
1569
1415
 
1570
- child.once('spawn', onSpawn);
1571
- child.once('error', onError);
1572
- }).catch((error) => {
1416
+ isOpenCodeReady = true;
1417
+ lastOpenCodeError = null;
1418
+ openCodeNotReadySince = 0;
1419
+
1420
+ return serverInstance;
1421
+ } else {
1422
+ try {
1423
+ serverInstance.close();
1424
+ } catch {
1425
+ // ignore
1426
+ }
1427
+ throw new Error('Server started but health check failed (timeout)');
1428
+ }
1429
+ } catch (error) {
1573
1430
  lastOpenCodeError = error.message;
1574
1431
  openCodePort = null;
1575
1432
  syncToHmrState();
1576
- settleFirstSignal();
1577
- return error;
1578
- });
1579
-
1580
- if (startupError) {
1581
- if (startupError.code === 'ENOENT') {
1582
- const enhanced = new Error(
1583
- `Failed to start OpenCode – executable "${command}" not found. ` +
1584
- 'Set OPENCODE_BINARY to the full path of the opencode CLI or ensure it is on PATH.'
1585
- );
1586
- enhanced.code = startupError.code;
1587
- startupError = enhanced;
1588
- }
1589
- throw startupError;
1433
+ console.error(`Failed to start OpenCode: ${error.message}`);
1434
+ throw error;
1590
1435
  }
1591
-
1592
- child.on('exit', (code, signal) => {
1593
- lastOpenCodeError = `OpenCode exited with code ${code}, signal ${signal ?? 'null'}`;
1594
- isOpenCodeReady = false;
1595
- openCodeNotReadySince = Date.now();
1596
-
1597
- if (!isShuttingDown && !isRestartingOpenCode) {
1598
- console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
1599
-
1600
- setTimeout(() => {
1601
- restartOpenCode().catch((err) => {
1602
- console.error('Failed to restart OpenCode after exit:', err);
1603
- });
1604
- }, 5000);
1605
- } else if (isRestartingOpenCode) {
1606
- console.log('OpenCode exit during controlled restart, not triggering auto-restart');
1607
- }
1608
- });
1609
-
1610
- child.on('error', (error) => {
1611
- lastOpenCodeError = error.message;
1612
- isOpenCodeReady = false;
1613
- openCodeNotReadySince = Date.now();
1614
- console.error(`OpenCode process error: ${error.message}`);
1615
- if (!isShuttingDown) {
1616
-
1617
- setTimeout(() => {
1618
- restartOpenCode().catch((err) => {
1619
- console.error('Failed to restart OpenCode after error:', err);
1620
- });
1621
- }, 5000);
1622
- }
1623
- });
1624
-
1625
- await firstSignalPromise;
1626
-
1627
- return child;
1628
1436
  }
1629
1437
 
1630
1438
  async function restartOpenCode() {
@@ -1641,60 +1449,16 @@ async function restartOpenCode() {
1641
1449
  console.log('Restarting OpenCode process...');
1642
1450
 
1643
1451
  if (openCodeProcess) {
1644
- console.log('Waiting for OpenCode process to terminate...');
1645
- const processToTerminate = openCodeProcess;
1646
- let forcedTermination = false;
1647
-
1648
- if (processToTerminate.exitCode === null && processToTerminate.signalCode === null) {
1649
- processToTerminate.kill('SIGTERM');
1650
-
1651
- await new Promise((resolve) => {
1652
- let resolved = false;
1653
-
1654
- const cleanup = () => {
1655
- processToTerminate.off('exit', onExit);
1656
- clearTimeout(forceKillTimer);
1657
- clearTimeout(hardStopTimer);
1658
- if (!resolved) {
1659
- resolved = true;
1660
- resolve();
1661
- }
1662
- };
1663
-
1664
- const onExit = () => {
1665
- cleanup();
1666
- };
1667
-
1668
- const forceKillTimer = setTimeout(() => {
1669
- if (resolved) {
1670
- return;
1671
- }
1672
- forcedTermination = true;
1673
- console.warn('OpenCode process did not exit after SIGTERM, sending SIGKILL');
1674
- processToTerminate.kill('SIGKILL');
1675
- }, 3000);
1676
-
1677
- const hardStopTimer = setTimeout(() => {
1678
- if (resolved) {
1679
- return;
1680
- }
1681
- console.warn('OpenCode process unresponsive after SIGKILL, continuing restart');
1682
- cleanup();
1683
- }, 5000);
1684
-
1685
- processToTerminate.once('exit', onExit);
1686
- });
1687
-
1688
- if (forcedTermination) {
1689
- console.log('OpenCode process terminated forcefully during restart');
1690
- }
1691
- } else {
1692
- console.log('OpenCode process already stopped before restart command');
1452
+ console.log('Stopping existing OpenCode process...');
1453
+ try {
1454
+ openCodeProcess.close();
1455
+ } catch (error) {
1456
+ console.warn('Error closing OpenCode process:', error);
1693
1457
  }
1694
-
1695
1458
  openCodeProcess = null;
1696
1459
  syncToHmrState();
1697
-
1460
+
1461
+ // Brief delay to allow port release
1698
1462
  await new Promise((resolve) => setTimeout(resolve, 250));
1699
1463
  }
1700
1464
 
@@ -1705,6 +1469,8 @@ async function restartOpenCode() {
1705
1469
  openCodePort = null;
1706
1470
  syncToHmrState();
1707
1471
  }
1472
+
1473
+ // Reset detection state
1708
1474
  openCodeApiPrefixDetected = false;
1709
1475
  if (openCodeApiDetectionTimer) {
1710
1476
  clearTimeout(openCodeApiDetectionTimer);
@@ -1716,13 +1482,10 @@ async function restartOpenCode() {
1716
1482
  openCodeProcess = await startOpenCode();
1717
1483
  syncToHmrState();
1718
1484
 
1719
- if (!ENV_CONFIGURED_OPENCODE_PORT) {
1720
- await waitForOpenCodePort();
1721
- }
1722
-
1723
1485
  if (expressApp) {
1724
1486
  setupProxy(expressApp);
1725
- scheduleOpenCodeApiDetection();
1487
+ // Ensure prefix is set correctly (SDK usually handles this, but just in case)
1488
+ ensureOpenCodeApiPrefix();
1726
1489
  }
1727
1490
  })();
1728
1491
 
@@ -2928,6 +2691,7 @@ async function main(options = {}) {
2928
2691
  const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js');
2929
2692
  const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js');
2930
2693
  const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js');
2694
+ const { scanClawdHub, installSkillsFromClawdHub, isClawdHubSource } = await import('./lib/skills-catalog/clawdhub/index.js');
2931
2695
  const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js');
2932
2696
 
2933
2697
  const listGitIdentitiesForResponse = () => {
@@ -2984,6 +2748,37 @@ async function main(options = {}) {
2984
2748
  const itemsBySource = {};
2985
2749
 
2986
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)
2987
2782
  const parsed = parseSkillRepoSource(src.source);
2988
2783
  if (!parsed.ok) {
2989
2784
  itemsBySource[src.id] = [];
@@ -3093,6 +2888,29 @@ async function main(options = {}) {
3093
2888
  }
3094
2889
  workingDirectory = resolved.directory;
3095
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)
3096
2914
  const identity = resolveGitIdentity(gitIdentityId);
3097
2915
 
3098
2916
  const result = await installSkillsFromRepository({