ht-skills 0.1.7 → 0.2.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 +6 -0
  2. package/lib/cli.js +290 -3
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -11,8 +11,14 @@ npx ht-skills add --skill repo-bug-analyze
11
11
  ## Commands
12
12
 
13
13
  ```text
14
+ ht-skills login [--registry <url>]
15
+ ht-skills publish [skillDir] [--registry <url>] [--visibility public|private|shared] [--shared-with a@b.com,c@d.com] [--publish-now]
14
16
  ht-skills search <query> [--registry <url>] [--limit <n>]
15
17
  ht-skills submit <skillDir> [--registry <url>] [--submitter <name>] [--visibility public|private|shared] [--shared-with a@b.com,c@d.com] [--publish-now]
16
18
  ht-skills install <slug[@version]> [more-skills...] [--registry <url>] [--target <dir>] [--tool codex|claude|vscode]
17
19
  ht-skills add [registry] --skill <slug[@version]>,<slug[@version]> [--tool codex|claude|vscode]
18
20
  ```
21
+
22
+ `login` opens the browser, completes marketplace sign-in, and stores a registry token under `~/.ht-skills/config.json`.
23
+
24
+ `publish` zips the target skill directory, uploads it through the marketplace package inspection flow, and then submits the generated preview token for review.
package/lib/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  const fs = require("fs/promises");
2
2
  const os = require("os");
3
3
  const path = require("path");
4
+ const { spawn } = require("child_process");
4
5
  const readline = require("readline/promises");
6
+ const AdmZip = require("adm-zip");
5
7
 
6
8
  const INSTALL_TARGETS = {
7
9
  codex: {
@@ -29,6 +31,11 @@ const TOOL_ALIASES = {
29
31
  "github-copilot": "vscode",
30
32
  };
31
33
  const DEFAULT_REGISTRY_URL = "http://skills.ic.aeroht.local";
34
+ const CONFIG_DIR_NAME = ".ht-skills";
35
+ const CONFIG_FILE_NAME = "config.json";
36
+ const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
37
+ const DEFAULT_LOGIN_POLL_MS = 1500;
38
+ const DEFAULT_PUBLISH_POLL_MS = 1500;
32
39
 
33
40
  const BANNER_TEXT = String.raw` _ _ _____ ___ _ _ _ _ __ __ _ _ _
34
41
  | || |_ _| / __| |_(_) | |___ | \/ |__ _ _ _| |_____| |_ _ __| |__ _ __
@@ -40,12 +47,16 @@ function printHelp() {
40
47
  // eslint-disable-next-line no-console
41
48
  console.log(`Usage:
42
49
  ht-skills search <query> [--registry <url>] [--limit <n>]
50
+ ht-skills login [--registry <url>]
51
+ ht-skills publish [skillDir] [--registry <url>] [--visibility public|private|shared] [--shared-with a@b.com,c@d.com] [--publish-now]
43
52
  ht-skills submit <skillDir> [--registry <url>] [--submitter <name>] [--visibility public|private|shared] [--shared-with a@b.com,c@d.com] [--publish-now]
44
53
  ht-skills install <slug[@version]> [more-skills...] [--registry <url>] [--target <dir>] [--tool codex|claude|vscode]
45
54
  ht-skills add [registry] --skill <slug[@version]>,<slug[@version]> [--tool codex|claude|vscode]
46
55
 
47
56
  Examples:
48
57
  ht-skills search openai
58
+ ht-skills login
59
+ ht-skills publish .
49
60
  ht-skills submit ./examples/hello-skill
50
61
  ht-skills install hello-skill@1.0.0 --tool codex
51
62
  ht-skills install hello-skill repo-bug-analyze --tool codex
@@ -103,6 +114,130 @@ async function requestJson(url, options = {}) {
103
114
  return payload;
104
115
  }
105
116
 
117
+ function getConfigPath(homeDir = os.homedir()) {
118
+ return path.join(homeDir, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
119
+ }
120
+
121
+ async function loadCliConfig(homeDir = os.homedir()) {
122
+ const configPath = getConfigPath(homeDir);
123
+ try {
124
+ const raw = await fs.readFile(configPath, "utf8");
125
+ const parsed = JSON.parse(raw);
126
+ return parsed && typeof parsed === "object" ? parsed : {};
127
+ } catch (error) {
128
+ if (error.code === "ENOENT") {
129
+ return {};
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ async function saveCliConfig(config, homeDir = os.homedir()) {
136
+ const configPath = getConfigPath(homeDir);
137
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
138
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
139
+ }
140
+
141
+ function getRegistryConfigKey(registry) {
142
+ return String(registry || "").replace(/\/$/, "");
143
+ }
144
+
145
+ async function getStoredRegistryAuth(registry, { homeDir = os.homedir() } = {}) {
146
+ const config = await loadCliConfig(homeDir);
147
+ return config.registries?.[getRegistryConfigKey(registry)] || null;
148
+ }
149
+
150
+ async function setStoredRegistryAuth(registry, value, { homeDir = os.homedir() } = {}) {
151
+ const config = await loadCliConfig(homeDir);
152
+ if (!config.registries || typeof config.registries !== "object") {
153
+ config.registries = {};
154
+ }
155
+ config.registries[getRegistryConfigKey(registry)] = value;
156
+ await saveCliConfig(config, homeDir);
157
+ }
158
+
159
+ async function resolveAuthToken(registry, flags, { homeDir = os.homedir() } = {}) {
160
+ const inlineToken = String(flags.token || "").trim();
161
+ if (inlineToken) {
162
+ return inlineToken;
163
+ }
164
+ const stored = await getStoredRegistryAuth(registry, { homeDir });
165
+ return String(stored?.token || "").trim() || null;
166
+ }
167
+
168
+ async function getRequiredAuthToken(registry, flags, { homeDir = os.homedir() } = {}) {
169
+ const token = await resolveAuthToken(registry, flags, { homeDir });
170
+ if (!token) {
171
+ throw new Error(`No saved login for ${registry}. Run "ht-skills login --registry ${registry}" first.`);
172
+ }
173
+ return token;
174
+ }
175
+
176
+ function withBearerToken(headers = {}, token = null) {
177
+ if (!token) return { ...headers };
178
+ return {
179
+ ...headers,
180
+ authorization: `Bearer ${token}`,
181
+ };
182
+ }
183
+
184
+ function sleep(ms) {
185
+ return new Promise((resolve) => setTimeout(resolve, ms));
186
+ }
187
+
188
+ function openBrowserUrl(url) {
189
+ const safeUrl = String(url || "").trim();
190
+ if (!safeUrl) {
191
+ throw new Error("browser URL is required");
192
+ }
193
+
194
+ let child;
195
+ if (process.platform === "win32") {
196
+ child = spawn("cmd", ["/c", "start", "", safeUrl], {
197
+ detached: true,
198
+ stdio: "ignore",
199
+ windowsHide: true,
200
+ });
201
+ } else if (process.platform === "darwin") {
202
+ child = spawn("open", [safeUrl], {
203
+ detached: true,
204
+ stdio: "ignore",
205
+ });
206
+ } else {
207
+ child = spawn("xdg-open", [safeUrl], {
208
+ detached: true,
209
+ stdio: "ignore",
210
+ });
211
+ }
212
+
213
+ child.on("error", () => {});
214
+ child.unref();
215
+ }
216
+
217
+ async function createZipFromDirectory(skillDir) {
218
+ const zip = new AdmZip();
219
+ const files = await walkFiles(skillDir);
220
+ if (files.length === 0) {
221
+ throw new Error(`No files found under ${skillDir}`);
222
+ }
223
+
224
+ for (const absolutePath of files) {
225
+ const content = await fs.readFile(absolutePath);
226
+ const relativePath = path.relative(skillDir, absolutePath).replace(/\\/g, "/");
227
+ zip.addFile(relativePath, content);
228
+ }
229
+
230
+ return zip.toBuffer();
231
+ }
232
+
233
+ function summarizePreviewErrors(preview) {
234
+ const errors = Array.isArray(preview?.errors) ? preview.errors.filter(Boolean) : [];
235
+ if (errors.length === 0) {
236
+ return "archive inspection failed";
237
+ }
238
+ return errors.slice(0, 4).join("; ");
239
+ }
240
+
106
241
  function formatInstallError(error, { slug, version = null, registry, stage = "resolve" }) {
107
242
  const rawMessage = String(error?.message || "Install failed").trim();
108
243
  const status = Number(error?.status || 0);
@@ -1017,11 +1152,153 @@ async function cmdInstall(flags, deps = {}) {
1017
1152
  return allTargets;
1018
1153
  }
1019
1154
 
1155
+ async function cmdLogin(flags, deps = {}) {
1156
+ const requestJsonImpl = deps.requestJson || requestJson;
1157
+ const log = deps.log || ((message) => console.log(message));
1158
+ const openBrowser = deps.openBrowser || openBrowserUrl;
1159
+ const homeDir = deps.homeDir || os.homedir();
1160
+ const registry = getRegistryUrl(flags);
1161
+ const timeoutMs = Math.max(10_000, Number(flags.timeout || deps.timeoutMs || DEFAULT_LOGIN_TIMEOUT_MS));
1162
+ const pollIntervalMs = Math.max(500, Number(flags["poll-interval"] || deps.pollIntervalMs || DEFAULT_LOGIN_POLL_MS));
1163
+
1164
+ const started = await requestJsonImpl(`${registry}/api/cli/auth/start`, {
1165
+ method: "POST",
1166
+ headers: {
1167
+ "content-type": "application/json",
1168
+ },
1169
+ body: JSON.stringify({
1170
+ client: "ht-skills",
1171
+ }),
1172
+ });
1173
+
1174
+ const browserUrl = String(started.browser_url || "").trim();
1175
+ const requestId = String(started.request_id || "").trim();
1176
+ const pollUrl = String(started.poll_url || `${registry}/api/cli/auth/poll/${encodeURIComponent(requestId)}`).trim();
1177
+ if (!browserUrl || !requestId) {
1178
+ throw new Error("registry did not return a usable CLI login flow");
1179
+ }
1180
+
1181
+ try {
1182
+ await Promise.resolve(openBrowser(browserUrl));
1183
+ log(`Opened browser for ${registry}. Complete sign-in to continue...`);
1184
+ } catch (error) {
1185
+ log(`Open this URL in your browser and sign in:\n${browserUrl}`);
1186
+ }
1187
+
1188
+ const expiresAtMs = Date.parse(started.expires_at || "") || (Date.now() + timeoutMs);
1189
+ const deadline = Math.min(Date.now() + timeoutMs, expiresAtMs);
1190
+
1191
+ while (Date.now() <= deadline) {
1192
+ await sleep(pollIntervalMs);
1193
+ const status = await requestJsonImpl(pollUrl);
1194
+ if (status.status === "approved") {
1195
+ await setStoredRegistryAuth(registry, {
1196
+ token: status.token,
1197
+ user: status.user || null,
1198
+ tokenExpiresAt: status.token_expires_at || null,
1199
+ savedAt: new Date().toISOString(),
1200
+ }, { homeDir });
1201
+ const userLabel = status.user?.name || status.user?.email || status.user?.id || "user";
1202
+ log(`Signed in to ${registry} as ${userLabel}.`);
1203
+ return status;
1204
+ }
1205
+ }
1206
+
1207
+ throw new Error(`Timed out waiting for browser sign-in at ${registry}`);
1208
+ }
1209
+
1210
+ async function cmdPublish(flags, deps = {}) {
1211
+ const requestJsonImpl = deps.requestJson || requestJson;
1212
+ const log = deps.log || ((message) => console.log(message));
1213
+ const homeDir = deps.homeDir || os.homedir();
1214
+ const registry = getRegistryUrl(flags);
1215
+ const skillDir = path.resolve(flags._[0] || ".");
1216
+ const token = await getRequiredAuthToken(registry, flags, { homeDir });
1217
+ const archiveName = `${path.basename(skillDir) || "skill"}.zip`;
1218
+ const archiveBuffer = await createZipFromDirectory(skillDir);
1219
+ const pollIntervalMs = Math.max(500, Number(flags["poll-interval"] || deps.pollIntervalMs || DEFAULT_PUBLISH_POLL_MS));
1220
+
1221
+ const job = await requestJsonImpl(
1222
+ `${registry}/api/skills/inspect-package-jobs/upload?archive_name=${encodeURIComponent(archiveName)}`,
1223
+ {
1224
+ method: "POST",
1225
+ headers: withBearerToken({
1226
+ "content-type": "application/zip",
1227
+ }, token),
1228
+ body: archiveBuffer,
1229
+ },
1230
+ );
1231
+
1232
+ const jobId = String(job.job_id || "").trim();
1233
+ if (!jobId) {
1234
+ throw new Error("registry did not return an inspection job id");
1235
+ }
1236
+
1237
+ let inspection = job;
1238
+ while (inspection.status !== "succeeded" && inspection.status !== "failed") {
1239
+ await sleep(pollIntervalMs);
1240
+ inspection = await requestJsonImpl(
1241
+ `${registry}/api/skills/inspect-package-jobs/${encodeURIComponent(jobId)}`,
1242
+ {
1243
+ headers: withBearerToken({}, token),
1244
+ },
1245
+ );
1246
+ }
1247
+
1248
+ if (inspection.status !== "succeeded") {
1249
+ throw new Error(inspection.error || "skill archive inspection failed");
1250
+ }
1251
+
1252
+ const preview = inspection.result || {};
1253
+ if (!preview.valid || !preview.preview_token) {
1254
+ throw new Error(summarizePreviewErrors(preview));
1255
+ }
1256
+
1257
+ const body = {
1258
+ preview_token: preview.preview_token,
1259
+ };
1260
+ if (flags.visibility) {
1261
+ body.visibility = String(flags.visibility);
1262
+ }
1263
+ if (flags["shared-with"]) {
1264
+ body.shared_with = String(flags["shared-with"])
1265
+ .split(",")
1266
+ .map((item) => item.trim())
1267
+ .filter(Boolean);
1268
+ }
1269
+ if (flags["publish-now"]) {
1270
+ body.publish_now = true;
1271
+ }
1272
+
1273
+ const result = await requestJsonImpl(`${registry}/api/skills/submit`, {
1274
+ method: "POST",
1275
+ headers: withBearerToken({
1276
+ "content-type": "application/json",
1277
+ }, token),
1278
+ body: JSON.stringify(body),
1279
+ });
1280
+
1281
+ log(JSON.stringify({
1282
+ status: result.status,
1283
+ submission_id: result.submission_id || null,
1284
+ created_at: result.created_at || null,
1285
+ visibility: result.visibility || body.visibility || null,
1286
+ publication: result.publication || null,
1287
+ preview_token: preview.preview_token,
1288
+ archive_name: archiveName,
1289
+ }, null, 2));
1290
+
1291
+ return result;
1292
+ }
1293
+
1020
1294
  async function cmdSubmit(flags, deps = {}) {
1021
1295
  const requestJsonImpl = deps.requestJson || requestJson;
1022
1296
  const log = deps.log || ((message) => console.log(message));
1297
+ const homeDir = deps.homeDir || os.homedir();
1023
1298
  const skillDirArg = flags._[0] || ".";
1024
1299
  const skillDir = path.resolve(skillDirArg);
1300
+ const registry = getRegistryUrl(flags);
1301
+ const token = await getRequiredAuthToken(registry, flags, { homeDir });
1025
1302
  const manifestPath = path.resolve(flags.manifest || path.join(skillDir, "skill.json"));
1026
1303
  const manifestRaw = await fs.readFile(manifestPath, "utf8");
1027
1304
  const manifest = JSON.parse(manifestRaw);
@@ -1058,12 +1335,11 @@ async function cmdSubmit(flags, deps = {}) {
1058
1335
  body.publish_now = true;
1059
1336
  }
1060
1337
 
1061
- const registry = getRegistryUrl(flags);
1062
1338
  const result = await requestJsonImpl(`${registry}/api/skills/submit`, {
1063
1339
  method: "POST",
1064
- headers: {
1340
+ headers: withBearerToken({
1065
1341
  "content-type": "application/json",
1066
- },
1342
+ }, token),
1067
1343
  body: JSON.stringify(body),
1068
1344
  });
1069
1345
  log(JSON.stringify(result, null, 2));
@@ -1096,6 +1372,14 @@ async function main(argv = process.argv) {
1096
1372
  await cmdSearch(flags);
1097
1373
  return;
1098
1374
  }
1375
+ if (command === "login") {
1376
+ await cmdLogin(flags);
1377
+ return;
1378
+ }
1379
+ if (command === "publish") {
1380
+ await cmdPublish(flags);
1381
+ return;
1382
+ }
1099
1383
  if (command === "install") {
1100
1384
  await cmdInstall(flags);
1101
1385
  return;
@@ -1125,11 +1409,14 @@ module.exports = {
1125
1409
  normalizeSkillSpecs,
1126
1410
  normalizeInlineText,
1127
1411
  printFallbackSearchIntro,
1412
+ getConfigPath,
1128
1413
  normalizeToolIds,
1129
1414
  parseInstallSelection,
1130
1415
  getAvailableInstallTargets,
1131
1416
  resolveInstallTargets,
1132
1417
  fetchResolvedVersion,
1418
+ cmdLogin,
1419
+ cmdPublish,
1133
1420
  cmdInstall,
1134
1421
  cmdAdd,
1135
1422
  cmdSearch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ht-skills",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for installing and submitting skills from HT Skills Marketplace.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@clack/prompts": "^1.1.0",
19
+ "adm-zip": "^0.5.16",
19
20
  "picocolors": "^1.1.1"
20
21
  }
21
22
  }