@skilltap/core 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilltap/core",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Core library for skilltap — agent skill management",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -92,7 +92,11 @@ enabled = ["skills.sh"]
92
92
  # name = "my-org"
93
93
  # url = "https://skills.example.com"
94
94
 
95
- # Tap definitions (repeatable section)
95
+ # Built-in tap: the official skilltap-skills collection.
96
+ # Set to false to opt out of the built-in tap entirely.
97
+ builtin_tap = true
98
+
99
+ # Additional tap definitions (repeatable section)
96
100
  # [[taps]]
97
101
  # name = "home"
98
102
  # url = "https://gitea.example.com/nathan/my-skills-tap"
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseSkillFrontmatter } from "./frontmatter";
3
+
4
+ describe("parseSkillFrontmatter", () => {
5
+ test("parses simple single-line values", () => {
6
+ const content = `---
7
+ name: my-skill
8
+ description: A short description
9
+ license: MIT
10
+ ---
11
+ `;
12
+ const result = parseSkillFrontmatter(content);
13
+ expect(result).toEqual({
14
+ name: "my-skill",
15
+ description: "A short description",
16
+ license: "MIT",
17
+ });
18
+ });
19
+
20
+ test("returns null when no frontmatter", () => {
21
+ expect(parseSkillFrontmatter("No frontmatter here")).toBeNull();
22
+ });
23
+
24
+ test("coerces boolean values", () => {
25
+ const result = parseSkillFrontmatter("---\nfoo: true\nbar: false\n---\n");
26
+ expect(result?.foo).toBe(true);
27
+ expect(result?.bar).toBe(false);
28
+ });
29
+
30
+ test("coerces numeric values", () => {
31
+ const result = parseSkillFrontmatter("---\ncount: 42\n---\n");
32
+ expect(result?.count).toBe(42);
33
+ });
34
+
35
+ test("parses folded block scalar (>)", () => {
36
+ const content = `---
37
+ name: my-skill
38
+ description: >
39
+ This is a long description
40
+ that spans multiple lines.
41
+ license: MIT
42
+ ---
43
+ `;
44
+ const result = parseSkillFrontmatter(content);
45
+ expect(result?.description).toBe("This is a long description that spans multiple lines.");
46
+ expect(result?.license).toBe("MIT");
47
+ });
48
+
49
+ test("parses literal block scalar (|)", () => {
50
+ const content = `---
51
+ name: my-skill
52
+ description: |
53
+ Line one.
54
+ Line two.
55
+ ---
56
+ `;
57
+ const result = parseSkillFrontmatter(content);
58
+ expect(result?.description).toBe("Line one.\nLine two.");
59
+ });
60
+
61
+ test("folded block scalar with single line", () => {
62
+ const content = `---
63
+ description: >
64
+ Single line text.
65
+ ---
66
+ `;
67
+ const result = parseSkillFrontmatter(content);
68
+ expect(result?.description).toBe("Single line text.");
69
+ });
70
+
71
+ test("block scalar followed by another key", () => {
72
+ const content = `---
73
+ name: my-skill
74
+ description: >
75
+ Multi-line
76
+ description here.
77
+ license: Apache-2.0
78
+ ---
79
+ `;
80
+ const result = parseSkillFrontmatter(content);
81
+ expect(result?.description).toBe("Multi-line description here.");
82
+ expect(result?.license).toBe("Apache-2.0");
83
+ expect(result?.name).toBe("my-skill");
84
+ });
85
+ });
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Parse YAML-style frontmatter between leading --- delimiters.
3
3
  * Returns null if no frontmatter found.
4
+ * Supports block scalars: > (folded) and | (literal).
4
5
  */
5
6
  export function parseSkillFrontmatter(
6
7
  content: string,
@@ -10,16 +11,34 @@ export function parseSkillFrontmatter(
10
11
  // biome-ignore lint/style/noNonNullAssertion: match[1] is defined because the regex has a capturing group
11
12
  const block = match[1]!;
12
13
  const data: Record<string, unknown> = {};
13
- for (const line of block.split("\n")) {
14
+ const lines = block.split("\n");
15
+ let i = 0;
16
+ while (i < lines.length) {
17
+ const line = lines[i]!;
14
18
  const sep = line.indexOf(":");
15
- if (sep === -1) continue;
19
+ if (sep === -1) { i++; continue; }
16
20
  const key = line.slice(0, sep).trim();
17
- if (!key) continue;
21
+ if (!key) { i++; continue; }
18
22
  const raw = line.slice(sep + 1).trim();
19
- if (raw === "true") data[key] = true;
20
- else if (raw === "false") data[key] = false;
21
- else if (raw !== "" && !Number.isNaN(Number(raw))) data[key] = Number(raw);
22
- else data[key] = raw;
23
+ if (raw === ">" || raw === "|") {
24
+ const style = raw;
25
+ const parts: string[] = [];
26
+ i++;
27
+ while (i < lines.length) {
28
+ const next = lines[i]!;
29
+ if (next.length > 0 && (next[0] === " " || next[0] === "\t")) {
30
+ parts.push(next.trimStart());
31
+ i++;
32
+ } else break;
33
+ }
34
+ data[key] = style === ">" ? parts.join(" ").trim() : parts.join("\n").trimEnd();
35
+ } else {
36
+ if (raw === "true") data[key] = true;
37
+ else if (raw === "false") data[key] = false;
38
+ else if (raw !== "" && !Number.isNaN(Number(raw))) data[key] = Number(raw);
39
+ else data[key] = raw;
40
+ i++;
41
+ }
23
42
  }
24
43
  return data;
25
44
  }
@@ -439,7 +439,10 @@ describe("updateSkill — npm", () => {
439
439
  integrity: v2.integrity,
440
440
  });
441
441
 
442
- const result = await updateSkill({ yes: true });
442
+ const result = await updateSkill(
443
+ { yes: true },
444
+ async () => ({ tier: "unverified" as const }),
445
+ );
443
446
  expect(result.ok).toBe(true);
444
447
  if (!result.ok) return;
445
448
 
@@ -464,5 +467,5 @@ describe("updateSkill — npm", () => {
464
467
  await removeTmpDir(tgzDir1);
465
468
  await removeTmpDir(tgzDir2);
466
469
  }
467
- });
470
+ }, 30_000);
468
471
  });
@@ -53,6 +53,8 @@ export const ConfigSchema = z.object({
53
53
  security: SecurityConfigSchema.prefault({}),
54
54
  "agent-mode": AgentModeSchema.prefault({}),
55
55
  registry: RegistryConfigSchema,
56
+ /** Whether the built-in skilltap-skills tap is enabled. Set to false to opt out. */
57
+ builtin_tap: z.boolean().default(true),
56
58
  taps: z
57
59
  .array(
58
60
  z.object({
package/src/taps.ts CHANGED
@@ -11,6 +11,12 @@ import type { Tap, TapSkill } from "./schemas/tap";
11
11
  import { TapSchema } from "./schemas/tap";
12
12
  import { err, type GitError, ok, type Result, UserError } from "./types";
13
13
 
14
+ /** The built-in tap — always available unless explicitly opted out via config. */
15
+ export const BUILTIN_TAP = {
16
+ name: "skilltap-skills",
17
+ url: "https://github.com/nklisch/skilltap-skills.git",
18
+ } as const;
19
+
14
20
  export type TapEntry = { tapName: string; skill: TapSkill };
15
21
 
16
22
  export type UpdateTapResult = {
@@ -93,6 +99,27 @@ export function parseGitHubTapShorthand(
93
99
  };
94
100
  }
95
101
 
102
+ /** Returns true if the built-in tap is already cloned locally. */
103
+ export async function isBuiltinTapCloned(): Promise<boolean> {
104
+ return Bun.file(join(tapDir(BUILTIN_TAP.name), "tap.json")).exists();
105
+ }
106
+
107
+ /**
108
+ * Ensure the built-in tap is cloned locally. Idempotent — no-op if already present.
109
+ * Returns ok(undefined) whether freshly cloned or already present.
110
+ */
111
+ export async function ensureBuiltinTap(): Promise<Result<void, UserError | GitError>> {
112
+ const dir = tapDir(BUILTIN_TAP.name);
113
+ const exists = await Bun.file(join(dir, "tap.json")).exists();
114
+ if (exists) return ok(undefined);
115
+
116
+ const gitCheck = await checkGitInstalled();
117
+ if (!gitCheck.ok) return gitCheck;
118
+
119
+ const cloneResult = await clone(BUILTIN_TAP.url, dir, { depth: 1 });
120
+ return cloneResult;
121
+ }
122
+
96
123
  export async function addTap(
97
124
  name: string,
98
125
  url: string,
@@ -102,6 +129,15 @@ export async function addTap(
102
129
  if (!configResult.ok) return configResult;
103
130
  const config = configResult.value;
104
131
 
132
+ if (name === BUILTIN_TAP.name && config.builtin_tap !== false) {
133
+ return err(
134
+ new UserError(
135
+ `'${BUILTIN_TAP.name}' is the built-in tap and is already included.`,
136
+ "To disable it, set 'builtin_tap = false' in your config.toml.",
137
+ ),
138
+ );
139
+ }
140
+
105
141
  if (config.taps.some((t) => t.name === name)) {
106
142
  return err(
107
143
  new UserError(
@@ -153,6 +189,23 @@ export async function removeTap(
153
189
  if (!configResult.ok) return configResult;
154
190
  const config = configResult.value;
155
191
 
192
+ // Special case: disable the built-in tap
193
+ if (name === BUILTIN_TAP.name) {
194
+ if (config.builtin_tap === false) {
195
+ return err(
196
+ new UserError(
197
+ `Built-in tap '${BUILTIN_TAP.name}' is already disabled.`,
198
+ "Set 'builtin_tap = true' in config.toml to re-enable it.",
199
+ ),
200
+ );
201
+ }
202
+ config.builtin_tap = false;
203
+ const saveResult = await saveConfig(config);
204
+ if (!saveResult.ok) return saveResult;
205
+ await rm(tapDir(name), { recursive: true, force: true });
206
+ return ok(undefined);
207
+ }
208
+
156
209
  const idx = config.taps.findIndex((t) => t.name === name);
157
210
  if (idx === -1) {
158
211
  return err(
@@ -182,6 +235,46 @@ export async function updateTap(
182
235
  if (!configResult.ok) return configResult;
183
236
  const config = configResult.value;
184
237
 
238
+ const updated: Record<string, number> = {};
239
+ const http: string[] = [];
240
+
241
+ // Handle built-in tap: update if enabled and requested
242
+ if (name === BUILTIN_TAP.name) {
243
+ if (config.builtin_tap === false) {
244
+ return err(
245
+ new UserError(
246
+ `Tap '${BUILTIN_TAP.name}' is not configured.`,
247
+ "Set 'builtin_tap = true' in config.toml to re-enable it.",
248
+ ),
249
+ );
250
+ }
251
+ const dir = tapDir(BUILTIN_TAP.name);
252
+ if (!(await Bun.file(join(dir, "tap.json")).exists())) {
253
+ return err(
254
+ new UserError(
255
+ `Built-in tap '${BUILTIN_TAP.name}' is not yet cloned.`,
256
+ "Run 'skilltap tap install' to set it up.",
257
+ ),
258
+ );
259
+ }
260
+ const pullResult = await pull(dir);
261
+ if (!pullResult.ok) return pullResult;
262
+ const tapResult = await loadTapJson(dir, BUILTIN_TAP.name);
263
+ updated[BUILTIN_TAP.name] = tapResult.ok ? tapResult.value.skills.length : 0;
264
+ return ok({ updated, http });
265
+ }
266
+
267
+ // Update all: include built-in tap if enabled and cloned
268
+ if (!name && config.builtin_tap !== false) {
269
+ const dir = tapDir(BUILTIN_TAP.name);
270
+ if (await Bun.file(join(dir, "tap.json")).exists()) {
271
+ const pullResult = await pull(dir);
272
+ if (!pullResult.ok) return pullResult;
273
+ const tapResult = await loadTapJson(dir, BUILTIN_TAP.name);
274
+ updated[BUILTIN_TAP.name] = tapResult.ok ? tapResult.value.skills.length : 0;
275
+ }
276
+ }
277
+
185
278
  const targets = name
186
279
  ? config.taps.filter((t) => t.name === name)
187
280
  : config.taps;
@@ -195,9 +288,6 @@ export async function updateTap(
195
288
  );
196
289
  }
197
290
 
198
- const updated: Record<string, number> = {};
199
- const http: string[] = [];
200
-
201
291
  for (const tap of targets) {
202
292
  if (tap.type === "http") {
203
293
  http.push(tap.name);
@@ -223,6 +313,17 @@ export async function loadTaps(): Promise<Result<TapEntry[], UserError>> {
223
313
 
224
314
  const entries: TapEntry[] = [];
225
315
 
316
+ // Load built-in tap first (if enabled and already cloned)
317
+ if (config.builtin_tap !== false) {
318
+ const dir = tapDir(BUILTIN_TAP.name);
319
+ const tapResult = await loadTapJson(dir, BUILTIN_TAP.name);
320
+ if (tapResult.ok) {
321
+ for (const skill of tapResult.value.skills) {
322
+ entries.push({ tapName: BUILTIN_TAP.name, skill });
323
+ }
324
+ }
325
+ }
326
+
226
327
  for (const tap of config.taps) {
227
328
  if (tap.type === "http") {
228
329
  // HTTP registry: fetch skills from API
package/src/update.ts CHANGED
@@ -22,6 +22,8 @@ import { scanSemantic } from "./security/semantic";
22
22
  import { wrapShell } from "./shell";
23
23
  import { createAgentSymlinks, removeAgentSymlinks } from "./symlink";
24
24
  import { parseGitHubRepo, resolveTrust } from "./trust";
25
+
26
+ type ResolveTrustFn = typeof resolveTrust;
25
27
  import type { Result } from "./types";
26
28
  import {
27
29
  err,
@@ -172,6 +174,7 @@ async function updateNpmSkill(
172
174
  installed: { skills: InstalledSkill[] },
173
175
  options: UpdateOptions,
174
176
  result: UpdateResult,
177
+ _resolveTrust: ResolveTrustFn,
175
178
  ): Promise<Result<void, UserError | NetworkError | ScanError>> {
176
179
  // biome-ignore lint/style/noNonNullAssertion: caller checks record.repo?.startsWith("npm:")
177
180
  const { name: packageName } = parseNpmSource(record.repo!);
@@ -262,7 +265,7 @@ async function updateNpmSkill(
262
265
  await refreshAgentSymlinks(record, options.projectRoot);
263
266
 
264
267
  // Re-verify trust for the new version
265
- const newTrust = await resolveTrust({
268
+ const newTrust = await _resolveTrust({
266
269
  adapter: "npm",
267
270
  url: record.repo!,
268
271
  tap: record.tap,
@@ -294,6 +297,7 @@ async function updateGitSkill(
294
297
  installed: { skills: InstalledSkill[] },
295
298
  options: UpdateOptions,
296
299
  result: UpdateResult,
300
+ _resolveTrust: ResolveTrustFn,
297
301
  ): Promise<Result<void, UserError | GitError | ScanError>> {
298
302
  // Standalone: work dir is the install path. Multi-skill: work dir is the cache.
299
303
  const isMulti = record.path !== null;
@@ -388,7 +392,7 @@ async function updateGitSkill(
388
392
  if (!newShaResult.ok) return newShaResult;
389
393
 
390
394
  // Re-verify trust for the updated skill
391
- const newTrust = await resolveTrust({
395
+ const newTrust = await _resolveTrust({
392
396
  adapter: "git",
393
397
  url: record.repo ?? "",
394
398
  tap: record.tap,
@@ -412,6 +416,7 @@ async function updateGitSkill(
412
416
 
413
417
  export async function updateSkill(
414
418
  options: UpdateOptions = {},
419
+ _resolveTrust: ResolveTrustFn = resolveTrust,
415
420
  ): Promise<Result<UpdateResult, UserError | GitError | ScanError | NetworkError>> {
416
421
  debug("updateSkill", { name: options.name ?? "all" });
417
422
  const installedResult = await loadInstalled();
@@ -444,10 +449,10 @@ export async function updateSkill(
444
449
 
445
450
  // Dispatch to adapter-specific update handler
446
451
  if (record.repo?.startsWith("npm:")) {
447
- const npmResult = await updateNpmSkill(record, installed, options, result);
452
+ const npmResult = await updateNpmSkill(record, installed, options, result, _resolveTrust);
448
453
  if (!npmResult.ok) return npmResult;
449
454
  } else {
450
- const gitResult = await updateGitSkill(record, installed, options, result);
455
+ const gitResult = await updateGitSkill(record, installed, options, result, _resolveTrust);
451
456
  if (!gitResult.ok) return gitResult;
452
457
  }
453
458
  }