ht-skills 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/cli.js +316 -8
  2. package/package.json +1 -1
package/lib/cli.js CHANGED
@@ -157,6 +157,15 @@ async function setStoredRegistryAuth(registry, value, { homeDir = os.homedir() }
157
157
  await saveCliConfig(config, homeDir);
158
158
  }
159
159
 
160
+ async function clearStoredRegistryAuth(registry, { homeDir = os.homedir() } = {}) {
161
+ const config = await loadCliConfig(homeDir);
162
+ if (!config.registries || typeof config.registries !== "object") {
163
+ return;
164
+ }
165
+ delete config.registries[getRegistryConfigKey(registry)];
166
+ await saveCliConfig(config, homeDir);
167
+ }
168
+
160
169
  async function resolveAuthToken(registry, flags, { homeDir = os.homedir() } = {}) {
161
170
  const inlineToken = String(flags.token || "").trim();
162
171
  if (inlineToken) {
@@ -174,6 +183,79 @@ async function getRequiredAuthToken(registry, flags, { homeDir = os.homedir() }
174
183
  return token;
175
184
  }
176
185
 
186
+ function isInteractiveSession(deps = {}) {
187
+ return typeof deps.isInteractive === "boolean"
188
+ ? deps.isInteractive
189
+ : Boolean(process.stdin.isTTY && process.stdout.isTTY);
190
+ }
191
+
192
+ async function validateAuthToken(registry, token, deps = {}) {
193
+ const requestJsonImpl = deps.requestJson || requestJson;
194
+ try {
195
+ const payload = await requestJsonImpl(`${registry}/auth/me`, {
196
+ headers: withBearerToken({}, token),
197
+ });
198
+ if (!payload || payload.authenticated !== true) {
199
+ return null;
200
+ }
201
+ return payload;
202
+ } catch (error) {
203
+ if (Number(error?.status || 0) === 401) {
204
+ return null;
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ async function ensureValidAuthToken(registry, flags, deps = {}) {
211
+ const log = deps.log || ((message) => console.log(message));
212
+ const homeDir = deps.homeDir || os.homedir();
213
+ const loginImpl = deps.cmdLogin || cmdLogin;
214
+ const interactive = isInteractiveSession(deps);
215
+ const explicitToken = String(flags.token || "").trim();
216
+
217
+ let token = await resolveAuthToken(registry, flags, { homeDir });
218
+ if (!token) {
219
+ if (!interactive) {
220
+ throw new Error(`No saved login for ${registry}. Run "ht-skills login --registry ${registry}" first.`);
221
+ }
222
+ log(`Login required for ${registry}. Starting browser sign-in...`);
223
+ await loginImpl({ ...flags, registry }, deps);
224
+ token = await getRequiredAuthToken(registry, flags, { homeDir });
225
+ }
226
+
227
+ const authState = await validateAuthToken(registry, token, deps);
228
+ if (authState) {
229
+ return {
230
+ token,
231
+ authState,
232
+ };
233
+ }
234
+
235
+ if (explicitToken) {
236
+ throw new Error(`The provided token for ${registry} is invalid or expired.`);
237
+ }
238
+
239
+ await clearStoredRegistryAuth(registry, { homeDir });
240
+
241
+ if (!interactive) {
242
+ throw new Error(`Saved login for ${registry} is invalid or expired. Run "ht-skills login --registry ${registry}" first.`);
243
+ }
244
+
245
+ log(`Saved login for ${registry} is invalid or expired. Starting browser sign-in...`);
246
+ await loginImpl({ ...flags, registry }, deps);
247
+ token = await getRequiredAuthToken(registry, flags, { homeDir });
248
+ const refreshedAuthState = await validateAuthToken(registry, token, deps);
249
+ if (!refreshedAuthState) {
250
+ throw new Error(`Login completed but ${registry} did not accept the refreshed token.`);
251
+ }
252
+
253
+ return {
254
+ token,
255
+ authState: refreshedAuthState,
256
+ };
257
+ }
258
+
177
259
  function withBearerToken(headers = {}, token = null) {
178
260
  if (!token) return { ...headers };
179
261
  return {
@@ -186,6 +268,17 @@ function sleep(ms) {
186
268
  return new Promise((resolve) => setTimeout(resolve, ms));
187
269
  }
188
270
 
271
+ function formatBytes(bytes) {
272
+ const value = Number(bytes || 0);
273
+ if (value >= 1024 * 1024) {
274
+ return `${(value / (1024 * 1024)).toFixed(2)} MB`;
275
+ }
276
+ if (value >= 1024) {
277
+ return `${(value / 1024).toFixed(2)} KB`;
278
+ }
279
+ return `${value} B`;
280
+ }
281
+
189
282
  function openBrowserUrl(url) {
190
283
  const safeUrl = String(url || "").trim();
191
284
  if (!safeUrl) {
@@ -726,6 +819,64 @@ function formatSearchCard(item, { index = 0, colorize = (text) => text, width =
726
819
  return [title, blank, firstLine, ...detailRendered, blank, bottom].join("\n");
727
820
  }
728
821
 
822
+ const PUBLISH_FLOW_STEPS = [
823
+ { id: "auth", label: "Login" },
824
+ { id: "pack", label: "Pack" },
825
+ { id: "inspect", label: "Inspect" },
826
+ { id: "submit", label: "Submit" },
827
+ ];
828
+
829
+ function getPublishStepStatusLabel(status) {
830
+ switch (status) {
831
+ case "done":
832
+ return "Done";
833
+ case "active":
834
+ return "Active";
835
+ case "error":
836
+ return "Error";
837
+ default:
838
+ return "Pending";
839
+ }
840
+ }
841
+
842
+ function renderPublishFlowCard(flowState, { colorize = (text) => text, width = 80 } = {}) {
843
+ const maxInnerWidth = Math.max(28, width - 6);
844
+ const lines = PUBLISH_FLOW_STEPS.map((step, index) => {
845
+ const status = flowState?.[step.id] || "pending";
846
+ const symbol = status === "done"
847
+ ? colorize("●", "success")
848
+ : status === "active"
849
+ ? colorize("◉", "accent")
850
+ : status === "error"
851
+ ? colorize("▲", "warn")
852
+ : colorize("○", "muted");
853
+ const label = status === "done"
854
+ ? colorize(step.label, "success")
855
+ : status === "active"
856
+ ? colorize(step.label, "accent")
857
+ : status === "error"
858
+ ? colorize(step.label, "warn")
859
+ : step.label;
860
+ const statusLabel = colorize(getPublishStepStatusLabel(status), status === "pending" ? "muted" : status === "error" ? "warn" : status === "active" ? "accent" : "success");
861
+ const plainLine = `${index + 1}. ${step.label} ${getPublishStepStatusLabel(status)}`;
862
+ const paddedWidth = Math.max(0, maxInnerWidth - getDisplayWidth(plainLine));
863
+ return `│ ${symbol} ${label}${" ".repeat(paddedWidth)} ${statusLabel} │`;
864
+ });
865
+
866
+ const contentWidth = Math.max(
867
+ 28,
868
+ ...PUBLISH_FLOW_STEPS.map((step, index) => getDisplayWidth(`${index + 1}. ${step.label} Pending`)),
869
+ );
870
+ const title = `◇ ${colorize("Publish Flow", "accent")} ${colorize("─".repeat(Math.max(0, contentWidth - getDisplayWidth("Publish Flow") - 1)), "muted")}╮`;
871
+ const normalizedLines = lines.map((line) => {
872
+ const plainWidth = getDisplayWidth(line);
873
+ const targetWidth = contentWidth + 4;
874
+ return plainWidth < targetWidth ? `${line.slice(0, -2)}${" ".repeat(targetWidth - plainWidth)} │` : line;
875
+ });
876
+ const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
877
+ return [title, ...normalizedLines, bottom].join("\n");
878
+ }
879
+
729
880
  function printFallbackIntro({ registry, slug, version, skillName, skillDescription, installTargets }, log = console.log) {
730
881
  log("");
731
882
  log("ht-skills");
@@ -755,6 +906,19 @@ function printFallbackSearchIntro({ registry, query, limit }, log = console.log)
755
906
  log("");
756
907
  }
757
908
 
909
+ function printFallbackPublishIntro({ registry, skillDir, archiveName, visibility }, log = console.log) {
910
+ log("");
911
+ log(renderGradientBanner());
912
+ log("");
913
+ log("Publish");
914
+ log("");
915
+ log(`Source: ${registry}`);
916
+ log(`Directory: ${skillDir}`);
917
+ log(`Archive: ${archiveName}`);
918
+ log(`Access: ${visibility}`);
919
+ log("");
920
+ }
921
+
758
922
  async function loadTerminalUi() {
759
923
  const [{ intro, outro, note, multiselect, select, spinner, isCancel, cancel }, pcModule] = await Promise.all([
760
924
  import("@clack/prompts"),
@@ -1345,14 +1509,90 @@ async function cmdLogin(flags, deps = {}) {
1345
1509
  async function cmdPublish(flags, deps = {}) {
1346
1510
  const requestJsonImpl = deps.requestJson || requestJson;
1347
1511
  const log = deps.log || ((message) => console.log(message));
1348
- const homeDir = deps.homeDir || os.homedir();
1349
1512
  const registry = getRegistryUrl(flags);
1350
1513
  const skillDir = path.resolve(flags._[0] || ".");
1351
- const token = await getRequiredAuthToken(registry, flags, { homeDir });
1514
+ const visibility = String(flags.access || flags.visibility || "public").trim().toLowerCase() || "public";
1352
1515
  const archiveName = `${path.basename(skillDir) || "skill"}.zip`;
1353
- const archiveBuffer = await createZipFromDirectory(skillDir);
1516
+ const isInteractive = isInteractiveSession(deps);
1517
+ const canUseFancyUi = Boolean(isInteractive && !deps.disableUi);
1518
+ const ui = deps.ui || (canUseFancyUi ? await loadTerminalUi().catch(() => null) : null);
1519
+ const colorize = ui ? createUiColorizer(ui.pc) : (text) => String(text);
1520
+ const outputWidth = getOutputWidth(deps.outputWidth);
1354
1521
  const pollIntervalMs = Math.max(500, Number(flags["poll-interval"] || deps.pollIntervalMs || DEFAULT_PUBLISH_POLL_MS));
1522
+ const flowState = {
1523
+ auth: "pending",
1524
+ pack: "pending",
1525
+ inspect: "pending",
1526
+ submit: "pending",
1527
+ };
1528
+
1529
+ const renderPublishFlow = () => {
1530
+ if (!ui) return;
1531
+ // eslint-disable-next-line no-console
1532
+ console.log(renderPublishFlowCard(flowState, {
1533
+ colorize,
1534
+ width: Math.max(44, Math.min(outputWidth - 4, 72)),
1535
+ }));
1536
+ };
1537
+
1538
+ if (ui) {
1539
+ // eslint-disable-next-line no-console
1540
+ console.log(`\n${renderGradientBanner()}\n`);
1541
+ ui.intro(ui.pc.bgCyan(ui.pc.black(" ht-skills ")));
1542
+ ui.note(
1543
+ [
1544
+ `${colorize("Source", "muted")}: ${registry}`,
1545
+ `${colorize("Directory", "muted")}: ${skillDir}`,
1546
+ `${colorize("Archive", "muted")}: ${archiveName}`,
1547
+ `${colorize("Access", "muted")}: ${visibility}`,
1548
+ ].join("\n"),
1549
+ "Publish",
1550
+ );
1551
+ renderPublishFlow();
1552
+ } else {
1553
+ printFallbackPublishIntro({ registry, skillDir, archiveName, visibility }, log);
1554
+ }
1555
+
1556
+ const authSpinner = ui ? ui.spinner() : null;
1557
+ flowState.auth = "active";
1558
+ renderPublishFlow();
1559
+ if (authSpinner) {
1560
+ authSpinner.start("Checking login");
1561
+ } else {
1562
+ log(`Checking login for ${registry}...`);
1563
+ }
1564
+ const { token } = await ensureValidAuthToken(registry, flags, deps);
1565
+ flowState.auth = "done";
1566
+ renderPublishFlow();
1567
+ if (authSpinner) {
1568
+ authSpinner.stop("Login confirmed");
1569
+ }
1355
1570
 
1571
+ const packSpinner = ui ? ui.spinner() : null;
1572
+ flowState.pack = "active";
1573
+ renderPublishFlow();
1574
+ if (packSpinner) {
1575
+ packSpinner.start(`Packing ${path.basename(skillDir) || skillDir}`);
1576
+ } else {
1577
+ log(`Packing skill directory: ${skillDir}`);
1578
+ }
1579
+ const archiveBuffer = await createZipFromDirectory(skillDir);
1580
+ flowState.pack = "done";
1581
+ renderPublishFlow();
1582
+ if (packSpinner) {
1583
+ packSpinner.stop(`Created ${archiveName} (${formatBytes(archiveBuffer.length)})`);
1584
+ } else {
1585
+ log(`Created archive ${archiveName} (${formatBytes(archiveBuffer.length)})`);
1586
+ }
1587
+
1588
+ const uploadSpinner = ui ? ui.spinner() : null;
1589
+ flowState.inspect = "active";
1590
+ renderPublishFlow();
1591
+ if (uploadSpinner) {
1592
+ uploadSpinner.start("Uploading archive for package inspection");
1593
+ } else {
1594
+ log("Uploading archive for package inspection...");
1595
+ }
1356
1596
  const job = await requestJsonImpl(
1357
1597
  `${registry}/api/skills/inspect-package-jobs/upload?archive_name=${encodeURIComponent(archiveName)}`,
1358
1598
  {
@@ -1368,8 +1608,18 @@ async function cmdPublish(flags, deps = {}) {
1368
1608
  if (!jobId) {
1369
1609
  throw new Error("registry did not return an inspection job id");
1370
1610
  }
1611
+ if (uploadSpinner) {
1612
+ uploadSpinner.stop(`Inspection job created (${jobId})`);
1613
+ } else {
1614
+ log(`Inspection job created: ${jobId}`);
1615
+ }
1371
1616
 
1372
1617
  let inspection = job;
1618
+ let lastProgressKey = "";
1619
+ const inspectSpinner = ui ? ui.spinner() : null;
1620
+ if (inspectSpinner) {
1621
+ inspectSpinner.start("Inspecting archive");
1622
+ }
1373
1623
  while (inspection.status !== "succeeded" && inspection.status !== "failed") {
1374
1624
  await sleep(pollIntervalMs);
1375
1625
  inspection = await requestJsonImpl(
@@ -1378,18 +1628,47 @@ async function cmdPublish(flags, deps = {}) {
1378
1628
  headers: withBearerToken({}, token),
1379
1629
  },
1380
1630
  );
1631
+
1632
+ const step = String(inspection.progress?.step || inspection.result?.step || "").trim();
1633
+ const percent = inspection.progress?.percent;
1634
+ const progressKey = `${inspection.status}:${step}:${percent}`;
1635
+ if (progressKey !== lastProgressKey) {
1636
+ lastProgressKey = progressKey;
1637
+ const progressLabel = step
1638
+ ? `${step}${typeof percent === "number" ? ` ${percent}%` : ""}`
1639
+ : inspection.status;
1640
+ if (inspectSpinner) {
1641
+ inspectSpinner.message(`Inspecting archive (${progressLabel})`);
1642
+ } else {
1643
+ log(`Inspection in progress: ${progressLabel}`);
1644
+ }
1645
+ }
1381
1646
  }
1382
1647
 
1383
1648
  if (inspection.status !== "succeeded") {
1649
+ flowState.inspect = "error";
1650
+ renderPublishFlow();
1651
+ if (inspectSpinner) {
1652
+ inspectSpinner.stop("Inspection failed");
1653
+ }
1384
1654
  throw new Error(inspection.error || "skill archive inspection failed");
1385
1655
  }
1656
+ flowState.inspect = "done";
1657
+ renderPublishFlow();
1658
+ if (inspectSpinner) {
1659
+ inspectSpinner.stop("Inspection passed");
1660
+ } else {
1661
+ log("Inspection passed.");
1662
+ }
1386
1663
 
1387
1664
  const preview = inspection.result || {};
1388
1665
  if (!preview.valid || !preview.preview_token) {
1389
1666
  throw new Error(summarizePreviewErrors(preview));
1390
1667
  }
1668
+ if (!ui) {
1669
+ log(`Preview token created: ${preview.preview_token}`);
1670
+ }
1391
1671
 
1392
- const visibility = String(flags.access || flags.visibility || "public").trim().toLowerCase() || "public";
1393
1672
  const body = {
1394
1673
  preview_token: preview.preview_token,
1395
1674
  visibility,
@@ -1404,6 +1683,14 @@ async function cmdPublish(flags, deps = {}) {
1404
1683
  body.publish_now = true;
1405
1684
  }
1406
1685
 
1686
+ const submitSpinner = ui ? ui.spinner() : null;
1687
+ flowState.submit = "active";
1688
+ renderPublishFlow();
1689
+ if (submitSpinner) {
1690
+ submitSpinner.start(`Submitting review request (${visibility})`);
1691
+ } else {
1692
+ log(`Submitting review request with access=${visibility}...`);
1693
+ }
1407
1694
  const result = await requestJsonImpl(`${registry}/api/skills/submit`, {
1408
1695
  method: "POST",
1409
1696
  headers: withBearerToken({
@@ -1411,8 +1698,15 @@ async function cmdPublish(flags, deps = {}) {
1411
1698
  }, token),
1412
1699
  body: JSON.stringify(body),
1413
1700
  });
1701
+ flowState.submit = "done";
1702
+ renderPublishFlow();
1703
+ if (submitSpinner) {
1704
+ submitSpinner.stop(`Review submission created (${result.submission_id || "pending"})`);
1705
+ } else {
1706
+ log(`Review submission created: ${result.submission_id || "(no submission id returned)"}`);
1707
+ }
1414
1708
 
1415
- log(JSON.stringify({
1709
+ const summaryPayload = {
1416
1710
  status: result.status,
1417
1711
  submission_id: result.submission_id || null,
1418
1712
  created_at: result.created_at || null,
@@ -1420,7 +1714,22 @@ async function cmdPublish(flags, deps = {}) {
1420
1714
  publication: result.publication || null,
1421
1715
  preview_token: preview.preview_token,
1422
1716
  archive_name: archiveName,
1423
- }, null, 2));
1717
+ };
1718
+
1719
+ if (ui) {
1720
+ ui.note(
1721
+ [
1722
+ `${colorize("Status", "muted")}: ${result.status || "pending"}`,
1723
+ `${colorize("Submission", "muted")}: ${result.submission_id || "-"}`,
1724
+ `${colorize("Preview", "muted")}: ${preview.preview_token}`,
1725
+ `${colorize("Access", "muted")}: ${summaryPayload.visibility || visibility}`,
1726
+ ].join("\n"),
1727
+ "Published",
1728
+ );
1729
+ ui.outro(`Submitted ${ui.pc.cyan(archiveName)} for review.`);
1730
+ } else {
1731
+ log(JSON.stringify(summaryPayload, null, 2));
1732
+ }
1424
1733
 
1425
1734
  return result;
1426
1735
  }
@@ -1428,11 +1737,10 @@ async function cmdPublish(flags, deps = {}) {
1428
1737
  async function cmdSubmit(flags, deps = {}) {
1429
1738
  const requestJsonImpl = deps.requestJson || requestJson;
1430
1739
  const log = deps.log || ((message) => console.log(message));
1431
- const homeDir = deps.homeDir || os.homedir();
1432
1740
  const skillDirArg = flags._[0] || ".";
1433
1741
  const skillDir = path.resolve(skillDirArg);
1434
1742
  const registry = getRegistryUrl(flags);
1435
- const token = await getRequiredAuthToken(registry, flags, { homeDir });
1743
+ const { token } = await ensureValidAuthToken(registry, flags, deps);
1436
1744
  const manifestPath = path.resolve(flags.manifest || path.join(skillDir, "skill.json"));
1437
1745
  const manifestRaw = await fs.readFile(manifestPath, "utf8");
1438
1746
  const manifest = JSON.parse(manifestRaw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ht-skills",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "CLI for installing and submitting skills from HT Skills Marketplace.",
5
5
  "type": "commonjs",
6
6
  "bin": {