oh-skillhub 0.1.19 → 0.1.21

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/package.json +1 -1
  2. package/src/cli.js +64 -17
  3. package/src/manifest.js +231 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-skillhub",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "OpenHarmony Skills installer for Codex, Claude Code, and OpenCode.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -6,7 +6,7 @@ const readlinePromises = require("node:readline/promises");
6
6
 
7
7
  const { resolveAgentTargets } = require("./agents");
8
8
  const { applyCleanPlan, planClean, scanInstalledSkills } = require("./cleaner");
9
- const { loadLocalManifest, loadProfiles, selectSkills } = require("./manifest");
9
+ const { loadLocalManifest, loadProfiles, loadRuntimeManifest, selectSkills } = require("./manifest");
10
10
  const { applyInstallPlan, planInstall } = require("./planner");
11
11
  const { ensureSkillSourceRoot, ensureSkillSourceRootAsync } = require("./source");
12
12
  const { buildTelemetryEvent, enqueueTelemetryEvent, telemetryStatus } = require("./telemetry");
@@ -368,7 +368,7 @@ async function runInteractiveInstaller(input = process.stdin, output = process.s
368
368
  return;
369
369
  }
370
370
 
371
- const manifest = loadLocalManifest();
371
+ const manifest = await loadInteractiveManifest(input, output);
372
372
  const choices = buildRepositoryChoices(manifest);
373
373
  const agent = await runRawAgentSelection(input, output);
374
374
  const selectedIndexes = await runRawTuiSelection(input, output, choices, agent);
@@ -376,7 +376,7 @@ async function runInteractiveInstaller(input = process.stdin, output = process.s
376
376
  }
377
377
 
378
378
  async function runInteractiveInstallerFromAnswers(answers, output = process.stdout) {
379
- const manifest = loadLocalManifest();
379
+ const manifest = await loadRuntimeManifest({ env: process.env });
380
380
  const choices = buildRepositoryChoices(manifest);
381
381
  const [agentAnswer, groupAnswer] = answers.length > 1 ? [answers[0], answers.slice(1).join(" ")] : ["", answers[0] || ""];
382
382
  output.write(renderAgentMenu());
@@ -388,6 +388,13 @@ async function runInteractiveInstallerFromAnswers(answers, output = process.stdo
388
388
  await installInteractiveSelection(manifest, choices, agent, selectedIndexes, output);
389
389
  }
390
390
 
391
+ async function loadInteractiveManifest(_input, output) {
392
+ if (output.isTTY) {
393
+ return withSpinner(output, "Refreshing skill catalog", () => loadRuntimeManifest({ env: process.env }));
394
+ }
395
+ return loadRuntimeManifest({ env: process.env });
396
+ }
397
+
391
398
  async function installInteractiveSelection(manifest, choices, agent, selectedIndexes, output) {
392
399
  const selectedChoices = selectedIndexes.map((index) => choices[index]);
393
400
  const skills = selectSkillsForChoices(manifest, selectedChoices);
@@ -555,14 +562,16 @@ async function runPromptSelection(input, output, choices, agent = "codex", exist
555
562
  function runRawTuiSelection(input, output, choices, agent = "codex") {
556
563
  return new Promise((resolve, reject) => {
557
564
  let cursor = 8;
558
- const selected = new Set([cursor]);
565
+ const selected = new Set();
566
+ let notice = "";
559
567
  const wasRaw = input.isRaw;
560
568
 
561
569
  prepareRawInput(input);
562
570
 
563
571
  function render() {
564
572
  output.write("\x1b[2J\x1b[H");
565
- output.write(renderRawTuiMenu(choices, cursor, selected, agent));
573
+ output.write(renderRawTuiMenu(choices, cursor, selected, agent, notice));
574
+ notice = "";
566
575
  }
567
576
 
568
577
  function cleanup() {
@@ -594,8 +603,13 @@ function runRawTuiSelection(input, output, choices, agent = "codex") {
594
603
  return;
595
604
  }
596
605
  if (key && key.name === "return") {
606
+ if (!selected.size) {
607
+ notice = "Select at least one group with Space.";
608
+ render();
609
+ return;
610
+ }
597
611
  cleanup();
598
- resolve(selected.size ? Array.from(selected).sort((a, b) => a - b) : [cursor]);
612
+ resolve(Array.from(selected).sort((a, b) => a - b));
599
613
  }
600
614
  }
601
615
 
@@ -608,14 +622,16 @@ function runRawTuiSelection(input, output, choices, agent = "codex") {
608
622
  function runRawAgentSelection(input, output) {
609
623
  return new Promise((resolve, reject) => {
610
624
  let cursor = 0;
611
- const selected = new Set([0]);
625
+ const selected = new Set();
626
+ let notice = "";
612
627
  const wasRaw = input.isRaw;
613
628
 
614
629
  prepareRawInput(input);
615
630
 
616
631
  function render() {
617
632
  output.write("\x1b[2J\x1b[H");
618
- output.write(renderRawAgentMenu(cursor, selected));
633
+ output.write(renderRawAgentMenu(cursor, selected, notice));
634
+ notice = "";
619
635
  }
620
636
 
621
637
  function cleanup() {
@@ -646,6 +662,11 @@ function runRawAgentSelection(input, output) {
646
662
  return;
647
663
  }
648
664
  if (key && key.name === "return") {
665
+ if (!normalizeAgentIndexes(selected).size) {
666
+ notice = "Select at least one target with Space.";
667
+ render();
668
+ return;
669
+ }
649
670
  cleanup();
650
671
  resolve(agentSelectionFromIndexes(selected, cursor));
651
672
  }
@@ -660,14 +681,16 @@ function runRawAgentSelection(input, output) {
660
681
  function runRawActionSelection(input, output) {
661
682
  return new Promise((resolve, reject) => {
662
683
  let cursor = 0;
663
- let selected = 0;
684
+ let selected = null;
685
+ let notice = "";
664
686
  const wasRaw = input.isRaw;
665
687
 
666
688
  prepareRawInput(input);
667
689
 
668
690
  function render() {
669
691
  output.write("\x1b[2J\x1b[H");
670
- output.write(renderRawActionMenu(cursor, selected));
692
+ output.write(renderRawActionMenu(cursor, selected, notice));
693
+ notice = "";
671
694
  }
672
695
 
673
696
  function cleanup() {
@@ -698,6 +721,11 @@ function runRawActionSelection(input, output) {
698
721
  return;
699
722
  }
700
723
  if (key && key.name === "return") {
724
+ if (selected === null) {
725
+ notice = "Select an action with Space.";
726
+ render();
727
+ return;
728
+ }
701
729
  cleanup();
702
730
  resolve(ACTION_CHOICES[selected].action);
703
731
  }
@@ -709,7 +737,7 @@ function runRawActionSelection(input, output) {
709
737
  });
710
738
  }
711
739
 
712
- function renderRawActionMenu(cursor, selected = cursor) {
740
+ function renderRawActionMenu(cursor, selected = null, notice = "") {
713
741
  const width = 76;
714
742
  const line = `+${"-".repeat(width - 2)}+`;
715
743
  const lines = [
@@ -728,6 +756,9 @@ function renderRawActionMenu(cursor, selected = cursor) {
728
756
  const row = `${pointer} ${rawCheckbox(index === selected, highlighted)} ${choice.label.padEnd(22, " ")} ${choice.hint}`;
729
757
  lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
730
758
  });
759
+ if (notice) {
760
+ lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
761
+ }
731
762
  lines.push("");
732
763
  return `${lines.join("\n")}\n`;
733
764
  }
@@ -749,7 +780,7 @@ function releaseRawInput(input, wasRaw) {
749
780
  }
750
781
  }
751
782
 
752
- function renderRawAgentMenu(cursor, selected = new Set([cursor])) {
783
+ function renderRawAgentMenu(cursor, selected = new Set(), notice = "") {
753
784
  const width = 76;
754
785
  const line = `+${"-".repeat(width - 2)}+`;
755
786
  const selectedIndexes = normalizeAgentIndexes(selected);
@@ -769,11 +800,14 @@ function renderRawAgentMenu(cursor, selected = new Set([cursor])) {
769
800
  const row = `${pointer} ${rawCheckbox(isAgentIndexSelected(selectedIndexes, index), highlighted)} ${choice.label.padEnd(10, " ")} ${choice.hint}`;
770
801
  lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
771
802
  });
803
+ if (notice) {
804
+ lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
805
+ }
772
806
  lines.push("");
773
807
  return `${lines.join("\n")}\n`;
774
808
  }
775
809
 
776
- function renderRawTuiMenu(choices, cursor, selected, agent = "codex") {
810
+ function renderRawTuiMenu(choices, cursor, selected, agent = "codex", notice = "") {
777
811
  const width = 76;
778
812
  const line = `+${"-".repeat(width - 2)}+`;
779
813
  const lines = [
@@ -800,6 +834,9 @@ function renderRawTuiMenu(choices, cursor, selected, agent = "codex") {
800
834
  lines.push(colorize(` - ${leaf}`, ANSI.dim));
801
835
  }
802
836
  });
837
+ if (notice) {
838
+ lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
839
+ }
803
840
  lines.push("");
804
841
  return `${lines.join("\n")}\n`;
805
842
  }
@@ -807,14 +844,16 @@ function renderRawTuiMenu(choices, cursor, selected, agent = "codex") {
807
844
  function runRawCleanSelection(input, output, skills) {
808
845
  return new Promise((resolve, reject) => {
809
846
  let cursor = 0;
810
- const selected = new Set([cursor]);
847
+ const selected = new Set();
848
+ let notice = "";
811
849
  const wasRaw = input.isRaw;
812
850
 
813
851
  prepareRawInput(input);
814
852
 
815
853
  function render() {
816
854
  output.write("\x1b[2J\x1b[H");
817
- output.write(renderRawCleanSelectionMenu(skills, cursor, selected));
855
+ output.write(renderRawCleanSelectionMenu(skills, cursor, selected, notice));
856
+ notice = "";
818
857
  }
819
858
 
820
859
  function cleanup() {
@@ -846,8 +885,13 @@ function runRawCleanSelection(input, output, skills) {
846
885
  return;
847
886
  }
848
887
  if (key && key.name === "return") {
888
+ if (!selected.size) {
889
+ notice = "Select at least one skill with Space.";
890
+ render();
891
+ return;
892
+ }
849
893
  cleanup();
850
- resolve(selected.size ? Array.from(selected).sort((a, b) => a - b) : [cursor]);
894
+ resolve(Array.from(selected).sort((a, b) => a - b));
851
895
  }
852
896
  }
853
897
 
@@ -857,7 +901,7 @@ function runRawCleanSelection(input, output, skills) {
857
901
  });
858
902
  }
859
903
 
860
- function renderRawCleanSelectionMenu(skills, cursor, selected) {
904
+ function renderRawCleanSelectionMenu(skills, cursor, selected, notice = "") {
861
905
  const width = 92;
862
906
  const line = `+${"-".repeat(width - 2)}+`;
863
907
  const lines = [
@@ -878,6 +922,9 @@ function renderRawCleanSelectionMenu(skills, cursor, selected) {
878
922
  const row = `${pointer} ${checkbox} ${skill.name.padEnd(36, " ")} ${skill.status.padEnd(9, " ")} ${group}`;
879
923
  lines.push(highlighted ? colorize(row, ANSI.reverse, ANSI.bold) : row);
880
924
  });
925
+ if (notice) {
926
+ lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
927
+ }
881
928
  lines.push("");
882
929
  return `${lines.join("\n")}\n`;
883
930
  }
package/src/manifest.js CHANGED
@@ -1,13 +1,242 @@
1
+ const { spawn } = require("node:child_process");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
1
4
  const path = require("node:path");
2
5
 
3
6
  function loadLocalManifest() {
4
7
  return require(path.join(__dirname, "data", "manifest.json"));
5
8
  }
6
9
 
10
+ async function loadRuntimeManifest(options = {}) {
11
+ const env = options.env || process.env;
12
+ const bundled = loadLocalManifest();
13
+ if (options.offline || env.OH_SKILLHUB_OFFLINE === "1") {
14
+ return loadCachedManifest(env) || bundled;
15
+ }
16
+
17
+ try {
18
+ const manifest = env.OH_SKILLHUB_SOURCE_DIR
19
+ ? buildManifestFromSourceTree(env.OH_SKILLHUB_SOURCE_DIR, bundled)
20
+ : await buildManifestFromGit(bundled, { env });
21
+ writeCachedManifest(manifest, env);
22
+ return manifest;
23
+ } catch (error) {
24
+ if (env.OH_SKILLHUB_SOURCE_DIR) {
25
+ throw error;
26
+ }
27
+ return loadCachedManifest(env) || bundled;
28
+ }
29
+ }
30
+
7
31
  function loadProfiles() {
8
32
  return require(path.join(__dirname, "data", "profiles.json"));
9
33
  }
10
34
 
35
+ function buildManifestFromSourceTree(sourceRoot, baseManifest = loadLocalManifest()) {
36
+ const root = path.resolve(sourceRoot);
37
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
38
+ throw new Error(`OH_SKILLHUB_SOURCE_DIR is not a directory: ${root}`);
39
+ }
40
+ const skillFiles = findSkillFiles(path.join(root, "skills"));
41
+ if (!skillFiles.length) {
42
+ throw new Error(`No SKILL.md files found in source tree: ${root}`);
43
+ }
44
+ return buildManifestFromSkillFiles(root, skillFiles, baseManifest);
45
+ }
46
+
47
+ async function buildManifestFromGit(baseManifest, options = {}) {
48
+ const source = baseManifest.source;
49
+ const ref = baseManifest.ref || "release";
50
+ const env = options.env || process.env;
51
+ const cacheRoot = env.OH_SKILLHUB_CACHE_DIR || path.join(os.homedir(), ".oh-skillhub", "cache");
52
+ fs.mkdirSync(cacheRoot, { recursive: true });
53
+ const tempCheckout = path.join(cacheRoot, `.manifest-${Date.now()}-${Math.random().toString(16).slice(2)}`);
54
+ try {
55
+ const clone = await runGitAsync([
56
+ "-c",
57
+ "core.longpaths=true",
58
+ "clone",
59
+ "--depth",
60
+ "1",
61
+ "--branch",
62
+ ref,
63
+ "--no-checkout",
64
+ source,
65
+ tempCheckout,
66
+ ]);
67
+ if (clone.status !== 0) {
68
+ throw new Error(gitDetail(clone) || `Failed to clone ${source}#${ref}`);
69
+ }
70
+ const tree = await runGitAsync(["-C", tempCheckout, "ls-tree", "-r", "--name-only", "HEAD", "skills"]);
71
+ if (tree.status !== 0) {
72
+ throw new Error(gitDetail(tree) || "Failed to list remote skills tree.");
73
+ }
74
+ const skillFiles = tree.stdout
75
+ .split(/\r?\n/)
76
+ .filter((item) => item.endsWith("/SKILL.md"))
77
+ .sort();
78
+ if (!skillFiles.length) {
79
+ throw new Error("Remote skills tree does not contain SKILL.md files.");
80
+ }
81
+ const loadedFiles = [];
82
+ for (const relativePath of skillFiles) {
83
+ const show = await runGitAsync(["-C", tempCheckout, "show", `HEAD:${relativePath}`]);
84
+ if (show.status === 0) {
85
+ loadedFiles.push({ relativePath, contents: show.stdout });
86
+ }
87
+ }
88
+ if (!loadedFiles.length) {
89
+ throw new Error("Remote skills tree could not load SKILL.md files.");
90
+ }
91
+ return buildManifestFromSkillFiles(null, loadedFiles, baseManifest);
92
+ } finally {
93
+ fs.rmSync(tempCheckout, { recursive: true, force: true });
94
+ }
95
+ }
96
+
97
+ function buildManifestFromSkillFiles(root, skillFiles, baseManifest) {
98
+ const byPath = new Map((baseManifest.skills || []).map((skill) => [skill.path, skill]));
99
+ const byName = new Map((baseManifest.skills || []).map((skill) => [skill.name, skill]));
100
+ const skills = [];
101
+ for (const item of skillFiles) {
102
+ const relativePath = typeof item === "string" ? toPosixPath(path.relative(root, item)) : item.relativePath;
103
+ const contents = typeof item === "string" ? fs.readFileSync(item, "utf8") : item.contents;
104
+ const skillPath = relativePath.replace(/\/SKILL\.md$/, "");
105
+ const parsed = parseSkillFile(contents);
106
+ const inferred = inferSkillFromPath(skillPath, parsed);
107
+ const base = byPath.get(skillPath) || byName.get(inferred.name) || {};
108
+ skills.push({
109
+ name: parsed.name || base.name || inferred.name,
110
+ scope: inferred.scope || base.scope || "domain",
111
+ domain: parsed.domain || base.domain || inferred.domain || "unknown",
112
+ stage: parsed.stage || base.stage || inferred.stage || "unknown",
113
+ path: skillPath,
114
+ description: parsed.description || base.description || `Use ${parsed.name || inferred.name}.`,
115
+ version: parsed.version || base.version || "0.1.0",
116
+ status: parsed.status || base.status || "stable",
117
+ tags: parsed.tags || base.tags || [],
118
+ });
119
+ }
120
+ return {
121
+ source: baseManifest.source,
122
+ ref: baseManifest.ref || "release",
123
+ generatedAt: new Date().toISOString(),
124
+ skills: skills.sort((left, right) => left.path.localeCompare(right.path)),
125
+ };
126
+ }
127
+
128
+ function findSkillFiles(root) {
129
+ if (!fs.existsSync(root)) {
130
+ return [];
131
+ }
132
+ const found = [];
133
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
134
+ const fullPath = path.join(root, entry.name);
135
+ if (entry.isDirectory()) {
136
+ found.push(...findSkillFiles(fullPath));
137
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
138
+ found.push(fullPath);
139
+ }
140
+ }
141
+ return found.sort();
142
+ }
143
+
144
+ function parseSkillFile(contents) {
145
+ const frontmatter = contents.match(/^---\r?\n([\s\S]*?)\r?\n---/);
146
+ if (!frontmatter) {
147
+ return {};
148
+ }
149
+ const parsed = {};
150
+ let inMetadata = false;
151
+ for (const line of frontmatter[1].split(/\r?\n/)) {
152
+ if (/^\s*metadata\s*:\s*$/.test(line)) {
153
+ inMetadata = true;
154
+ continue;
155
+ }
156
+ const nested = line.match(/^\s+([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
157
+ if (inMetadata && nested) {
158
+ parsed[nested[1]] = cleanYamlValue(nested[2]);
159
+ continue;
160
+ }
161
+ if (/^\S/.test(line)) {
162
+ inMetadata = false;
163
+ }
164
+ const topLevel = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
165
+ if (topLevel) {
166
+ parsed[topLevel[1]] = cleanYamlValue(topLevel[2]);
167
+ }
168
+ }
169
+ return parsed;
170
+ }
171
+
172
+ function cleanYamlValue(value) {
173
+ return value.trim().replace(/^['"]|['"]$/g, "");
174
+ }
175
+
176
+ function inferSkillFromPath(skillPath, parsed = {}) {
177
+ const parts = skillPath.split("/");
178
+ const name = parsed.name || parts[parts.length - 1];
179
+ if (parts[1] === "common") {
180
+ return { name, scope: "common", stage: parts[2] || parsed.stage || "unknown", domain: parsed.domain || "unknown" };
181
+ }
182
+ if (parts[1] === "domain") {
183
+ return { name, scope: "domain", domain: parts[2] || parsed.domain || "unknown", stage: parsed.stage || parts[3] || "unknown" };
184
+ }
185
+ return { name, scope: parsed.scope || "domain", domain: parsed.domain || "unknown", stage: parsed.stage || "unknown" };
186
+ }
187
+
188
+ function runtimeManifestCachePath(env = process.env) {
189
+ return env.OH_SKILLHUB_MANIFEST_CACHE || path.join(env.OH_SKILLHUB_CACHE_DIR || path.join(os.homedir(), ".oh-skillhub", "cache"), "manifest-release.json");
190
+ }
191
+
192
+ function loadCachedManifest(env = process.env) {
193
+ const file = runtimeManifestCachePath(env);
194
+ if (!fs.existsSync(file)) {
195
+ return null;
196
+ }
197
+ try {
198
+ return JSON.parse(fs.readFileSync(file, "utf8"));
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function writeCachedManifest(manifest, env = process.env) {
205
+ const file = runtimeManifestCachePath(env);
206
+ fs.mkdirSync(path.dirname(file), { recursive: true });
207
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
208
+ }
209
+
210
+ function toPosixPath(value) {
211
+ return value.split(path.sep).join("/");
212
+ }
213
+
214
+ function runGitAsync(args) {
215
+ return new Promise((resolve) => {
216
+ const child = spawn("git", args, { shell: false });
217
+ let stdout = "";
218
+ let stderr = "";
219
+ child.stdout.setEncoding("utf8");
220
+ child.stderr.setEncoding("utf8");
221
+ child.stdout.on("data", (chunk) => {
222
+ stdout += chunk;
223
+ });
224
+ child.stderr.on("data", (chunk) => {
225
+ stderr += chunk;
226
+ });
227
+ child.on("error", (error) => {
228
+ resolve({ status: 1, stdout, stderr: `${stderr}${error.message}` });
229
+ });
230
+ child.on("close", (status) => {
231
+ resolve({ status, stdout, stderr });
232
+ });
233
+ });
234
+ }
235
+
236
+ function gitDetail(result) {
237
+ return (result.stderr || result.stdout || "").trim();
238
+ }
239
+
11
240
  function selectSkills(manifest, profiles, options = {}) {
12
241
  const selected = new Map();
13
242
  const unknownNames = [];
@@ -88,7 +317,9 @@ function selectSkills(manifest, profiles, options = {}) {
88
317
  }
89
318
 
90
319
  module.exports = {
320
+ buildManifestFromSourceTree,
91
321
  loadLocalManifest,
92
322
  loadProfiles,
323
+ loadRuntimeManifest,
93
324
  selectSkills,
94
325
  };