oh-skillhub 0.1.11 → 0.1.13

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 +12 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +264 -31
package/README.md CHANGED
@@ -12,16 +12,23 @@ npx oh-skillhub install --domain arkui --agent all --scope user
12
12
  npx oh-skillhub clean --agent claude --scope user --dry-run
13
13
  ```
14
14
 
15
- Running without arguments starts a TUI selector. Choose the install target first (`Codex`, `Claude`, `OpenCode`, or `All`), then choose the skill groups to install.
15
+ Running without arguments starts a TUI selector. Choose an action first (`Install skills` or `Clean installed skills`). Install then asks for the target (`Codex`, `Claude`, `OpenCode`, or `All`) and skill groups. Clean asks for the target and installed skills to remove.
16
16
 
17
17
  In an interactive terminal:
18
18
 
19
19
  - Use `Up` / `Down` or `j` / `k` to move.
20
- - Press `Space` to select or unselect a group.
21
- - Press `Enter` to install selected groups.
20
+ - Press `Space` to select or unselect items.
21
+ - Press `Enter` to confirm the current action.
22
22
 
23
23
  When input is piped or the terminal does not support raw key input, enter numbers separated by spaces:
24
24
 
25
+ ```bash
26
+ printf "1\n1\n3 9\n" | npx oh-skillhub@latest
27
+ printf "2\n2\n1\n\n" | npx oh-skillhub@latest
28
+ ```
29
+
30
+ The old piped installer format is still accepted:
31
+
25
32
  ```bash
26
33
  printf "1\n3 9\n" | npx oh-skillhub@latest
27
34
  ```
@@ -37,6 +44,7 @@ printf "1\n3 9\n" | npx oh-skillhub@latest
37
44
  - Support `--scope user|project`.
38
45
  - Support `--dry-run` install plans.
39
46
  - Scan installed skills and clean selected skills with `clean`.
47
+ - Show clean as a first-class option in the default TUI.
40
48
  - Move cleaned skills to `.oh-skillhub/trash` by default, with `--purge` for permanent deletion.
41
49
  - Run a TUI matching the `skills/common/*` and `skills/domain/*` repository layout.
42
50
  - Keep anonymous telemetry events in a local retry queue.
@@ -119,7 +127,7 @@ node bin/oh-skillhub.js install --domain arkui --agent all --scope user --dry-ru
119
127
 
120
128
  - Uses a bundled manifest derived from the GitCode `release` branch examples.
121
129
  - Does not yet refresh the remote GitCode manifest at runtime.
122
- - Writes a generated `SKILL.md` from manifest metadata instead of downloading full remote skill directories.
130
+ - Downloads and installs full skill directories for manifest entries, but does not yet support arbitrary remote skill discovery outside the bundled manifest.
123
131
  - Telemetry collector upload is designed but not yet wired; events are queued locally.
124
132
 
125
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-skillhub",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "OpenHarmony Skills installer for Codex, Claude Code, and OpenCode.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -37,10 +37,14 @@ const AGENT_CHOICES = [
37
37
  { agent: "opencode", label: "OpenCode", hint: "~/.config/opencode/skill" },
38
38
  { agent: "all", label: "All", hint: "Codex + Claude + OpenCode" },
39
39
  ];
40
+ const ACTION_CHOICES = [
41
+ { action: "install", label: "Install skills", hint: "Choose OpenHarmony skill groups to install" },
42
+ { action: "clean", label: "Clean installed skills", hint: "Scan an agent target and remove selected skills" },
43
+ ];
40
44
 
41
45
  async function main(argv = []) {
42
46
  if (argv.length === 0) {
43
- await runInteractiveInstaller();
47
+ await runHome();
44
48
  return;
45
49
  }
46
50
  if (argv[0] === "--help" || argv[0] === "-h") {
@@ -101,18 +105,52 @@ function parseArgs(args) {
101
105
  return options;
102
106
  }
103
107
 
108
+ async function runHome(input = process.stdin, output = process.stdout) {
109
+ if (input.isTTY && output.isTTY) {
110
+ const action = await runRawActionSelection(input, output);
111
+ if (action === "clean") {
112
+ await runClean({}, input, output);
113
+ return;
114
+ }
115
+ await runInteractiveInstaller(input, output);
116
+ return;
117
+ }
118
+
119
+ const answers = splitPromptAnswers(await readAll(input));
120
+ const hasActionAnswer = answers.length >= 3 && isSingleSelection(answers[0], ACTION_CHOICES.length);
121
+ if (!hasActionAnswer) {
122
+ await runInteractiveInstallerFromAnswers(answers, output);
123
+ return;
124
+ }
125
+
126
+ output.write(renderActionMenu());
127
+ output.write("Select action [1]: \n");
128
+ const action = ACTION_CHOICES[parseSingleSelection(answers[0], ACTION_CHOICES.length, 1)].action;
129
+ if (action === "clean") {
130
+ await runCleanWithAnswers({}, input, output, answers.slice(1));
131
+ return;
132
+ }
133
+ await runInteractiveInstallerFromAnswers(answers.slice(1), output);
134
+ }
135
+
104
136
  async function runClean(options, input = process.stdin, output = process.stdout) {
105
137
  const scriptedAnswers = input.isTTY ? null : splitPromptAnswers(await readAll(input));
138
+ await runCleanWithAnswers(options, input, output, scriptedAnswers);
139
+ }
140
+
141
+ async function runCleanWithAnswers(options, input, output, scriptedAnswers = null) {
142
+ const cleanOptions = { names: [], ...options };
143
+ const hasScriptedAnswers = Array.isArray(scriptedAnswers);
106
144
  let answerIndex = 0;
107
145
  const takeAnswer = () => {
108
- if (!scriptedAnswers) return null;
146
+ if (!hasScriptedAnswers) return null;
109
147
  const answer = scriptedAnswers[answerIndex] || "";
110
148
  answerIndex += 1;
111
149
  return answer;
112
150
  };
113
- let agent = options.agent;
114
- if (!agent && !options.target) {
115
- if (input.isTTY && output.isTTY) {
151
+ let agent = cleanOptions.agent;
152
+ if (!agent && !cleanOptions.target) {
153
+ if (!hasScriptedAnswers && input.isTTY && output.isTTY) {
116
154
  agent = await runRawAgentSelection(input, output);
117
155
  } else {
118
156
  output.write(renderAgentMenu());
@@ -122,8 +160,8 @@ async function runClean(options, input = process.stdin, output = process.stdout)
122
160
  }
123
161
  const targets = resolveAgentTargets({
124
162
  agent: agent || "codex",
125
- scope: options.scope || "user",
126
- target: options.target,
163
+ scope: cleanOptions.scope || "user",
164
+ target: cleanOptions.target,
127
165
  cwd: process.cwd(),
128
166
  homeDir: os.homedir(),
129
167
  env: process.env,
@@ -135,8 +173,8 @@ async function runClean(options, input = process.stdin, output = process.stdout)
135
173
  }
136
174
  let selected;
137
175
  let prefilledConfirmation = null;
138
- if (!options.names.length && !options.dryRun) {
139
- if (input.isTTY) {
176
+ if (!cleanOptions.names.length && !cleanOptions.dryRun) {
177
+ if (!hasScriptedAnswers && input.isTTY && output.isTTY) {
140
178
  selected = await promptCleanSelection(input, output, discovered);
141
179
  } else {
142
180
  output.write(renderCleanSelectionMenu(discovered));
@@ -145,16 +183,16 @@ async function runClean(options, input = process.stdin, output = process.stdout)
145
183
  prefilledConfirmation = takeAnswer() || "";
146
184
  }
147
185
  } else {
148
- selected = selectCleanSkills(discovered, options.names);
149
- if (!input.isTTY) {
186
+ selected = selectCleanSkills(discovered, cleanOptions.names);
187
+ if (hasScriptedAnswers || !input.isTTY) {
150
188
  prefilledConfirmation = takeAnswer();
151
189
  }
152
190
  }
153
191
  if (!selected.length) {
154
- throw new Error(`No installed skills matched: ${options.names.join(", ")}`);
192
+ throw new Error(`No installed skills matched: ${cleanOptions.names.join(", ")}`);
155
193
  }
156
- const plan = planClean(selected, { mode: options.purge ? "purge" : "trash" });
157
- if (options.dryRun) {
194
+ const plan = planClean(selected, { mode: cleanOptions.purge ? "purge" : "trash" });
195
+ if (cleanOptions.dryRun) {
158
196
  output.write(`${renderCleanPlan("Clean dry run", plan)}\n`);
159
197
  return;
160
198
  }
@@ -165,6 +203,10 @@ async function runClean(options, input = process.stdin, output = process.stdout)
165
203
  }
166
204
 
167
205
  async function promptCleanSelection(input, output, skills) {
206
+ if (input.isTTY && output.isTTY) {
207
+ const selectedIndexes = await runRawCleanSelection(input, output, skills);
208
+ return selectedIndexes.map((index) => skills[index]);
209
+ }
168
210
  output.write(renderCleanSelectionMenu(skills));
169
211
  const rl = readlinePromises.createInterface({ input, output });
170
212
  try {
@@ -318,23 +360,32 @@ function renderInstall(options) {
318
360
  }
319
361
 
320
362
  async function runInteractiveInstaller(input = process.stdin, output = process.stdout) {
363
+ if (!(input.isTTY && output.isTTY)) {
364
+ await runInteractiveInstallerFromAnswers(splitPromptAnswers(await readAll(input)), output);
365
+ return;
366
+ }
367
+
321
368
  const manifest = loadLocalManifest();
322
369
  const choices = buildRepositoryChoices(manifest);
323
- let agent;
324
- let selectedIndexes;
325
- if (input.isTTY && output.isTTY) {
326
- agent = await runRawAgentSelection(input, output);
327
- selectedIndexes = await runRawTuiSelection(input, output, choices, agent);
328
- } else {
329
- const answers = splitPromptAnswers(await readAll(input));
330
- const [agentAnswer, groupAnswer] = answers.length > 1 ? [answers[0], answers.slice(1).join(" ")] : ["", answers[0] || ""];
331
- output.write(renderAgentMenu());
332
- output.write("Select target [1]: \n");
333
- agent = AGENT_CHOICES[parseSingleSelection(agentAnswer, AGENT_CHOICES.length, 1)].agent;
334
- output.write(renderTuiMenu(choices, agent));
335
- output.write("Select groups [9]: \n");
336
- selectedIndexes = parseSelection(groupAnswer, choices.length, 9);
337
- }
370
+ const agent = await runRawAgentSelection(input, output);
371
+ const selectedIndexes = await runRawTuiSelection(input, output, choices, agent);
372
+ await installInteractiveSelection(manifest, choices, agent, selectedIndexes, output);
373
+ }
374
+
375
+ async function runInteractiveInstallerFromAnswers(answers, output = process.stdout) {
376
+ const manifest = loadLocalManifest();
377
+ const choices = buildRepositoryChoices(manifest);
378
+ const [agentAnswer, groupAnswer] = answers.length > 1 ? [answers[0], answers.slice(1).join(" ")] : ["", answers[0] || ""];
379
+ output.write(renderAgentMenu());
380
+ output.write("Select target [1]: \n");
381
+ const agent = AGENT_CHOICES[parseSingleSelection(agentAnswer, AGENT_CHOICES.length, 1)].agent;
382
+ output.write(renderTuiMenu(choices, agent));
383
+ output.write("Select groups [9]: \n");
384
+ const selectedIndexes = parseSelection(groupAnswer, choices.length, 9);
385
+ await installInteractiveSelection(manifest, choices, agent, selectedIndexes, output);
386
+ }
387
+
388
+ async function installInteractiveSelection(manifest, choices, agent, selectedIndexes, output) {
338
389
  const selectedChoices = selectedIndexes.map((index) => choices[index]);
339
390
  const skills = selectSkillsForChoices(manifest, selectedChoices);
340
391
  if (!skills.length) {
@@ -426,6 +477,26 @@ function renderAgentMenu() {
426
477
  return `${lines.join("\n")}\n`;
427
478
  }
428
479
 
480
+ function renderActionMenu() {
481
+ const width = 72;
482
+ const line = `+${"-".repeat(width - 2)}+`;
483
+ const lines = [
484
+ line,
485
+ `| ${padRight("OH SkillHub", width - 4)} |`,
486
+ `| ${padRight("OpenHarmony Skills Manager", width - 4)} |`,
487
+ line,
488
+ "",
489
+ "Choose action",
490
+ " Install new skills or clean existing skills from an agent target.",
491
+ "",
492
+ ];
493
+ ACTION_CHOICES.forEach((choice, index) => {
494
+ lines.push(` ${index + 1}. [ ] ${choice.label.padEnd(22, " ")} ${choice.hint}`);
495
+ });
496
+ lines.push("");
497
+ return `${lines.join("\n")}\n`;
498
+ }
499
+
429
500
  async function runPromptAgentSelection(input, output, existingInterface) {
430
501
  output.write(renderAgentMenu());
431
502
  const rl = existingInterface || readlinePromises.createInterface({ input, output });
@@ -549,6 +620,76 @@ function runRawAgentSelection(input, output) {
549
620
  });
550
621
  }
551
622
 
623
+ function runRawActionSelection(input, output) {
624
+ return new Promise((resolve, reject) => {
625
+ let cursor = 0;
626
+ const wasRaw = input.isRaw;
627
+
628
+ readline.emitKeypressEvents(input);
629
+ input.setRawMode(true);
630
+
631
+ function render() {
632
+ output.write("\x1b[2J\x1b[H");
633
+ output.write(renderRawActionMenu(cursor));
634
+ }
635
+
636
+ function cleanup() {
637
+ input.removeListener("keypress", onKeypress);
638
+ input.setRawMode(Boolean(wasRaw));
639
+ output.write("\x1b[?25h");
640
+ }
641
+
642
+ function onKeypress(_str, key) {
643
+ if (key && key.ctrl && key.name === "c") {
644
+ cleanup();
645
+ reject(new Error("Cancelled."));
646
+ return;
647
+ }
648
+ if (key && (key.name === "down" || key.name === "j")) {
649
+ cursor = (cursor + 1) % ACTION_CHOICES.length;
650
+ render();
651
+ return;
652
+ }
653
+ if (key && (key.name === "up" || key.name === "k")) {
654
+ cursor = (cursor - 1 + ACTION_CHOICES.length) % ACTION_CHOICES.length;
655
+ render();
656
+ return;
657
+ }
658
+ if (key && (key.name === "space" || key.name === "return")) {
659
+ cleanup();
660
+ resolve(ACTION_CHOICES[cursor].action);
661
+ }
662
+ }
663
+
664
+ output.write("\x1b[?25l");
665
+ render();
666
+ input.on("keypress", onKeypress);
667
+ });
668
+ }
669
+
670
+ function renderRawActionMenu(cursor) {
671
+ const width = 76;
672
+ const line = `+${"-".repeat(width - 2)}+`;
673
+ const lines = [
674
+ line,
675
+ rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
676
+ rawHeaderLine("OpenHarmony Skills Manager", width, ANSI.dim),
677
+ line,
678
+ "",
679
+ colorize("Choose action", ANSI.bold),
680
+ colorize(" Up/Down or j/k: move Space/Enter: select Ctrl+C: cancel", ANSI.dim),
681
+ "",
682
+ ];
683
+ ACTION_CHOICES.forEach((choice, index) => {
684
+ const pointer = index === cursor ? ">" : " ";
685
+ const highlighted = index === cursor;
686
+ const row = `${pointer} ${rawCheckbox(highlighted, highlighted)} ${choice.label.padEnd(22, " ")} ${choice.hint}`;
687
+ lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
688
+ });
689
+ lines.push("");
690
+ return `${lines.join("\n")}\n`;
691
+ }
692
+
552
693
  function renderRawAgentMenu(cursor) {
553
694
  const width = 76;
554
695
  const line = `+${"-".repeat(width - 2)}+`;
@@ -564,7 +705,8 @@ function renderRawAgentMenu(cursor) {
564
705
  ];
565
706
  AGENT_CHOICES.forEach((choice, index) => {
566
707
  const pointer = index === cursor ? ">" : " ";
567
- const row = `${pointer} [ ] ${choice.label.padEnd(10, " ")} ${choice.hint}`;
708
+ const highlighted = index === cursor;
709
+ const row = `${pointer} ${rawCheckbox(highlighted, highlighted)} ${choice.label.padEnd(10, " ")} ${choice.hint}`;
568
710
  lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
569
711
  });
570
712
  lines.push("");
@@ -602,6 +744,85 @@ function renderRawTuiMenu(choices, cursor, selected, agent = "codex") {
602
744
  return `${lines.join("\n")}\n`;
603
745
  }
604
746
 
747
+ function runRawCleanSelection(input, output, skills) {
748
+ return new Promise((resolve, reject) => {
749
+ let cursor = 0;
750
+ const selected = new Set([cursor]);
751
+ const wasRaw = input.isRaw;
752
+
753
+ readline.emitKeypressEvents(input);
754
+ input.setRawMode(true);
755
+
756
+ function render() {
757
+ output.write("\x1b[2J\x1b[H");
758
+ output.write(renderRawCleanSelectionMenu(skills, cursor, selected));
759
+ }
760
+
761
+ function cleanup() {
762
+ input.removeListener("keypress", onKeypress);
763
+ input.setRawMode(Boolean(wasRaw));
764
+ output.write("\x1b[?25h");
765
+ }
766
+
767
+ function onKeypress(_str, key) {
768
+ if (key && key.ctrl && key.name === "c") {
769
+ cleanup();
770
+ reject(new Error("Cancelled."));
771
+ return;
772
+ }
773
+ if (key && (key.name === "down" || key.name === "j")) {
774
+ cursor = (cursor + 1) % skills.length;
775
+ render();
776
+ return;
777
+ }
778
+ if (key && (key.name === "up" || key.name === "k")) {
779
+ cursor = (cursor - 1 + skills.length) % skills.length;
780
+ render();
781
+ return;
782
+ }
783
+ if (key && key.name === "space") {
784
+ if (selected.has(cursor)) selected.delete(cursor);
785
+ else selected.add(cursor);
786
+ render();
787
+ return;
788
+ }
789
+ if (key && key.name === "return") {
790
+ cleanup();
791
+ resolve(selected.size ? Array.from(selected).sort((a, b) => a - b) : [cursor]);
792
+ }
793
+ }
794
+
795
+ output.write("\x1b[?25l");
796
+ render();
797
+ input.on("keypress", onKeypress);
798
+ });
799
+ }
800
+
801
+ function renderRawCleanSelectionMenu(skills, cursor, selected) {
802
+ const width = 92;
803
+ const line = `+${"-".repeat(width - 2)}+`;
804
+ const lines = [
805
+ line,
806
+ rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
807
+ rawHeaderLine("OpenHarmony Skills Cleaner", width, ANSI.dim),
808
+ line,
809
+ "",
810
+ colorize("Detected skills", ANSI.bold),
811
+ colorize(" Up/Down or j/k: move Space: select Enter: clean Ctrl+C: cancel", ANSI.dim),
812
+ "",
813
+ ];
814
+ skills.forEach((skill, index) => {
815
+ const pointer = index === cursor ? ">" : " ";
816
+ const highlighted = index === cursor;
817
+ const checkbox = rawCheckbox(selected.has(index), highlighted);
818
+ const group = `${skill.domain || "unknown"}/${skill.stage || "unknown"}`;
819
+ const row = `${pointer} ${checkbox} ${skill.name.padEnd(36, " ")} ${skill.status.padEnd(9, " ")} ${group}`;
820
+ lines.push(highlighted ? colorize(row, ANSI.reverse, ANSI.bold) : row);
821
+ });
822
+ lines.push("");
823
+ return `${lines.join("\n")}\n`;
824
+ }
825
+
605
826
  function colorize(value, ...codes) {
606
827
  return `${codes.join("")}${value}${ANSI.reset}`;
607
828
  }
@@ -661,6 +882,15 @@ function parseSingleSelection(answer, max, defaultNumber) {
661
882
  return indexes[0];
662
883
  }
663
884
 
885
+ function isSingleSelection(answer, max) {
886
+ try {
887
+ parseSingleSelection(answer, max, 1);
888
+ return true;
889
+ } catch {
890
+ return false;
891
+ }
892
+ }
893
+
664
894
  function selectSkillsForChoices(manifest, choices) {
665
895
  const selected = new Map();
666
896
  for (const choice of choices) {
@@ -774,7 +1004,7 @@ function helpText() {
774
1004
  return [
775
1005
  "oh-skillhub",
776
1006
  "",
777
- "Run without arguments to choose an agent target and skill domain interactively.",
1007
+ "Run without arguments to choose install or clean interactively.",
778
1008
  "",
779
1009
  "Commands:",
780
1010
  " list [--domain <name>] [--stage <name>]",
@@ -798,8 +1028,11 @@ module.exports = {
798
1028
  renderTelemetry,
799
1029
  buildRepositoryChoices,
800
1030
  renderAgentMenu,
1031
+ renderActionMenu,
801
1032
  renderCleanPlan,
1033
+ renderRawActionMenu,
802
1034
  renderRawAgentMenu,
1035
+ renderRawCleanSelectionMenu,
803
1036
  parseSelection,
804
1037
  renderRawTuiMenu,
805
1038
  renderTuiMenu,