File size: 171,891 Bytes
f76c374 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 | #!/bin/bash
# ============================================================================
# Hermes Agent Installer
# ============================================================================
# Installation script for Linux, macOS, and Android/Termux.
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
#
# Usage:
# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
#
# Or with options:
# curl -fsSL ... | bash -s -- --no-venv --skip-setup
#
# ============================================================================
set -e
# Guard against environment leakage when the installer is launched from another
# Python-driven tool session (e.g. Hermes terminal tool). A pre-set PYTHONPATH
# can force pip/entrypoints to import a different checkout than the one being
# installed, which makes fresh installs appear broken or stale.
if [ -n "${PYTHONPATH:-}" ]; then
echo "β Ignoring inherited PYTHONPATH during install to avoid module shadowing"
unset PYTHONPATH
fi
if [ -n "${PYTHONHOME:-}" ]; then
echo "β Ignoring inherited PYTHONHOME during install"
unset PYTHONHOME
fi
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
# wrong user's home directory when running under sudo -u <user>. See #21269.
export UV_NO_CONFIG=1
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Configuration
REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git"
REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
# INSTALL_DIR is resolved AFTER arg parsing and OS detection so we can pick an
# FHS-style layout for root installs. Track whether the user gave us an
# explicit directory β if so we never override it.
if [ -n "${HERMES_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$HERMES_INSTALL_DIR"
INSTALL_DIR_EXPLICIT=true
else
INSTALL_DIR=""
INSTALL_DIR_EXPLICIT=false
fi
PYTHON_VERSION="3.11"
PYTHON_SUPPORTED_RANGE=">=3.11,<3.14" # pyproject requires-python; keep in sync
NODE_VERSION="26"
# FHS-style root install layout (set by resolve_install_layout when applicable):
# code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes,
# data still at /root/.hermes (HERMES_HOME). Matches Claude Code / Codex CLI
# and keeps Docker bind-mounted /root/ volumes lean.
ROOT_FHS_LAYOUT=false
DETECTED_BROWSER_EXECUTABLE=""
# Options
USE_VENV=true
RUN_SETUP=true
SKIP_BROWSER=false
SKIP_COMPUTER_USE=false
NO_SKILLS=false
BRANCH="main"
INSTALL_COMMIT=""
FORCE_COMMIT=false
ENSURE_DEPS=""
MANIFEST_MODE=false
STAGE_NAME=""
JSON_OUTPUT=false
NON_INTERACTIVE=false
INCLUDE_DESKTOP=false
# Detect non-interactive mode (e.g. curl | bash)
# When stdin is not a terminal, read -p will fail with EOF,
# causing set -e to silently abort the entire script.
if [ -t 0 ]; then
IS_INTERACTIVE=true
else
IS_INTERACTIVE=false
fi
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--no-venv)
USE_VENV=false
shift
;;
--skip-setup)
RUN_SETUP=false
shift
;;
--skip-browser|--no-playwright)
SKIP_BROWSER=true
shift
;;
--skip-computer-use)
SKIP_COMPUTER_USE=true
shift
;;
--no-skills)
NO_SKILLS=true
shift
;;
--branch|-Branch)
BRANCH="$2"
shift 2
;;
--commit|-Commit)
INSTALL_COMMIT="$2"
shift 2
;;
--force-commit|-ForceCommit)
FORCE_COMMIT=true
shift
;;
--manifest|-Manifest)
MANIFEST_MODE=true
shift
;;
--stage|-Stage)
STAGE_NAME="$2"
shift 2
;;
--json|-Json)
JSON_OUTPUT=true
shift
;;
--non-interactive|-NonInteractive)
NON_INTERACTIVE=true
shift
;;
--include-desktop|-IncludeDesktop)
INCLUDE_DESKTOP=true
shift
;;
--dir)
INSTALL_DIR="$2"
INSTALL_DIR_EXPLICIT=true
shift 2
;;
--hermes-home)
HERMES_HOME="$2"
shift 2
;;
--ensure)
ENSURE_DEPS="$2"
shift 2
;;
-h|--help)
echo "Hermes Agent Installer"
echo ""
echo "Usage: install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --no-venv Don't create virtual environment"
echo " --skip-setup Skip interactive setup wizard"
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
echo " --skip-computer-use Skip the cua-driver (Computer Use) install"
echo " --no-skills Start with a blank slate β seed no bundled skills, and"
echo " write \$HERMES_HOME/.no-bundled-skills so future"
echo " 'hermes update' runs never inject bundled skills either"
echo " --branch NAME Git branch to install (default: main)"
echo " --commit SHA Pin checkout to a specific commit after clone/update"
echo " (ignored when it would roll an existing install back)"
echo " --force-commit Apply --commit even if it rolls the install backwards"
echo " --manifest Print desktop bootstrap stage manifest as JSON"
echo " --stage NAME Run one desktop bootstrap stage"
echo " --json Print a JSON result frame for --stage"
echo " --non-interactive Skip stages that require user input"
echo " --include-desktop Also build the desktop app (apps/desktop -> Hermes.app)"
echo " --dir PATH Installation directory"
echo " default (non-root): ~/.hermes/hermes-agent"
echo " default (root, Linux): /usr/local/lib/hermes-agent"
echo " --hermes-home PATH Data directory (default: ~/.hermes, or \$HERMES_HOME)"
echo " -h, --help Show this help"
echo ""
echo "Notes:"
echo " When running as root on Linux, Hermes installs the code under"
echo " /usr/local/lib/hermes-agent and links the command into"
echo " /usr/local/bin/hermes (FHS layout β matches Claude Code / Codex CLI)."
echo " Data, config, sessions, and logs still live in \$HERMES_HOME"
echo " (default /root/.hermes). This keeps Docker bind-mounted volumes"
echo " small and ensures the command is on PATH for all shells."
echo " Existing installs at \$HERMES_HOME/hermes-agent are preserved in-place."
echo " --ensure DEPS Install only specified deps (comma-separated)"
echo " Supported: node, browser, ripgrep, ffmpeg"
echo " Does NOT clone repo or create venv"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# ============================================================================
# Helper functions
# ============================================================================
print_banner() {
echo ""
echo -e "${MAGENTA}${BOLD}"
echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
echo "β β€ Hermes Agent Installer β"
echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€"
echo "β An open source AI agent by Nous Research. β"
echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
echo -e "${NC}"
}
log_info() {
echo -e "${CYAN}β${NC} $1"
}
log_success() {
echo -e "${GREEN}β${NC} $1"
}
log_warn() {
echo -e "${YELLOW}β ${NC} $1"
}
log_error() {
echo -e "${RED}β${NC} $1"
}
json_escape() {
# Enough for short installer status strings; avoids requiring jq during
# pre-install bootstrap.
printf '%s' "$1" | tr '\n' ' ' | sed \
-e 's/\\/\\\\/g' \
-e 's/"/\\"/g'
}
# npm rewrites tracked package-lock.json files non-deterministically during
# `npm install` / `npm run pack`. On a managed install those diffs are never
# intentional, but they leave the checkout dirty β which forces `hermes update`
# to autostash on every run and makes branch switches fragile. Restore them so
# a fresh install ends with a clean tree. Best-effort; only touches lockfiles.
restore_dirty_lockfiles() {
local repo="${1:-$INSTALL_DIR}"
[ -n "$repo" ] && [ -d "$repo/.git" ] || return 0
command -v git >/dev/null 2>&1 || return 0
local dirty
dirty=$(git -C "$repo" diff --name-only 2>/dev/null | grep 'package-lock\.json$' || true)
[ -z "$dirty" ] && return 0
echo "$dirty" | while IFS= read -r f; do
[ -n "$f" ] && git -C "$repo" checkout -- "$f" 2>/dev/null || true
done
}
# npm rewrites tracked package-lock.json files non-deterministically during
# local builds. On a managed install those diffs are usually runtime churn, not
# intentional user edits, so discard them before the repository-stage stash.
# If package.json in the same directory is also dirty we keep both changes.
discard_update_lockfile_churn() {
local repo="${1:-$INSTALL_DIR}"
[ -n "$repo" ] && [ -d "$repo/.git" ] || return 0
command -v git >/dev/null 2>&1 || return 0
local dirty_diff
dirty_diff=$(git -C "$repo" diff --name-only 2>/dev/null) || return 0
[ -n "$dirty_diff" ] || return 0
local dirty_package_dirs=""
while IFS= read -r path; do
case "$path" in
*package.json)
dirty_package_dirs="${dirty_package_dirs}$(dirname "$path")"$'\n'
;;
esac
done <<EOF
$dirty_diff
EOF
local dirty_locks=""
local dirty_count=0
while IFS= read -r path; do
case "$path" in
*package-lock.json)
local lock_dir
lock_dir=$(dirname "$path")
case $'\n'"$dirty_package_dirs" in
*$'\n'"$lock_dir"$'\n'*) continue ;;
esac
dirty_locks="${dirty_locks}${path}"$'\n'
dirty_count=$((dirty_count + 1))
;;
esac
done <<EOF
$dirty_diff
EOF
[ "$dirty_count" -gt 0 ] || return 0
while IFS= read -r path; do
[ -n "$path" ] || continue
git -C "$repo" checkout -- "$path" 2>/dev/null || true
done <<EOF
$dirty_locks
EOF
log_info "Discarded npm lockfile churn (${dirty_count} file(s))"
}
emit_manifest() {
# Stage-Desktop is included only with --include-desktop, mirroring
# install.ps1: the signed bootstrap installer (Hermes-Setup) passes it so
# a GUI install ends up with a launchable app; the Electron app's own
# first-launch bootstrap and the CLI one-liner omit it (building the
# desktop from inside the already-running app would clobber it).
local desktop_stage=""
if [ "$INCLUDE_DESKTOP" = true ]; then
desktop_stage='{"name":"desktop","title":"Build desktop app","category":"runtime","needs_user_input":false},'
fi
printf '%s' '{"protocol_version":1,"stages":[{"name":"prerequisites","title":"System prerequisites","category":"runtime","needs_user_input":false},{"name":"repository","title":"Download Hermes Agent","category":"runtime","needs_user_input":false},{"name":"venv","title":"Create Python virtual environment","category":"runtime","needs_user_input":false},{"name":"python-deps","title":"Install Python dependencies","category":"runtime","needs_user_input":false},{"name":"node-deps","title":"Install browser-tool dependencies","category":"runtime","needs_user_input":false},{"name":"path","title":"Install hermes command","category":"runtime","needs_user_input":false},{"name":"config","title":"Prepare config and skills","category":"configuration","needs_user_input":false},{"name":"setup","title":"Configure API keys and settings","category":"configuration","needs_user_input":true},{"name":"gateway","title":"Configure gateway service","category":"configuration","needs_user_input":true},'"$desktop_stage"'{"name":"complete","title":"Finish install","category":"runtime","needs_user_input":false}]}'
printf '\n'
}
stage_needs_user_input() {
case "$1" in
setup|gateway) return 0 ;;
*) return 1 ;;
esac
}
emit_stage_json() {
local stage="$1"
local ok="$2"
local skipped="${3:-false}"
local reason="${4:-}"
local escaped_reason
escaped_reason="$(json_escape "$reason")"
if [ -n "$escaped_reason" ]; then
printf '{"ok":%s,"stage":"%s","skipped":%s,"reason":"%s"}\n' "$ok" "$stage" "$skipped" "$escaped_reason"
else
printf '{"ok":%s,"stage":"%s","skipped":%s}\n' "$ok" "$stage" "$skipped"
fi
}
prompt_yes_no() {
local question="$1"
local default="${2:-yes}"
local prompt_suffix
local answer=""
# Use case patterns (not ${var,,}) so this works on bash 3.2 (macOS /bin/bash).
case "$default" in
[yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) prompt_suffix="[Y/n]" ;;
*) prompt_suffix="[y/N]" ;;
esac
if [ "$NON_INTERACTIVE" = true ]; then
answer=""
elif [ "$IS_INTERACTIVE" = true ]; then
read -r -p "$question $prompt_suffix " answer || answer=""
elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf "%s %s " "$question" "$prompt_suffix" > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
else
answer=""
fi
answer="${answer#"${answer%%[![:space:]]*}"}"
answer="${answer%"${answer##*[![:space:]]}"}"
if [ -z "$answer" ]; then
case "$default" in
[yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) return 0 ;;
*) return 1 ;;
esac
fi
case "$answer" in
[yY]|[yY][eE][sS]) return 0 ;;
*) return 1 ;;
esac
}
is_termux() {
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
}
# Decide where the repo checkout + venv live, and where the `hermes` command
# symlink goes. Called after detect_os so $OS/$DISTRO are known.
#
# Defaults:
# - Non-root, any OS: INSTALL_DIR = $HERMES_HOME/hermes-agent
# command link in $HOME/.local/bin
# - Termux (any uid): INSTALL_DIR = $HERMES_HOME/hermes-agent
# command link in $PREFIX/bin (already on PATH)
# - Root on Linux (new): INSTALL_DIR = /usr/local/lib/hermes-agent
# command link in /usr/local/bin
# (unless a legacy install already exists at
# $HERMES_HOME/hermes-agent β then preserve it)
#
# Always no-op when the user set --dir or $HERMES_INSTALL_DIR.
resolve_install_layout() {
if [ "$INSTALL_DIR_EXPLICIT" = true ]; then
log_info "Install directory: $INSTALL_DIR (explicit)"
return 0
fi
# Termux: package manager manages /data/data/..., keep code in HERMES_HOME.
if is_termux; then
INSTALL_DIR="$HERMES_HOME/hermes-agent"
return 0
fi
# Root on Linux: prefer FHS layout unless a legacy install already exists.
# macOS root installs keep the legacy layout because /usr/local/ on macOS
# is Homebrew territory and we don't want to fight that.
if [ "$OS" = "linux" ] && [ "$(id -u)" -eq 0 ]; then
if [ -d "$HERMES_HOME/hermes-agent/.git" ]; then
INSTALL_DIR="$HERMES_HOME/hermes-agent"
log_info "Existing install detected at $INSTALL_DIR β keeping legacy layout"
log_info " (new root installs use /usr/local/lib/hermes-agent)"
return 0
fi
INSTALL_DIR="/usr/local/lib/hermes-agent"
ROOT_FHS_LAYOUT=true
# Place uv-managed Python under /usr/local/share so the venv interpreter
# is world-readable. Default uv paths land in /root/.local/share/uv,
# which non-root users can't traverse β leaving the shared
# /usr/local/bin/hermes wrapper unable to exec the bad-interpreter venv
# python. See #21457.
export UV_PYTHON_INSTALL_DIR="${UV_PYTHON_INSTALL_DIR:-/usr/local/share/uv/python}"
export UV_PYTHON_BIN_DIR="${UV_PYTHON_BIN_DIR:-/usr/local/share/uv/bin}"
log_info "Root install on Linux β using FHS layout"
log_info " Code: $INSTALL_DIR"
log_info " Command: /usr/local/bin/hermes"
log_info " Data: $HERMES_HOME (unchanged)"
log_info " uv Python: $UV_PYTHON_INSTALL_DIR (world-readable)"
return 0
fi
# Default: non-root, non-Termux β legacy user-scoped layout.
INSTALL_DIR="$HERMES_HOME/hermes-agent"
}
get_command_link_dir() {
if is_termux && [ -n "${PREFIX:-}" ]; then
echo "$PREFIX/bin"
elif [ "$ROOT_FHS_LAYOUT" = true ]; then
echo "/usr/local/bin"
else
echo "$HOME/.local/bin"
fi
}
get_command_link_display_dir() {
if is_termux && [ -n "${PREFIX:-}" ]; then
echo '$PREFIX/bin'
elif [ "$ROOT_FHS_LAYOUT" = true ]; then
echo '/usr/local/bin'
else
echo '~/.local/bin'
fi
}
# Point a Hermes-managed Node's `npm install -g` at a directory that is on
# PATH. npm's default global prefix for a bundled Node is the Node dir itself,
# so global package binaries land in $HERMES_HOME/node/bin β which is NOT on
# PATH (only the command link dir is) and is wiped on every Node upgrade.
# Redirecting the prefix to the link dir's parent makes global bins resolve to
# the command link dir (node/npm/npx live there too, already on PATH) and
# survive upgrades. Scoped to the managed Node via its prefix-local global
# npmrc, so the user's other Node installs and their ~/.npmrc are untouched.
# Hermes's own global installs pass an explicit --prefix and are unaffected.
# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on
# every install run repairs pre-existing installs, not just fresh ones.
configure_managed_node_npm_prefix() {
[ -x "$HERMES_HOME/node/bin/npm" ] || return 0
local link_dir
link_dir="$(get_command_link_dir)"
mkdir -p "$HERMES_HOME/node/etc"
printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"
}
get_hermes_command_path() {
local link_dir
link_dir="$(get_command_link_dir)"
if [ -x "$link_dir/hermes" ]; then
echo "$link_dir/hermes"
else
echo "hermes"
fi
}
# ============================================================================
# System detection
# ============================================================================
detect_os() {
case "$(uname -s)" in
Linux*)
if is_termux; then
OS="android"
DISTRO="termux"
else
OS="linux"
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO="$ID"
# VERSION_ID (e.g. "26.04", "14") lets us tell whether the
# apt release is newer than the newest one Playwright's
# platform resolver recognizes β the #35166 hang condition.
DISTRO_VERSION="${VERSION_ID:-}"
else
DISTRO="unknown"
DISTRO_VERSION=""
fi
fi
;;
Darwin*)
OS="macos"
DISTRO="macos"
;;
CYGWIN*|MINGW*|MSYS*)
OS="windows"
DISTRO="windows"
log_error "Windows detected. Please use the PowerShell installer:"
log_info " iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
exit 1
;;
*)
OS="unknown"
DISTRO="unknown"
log_warn "Unknown operating system"
;;
esac
log_success "Detected: $OS ($DISTRO)"
}
# ============================================================================
# Dependency checks
# ============================================================================
install_uv() {
if [ "$DISTRO" = "termux" ]; then
log_info "Termux detected β using Python's stdlib venv + pip instead of uv"
UV_CMD=""
return 0
fi
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there β
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.sh and `hermes update` stay in sync.
local _managed_uv="$HERMES_HOME/bin/uv"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "Managed uv found ($UV_VERSION)"
return 0
fi
log_info "Installing managed uv into $HERMES_HOME/bin ..."
mkdir -p "$HERMES_HOME/bin"
# Two-stage: download the installer, then run it. Piping
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
# and conflates network errors with installer errors.
local _uv_install_log _uv_installer
_uv_install_log="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-install.$$.log")"
_uv_installer="$(mktemp 2>/dev/null || echo "/tmp/hermes-uv-installer.$$.sh")"
if ! curl -LsSf https://astral.sh/uv/install.sh -o "$_uv_installer" 2>"$_uv_install_log"; then
log_error "Failed to download uv installer from https://astral.sh/uv/install.sh"
log_info "curl output:"
sed 's/^/ /' "$_uv_install_log" >&2
log_info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
rm -f "$_uv_install_log" "$_uv_installer"
exit 1
fi
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
rm -f "$_uv_installer"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
else
log_error "uv installer reported success but binary not found at $_managed_uv"
log_info "Installer output:"
sed 's/^/ /' "$_uv_install_log" >&2
rm -f "$_uv_install_log"
exit 1
fi
rm -f "$_uv_install_log"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "Managed uv installed ($UV_VERSION)"
else
log_error "Failed to install uv"
log_info "Installer output:"
sed 's/^/ /' "$_uv_install_log" >&2
log_info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
rm -f "$_uv_install_log" "$_uv_installer"
exit 1
fi
}
check_python() {
if [ "$DISTRO" = "termux" ]; then
log_info "Checking Termux Python..."
# Hermes currently declares requires-python >=3.11,<3.14. Termux can
# expose a newer default `python` before dependencies have compatible
# wheels, so do not accept the default interpreter until the upper bound
# is verified. Prefer the project's pinned minor when present, then
# other explicit compatible interpreters.
for python_cmd in python3.11 python3.12 python3.13 python; do
if command -v "$python_cmd" >/dev/null 2>&1; then
local candidate_path
candidate_path="$(command -v "$python_cmd")"
if "$candidate_path" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_PATH="$candidate_path"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
fi
done
log_info "Installing Python via pkg..."
pkg install -y python >/dev/null
PYTHON_PATH="$(command -v python)"
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
return 0
fi
# Termux's default `python` package is outside the supported range
# (e.g. 3.14.x before Rust transitives ship cp314 wheels). The Termux
# User Repository (TUR) publishes versioned CPython packages
# (python3.13, python3.11), so try to provision a supported
# interpreter from there before giving up.
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null || true)"
log_warn "Termux Python $PYTHON_FOUND_VERSION is outside the supported range (>=3.11,<3.14)"
log_info "Trying the Termux User Repository (TUR) for a supported Python..."
pkg install -y tur-repo >/dev/null 2>&1 || true
local tur_pkg
for tur_pkg in python3.13 python3.12 python3.11; do
if ! pkg install -y "$tur_pkg" >/dev/null 2>&1; then
continue
fi
if ! command -v "$tur_pkg" >/dev/null 2>&1; then
continue
fi
local tur_path
tur_path="$(command -v "$tur_pkg")"
if "$tur_path" -c 'import sys; raise SystemExit(0 if (3, 11) <= sys.version_info[:2] < (3, 14) else 1)' 2>/dev/null; then
PYTHON_PATH="$tur_path"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed from TUR: $PYTHON_FOUND_VERSION"
return 0
fi
done
log_error "Termux Python $PYTHON_FOUND_VERSION is not supported; Hermes requires Python >=3.11,<3.14"
log_info "Install a supported interpreter and re-run this script:"
log_info " pkg install tur-repo && pkg install python3.13"
exit 1
fi
log_info "Checking Python $PYTHON_VERSION..."
# Let uv handle Python β it can download and manage Python versions
# First check if a suitable Python is already available
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
# No 3.11, but any interpreter inside requires-python (>=3.11,<3.14) works: reuse it rather
# than downloading 3.11 β the download is a hard failure on hosts that cannot reach GitHub
# releases, and the user already has a supported Python (#10778).
# --system: with the install's own venv activated (a re-run), `uv python find` would return
# venv/bin/python3, which setup_venv is about to delete out from under itself.
if PYTHON_PATH="$("$UV_CMD" python find --system "$PYTHON_SUPPORTED_RANGE" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
PYTHON_VERSION="$PYTHON_PATH" # uv venv --python / UV_PYTHON pin onto this interpreter
log_success "Python found: $PYTHON_FOUND_VERSION (supported; reusing instead of downloading 3.11)"
return 0
fi
# Python not found β use uv to install it (no sudo needed!)
log_info "Python $PYTHON_VERSION not found, installing via uv..."
if "$UV_CMD" python install "$PYTHON_VERSION"; then
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
else
log_error "Failed to install Python $PYTHON_VERSION"
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
exit 1
fi
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
# (which downloads PortableGit on Windows). git is required to clone the repo,
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
# if git is available afterwards, non-zero otherwise (caller prints manual
# instructions and aborts).
attempt_install_git() {
case "$OS" in
macos)
# Prefer Homebrew β fully headless when present.
if command -v brew >/dev/null 2>&1; then
log_info "Installing Git via Homebrew..."
brew install git >/dev/null 2>&1 || true
command -v git >/dev/null 2>&1 && return 0
fi
# Fall back to Apple Command Line Tools, which provide git AND the
# compiler some Python wheels need. `xcode-select --install` pops a
# system dialog (Apple gates CLT behind it β it cannot be fully
# silent without MDM), so we trigger it and poll for git to appear.
if command -v xcode-select >/dev/null 2>&1; then
log_info "Requesting Apple Command Line Tools (provides git + compiler)..."
log_info "If a macOS dialog appears, click \"Install\" and accept the license."
xcode-select --install >/dev/null 2>&1 || true
local waited=0
local timeout=900
while [ "$waited" -lt "$timeout" ]; do
if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then
return 0
fi
sleep 5
waited=$((waited + 5))
if [ $((waited % 60)) -eq 0 ]; then
log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..."
fi
done
fi
return 1
;;
linux)
local sudo_cmd=""
if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo"
fi
case "$DISTRO" in
ubuntu|debian)
log_info "Installing Git via apt..."
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null 2>&1 || true
;;
fedora)
log_info "Installing Git via dnf..."
$sudo_cmd dnf install -y git >/dev/null 2>&1 || true
;;
arch)
log_info "Installing Git via pacman..."
$sudo_cmd pacman -S --noconfirm git >/dev/null 2>&1 || true
;;
*)
return 1
;;
esac
command -v git >/dev/null 2>&1 && return 0
return 1
;;
esac
return 1
}
check_git() {
log_info "Checking Git..."
# On fresh macOS /usr/bin/git is a stub that exits non-zero until CLT is installed.
if command -v git &> /dev/null && git --version &> /dev/null; then
GIT_VERSION=$(git --version | awk '{print $3}')
log_success "Git $GIT_VERSION found"
return 0
fi
log_error "Git not found"
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Git via pkg..."
pkg install -y git >/dev/null
if command -v git >/dev/null 2>&1; then
GIT_VERSION=$(git --version | awk '{print $3}')
log_success "Git $GIT_VERSION installed"
return 0
fi
fi
# Try to install it automatically before giving up (parity with install.ps1).
log_info "Attempting to install Git automatically..."
if attempt_install_git; then
GIT_VERSION=$(git --version | awk '{print $3}')
log_success "Git $GIT_VERSION installed"
return 0
fi
log_warn "Could not install Git automatically. Please install it manually:"
case "$OS" in
linux)
case "$DISTRO" in
ubuntu|debian)
log_info " sudo apt update && sudo apt install git"
;;
fedora)
log_info " sudo dnf install git"
;;
arch)
log_info " sudo pacman -S git"
;;
*)
log_info " Use your package manager to install git"
;;
esac
;;
android)
log_info " pkg install git"
;;
macos)
log_info " xcode-select --install"
log_info " Or: brew install git"
;;
esac
exit 1
}
# Node deps below (install_node_deps) build native addons β most notably
# node-pty, which every install needs β via node-gyp. node-gyp needs a C/C++
# toolchain (make + a compiler); Python and make are already covered by
# check_python/check_node's prerequisites, but the compiler itself was never
# checked, so a missing g++/clang++ only surfaced as a wall of node-gyp/make
# output deep inside `npm install`, with no earlier, actionable warning.
# Best-effort like install_system_packages: warns and lets the caller decide
# whether to proceed rather than aborting the whole install (unlike git,
# which is hard-required much earlier for clone_repo).
check_cxx_compiler() {
log_info "Checking for a C++ compiler (needed to build native Node modules like node-pty)..."
if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then
log_success "C++ compiler found"
HAS_CXX_COMPILER=true
return 0
fi
HAS_CXX_COMPILER=false
log_warn "No C++ compiler found"
case "$OS" in
macos)
# Same Command Line Tools path as attempt_install_git β CLT provides
# git AND a compiler, so this is usually already satisfied by the
# check_git step above. Handles the case where git was present
# without CLT (e.g. installed via Homebrew) so CLT never triggered.
log_info "Attempting to install Xcode Command Line Tools (provides a C++ compiler)..."
log_info "If a macOS dialog appears, click \"Install\" and accept the license."
xcode-select --install >/dev/null 2>&1 || true
local waited=0
local timeout=900
while [ "$waited" -lt "$timeout" ]; do
if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then
log_success "C++ compiler installed"
HAS_CXX_COMPILER=true
return 0
fi
sleep 5
waited=$((waited + 5))
if [ $((waited % 60)) -eq 0 ]; then
log_info "Still waiting for Command Line Tools install ($((waited / 60))m)..."
fi
done
;;
linux)
local sudo_cmd=""
if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo"
fi
log_info "Attempting to install a C++ compiler automatically..."
case "$DISTRO" in
ubuntu|debian)
log_info "Installing build-essential via apt..."
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential >/dev/null 2>&1 || true
;;
fedora)
log_info "Installing gcc-c++ via dnf..."
$sudo_cmd dnf install -y gcc-c++ >/dev/null 2>&1 || true
;;
arch)
log_info "Installing base-devel via pacman..."
$sudo_cmd pacman -S --noconfirm base-devel >/dev/null 2>&1 || true
;;
esac
if command -v g++ &> /dev/null || command -v clang++ &> /dev/null; then
log_success "C++ compiler installed"
HAS_CXX_COMPILER=true
return 0
fi
;;
esac
log_warn "Could not install a C++ compiler automatically."
log_warn "Node steps that compile native modules (e.g. node-pty) will fail below until one is installed."
log_info "Install it manually, then re-run this installer:"
case "$OS" in
linux)
case "$DISTRO" in
ubuntu|debian) log_info " sudo apt install build-essential" ;;
fedora) log_info " sudo dnf install gcc-c++" ;;
arch) log_info " sudo pacman -S base-devel" ;;
*) log_info " Install a C++ compiler (g++/gcc-c++) via your package manager" ;;
esac
;;
android)
log_info " pkg install clang"
;;
macos)
log_info " xcode-select --install"
;;
esac
return 1
}
# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 β so accepting 23/25 or an early Node 24
# here only defers the failure to `npm ci` under engine-strict. Keep this in
# sync with the root package.json. Anything outside the supported lines is
# replaced with the Hermes-managed Node $NODE_VERSION.
node_satisfies_build() {
local ver="${1#v}"
# Pre-release builds are rejected outright, however new they are. `node-pty`
# ships no Linux prebuild, so every install compiles it with node-gyp, which
# fetches the headers named by `process.release.headersUrl` β a URL only
# published for final releases. nodejs.org/dist/latest-v26.x currently
# serves node-v26.8.0-<os>-<arch>.tar.xz whose binary reports
# v26.8.0-alpha.0.0.0; its headers 404, and the install dies at
# `node-gyp rebuild` with the underlying error swallowed by the
# `node scripts/prebuild.js || node-gyp rebuild` fallback.
case "$ver" in *-*) return 1 ;; esac
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -eq 22 ] && [ "$minor" -ge 22 ]; then return 0; fi
if [ "$major" -eq 24 ] && [ "$minor" -ge 11 ]; then return 0; fi
if [ "$major" -ge 26 ]; then return 0; fi
return 1
}
# npm 11.10.0β11.16.x honor `min-release-age` but ignore
# `min-release-age-exclude`, both of which `.npmrc` sets. That combination
# applies the 14-day age gate to packages we deliberately exempted, so every
# install fails ETARGET on a freshly published dependency. The root
# package.json excludes that band via `engines.npm`, and `engine-strict=true`
# makes it fatal β so a system npm in the band cannot install this repo, no
# matter how new its Node is. Returns 0 when the npm is usable.
npm_supports_npmrc() {
local ver="${1#v}"
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
# The bad band is 11.10.0 through 11.16.x.
if [ "$major" -eq 11 ] && [ "$minor" -ge 10 ] && [ "$minor" -le 16 ]; then
return 1
fi
return 0
}
check_node() {
log_info "Checking Node.js (for browser tools)..."
# Repair pre-existing Hermes-managed installs where `npm install -g` lands
# off PATH. No-op when there's no managed Node, so this is safe to run on
# every install β including re-runs that skip the Node (re)install below.
configure_managed_node_npm_prefix
# The system toolchain is only usable when BOTH halves work: a Node new
# enough for the desktop build AND an npm that can read our .npmrc. A
# bad-band npm (see npm_supports_npmrc) fails `npm ci` outright, and the
# managed Node we install instead bundles one that works.
#
# npm must actually be reachable, not just node: a stray `node` symlink
# without a sibling npm (leftover from a node version manager) makes
# `command -v node` succeed while every later `npm install` silently
# fails and the desktop build dies with an opaque "Node.js / npm
# unavailable" (#77003). Node only counts as found when npm resolves on
# the same PATH.
if command -v node &> /dev/null && command -v npm &> /dev/null \
&& node_satisfies_build "$(node --version)"; then
if npm_supports_npmrc "$(npm --version 2>/dev/null)"; then
log_success "Node.js $(node --version) found"
HAS_NODE=true
return 0
fi
log_warn "npm $(npm --version) cannot honor this repo's .npmrc (npm 11.10-11.16 ignore"
log_warn "min-release-age-exclude) β installing Hermes-managed Node $NODE_VERSION instead..."
install_node
return
fi
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
if [ -x "$HERMES_HOME/node/bin/node" ] && [ -x "$HERMES_HOME/node/bin/npm" ] \
&& node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
export PATH="$HERMES_HOME/node/bin:$PATH"
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
HAS_NODE=true
return 0
fi
if command -v node &> /dev/null && ! command -v npm &> /dev/null; then
log_warn "node found but npm is not on PATH (stray node symlink?) β installing Hermes-managed Node $NODE_VERSION LTS..."
elif command -v node &> /dev/null; then
log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+) β installing Hermes-managed Node $NODE_VERSION..."
elif [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found β installing Node.js via pkg..."
else
log_info "Node.js not found β installing Node.js $NODE_VERSION LTS..."
fi
install_node
}
# Download and adopt one Node release line (e.g. 26) into ~/.hermes/node/.
#
# Split out of install_node() so the caller can walk a list of candidate lines.
# A line is rejected β and the caller should try an older one β when nodejs.org
# publishes no tarball for this platform, when the download or extraction
# fails, or when the tree it ships fails node_satisfies_build.
#
# Returns 0 with the tree in place, PATH exported and HAS_NODE=true; 1 when the
# line is unusable. Nothing on disk is replaced until the candidate passes, so
# a rejected line leaves any existing managed Node untouched.
install_node_line() {
local node_line="$1"
local node_os="$2"
local node_arch="$3"
# Resolve the latest v${node_line}.x.x tarball name from the index page
local index_url="https://nodejs.org/dist/latest-v${node_line}.x/"
local tarball_name=""
# `tar xf` shells out to xz for .tar.xz; minimal Debian/DietPi/WSL images ship tar without it
# and the extract dies mid-way ("xz: Cannot exec"). Only pick .tar.xz when xz is present (#11197).
if command -v xz >/dev/null 2>&1; then
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
| head -1)
else
log_info "xz not found β using the .tar.gz Node.js archive"
fi
# Fallback to .tar.gz if .tar.xz not available (or xz is missing)
if [ -z "$tarball_name" ]; then
tarball_name=$(curl -fsSL "$index_url" \
| grep -oE "node-v${node_line}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
| head -1)
fi
if [ -z "$tarball_name" ]; then
log_warn "Could not find Node.js $node_line binary for $node_os-$node_arch"
return 1
fi
local download_url="${index_url}${tarball_name}"
local tmp_dir
tmp_dir=$(mktemp -d)
log_info "Downloading $tarball_name..."
if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then
log_warn "Download failed"
rm -rf "$tmp_dir"
return 1
fi
log_info "Extracting to ~/.hermes/node/..."
if [[ "$tarball_name" == *.tar.xz ]]; then
tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir"
else
tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir"
fi
local extracted_dir
extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1)
if [ ! -d "$extracted_dir" ]; then
log_warn "Extraction failed"
rm -rf "$tmp_dir"
return 1
fi
# Trust the binary, not the filename. A tarball named for a final release
# can still carry a pre-release build β latest-v26.x serves
# node-v26.8.0-<os>-<arch>.tar.xz whose `node --version` is
# v26.8.0-alpha.0.0.0 β and adopting it leaves node-pty unbuildable for the
# life of the install. Probe the extracted tree before it replaces anything.
local candidate_ver
candidate_ver=$("$extracted_dir/bin/node" --version 2>/dev/null)
if ! node_satisfies_build "$candidate_ver"; then
log_warn "Node.js ${candidate_ver:-unreadable} from ${index_url} cannot build native modules β trying an older release line..."
rm -rf "$tmp_dir"
return 1
fi
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
rm -rf "$HERMES_HOME/node"
mkdir -p "$HERMES_HOME"
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
# Node's official linux-x64 builds (observed: v26.7.0) link
# libatomic.so.1, which minimal Debian/Ubuntu images do not ship β
# the freshly downloaded binary then fails to start. Install the
# library up front, best-effort; the version probe below reports
# clearly if the binary still cannot run (#87460).
if [ "$OS" = "linux" ] && { [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; }; then
if command -v apt-get >/dev/null 2>&1; then
local sudo_cmd=""
if [ "$(id -u 2>/dev/null || echo 1000)" -ne 0 ]; then
command -v sudo >/dev/null 2>&1 && sudo_cmd="sudo"
fi
$sudo_cmd env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq libatomic1 >/dev/null 2>&1 || true
fi
fi
local node_link_dir
node_link_dir="$(get_command_link_dir)"
mkdir -p "$node_link_dir"
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
configure_managed_node_npm_prefix
export PATH="$HERMES_HOME/node/bin:$PATH"
local installed_ver
if ! installed_ver=$("$HERMES_HOME/node/bin/node" --version 2>&1); then
# The adopted Node exists but cannot start (observed: missing
# libatomic.so.1 on minimal Debian/Ubuntu, #87460). Degrade loudly,
# surface the loader's real error, and remove the broken tree and
# bin links so later steps and retry runs start clean. Signal the
# caller to try an older release line rather than claiming success.
log_error "Downloaded Node.js failed to start:"
printf '%s\n' "$installed_ver" >&2
log_info "On Debian/Ubuntu the usual fix is: sudo apt-get install -y libatomic1"
rm -rf "$HERMES_HOME/node"
rm -f "$node_link_dir/node" "$node_link_dir/npm" "$node_link_dir/npx"
return 1
fi
log_success "Node.js $installed_ver installed to ~/.hermes/node/"
HAS_NODE=true
return 0
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
if pkg install -y nodejs >/dev/null; then
local installed_ver
installed_ver=$(node --version 2>/dev/null || true)
if [ -n "$installed_ver" ]; then
log_success "Node.js $installed_ver installed via pkg"
HAS_NODE=true
else
# pkg succeeded but the binary cannot start β the same
# silent-success class the managed-download probe guards
# against (#87460). Degrade instead of claiming success.
log_error "Node.js installed via pkg failed to start:"
node --version >&2 || true
HAS_NODE=false
fi
else
log_warn "Failed to install Node.js via pkg"
HAS_NODE=false
fi
return 0
fi
local arch=$(uname -m)
local node_arch
case "$arch" in
x86_64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*)
log_warn "Unsupported architecture ($arch) for Node.js auto-install"
log_info "Install manually: https://nodejs.org/en/download/"
HAS_NODE=false
return 0
;;
esac
local node_os
case "$OS" in
linux) node_os="linux" ;;
macos) node_os="darwin" ;;
*)
log_warn "Unsupported OS for Node.js auto-install"
HAS_NODE=false
return 0
;;
esac
# Try the target line first, then the two previous even-numbered (LTS)
# lines. Whether a line is usable cannot be known from its index page β
# see install_node_line β and stepping down beats adopting a Node that
# leaves every native dependency unbuildable.
local node_line
for node_line in "$NODE_VERSION" "$((NODE_VERSION - 2))" "$((NODE_VERSION - 4))"; do
if install_node_line "$node_line" "$node_os" "$node_arch"; then
return 0
fi
done
log_warn "No usable Node.js release line found for $node_os-$node_arch"
log_info "Install manually: https://nodejs.org/en/download/"
HAS_NODE=false
return 0
}
check_network_prerequisites() {
log_info "Checking internet connectivity for package install and web tools..."
local url
local failed=false
local checks=("https://pypi.org/simple/" "https://duckduckgo.com/")
if ! command -v curl >/dev/null 2>&1; then
log_warn "curl not found; skipping connectivity probes"
return 0
fi
# Run the probes in parallel β serially, two blocked probes cost
# 2 Γ --max-time (16 s) before the user sees any useful error; in
# parallel the worst case is one --max-time (8 s).
local pids=()
local tmpdir
tmpdir=$(mktemp -d)
local i=0
for url in "${checks[@]}"; do
(
if curl -fsSI --max-time 8 "$url" >/dev/null 2>&1; then
: > "$tmpdir/ok_$i"
fi
) &
pids+=($!)
i=$((i + 1))
done
wait "${pids[@]}" 2>/dev/null
i=0
for url in "${checks[@]}"; do
if [ ! -e "$tmpdir/ok_$i" ]; then
failed=true
log_warn "Could not reach $url"
fi
i=$((i + 1))
done
rm -rf "$tmpdir"
if [ "$failed" = false ]; then
log_success "Internet connectivity looks good"
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_warn "Termux network prerequisites may be incomplete."
log_info "Try: pkg install -y ca-certificates curl && pkg update"
log_info "If mirrors are stale: termux-change-repo"
log_info "Then test: curl -I https://pypi.org/simple/ && curl -I https://duckduckgo.com/"
else
log_warn "Network checks failed. Hermes install may complete, but web search and dependency downloads can fail."
log_info "Verify internet/DNS and retry if pip install fails."
fi
}
install_system_packages() {
# Detect what's missing
HAS_RIPGREP=false
HAS_FFMPEG=false
local need_ripgrep=false
local need_ffmpeg=false
log_info "Checking ripgrep (fast file search)..."
if command -v rg &> /dev/null; then
log_success "$(rg --version | head -1) found"
HAS_RIPGREP=true
else
need_ripgrep=true
fi
log_info "Checking ffmpeg (TTS voice messages)..."
if command -v ffmpeg &> /dev/null; then
local ffmpeg_ver=$(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}')
log_success "ffmpeg $ffmpeg_ver found"
HAS_FFMPEG=true
else
need_ffmpeg=true
fi
# Termux always needs the Android build toolchain for the tested pip path,
# even when ripgrep/ffmpeg are already present.
if [ "$DISTRO" = "termux" ]; then
local termux_pkgs=(clang rust make pkg-config libffi openssl ca-certificates curl)
if [ "$need_ripgrep" = true ]; then
termux_pkgs+=("ripgrep")
fi
if [ "$need_ffmpeg" = true ]; then
termux_pkgs+=("ffmpeg")
fi
log_info "Installing Termux packages: ${termux_pkgs[*]}"
if pkg install -y "${termux_pkgs[@]}" >/dev/null; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
log_success "Termux build dependencies installed"
return 0
fi
log_warn "Could not auto-install all Termux packages"
log_info "Install manually: pkg install ${termux_pkgs[*]}"
return 0
fi
# Nothing to install β done
if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then
return 0
fi
# Build a human-readable description + package list
local desc_parts=()
local pkgs=()
if [ "$need_ripgrep" = true ]; then
desc_parts+=("ripgrep for faster file search")
pkgs+=("ripgrep")
fi
if [ "$need_ffmpeg" = true ]; then
desc_parts+=("ffmpeg for TTS voice messages")
pkgs+=("ffmpeg")
fi
local description
description=$(IFS=" and "; echo "${desc_parts[*]}")
# ββ macOS: brew ββ
if [ "$OS" = "macos" ]; then
if command -v brew &> /dev/null; then
log_info "Installing ${pkgs[*]} via Homebrew..."
if brew install "${pkgs[@]}"; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
return 0
fi
fi
log_warn "Could not auto-install (brew not found or install failed)"
log_info "Install manually: brew install ${pkgs[*]}"
return 0
fi
# ββ Linux: resolve package manager command ββ
local pkg_install=""
case "$DISTRO" in
ubuntu|debian) pkg_install="apt install -y" ;;
fedora) pkg_install="dnf install -y" ;;
arch) pkg_install="pacman -S --noconfirm" ;;
esac
if [ -n "$pkg_install" ]; then
local install_cmd="$pkg_install ${pkgs[*]}"
# Prevent needrestart/whiptail dialogs from blocking non-interactive installs
case "$DISTRO" in
ubuntu|debian) export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a ;;
esac
# Already root β just install
if [ "$(id -u)" -eq 0 ]; then
log_info "Installing ${pkgs[*]}..."
if $install_cmd; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
return 0
fi
# Passwordless sudo β just install
elif command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
log_info "Installing ${pkgs[*]}..."
if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
return 0
fi
# sudo needs password β ask once for everything
elif command -v sudo &> /dev/null; then
if [ "$IS_INTERACTIVE" = true ]; then
echo ""
log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager."
log_info "Hermes Agent itself does not require or retain root access."
if prompt_yes_no "Install ${description}? (requires sudo)" "no"; then
if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
return 0
fi
fi
elif (: </dev/tty) 2>/dev/null; then
# Non-interactive (e.g. curl | bash) but a terminal is available.
# Read the prompt from /dev/tty (same approach the setup wizard uses).
# Probe by actually opening /dev/tty: a bare existence test passes
# in Docker builds where the device node is in the mount namespace
# but opening fails with ENXIO. See #16746.
echo ""
log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager."
log_info "Hermes Agent itself does not require or retain root access."
if prompt_yes_no "Install ${description}?" "yes"; then
if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd < /dev/tty; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
return 0
fi
fi
else
log_warn "Non-interactive mode and no terminal available β cannot install system packages"
log_info "Install manually after setup completes: sudo $install_cmd"
fi
fi
fi
# ββ Fallback for ripgrep: cargo ββ
if [ "$need_ripgrep" = true ] && [ "$HAS_RIPGREP" = false ]; then
if command -v cargo &> /dev/null; then
log_info "Trying cargo install ripgrep (no sudo needed)..."
if cargo install ripgrep; then
log_success "ripgrep installed via cargo"
HAS_RIPGREP=true
fi
fi
fi
# ββ Show manual instructions for anything still missing ββ
if [ "$HAS_RIPGREP" = false ] && [ "$need_ripgrep" = true ]; then
log_warn "ripgrep not installed (file search will use grep fallback)"
show_manual_install_hint "ripgrep"
fi
if [ "$HAS_FFMPEG" = false ] && [ "$need_ffmpeg" = true ]; then
log_warn "ffmpeg not installed (TTS voice messages will be limited)"
show_manual_install_hint "ffmpeg"
fi
}
show_manual_install_hint() {
local pkg="$1"
log_info "To install $pkg manually:"
case "$OS" in
linux)
case "$DISTRO" in
ubuntu|debian) log_info " sudo apt install $pkg" ;;
fedora) log_info " sudo dnf install $pkg" ;;
arch) log_info " sudo pacman -S $pkg" ;;
*) log_info " Use your package manager or visit the project homepage" ;;
esac
;;
android)
log_info " pkg install $pkg"
;;
macos) log_info " brew install $pkg" ;;
esac
}
# ============================================================================
# Installation
# ============================================================================
clone_repo() {
log_info "Installing to $INSTALL_DIR..."
# An interrupted previous clone leaves a .git with no initial commit, where
# the update path's `git stash` / `git checkout` abort with "You do not
# have the initial commit yet" and fail the install (#40998). Move such a
# partial checkout aside -- never delete it, in case it holds something the
# user wants -- so the fresh-clone path below can proceed.
if [ -d "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
backup_dir="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
log_warn "Existing checkout at $INSTALL_DIR has no commits (interrupted clone)."
log_warn "Moving it aside to $backup_dir before re-cloning."
mv "$INSTALL_DIR" "$backup_dir"
fi
if [ -d "$INSTALL_DIR" ]; then
if [ -d "$INSTALL_DIR/.git" ]; then
log_info "Existing installation found, updating..."
cd "$INSTALL_DIR"
local autostash_ref=""
discard_update_lockfile_churn "$INSTALL_DIR"
if [ -n "$(git status --porcelain)" ]; then
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the later `git checkout` aborts
# with "you need to resolve your current index first", failing
# the whole install at the repository stage. Clear the conflict
# markers with `git reset` first -- this keeps working-tree
# changes (they're still stashed just below) and only drops the
# index-level conflict state. Mirrors the `hermes update` path
# (#4735).
if [ -n "$(git ls-files --unmerged)" ]; then
log_info "Clearing unmerged index entries from a previous conflict..."
git reset -q
fi
local stash_name
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
log_info "Local changes detected, stashing before update..."
git stash push --include-untracked -m "$stash_name"
autostash_ref="stash@{0}"
fi
# Fetch only the target branch. A bare `git fetch origin` pulls
# every ref, and this repo carries thousands of auto-generated
# branches β on a non-single-branch checkout that turns each update
# into a multi-minute download that can stall the installer.
git remote set-branches origin "$BRANCH" 2>/dev/null || true
git fetch origin "$BRANCH"
git checkout "$BRANCH"
# Managed installs should follow origin/$BRANCH exactly. If the
# checkout has diverged (or has local-only commits), ff-only pull
# cannot succeed β mirror ``hermes update`` and reset to the
# fetched remote so bootstrap/install can recover.
if ! git pull --ff-only origin "$BRANCH"; then
log_warn "Fast-forward not possible; resetting managed install to origin/$BRANCH..."
git reset --hard "origin/$BRANCH"
fi
if [ -n "$autostash_ref" ]; then
local restore_now="yes"
if [ -t 0 ] && [ -t 1 ]; then
echo
log_warn "Local changes were stashed before updating."
log_warn "Restoring them may reapply local customizations onto the updated codebase."
printf "Restore local changes now? [Y/n] "
read -r restore_answer
case "$restore_answer" in
""|y|Y|yes|YES|Yes) restore_now="yes" ;;
*) restore_now="no" ;;
esac
fi
if [ "$restore_now" = "yes" ]; then
log_info "Restoring local changes..."
local restore_output=""
local restore_ok="yes"
if restore_output="$(git stash apply "$autostash_ref" 2>&1)"; then
restore_ok="yes"
else
restore_ok="no"
fi
local conflicted_files=""
conflicted_files="$(git diff --name-only --diff-filter=U || true)"
if [ "$restore_ok" = "yes" ] && [ -z "$conflicted_files" ]; then
git stash drop "$autostash_ref" >/dev/null
log_warn "Local changes were restored on top of the updated codebase."
log_warn "Review git diff / git status if Hermes behaves unexpectedly."
else
log_error "Update pulled new code, but restoring local changes hit conflicts."
if [ -n "$restore_output" ]; then
printf '%s\n' "$restore_output"
fi
if [ -n "$conflicted_files" ]; then
printf '\nConflicted files:\n'
while IFS= read -r file; do
[ -n "$file" ] && printf ' β’ %s\n' "$file"
done <<EOF
$conflicted_files
EOF
fi
printf '\n'
log_info "Your stashed changes are preserved β nothing is lost."
log_info " Stash ref: $autostash_ref"
git reset --hard HEAD >/dev/null 2>&1 || true
log_info "Working tree reset to clean state."
log_info "Restore your changes later with: git stash apply $autostash_ref"
fi
else
log_info "Skipped restoring local changes."
log_info "Your changes are still preserved in git stash."
log_info "Restore manually with: git stash apply $autostash_ref"
fi
fi
else
log_error "Directory exists but is not a git repository: $INSTALL_DIR"
log_info "Remove it or choose a different directory with --dir"
exit 1
fi
else
# Try SSH first (for private repo access), fall back to HTTPS
# GIT_SSH_COMMAND disables interactive prompts and sets a short timeout
# so SSH fails fast instead of hanging when no key is configured.
log_info "Trying SSH clone..."
if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \
git clone --depth 1 --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then
log_success "Cloned via SSH"
else
rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone
log_info "SSH failed, trying HTTPS..."
# GitHub throttles packfile generation for large repos (this one:
# ~9.6k files at HEAD plus thousands of auto-generated branches)
# with repo-scoped HTTP 429s that are NOT client IP rate limits β
# an anonymous clone of a small repo succeeds and the API quota
# is untouched, but the single big pack behind `--depth 1` dies
# mid-transfer with "RPC failed; HTTP 429 / expected 'packfile'"
# (#89624, same throttle as the update path in #89287). Retry
# with backoff, then degrade to a blobless partial clone + fetch
# (many small packs instead of one big one β what gets past the
# throttle). Fully materialize the tree afterwards so the rest of
# the installer sees the normal files.
local clone_ok=false
local attempt=0
local max_attempts=4
for attempt in $(seq 1 "$max_attempts"); do
[ "$attempt" -gt 1 ] && log_info "Retrying HTTPS clone (attempt $attempt/$max_attempts)..."
if git clone --depth 1 --single-branch --branch "$BRANCH" \
"$REPO_URL_HTTPS" "$INSTALL_DIR"; then
clone_ok=true
break
fi
rm -rf "$INSTALL_DIR" 2>/dev/null # partial clone is unusable
[ "$attempt" -lt "$max_attempts" ] && sleep $((attempt * 5))
done
if [ "$clone_ok" != true ]; then
log_info "Direct clone throttled β trying blobless partial clone..."
# --no-checkout keeps the clone itself to commits+trees (small,
# gets past the pack throttle). Without it the blob fetch runs
# inside `git clone`'s own checkout step, the throttle kills
# the whole clone, and this fallback degrades to one more
# failed clone. The blobs are fetched by the reset below β a
# separate request the retry can actually wrap.
if git clone --depth 1 --single-branch --filter=blob:none \
--no-checkout --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then
# Materialize the working tree: on a --no-checkout clone
# this reset is the step that fetches the blobs (several
# small packs instead of one big one). Fail closed β a
# half-materialized checkout must not report success and
# hand the rest of the installer an unusable tree.
if (cd "$INSTALL_DIR" \
&& (git reset --hard HEAD >/dev/null 2>&1 \
|| { sleep 5; git reset --hard HEAD >/dev/null 2>&1; })); then
clone_ok=true
else
rm -rf "$INSTALL_DIR" 2>/dev/null # unusable checkout
fi
else
rm -rf "$INSTALL_DIR" 2>/dev/null
fi
fi
if [ "$clone_ok" = true ]; then
log_success "Cloned via HTTPS"
else
log_error "Failed to clone repository"
exit 1
fi
fi
fi
cd "$INSTALL_DIR"
if [ -n "$INSTALL_COMMIT" ]; then
# Validate the commit argument: must look like a hex SHA (full 40-char
# or abbreviated 7-39 char). Reject anything else early so the user
# gets a clear error instead of a misleading git message (#87268).
if ! printf '%s' "$INSTALL_COMMIT" | grep -qE '^[0-9a-fA-F]{7,40}$'; then
log_error "--commit expects a hex SHA (7-40 chars), got: $INSTALL_COMMIT"
return 1
fi
# A commit pin must never move an existing install BACKWARDS. The
# bootstrap installer bakes its build-time commit into the binary
# (BUILD_PIN_COMMIT) and passes it as --commit on every install-mode
# run -- including the one the desktop's failure screen retries. An
# installer built months ago would otherwise rewind a current checkout
# to its build commit, stranding the user on ancient code with a
# current venv. Only pin when the target is not already an ancestor of
# HEAD; a fresh clone has no such ancestry and pins normally.
if ! git cat-file -e "$INSTALL_COMMIT^{commit}" 2>/dev/null; then
if ! git fetch origin "$INSTALL_COMMIT"; then
log_error "Could not fetch commit $INSTALL_COMMIT from origin."
log_error "Abbreviated SHAs are not supported β use the full 40-char hash."
log_error "Find it with: git ls-remote origin | grep <short-sha>"
return 1
fi
fi
if git rev-parse --verify --quiet HEAD >/dev/null 2>&1 \
&& git merge-base --is-ancestor "$INSTALL_COMMIT" HEAD 2>/dev/null \
&& [ "$(git rev-parse "$INSTALL_COMMIT^{commit}" 2>/dev/null)" != "$(git rev-parse HEAD)" ]; then
if [ "$FORCE_COMMIT" = true ]; then
log_warn "--force-commit: rolling this install back to $INSTALL_COMMIT."
if ! git checkout --detach "$INSTALL_COMMIT"; then
log_error "Failed to detach at $INSTALL_COMMIT"
return 1
fi
else
log_warn "Ignoring --commit $INSTALL_COMMIT: the checkout is already newer."
log_warn "Pinning to it would roll this install back. Pass --force-commit to override."
fi
else
log_info "Pinning checkout to commit $INSTALL_COMMIT..."
if ! git checkout --detach "$INSTALL_COMMIT"; then
log_error "Failed to detach at $INSTALL_COMMIT"
return 1
fi
fi
fi
log_success "Repository ready"
}
setup_venv() {
if [ "$USE_VENV" = false ]; then
log_info "Skipping virtual environment (--no-venv)"
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_info "Creating virtual environment with Termux Python..."
if [ -d "venv" ]; then
log_info "Virtual environment already exists, recreating..."
rm -rf venv
fi
"$PYTHON_PATH" -m venv venv
log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))"
return 0
fi
log_info "Creating virtual environment with Python $PYTHON_VERSION..."
if [ -d "venv" ]; then
log_info "Virtual environment already exists, recreating..."
rm -rf venv
fi
# uv creates the venv and pins the Python version in one step. Fail loudly: `set -e` does not
# reach this line's callers on every path, and a missing venv used to be reported as ready.
if ! $UV_CMD venv venv --python "$PYTHON_VERSION" || [ ! -x "venv/bin/python" ]; then
log_error "Failed to create the virtual environment with Python $PYTHON_VERSION"
exit 1
fi
# Neutralize any inherited UV_PYTHON (e.g. UV_PYTHON=3.14 left in the
# user's shell env). uv honours UV_PYTHON over an existing venv for the
# later `uv sync` / `uv pip install` tiers, so without this it would
# silently delete this 3.11 venv and recreate it at the inherited
# version β building Rust transitives that have no wheel for that
# version from source via maturin, which fails. Pinning UV_PYTHON to the
# interpreter we just created forces every subsequent uv command onto it.
if [ -x "$INSTALL_DIR/venv/bin/python" ]; then
export UV_PYTHON="$INSTALL_DIR/venv/bin/python"
fi
log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null || echo "Python $PYTHON_VERSION"))"
}
run_locked_uv_sync() {
# Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG
# (#21269). A locked project sync is different: uv.lock records resolver
# settings from this checkout's [tool.uv], so hiding pyproject.toml makes
# uv 0.12+ reject the valid lock. Re-enable project discovery only for
# this subprocess while redirecting user/system config lookups to an empty
# directory. Keep HOME unchanged so caches, credentials, and git continue
# to work normally.
local project_env="$1"
local isolated_uv_config
local sync_rc
isolated_uv_config="$(mktemp -d)" || return 1
(
unset UV_NO_CONFIG UV_CONFIG_FILE
export XDG_CONFIG_HOME="$isolated_uv_config"
export XDG_CONFIG_DIRS="$isolated_uv_config"
UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked
)
sync_rc=$?
rmdir "$isolated_uv_config" 2>/dev/null || true
return "$sync_rc"
}
install_deps() {
log_info "Installing dependencies..."
# Re-pin UV_PYTHON to the venv interpreter. setup_venv already does this,
# but the bootstrap runs install stages (`venv`, `python-deps`) as separate
# processes, so an export from setup_venv does NOT survive into a separate
# python-deps invocation. Re-deriving it here covers that path. Without it,
# an inherited UV_PYTHON=3.14 makes the uv sync/pip tiers below recreate the
# venv at 3.14 and fail the maturin source build (no cp314 wheels yet).
if [ "$DISTRO" != "termux" ] && [ -x "$INSTALL_DIR/venv/bin/python" ]; then
export UV_PYTHON="$INSTALL_DIR/venv/bin/python"
fi
if [ "$DISTRO" = "termux" ]; then
if [ "$USE_VENV" = true ]; then
export VIRTUAL_ENV="$INSTALL_DIR/venv"
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
else
PIP_PYTHON="$PYTHON_PATH"
fi
if [ -z "${ANDROID_API_LEVEL:-}" ]; then
ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
if [ -z "$ANDROID_API_LEVEL" ]; then
ANDROID_API_LEVEL=24
fi
export ANDROID_API_LEVEL
log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds"
fi
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
# On Android, psutil's setup.py rejects sys.platform == 'android' before
# it ever invokes the C build, so the next pip install would fail at
# "platform android is not supported". Prebuild psutil from the official
# sdist with a one-line marker patch (Linux source path is fine on
# Android). Stopgap until psutil#2762 ships upstream.
if "$PIP_PYTHON" -c 'import sys; raise SystemExit(0 if sys.platform == "android" else 1)' 2>/dev/null; then
log_info "Android Python detected: prebuilding psutil compatibility shim..."
if ! "$PIP_PYTHON" "$INSTALL_DIR/scripts/install_psutil_android.py" --pip "$PIP_PYTHON -m pip"; then
log_warn "psutil Android prebuild failed β package install will likely fail next."
log_info "Workaround: manually rerun 'python scripts/install_psutil_android.py' once your toolchain is set up."
fi
fi
# Try the broad Termux profile first (best-effort "install all" for Android),
# then fall back to the conservative Termux baseline, then base package.
if ! "$PIP_PYTHON" -m pip install -e '.[termux-all]' -c constraints-termux.txt; then
log_warn "Termux broad profile (.[termux-all]) failed, trying baseline Termux profile..."
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
log_warn "Termux baseline profile (.[termux]) failed, trying base install..."
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
log_error "Package installation failed on Termux."
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl ca-certificates curl"
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
exit 1
fi
fi
fi
log_success "Main package installed"
log_info "Termux note: matrix e2ee and local faster-whisper extras are excluded from .[termux-all] due to upstream Android wheel/toolchain blockers."
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
log_success "All dependencies installed"
return 0
fi
if [ "$USE_VENV" = true ]; then
# Tell uv to install into our venv (no need to activate)
export VIRTUAL_ENV="$INSTALL_DIR/venv"
fi
# On Debian/Ubuntu (including WSL), some Python packages need build tools.
# Check and offer to install them if missing.
if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
local need_build_tools=false
for pkg in gcc python3-dev libffi-dev; do
if ! dpkg -s "$pkg" &>/dev/null; then
need_build_tools=true
break
fi
done
if [ "$need_build_tools" = true ]; then
log_info "Some build tools may be needed for Python packages..."
if command -v sudo &> /dev/null; then
if sudo -n true 2>/dev/null; then
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true
log_success "Build tools installed"
else
log_info "sudo is needed ONLY to install build tools (build-essential, python3-dev, libffi-dev) via apt."
log_info "Hermes Agent itself does not require or retain root access."
if prompt_yes_no "Install build tools?" "yes"; then
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true
log_success "Build tools installed"
fi
fi
fi
fi
fi
# Install the main package in editable mode with all extras.
#
# Hash-verified install (Tier 0) β when uv.lock is present, prefer
# `uv sync --locked`. The lockfile records SHA256 hashes for every
# transitive, so a compromised transitive (different hash than what
# we shipped) is REJECTED by the resolver. This is the *only* path
# that protects against the "direct dep is fine, but the dep's dep
# got worm-poisoned overnight" failure mode. All `uv pip install`
# tiers below re-resolve transitives fresh from PyPI without any
# hash verification β they exist to keep installs working when the
# lockfile is stale, missing, or out-of-sync with the current
# extras spec, NOT because they're equivalent in posture.
if [ -f "uv.lock" ]; then
log_info "Trying tier: hash-verified (uv.lock) ..."
log_info "(this resolves + downloads the curated [all] set β first run on a"
log_info " fresh venv can take 1-5 minutes; uv prints progress below)"
# Stream uv's progress directly to the user instead of swallowing
# it with `2>"$(mktemp)"`. Two reasons:
# 1. `--extra all --locked` against a fresh venv has to pull
# every transitive β silencing stderr makes the install
# look frozen for minutes on slow networks. Users see
# "Trying tier: hash-verified ..." and assume it's hung.
# 2. The previous `2>"$(mktemp)"` substituted the path at
# command-build time but never saved it, so on failure the
# uv error message was unreachable β the user just got the
# generic "lockfile may be stale" warning.
#
# Critical flag choice: `--extra all`, NOT `--all-extras`.
# --all-extras = every [project.optional-dependencies] key.
# This bypasses the curated `[all]` extra
# entirely and pulls e.g. [matrix] (which
# needs python-olm + make on Windows) and
# [rl] (git+https deps that fail offline).
# --extra all = install just the `[all]` extra's contents.
# This respects the curation in pyproject.toml.
# uv's own progress UI handles TTY detection and downgrades
# gracefully when stdout/stderr aren't terminals.
#
# Run the Tier-0 locked sync via run_locked_uv_sync: the global
# UV_NO_CONFIG export at script start (the #21269 sudo -u hygiene
# guard) also hides the project's own [tool.uv] policy β
# exclude-newer, its package exemptions, and override-dependencies β
# from uv. The resolver then runs under a different policy than the
# one uv.lock was resolved under, and --locked turns that mismatch
# fatal, so every fresh install fell through to the non-hash-verified
# PyPI tiers. The helper re-enables project config discovery for this
# one subprocess while keeping ambient user/system uv config hidden
# (redirected to an empty XDG dir), preserving the #21269 guarantee.
# Runtime code does the same before its locked syncs
# (hermes_cli/managed_uv.py).
if run_locked_uv_sync "$INSTALL_DIR/venv"; then
log_success "Main package installed (hash-verified via uv.lock)"
log_success "All dependencies installed"
return 0
fi
log_warn "uv.lock sync failed (see uv output above), falling back to PyPI resolve..."
else
log_info "uv.lock not found β falling back to PyPI resolve (no hash verification)"
fi
# Multi-tier fallback. The point of the tiers is that ONE compromised
# PyPI package (a worm-poisoned release that gets quarantined, like
# mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a
# fresh install all the way down to "core only" β the user should keep
# everything else they signed up for.
#
# Tier 1: [all] β the curated extra in pyproject.toml.
# Tier 2: [all] minus the currently-broken extras list (_BROKEN_EXTRAS).
# Edit _BROKEN_EXTRAS below when something on PyPI breaks; this
# lets users keep the rest of [all] when one transitive is
# unavailable. The list of [all]'s contents is parsed from
# pyproject.toml at runtime β there is NO hand-mirrored copy
# to drift out of sync. If you want to change what [all]
# contains, edit pyproject.toml only.
# Tier 3: bare `.` β last-resort so at least the core CLI launches.
# Skipped tiers like "PyPI-only extras (no git deps)" used to
# exist to dodge [rl] / [matrix] git+sdist deps; those are no
# longer in [all] post-2026-05-12 lazy-install migration, so
# a separate PyPI-only tier had no remaining content.
local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
# Parse [project.optional-dependencies].all from pyproject.toml.
# tomllib is stdlib on Python 3.11+ which uv's bootstrap guarantees.
# Falls back to a hand list if parse fails β defensive only.
local _ALL_EXTRAS_CSV
_ALL_EXTRAS_CSV="$(
"$PYTHON_PATH" - <<'PY' 2>/dev/null
import re, sys, tomllib
try:
with open("pyproject.toml", "rb") as fh:
data = tomllib.load(fh)
specs = data["project"]["optional-dependencies"]["all"]
extras = []
for s in specs:
m = re.search(r"hermes-agent\[([\w-]+)\]", s)
if m:
extras.append(m.group(1))
print(",".join(extras))
except Exception as e:
print("", file=sys.stderr)
sys.exit(1)
PY
)"
if [ -z "$_ALL_EXTRAS_CSV" ]; then
log_warn "Could not parse [all] from pyproject.toml; falling back to .[all] only."
_ALL_EXTRAS_CSV=""
fi
# Build "[all] minus broken" spec by filtering the parsed list.
local _SAFE_SPEC=".[all]"
if [ -n "$_ALL_EXTRAS_CSV" ] && [ "${#_BROKEN_EXTRAS[@]}" -gt 0 ]; then
local _SAFE_EXTRAS=()
local _e _b _skip
IFS=',' read -ra _ALL_EXTRAS_ARR <<< "$_ALL_EXTRAS_CSV"
for _e in "${_ALL_EXTRAS_ARR[@]}"; do
_skip=false
for _b in "${_BROKEN_EXTRAS[@]}"; do
if [ "$_e" = "$_b" ]; then _skip=true; break; fi
done
if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi
done
_SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]"
fi
ALL_INSTALL_LOG=$(mktemp)
local _installed=false
local _tier_name=""
install_tier() {
local name="$1"; local spec="$2"
log_info "Trying tier: $name ..."
if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then
log_success "Main package installed ($name)"
_installed=true
_tier_name="$name"
return 0
fi
log_warn "Tier '$name' failed. Top of pip output:"
head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2
return 1
}
install_tier "all" ".[all]" \
|| install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \
|| install_tier "core only (no extras)" "."
rm -f "$ALL_INSTALL_LOG"
if [ "$_installed" = false ]; then
log_error "Package installation failed even with no extras."
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
exit 1
fi
if [ "$_tier_name" != "all" ]; then
log_warn "Note: installed via fallback tier ($_tier_name)."
log_info "Some optional features may be missing. After resolving any"
log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'"
fi
log_success "Main package installed"
log_success "All dependencies installed"
}
setup_path() {
log_info "Setting up hermes command..."
if [ "$USE_VENV" = true ]; then
HERMES_BIN="$INSTALL_DIR/venv/bin/python"
HERMES_ENTRYPOINT="$INSTALL_DIR/hermes"
else
HERMES_BIN="$(which hermes 2>/dev/null || echo "")"
if [ -z "$HERMES_BIN" ]; then
log_warn "hermes not found on PATH after install"
return 0
fi
fi
# Verify the interpreter and the checked-in entrypoint needed by the launcher.
if [ ! -x "$HERMES_BIN" ] || { [ "$USE_VENV" = true ] && [ ! -f "$HERMES_ENTRYPOINT" ]; }; then
log_warn "Hermes launcher prerequisites not found"
log_info "This usually means the Python package install didn't complete successfully."
if [ "$DISTRO" = "termux" ]; then
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
else
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
fi
return 0
fi
local command_link_dir
local command_link_display_dir
command_link_dir="$(get_command_link_dir)"
command_link_display_dir="$(get_command_link_display_dir)"
# Create a user-facing shim for the hermes command.
# We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars
# can't make this launcher import modules from another checkout.
mkdir -p "$command_link_dir"
# Older installs created this path as a symlink to $HERMES_BIN. Without
# the rm, `cat >` follows the symlink and overwrites the venv pip entry
# point with this shim β making `exec "$HERMES_BIN"` self-recurse. (#21454)
rm -f "$command_link_dir/hermes"
if [ "$USE_VENV" = true ]; then
# uv-generated console scripts resolve themselves through `realpath`,
# which stock macOS does not provide. Run the checked-in entrypoint
# with the venv interpreter instead, so the public launcher remains
# independent of non-standard shell utilities.
cat > "$command_link_dir/hermes" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "$HERMES_ENTRYPOINT" "\$@"
EOF
else
cat > "$command_link_dir/hermes" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "\$@"
EOF
fi
chmod +x "$command_link_dir/hermes"
log_success "Installed hermes launcher β $command_link_display_dir/hermes"
# Also expose `hermes-agent`. The `hermes-agent` console script declared in
# pyproject.toml's [project.scripts] lives inside the venv, which is not on
# the login-shell PATH. Without this launcher users can't invoke the agent
# entrypoint directly from outside the venv. (#74819)
rm -f "$command_link_dir/hermes-agent"
if [ "$USE_VENV" = true ]; then
cat > "$command_link_dir/hermes-agent" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "$INSTALL_DIR/run_agent.py" "\$@"
EOF
else
cat > "$command_link_dir/hermes-agent" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" run_agent.py "\$@"
EOF
fi
chmod +x "$command_link_dir/hermes-agent"
log_success "Installed hermes-agent launcher β $command_link_display_dir/hermes-agent"
# Also expose `hermes-acp`. ACP hosts (Zed, JetBrains, Buzz) resolve the
# agent by command name on the login-shell PATH, and the `hermes-acp`
# console script lives inside the venv, which is not on that PATH. Without
# this launcher those hosts report Hermes as not installed. (#21454 applies
# here too: clear the path first so `cat >` cannot follow an old symlink
# into the venv and overwrite the console script.)
rm -f "$command_link_dir/hermes-acp"
if [ "$USE_VENV" = true ]; then
cat > "$command_link_dir/hermes-acp" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "$HERMES_ENTRYPOINT" acp "\$@"
EOF
else
cat > "$command_link_dir/hermes-acp" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" acp "\$@"
EOF
fi
chmod +x "$command_link_dir/hermes-acp"
log_success "Installed hermes-acp launcher β $command_link_display_dir/hermes-acp"
if [ "$DISTRO" = "termux" ]; then
export PATH="$command_link_dir:$PATH"
log_info "$command_link_display_dir is the native Termux command path"
log_success "hermes command ready"
return 0
fi
# FHS layout: /usr/local/bin is normally on PATH for login shells (via
# /etc/profile pathmunge), but on RHEL/CentOS/Rocky/Alma 8+ non-login
# interactive root shells (su, sudo -s, tmux panes, some web terminals)
# only source /etc/bashrc, which does NOT add /usr/local/bin β and
# /root/.bash_profile doesn't either. So verify with `command -v` and
# fall back to writing a PATH guard into /root/.bashrc when needed.
if [ "$ROOT_FHS_LAYOUT" = true ]; then
export PATH="$command_link_dir:$PATH"
# Probe a fresh non-login interactive bash the way the user will use it.
# `bash -i -c` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
# which is the exact scenario where RHEL root loses /usr/local/bin.
if env -i HOME="$HOME" TERM="${TERM:-dumb}" bash -i -c 'command -v hermes' \
>/dev/null 2>&1; then
log_info "/usr/local/bin is already on PATH for all shells"
log_success "hermes command ready"
return 0
fi
log_info "hermes not on PATH in non-login shells (common on RHEL-family)"
PATH_LINE='export PATH="/usr/local/bin:$PATH"'
PATH_COMMENT='# Hermes Agent β ensure /usr/local/bin is on PATH (RHEL non-login shells)'
for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do
[ -f "$SHELL_CONFIG" ] || continue
if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \
| grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then
echo "" >> "$SHELL_CONFIG"
echo "$PATH_COMMENT" >> "$SHELL_CONFIG"
echo "$PATH_LINE" >> "$SHELL_CONFIG"
log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG"
fi
done
log_success "hermes command ready"
return 0
fi
# Check if ~/.local/bin is on PATH; if not, add it to shell config.
# Detect the user's actual login shell (not the shell running this script,
# which is always bash when piped from curl).
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then
SHELL_CONFIGS=()
IS_FISH=false
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
case "$LOGIN_SHELL" in
zsh)
[ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
[ -f "$HOME/.zprofile" ] && SHELL_CONFIGS+=("$HOME/.zprofile")
# If neither exists, create ~/.zshrc (common on fresh macOS installs)
if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
touch "$HOME/.zshrc"
SHELL_CONFIGS+=("$HOME/.zshrc")
fi
;;
bash)
[ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
[ -f "$HOME/.bash_profile" ] && SHELL_CONFIGS+=("$HOME/.bash_profile")
;;
fish)
# fish uses ~/.config/fish/config.fish and fish_add_path β not export PATH=
IS_FISH=true
FISH_CONFIG="$HOME/.config/fish/config.fish"
mkdir -p "$(dirname "$FISH_CONFIG")"
touch "$FISH_CONFIG"
;;
*)
[ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
[ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
;;
esac
# Also ensure ~/.profile has it (sourced by login shells on
# Ubuntu/Debian/WSL even when ~/.bashrc is skipped)
[ "$IS_FISH" = "false" ] && [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
for SHELL_CONFIG in "${SHELL_CONFIGS[@]}"; do
if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null | grep -qE 'PATH=.*\.local/bin'; then
echo "" >> "$SHELL_CONFIG"
echo "# Hermes Agent β ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
echo "$PATH_LINE" >> "$SHELL_CONFIG"
log_success "Added ~/.local/bin to PATH in $SHELL_CONFIG"
fi
done
# fish uses fish_add_path instead of export PATH=...
if [ "$IS_FISH" = "true" ]; then
if ! grep -q 'fish_add_path.*\.local/bin' "$FISH_CONFIG" 2>/dev/null; then
echo "" >> "$FISH_CONFIG"
echo "# Hermes Agent β ensure ~/.local/bin is on PATH" >> "$FISH_CONFIG"
echo 'fish_add_path "$HOME/.local/bin"' >> "$FISH_CONFIG"
log_success "Added ~/.local/bin to PATH in $FISH_CONFIG"
fi
fi
if [ "$IS_FISH" = "false" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
log_warn "Could not detect shell config file to add ~/.local/bin to PATH"
log_info "Add manually: $PATH_LINE"
fi
else
log_info "~/.local/bin already on PATH"
fi
# Export for current session so hermes works immediately
export PATH="$command_link_dir:$PATH"
log_success "hermes command ready"
}
copy_config_templates() {
log_info "Setting up configuration files..."
# Create ~/.hermes directory structure (config at top level, code in subdir)
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills}
# Create .env at ~/.hermes/.env (top level, easy to find)
if [ ! -f "$HERMES_HOME/.env" ]; then
if [ -f "$INSTALL_DIR/.env.example" ]; then
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env"
log_success "Created ~/.hermes/.env from template"
else
touch "$HERMES_HOME/.env"
log_success "Created ~/.hermes/.env"
fi
else
log_info "~/.hermes/.env already exists, keeping it"
fi
# Restrict .env permissions β this file holds API keys and tokens.
# 0600 ensures only the file owner can read/write, matching standard
# practice for credential files (.netrc, .aws/credentials, .ssh/config).
chmod 600 "$HERMES_HOME/.env"
configure_browser_env_from_system_browser
# Create config.yaml at ~/.hermes/config.yaml (top level, easy to find)
if [ ! -f "$HERMES_HOME/config.yaml" ]; then
if [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
log_success "Created ~/.hermes/config.yaml from template"
fi
else
log_info "~/.hermes/config.yaml already exists, keeping it"
fi
# Create SOUL.md if it doesn't exist (global persona file).
# This MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py β the
# runtime (_ensure_default_soul_md) treats the old comment-only scaffold as
# "never customized" and upgrades it to this text on next run, so any drift
# here is self-healing, but keep them in sync to avoid a churn on first run.
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF'
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask β a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned β give it when the user asks for detail, teaches, or the stakes demand it, not by default.
SOUL_EOF
log_success "Created ~/.hermes/SOUL.md (edit to customize personality)"
fi
log_success "Configuration directory ready: ~/.hermes/"
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
if [ "$NO_SKILLS" = true ]; then
# Blank-slate install: write the opt-out marker and skip seeding.
# skills_sync.py and `hermes update` both honor this marker, so the
# default profile stays empty across future updates too.
printf '%s\n' \
"This profile opted out of bundled-skill seeding (installed with --no-skills)." \
"Delete this file to re-enable sync on the next 'hermes update'." \
> "$HERMES_HOME/.no-bundled-skills" 2>/dev/null || true
log_info "Skipping bundled skills (--no-skills). Wrote $HERMES_HOME/.no-bundled-skills"
log_info " Future 'hermes update' runs will not inject bundled skills. Delete the marker to opt back in."
else
log_info "Syncing bundled skills to ~/.hermes/skills/ ..."
if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then
log_success "Skills synced to ~/.hermes/skills/"
else
# Fallback: simple directory copy if Python sync fails
if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then
cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true
log_success "Skills copied to ~/.hermes/skills/"
fi
fi
fi
}
find_system_browser() {
# Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override.
#
# We deliberately do NOT scan PATH or well-known app locations any more.
# Auto-detection silently bound the install to whatever `command -v chromium`
# resolved to β most damagingly a Snap Chromium (/snap/bin/chromium), whose
# sandbox blocks agent-browser's control socket under /tmp, so every
# browser_navigate hung until the 60s timeout fired ("opening web page
# failed"). Every install now uses the bundled Playwright Chromium unless the
# user explicitly points elsewhere.
local override="${AGENT_BROWSER_EXECUTABLE_PATH:-}"
if [ -z "$override" ]; then
return 1
fi
# A Snap binary is never a valid target β its confinement is the very bug we
# are fixing β so reject it even when set explicitly.
case "$override" in
/snap/*) return 1 ;;
esac
if [ -x "$override" ]; then
echo "$override"
return 0
fi
if command -v "$override" >/dev/null 2>&1; then
command -v "$override"
return 0
fi
return 1
}
strip_snap_browser_override() {
# Existing installs created before the system-browser fallback was dropped
# may carry an auto-written AGENT_BROWSER_EXECUTABLE_PATH pointing at a Snap
# Chromium (/snap/bin/chromium). That path is the root cause of the "opening
# web page failed" hang, and the runtime reads it straight from .env β so
# removing the fallback in the installer is not enough on its own. Strip any
# snap-pointing override here (and its auto-written comment) so the bundled
# Chromium download runs and the agent stops using the broken binary. A
# deliberately-set non-snap override is left untouched.
local env_file="$HERMES_HOME/.env"
[ -f "$env_file" ] || return 0
grep -Eq '^AGENT_BROWSER_EXECUTABLE_PATH=/snap/' "$env_file" 2>/dev/null || return 0
local tmp
tmp="$(mktemp)" || return 0
if grep -Ev '^AGENT_BROWSER_EXECUTABLE_PATH=/snap/|^# Hermes Agent browser tools' "$env_file" > "$tmp"; then
mv "$tmp" "$env_file"
log_warn "Removed stale Snap browser override (AGENT_BROWSER_EXECUTABLE_PATH=/snap/...) from $env_file"
log_info "Hermes will use the bundled Chromium instead."
# Drop it from this process too so the rest of the run doesn't re-detect it.
unset AGENT_BROWSER_EXECUTABLE_PATH
else
rm -f "$tmp"
fi
}
run_browser_install_with_timeout() {
run_with_timeout "$@"
}
# Run a command with a hard wall-clock timeout, returning non-zero if it is
# killed. Prefers GNU coreutils `timeout` (Linux) or `gtimeout` (macOS via
# Homebrew) for an external-command target; otherwise (and always for a shell
# function target, which the `timeout` binary cannot exec) it uses a pure-shell
# watchdog: launch the command in its own process group, poll until it finishes,
# and SIGTERM (then SIGKILL) the whole group on timeout. The pure-shell path is
# what protects the bug-#39219 case β a stalled Electron download on macOS,
# where `timeout` is usually absent β turning an indefinite hang into a non-zero
# exit so callers (install_desktop) can self-heal via the mirror fallback.
#
# $1 (timeout) must be a bare integer number of seconds β the pure-shell loop
# compares it arithmetically (the `timeout` binary would also accept suffixes
# like 15m, but we normalize so both paths share one contract). On timeout the
# return code is 124, matching GNU `timeout`.
run_with_timeout() {
local timeout_seconds="$1"
shift
# Normalize to a bare integer; fall back to the desktop default if a caller
# ever passes a suffixed/empty value (the pure-shell loop needs an int).
case "$timeout_seconds" in
''|*[!0-9]*) timeout_seconds=900 ;;
esac
# The `timeout` binary can only exec an external command, not a shell
# function. Use it only when the target is NOT a function; functions always
# go through the pure-shell watchdog (which runs them in a subshell of the
# current shell and sees them directly β no fragile env export needed).
if [ "$(type -t "$1" 2>/dev/null)" != "function" ]; then
local timeout_bin=""
if command -v timeout >/dev/null 2>&1; then
timeout_bin="timeout"
elif command -v gtimeout >/dev/null 2>&1; then
timeout_bin="gtimeout"
fi
if [ -n "$timeout_bin" ]; then
# GNU `timeout` runs the command in its own process group, so a
# terminal Ctrl+C is delivered to `timeout` but never reaches the
# child β the download looks frozen and ignores Ctrl+C (#35166).
# `--foreground` keeps the command in the shell's foreground group
# so Ctrl+C reaches it; `-k 10` sends SIGKILL 10s after the deadline
# so a wedged download can't outlive the timeout. Both flags are
# GNU-only β probe once and fall back to plain `timeout` on BusyBox
# (Alpine). When neither binary exists (stock macOS) we drop to the
# pure-shell watchdog below.
if "$timeout_bin" --foreground -k 10 1 true >/dev/null 2>&1; then
"$timeout_bin" --foreground -k 10 "$timeout_seconds" "$@"
else
"$timeout_bin" "$timeout_seconds" "$@"
fi
return $?
fi
fi
# Pure-shell fallback: run in a new process group so we can kill the whole
# subtree (npm spawns node + the Electron downloader as children).
set -m
( "$@" ) &
local cmd_pid=$!
set +m
local waited=0
local rc
while [ "$waited" -lt "$timeout_seconds" ]; do
if ! kill -0 "$cmd_pid" 2>/dev/null; then
# `|| rc=$?` keeps the non-zero child status without letting `set -e`
# abort the caller here (this would fire if run_with_timeout were
# ever called outside an if/|| context).
rc=0; wait "$cmd_pid" 2>/dev/null || rc=$?
return "$rc"
fi
sleep 1
waited=$((waited + 1))
done
# Final boundary recheck: the command may have finished during the last
# poll interval β don't kill (and mislabel as 124) a process that already
# exited cleanly in the last second of the budget.
if ! kill -0 "$cmd_pid" 2>/dev/null; then
rc=0; wait "$cmd_pid" 2>/dev/null || rc=$?
return "$rc"
fi
# Timed out: kill the process group (negative PID), escalate to KILL.
kill -TERM "-$cmd_pid" 2>/dev/null || kill -TERM "$cmd_pid" 2>/dev/null || true
sleep 2
kill -KILL "-$cmd_pid" 2>/dev/null || kill -KILL "$cmd_pid" 2>/dev/null || true
wait "$cmd_pid" 2>/dev/null || true
return 124
}
# Return success only when the host is an apt release NEWER than the newest one
# Playwright's platform resolver recognizes β the exact condition that makes
# `playwright install` hang uninterruptibly (#35166). We scope the override
# retry to this case rather than retrying on *any* failure, so a genuine
# network/disk/permission failure doesn't get a mismatched-glibc build forced
# onto it. Newest Playwright-known apt releases as of this writing: Ubuntu
# 24.04, Debian 13. Anything above triggers the fallback; everything Playwright
# already handles (and every non-apt distro) does not.
playwright_host_unrecognized() {
# Compare dotted versions: returns 0 if $1 > $2.
_ver_gt() {
[ "$1" = "$2" ] && return 1
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}
case "$DISTRO" in
ubuntu) _ver_gt "${DISTRO_VERSION:-0}" "24.04" ;;
debian) _ver_gt "${DISTRO_VERSION:-0}" "13" ;;
*) return 1 ;; # Non-apt or unknown β not the #35166 hang condition.
esac
}
# Compute the PLAYWRIGHT_HOST_PLATFORM_OVERRIDE value to retry an install with
# when Playwright's platform resolver rejects the host. ubuntu24.04 is the
# newest Linux build Playwright has shipped across recent releases and runs on
# newer apt releases (its binaries are dynamically linked); we point too-new /
# unrecognized hosts at it. Only x64/arm64 Linux have Playwright builds β emit
# nothing for anything else so the caller skips the retry. Echoes the value
# (e.g. "ubuntu24.04-x64") or nothing.
playwright_fallback_platform() {
case "$(uname -m)" in
x86_64|amd64) echo "ubuntu24.04-x64" ;;
aarch64|arm64) echo "ubuntu24.04-arm64" ;;
*) : ;; # No Playwright Linux build for this arch.
esac
}
# Run a `playwright install ...` command, and if it fails or hangs (the
# uninterruptible "Installing Playwright Chromium with system dependencies"
# stall on apt releases Playwright doesn't recognize yet β Ubuntu 26.04,
# Debian 14, future distros β see #35166), retry it ONCE with
# PLAYWRIGHT_HOST_PLATFORM_OVERRIDE pinned to the newest known build.
#
# The override retry is scoped to the actual hang condition: it fires only when
# the host is an apt release NEWER than Playwright recognizes
# (playwright_host_unrecognized). On every release Playwright already supports
# (Ubuntu <=24.04, Debian <=13) and every non-apt distro, the first attempt is
# authoritative and a failure is reported as-is β we never force a
# mismatched-glibc build (microsoft/playwright#35114) onto a host Playwright
# handles correctly. This is deliberately narrower than a retry-on-any-failure:
# a network/disk/permission error on a supported host should surface, not get
# papered over with a platform override. Playwright's maintainers bless this
# env var as the supported escape hatch for unrecognized platforms
# (microsoft/playwright#33434); a hardcoded full distro/version table was
# rejected upstream (microsoft/playwright#33432), so we only need the
# newest-known floor here.
#
# An operator-provided PLAYWRIGHT_HOST_PLATFORM_OVERRIDE is always respected:
# it is inherited by the first attempt, and the retry is skipped.
#
# Usage: run_playwright_install <timeout_seconds> npx playwright install [args...]
run_playwright_install() {
local timeout_seconds="$1"
shift
# First attempt: native platform resolution (inherits any operator override).
if run_browser_install_with_timeout "$timeout_seconds" "$@" 2>/dev/null; then
return 0
fi
# Operator already pinned the platform β their choice already applied to the
# attempt above; a second identical run won't help.
if [ -n "${PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}" ]; then
return 1
fi
# Only retry with an override on the apt releases too new for Playwright to
# recognize (the #35166 hang). Any other failure is a real failure and is
# surfaced unchanged.
if ! playwright_host_unrecognized; then
return 1
fi
local fallback
fallback="$(playwright_fallback_platform)"
if [ -z "$fallback" ]; then
return 1 # No usable fallback build for this arch.
fi
log_warn "Playwright doesn't recognize ${DISTRO} ${DISTRO_VERSION} yet β retrying with PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$fallback"
log_info "(apt releases newer than Playwright knows hang at this step; see #35166)"
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$fallback" \
run_browser_install_with_timeout "$timeout_seconds" "$@"
}
configure_browser_env_from_system_browser() {
local env_file="$HERMES_HOME/.env"
local browser_path="${DETECTED_BROWSER_EXECUTABLE:-}"
if [ -z "$browser_path" ]; then
browser_path="$(find_system_browser 2>/dev/null || true)"
fi
if [ -z "$browser_path" ]; then
return 0
fi
mkdir -p "$HERMES_HOME"
if [ ! -f "$env_file" ]; then
touch "$env_file"
fi
if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then
log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured"
return 0
fi
{
echo ""
echo "# Hermes Agent browser tools β explicit browser override."
echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path"
} >> "$env_file"
log_success "Configured browser tools to use $browser_path"
}
# Select the npm workspaces a CLI install actually needs, into the
# NODE_DEPS_WORKSPACE_ARGS array.
#
# A bare `npm install` at the repo root resolves package.json's `apps/*`
# glob, which materializes apps/desktop β and with it node-pty, which ships
# no Linux prebuild and falls back to `node-gyp rebuild`. On a host without
# make/gcc that rebuild fails, and since #85297 made a failed npm install
# fatal it aborts the whole install of a machine that will never launch
# Electron or a PTY addon (#38311, #38772). Desktop dependencies are
# installed by install_desktop(), reachable only via --include-desktop.
#
# Naming ui-tui/web excludes the unnamed apps/* workspaces, and
# --include-workspace-root keeps the root's own devDependencies (the shared
# ESLint flat config each workspace imports) from being pruned by the scoped
# install β the same closure `hermes update` installs
# (hermes_cli/main.py::_update_node_dependencies). Prebuilt/partial checkouts
# can lack a workspace, and naming a missing one makes npm fail hard, so fall
# back to a root-only install that still skips apps/*.
node_deps_workspace_args() {
local install_dir="$1"
NODE_DEPS_WORKSPACE_ARGS=()
[ -f "$install_dir/ui-tui/package.json" ] && NODE_DEPS_WORKSPACE_ARGS+=(--workspace ui-tui)
[ -f "$install_dir/web/package.json" ] && NODE_DEPS_WORKSPACE_ARGS+=(--workspace web)
if [ "${#NODE_DEPS_WORKSPACE_ARGS[@]}" -eq 0 ]; then
NODE_DEPS_WORKSPACE_ARGS=(--workspaces=false)
return 0
fi
NODE_DEPS_WORKSPACE_ARGS+=(--include-workspace-root)
}
install_node_deps() {
if [ "$HAS_NODE" = false ]; then
log_info "Skipping Node.js dependencies (Node not installed)"
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_info "Skipping automatic Node/browser dependency setup on Termux"
log_info "Browser automation is not part of the tested Termux install path yet."
log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
return 0
fi
if [ -f "$INSTALL_DIR/package.json" ]; then
log_info "Installing Node.js dependencies (browser tools)..."
cd "$INSTALL_DIR"
# Time-boxed: a stalled registry fetch would otherwise hang here with no
# progress (same #39219 stall class as the desktop build below).
# A failed npm install used to still print "β Node.js dependencies
# installed", hiding the degradation from the user (#77003). Now it
# fails the install outright instead of burying the warning (#85297).
# Capture npm output so failures are diagnosable (#87340).
# Scoped to the workspaces a CLI install needs so apps/desktop's
# node-pty is never built here β see node_deps_workspace_args().
node_deps_workspace_args "$INSTALL_DIR"
local npm_log
npm_log="$(mktemp)"
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install "${NODE_DEPS_WORKSPACE_ARGS[@]}" --silent \
>"$npm_log" 2>&1; then
log_error "npm install failed or timed out; Node.js dependencies were not installed"
if [ -s "$npm_log" ]; then
log_error "npm output:"
cat "$npm_log" >&2
fi
rm -f "$npm_log"
restore_dirty_lockfiles "$INSTALL_DIR"
return 1
fi
rm -f "$npm_log"
log_success "Node.js dependencies installed"
# Install Playwright browser + system dependencies.
# Playwright's --with-deps only supports apt-based systems natively.
# For Arch/Manjaro we install the system libs via pacman first.
# Other systems must install Chromium dependencies manually.
if [ "$SKIP_BROWSER" = true ]; then
log_info "Skipping Playwright/Chromium install (--skip-browser)"
log_info "Browser tools will be unavailable until you run manually:"
log_info " cd $INSTALL_DIR && npx playwright install chromium"
log_info "On apt-based systems, an admin also needs to run:"
log_info " sudo npx playwright install-deps chromium"
else
log_info "Installing browser engine (Playwright Chromium)..."
strip_snap_browser_override
DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)"
if [ -n "$DETECTED_BROWSER_EXECUTABLE" ]; then
log_success "Using explicit browser override: $DETECTED_BROWSER_EXECUTABLE"
log_info "Skipping bundled Chromium download (AGENT_BROWSER_EXECUTABLE_PATH is set)."
else
case "$DISTRO" in
ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot)
# Use --with-deps only when sudo is available non-interactively
# (root, or a user with passwordless sudo). Non-sudo users
# β typical for systemd service accounts and unprivileged
# operator users β would otherwise get blocked on an
# interactive sudo prompt that they can't satisfy. Fall back
# to the browser-only install in that case, and print the
# exact command the admin needs to run separately.
if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then
log_info "Installing Playwright Chromium with system dependencies..."
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install --with-deps chromium || {
log_warn "Playwright browser installation failed β browser tools will not work."
log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium"
}
else
log_warn "No sudo available β skipping system-library install (--with-deps)."
log_info "Ask an administrator to run, one time, as root:"
log_info " sudo npx playwright install-deps chromium"
log_info " (from $INSTALL_DIR, after Node.js deps are installed)"
log_info "Installing Chromium binary into this user's Playwright cache..."
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || {
log_warn "Playwright browser installation failed β browser tools will not work."
log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install chromium"
}
fi
;;
arch|manjaro|cachyos|endeavouros|garuda)
if command -v pacman &> /dev/null; then
log_info "Arch-family distro detected β installing Chromium system dependencies via pacman..."
if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \
nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true
elif [ "$(id -u)" -eq 0 ]; then
pacman -S --noconfirm --needed \
nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true
else
log_warn "Cannot install browser deps without sudo. Run manually:"
log_warn " sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib"
fi
fi
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || {
log_warn "Playwright browser installation failed β browser tools will not work."
}
;;
fedora|rhel|centos|rocky|alma)
log_warn "Playwright does not support automatic dependency installation on RPM-based systems."
log_info "Install Chromium system dependencies manually before using browser tools:"
log_info " sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib"
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || {
log_warn "Playwright browser installation failed β install dependencies above and retry."
}
;;
opensuse*|sles)
log_warn "Playwright does not support automatic dependency installation on zypper-based systems."
log_info "Install Chromium system dependencies manually before using browser tools:"
log_info " sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2"
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || {
log_warn "Playwright browser installation failed β install dependencies above and retry."
}
;;
*)
log_warn "Playwright does not support automatic dependency installation on $DISTRO."
log_info "Install Chromium/browser system dependencies for your distribution, then run:"
log_info " cd $INSTALL_DIR && npx playwright install chromium"
log_info "Browser tools will not work until dependencies are installed."
cd "$INSTALL_DIR" && run_playwright_install 600 npx playwright install chromium || true
;;
esac
fi
fi
log_success "Browser engine setup complete"
fi
# Install TUI dependencies
if [ -f "$INSTALL_DIR/ui-tui/package.json" ]; then
log_info "Installing TUI dependencies..."
cd "$INSTALL_DIR/ui-tui"
# Time-boxed: a stalled registry fetch would otherwise hang here (#39219).
# Report success only on actual success, same as node-deps above
# (#77003) β and fail the install outright (#85297).
# Capture npm output so failures are diagnosable (#87340).
local tui_npm_log
tui_npm_log="$(mktemp)"
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" npm install --silent \
>"$tui_npm_log" 2>&1; then
log_error "TUI npm install failed or timed out; TUI dependencies were not installed"
if [ -s "$tui_npm_log" ]; then
log_error "npm output:"
cat "$tui_npm_log" >&2
fi
rm -f "$tui_npm_log"
restore_dirty_lockfiles "$INSTALL_DIR"
return 1
fi
rm -f "$tui_npm_log"
log_success "TUI dependencies installed"
fi
# Keep the checkout clean so `hermes update` doesn't autostash every run.
restore_dirty_lockfiles "$INSTALL_DIR"
}
install_browser_use_cli() {
# The Browser Use CLI is the default browser backend when it is runnable
# (tools/browser_use_cli.py). Provision it here so fresh installs don't
# silently fall back to the built-in browser tools. Best-effort: any
# failure is non-fatal because browser_exec can still run via uvx and
# `hermes tools` can install it later.
if [ "$SKIP_BROWSER" = true ]; then
log_info "Skipping Browser Use CLI install (--skip-browser)"
return 0
fi
if [ "$DISTRO" = "termux" ]; then
return 0
fi
if [ -z "$UV_CMD" ]; then
log_info "Skipping Browser Use CLI install (uv unavailable)"
return 0
fi
# MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use
# on the user's PATH is a side install β resolution prefers the managed
# copy, so it must be provisioned regardless.
if [ -x "$HERMES_HOME/bin/browser-use" ]; then
log_success "Browser Use CLI already installed"
return 0
fi
log_info "Installing Browser Use CLI (default browser backend)..."
# UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir, where
# the browser tool resolves it β no reliance on the user's PATH.
if run_with_timeout 600 env UV_NO_CONFIG=1 UV_TOOL_BIN_DIR="$HERMES_HOME/bin" \
"$UV_CMD" tool install browser-use >/dev/null 2>&1; then
log_success "Browser Use CLI installed"
else
log_warn "Browser Use CLI install failed β browser automation falls back to built-in tools."
log_info "Install later with: $UV_CMD tool install browser-use (or via 'hermes tools')"
fi
}
cua_driver_runtime_compatible() {
local driver_path version_output manifest_output
local major minor
driver_path="$(command -v cua-driver 2>/dev/null)" || return 1
version_output="$("$driver_path" --version 2>/dev/null)" || return 1
if [[ ! "$version_output" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
return 1
fi
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
if (( major == 0 && minor < 20 )); then
return 1
fi
manifest_output="$("$driver_path" manifest 2>/dev/null)" || return 1
local required
for required in \
'"mcp_invocation"' \
'"--socket"' \
'"--grant"' \
'"--permission-mode"' \
'"--capability-manifest"' \
'"--approve-capability-manifest"' \
'"--embedded"'; do
case "$manifest_output" in
*"$required"*) ;;
*) return 1 ;;
esac
done
return 0
}
install_computer_use_driver() {
# cua-driver powers the computer_use toolset (background desktop control).
# Provision it at install time so enabling the tool later β via
# `hermes tools`, the dashboard, or the desktop app β is a config flip,
# not a surprise multi-minute binary fetch (the confusion this fixes:
# users had to discover `hermes computer-use install` on their own).
# Best-effort and non-fatal: the enable paths still lazy-install via
# install_cua_driver() when this step was skipped or failed.
if [ "$SKIP_COMPUTER_USE" = true ]; then
log_info "Skipping Computer Use (cua-driver) install (--skip-computer-use)"
return 0
fi
case "$DISTRO" in
termux)
return 0
;;
esac
if command -v cua-driver >/dev/null 2>&1; then
if cua_driver_runtime_compatible; then
log_success "Computer Use driver (cua-driver) already installed and compatible"
return 0
fi
log_warn "Existing cua-driver is old or incomplete; repairing it"
fi
# Non-admin macOS accounts can't receive the CuaDriver.app bundle in
# /Applications; skip cleanly instead of failing loudly (#47865 class).
if [ "$(uname -s)" = "Darwin" ] && [ -d /Applications ] && [ ! -w /Applications ]; then
log_info "Skipping Computer Use driver (cua-driver): /Applications is not writable"
return 0
fi
log_info "Installing Computer Use driver (cua-driver)..."
# Same upstream installer `hermes computer-use install` runs; time-boxed
# so a stalled GitHub download can't hang the Hermes install. The
# upstream installer serializes with its own lock (600s stale window),
# so give it a ceiling above that β matching Hermes'
# _CUA_INSTALLER_TIMEOUT (660s).
local cua_log
cua_log="$(mktemp)"
if run_with_timeout 660 /bin/bash -c \
'curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh | /bin/bash' \
>"$cua_log" 2>&1; then
log_success "Computer Use driver installed (enable via 'hermes tools' β Computer Use)"
else
log_warn "Computer Use driver install failed β it will install on demand when you enable the tool."
log_info "Install later with: hermes computer-use install"
tail -n 5 "$cua_log" >&2 || true
fi
rm -f "$cua_log"
}
run_setup_wizard() {
if [ "$RUN_SETUP" = false ]; then
log_info "Skipping setup wizard (--skip-setup)"
return 0
fi
# The setup wizard reads from /dev/tty, so it works even when the
# install script itself is piped (curl | bash). Only skip if no
# terminal is available at all (e.g. Docker build, CI).
#
# Probe by actually opening /dev/tty: a bare existence test passes
# in Docker builds where the device node is in the mount namespace
# but opening fails with ENXIO, so the wizard would proceed and
# then crash on `< /dev/tty` below.
if ! (: </dev/tty) 2>/dev/null; then
log_info "Setup wizard skipped (no terminal available). Run 'hermes setup' after install."
return 0
fi
echo ""
log_info "Starting setup wizard..."
echo ""
cd "$INSTALL_DIR"
# Run hermes setup using the venv Python directly (no activation needed).
# Redirect stdin from /dev/tty so interactive prompts work when piped from curl.
if [ "$USE_VENV" = true ]; then
"$INSTALL_DIR/venv/bin/python" -m hermes_cli.main setup < /dev/tty
else
python -m hermes_cli.main setup < /dev/tty
fi
}
maybe_start_gateway() {
# Check if any messaging platform tokens were configured
ENV_FILE="$HERMES_HOME/.env"
if [ ! -f "$ENV_FILE" ]; then
return 0
fi
HAS_MESSAGING=false
for VAR in TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_ENABLED; do
VAL=$(grep "^${VAR}=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
if [ -n "$VAL" ] && [ "$VAL" != "your-token-here" ]; then
HAS_MESSAGING=true
break
fi
done
if [ "$HAS_MESSAGING" = false ]; then
return 0
fi
echo ""
log_info "Messaging platform token detected!"
log_info "The gateway needs to be running for Hermes to send/receive messages."
# If WhatsApp is enabled and no session exists yet, run foreground first for QR scan
WHATSAPP_VAL=$(grep "^WHATSAPP_ENABLED=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
WHATSAPP_SESSION="$HERMES_HOME/whatsapp/session/creds.json"
if [ "$WHATSAPP_VAL" = "true" ] && [ ! -f "$WHATSAPP_SESSION" ]; then
if [ "$IS_INTERACTIVE" = true ]; then
echo ""
log_info "WhatsApp is enabled but not yet paired."
log_info "Running 'hermes whatsapp' to pair via QR code..."
echo ""
if prompt_yes_no "Pair WhatsApp now?" "yes"; then
HERMES_CMD="$(get_hermes_command_path)"
$HERMES_CMD whatsapp || true
fi
else
log_info "WhatsApp pairing skipped (non-interactive). Run 'hermes whatsapp' to pair."
fi
fi
# Probe by actually opening /dev/tty: a bare existence test passes
# in Docker builds where the device node is in the mount namespace
# but opening fails with ENXIO. See #16746.
if ! (: </dev/tty) 2>/dev/null; then
log_info "Gateway setup skipped (no terminal available). Run 'hermes gateway install' later."
return 0
fi
echo ""
local should_install_gateway=false
if [ "$DISTRO" = "termux" ]; then
if prompt_yes_no "Would you like to start the gateway in the background?" "yes"; then
should_install_gateway=true
fi
else
if prompt_yes_no "Would you like to install the gateway as a background service?" "yes"; then
should_install_gateway=true
fi
fi
if [ "$should_install_gateway" = true ]; then
HERMES_CMD="$(get_hermes_command_path)"
if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then
log_info "Installing systemd service..."
if $HERMES_CMD gateway install 2>/dev/null; then
log_success "Gateway service installed"
if $HERMES_CMD gateway start 2>/dev/null; then
log_success "Gateway started! Your bot is now online."
else
log_warn "Service installed but failed to start. Try: hermes gateway start"
fi
else
log_warn "Systemd install failed. You can start manually: hermes gateway"
fi
else
if [ "$DISTRO" = "termux" ]; then
log_info "Termux detected β starting gateway in best-effort background mode..."
else
log_info "systemd not available β starting gateway in background..."
fi
nohup $HERMES_CMD gateway > "$HERMES_HOME/logs/gateway.log" 2>&1 &
GATEWAY_PID=$!
log_success "Gateway started (PID $GATEWAY_PID). Logs: ~/.hermes/logs/gateway.log"
log_info "To stop: kill $GATEWAY_PID"
log_info "To restart later: hermes gateway"
if [ "$DISTRO" = "termux" ]; then
log_warn "Android may stop background processes when Termux is suspended or the system reclaims resources."
fi
fi
else
log_info "Skipped. Start the gateway later with: hermes gateway"
fi
}
write_bootstrap_marker() {
# Writes $INSTALL_DIR/.hermes-bootstrap-complete, which tells the Hermes
# desktop app (apps/desktop/electron/main.ts) and the macOS launcher fast
# path (apps/bootstrap-installer) "a real install finished here -- don't
# re-run first-run bootstrap."
#
# Schema mirrors install.ps1's Write-BootstrapMarker and main.ts's
# writeBootstrapMarker(). Keep the three in lockstep:
# schemaVersion 1 + pinnedCommit (length >= 7) are what the desktop
# validator requires; desktopVersion is omitted because only the desktop
# app knows its own version.
if [ ! -d "$INSTALL_DIR" ]; then
log_warn "Skipping bootstrap marker: $INSTALL_DIR doesn't exist"
return 0
fi
# Explicit --commit wins; otherwise read HEAD from the checkout we just
# installed. If neither resolves, skip the marker entirely rather than
# write one the desktop will reject -- an absent marker is a clean
# "bootstrap needed", a malformed one is a confusing half-state.
local pinned_commit="$INSTALL_COMMIT"
if [ -z "$pinned_commit" ]; then
pinned_commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || pinned_commit=""
fi
if [ -z "$pinned_commit" ]; then
log_warn "Skipping bootstrap marker: could not resolve HEAD in $INSTALL_DIR"
return 0
fi
local marker_path="$INSTALL_DIR/.hermes-bootstrap-complete"
local tmp_path="$marker_path.tmp"
# Atomic publish: the macOS launcher predicate only checks existence, so a
# torn write would arm the fast path against a half-written marker.
printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \
"$pinned_commit" \
"$BRANCH" \
"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$tmp_path"
mv -f "$tmp_path" "$marker_path"
}
print_success() {
echo ""
echo -e "${GREEN}${BOLD}"
echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
echo "β β Installation Complete! β"
echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
echo -e "${NC}"
echo ""
# Show file locations
echo -e "${CYAN}${BOLD}π Your files:${NC}"
echo ""
echo -e " ${YELLOW}Config:${NC} $HERMES_HOME/config.yaml"
echo -e " ${YELLOW}API Keys:${NC} $HERMES_HOME/.env"
echo -e " ${YELLOW}Data:${NC} $HERMES_HOME/cron/, sessions/, logs/"
echo -e " ${YELLOW}Code:${NC} $INSTALL_DIR"
echo ""
echo -e "${CYAN}βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
echo ""
echo -e "${CYAN}${BOLD}π Commands:${NC}"
echo ""
echo -e " ${GREEN}hermes${NC} Start chatting"
echo -e " ${GREEN}hermes setup${NC} Configure API keys & settings"
echo -e " ${GREEN}hermes config${NC} View/edit configuration"
echo -e " ${GREEN}hermes config edit${NC} Open config in editor"
echo -e " ${GREEN}hermes gateway install${NC} Install gateway service (messaging + cron)"
echo -e " ${GREEN}hermes update${NC} Update to latest version"
echo ""
echo -e "${CYAN}βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ${NC}"
echo ""
if [ "$DISTRO" = "termux" ]; then
echo -e "${YELLOW}β‘ 'hermes' was linked into $(get_command_link_display_dir), which is already on PATH in Termux.${NC}"
echo ""
elif [ "$ROOT_FHS_LAYOUT" = true ]; then
echo -e "${YELLOW}β‘ 'hermes' was linked into /usr/local/bin and is ready to use β no shell reload needed.${NC}"
echo ""
else
echo -e "${YELLOW}β‘ Reload your shell to use 'hermes' command:${NC}"
echo ""
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
if [ "$LOGIN_SHELL" = "zsh" ]; then
echo " source ~/.zshrc"
elif [ "$LOGIN_SHELL" = "bash" ]; then
echo " source ~/.bashrc"
elif [ "$LOGIN_SHELL" = "fish" ]; then
echo " source ~/.config/fish/config.fish"
else
echo " source ~/.bashrc # or ~/.zshrc"
fi
echo ""
fi
# Show Node.js warning if auto-install failed
if [ "$HAS_NODE" = false ]; then
echo -e "${YELLOW}"
echo "Note: Node.js could not be installed automatically."
echo "Browser tools need Node.js. Install manually:"
if [ "$DISTRO" = "termux" ]; then
echo " pkg install nodejs"
else
echo " https://nodejs.org/en/download/"
fi
echo -e "${NC}"
fi
# Show ripgrep note if not installed
if [ "$HAS_RIPGREP" = false ]; then
echo -e "${YELLOW}"
echo "Note: ripgrep (rg) was not found. File search will use"
echo "grep as a fallback. For faster search in large codebases,"
if [ "$DISTRO" = "termux" ]; then
echo "install ripgrep: pkg install ripgrep"
else
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
fi
echo -e "${NC}"
fi
}
ensure_browser() {
if ! command -v node >/dev/null 2>&1; then
local node_bin="$HERMES_HOME/node/bin/node"
if [ -x "$node_bin" ]; then
export PATH="$HERMES_HOME/node/bin:$PATH"
else
log_error "Node.js not found. Run with --ensure node first."
return 1
fi
fi
local npm_bin
npm_bin="$(command -v npm 2>/dev/null || echo "$HERMES_HOME/node/bin/npm")"
if [ ! -x "$npm_bin" ]; then
log_error "npm not found"
return 1
fi
# agent-browser itself is intentionally NOT installed here (#43564 /
# PR #44772 review): it resolves lazily via `npx agent-browser` instead,
# which every consumer (tools/browser_tool.py, `hermes update`'s npx
# cache warm) already goes through. Eagerly npm-installing a second,
# separately version-pinned copy here -- only reachable via this
# explicit --ensure browser fallback in the first place -- was redundant
# complexity and an extra credential/supply-chain surface for a path
# npx already covers.
log_info "Installing camofox browser server..."
local log_file
log_file="$(mktemp)"
# Time-boxed (#39219): a stalled npm registry fetch here would otherwise
# hang the installer with no progress, same class as the desktop build.
if ! run_with_timeout "$NODE_DEPS_TIMEOUT" "$npm_bin" install -g --prefix "$HERMES_HOME/node" --silent --ignore-scripts \
"@askjo/camofox-browser@^1.5.2" \
>"$log_file" 2>&1; then
log_error "npm install failed or timed out:"
cat "$log_file" >&2
rm -f "$log_file"
return 1
fi
rm -f "$log_file"
export PATH="$HERMES_HOME/node/bin:$PATH"
strip_snap_browser_override
local sys_browser
sys_browser="$(find_system_browser 2>/dev/null || true)"
if [ -n "$sys_browser" ]; then
configure_browser_env_from_system_browser "$sys_browser"
log_info "Explicit browser override set -- Chromium download will be skipped when agent-browser installs on demand"
fi
return 0
}
ensure_mode() {
detect_os
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
for dep in "${DEPS[@]}"; do
dep="$(echo "$dep" | tr -d '[:space:]')"
case "$dep" in
node)
check_node
;;
browser)
check_node
if [ "$HAS_NODE" = true ]; then
ensure_browser
fi
;;
ripgrep)
if ! command -v rg &>/dev/null; then
HAS_RIPGREP=false
HAS_FFMPEG=true
install_system_packages
fi
;;
ffmpeg)
if ! command -v ffmpeg &>/dev/null; then
HAS_FFMPEG=false
HAS_RIPGREP=true
install_system_packages
fi
;;
*)
log_warn "Unknown dependency: $dep"
;;
esac
done
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial/resumed download
# that leaves concatenated junk - makes electron-builder's `unpack-electron`
# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename
# dies with ENOENT and every re-run repeats the broken extraction forever. This
# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python
# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only
# build path lacking it. Echoes the removed paths (one per line); best-effort.
clear_electron_build_cache() {
local desktop_dir="$1"
local removed=""
# Per-user Electron download cache dirs, honoring the overrides @electron/get
# respects, then the platform defaults (macOS: ~/Library/Caches/electron,
# Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron).
local cache_dirs=()
[ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache")
[ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE")
if [ "$OS" = "macos" ]; then
cache_dirs+=("$HOME/Library/Caches/electron")
else
[ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron")
cache_dirs+=("$HOME/.cache/electron")
fi
local dir zip
for dir in "${cache_dirs[@]}"; do
[ -d "$dir" ] || continue
# Recurse: the bad copy may be the top-level zip OR a copy inside an
# @electron/get hash subdir.
while IFS= read -r zip; do
[ -n "$zip" ] || continue
if rm -f "$zip" 2>/dev/null; then
removed="$removed$zip
"
fi
done <<EOF
$(find "$dir" -type f -name 'electron-*.zip' 2>/dev/null)
EOF
done
# A half-written unpacked dir from an interrupted prior pack poisons the
# rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked).
local release_dir="$desktop_dir/release"
if [ -d "$release_dir" ]; then
local unpacked
while IFS= read -r unpacked; do
[ -n "$unpacked" ] || continue
if rm -rf "$unpacked" 2>/dev/null; then
removed="$removed$unpacked
"
fi
done <<EOF
$(find "$release_dir" -maxdepth 1 -type d -name '*-unpacked' 2>/dev/null)
EOF
fi
printf '%s' "$removed"
}
# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc +
# vite build + electron-builder --dir, producing an unpacked app for the
# current OS. Signing auto-discovery is disabled so electron-builder falls back
# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the
# keychain (a real signed/notarized .dmg needs Apple credentials β a separate
# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt,
# used as a fallback when the default GitHub release download is blocked.
_desktop_pack() {
local desktop_dir="$1"
local mirror="${2:-}"
if [ -n "$mirror" ]; then
( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
else
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
fi
}
# Last-resort Electron mirror after GitHub download fails (#47266).
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
# Per-attempt wall-clock cap for the desktop npm install / electron-builder pack
# (#39219). A stalled (not failed) Electron download on a throttled/blocked link
# never returns, so without this the installer hangs forever on "Build desktop
# app". 900s is generous enough for a slow-but-progressing ~150MB fetch + build;
# override with DESKTOP_BUILD_TIMEOUT for very slow links.
DESKTOP_BUILD_TIMEOUT="${DESKTOP_BUILD_TIMEOUT:-900}"
# Wall-clock cap for the plain registry `npm install`s (browser-tools + TUI
# deps). Same #39219 stall class but no ~150MB Electron binary, so a shorter
# default; override with NODE_DEPS_TIMEOUT for very slow links.
NODE_DEPS_TIMEOUT="${NODE_DEPS_TIMEOUT:-600}"
# Electron package dir β workspace-local nest first, then root hoist.
_electron_dir() {
local install_dir="$1"
if [ -d "$install_dir/apps/desktop/node_modules/electron" ]; then
printf '%s\n' "$install_dir/apps/desktop/node_modules/electron"
else
printf '%s\n' "$install_dir/node_modules/electron"
fi
}
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs).
_electron_dist_ok() {
local install_dir="$1"
local electron_dir
electron_dir="$(_electron_dir "$install_dir")"
if [ "$OS" = "macos" ]; then
[ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ]
else
[ -e "$electron_dir/dist/electron" ]
fi
}
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
_restore_electron_dist() {
local install_dir="$1"
local mirror="${2:-}"
local electron_dir
electron_dir="$(_electron_dir "$install_dir")"
_electron_dist_ok "$install_dir" && return 0
[ -f "$electron_dir/install.js" ] || return 1
command -v node >/dev/null 2>&1 || return 1
rm -rf "$electron_dir/dist" 2>/dev/null || true
rm -f "$electron_dir/path.txt" 2>/dev/null || true
if [ -n "$mirror" ]; then
( cd "$electron_dir" && ELECTRON_MIRROR="$mirror" node install.js ) || true
else
( cd "$electron_dir" && node install.js ) || true
fi
_electron_dist_ok "$install_dir"
}
_electron_pkg_staged_missing_dist() {
local install_dir="$1"
local electron_dir
electron_dir="$(_electron_dir "$install_dir")"
[ -f "$electron_dir/package.json" ] && [ -f "$electron_dir/install.js" ] && ! _electron_dist_ok "$install_dir"
}
_restore_electron_dist_with_fallback() {
local install_dir="$1"
_restore_electron_dist "$install_dir" \
|| { [ -z "${ELECTRON_MIRROR:-}" ] && _restore_electron_dist "$install_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; }
}
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
# (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked
# via the 'desktop' stage / --include-desktop, which the Electron app's own
# first-launch bootstrap never requests (it must not rebuild itself).
install_desktop_voice_deps() {
# Desktop ships with working voice out of the box: eagerly install the
# wake-word + local-STT stacks ([wake] + [voice] extras) instead of
# leaving them to lazy first-use install. Policy change (Teknium, July
# 2026, #70509 testing): the first ear-click used to trigger a
# multi-minute onnxruntime pip install that froze the UI and blew RPC
# timeouts. Lazy install remains the fallback for CLI-only installs and
# for anything this best-effort step fails to fetch.
local _prev_venv="${VIRTUAL_ENV:-}"
if [ "$USE_VENV" = true ]; then
export VIRTUAL_ENV="$INSTALL_DIR/venv"
fi
if [ -z "${UV_CMD:-}" ]; then
install_uv || true
fi
if [ -z "${UV_CMD:-}" ]; then
log_warn "uv unavailable β voice/wake deps will lazy-install at first use instead"
return 0
fi
log_info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper β 1-3min)..."
if (cd "$INSTALL_DIR" && $UV_CMD pip install -e ".[wake,voice]") ; then
log_success "Voice + wake-word dependencies installed"
else
log_warn "Voice/wake dependency install failed β they will lazy-install at first use"
fi
if [ "$USE_VENV" = true ] && [ -z "$_prev_venv" ]; then
unset VIRTUAL_ENV
fi
return 0
}
install_desktop() {
local desktop_dir="$INSTALL_DIR/apps/desktop"
# The desktop stage only runs when a build is explicitly requested
# (--include-desktop / 'desktop' stage), so a missing toolchain is a hard
# failure, not a silent skip β a silent skip yields a "complete" install
# with no app and a confusing "couldn't find a built desktop" at launch.
# Always re-resolve Node here. Stages run in separate processes, so we can't
# trust an earlier check; more importantly check_node now enforces the
# supported Node lines and prepends the Hermes-managed Node to PATH, so
# the build never runs on a too-old system Node β the cause of the opaque
# "Build desktop app β¦ exit code 1" failure (Vite crashes on old Node).
check_node
if ! command -v npm >/dev/null 2>&1; then
log_error "Cannot build desktop app: Node.js / npm unavailable"
log_info "Install Node.js and retry: cd $desktop_dir && npm run pack"
return 1
fi
if [ ! -f "$desktop_dir/package.json" ]; then
log_warn "Skipping desktop build (apps/desktop not present in checkout)"
return 0
fi
# 1. Root workspace install so apps/desktop's deps (Electron, Vite,
# node-pty prebuilds) resolve. The browser-tools install runs in the
# repo-root package workspace, which does not pull apps/* deps.
#
# Prefer `npm ci`: it deletes node_modules and reinstalls from the
# lockfile, so it always produces a complete tree. Bare `npm install`
# can report "up to date" against a stale node_modules/.package-lock.json
# marker while node_modules is actually empty (Windows workspace-hoisting
# flake) β leaving tsc/typescript unresolved and `npm run pack`'s
# `tsc -b` failing with no obvious cause. Fall back to `npm install`
# only if `npm ci` is unavailable or the lockfile is out of sync.
#
# Both the install and the build below are wrapped in a hard wall-clock
# timeout (#39219): the Electron binary (~150MB) is fetched from GitHub,
# and on a throttled/blocked connection that download can *stall* β npm
# neither errors nor exits, so the installer sits on "Build desktop app"
# forever with only `npm warn deprecated` lines visible. A stall now
# converts to a non-zero exit, which feeds the existing self-heal /
# mirror-fallback escalation instead of hanging the whole install.
#
# The `npm ci` and its `npm install` fallback SHARE one budget: a stalled
# link wedges both identically, so giving each a full DESKTOP_BUILD_TIMEOUT
# would double the worst-case hang. We compute a single deadline and pass
# the remaining seconds to the fallback (min 30s so it still gets a real
# attempt if `npm ci` failed fast rather than stalling).
log_info "Installing desktop workspace dependencies (includes Electron ~150MB, 1-3min)..."
local _deps_start _deps_remaining
_deps_start=$(date +%s)
if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" bash -c 'cd "$1" && npm ci' _ "$INSTALL_DIR"; then
log_success "Desktop workspace dependencies installed"
elif _deps_remaining=$(( DESKTOP_BUILD_TIMEOUT - ($(date +%s) - _deps_start) )); \
[ "$_deps_remaining" -lt 30 ] && _deps_remaining=30; \
run_with_timeout "$_deps_remaining" bash -c 'cd "$1" && npm install' _ "$INSTALL_DIR"; then
log_success "Desktop workspace dependencies installed"
elif _electron_pkg_staged_missing_dist "$INSTALL_DIR"; then
log_warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
_restore_electron_dist_with_fallback "$INSTALL_DIR" || true
else
log_error "Desktop workspace npm install failed"
# Common cause: a previous 'sudo npm'/'sudo npx' left root-owned files in
# ~/.npm, so this non-root install can't write the shared cache. npm hides
# it behind a confusing EEXIST / "File exists" message while the real errno
# is EACCES (-13). Point the user at the fix instead of a raw npm trace.
log_info "If the errors above mention EACCES / 'permission denied' / EEXIST while"
log_info "writing the npm cache, your ~/.npm likely holds root-owned files from an"
log_info "earlier 'sudo npm' or 'sudo npx'. Reclaim ownership and retry:"
log_info " sudo chown -R \"\$(id -un)\" ~/.npm && npm cache verify"
log_info "Then re-run this installer, or build manually:"
log_info " cd \"$INSTALL_DIR\" && npm ci && cd apps/desktop && npm run pack"
return 1
fi
# 2. Build, with up to three escalating attempts so a transient/blocked
# Electron download self-heals instead of failing the whole install:
# a) plain `npm run pack` (downloads Electron from GitHub),
# b) on failure, purge a corrupt cached zip + stale unpacked dir and
# retry (matches install.ps1 / `hermes desktop`),
# c) on still-failing, fall back to a public Electron mirror β this is
# the GitHub-blocked/throttled case (the repeating "retrying" log).
log_info "Building desktop app (this takes 1-3 minutes)..."
local pack_ok=false
if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir"; then
pack_ok=true
else
local purged=""
local restored=false
if ! _electron_dist_ok "$INSTALL_DIR"; then
purged="$(clear_electron_build_cache "$desktop_dir")"
if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi
fi
if [ "$restored" = true ]; then
log_warn "Desktop build failed; refreshed the Electron download and retrying once..."
if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir"; then
pack_ok=true
fi
fi
fi
# (c) GitHub blocked β mirror fallback (#47266).
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
log_warn "Desktop build still failing β the Electron download from GitHub looks blocked."
log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..."
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
_electron_dist_ok "$INSTALL_DIR" || _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR" || true
if run_with_timeout "$DESKTOP_BUILD_TIMEOUT" _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
fi
if [ "$pack_ok" = false ]; then
log_error "Desktop app build failed"
# If the log shows repeated "retrying" lines fetching the Electron zip,
# the binary download is blocked/throttled (firewall, proxy, region) and
# the mirror fallback above also couldn't reach a host. Try a mirror you
# trust and rebuild (@electron/get honors ELECTRON_MIRROR):
log_info "If the log shows Electron download retries, rebuild via a reachable mirror:"
log_info " ELECTRON_MIRROR=<mirror-base-url> \\"
log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'"
log_info "Otherwise build manually: cd $desktop_dir && npm run pack"
return 1
fi
local app=""
if [ "$OS" = "linux" ]; then
if [ -x "$desktop_dir/release/linux-unpacked/Hermes" ]; then
app="$desktop_dir/release/linux-unpacked/Hermes"
elif [ -x "$desktop_dir/release/linux-unpacked/hermes" ]; then
app="$desktop_dir/release/linux-unpacked/hermes"
fi
else
local cand
for cand in \
"$desktop_dir/release/mac-arm64/Hermes.app" \
"$desktop_dir/release/mac/Hermes.app"; do
if [ -d "$cand" ]; then
app="$cand"
break
fi
done
fi
if [ -z "$app" ]; then
log_error "Desktop build completed but no app was found under $desktop_dir/release/"
return 1
fi
log_success "Desktop app built: $app"
# Linux: Electron's chrome-sandbox helper needs root:root 4755 or the
# sandboxed renderer will abort on startup. Check the file is a regular
# file (not a symlink) before chown/chmod so we don't follow an
# attacker-controlled link to an arbitrary path.
if [ "$OS" = "linux" ]; then
local sandbox="$desktop_dir/release/linux-unpacked/chrome-sandbox"
if [ -f "$sandbox" ] && [ ! -L "$sandbox" ]; then
if [ "$(id -u)" -eq 0 ]; then
chown root:root "$sandbox" && chmod 4755 "$sandbox" || {
log_error "Cannot configure Electron sandbox helper: $sandbox"
return 1
}
elif command -v sudo >/dev/null 2>&1; then
sudo chown root:root "$sandbox" && sudo chmod 4755 "$sandbox" || {
log_error "Cannot configure Electron sandbox helper (sudo failed): $sandbox"
return 1
}
else
log_error "Cannot configure Electron sandbox helper without sudo: $sandbox"
return 1
fi
fi
fi
# macOS: route through the same config-aware signing fixup as
# `hermes desktop`, so install/repair and self-update agree about the app's
# identity. The fixup preserves the Electron entitlement plists and signs
# with a stable Designated Requirement (configured keychain identity, else
# identifier-pinned ad-hoc), so macOS TCC grants β Full Disk Access,
# Desktop/Downloads/Documents, Accessibility, microphone β survive the
# rebuild instead of resetting on every update. The shell's
# publisher-signing decision governed the build and is passed explicitly so
# importing Python cannot reverse it by loading HERMES_HOME/.env. If the
# helper is unavailable or fails, branch into the historical quarantine
# strip + deep ad-hoc repair so a broken venv never leaves the bundle
# unsigned/unlaunchable.
if [ "$OS" = "macos" ] && [ -z "${CSC_LINK:-}" ] && [ -z "${APPLE_SIGNING_IDENTITY:-}" ] && command -v codesign >/dev/null 2>&1; then
local config_python="$INSTALL_DIR/venv/bin/python"
local fixup_ok=""
if [ -x "$config_python" ]; then
if HERMES_HOME="$HERMES_HOME" "$config_python" - "$desktop_dir" <<'PYEOF'
import sys
from pathlib import Path
from hermes_cli.main import _desktop_macos_relaunchable_fixup
ok = _desktop_macos_relaunchable_fixup(
Path(sys.argv[1]), publisher_signing_configured=False
)
sys.exit(0 if ok else 1)
PYEOF
then
fixup_ok=1
else
log_warn "Config-aware macOS signing fixup failed; applying the historical ad-hoc fallback."
fi
fi
if [ -z "$fixup_ok" ]; then
xattr -cr "$app" 2>/dev/null || true
codesign --force --deep --sign - "$app" >/dev/null 2>&1 || true
fi
fi
# `npm install` + `npm run pack` rewrite lockfiles; restore them so the
# checkout stays clean for the next `hermes update`.
restore_dirty_lockfiles "$INSTALL_DIR"
}
# Each --stage runs in its own process, so (unlike the monolithic main() where
# clone_repo cd's once and later steps inherit it) a stage that operates on the
# checkout must cd into it explicitly. Without this, install_deps/setup_path run
# from the desktop app's cwd and resolve `.` / the venv against the wrong tree.
require_install_dir() {
if [ -z "$INSTALL_DIR" ] || [ ! -d "$INSTALL_DIR" ]; then
log_error "Install directory not found: ${INSTALL_DIR:-<unset>}"
log_info "The 'repository' stage must run before this one."
return 1
fi
cd "$INSTALL_DIR"
}
# Desktop bootstrap stage protocol. Mirrors the Windows install.ps1 surface
# closely enough for the Electron bootstrap runner to show structured progress.
run_stage_body() {
local stage="$1"
case "$stage" in
prerequisites)
print_banner
detect_os
resolve_install_layout
install_uv
check_python
check_git
check_node
check_cxx_compiler
check_network_prerequisites
install_system_packages
;;
repository)
detect_os
resolve_install_layout
check_git
clone_repo
;;
venv)
detect_os
resolve_install_layout
require_install_dir
install_uv
check_python
setup_venv
;;
python-deps)
detect_os
resolve_install_layout
require_install_dir
install_uv
check_python
install_deps
;;
node-deps)
detect_os
resolve_install_layout
require_install_dir
check_node
install_node_deps || return
install_uv
install_browser_use_cli
install_computer_use_driver
;;
path)
detect_os
resolve_install_layout
require_install_dir
setup_path
;;
config)
detect_os
resolve_install_layout
require_install_dir
copy_config_templates
;;
setup)
detect_os
resolve_install_layout
require_install_dir
run_setup_wizard
;;
gateway)
detect_os
resolve_install_layout
require_install_dir
maybe_start_gateway
;;
desktop)
detect_os
resolve_install_layout
require_install_dir
# Each stage runs in its own process, so the Hermes-managed Node
# provisioned during prerequisites/node-deps (at $HERMES_HOME/node/bin)
# isn't on PATH here. check_node re-adds it (or installs if missing)
# so install_desktop can find npm instead of silently skipping.
check_node
install_desktop_voice_deps
install_desktop
;;
complete)
detect_os
resolve_install_layout
print_success
write_bootstrap_marker
# Code-scoped stamp: write next to the install tree, not into
# $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be
# bind-mounted into a Docker gateway too), so a stamp there gets
# clobbered by the container's 'docker' stamp and wrongly blocks
# 'hermes update' on this host install. See detect_install_method().
echo "git" > "$INSTALL_DIR/.install_method"
;;
*)
log_error "Unknown stage: $stage"
return 2
;;
esac
}
run_stage_protocol() {
local stage="$1"
if [ -z "$stage" ]; then
log_error "--stage requires a stage name"
if [ "$JSON_OUTPUT" = true ]; then
emit_stage_json "" false false "missing stage name"
fi
return 2
fi
if [ "$NON_INTERACTIVE" = true ] && stage_needs_user_input "$stage"; then
log_info "Skipping $stage (non-interactive bootstrap)"
if [ "$JSON_OUTPUT" = true ]; then
emit_stage_json "$stage" true true
fi
return 0
fi
# Run the stage body in a subshell so a stage helper that calls `exit 1`
# on failure (clone_repo, install_deps, etc. were written for the monolithic
# flow) only exits the subshell β the parent still reaches the JSON result
# frame below. Without this, a failed --stage would terminate the process
# before emitting the frame and the Rust/Electron parser would see "no
# result frame" instead of a clean {ok:false} contract response.
set +e
( run_stage_body "$stage" )
local code=$?
set -e
if [ "$JSON_OUTPUT" = true ]; then
if [ "$code" -eq 0 ]; then
emit_stage_json "$stage" true false
else
emit_stage_json "$stage" false false "exit code $code"
fi
fi
return "$code"
}
# ============================================================================
# Main
# ============================================================================
main() {
print_banner
detect_os
resolve_install_layout
install_uv
check_python
check_git
check_node
check_cxx_compiler
check_network_prerequisites
install_system_packages
clone_repo
setup_venv
install_deps
install_node_deps || return
install_browser_use_cli
install_computer_use_driver
setup_path
copy_config_templates
run_setup_wizard
maybe_start_gateway
if [ "$INCLUDE_DESKTOP" = true ]; then
install_desktop_voice_deps
install_desktop
fi
print_success
write_bootstrap_marker
# Code-scoped stamp: write next to the install tree, not into $HERMES_HOME.
# $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker
# gateway too), so a stamp there gets clobbered by the container's 'docker'
# stamp and wrongly blocks 'hermes update' on this host install.
# See detect_install_method().
echo "git" > "$INSTALL_DIR/.install_method"
}
if [ "$MANIFEST_MODE" = true ]; then
emit_manifest
elif [ -n "$STAGE_NAME" ]; then
run_stage_protocol "$STAGE_NAME"
elif [ -n "$ENSURE_DEPS" ]; then
ensure_mode
else
main
fi
|