@localskills/cli 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +27 -6
  2. package/dist/index.js +831 -193
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,13 +5,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __require = /* @__PURE__ */ ((x3) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x3, {
9
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
- }) : x3)(function(x3) {
11
- if (typeof require !== "undefined") return require.apply(this, arguments);
12
- throw Error('Dynamic require of "' + x3 + '" is not supported');
13
- });
14
- var __commonJS = (cb, mod) => function __require2() {
8
+ var __commonJS = (cb, mod) => function __require() {
15
9
  try {
16
10
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
11
  } catch (e2) {
@@ -1400,6 +1394,9 @@ var ApiClient = class {
1400
1394
  }
1401
1395
  return h;
1402
1396
  }
1397
+ authHeader() {
1398
+ return this.token ? { Authorization: `Bearer ${this.token}` } : {};
1399
+ }
1403
1400
  async handleResponse(res) {
1404
1401
  if (res.status === 401) {
1405
1402
  return {
@@ -1409,19 +1406,45 @@ var ApiClient = class {
1409
1406
  }
1410
1407
  return res.json();
1411
1408
  }
1409
+ /** Turn a transport-level rejection (offline, DNS, TLS, reset, bad body) into a failure response. */
1410
+ transportError(err) {
1411
+ const detail = err instanceof Error ? err.message : String(err);
1412
+ return { success: false, error: `Network error: ${detail}`, networkError: true };
1413
+ }
1412
1414
  async get(path) {
1413
- const res = await fetch(`${this.baseUrl}${path}`, {
1414
- headers: this.headers()
1415
- });
1416
- return this.handleResponse(res);
1415
+ try {
1416
+ const res = await fetch(`${this.baseUrl}${path}`, {
1417
+ headers: this.headers()
1418
+ });
1419
+ return await this.handleResponse(res);
1420
+ } catch (err) {
1421
+ return this.transportError(err);
1422
+ }
1417
1423
  }
1418
1424
  async post(path, body) {
1419
- const res = await fetch(`${this.baseUrl}${path}`, {
1420
- method: "POST",
1421
- headers: this.headers(),
1422
- body: body ? JSON.stringify(body) : void 0
1423
- });
1424
- return this.handleResponse(res);
1425
+ try {
1426
+ const res = await fetch(`${this.baseUrl}${path}`, {
1427
+ method: "POST",
1428
+ headers: this.headers(),
1429
+ body: body ? JSON.stringify(body) : void 0
1430
+ });
1431
+ return await this.handleResponse(res);
1432
+ } catch (err) {
1433
+ return this.transportError(err);
1434
+ }
1435
+ }
1436
+ async postForm(path, form) {
1437
+ try {
1438
+ const res = await fetch(`${this.baseUrl}${path}`, {
1439
+ method: "POST",
1440
+ // No Content-Type — fetch sets the multipart/form-data boundary.
1441
+ headers: this.authHeader(),
1442
+ body: form
1443
+ });
1444
+ return await this.handleResponse(res);
1445
+ } catch (err) {
1446
+ return this.transportError(err);
1447
+ }
1425
1448
  }
1426
1449
  async fetchBinary(url) {
1427
1450
  const headers = {};
@@ -1549,6 +1572,11 @@ var loginCommand = new Command("login").description("Log in to localskills.sh").
1549
1572
  Le("Done!");
1550
1573
  return;
1551
1574
  }
1575
+ if (pollRes.data.status === "denied") {
1576
+ pollSpinner.stop("Authorization denied in the browser.");
1577
+ process.exit(1);
1578
+ return;
1579
+ }
1552
1580
  if (pollRes.data.status === "expired" || pollRes.data.status === "not_found") {
1553
1581
  pollSpinner.stop("Login expired. Please try again.");
1554
1582
  process.exit(1);
@@ -1587,7 +1615,8 @@ var whoamiCommand = new Command("whoami").description("Show current user info").
1587
1615
 
1588
1616
  // src/commands/install.ts
1589
1617
  import { Command as Command2 } from "commander";
1590
- import { mkdirSync as mkdirSync7 } from "fs";
1618
+ import { mkdirSync as mkdirSync7, rmSync as rmSync4, cpSync } from "fs";
1619
+ import { dirname as dirname5 } from "path";
1591
1620
 
1592
1621
  // ../../packages/shared/dist/utils/semver.js
1593
1622
  var SEMVER_RE = /^\d+\.\d+\.\d+$/;
@@ -1624,6 +1653,10 @@ function isGreaterThan(next, prev) {
1624
1653
  }
1625
1654
 
1626
1655
  // ../../packages/shared/dist/utils/index.js
1656
+ var PACKAGE_MAX_COMPRESSED_BYTES = 1 * 1024 * 1024;
1657
+ var PACKAGE_MAX_DECOMPRESSED_BYTES = 1 * 1024 * 1024;
1658
+ var PACKAGE_MAX_FILE_COUNT = 500;
1659
+ var PACKAGE_PREVIEW_BYTES = 2048;
1627
1660
  function titleFromSlug(slug) {
1628
1661
  return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1629
1662
  }
@@ -1660,13 +1693,13 @@ function cancelGuard(value) {
1660
1693
  // src/lib/cache.ts
1661
1694
  import {
1662
1695
  existsSync as existsSync13,
1663
- mkdirSync as mkdirSync5,
1696
+ mkdirSync as mkdirSync6,
1664
1697
  readFileSync as readFileSync4,
1665
- readdirSync,
1666
- writeFileSync as writeFileSync5,
1698
+ readdirSync as readdirSync2,
1699
+ writeFileSync as writeFileSync6,
1667
1700
  rmSync as rmSync3
1668
1701
  } from "fs";
1669
- import { join as join12, resolve as resolve2 } from "path";
1702
+ import { join as join13, resolve as resolve3 } from "path";
1670
1703
  import { homedir as homedir8 } from "os";
1671
1704
 
1672
1705
  // src/lib/installers/cursor.ts
@@ -1722,9 +1755,10 @@ import {
1722
1755
  lstatSync,
1723
1756
  existsSync as existsSync2,
1724
1757
  mkdirSync as mkdirSync2,
1725
- rmSync
1758
+ rmSync,
1759
+ readlinkSync
1726
1760
  } from "fs";
1727
- import { dirname, resolve } from "path";
1761
+ import { dirname, isAbsolute, relative, resolve, sep } from "path";
1728
1762
  function createSymlink(targetPath, linkPath) {
1729
1763
  const absTarget = resolve(targetPath);
1730
1764
  const absLink = resolve(linkPath);
@@ -1753,6 +1787,17 @@ function isSymlink(path) {
1753
1787
  return false;
1754
1788
  }
1755
1789
  }
1790
+ function isSymlinkInto(linkPath, dir) {
1791
+ try {
1792
+ if (!lstatSync(linkPath).isSymbolicLink()) return false;
1793
+ const target = resolve(dirname(linkPath), readlinkSync(linkPath));
1794
+ const base = resolve(dir);
1795
+ const rel = relative(base, target);
1796
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1756
1801
 
1757
1802
  // src/lib/installers/common.ts
1758
1803
  function safeSlugName(slug) {
@@ -1828,7 +1873,7 @@ var cursorAdapter = {
1828
1873
  };
1829
1874
 
1830
1875
  // src/lib/installers/claude.ts
1831
- import { existsSync as existsSync5, rmSync as rmSync2 } from "fs";
1876
+ import { existsSync as existsSync5, rmSync as rmSync2, statSync, readdirSync } from "fs";
1832
1877
  import { join as join4 } from "path";
1833
1878
  import { homedir as homedir3 } from "os";
1834
1879
  var descriptor2 = {
@@ -1847,14 +1892,20 @@ function detect2(projectDir) {
1847
1892
  project: existsSync5(join4(cwd, ".claude"))
1848
1893
  };
1849
1894
  }
1895
+ function claudeBase(scope, projectDir) {
1896
+ return scope === "global" ? join4(homedir3(), ".claude") : join4(projectDir || process.cwd(), ".claude");
1897
+ }
1850
1898
  function resolvePath2(slug, scope, projectDir, contentType) {
1851
1899
  const safeName = safeSlugName(slug);
1852
- const base = scope === "global" ? join4(homedir3(), ".claude") : join4(projectDir || process.cwd(), ".claude");
1900
+ const base = claudeBase(scope, projectDir);
1853
1901
  if (contentType === "rule") {
1854
1902
  return join4(base, "rules", `${safeName}.md`);
1855
1903
  }
1856
1904
  return join4(base, "skills", safeName, "SKILL.md");
1857
1905
  }
1906
+ function resolvePackageDir(slug, scope, projectDir, _contentType) {
1907
+ return join4(claudeBase(scope, projectDir), "skills", safeSlugName(slug));
1908
+ }
1858
1909
  function transformContent2(content, skill) {
1859
1910
  if (skill.type === "rule") {
1860
1911
  return toPlainMD(content);
@@ -1866,11 +1917,19 @@ function install2(opts) {
1866
1917
  return installFileOrSymlink(opts, targetPath);
1867
1918
  }
1868
1919
  function uninstall2(installation, _slug) {
1869
- uninstallFile(installation);
1870
- const parentDir = join4(installation.path, "..");
1920
+ const target = installation.path;
1921
+ if (isSymlink(target)) {
1922
+ removeSymlink(target);
1923
+ } else if (existsSync5(target)) {
1924
+ if (statSync(target).isDirectory()) {
1925
+ rmSync2(target, { recursive: true, force: true });
1926
+ } else {
1927
+ rmSync2(target, { force: true });
1928
+ }
1929
+ }
1930
+ const parentDir = join4(target, "..");
1871
1931
  try {
1872
- const { readdirSync: readdirSync3 } = __require("fs");
1873
- if (existsSync5(parentDir) && readdirSync3(parentDir).length === 0) {
1932
+ if (existsSync5(parentDir) && readdirSync(parentDir).length === 0) {
1874
1933
  rmSync2(parentDir, { recursive: true });
1875
1934
  }
1876
1935
  } catch {
@@ -1883,6 +1942,7 @@ var claudeAdapter = {
1883
1942
  descriptor: descriptor2,
1884
1943
  detect: detect2,
1885
1944
  resolvePath: resolvePath2,
1945
+ resolvePackageDir,
1886
1946
  transformContent: transformContent2,
1887
1947
  install: install2,
1888
1948
  uninstall: uninstall2,
@@ -2334,8 +2394,32 @@ function getAllAdapters() {
2334
2394
  return [...adapters.values()];
2335
2395
  }
2336
2396
 
2397
+ // src/lib/extract.ts
2398
+ import { unzipSync } from "fflate";
2399
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2400
+ import { join as join12, dirname as dirname3, resolve as resolve2 } from "path";
2401
+ function extractPackage(zipBuffer, targetDir) {
2402
+ const resolvedTarget = resolve2(targetDir);
2403
+ const extracted = unzipSync(new Uint8Array(zipBuffer));
2404
+ const writtenFiles = [];
2405
+ for (const [path, data] of Object.entries(extracted)) {
2406
+ if (path.endsWith("/")) continue;
2407
+ if (path.includes("..") || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) {
2408
+ continue;
2409
+ }
2410
+ const fullPath = resolve2(join12(targetDir, path));
2411
+ if (!fullPath.startsWith(resolvedTarget + "/") && fullPath !== resolvedTarget) {
2412
+ continue;
2413
+ }
2414
+ mkdirSync5(dirname3(fullPath), { recursive: true });
2415
+ writeFileSync5(fullPath, Buffer.from(data));
2416
+ writtenFiles.push(path);
2417
+ }
2418
+ return writtenFiles;
2419
+ }
2420
+
2337
2421
  // src/lib/cache.ts
2338
- var CACHE_DIR = join12(homedir8(), ".localskills", "cache");
2422
+ var CACHE_DIR = join13(homedir8(), ".localskills", "cache");
2339
2423
  function slugToDir(slug) {
2340
2424
  if (slug.includes("..") || slug.includes("\0")) {
2341
2425
  throw new Error("Invalid slug: contains forbidden characters");
@@ -2343,16 +2427,16 @@ function slugToDir(slug) {
2343
2427
  return slug.replace(/\//g, "--");
2344
2428
  }
2345
2429
  function getCacheDir(slug) {
2346
- const dir = resolve2(join12(CACHE_DIR, slugToDir(slug)));
2347
- if (!dir.startsWith(resolve2(CACHE_DIR) + "/") && dir !== resolve2(CACHE_DIR)) {
2430
+ const dir = resolve3(join13(CACHE_DIR, slugToDir(slug)));
2431
+ if (!dir.startsWith(resolve3(CACHE_DIR) + "/") && dir !== resolve3(CACHE_DIR)) {
2348
2432
  throw new Error("Invalid slug: path traversal detected");
2349
2433
  }
2350
2434
  return dir;
2351
2435
  }
2352
2436
  function store(slug, content, skill, version2) {
2353
2437
  const dir = getCacheDir(slug);
2354
- mkdirSync5(dir, { recursive: true });
2355
- writeFileSync5(join12(dir, "raw.md"), content);
2438
+ mkdirSync6(dir, { recursive: true });
2439
+ writeFileSync6(join13(dir, "raw.md"), content);
2356
2440
  const meta = {
2357
2441
  hash: skill.contentHash,
2358
2442
  version: version2,
@@ -2362,7 +2446,7 @@ function store(slug, content, skill, version2) {
2362
2446
  type: skill.type ?? "skill",
2363
2447
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2364
2448
  };
2365
- writeFileSync5(join12(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
2449
+ writeFileSync6(join13(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
2366
2450
  clearPlatformFiles(slug);
2367
2451
  }
2368
2452
  function getPlatformFile(slug, platform, skill) {
@@ -2373,25 +2457,25 @@ function getPlatformFile(slug, platform, skill) {
2373
2457
  const transformed = adapter.transformContent(raw, skill);
2374
2458
  if (platform === "claude") {
2375
2459
  if (skill.type === "rule") {
2376
- const claudeRuleDir = join12(dir, "claude-rule");
2377
- mkdirSync5(claudeRuleDir, { recursive: true });
2378
- const filePath3 = join12(claudeRuleDir, `${slug.replace(/\//g, "-")}.md`);
2379
- writeFileSync5(filePath3, transformed);
2460
+ const claudeRuleDir = join13(dir, "claude-rule");
2461
+ mkdirSync6(claudeRuleDir, { recursive: true });
2462
+ const filePath3 = join13(claudeRuleDir, `${slug.replace(/\//g, "-")}.md`);
2463
+ writeFileSync6(filePath3, transformed);
2380
2464
  return filePath3;
2381
2465
  }
2382
- const claudeDir = join12(dir, "claude");
2383
- mkdirSync5(claudeDir, { recursive: true });
2384
- const filePath2 = join12(claudeDir, "SKILL.md");
2385
- writeFileSync5(filePath2, transformed);
2466
+ const claudeDir = join13(dir, "claude");
2467
+ mkdirSync6(claudeDir, { recursive: true });
2468
+ const filePath2 = join13(claudeDir, "SKILL.md");
2469
+ writeFileSync6(filePath2, transformed);
2386
2470
  return filePath2;
2387
2471
  }
2388
2472
  const ext = adapter.descriptor.fileExtension;
2389
- const filePath = join12(dir, `${platform}${ext}`);
2390
- writeFileSync5(filePath, transformed);
2473
+ const filePath = join13(dir, `${platform}${ext}`);
2474
+ writeFileSync6(filePath, transformed);
2391
2475
  return filePath;
2392
2476
  }
2393
2477
  function getRawContent(slug) {
2394
- const filePath = join12(getCacheDir(slug), "raw.md");
2478
+ const filePath = join13(getCacheDir(slug), "raw.md");
2395
2479
  if (!existsSync13(filePath)) return null;
2396
2480
  return readFileSync4(filePath, "utf-8");
2397
2481
  }
@@ -2401,11 +2485,18 @@ function purge(slug) {
2401
2485
  rmSync3(dir, { recursive: true, force: true });
2402
2486
  }
2403
2487
  }
2488
+ function getPackageDir(slug) {
2489
+ return join13(getCacheDir(slug), "pkg");
2490
+ }
2404
2491
  function storePackage(slug, zipBuffer, manifest, skill, version2) {
2405
2492
  const dir = getCacheDir(slug);
2406
- mkdirSync5(dir, { recursive: true });
2407
- writeFileSync5(join12(dir, "package.zip"), zipBuffer);
2408
- writeFileSync5(join12(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
2493
+ mkdirSync6(dir, { recursive: true });
2494
+ const pkgDir = join13(dir, "pkg");
2495
+ rmSync3(pkgDir, { recursive: true, force: true });
2496
+ mkdirSync6(pkgDir, { recursive: true });
2497
+ extractPackage(zipBuffer, pkgDir);
2498
+ rmSync3(join13(dir, "package.zip"), { force: true });
2499
+ writeFileSync6(join13(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
2409
2500
  const meta = {
2410
2501
  hash: skill.contentHash,
2411
2502
  version: version2,
@@ -2416,41 +2507,44 @@ function storePackage(slug, zipBuffer, manifest, skill, version2) {
2416
2507
  format: "package",
2417
2508
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
2418
2509
  };
2419
- writeFileSync5(join12(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
2510
+ writeFileSync6(join13(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
2420
2511
  }
2421
2512
  function clearPlatformFiles(slug) {
2422
2513
  const dir = getCacheDir(slug);
2423
2514
  if (!existsSync13(dir)) return;
2424
- const keep = /* @__PURE__ */ new Set(["raw.md", "meta.json", "package.zip", "manifest.json"]);
2425
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
2515
+ const keep = /* @__PURE__ */ new Set(["raw.md", "meta.json", "manifest.json", "pkg"]);
2516
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
2426
2517
  if (!keep.has(entry.name)) {
2427
- rmSync3(join12(dir, entry.name), { recursive: true, force: true });
2518
+ rmSync3(join13(dir, entry.name), { recursive: true, force: true });
2428
2519
  }
2429
2520
  }
2430
2521
  }
2431
2522
 
2432
- // src/lib/extract.ts
2433
- import { unzipSync } from "fflate";
2434
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "fs";
2435
- import { join as join13, dirname as dirname3, resolve as resolve3 } from "path";
2436
- function extractPackage(zipBuffer, targetDir) {
2437
- const resolvedTarget = resolve3(targetDir);
2438
- const extracted = unzipSync(new Uint8Array(zipBuffer));
2439
- const writtenFiles = [];
2440
- for (const [path, data] of Object.entries(extracted)) {
2441
- if (path.endsWith("/")) continue;
2442
- if (path.includes("..") || path.startsWith("/") || path.startsWith("\\") || path.includes("\0")) {
2443
- continue;
2444
- }
2445
- const fullPath = resolve3(join13(targetDir, path));
2446
- if (!fullPath.startsWith(resolvedTarget + "/") && fullPath !== resolvedTarget) {
2447
- continue;
2448
- }
2449
- mkdirSync6(dirname3(fullPath), { recursive: true });
2450
- writeFileSync6(fullPath, Buffer.from(data));
2451
- writtenFiles.push(path);
2523
+ // src/lib/package-target.ts
2524
+ import { lstatSync as lstatSync2, readdirSync as readdirSync3 } from "fs";
2525
+ import { basename, dirname as dirname4, join as join14 } from "path";
2526
+ import { homedir as homedir9 } from "os";
2527
+ function packageTargetConflict(targetDir, ownedByConfig) {
2528
+ try {
2529
+ lstatSync2(targetDir);
2530
+ } catch {
2531
+ return null;
2452
2532
  }
2453
- return writtenFiles;
2533
+ if (ownedByConfig) return null;
2534
+ if (isSymlinkInto(targetDir, join14(homedir9(), ".localskills", "cache"))) {
2535
+ return null;
2536
+ }
2537
+ return `${targetDir} already exists and isn't managed by localskills \u2014 move or remove it first`;
2538
+ }
2539
+ function soleTrackedOccupant(targetDir, installedPath) {
2540
+ if (dirname4(installedPath) !== targetDir) return false;
2541
+ let entries;
2542
+ try {
2543
+ entries = readdirSync3(targetDir);
2544
+ } catch {
2545
+ return false;
2546
+ }
2547
+ return entries.length === 1 && entries[0] === basename(installedPath);
2454
2548
  }
2455
2549
 
2456
2550
  // src/lib/interactive.ts
@@ -2570,6 +2664,32 @@ function buildSkillRecord(cacheKey, skill, version2, resolvedSemver, requestedRa
2570
2664
  installations: existingInstallations ? [...existingInstallations, ...newInstallations] : newInstallations
2571
2665
  };
2572
2666
  }
2667
+ function reconcilePlatformInstalls(existing, slug, platformId, scope, projectDir, newPath, newFormat) {
2668
+ const remaining = [];
2669
+ const stale = [];
2670
+ let ownedTarget = false;
2671
+ for (const inst of existing ?? []) {
2672
+ const sameTarget = inst.platform === platformId && inst.scope === scope && (inst.projectDir ?? null) === (projectDir ?? null);
2673
+ if (!sameTarget) {
2674
+ remaining.push(inst);
2675
+ continue;
2676
+ }
2677
+ if (inst.path === newPath && (inst.format ?? "text") === newFormat) {
2678
+ ownedTarget = true;
2679
+ continue;
2680
+ }
2681
+ stale.push(inst);
2682
+ }
2683
+ return { remaining, stale, ownedTarget };
2684
+ }
2685
+ function removeStaleInstalls(stale, slug) {
2686
+ for (const inst of stale) {
2687
+ try {
2688
+ getAdapter(inst.platform).uninstall(inst, slug);
2689
+ } catch {
2690
+ }
2691
+ }
2692
+ }
2573
2693
  var installCommand = new Command2("install").description("Install a skill locally").argument("[slug]", "Skill slug (omit for interactive search)").option("-t, --target <targets...>", "Target platforms (cursor, claude, codex, ...)").option("-g, --global", "Install globally").option("-p, --project [dir]", "Install in project directory").option("--symlink", "Use symlink (default)").option("--copy", "Copy instead of symlink").action(
2574
2694
  async (slugArg, opts) => {
2575
2695
  const client = new ApiClient();
@@ -2652,11 +2772,20 @@ var installCommand = new Command2("install").description("Install a skill locall
2652
2772
  spinner.stop(`Fetched ${skill2.name} ${formatVersionLabel(resolvedSemver2, version3)} (package, ${manifest.files.length} files)`);
2653
2773
  const dlSpinner = bt2();
2654
2774
  dlSpinner.start("Downloading package...");
2655
- const zipBuffer = await client.fetchBinary(downloadUrl);
2775
+ let zipBuffer;
2776
+ try {
2777
+ zipBuffer = await client.fetchBinary(downloadUrl);
2778
+ } catch (err) {
2779
+ dlSpinner.stop(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
2780
+ process.exit(1);
2781
+ return;
2782
+ }
2656
2783
  dlSpinner.stop(`Downloaded ${(zipBuffer.length / 1024).toFixed(1)} KB`);
2657
2784
  storePackage(cacheKey, zipBuffer, manifest, skill2, version3);
2785
+ const pkgDir = getPackageDir(cacheKey);
2658
2786
  const installations2 = [];
2659
2787
  const results2 = [];
2788
+ let existingInstallations2 = config.installed_skills[cacheKey]?.installations;
2660
2789
  for (const platformId of platforms) {
2661
2790
  const adapter = getAdapter(platformId);
2662
2791
  const desc = adapter.descriptor;
@@ -2668,19 +2797,55 @@ var installCommand = new Command2("install").description("Install a skill locall
2668
2797
  R2.warn(`${desc.name} does not support project \u2014 skipping.`);
2669
2798
  continue;
2670
2799
  }
2671
- const targetPath = adapter.resolvePath(cacheKey, scope, projectDir, skill2.type ?? "skill");
2672
- mkdirSync7(targetPath, { recursive: true });
2673
- const written = extractPackage(zipBuffer, targetPath);
2800
+ if (!adapter.resolvePackageDir) {
2801
+ R2.warn(`${desc.name} does not support multi-file (folder) skills \u2014 skipping.`);
2802
+ continue;
2803
+ }
2804
+ const targetDir = adapter.resolvePackageDir(cacheKey, scope, projectDir, skill2.type ?? "skill");
2805
+ const reconciled = reconcilePlatformInstalls(
2806
+ existingInstallations2,
2807
+ cacheKey,
2808
+ platformId,
2809
+ scope,
2810
+ projectDir,
2811
+ targetDir,
2812
+ "package"
2813
+ );
2814
+ const owned = reconciled.ownedTarget || reconciled.stale.some((inst) => soleTrackedOccupant(targetDir, inst.path));
2815
+ const conflict = packageTargetConflict(targetDir, owned);
2816
+ if (conflict) {
2817
+ R2.warn(`${desc.name} \u2014 ${conflict}. Skipping.`);
2818
+ existingInstallations2 = [...reconciled.remaining, ...reconciled.stale];
2819
+ continue;
2820
+ }
2821
+ removeStaleInstalls(reconciled.stale, cacheKey);
2822
+ existingInstallations2 = reconciled.remaining;
2823
+ const actualMethod = method === "copy" ? "copy" : "symlink";
2824
+ if (actualMethod === "symlink") {
2825
+ createSymlink(pkgDir, targetDir);
2826
+ } else {
2827
+ rmSync4(targetDir, { recursive: true, force: true });
2828
+ mkdirSync7(dirname5(targetDir), { recursive: true });
2829
+ cpSync(pkgDir, targetDir, { recursive: true });
2830
+ }
2674
2831
  const installation = {
2675
2832
  platform: platformId,
2676
2833
  scope,
2677
- method: "copy",
2678
- path: targetPath,
2834
+ method: actualMethod,
2835
+ path: targetDir,
2679
2836
  projectDir,
2680
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
2837
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2838
+ format: "package"
2681
2839
  };
2682
2840
  installations2.push(installation);
2683
- results2.push(`${desc.name} \u2192 ${targetPath} (${written.length} files extracted)`);
2841
+ const methodLabel = actualMethod === "symlink" ? "symlinked" : "copied";
2842
+ results2.push(`${desc.name} \u2192 ${targetDir} (${methodLabel}, ${manifest.files.length} files)`);
2843
+ }
2844
+ if (installations2.length === 0) {
2845
+ R2.error("No compatible targets for this folder skill.");
2846
+ R2.info("Folder (package) skills currently install to Claude Code only.");
2847
+ process.exit(1);
2848
+ return;
2684
2849
  }
2685
2850
  config.installed_skills[cacheKey] = buildSkillRecord(
2686
2851
  cacheKey,
@@ -2688,7 +2853,7 @@ var installCommand = new Command2("install").description("Install a skill locall
2688
2853
  version3,
2689
2854
  resolvedSemver2,
2690
2855
  requestedRange,
2691
- config.installed_skills[cacheKey]?.installations,
2856
+ existingInstallations2,
2692
2857
  installations2
2693
2858
  );
2694
2859
  saveConfig(config);
@@ -2704,6 +2869,7 @@ var installCommand = new Command2("install").description("Install a skill locall
2704
2869
  const contentType = skill.type ?? "skill";
2705
2870
  const installations = [];
2706
2871
  const results = [];
2872
+ let existingInstallations = config.installed_skills[cacheKey]?.installations;
2707
2873
  for (const platformId of platforms) {
2708
2874
  const adapter = getAdapter(platformId);
2709
2875
  const desc = adapter.descriptor;
@@ -2716,6 +2882,18 @@ var installCommand = new Command2("install").description("Install a skill locall
2716
2882
  continue;
2717
2883
  }
2718
2884
  const actualMethod = adapter.defaultMethod(scope) === "section" ? "section" : method;
2885
+ const newPath = adapter.resolvePath(cacheKey, scope, projectDir, contentType);
2886
+ const reconciledText = reconcilePlatformInstalls(
2887
+ existingInstallations,
2888
+ cacheKey,
2889
+ platformId,
2890
+ scope,
2891
+ projectDir,
2892
+ newPath,
2893
+ "text"
2894
+ );
2895
+ removeStaleInstalls(reconciledText.stale, cacheKey);
2896
+ existingInstallations = reconciledText.remaining;
2719
2897
  const cachePath = getPlatformFile(cacheKey, platformId, skill);
2720
2898
  const transformed = adapter.transformContent(content, skill);
2721
2899
  const installedPath = adapter.install({
@@ -2733,7 +2911,8 @@ var installCommand = new Command2("install").description("Install a skill locall
2733
2911
  method: actualMethod,
2734
2912
  path: installedPath,
2735
2913
  projectDir,
2736
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
2914
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2915
+ format: "text"
2737
2916
  };
2738
2917
  installations.push(installation);
2739
2918
  const methodLabel = actualMethod === "symlink" ? "symlinked" : actualMethod === "section" ? "section" : "copied";
@@ -2745,7 +2924,7 @@ var installCommand = new Command2("install").description("Install a skill locall
2745
2924
  version2,
2746
2925
  resolvedSemver,
2747
2926
  requestedRange,
2748
- config.installed_skills[cacheKey]?.installations,
2927
+ existingInstallations,
2749
2928
  installations
2750
2929
  );
2751
2930
  saveConfig(config);
@@ -2806,6 +2985,28 @@ var uninstallCommand = new Command3("uninstall").description("Uninstall a skill"
2806
2985
 
2807
2986
  // src/commands/list.ts
2808
2987
  import { Command as Command4 } from "commander";
2988
+ function toTagArray(tags) {
2989
+ if (Array.isArray(tags)) return tags.filter((t) => typeof t === "string");
2990
+ if (typeof tags === "string" && tags.trim()) {
2991
+ try {
2992
+ const parsed = JSON.parse(tags);
2993
+ if (Array.isArray(parsed)) return parsed.filter((t) => typeof t === "string");
2994
+ return [tags];
2995
+ } catch {
2996
+ return [tags];
2997
+ }
2998
+ }
2999
+ return [];
3000
+ }
3001
+ function formatTags(tags) {
3002
+ const list = toTagArray(tags);
3003
+ return list.length > 0 ? ` [${list.join(", ")}]` : "";
3004
+ }
3005
+ function oneLine(text, max = 100) {
3006
+ if (!text) return "";
3007
+ const flat = text.replace(/\s+/g, " ").trim();
3008
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
3009
+ }
2809
3010
  var listCommand = new Command4("list").description("List available skills").option("--public", "Show public skills only").option("--tag <tag>", "Filter by tag (requires --public)").option("--search <query>", "Search skills (requires --public)").action(async (opts) => {
2810
3011
  const client = new ApiClient();
2811
3012
  if ((opts.tag || opts.search) && !opts.public) {
@@ -2831,8 +3032,8 @@ var listCommand = new Command4("list").description("List available skills").opti
2831
3032
  console.log("Public skills:\n");
2832
3033
  for (const skill of res.data) {
2833
3034
  const ver = formatVersionLabel(skill.currentSemver, skill.currentVersion);
2834
- const tags = skill.tags.length > 0 ? ` [${skill.tags.join(", ")}]` : "";
2835
- console.log(` ${skill.slug} ${ver}${tags} \u2014 ${skill.description || skill.name}`);
3035
+ const tags = formatTags(skill.tags);
3036
+ console.log(` ${skill.slug} ${ver}${tags} \u2014 ${oneLine(skill.description) || skill.name}`);
2836
3037
  }
2837
3038
  console.log(`
2838
3039
  ${res.data.length} skill(s) found.`);
@@ -2852,8 +3053,8 @@ ${res.data.length} skill(s) found.`);
2852
3053
  for (const skill of res.data) {
2853
3054
  const vis = skill.visibility === "public" ? "" : ` [${skill.visibility}]`;
2854
3055
  const ver = formatVersionLabel(skill.currentSemver, skill.currentVersion);
2855
- const tags = skill.tags?.length > 0 ? ` [${skill.tags.join(", ")}]` : "";
2856
- console.log(` ${skill.slug} ${ver}${vis}${tags} \u2014 ${skill.description || skill.name}`);
3056
+ const tags = formatTags(skill.tags);
3057
+ console.log(` ${skill.slug} ${ver}${vis}${tags} \u2014 ${oneLine(skill.description) || skill.name}`);
2857
3058
  }
2858
3059
  console.log(`
2859
3060
  ${res.data.length} skill(s) found.`);
@@ -2861,8 +3062,21 @@ ${res.data.length} skill(s) found.`);
2861
3062
  });
2862
3063
 
2863
3064
  // src/commands/pull.ts
2864
- import { mkdirSync as mkdirSync8 } from "fs";
3065
+ import { mkdirSync as mkdirSync8, rmSync as rmSync5, cpSync as cpSync2, statSync as statSync2 } from "fs";
3066
+ import { dirname as dirname6 } from "path";
2865
3067
  import { Command as Command5 } from "commander";
3068
+ function errMsg(err) {
3069
+ return err instanceof Error ? err.message : String(err);
3070
+ }
3071
+ function installedAsPackage(inst) {
3072
+ if (inst.format === "package") return true;
3073
+ if (inst.format === "text") return false;
3074
+ try {
3075
+ return statSync2(inst.path).isDirectory();
3076
+ } catch {
3077
+ return false;
3078
+ }
3079
+ }
2866
3080
  var pullCommand = new Command5("pull").description("Pull latest versions of all installed skills").argument("[slug]", "Pull a specific skill (omit for all)").action(async (slugArg) => {
2867
3081
  const config = loadConfig();
2868
3082
  const slugs = slugArg ? [slugArg] : Object.keys(config.installed_skills);
@@ -2898,53 +3112,129 @@ var pullCommand = new Command5("pull").description("Pull latest versions of all
2898
3112
  skipped++;
2899
3113
  continue;
2900
3114
  }
3115
+ let allHandled = true;
2901
3116
  if (format === "package") {
2902
3117
  const { downloadUrl, manifest } = resData;
2903
- const zipBuffer = await client.fetchBinary(downloadUrl);
3118
+ let zipBuffer;
3119
+ try {
3120
+ zipBuffer = await client.fetchBinary(downloadUrl);
3121
+ } catch (err) {
3122
+ spinner.stop(`${slug} \u2014 download failed: ${errMsg(err)}`);
3123
+ continue;
3124
+ }
2904
3125
  storePackage(slug, zipBuffer, manifest, skill, version2);
3126
+ const pkgDir = getPackageDir(slug);
3127
+ const kept = [];
2905
3128
  for (const installation of installed.installations) {
2906
3129
  const adapter = getAdapter(installation.platform);
2907
- const targetPath = adapter.resolvePath(slug, installation.scope, installation.projectDir, skill.type ?? "skill");
2908
- mkdirSync8(targetPath, { recursive: true });
2909
- extractPackage(zipBuffer, targetPath);
3130
+ const wasPackage = installedAsPackage(installation);
3131
+ if (!adapter.resolvePackageDir) {
3132
+ R2.warn(
3133
+ `${adapter.descriptor.name} can't hold a folder skill \u2014 keeping the existing install; it won't receive this update.`
3134
+ );
3135
+ kept.push(installation);
3136
+ allHandled = false;
3137
+ continue;
3138
+ }
3139
+ try {
3140
+ const targetDir = adapter.resolvePackageDir(slug, installation.scope, installation.projectDir, skill.type ?? "skill");
3141
+ const owned = wasPackage && installation.path === targetDir || !wasPackage && soleTrackedOccupant(targetDir, installation.path);
3142
+ const conflict = packageTargetConflict(targetDir, owned);
3143
+ if (conflict) {
3144
+ R2.warn(
3145
+ `${adapter.descriptor.name} \u2014 ${conflict}. Skipping; reinstall with \`localskills install ${slug}\`.`
3146
+ );
3147
+ kept.push(installation);
3148
+ allHandled = false;
3149
+ continue;
3150
+ }
3151
+ if (!wasPackage) {
3152
+ adapter.uninstall(installation, slug);
3153
+ }
3154
+ const useMethod = installation.method === "copy" ? "copy" : "symlink";
3155
+ if (useMethod === "symlink") {
3156
+ createSymlink(pkgDir, targetDir);
3157
+ } else {
3158
+ rmSync5(targetDir, { recursive: true, force: true });
3159
+ mkdirSync8(dirname6(targetDir), { recursive: true });
3160
+ cpSync2(pkgDir, targetDir, { recursive: true });
3161
+ }
3162
+ kept.push({ ...installation, method: useMethod, path: targetDir, format: "package" });
3163
+ } catch (err) {
3164
+ R2.warn(`${adapter.descriptor.name} \u2014 failed to update: ${errMsg(err)}`);
3165
+ kept.push(installation);
3166
+ allHandled = false;
3167
+ }
2910
3168
  }
3169
+ installed.installations = kept;
2911
3170
  } else {
2912
3171
  const { content } = resData;
2913
3172
  store(slug, content, skill, version2);
2914
3173
  for (const installation of installed.installations) {
2915
- if (installation.method === "symlink") {
2916
- getPlatformFile(slug, installation.platform, skill);
2917
- continue;
2918
- }
2919
3174
  const adapter = getAdapter(installation.platform);
2920
- const transformed = adapter.transformContent(content, skill);
2921
- if (installation.method === "section") {
2922
- upsertSection(
2923
- installation.path,
2924
- slug,
2925
- `## ${slug}
3175
+ try {
3176
+ const transformed = adapter.transformContent(content, skill);
3177
+ if (installedAsPackage(installation)) {
3178
+ adapter.uninstall(installation, slug);
3179
+ const method = installation.method === "section" ? "copy" : installation.method;
3180
+ const cachePath = getPlatformFile(slug, installation.platform, skill);
3181
+ const installedPath = adapter.install({
3182
+ slug,
3183
+ content: transformed,
3184
+ scope: installation.scope,
3185
+ method,
3186
+ cachePath,
3187
+ projectDir: installation.projectDir,
3188
+ contentType: skill.type
3189
+ });
3190
+ installation.path = installedPath;
3191
+ installation.method = method;
3192
+ installation.format = "text";
3193
+ continue;
3194
+ }
3195
+ if (installation.method === "symlink") {
3196
+ getPlatformFile(slug, installation.platform, skill);
3197
+ installation.format = "text";
3198
+ continue;
3199
+ }
3200
+ if (installation.method === "section") {
3201
+ upsertSection(
3202
+ installation.path,
3203
+ slug,
3204
+ `## ${slug}
2926
3205
 
2927
3206
  ${transformed}`
2928
- );
2929
- } else {
2930
- const cachePath = getPlatformFile(slug, installation.platform, skill);
2931
- adapter.install({
2932
- slug,
2933
- content: transformed,
2934
- scope: installation.scope,
2935
- method: "copy",
2936
- cachePath,
2937
- projectDir: installation.projectDir
2938
- });
3207
+ );
3208
+ } else {
3209
+ const cachePath = getPlatformFile(slug, installation.platform, skill);
3210
+ adapter.install({
3211
+ slug,
3212
+ content: transformed,
3213
+ scope: installation.scope,
3214
+ method: "copy",
3215
+ cachePath,
3216
+ projectDir: installation.projectDir,
3217
+ contentType: skill.type
3218
+ });
3219
+ }
3220
+ installation.format = "text";
3221
+ } catch (err) {
3222
+ R2.warn(`${adapter.descriptor.name} \u2014 failed to update: ${errMsg(err)}`);
3223
+ allHandled = false;
2939
3224
  }
2940
3225
  }
2941
3226
  }
2942
- installed.hash = skill.contentHash;
2943
- installed.version = version2;
2944
- installed.semver = resData.semver ?? null;
2945
- installed.cachedAt = (/* @__PURE__ */ new Date()).toISOString();
2946
- updated++;
2947
- spinner.stop(`${slug} \u2014 updated to ${formatVersionLabel(res.data.semver, version2)}`);
3227
+ if (allHandled) {
3228
+ installed.hash = skill.contentHash;
3229
+ installed.version = version2;
3230
+ installed.semver = resData.semver ?? null;
3231
+ installed.cachedAt = (/* @__PURE__ */ new Date()).toISOString();
3232
+ updated++;
3233
+ spinner.stop(`${slug} \u2014 updated to ${formatVersionLabel(res.data.semver, version2)}`);
3234
+ } else {
3235
+ updated++;
3236
+ spinner.stop(`${slug} \u2014 partially updated to ${formatVersionLabel(res.data.semver, version2)} (see warnings above)`);
3237
+ }
2948
3238
  }
2949
3239
  saveConfig(config);
2950
3240
  Le(`Pull complete. ${updated} updated, ${skipped} up to date.`);
@@ -2952,44 +3242,43 @@ ${transformed}`
2952
3242
 
2953
3243
  // src/commands/publish.ts
2954
3244
  import { Command as Command6 } from "commander";
2955
- import { readFileSync as readFileSync6, existsSync as existsSync15 } from "fs";
2956
- import { resolve as resolve4, basename as basename2, extname as extname2 } from "path";
2957
- import { homedir as homedir10 } from "os";
3245
+ import { readFileSync as readFileSync7, existsSync as existsSync15, statSync as statSync4 } from "fs";
3246
+ import { resolve as resolve4, basename as basename3, extname as extname2 } from "path";
3247
+ import { homedir as homedir11 } from "os";
2958
3248
 
2959
3249
  // src/lib/scanner.ts
2960
- import { existsSync as existsSync14, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
2961
- import { join as join14, basename, extname } from "path";
2962
- import { homedir as homedir9 } from "os";
2963
- import { readlinkSync, lstatSync as lstatSync2 } from "fs";
3250
+ import { existsSync as existsSync14, readdirSync as readdirSync4, readFileSync as readFileSync5 } from "fs";
3251
+ import { join as join15, basename as basename2, extname, dirname as dirname7 } from "path";
3252
+ import { homedir as homedir10 } from "os";
2964
3253
  function scanForSkills(projectDir) {
2965
- const home = homedir9();
3254
+ const home = homedir10();
2966
3255
  const cwd = projectDir || process.cwd();
2967
3256
  const results = [];
2968
- scanDirectory(join14(home, ".cursor", "rules"), ".mdc", "cursor", "global", results);
2969
- scanDirectory(join14(cwd, ".cursor", "rules"), ".mdc", "cursor", "project", results);
2970
- scanClaudeSkills(join14(home, ".claude", "skills"), "global", results);
2971
- scanClaudeSkills(join14(cwd, ".claude", "skills"), "project", results);
2972
- scanDirectory(join14(home, ".claude", "rules"), ".md", "claude", "global", results, "rule");
2973
- scanDirectory(join14(cwd, ".claude", "rules"), ".md", "claude", "project", results, "rule");
2974
- scanSingleFile(join14(home, ".codex", "AGENTS.md"), "codex", "global", results);
2975
- scanSingleFile(join14(cwd, "AGENTS.md"), "codex", "project", results);
3257
+ scanDirectory(join15(home, ".cursor", "rules"), ".mdc", "cursor", "global", results);
3258
+ scanDirectory(join15(cwd, ".cursor", "rules"), ".mdc", "cursor", "project", results);
3259
+ scanClaudeSkills(join15(home, ".claude", "skills"), "global", results);
3260
+ scanClaudeSkills(join15(cwd, ".claude", "skills"), "project", results);
3261
+ scanDirectory(join15(home, ".claude", "rules"), ".md", "claude", "global", results, "rule");
3262
+ scanDirectory(join15(cwd, ".claude", "rules"), ".md", "claude", "project", results, "rule");
3263
+ scanSingleFile(join15(home, ".codex", "AGENTS.md"), "codex", "global", results);
3264
+ scanSingleFile(join15(cwd, "AGENTS.md"), "codex", "project", results);
2976
3265
  scanSingleFile(
2977
- join14(home, ".codeium", "windsurf", "memories", "global_rules.md"),
3266
+ join15(home, ".codeium", "windsurf", "memories", "global_rules.md"),
2978
3267
  "windsurf",
2979
3268
  "global",
2980
3269
  results
2981
3270
  );
2982
- scanDirectory(join14(cwd, ".windsurf", "rules"), ".md", "windsurf", "project", results);
2983
- scanDirectory(join14(cwd, ".clinerules"), ".md", "cline", "project", results);
3271
+ scanDirectory(join15(cwd, ".windsurf", "rules"), ".md", "windsurf", "project", results);
3272
+ scanDirectory(join15(cwd, ".clinerules"), ".md", "cline", "project", results);
2984
3273
  scanSingleFile(
2985
- join14(cwd, ".github", "copilot-instructions.md"),
3274
+ join15(cwd, ".github", "copilot-instructions.md"),
2986
3275
  "copilot",
2987
3276
  "project",
2988
3277
  results
2989
3278
  );
2990
- scanDirectory(join14(home, ".config", "opencode", "rules"), ".md", "opencode", "global", results);
2991
- scanDirectory(join14(cwd, ".opencode", "rules"), ".md", "opencode", "project", results);
2992
- scanDirectory(join14(cwd, ".aider", "skills"), ".md", "aider", "project", results);
3279
+ scanDirectory(join15(home, ".config", "opencode", "rules"), ".md", "opencode", "global", results);
3280
+ scanDirectory(join15(cwd, ".opencode", "rules"), ".md", "opencode", "project", results);
3281
+ scanDirectory(join15(cwd, ".aider", "skills"), ".md", "aider", "project", results);
2993
3282
  return results;
2994
3283
  }
2995
3284
  function filterTracked(detected, config) {
@@ -2999,35 +3288,32 @@ function filterTracked(detected, config) {
2999
3288
  trackedPaths.add(inst.path);
3000
3289
  }
3001
3290
  }
3002
- const cacheDir = join14(homedir9(), ".localskills", "cache");
3291
+ const cacheDir = join15(homedir10(), ".localskills", "cache");
3003
3292
  return detected.filter((skill) => {
3004
- if (trackedPaths.has(skill.filePath)) return false;
3005
- try {
3006
- const stat = lstatSync2(skill.filePath);
3007
- if (stat.isSymbolicLink()) {
3008
- const target = readlinkSync(skill.filePath);
3009
- if (target.startsWith(cacheDir)) return false;
3010
- }
3011
- } catch {
3293
+ const candidates = [skill.filePath, dirname7(skill.filePath)];
3294
+ if (skill.dir) candidates.push(skill.dir);
3295
+ for (const path of candidates) {
3296
+ if (trackedPaths.has(path)) return false;
3297
+ if (isSymlinkInto(path, cacheDir)) return false;
3012
3298
  }
3013
3299
  return true;
3014
3300
  });
3015
3301
  }
3016
3302
  function slugFromFilename(filename) {
3017
- return basename(filename, extname(filename));
3303
+ return basename2(filename, extname(filename));
3018
3304
  }
3019
3305
  var nameFromSlug = titleFromSlug;
3020
3306
  function scanDirectory(dir, ext, platform, scope, results, contentType = "skill") {
3021
3307
  if (!existsSync14(dir)) return;
3022
3308
  let entries;
3023
3309
  try {
3024
- entries = readdirSync2(dir);
3310
+ entries = readdirSync4(dir);
3025
3311
  } catch {
3026
3312
  return;
3027
3313
  }
3028
3314
  for (const entry of entries) {
3029
3315
  if (!entry.endsWith(ext)) continue;
3030
- const filePath = join14(dir, entry);
3316
+ const filePath = join15(dir, entry);
3031
3317
  try {
3032
3318
  const raw = readFileSync5(filePath, "utf-8");
3033
3319
  const content = stripFrontmatter(raw).trim();
@@ -3035,6 +3321,7 @@ function scanDirectory(dir, ext, platform, scope, results, contentType = "skill"
3035
3321
  const slug = slugFromFilename(entry);
3036
3322
  results.push({
3037
3323
  filePath,
3324
+ format: "text",
3038
3325
  platform,
3039
3326
  scope,
3040
3327
  suggestedName: nameFromSlug(slug),
@@ -3046,28 +3333,56 @@ function scanDirectory(dir, ext, platform, scope, results, contentType = "skill"
3046
3333
  }
3047
3334
  }
3048
3335
  }
3336
+ var SCAN_JUNK = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", ".git", "node_modules", ".localskills"]);
3337
+ function dirHasFilesBesides(dir, excludeRootFile) {
3338
+ const stack = [dir];
3339
+ while (stack.length > 0) {
3340
+ const current = stack.pop();
3341
+ let entries;
3342
+ try {
3343
+ entries = readdirSync4(current, { withFileTypes: true });
3344
+ } catch {
3345
+ continue;
3346
+ }
3347
+ for (const entry of entries) {
3348
+ if (SCAN_JUNK.has(entry.name)) continue;
3349
+ if (entry.isDirectory()) {
3350
+ stack.push(join15(current, entry.name));
3351
+ continue;
3352
+ }
3353
+ if (current === dir && entry.name === excludeRootFile) continue;
3354
+ return true;
3355
+ }
3356
+ }
3357
+ return false;
3358
+ }
3049
3359
  function scanClaudeSkills(skillsDir, scope, results) {
3050
3360
  if (!existsSync14(skillsDir)) return;
3051
3361
  let entries;
3052
3362
  try {
3053
- entries = readdirSync2(skillsDir);
3363
+ entries = readdirSync4(skillsDir);
3054
3364
  } catch {
3055
3365
  return;
3056
3366
  }
3057
3367
  for (const entry of entries) {
3058
- const skillFile = join14(skillsDir, entry, "SKILL.md");
3368
+ const skillDir = join15(skillsDir, entry);
3369
+ const skillFile = join15(skillDir, "SKILL.md");
3059
3370
  if (!existsSync14(skillFile)) continue;
3060
3371
  try {
3061
3372
  const raw = readFileSync5(skillFile, "utf-8");
3062
3373
  const content = stripFrontmatter(raw).trim();
3063
- if (!content) continue;
3374
+ const isPackage = dirHasFilesBesides(skillDir, "SKILL.md");
3375
+ if (!isPackage && !content) continue;
3064
3376
  results.push({
3065
3377
  filePath: skillFile,
3378
+ ...isPackage ? { dir: skillDir } : {},
3379
+ format: isPackage ? "package" : "text",
3066
3380
  platform: "claude",
3067
3381
  scope,
3068
3382
  suggestedName: nameFromSlug(entry),
3069
3383
  suggestedSlug: entry,
3070
3384
  content,
3385
+ // for packages: SKILL.md body, used only for the hint/preview
3071
3386
  contentType: "skill"
3072
3387
  });
3073
3388
  } catch {
@@ -3094,6 +3409,7 @@ function scanSingleFile(filePath, platform, scope, results) {
3094
3409
  if (!content2) continue;
3095
3410
  results.push({
3096
3411
  filePath,
3412
+ format: "text",
3097
3413
  platform,
3098
3414
  scope,
3099
3415
  suggestedName: nameFromSlug(slug2),
@@ -3109,6 +3425,7 @@ function scanSingleFile(filePath, platform, scope, results) {
3109
3425
  const slug = slugFromFilename(filePath);
3110
3426
  results.push({
3111
3427
  filePath,
3428
+ format: "text",
3112
3429
  platform,
3113
3430
  scope,
3114
3431
  suggestedName: nameFromSlug(slug),
@@ -3118,8 +3435,177 @@ function scanSingleFile(filePath, platform, scope, results) {
3118
3435
  });
3119
3436
  }
3120
3437
 
3438
+ // src/lib/pack.ts
3439
+ import { readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync3 } from "fs";
3440
+ import { join as join16, relative as relative2, sep as sep2 } from "path";
3441
+ import { zipSync } from "fflate";
3442
+ var PackError = class extends Error {
3443
+ constructor(message) {
3444
+ super(message);
3445
+ this.name = "PackError";
3446
+ }
3447
+ };
3448
+ function zipBlob(zip) {
3449
+ return new Blob([new Uint8Array(zip)], { type: "application/zip" });
3450
+ }
3451
+ var SKIP = /* @__PURE__ */ new Set([
3452
+ ".DS_Store",
3453
+ ".git",
3454
+ "node_modules",
3455
+ ".localskills",
3456
+ "Thumbs.db"
3457
+ ]);
3458
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
3459
+ ".png",
3460
+ ".jpg",
3461
+ ".jpeg",
3462
+ ".gif",
3463
+ ".webp",
3464
+ ".ico",
3465
+ ".bmp",
3466
+ ".tiff",
3467
+ ".woff",
3468
+ ".woff2",
3469
+ ".ttf",
3470
+ ".eot",
3471
+ ".otf",
3472
+ ".pdf",
3473
+ ".zip",
3474
+ ".tar",
3475
+ ".gz",
3476
+ ".bz2",
3477
+ ".7z",
3478
+ ".rar",
3479
+ ".exe",
3480
+ ".dll",
3481
+ ".so",
3482
+ ".dylib",
3483
+ ".bin",
3484
+ ".wasm",
3485
+ ".pyc",
3486
+ ".class",
3487
+ ".o",
3488
+ ".obj",
3489
+ ".mp3",
3490
+ ".mp4",
3491
+ ".wav",
3492
+ ".avi",
3493
+ ".mov",
3494
+ ".mkv",
3495
+ ".sqlite",
3496
+ ".db"
3497
+ ]);
3498
+ function getExtension(path) {
3499
+ const dot = path.lastIndexOf(".");
3500
+ return dot === -1 ? "" : path.substring(dot).toLowerCase();
3501
+ }
3502
+ function isBinaryByContent(data) {
3503
+ const sample = data.subarray(0, 8192);
3504
+ for (let i = 0; i < sample.length; i++) if (sample[i] === 0) return true;
3505
+ return false;
3506
+ }
3507
+ function validatePath(path) {
3508
+ if (path.startsWith("/") || path.startsWith("\\")) return false;
3509
+ if (path.includes("..")) return false;
3510
+ if (path.includes("\0")) return false;
3511
+ if (path.includes("\\")) return false;
3512
+ if (path === "__proto__") return false;
3513
+ return true;
3514
+ }
3515
+ function walk(root, current, acc) {
3516
+ for (const entry of readdirSync5(current, { withFileTypes: true })) {
3517
+ if (SKIP.has(entry.name)) continue;
3518
+ const abs = join16(current, entry.name);
3519
+ const rel = relative2(root, abs).split(sep2).join("/");
3520
+ let isFile = entry.isFile();
3521
+ let size;
3522
+ if (entry.isDirectory()) {
3523
+ walk(root, abs, acc);
3524
+ continue;
3525
+ }
3526
+ if (entry.isSymbolicLink()) {
3527
+ let st3;
3528
+ try {
3529
+ st3 = statSync3(abs);
3530
+ } catch {
3531
+ acc.warnings.push(`Skipped broken symlink: ${rel}`);
3532
+ continue;
3533
+ }
3534
+ if (st3.isDirectory()) {
3535
+ acc.warnings.push(`Skipped symlinked directory: ${rel}/`);
3536
+ continue;
3537
+ }
3538
+ isFile = st3.isFile();
3539
+ size = st3.size;
3540
+ }
3541
+ if (!isFile) continue;
3542
+ acc.count++;
3543
+ if (acc.count > PACKAGE_MAX_FILE_COUNT) {
3544
+ throw new PackError(
3545
+ `Folder contains more than ${PACKAGE_MAX_FILE_COUNT} files`
3546
+ );
3547
+ }
3548
+ size ??= statSync3(abs).size;
3549
+ if (acc.bytes + size > PACKAGE_MAX_DECOMPRESSED_BYTES) {
3550
+ throw new PackError(
3551
+ `Folder exceeds ${PACKAGE_MAX_DECOMPRESSED_BYTES / 1024 / 1024} MB uncompressed limit`
3552
+ );
3553
+ }
3554
+ const data = new Uint8Array(readFileSync6(abs));
3555
+ acc.bytes += data.length;
3556
+ if (acc.bytes > PACKAGE_MAX_DECOMPRESSED_BYTES) {
3557
+ throw new PackError(
3558
+ `Folder exceeds ${PACKAGE_MAX_DECOMPRESSED_BYTES / 1024 / 1024} MB uncompressed limit`
3559
+ );
3560
+ }
3561
+ acc.files.set(rel, data);
3562
+ }
3563
+ }
3564
+ function packFolder(dir, opts = {}) {
3565
+ const requireSkillMd = opts.requireSkillMd ?? true;
3566
+ const stat = statSync3(dir);
3567
+ if (!stat.isDirectory()) throw new PackError(`Not a directory: ${dir}`);
3568
+ const acc = { files: /* @__PURE__ */ new Map(), count: 0, bytes: 0, warnings: [] };
3569
+ walk(dir, dir, acc);
3570
+ const entries = acc.files;
3571
+ if (entries.size === 0) throw new PackError("Folder contains no files");
3572
+ if (requireSkillMd && !entries.has("SKILL.md")) {
3573
+ throw new PackError(
3574
+ "No SKILL.md at the folder root. A skill folder must contain a SKILL.md file."
3575
+ );
3576
+ }
3577
+ let totalSize = 0;
3578
+ const files = [];
3579
+ for (const [path, data] of entries) {
3580
+ if (!validatePath(path)) throw new PackError(`Invalid file path: ${path}`);
3581
+ totalSize += data.length;
3582
+ const binary = BINARY_EXTENSIONS.has(getExtension(path)) || isBinaryByContent(data);
3583
+ const file = { path, size: data.length, isBinary: binary };
3584
+ if (!binary && data.length > 0) {
3585
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(data);
3586
+ file.preview = text.length > PACKAGE_PREVIEW_BYTES ? text.substring(0, PACKAGE_PREVIEW_BYTES) : text;
3587
+ }
3588
+ files.push(file);
3589
+ }
3590
+ files.sort((a, b) => a.path.localeCompare(b.path));
3591
+ const zipInput = /* @__PURE__ */ Object.create(null);
3592
+ for (const [path, data] of entries) zipInput[path] = data;
3593
+ const zip = Buffer.from(zipSync(zipInput));
3594
+ if (zip.length > PACKAGE_MAX_COMPRESSED_BYTES) {
3595
+ throw new PackError(
3596
+ `Archive exceeds ${PACKAGE_MAX_COMPRESSED_BYTES / 1024 / 1024} MB compressed limit`
3597
+ );
3598
+ }
3599
+ return {
3600
+ zip,
3601
+ manifest: { version: 1, totalSize, archiveSize: zip.length, files },
3602
+ fileCount: files.length,
3603
+ warnings: acc.warnings
3604
+ };
3605
+ }
3606
+
3121
3607
  // src/commands/publish.ts
3122
- var publishCommand = new Command6("publish").description("Publish local skill files to localskills.sh").argument("[file]", "Path to a specific file to publish").option("-t, --team <id>", "Team ID to publish to").option("-n, --name <name>", "Skill name").option(
3608
+ var publishCommand = new Command6("publish").description("Publish local skills to localskills.sh").argument("[path]", "Path to a skill file, or a skill folder (uploaded as a package)").option("-t, --team <id>", "Team ID to publish to").option("-n, --name <name>", "Skill name").option(
3123
3609
  "--visibility <visibility>",
3124
3610
  "Visibility: public, private, or unlisted",
3125
3611
  "private"
@@ -3139,30 +3625,46 @@ var publishCommand = new Command6("publish").description("Publish local skill fi
3139
3625
  if (fileArg) {
3140
3626
  const filePath = resolve4(fileArg);
3141
3627
  if (!existsSync15(filePath)) {
3142
- console.error(`File not found: ${filePath}`);
3628
+ console.error(`Path not found: ${filePath}`);
3143
3629
  process.exit(1);
3144
3630
  return;
3145
3631
  }
3146
- const raw = readFileSync6(filePath, "utf-8");
3632
+ if (statSync4(filePath).isDirectory()) {
3633
+ const contentType2 = validateContentType(opts.type || "skill");
3634
+ const visibility2 = validateVisibility(opts.visibility || "private");
3635
+ const tenantId2 = await resolveTeam(teams, opts.team);
3636
+ const skillName2 = opts.name || titleFromSlug(basename3(filePath));
3637
+ const ok2 = await uploadPackage(client, {
3638
+ name: skillName2,
3639
+ dir: filePath,
3640
+ tenantId: tenantId2,
3641
+ visibility: visibility2,
3642
+ type: contentType2
3643
+ });
3644
+ if (!ok2) process.exit(1);
3645
+ return;
3646
+ }
3647
+ const raw = readFileSync7(filePath, "utf-8");
3147
3648
  const content = stripFrontmatter(raw).trim();
3148
3649
  if (!content) {
3149
3650
  console.error("File is empty after stripping frontmatter.");
3150
3651
  process.exit(1);
3151
3652
  return;
3152
3653
  }
3153
- const defaultSlug = basename2(filePath, extname2(filePath));
3654
+ const defaultSlug = basename3(filePath, extname2(filePath));
3154
3655
  const defaultName = titleFromSlug(defaultSlug);
3155
3656
  const skillName = opts.name || defaultName;
3156
3657
  const contentType = validateContentType(opts.type || "skill");
3157
3658
  const visibility = validateVisibility(opts.visibility || "private");
3158
3659
  const tenantId = await resolveTeam(teams, opts.team);
3159
- await uploadSkill(client, {
3660
+ const ok = await uploadSkill(client, {
3160
3661
  name: skillName,
3161
3662
  content,
3162
3663
  tenantId,
3163
3664
  visibility,
3164
3665
  type: contentType
3165
3666
  });
3667
+ if (!ok) process.exit(1);
3166
3668
  } else {
3167
3669
  We("localskills publish");
3168
3670
  const spinner = bt2();
@@ -3187,6 +3689,7 @@ var publishCommand = new Command6("publish").description("Publish local skill fi
3187
3689
  required: true
3188
3690
  }));
3189
3691
  const tenantId = await resolveTeam(teams, opts.team);
3692
+ let failures = 0;
3190
3693
  for (const skill of skills) {
3191
3694
  R2.step(`Publishing ${skill.suggestedName}...`);
3192
3695
  const name = cancelGuard(await Ze({
@@ -3214,15 +3717,23 @@ var publishCommand = new Command6("publish").description("Publish local skill fi
3214
3717
  ],
3215
3718
  initialValue: skill.contentType
3216
3719
  }));
3217
- await uploadSkill(client, {
3720
+ const ok = skill.format === "package" && skill.dir ? await uploadPackage(client, {
3721
+ name,
3722
+ dir: skill.dir,
3723
+ tenantId,
3724
+ visibility,
3725
+ type: contentType
3726
+ }) : await uploadSkill(client, {
3218
3727
  name,
3219
3728
  content: skill.content,
3220
3729
  tenantId,
3221
3730
  visibility,
3222
3731
  type: contentType
3223
3732
  });
3733
+ if (!ok) failures++;
3224
3734
  }
3225
- Le("Done!");
3735
+ Le(failures > 0 ? `Done, with ${failures} failure(s).` : "Done!");
3736
+ if (failures > 0) process.exit(1);
3226
3737
  }
3227
3738
  }
3228
3739
  );
@@ -3259,10 +3770,37 @@ async function uploadSkill(client, params) {
3259
3770
  });
3260
3771
  if (!res.success || !res.data) {
3261
3772
  spinner.stop(`Failed: ${res.error || "Unknown error"}`);
3262
- return;
3773
+ return false;
3263
3774
  }
3264
3775
  spinner.stop(`Published!`);
3265
- R2.success(`\u2192 localskills.sh/s/${res.data.slug}`);
3776
+ R2.success(`\u2192 localskills.sh/s/${res.data.publicId}`);
3777
+ return true;
3778
+ }
3779
+ async function uploadPackage(client, params) {
3780
+ const spinner = bt2();
3781
+ let packed;
3782
+ try {
3783
+ packed = packFolder(params.dir, { requireSkillMd: params.type === "skill" });
3784
+ } catch (e2) {
3785
+ R2.error(e2 instanceof PackError ? e2.message : String(e2));
3786
+ return false;
3787
+ }
3788
+ for (const w of packed.warnings) R2.warn(w);
3789
+ spinner.start(`Uploading ${params.name} (${packed.fileCount} files)...`);
3790
+ const form = new FormData();
3791
+ form.append("name", params.name);
3792
+ form.append("tenantId", params.tenantId);
3793
+ form.append("visibility", params.visibility);
3794
+ form.append("type", params.type);
3795
+ form.append("file", zipBlob(packed.zip), "skill.zip");
3796
+ const res = await client.postForm("/api/skills", form);
3797
+ if (!res.success || !res.data) {
3798
+ spinner.stop(`Failed: ${res.error || "Unknown error"}`);
3799
+ return false;
3800
+ }
3801
+ spinner.stop(`Published! (${packed.fileCount} files)`);
3802
+ R2.success(`\u2192 localskills.sh/s/${res.data.publicId}`);
3803
+ return true;
3266
3804
  }
3267
3805
  function validateContentType(value) {
3268
3806
  if (value === "skill" || value === "rule") {
@@ -3279,7 +3817,7 @@ function validateVisibility(value) {
3279
3817
  process.exit(1);
3280
3818
  }
3281
3819
  function shortenPath(filePath) {
3282
- const home = homedir10();
3820
+ const home = homedir11();
3283
3821
  if (filePath.startsWith(home)) {
3284
3822
  return "~" + filePath.slice(home.length);
3285
3823
  }
@@ -3292,41 +3830,78 @@ function shortenPath(filePath) {
3292
3830
 
3293
3831
  // src/commands/push.ts
3294
3832
  import { Command as Command7 } from "commander";
3295
- import { readFileSync as readFileSync7, existsSync as existsSync16 } from "fs";
3833
+ import { readFileSync as readFileSync8, existsSync as existsSync16, statSync as statSync5 } from "fs";
3296
3834
  import { resolve as resolve5 } from "path";
3297
- var pushCommand = new Command7("push").description("Push a new version of an existing skill").argument("<file>", "Path to the skill file").requiredOption("-s, --skill <id>", "Skill ID or slug").option("--version <semver>", "Explicit semver (e.g., 1.1.0)").option("--patch", "Bump patch version").option("--minor", "Bump minor version").option("--major", "Bump major version").option("-m, --message <message>", "Version message").action(
3835
+ var pushCommand = new Command7("push").description("Push a new version of an existing skill").argument("<path>", "Path to the skill file, or a skill folder (uploaded as a package)").requiredOption("-s, --skill <id>", "Skill ID or slug").option("--version <semver>", "Explicit semver (e.g., 1.1.0)").option("--patch", "Bump patch version").option("--minor", "Bump minor version").option("--major", "Bump major version").option("-m, --message <message>", "Version message").action(
3298
3836
  async (fileArg, opts) => {
3299
3837
  const client = new ApiClient();
3300
3838
  requireAuth(client);
3301
3839
  const filePath = resolve5(fileArg);
3302
3840
  if (!existsSync16(filePath)) {
3303
- console.error(`File not found: ${filePath}`);
3841
+ console.error(`Path not found: ${filePath}`);
3304
3842
  process.exit(1);
3305
3843
  return;
3306
3844
  }
3307
- const raw = readFileSync7(filePath, "utf-8");
3308
- const content = stripFrontmatter(raw).trim();
3309
- if (!content) {
3310
- console.error("File is empty after stripping frontmatter.");
3845
+ const bumpFlags = [opts.patch, opts.minor, opts.major].filter(Boolean);
3846
+ if (bumpFlags.length > 1) {
3847
+ console.error("Specify only one of --patch, --minor, or --major");
3311
3848
  process.exit(1);
3312
3849
  return;
3313
3850
  }
3314
- const bumpFlags = [opts.patch, opts.minor, opts.major].filter(Boolean);
3315
3851
  if (opts.version && bumpFlags.length > 0) {
3316
3852
  console.error("Cannot specify both --version and --patch/--minor/--major");
3317
3853
  process.exit(1);
3318
3854
  return;
3319
3855
  }
3856
+ if (opts.version && !isValidSemVer(opts.version)) {
3857
+ console.error(`Invalid semver format: ${opts.version}. Expected X.Y.Z`);
3858
+ process.exit(1);
3859
+ return;
3860
+ }
3861
+ if (statSync5(filePath).isDirectory()) {
3862
+ let packed;
3863
+ try {
3864
+ packed = packFolder(filePath, { requireSkillMd: false });
3865
+ } catch (e2) {
3866
+ console.error(e2 instanceof PackError ? e2.message : String(e2));
3867
+ process.exit(1);
3868
+ return;
3869
+ }
3870
+ for (const w of packed.warnings) R2.warn(w);
3871
+ const form = new FormData();
3872
+ form.append("file", zipBlob(packed.zip), "skill.zip");
3873
+ if (opts.message) form.append("message", opts.message);
3874
+ if (opts.version) form.append("semver", opts.version);
3875
+ else if (opts.major) form.append("bump", "major");
3876
+ else if (opts.minor) form.append("bump", "minor");
3877
+ else if (opts.patch) form.append("bump", "patch");
3878
+ const spinner2 = bt2();
3879
+ spinner2.start(`Pushing new version (${packed.fileCount} files)...`);
3880
+ const res2 = await client.postForm(
3881
+ `/api/skills/${encodeURIComponent(opts.skill)}/versions`,
3882
+ form
3883
+ );
3884
+ if (!res2.success || !res2.data) {
3885
+ spinner2.stop(`Failed: ${res2.error || "Unknown error"}`);
3886
+ process.exit(1);
3887
+ return;
3888
+ }
3889
+ spinner2.stop(`Pushed ${formatVersionLabel(res2.data.semver, res2.data.version)}`);
3890
+ Le("Done!");
3891
+ return;
3892
+ }
3893
+ const raw = readFileSync8(filePath, "utf-8");
3894
+ const content = stripFrontmatter(raw).trim();
3895
+ if (!content) {
3896
+ console.error("File is empty after stripping frontmatter.");
3897
+ process.exit(1);
3898
+ return;
3899
+ }
3320
3900
  let body = {
3321
3901
  content,
3322
3902
  message: opts.message
3323
3903
  };
3324
3904
  if (opts.version) {
3325
- if (!isValidSemVer(opts.version)) {
3326
- console.error(`Invalid semver format: ${opts.version}. Expected X.Y.Z`);
3327
- process.exit(1);
3328
- return;
3329
- }
3330
3905
  body.semver = opts.version;
3331
3906
  } else if (opts.major) {
3332
3907
  body.bump = "major";
@@ -3354,30 +3929,49 @@ var pushCommand = new Command7("push").description("Push a new version of an exi
3354
3929
 
3355
3930
  // src/commands/share.ts
3356
3931
  import { Command as Command8 } from "commander";
3357
- import { readFileSync as readFileSync8, existsSync as existsSync17 } from "fs";
3358
- import { resolve as resolve6, basename as basename3, extname as extname3 } from "path";
3932
+ import { readFileSync as readFileSync9, existsSync as existsSync17, statSync as statSync6 } from "fs";
3933
+ import { resolve as resolve6, basename as basename4, extname as extname3 } from "path";
3359
3934
  import { generateKeyPairSync } from "crypto";
3360
- var shareCommand = new Command8("share").description("Share a skill anonymously (no login required)").argument("[file]", "Path to a specific file to share").option("-n, --name <name>", "Skill name").option("--type <type>", "Content type: skill or rule", "skill").action(async (fileArg, opts) => {
3935
+ var shareCommand = new Command8("share").description("Share a skill anonymously (no login required)").argument("[path]", "Path to a skill file, or a skill folder (uploaded as a package)").option("-n, --name <name>", "Skill name").option("--type <type>", "Content type: skill or rule", "skill").action(async (fileArg, opts) => {
3361
3936
  We("localskills share");
3937
+ if (opts.type && opts.type !== "skill" && opts.type !== "rule") {
3938
+ R2.error(`Invalid type: ${opts.type}. Use skill or rule.`);
3939
+ process.exit(1);
3940
+ }
3362
3941
  await ensureAnonymousIdentity();
3363
3942
  const client = new ApiClient();
3364
3943
  if (fileArg) {
3365
3944
  const filePath = resolve6(fileArg);
3366
3945
  if (!existsSync17(filePath)) {
3367
- R2.error(`File not found: ${filePath}`);
3946
+ R2.error(`Path not found: ${filePath}`);
3368
3947
  process.exit(1);
3369
3948
  }
3370
- const raw = readFileSync8(filePath, "utf-8");
3949
+ if (statSync6(filePath).isDirectory()) {
3950
+ const contentType2 = opts.type === "rule" ? "rule" : "skill";
3951
+ const skillName2 = opts.name || titleFromSlug(basename4(filePath));
3952
+ const ok2 = await uploadAnonymousPackage(client, {
3953
+ name: skillName2,
3954
+ dir: filePath,
3955
+ type: contentType2
3956
+ });
3957
+ Le(ok2 ? "Done!" : "Share failed.");
3958
+ if (!ok2) process.exit(1);
3959
+ return;
3960
+ }
3961
+ const raw = readFileSync9(filePath, "utf-8");
3371
3962
  const content = stripFrontmatter(raw).trim();
3372
3963
  if (!content) {
3373
3964
  R2.error("File is empty after stripping frontmatter.");
3374
3965
  process.exit(1);
3375
3966
  }
3376
- const defaultSlug = basename3(filePath, extname3(filePath));
3967
+ const defaultSlug = basename4(filePath, extname3(filePath));
3377
3968
  const defaultName = titleFromSlug(defaultSlug);
3378
3969
  const skillName = opts.name || defaultName;
3379
3970
  const contentType = opts.type === "rule" ? "rule" : "skill";
3380
- await uploadAnonymousSkill(client, { name: skillName, content, type: contentType });
3971
+ const ok = await uploadAnonymousSkill(client, { name: skillName, content, type: contentType });
3972
+ Le(ok ? "Done!" : "Share failed.");
3973
+ if (!ok) process.exit(1);
3974
+ return;
3381
3975
  } else {
3382
3976
  const spinner = bt2();
3383
3977
  spinner.start("Scanning for skills...");
@@ -3411,13 +4005,18 @@ var shareCommand = new Command8("share").description("Share a skill anonymously
3411
4005
  }
3412
4006
  })
3413
4007
  );
3414
- await uploadAnonymousSkill(client, {
4008
+ const ok = selected.format === "package" && selected.dir ? await uploadAnonymousPackage(client, {
4009
+ name,
4010
+ dir: selected.dir,
4011
+ type: selected.contentType
4012
+ }) : await uploadAnonymousSkill(client, {
3415
4013
  name,
3416
4014
  content: selected.content,
3417
4015
  type: selected.contentType
3418
4016
  });
4017
+ Le(ok ? "Done!" : "Share failed.");
4018
+ if (!ok) process.exit(1);
3419
4019
  }
3420
- Le("Done!");
3421
4020
  });
3422
4021
  async function ensureAnonymousIdentity() {
3423
4022
  const config = loadConfig();
@@ -3425,6 +4024,10 @@ async function ensureAnonymousIdentity() {
3425
4024
  const client = new ApiClient();
3426
4025
  const res2 = await client.get("/api/cli/auth");
3427
4026
  if (res2.success) return;
4027
+ if (res2.networkError) {
4028
+ R2.error(`Could not verify your login: ${res2.error}. Try again.`);
4029
+ process.exit(1);
4030
+ }
3428
4031
  }
3429
4032
  let keyPair = getAnonymousKey();
3430
4033
  if (!keyPair) {
@@ -3475,11 +4078,46 @@ async function uploadAnonymousSkill(client, params) {
3475
4078
  });
3476
4079
  if (!res.success || !res.data) {
3477
4080
  s.stop(`Failed: ${res.error || "Unknown error"}`);
3478
- return;
4081
+ return false;
3479
4082
  }
3480
4083
  s.stop("Shared!");
3481
4084
  R2.success(`URL: https://localskills.sh/s/${res.data.publicId}`);
3482
4085
  R2.info(`Install: localskills install ${res.data.publicId}`);
4086
+ return true;
4087
+ }
4088
+ async function uploadAnonymousPackage(client, params) {
4089
+ const s = bt2();
4090
+ let packed;
4091
+ try {
4092
+ packed = packFolder(params.dir, { requireSkillMd: params.type === "skill" });
4093
+ } catch (e2) {
4094
+ R2.error(e2 instanceof PackError ? e2.message : String(e2));
4095
+ return false;
4096
+ }
4097
+ for (const w of packed.warnings) R2.warn(w);
4098
+ s.start(`Sharing "${params.name}" (${packed.fileCount} files)...`);
4099
+ const teamsRes = await client.get("/api/tenants");
4100
+ if (!teamsRes.success || !teamsRes.data || teamsRes.data.length === 0) {
4101
+ s.stop("Failed to find your team. Try running `localskills share` again.");
4102
+ process.exit(1);
4103
+ return false;
4104
+ }
4105
+ const tenantId = teamsRes.data[0].id;
4106
+ const form = new FormData();
4107
+ form.append("name", params.name);
4108
+ form.append("tenantId", tenantId);
4109
+ form.append("visibility", "unlisted");
4110
+ form.append("type", params.type);
4111
+ form.append("file", zipBlob(packed.zip), "skill.zip");
4112
+ const res = await client.postForm("/api/skills", form);
4113
+ if (!res.success || !res.data) {
4114
+ s.stop(`Failed: ${res.error || "Unknown error"}`);
4115
+ return false;
4116
+ }
4117
+ s.stop("Shared!");
4118
+ R2.success(`URL: https://localskills.sh/s/${res.data.publicId}`);
4119
+ R2.info(`Install: localskills install ${res.data.publicId}`);
4120
+ return true;
3483
4121
  }
3484
4122
 
3485
4123
  // src/commands/profile.ts