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.
- package/README.md +6 -0
- package/lib/cli.js +290 -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.
|
|
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
|
}
|