@lgtm-hq/ai-skills 0.5.4 → 0.6.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.
@@ -1,4 +1,5 @@
1
1
  import { loadVendors } from "./catalog.js";
2
+ import { getPackageVersion } from "./package-version.js";
2
3
  import { pruneMissingLockEntries, readLockfile, writeLockfile } from "./lockfile.js";
3
4
  import { resolveScope } from "./options.js";
4
5
  import { buildSkillsArguments, buildSkillsRemoveArguments, runSkills } from "./skills-runner.js";
@@ -60,7 +61,7 @@ export async function updateSkills(options, dependencies = {}) {
60
61
  );
61
62
  await writeLock({
62
63
  ...prunedLock,
63
- gatewayVersion: process.env.npm_package_version ?? "0.0.0-dev",
64
+ gatewayVersion: getPackageVersion(),
64
65
  skills,
65
66
  });
66
67
  return { pruned, updated };
@@ -147,7 +148,7 @@ function resolveSources(skills, vendors) {
147
148
  for (const [name, entry] of Object.entries(skills)) {
148
149
  const source =
149
150
  entry.vendor === "lgtm-hq"
150
- ? `lgtm-hq/ai-skills@v${process.env.npm_package_version ?? "0.0.0-dev"}`
151
+ ? `lgtm-hq/ai-skills@v${getPackageVersion()}`
151
152
  : resolveVendorSource(entry, vendors);
152
153
  sources.set(source, [...(sources.get(source) ?? []), name]);
153
154
  }
@@ -180,7 +181,7 @@ function resolveVendorSource(entry, vendors) {
180
181
  */
181
182
  function sourceSha(vendor, currentSha, vendors) {
182
183
  if (vendor === "lgtm-hq") {
183
- return `v${process.env.npm_package_version ?? "0.0.0-dev"}`;
184
+ return `v${getPackageVersion()}`;
184
185
  }
185
186
  return vendors.find((candidate) => candidate.id === vendor)?.sha ?? currentSha;
186
187
  }
package/lib/install.js CHANGED
@@ -1,107 +1,167 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin, stdout } from "node:process";
3
-
4
1
  import { loadBundles, loadVendorIndex, loadVendors } from "./catalog.js";
5
2
  import { mergeLockEntries, readLockfile, writeLockfile } from "./lockfile.js";
6
3
  import { resolveScope } from "./options.js";
4
+ import { getPackageVersion } from "./package-version.js";
7
5
  import { buildSkillsArguments, runSkills } from "./skills-runner.js";
8
-
9
- /**
10
- * Prompt for a numbered item.
11
- *
12
- * @template T
13
- * @param {string} label - Prompt heading.
14
- * @param {T[]} items - Selectable values.
15
- * @param {(item: T) => string} describe - Human-readable item formatter.
16
- * @param {{question: (query: string) => Promise<string>}} prompt - Interactive prompt.
17
- * @returns {Promise<T>} Selected value.
18
- */
19
- async function choose(label, items, describe, prompt) {
20
- stdout.write(`${label}\n`);
21
- items.forEach((item, index) => stdout.write(` ${index + 1}. ${describe(item)}\n`));
22
- const response = await prompt.question("> ");
23
- const selection = Number.parseInt(response, 10) - 1;
24
- if (!Number.isInteger(selection) || !items[selection]) {
25
- throw new Error("Choose a listed number");
26
- }
27
- return items[selection];
28
- }
6
+ import { createClackUi, KNOWN_AGENTS } from "./ui.js";
29
7
 
30
8
  /**
31
9
  * Fill unset install selections through the terminal picker.
32
10
  *
11
+ * Happy path asks for catalog source (+ vendor skill) and agents. Scope defaults
12
+ * to global, installs symlink (not copy), and uses overwrite for the gateway
13
+ * fail-closed `--on-conflict` API without prompting — upstream `skills` does
14
+ * not implement conflict policies yet.
15
+ *
33
16
  * @param {{agents: string[], bundle: string | null, copy: boolean, global: boolean, onConflict: string | null, project: boolean, skills: string[], vendor: string | null, yes: boolean}} options - Initial install options.
34
- * @param {{question: (query: string) => Promise<string>}} prompt - Interactive prompt.
17
+ * @param {ReturnType<typeof createClackUi>} [ui] - Injectable interactive UI.
35
18
  * @returns {Promise<typeof options>} Fully selected options.
36
19
  */
37
- export async function completeInteractively(options, prompt) {
20
+ export async function completeInteractively(options, ui = createClackUi()) {
21
+ ui.intro("ai-skills gateway");
22
+
38
23
  const bundles = await loadBundles();
39
24
  const vendors = await loadVendors();
40
- const source = await choose(
41
- "Choose a first-party bundle or vendor catalog:",
42
- [
43
- ...Object.entries(bundles.groups).map(([id, bundle]) => ({
44
- id,
45
- kind: "bundle",
46
- label: `${bundle.name} — ${bundle.description}`,
47
- })),
48
- ...vendors.vendors.map((vendor) => ({
49
- id: vendor.id,
50
- kind: "vendor",
51
- label: `${vendor.repo} @ ${vendor.sha.slice(0, 12)}`,
52
- })),
53
- ],
54
- (item) => item.label,
55
- prompt,
25
+ const sourceChoices = [
26
+ ...Object.entries(bundles.groups).map(([id, bundle]) => ({
27
+ value: `bundle:${id}`,
28
+ label: `${bundle.name} — ${bundle.description}`,
29
+ })),
30
+ ...vendors.vendors.map((vendor) => ({
31
+ value: `vendor:${vendor.id}`,
32
+ label: `${vendor.repo} @ ${vendor.sha.slice(0, 12)}`,
33
+ })),
34
+ ];
35
+
36
+ const sourceValue = await cancelable(
37
+ ui,
38
+ ui.select({
39
+ message: "Install from which catalog?",
40
+ options: sourceChoices,
41
+ }),
56
42
  );
43
+ const [sourceKind, sourceId] = splitSourceValue(sourceValue);
57
44
 
58
- if (source.kind === "bundle") {
59
- const bundle = bundles.groups[source.id];
60
- options.bundle = source.id;
45
+ if (sourceKind === "bundle") {
46
+ const bundle = bundles.groups[sourceId];
47
+ options.bundle = sourceId;
61
48
  options.skills = bundle.skills;
62
49
  } else {
63
- const index = await loadVendorIndex(source.id);
64
- const skill = await choose(
65
- `Choose a skill from ${index.vendor.repo}:`,
66
- index.skills,
67
- (item) => item.name,
68
- prompt,
50
+ const index = await loadVendorIndex(sourceId);
51
+ const skillName = await cancelable(
52
+ ui,
53
+ ui.select({
54
+ message: `Skill from ${index.vendor.repo}`,
55
+ options: index.skills.map((skill) => ({
56
+ value: skill.name,
57
+ label: skill.name,
58
+ })),
59
+ }),
69
60
  );
70
- options.vendor = source.id;
71
- options.skills = [skill.name];
61
+ options.vendor = sourceId;
62
+ options.skills = [skillName];
72
63
  }
73
64
 
74
65
  if (options.agents.length === 0) {
75
- const agents = await prompt.question(
76
- "Agent(s), comma-separated (blank lets skills CLI detect): ",
77
- );
78
- options.agents = agents
79
- .split(",")
80
- .map((agent) => agent.trim())
81
- .filter(Boolean);
82
- }
83
- if (!options.copy) {
84
- options.copy = (await prompt.question("Copy files instead of symlink? [y/N] "))
85
- .trim()
86
- .toLowerCase()
87
- .startsWith("y");
88
- }
89
- if (!options.onConflict) {
90
- options.onConflict = await choose(
91
- "When an installed skill conflicts:",
92
- ["keep", "overwrite", "skip"],
93
- (item) => item,
94
- prompt,
66
+ const selected = await cancelable(
67
+ ui,
68
+ ui.multiselect({
69
+ message: "Install into which agents?",
70
+ options: [
71
+ ...KNOWN_AGENTS,
72
+ {
73
+ value: "__detect__",
74
+ label: "Detect installed agents (may target many tools)",
75
+ },
76
+ ],
77
+ initialValues: KNOWN_AGENTS.map((agent) => agent.value),
78
+ required: true,
79
+ }),
95
80
  );
81
+ if (selected.length === 1 && selected[0] === "__detect__") {
82
+ options.agents = [];
83
+ ui.note(
84
+ "Leaving agents unset so the upstream skills CLI can detect targets.",
85
+ "Agent detection",
86
+ );
87
+ } else {
88
+ options.agents = selected.filter((agent) => agent !== "__detect__");
89
+ }
96
90
  }
91
+
97
92
  if (!options.global && !options.project) {
98
- const scope = await choose("Install scope:", ["global", "project"], (item) => item, prompt);
93
+ const scope = await cancelable(
94
+ ui,
95
+ ui.select({
96
+ message: "Install scope",
97
+ options: [
98
+ { value: "global", label: "Global (user home — recommended)" },
99
+ { value: "project", label: "Project (this repository)" },
100
+ ],
101
+ initialValue: "global",
102
+ }),
103
+ );
99
104
  options.global = scope === "global";
100
105
  options.project = scope === "project";
101
106
  }
107
+
108
+ // Symlink is the default; only offer copy as an advanced opt-in.
109
+ if (!options.copy) {
110
+ const advanced = await cancelable(
111
+ ui,
112
+ ui.confirm({
113
+ message: "Show advanced options (copy files instead of symlink)?",
114
+ initialValue: false,
115
+ }),
116
+ );
117
+ if (advanced) {
118
+ options.copy = await cancelable(
119
+ ui,
120
+ ui.confirm({
121
+ message: "Copy files into each agent instead of symlinking from ~/.agents/skills?",
122
+ initialValue: false,
123
+ }),
124
+ );
125
+ }
126
+ }
127
+
128
+ if (!options.onConflict) {
129
+ // Gateway still requires an explicit policy for -y; interactive defaults to
130
+ // overwrite so we do not ask a jargon question for a no-op upstream flag.
131
+ options.onConflict = "overwrite";
132
+ }
133
+
134
+ ui.outro("Starting install…");
102
135
  return options;
103
136
  }
104
137
 
138
+ /**
139
+ * Split a `kind:id` catalog selection value.
140
+ *
141
+ * @param {string} value - Combined select value.
142
+ * @returns {[string, string]} Kind and id.
143
+ */
144
+ function splitSourceValue(value) {
145
+ const separator = value.indexOf(":");
146
+ return [value.slice(0, separator), value.slice(separator + 1)];
147
+ }
148
+
149
+ /**
150
+ * Abort when Clack reports cancel (Ctrl+C / escape).
151
+ *
152
+ * @template T
153
+ * @param {ReturnType<typeof createClackUi>} ui - UI adapter.
154
+ * @param {Promise<T>} valuePromise - Pending prompt result.
155
+ * @returns {Promise<Exclude<T, symbol>>} Resolved non-cancel value.
156
+ */
157
+ async function cancelable(ui, valuePromise) {
158
+ const value = await valuePromise;
159
+ if (ui.isCancel(value)) {
160
+ throw new Error("Install cancelled");
161
+ }
162
+ return /** @type {Exclude<T, symbol>} */ (value);
163
+ }
164
+
105
165
  /**
106
166
  * Install a first-party bundle or SHA-pinned vendor skill.
107
167
  *
@@ -112,6 +172,11 @@ export async function completeInteractively(options, prompt) {
112
172
  * @returns {Promise<void>} Resolves when the skills CLI succeeds.
113
173
  */
114
174
  export async function install(options, run = runSkills, now = () => new Date(), lockEnvironment) {
175
+ if (options.onConflict && options.onConflict !== "overwrite") {
176
+ throw new Error(
177
+ `--on-conflict=${options.onConflict} is unsupported: upstream skills CLI has no conflict policy. Omit the flag, use overwrite, or remove the existing skill first.`,
178
+ );
179
+ }
115
180
  let source;
116
181
  let selectedOptions = options;
117
182
  let vendor;
@@ -141,7 +206,7 @@ export async function install(options, run = runSkills, now = () => new Date(),
141
206
  skills: bundle.skills,
142
207
  };
143
208
  }
144
- const packageVersion = process.env.npm_package_version ?? "0.0.0-dev";
209
+ const packageVersion = getPackageVersion();
145
210
  source = `lgtm-hq/ai-skills@v${packageVersion}`;
146
211
  }
147
212
  const scope = resolveScope(selectedOptions);
@@ -184,7 +249,7 @@ async function createLockEntries(options, vendor, now) {
184
249
  agents: options.agents,
185
250
  installedAt,
186
251
  repo: "lgtm-hq/ai-skills",
187
- sha: `v${process.env.npm_package_version ?? "0.0.0-dev"}`,
252
+ sha: `v${getPackageVersion()}`,
188
253
  skillPath: `skills/${name}/SKILL.md`,
189
254
  vendor: "lgtm-hq",
190
255
  },
@@ -221,10 +286,5 @@ async function createLockEntries(options, vendor, now) {
221
286
  * @returns {Promise<void>} Resolves when installation completes.
222
287
  */
223
288
  export async function installInteractively(options) {
224
- const prompt = createInterface({ input: stdin, output: stdout });
225
- try {
226
- await install(await completeInteractively(options, prompt));
227
- } finally {
228
- prompt.close();
229
- }
289
+ await install(await completeInteractively(options));
230
290
  }
package/lib/lockfile.js CHANGED
@@ -2,6 +2,8 @@ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
+ import { getPackageVersion } from "./package-version.js";
6
+
5
7
  const LOCKFILE_VERSION = 1;
6
8
 
7
9
  const AGENT_SKILL_PATHS = {
@@ -156,7 +158,7 @@ export async function isSkillInstalled(name, entry, scope, environment = {}) {
156
158
  */
157
159
  function createLockfile(scope) {
158
160
  return {
159
- gatewayVersion: process.env.npm_package_version ?? "0.0.0-dev",
161
+ gatewayVersion: getPackageVersion(),
160
162
  scope,
161
163
  skills: {},
162
164
  version: LOCKFILE_VERSION,
package/lib/options.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Minimum supported version of the upstream skills CLI.
2
+ * Minimum supported version of the upstream skills CLI (npm package `skills`).
3
3
  *
4
- * Keeping this value in the wrapper makes every invocation reproducible within
5
- * a compatible major version while allowing upstream patches.
4
+ * Pin a real published 1.x floor so `bunx skills@^…` resolves. Upstream is on
5
+ * the 1.x line (there is no 0.16.0). Caret allows compatible 1.x patches.
6
6
  */
7
- export const MINIMUM_SKILLS_VERSION = "0.16.0";
7
+ export const MINIMUM_SKILLS_VERSION = "1.5.0";
8
8
 
9
9
  /**
10
10
  * Parse wrapper command-line arguments.
@@ -113,8 +113,12 @@ export function validateUnattendedOptions(options) {
113
113
  if (options.skills.length === 0 && !options.bundle) {
114
114
  throw new Error("-y requires at least one --skill for a vendor");
115
115
  }
116
- if (!options.onConflict) {
117
- throw new Error("-y requires --on-conflict=keep, overwrite, or skip");
116
+ // Upstream `skills` has no conflict policy. Accept omitted/`overwrite` only so
117
+ // keep|skip cannot silently no-op under a fail-closed API.
118
+ if (options.onConflict && options.onConflict !== "overwrite") {
119
+ throw new Error(
120
+ `--on-conflict=${options.onConflict} is unsupported: upstream skills CLI has no conflict policy. Omit the flag, use overwrite, or remove the existing skill first.`,
121
+ );
118
122
  }
119
123
  }
120
124
 
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * Resolve the gateway package version for install pins and lock metadata.
7
+ *
8
+ * `bunx` / direct `node bin/ai-skills.js` do not set `npm_package_version`, so
9
+ * fall back to the on-disk `package.json` shipped in the published tarball.
10
+ *
11
+ * @param {{env?: NodeJS.ProcessEnv, packageJsonPath?: string}} [options] - Injectable paths/env for tests.
12
+ * @returns {string} Semver package version.
13
+ */
14
+ export function getPackageVersion(options = {}) {
15
+ const env = options.env ?? process.env;
16
+ if (env.npm_package_version) {
17
+ return env.npm_package_version;
18
+ }
19
+ const packageJsonPath =
20
+ options.packageJsonPath ?? join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
21
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
22
+ }
@@ -25,9 +25,9 @@ export function buildSkillsArguments(options, source) {
25
25
  if (options.copy) {
26
26
  args.push("--copy");
27
27
  }
28
- if (options.onConflict) {
29
- args.push("--on-conflict", options.onConflict);
30
- }
28
+ // Gateway still accepts --on-conflict for unattended fail-closed API stability,
29
+ // but upstream `skills` (through 1.5.x) does not implement that flag — do not
30
+ // forward a no-op that implies conflict handling the CLI does not provide.
31
31
  if (options.yes) {
32
32
  args.push("-y");
33
33
  }
package/lib/ui.js ADDED
@@ -0,0 +1,37 @@
1
+ import * as clack from "@clack/prompts";
2
+
3
+ /**
4
+ * Agents the gateway surfaces in interactive install.
5
+ *
6
+ * Keep labels user-facing; values match upstream `skills -a` identifiers.
7
+ */
8
+ export const KNOWN_AGENTS = [
9
+ { value: "claude-code", label: "Claude Code (~/.claude/skills)" },
10
+ { value: "cursor", label: "Cursor (~/.cursor/skills)" },
11
+ { value: "codex", label: "Codex (~/.codex/skills)" },
12
+ ];
13
+
14
+ /**
15
+ * Build the default interactive UI (Clack).
16
+ *
17
+ * @returns {{
18
+ * intro: (message: string) => void,
19
+ * outro: (message: string) => void,
20
+ * note: (message: string, title?: string) => void,
21
+ * select: (options: {message: string, options: {value: string, label: string}[], initialValue?: string}) => Promise<string | symbol>,
22
+ * multiselect: (options: {message: string, options: {value: string, label: string}[], initialValues?: string[], required?: boolean}) => Promise<string[] | symbol>,
23
+ * confirm: (options: {message: string, initialValue?: boolean}) => Promise<boolean | symbol>,
24
+ * isCancel: (value: unknown) => boolean,
25
+ * }} Interactive UI adapter.
26
+ */
27
+ export function createClackUi() {
28
+ return {
29
+ intro: clack.intro,
30
+ outro: clack.outro,
31
+ note: clack.note,
32
+ select: clack.select,
33
+ multiselect: clack.multiselect,
34
+ confirm: clack.confirm,
35
+ isCancel: clack.isCancel,
36
+ };
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lgtm-hq/ai-skills",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Pinned installer gateway for first-party and vendor AI skills.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/lgtm-hq/ai-skills#readme",
@@ -28,5 +28,8 @@
28
28
  "scripts": {
29
29
  "test": "bun test --coverage"
30
30
  },
31
- "type": "module"
31
+ "type": "module",
32
+ "dependencies": {
33
+ "@clack/prompts": "1.7.0"
34
+ }
32
35
  }