oh-skillhub 0.1.2 → 0.1.4
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 +14 -1
- package/docs/superpowers/specs/2026-05-25-npx-skill-installer-design.md +7 -1
- package/package.json +1 -1
- package/src/cli.js +238 -25
- package/src/data/manifest.json +11 -0
- package/src/manifest.js +3 -1
package/README.md
CHANGED
|
@@ -11,7 +11,19 @@ npx oh-skillhub install --domain arkui --agent codex
|
|
|
11
11
|
npx oh-skillhub install --domain arkui --agent all --scope user
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Running without arguments starts
|
|
14
|
+
Running without arguments starts a TUI selector and installs the selected skill set to the default Codex user skills directory.
|
|
15
|
+
|
|
16
|
+
In an interactive terminal:
|
|
17
|
+
|
|
18
|
+
- Use `Up` / `Down` or `j` / `k` to move.
|
|
19
|
+
- Press `Space` to select or unselect a group.
|
|
20
|
+
- Press `Enter` to install selected groups.
|
|
21
|
+
|
|
22
|
+
When input is piped or the terminal does not support raw key input, enter numbers separated by spaces:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
printf "3 9\n" | npx oh-skillhub@latest
|
|
26
|
+
```
|
|
15
27
|
|
|
16
28
|
## Current Capabilities
|
|
17
29
|
|
|
@@ -21,6 +33,7 @@ Running without arguments starts an interactive domain selector and installs the
|
|
|
21
33
|
- Support `--agent codex|claude|opencode|all`.
|
|
22
34
|
- Support `--scope user|project`.
|
|
23
35
|
- Support `--dry-run` install plans.
|
|
36
|
+
- Run a TUI matching the `skills/common/*` and `skills/domain/*` repository layout.
|
|
24
37
|
- Keep anonymous telemetry events in a local retry queue.
|
|
25
38
|
|
|
26
39
|
## Commands
|
|
@@ -153,12 +153,18 @@ npx oh-skillhub telemetry status
|
|
|
153
153
|
npx oh-skillhub doctor
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
无参数运行时进入 TUI 领域选择,默认安装到 Codex user scope:
|
|
157
157
|
|
|
158
158
|
```bash
|
|
159
159
|
npx oh-skillhub@latest
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
真实交互终端中使用方向键或 `j/k` 移动,按 `Space` 勾选或取消,按 `Enter` 安装。非 TTY 或管道输入时,使用空格分隔编号选择多个分组,例如:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
printf "3 9\n" | npx oh-skillhub@latest
|
|
166
|
+
```
|
|
167
|
+
|
|
162
168
|
推荐交互:
|
|
163
169
|
|
|
164
170
|
```bash
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const os = require("node:os");
|
|
3
3
|
const path = require("node:path");
|
|
4
|
-
const readline = require("node:readline
|
|
4
|
+
const readline = require("node:readline");
|
|
5
|
+
const readlinePromises = require("node:readline/promises");
|
|
5
6
|
|
|
6
7
|
const { resolveAgentTargets } = require("./agents");
|
|
7
8
|
const { loadLocalManifest, loadProfiles, selectSkills } = require("./manifest");
|
|
@@ -9,6 +10,17 @@ const { applyInstallPlan, planInstall } = require("./planner");
|
|
|
9
10
|
const { buildTelemetryEvent, enqueueTelemetryEvent, telemetryStatus } = require("./telemetry");
|
|
10
11
|
|
|
11
12
|
const packageJson = require("../package.json");
|
|
13
|
+
const COMMON_STAGES = ["cicd", "design", "development", "requirements", "testing", "troubleshooting"];
|
|
14
|
+
const DOMAIN_NAMES = [
|
|
15
|
+
"app-framework",
|
|
16
|
+
"arkruntime",
|
|
17
|
+
"arkui",
|
|
18
|
+
"arkweb",
|
|
19
|
+
"distributed",
|
|
20
|
+
"graphics",
|
|
21
|
+
"kernel",
|
|
22
|
+
"security",
|
|
23
|
+
];
|
|
12
24
|
|
|
13
25
|
async function main(argv = []) {
|
|
14
26
|
if (argv.length === 0) {
|
|
@@ -135,40 +147,236 @@ function renderInstall(options) {
|
|
|
135
147
|
}
|
|
136
148
|
|
|
137
149
|
async function runInteractiveInstaller(input = process.stdin, output = process.stdout) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
];
|
|
150
|
+
const manifest = loadLocalManifest();
|
|
151
|
+
const choices = buildRepositoryChoices(manifest);
|
|
152
|
+
const selectedIndexes =
|
|
153
|
+
input.isTTY && output.isTTY
|
|
154
|
+
? await runRawTuiSelection(input, output, choices)
|
|
155
|
+
: await runPromptSelection(input, output, choices);
|
|
156
|
+
const selectedChoices = selectedIndexes.map((index) => choices[index]);
|
|
157
|
+
const skills = selectSkillsForChoices(manifest, selectedChoices);
|
|
158
|
+
if (!skills.length) {
|
|
159
|
+
throw new Error("No skills matched the selected groups.");
|
|
160
|
+
}
|
|
161
|
+
output.write(`\nInstalling ${selectedChoices.map((choice) => choice.path).join(", ")} for codex:user...\n`);
|
|
162
|
+
output.write(`${renderInstallForSkills(skills, { agent: "codex", scope: "user" })}\n`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildRepositoryChoices(manifest) {
|
|
166
|
+
const choices = [];
|
|
167
|
+
for (const stage of COMMON_STAGES) {
|
|
168
|
+
const skills = manifest.skills.filter((skill) => skill.scope === "common" && skill.stage === stage);
|
|
169
|
+
choices.push({ scope: "common", stage, path: `common / ${stage}`, count: skills.length, skills });
|
|
170
|
+
}
|
|
171
|
+
for (const domain of DOMAIN_NAMES) {
|
|
172
|
+
const skills = manifest.skills.filter((skill) => skill.scope === "domain" && skill.domain === domain);
|
|
173
|
+
choices.push({ scope: "domain", domain, path: `domain / ${domain}`, count: skills.length, skills });
|
|
174
|
+
}
|
|
175
|
+
return choices;
|
|
176
|
+
}
|
|
145
177
|
|
|
146
|
-
|
|
178
|
+
function renderTuiMenu(choices) {
|
|
179
|
+
const width = 72;
|
|
180
|
+
const line = `+${"-".repeat(width - 2)}+`;
|
|
181
|
+
const lines = [
|
|
182
|
+
line,
|
|
183
|
+
`| ${padRight("OH SkillHub", width - 4)} |`,
|
|
184
|
+
`| ${padRight("OpenHarmony Skills Installer", width - 4)} |`,
|
|
185
|
+
line,
|
|
186
|
+
"",
|
|
187
|
+
"Target",
|
|
188
|
+
" Agent: codex",
|
|
189
|
+
" Scope: user",
|
|
190
|
+
"",
|
|
191
|
+
"Choose skill groups",
|
|
192
|
+
" Interactive TTY: Up/Down or j/k to move, Space to toggle, Enter to install",
|
|
193
|
+
" Piped input: enter numbers separated by spaces, e.g. 3 9",
|
|
194
|
+
"",
|
|
195
|
+
];
|
|
147
196
|
choices.forEach((choice, index) => {
|
|
148
|
-
|
|
197
|
+
const marker = choice.count > 0 ? "[ ]" : "[ ]";
|
|
198
|
+
lines.push(` ${String(index + 1).padStart(2, " ")}. ${marker} ${choice.path.padEnd(28, " ")} ${choice.count} skill(s)`);
|
|
199
|
+
for (const leaf of renderSkillLeaves(choice)) {
|
|
200
|
+
lines.push(` - ${leaf}`);
|
|
201
|
+
}
|
|
149
202
|
});
|
|
203
|
+
lines.push("");
|
|
204
|
+
return `${lines.join("\n")}\n`;
|
|
205
|
+
}
|
|
150
206
|
|
|
151
|
-
|
|
207
|
+
async function runPromptSelection(input, output, choices) {
|
|
208
|
+
output.write(renderTuiMenu(choices));
|
|
209
|
+
const rl = readlinePromises.createInterface({ input, output });
|
|
152
210
|
try {
|
|
153
|
-
const answer = await rl.question("
|
|
154
|
-
|
|
155
|
-
const selected = choices[selectedIndex];
|
|
156
|
-
if (!selected) {
|
|
157
|
-
throw new Error(`Invalid selection "${answer}". Choose a number from 1 to ${choices.length}.`);
|
|
158
|
-
}
|
|
159
|
-
output.write(`\nInstalling ${selected.label} skills for codex:user...\n`);
|
|
160
|
-
output.write(
|
|
161
|
-
`${renderInstall({
|
|
162
|
-
preset: selected.preset,
|
|
163
|
-
agent: "codex",
|
|
164
|
-
scope: "user",
|
|
165
|
-
})}\n`,
|
|
166
|
-
);
|
|
211
|
+
const answer = await rl.question("Select groups [9]: ");
|
|
212
|
+
return parseSelection(answer, choices.length, 9);
|
|
167
213
|
} finally {
|
|
168
214
|
rl.close();
|
|
169
215
|
}
|
|
170
216
|
}
|
|
171
217
|
|
|
218
|
+
function runRawTuiSelection(input, output, choices) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let cursor = 8;
|
|
221
|
+
const selected = new Set([cursor]);
|
|
222
|
+
const wasRaw = input.isRaw;
|
|
223
|
+
|
|
224
|
+
readline.emitKeypressEvents(input);
|
|
225
|
+
input.setRawMode(true);
|
|
226
|
+
|
|
227
|
+
function render() {
|
|
228
|
+
output.write("\x1b[2J\x1b[H");
|
|
229
|
+
output.write(renderRawTuiMenu(choices, cursor, selected));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cleanup() {
|
|
233
|
+
input.removeListener("keypress", onKeypress);
|
|
234
|
+
input.setRawMode(Boolean(wasRaw));
|
|
235
|
+
output.write("\x1b[?25h");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function onKeypress(_str, key) {
|
|
239
|
+
if (key && key.ctrl && key.name === "c") {
|
|
240
|
+
cleanup();
|
|
241
|
+
reject(new Error("Cancelled."));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (key && (key.name === "down" || key.name === "j")) {
|
|
245
|
+
cursor = (cursor + 1) % choices.length;
|
|
246
|
+
render();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (key && (key.name === "up" || key.name === "k")) {
|
|
250
|
+
cursor = (cursor - 1 + choices.length) % choices.length;
|
|
251
|
+
render();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (key && key.name === "space") {
|
|
255
|
+
if (selected.has(cursor)) selected.delete(cursor);
|
|
256
|
+
else selected.add(cursor);
|
|
257
|
+
render();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (key && key.name === "return") {
|
|
261
|
+
cleanup();
|
|
262
|
+
resolve(selected.size ? Array.from(selected).sort((a, b) => a - b) : [cursor]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
output.write("\x1b[?25l");
|
|
267
|
+
render();
|
|
268
|
+
input.on("keypress", onKeypress);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderRawTuiMenu(choices, cursor, selected) {
|
|
273
|
+
const width = 76;
|
|
274
|
+
const line = `+${"-".repeat(width - 2)}+`;
|
|
275
|
+
const lines = [
|
|
276
|
+
line,
|
|
277
|
+
`| ${padRight("OH SkillHub", width - 4)} |`,
|
|
278
|
+
`| ${padRight("OpenHarmony Skills Installer", width - 4)} |`,
|
|
279
|
+
line,
|
|
280
|
+
"",
|
|
281
|
+
"Target",
|
|
282
|
+
" Agent: codex",
|
|
283
|
+
" Scope: user",
|
|
284
|
+
"",
|
|
285
|
+
"Choose skill groups",
|
|
286
|
+
" Up/Down or j/k: move Space: select Enter: install Ctrl+C: cancel",
|
|
287
|
+
"",
|
|
288
|
+
];
|
|
289
|
+
choices.forEach((choice, index) => {
|
|
290
|
+
const pointer = index === cursor ? ">" : " ";
|
|
291
|
+
const checkbox = selected.has(index) ? "[✓]" : "[ ]";
|
|
292
|
+
lines.push(`${pointer} ${checkbox} ${choice.path.padEnd(28, " ")} ${choice.count} skill(s)`);
|
|
293
|
+
for (const leaf of renderSkillLeaves(choice)) {
|
|
294
|
+
lines.push(` - ${leaf}`);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
lines.push("");
|
|
298
|
+
return `${lines.join("\n")}\n`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderSkillLeaves(choice) {
|
|
302
|
+
return (choice.skills || []).map((skill) => {
|
|
303
|
+
if (choice.scope === "common") {
|
|
304
|
+
return skill.name;
|
|
305
|
+
}
|
|
306
|
+
const prefix = `skills/domain/${choice.domain}/`;
|
|
307
|
+
if (skill.path.startsWith(prefix)) {
|
|
308
|
+
return skill.path.slice(prefix.length).split("/").join(" / ");
|
|
309
|
+
}
|
|
310
|
+
return skill.name;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function padRight(value, width) {
|
|
315
|
+
return value.length >= width ? value.slice(0, width) : `${value}${" ".repeat(width - value.length)}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseSelection(answer, max, defaultNumber) {
|
|
319
|
+
const text = answer.trim();
|
|
320
|
+
const tokens = text ? text.split(/\s+/) : [String(defaultNumber)];
|
|
321
|
+
const indexes = [];
|
|
322
|
+
for (const token of tokens) {
|
|
323
|
+
const number = Number(token);
|
|
324
|
+
if (!Number.isInteger(number) || number < 1 || number > max) {
|
|
325
|
+
throw new Error(`Invalid selection "${token}". Choose numbers from 1 to ${max}.`);
|
|
326
|
+
}
|
|
327
|
+
const index = number - 1;
|
|
328
|
+
if (!indexes.includes(index)) {
|
|
329
|
+
indexes.push(index);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return indexes;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function selectSkillsForChoices(manifest, choices) {
|
|
336
|
+
const selected = new Map();
|
|
337
|
+
for (const choice of choices) {
|
|
338
|
+
for (const skill of manifest.skills) {
|
|
339
|
+
if (choice.scope === "common" && skill.scope === "common" && skill.stage === choice.stage) {
|
|
340
|
+
selected.set(skill.name, skill);
|
|
341
|
+
}
|
|
342
|
+
if (choice.scope === "domain" && skill.scope === "domain" && skill.domain === choice.domain) {
|
|
343
|
+
selected.set(skill.name, skill);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return Array.from(selected.values()).sort((left, right) => left.name.localeCompare(right.name));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderInstallForSkills(skills, targetOptions) {
|
|
351
|
+
const targets = resolveAgentTargets({
|
|
352
|
+
agent: targetOptions.agent,
|
|
353
|
+
scope: targetOptions.scope,
|
|
354
|
+
cwd: process.cwd(),
|
|
355
|
+
homeDir: os.homedir(),
|
|
356
|
+
env: process.env,
|
|
357
|
+
});
|
|
358
|
+
const plan = planInstall(skills, targets);
|
|
359
|
+
const applied = applyInstallPlan(plan);
|
|
360
|
+
const telemetryEnabled = process.env.OH_SKILLHUB_NO_TELEMETRY !== "1";
|
|
361
|
+
for (const operation of applied) {
|
|
362
|
+
enqueueTelemetryEvent(
|
|
363
|
+
buildTelemetryEvent({
|
|
364
|
+
event: "skill_installed",
|
|
365
|
+
skill: operation.skill,
|
|
366
|
+
target: operation.target,
|
|
367
|
+
selection: {
|
|
368
|
+
profile: null,
|
|
369
|
+
domain: operation.skill.domain,
|
|
370
|
+
stage: operation.skill.stage,
|
|
371
|
+
},
|
|
372
|
+
packageVersion: packageJson.version,
|
|
373
|
+
}),
|
|
374
|
+
{ enabled: telemetryEnabled },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return renderInstallPlan("Install summary", plan);
|
|
378
|
+
}
|
|
379
|
+
|
|
172
380
|
function renderInstallPlan(title, plan) {
|
|
173
381
|
const lines = [title];
|
|
174
382
|
for (const operation of plan.operations) {
|
|
@@ -248,4 +456,9 @@ module.exports = {
|
|
|
248
456
|
renderInstall,
|
|
249
457
|
renderList,
|
|
250
458
|
renderTelemetry,
|
|
459
|
+
buildRepositoryChoices,
|
|
460
|
+
parseSelection,
|
|
461
|
+
renderRawTuiMenu,
|
|
462
|
+
renderTuiMenu,
|
|
463
|
+
selectSkillsForChoices,
|
|
251
464
|
};
|
package/src/data/manifest.json
CHANGED
|
@@ -144,6 +144,17 @@
|
|
|
144
144
|
"version": "0.1.0",
|
|
145
145
|
"status": "stable",
|
|
146
146
|
"tags": ["arkui", "review"]
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"name": "ohos-design-graphics-explain-code",
|
|
150
|
+
"scope": "domain",
|
|
151
|
+
"domain": "graphics",
|
|
152
|
+
"stage": "design",
|
|
153
|
+
"path": "skills/domain/graphics/design/ohos-design-graphics-explain-code",
|
|
154
|
+
"description": "Use when explaining OpenHarmony graphics design code.",
|
|
155
|
+
"version": "0.1.0",
|
|
156
|
+
"status": "stable",
|
|
157
|
+
"tags": ["graphics", "design"]
|
|
147
158
|
}
|
|
148
159
|
]
|
|
149
160
|
}
|
package/src/manifest.js
CHANGED
|
@@ -66,7 +66,9 @@ function selectSkills(manifest, profiles, options = {}) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
if (options.stage) {
|
|
69
|
-
for (const skill of manifest.skills.filter(
|
|
69
|
+
for (const skill of manifest.skills.filter(
|
|
70
|
+
(entry) => entry.stage === options.stage && (!options.scope || entry.scope === options.scope),
|
|
71
|
+
)) {
|
|
70
72
|
addSkill(skill);
|
|
71
73
|
}
|
|
72
74
|
}
|