@skilltap/core 0.5.0 → 0.5.3

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.3",
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"
@@ -160,9 +164,14 @@ export async function saveConfig(config: Config): Promise<Result<void>> {
160
164
 
161
165
  const _DEFAULT_INSTALLED: InstalledJson = { version: 1, skills: [] };
162
166
 
163
- export async function loadInstalled(): Promise<Result<InstalledJson>> {
164
- const dir = getConfigDir();
165
- const file = join(dir, "installed.json");
167
+ function getInstalledPath(projectRoot?: string): string {
168
+ return projectRoot
169
+ ? join(projectRoot, ".agents", "installed.json")
170
+ : join(getConfigDir(), "installed.json");
171
+ }
172
+
173
+ export async function loadInstalled(projectRoot?: string): Promise<Result<InstalledJson>> {
174
+ const file = getInstalledPath(projectRoot);
166
175
 
167
176
  const f = Bun.file(file);
168
177
  const exists = await f.exists();
@@ -190,12 +199,20 @@ export async function loadInstalled(): Promise<Result<InstalledJson>> {
190
199
 
191
200
  export async function saveInstalled(
192
201
  installed: InstalledJson,
202
+ projectRoot?: string,
193
203
  ): Promise<Result<void>> {
194
- const dir = getConfigDir();
195
- const file = join(dir, "installed.json");
204
+ const file = getInstalledPath(projectRoot);
196
205
 
197
- const dirsResult = await ensureDirs();
198
- if (!dirsResult.ok) return dirsResult;
206
+ if (projectRoot) {
207
+ try {
208
+ await mkdir(join(projectRoot, ".agents"), { recursive: true });
209
+ } catch (e) {
210
+ return err(new UserError(`Failed to create .agents directory: ${e}`));
211
+ }
212
+ } else {
213
+ const dirsResult = await ensureDirs();
214
+ if (!dirsResult.ok) return dirsResult;
215
+ }
199
216
 
200
217
  try {
201
218
  await Bun.write(file, JSON.stringify(installed, null, 2));
package/src/doctor.ts CHANGED
@@ -346,7 +346,6 @@ async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
346
346
  const trackedNames = new Set<string>();
347
347
 
348
348
  for (const skill of installed.skills) {
349
- if (skill.scope === "project") continue;
350
349
  trackedNames.add(skill.name);
351
350
 
352
351
  if (skill.scope === "linked") {
@@ -407,7 +406,7 @@ async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
407
406
  }
408
407
  }
409
408
 
410
- const total = installed.skills.filter((s) => s.scope !== "project").length;
409
+ const total = installed.skills.length;
411
410
  const missing = issues.filter((i) => i.fixable).length;
412
411
  const onDisk = total - missing;
413
412
 
@@ -434,7 +433,7 @@ async function checkSymlinks(installed: InstalledJson): Promise<DoctorCheck> {
434
433
  let valid = 0;
435
434
 
436
435
  for (const skill of installed.skills) {
437
- if (skill.scope === "project" || skill.also.length === 0) continue;
436
+ if (skill.also.length === 0) continue;
438
437
 
439
438
  for (const agent of skill.also) {
440
439
  const agentRelDir = AGENT_PATHS[agent];
@@ -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
  });
@@ -213,6 +213,59 @@ describe("installSkill — project scope", () => {
213
213
  await removeTmpDir(projectRoot);
214
214
  }
215
215
  });
216
+
217
+ test("saves to project installed.json, not global", async () => {
218
+ const repo = await createStandaloneSkillRepo();
219
+ const projectRoot = await makeTmpDir();
220
+ try {
221
+ await $`git -C ${projectRoot} init`.quiet();
222
+ await installSkill(repo.path, { scope: "project", projectRoot, skipScan: true });
223
+
224
+ // Project file should have the record
225
+ const projectInstalled = await loadInstalled(projectRoot);
226
+ expect(projectInstalled.ok).toBe(true);
227
+ if (!projectInstalled.ok) return;
228
+ expect(projectInstalled.value.skills.map((s) => s.name)).toContain("standalone-skill");
229
+
230
+ // Global file should be empty
231
+ const globalInstalled = await loadInstalled();
232
+ expect(globalInstalled.ok).toBe(true);
233
+ if (!globalInstalled.ok) return;
234
+ expect(globalInstalled.value.skills).toHaveLength(0);
235
+ } finally {
236
+ await repo.cleanup();
237
+ await removeTmpDir(projectRoot);
238
+ }
239
+ });
240
+
241
+ test("same skill in two projects coexist independently", async () => {
242
+ const repo = await createStandaloneSkillRepo();
243
+ const projectA = await makeTmpDir();
244
+ const projectB = await makeTmpDir();
245
+ try {
246
+ await $`git -C ${projectA} init`.quiet();
247
+ await $`git -C ${projectB} init`.quiet();
248
+
249
+ await installSkill(repo.path, { scope: "project", projectRoot: projectA, skipScan: true });
250
+ await installSkill(repo.path, { scope: "project", projectRoot: projectB, skipScan: true });
251
+
252
+ // Both project files have the record
253
+ const aInstalled = await loadInstalled(projectA);
254
+ const bInstalled = await loadInstalled(projectB);
255
+ expect(aInstalled.ok && aInstalled.value.skills).toHaveLength(1);
256
+ expect(bInstalled.ok && bInstalled.value.skills).toHaveLength(1);
257
+
258
+ // Global file is empty
259
+ const globalInstalled = await loadInstalled();
260
+ expect(globalInstalled.ok).toBe(true);
261
+ if (!globalInstalled.ok) return;
262
+ expect(globalInstalled.value.skills).toHaveLength(0);
263
+ } finally {
264
+ await repo.cleanup();
265
+ await removeTmpDir(projectA);
266
+ await removeTmpDir(projectB);
267
+ }
268
+ });
216
269
  });
217
270
 
218
271
  describe("removeSkill", () => {
package/src/install.ts CHANGED
@@ -365,7 +365,8 @@ export async function installSkill(
365
365
  const allSemanticWarnings: SemanticWarning[] = [];
366
366
 
367
367
  // 1. Check already-installed
368
- const installedResult = await loadInstalled();
368
+ const fileRoot = options.scope === "project" ? options.projectRoot : undefined;
369
+ const installedResult = await loadInstalled(fileRoot);
369
370
  if (!installedResult.ok) return installedResult;
370
371
  const installed = installedResult.value;
371
372
 
@@ -563,7 +564,7 @@ export async function installSkill(
563
564
 
564
565
  // 10. Save installed.json
565
566
  installed.skills.push(...newRecords);
566
- const saveResult = await saveInstalled(installed);
567
+ const saveResult = await saveInstalled(installed, fileRoot);
567
568
  if (!saveResult.ok) return saveResult;
568
569
 
569
570
  return ok({
package/src/link.ts CHANGED
@@ -38,7 +38,8 @@ export async function linkSkill(
38
38
  const skill = scanned[0]!;
39
39
 
40
40
  // 3. Load installed to check for conflicts
41
- const installedResult = await loadInstalled();
41
+ const fileRoot = options.scope === "project" ? options.projectRoot : undefined;
42
+ const installedResult = await loadInstalled(fileRoot);
42
43
  if (!installedResult.ok) return installedResult;
43
44
  const installed = installedResult.value;
44
45
 
@@ -99,7 +100,7 @@ export async function linkSkill(
99
100
 
100
101
  // 8. Save installed.json
101
102
  installed.skills.push(record);
102
- const saveResult = await saveInstalled(installed);
103
+ const saveResult = await saveInstalled(installed, fileRoot);
103
104
  if (!saveResult.ok) return saveResult;
104
105
 
105
106
  return ok(record);
package/src/remove.ts CHANGED
@@ -17,7 +17,8 @@ export async function removeSkill(
17
17
  options: RemoveOptions = {},
18
18
  ): Promise<Result<void, UserError>> {
19
19
  debug("removeSkill", { name, scope: options.scope });
20
- const installedResult = await loadInstalled();
20
+ const fileRoot = options.scope === "project" ? options.projectRoot : undefined;
21
+ const installedResult = await loadInstalled(fileRoot);
21
22
  if (!installedResult.ok) return installedResult;
22
23
  const installed = installedResult.value;
23
24
 
@@ -79,7 +80,7 @@ export async function removeSkill(
79
80
  }
80
81
 
81
82
  installed.skills.splice(idx, 1);
82
- const saveResult = await saveInstalled(installed);
83
+ const saveResult = await saveInstalled(installed, fileRoot);
83
84
  if (!saveResult.ok) return saveResult;
84
85
 
85
86
  return ok(undefined);
@@ -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/symlink.ts CHANGED
@@ -63,7 +63,7 @@ export async function removeAgentSymlinks(
63
63
  scope: "global" | "project" | "linked",
64
64
  projectRoot?: string,
65
65
  ): Promise<Result<void, UserError>> {
66
- const effectiveScope = scope === "linked" ? "global" : scope;
66
+ const effectiveScope = scope === "linked" ? (projectRoot ? "project" : "global") : scope;
67
67
  for (const agent of agents) {
68
68
  const linkPath = symlinkPath(skillName, agent, effectiveScope, projectRoot);
69
69
  if (!linkPath) continue;
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
@@ -314,4 +314,74 @@ describe("updateSkill — multi-skill", () => {
314
314
  await repo.cleanup();
315
315
  }
316
316
  });
317
+
318
+ test("both skills from same repo are checked when both installed", async () => {
319
+ const repo = await createMultiSkillRepo();
320
+ try {
321
+ // Install BOTH skills from the multi-skill repo
322
+ await installSkill(repo.path, { scope: "global", skipScan: true });
323
+
324
+ // Add a commit that changes BOTH skill paths
325
+ await addFileAndCommit(repo.path, ".agents/skills/skill-a/patch.md", "# Patch A");
326
+ await addFileAndCommit(repo.path, ".agents/skills/skill-b/patch.md", "# Patch B");
327
+
328
+ const result = await updateSkill({ yes: true });
329
+ expect(result.ok).toBe(true);
330
+ if (!result.ok) return;
331
+
332
+ expect(result.value.updated).toContain("skill-a");
333
+ expect(result.value.updated).toContain("skill-b");
334
+ } finally {
335
+ await repo.cleanup();
336
+ }
337
+ });
338
+
339
+ test("skill with no path changes is upToDate even when repo has changes", async () => {
340
+ const repo = await createMultiSkillRepo();
341
+ try {
342
+ // Install BOTH skills from the multi-skill repo
343
+ await installSkill(repo.path, { scope: "global", skipScan: true });
344
+
345
+ // Add a commit that ONLY changes skill-a's path
346
+ await addFileAndCommit(repo.path, ".agents/skills/skill-a/only-a.md", "# Only A");
347
+
348
+ const result = await updateSkill({ yes: true });
349
+ expect(result.ok).toBe(true);
350
+ if (!result.ok) return;
351
+
352
+ // skill-a has changes → updated
353
+ expect(result.value.updated).toContain("skill-a");
354
+ // skill-b has no path-specific changes → upToDate (not skipped, not updated)
355
+ expect(result.value.upToDate).toContain("skill-b");
356
+ expect(result.value.skipped).not.toContain("skill-b");
357
+ } finally {
358
+ await repo.cleanup();
359
+ }
360
+ });
361
+ });
362
+
363
+ describe("updateSkill — project scope", () => {
364
+ test("updates skills in project installed.json when projectRoot provided", async () => {
365
+ const repo = await createStandaloneSkillRepo();
366
+ const projectRoot = await makeTmpDir();
367
+ try {
368
+ await installSkill(repo.path, { scope: "project", projectRoot, skipScan: true });
369
+ await addFileAndCommit(repo.path, "new-file.md", "# New content");
370
+
371
+ const result = await updateSkill({ yes: true, projectRoot });
372
+ expect(result.ok).toBe(true);
373
+ if (!result.ok) return;
374
+
375
+ expect(result.value.updated).toContain("standalone-skill");
376
+
377
+ // Project installed.json should have updated SHA
378
+ const projectInstalled = await loadInstalled(projectRoot);
379
+ expect(projectInstalled.ok).toBe(true);
380
+ if (!projectInstalled.ok) return;
381
+ expect(projectInstalled.value.skills[0]?.sha).toBeTruthy();
382
+ } finally {
383
+ await repo.cleanup();
384
+ await removeTmpDir(projectRoot);
385
+ }
386
+ });
317
387
  });
package/src/update.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  resolveVersion,
15
15
  } from "./npm-registry";
16
16
  import { skillCacheDir, skillInstallDir } from "./paths";
17
- import type { InstalledSkill } from "./schemas/installed";
17
+ import type { InstalledJson, InstalledSkill } from "./schemas/installed";
18
18
  import type { StaticWarning } from "./security";
19
19
  import { scanDiff, scanStatic } from "./security";
20
20
  import type { SemanticWarning } from "./security/semantic";
@@ -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,
@@ -39,7 +41,7 @@ export type UpdateOptions = {
39
41
  yes?: boolean;
40
42
  /** Skip skills that have security warnings in their diff */
41
43
  strict?: boolean;
42
- /** Project root for project-scoped symlink re-creation */
44
+ /** Project root also processes project-scoped skills from {projectRoot}/.agents/installed.json */
43
45
  projectRoot?: string;
44
46
  onProgress?: (
45
47
  skillName: string,
@@ -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,
@@ -288,34 +291,19 @@ async function updateNpmSkill(
288
291
  }
289
292
  }
290
293
 
291
- /** Handle updates for git-sourced skills (SHA comparison, diff scanning). */
294
+ /** Handle updates for standalone git skills (path === null; workDir is the install dir). */
292
295
  async function updateGitSkill(
293
296
  record: InstalledSkill,
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
- // Standalone: work dir is the install path. Multi-skill: work dir is the cache.
299
- const isMulti = record.path !== null;
300
- const workDir = isMulti
301
- ? skillCacheDir(record.repo!)
302
- : skillInstallDir(
303
- record.name,
304
- record.scope as "global" | "project",
305
- options.projectRoot,
306
- );
307
-
308
- // For multi-skill, verify cache exists
309
- if (isMulti) {
310
- const cacheGitExists = await lstat(join(workDir, ".git"))
311
- .then(() => true)
312
- .catch(() => false);
313
- if (!cacheGitExists) {
314
- result.skipped.push(record.name);
315
- options.onProgress?.(record.name, "skipped");
316
- return ok(undefined);
317
- }
318
- }
302
+ const workDir = skillInstallDir(
303
+ record.name,
304
+ record.scope as "global" | "project",
305
+ options.projectRoot,
306
+ );
319
307
 
320
308
  const fetchResult = await fetch(workDir);
321
309
  if (!fetchResult.ok) return fetchResult;
@@ -334,69 +322,42 @@ async function updateGitSkill(
334
322
  return ok(undefined);
335
323
  }
336
324
 
337
- // Get diff (path-filtered for multi-skill)
338
- const pathSpec = record.path ?? undefined;
339
- const diffResult = await diff(workDir, "HEAD", "FETCH_HEAD", pathSpec);
325
+ const diffResult = await diff(workDir, "HEAD", "FETCH_HEAD");
340
326
  if (!diffResult.ok) return diffResult;
341
- const diffOutput = diffResult.value;
342
327
 
343
- const statResult = await diffStat(workDir, "HEAD", "FETCH_HEAD", pathSpec);
328
+ const statResult = await diffStat(workDir, "HEAD", "FETCH_HEAD");
344
329
  if (!statResult.ok) return statResult;
345
330
  const stat = statResult.value;
346
331
 
347
- // If skill-specific path has no changes, mark as up to date
348
- if (stat.filesChanged === 0) {
349
- result.upToDate.push(record.name);
350
- options.onProgress?.(record.name, "upToDate");
351
- return ok(undefined);
352
- }
353
-
354
332
  options.onDiff?.(record.name, stat, localSha, remoteSha);
355
333
 
356
- // Security scan on diff + confirmation
357
- const warnings = scanDiff(diffOutput);
334
+ const warnings = scanDiff(diffResult.value);
358
335
  if (await shouldSkipUpdate(warnings, options, record.name)) {
359
336
  result.skipped.push(record.name);
360
337
  options.onProgress?.(record.name, "skipped");
361
338
  return ok(undefined);
362
339
  }
363
340
 
364
- // Apply update
365
341
  const pullResult = await pull(workDir);
366
342
  if (!pullResult.ok) return pullResult;
367
343
 
368
- if (isMulti) {
369
- const recopyResult = await recopyMultiSkill(workDir, record, options.projectRoot);
370
- if (!recopyResult.ok) return recopyResult;
371
- }
372
-
373
- const installDir = skillInstallDir(
374
- record.name,
375
- record.scope as "global" | "project",
376
- options.projectRoot,
377
- );
378
-
379
- // Semantic scan on updated skill directory
380
- if (await runUpdateSemanticScan(installDir, record.name, options)) {
344
+ if (await runUpdateSemanticScan(workDir, record.name, options)) {
381
345
  result.skipped.push(record.name);
382
346
  options.onProgress?.(record.name, "skipped");
383
347
  return ok(undefined);
384
348
  }
385
349
 
386
- // Get new SHA
387
350
  const newShaResult = await revParse(workDir, "HEAD");
388
351
  if (!newShaResult.ok) return newShaResult;
389
352
 
390
- // Re-verify trust for the updated skill
391
- const newTrust = await resolveTrust({
353
+ const newTrust = await _resolveTrust({
392
354
  adapter: "git",
393
355
  url: record.repo ?? "",
394
356
  tap: record.tap,
395
- skillDir: installDir,
357
+ skillDir: workDir,
396
358
  githubRepo: record.repo ? parseGitHubRepo(record.repo) : null,
397
359
  });
398
360
 
399
- // Update the record in place
400
361
  patchRecord(installed, record, {
401
362
  sha: newShaResult.value,
402
363
  updatedAt: new Date().toISOString(),
@@ -410,18 +371,232 @@ async function updateGitSkill(
410
371
  return ok(undefined);
411
372
  }
412
373
 
374
+ /** Handle updates for a group of skills sharing the same multi-skill git repo cache. */
375
+ async function updateGitSkillGroup(
376
+ repo: string,
377
+ skills: InstalledSkill[],
378
+ installed: { skills: InstalledSkill[] },
379
+ options: UpdateOptions,
380
+ result: UpdateResult,
381
+ _resolveTrust: ResolveTrustFn,
382
+ ): Promise<Result<void, UserError | GitError | ScanError>> {
383
+ const workDir = skillCacheDir(repo);
384
+
385
+ // Verify cache exists
386
+ const cacheGitExists = await lstat(join(workDir, ".git"))
387
+ .then(() => true)
388
+ .catch(() => false);
389
+ if (!cacheGitExists) {
390
+ for (const skill of skills) {
391
+ result.skipped.push(skill.name);
392
+ options.onProgress?.(skill.name, "skipped");
393
+ }
394
+ return ok(undefined);
395
+ }
396
+
397
+ // Fetch once for the whole group
398
+ const fetchResult = await fetch(workDir);
399
+ if (!fetchResult.ok) return fetchResult;
400
+
401
+ // Capture SHAs BEFORE any pull
402
+ const localShaResult = await revParse(workDir, "HEAD");
403
+ if (!localShaResult.ok) return localShaResult;
404
+ const remoteShaResult = await revParse(workDir, "FETCH_HEAD");
405
+ if (!remoteShaResult.ok) return remoteShaResult;
406
+
407
+ const localSha = localShaResult.value;
408
+ const remoteSha = remoteShaResult.value;
409
+
410
+ // If the whole repo is up to date, all skills in the group are too
411
+ if (localSha === remoteSha) {
412
+ for (const skill of skills) {
413
+ result.upToDate.push(skill.name);
414
+ options.onProgress?.(skill.name, "upToDate");
415
+ }
416
+ return ok(undefined);
417
+ }
418
+
419
+ // Per-skill: check path-specific diff, scan, confirm
420
+ const toUpdate: InstalledSkill[] = [];
421
+ for (const skill of skills) {
422
+ options.onProgress?.(skill.name, "checking");
423
+
424
+ // biome-ignore lint/style/noNonNullAssertion: multi-skill records always have path
425
+ const pathSpec = skill.path!;
426
+
427
+ const statResult = await diffStat(workDir, "HEAD", "FETCH_HEAD", pathSpec);
428
+ if (!statResult.ok) return statResult;
429
+ const stat = statResult.value;
430
+
431
+ if (stat.filesChanged === 0) {
432
+ result.upToDate.push(skill.name);
433
+ options.onProgress?.(skill.name, "upToDate");
434
+ continue;
435
+ }
436
+
437
+ options.onDiff?.(skill.name, stat, localSha, remoteSha);
438
+
439
+ const diffResult = await diff(workDir, "HEAD", "FETCH_HEAD", pathSpec);
440
+ if (!diffResult.ok) return diffResult;
441
+
442
+ const warnings = scanDiff(diffResult.value);
443
+ if (await shouldSkipUpdate(warnings, options, skill.name)) {
444
+ result.skipped.push(skill.name);
445
+ options.onProgress?.(skill.name, "skipped");
446
+ continue;
447
+ }
448
+
449
+ toUpdate.push(skill);
450
+ }
451
+
452
+ if (toUpdate.length === 0) return ok(undefined);
453
+
454
+ // Pull once for the whole group
455
+ const pullResult = await pull(workDir);
456
+ if (!pullResult.ok) return pullResult;
457
+
458
+ const newShaResult = await revParse(workDir, "HEAD");
459
+ if (!newShaResult.ok) return newShaResult;
460
+ const newSha = newShaResult.value;
461
+
462
+ // Apply update to each confirmed skill
463
+ for (const skill of toUpdate) {
464
+ const recopyResult = await recopyMultiSkill(workDir, skill, options.projectRoot);
465
+ if (!recopyResult.ok) return recopyResult;
466
+
467
+ const installDir = skillInstallDir(
468
+ skill.name,
469
+ skill.scope as "global" | "project",
470
+ options.projectRoot,
471
+ );
472
+
473
+ if (await runUpdateSemanticScan(installDir, skill.name, options)) {
474
+ result.skipped.push(skill.name);
475
+ options.onProgress?.(skill.name, "skipped");
476
+ continue;
477
+ }
478
+
479
+ const newTrust = await _resolveTrust({
480
+ adapter: "git",
481
+ url: skill.repo ?? "",
482
+ tap: skill.tap,
483
+ skillDir: installDir,
484
+ githubRepo: skill.repo ? parseGitHubRepo(skill.repo) : null,
485
+ });
486
+
487
+ patchRecord(installed, skill, {
488
+ sha: newSha,
489
+ updatedAt: new Date().toISOString(),
490
+ trust: newTrust,
491
+ });
492
+
493
+ await refreshAgentSymlinks(skill, options.projectRoot);
494
+
495
+ result.updated.push(skill.name);
496
+ options.onProgress?.(skill.name, "updated");
497
+ }
498
+
499
+ return ok(undefined);
500
+ }
501
+
502
+ type SkillGroup =
503
+ | { type: "linked"; skill: InstalledSkill }
504
+ | { type: "npm"; skill: InstalledSkill }
505
+ | { type: "git-standalone"; skill: InstalledSkill }
506
+ | { type: "git-multi"; repo: string; skills: InstalledSkill[] };
507
+
508
+ /** Group skills by update strategy. Multi-skill records sharing a repo cache are grouped together. */
509
+ function groupSkillsByRepo(skills: InstalledSkill[]): SkillGroup[] {
510
+ const multiGroups = new Map<string, InstalledSkill[]>();
511
+ const solo: SkillGroup[] = [];
512
+
513
+ for (const skill of skills) {
514
+ if (skill.scope === "linked") {
515
+ solo.push({ type: "linked", skill });
516
+ continue;
517
+ }
518
+ if (skill.repo?.startsWith("npm:")) {
519
+ solo.push({ type: "npm", skill });
520
+ continue;
521
+ }
522
+ if (skill.path !== null && skill.repo) {
523
+ const existing = multiGroups.get(skill.repo);
524
+ if (existing) {
525
+ existing.push(skill);
526
+ } else {
527
+ multiGroups.set(skill.repo, [skill]);
528
+ }
529
+ } else {
530
+ solo.push({ type: "git-standalone", skill });
531
+ }
532
+ }
533
+
534
+ const groups: SkillGroup[] = [...solo];
535
+ for (const [repo, skills] of multiGroups) {
536
+ groups.push({ type: "git-multi", repo, skills });
537
+ }
538
+ return groups;
539
+ }
540
+
541
+ async function runUpdatePass(
542
+ skills: InstalledSkill[],
543
+ installed: InstalledJson,
544
+ options: UpdateOptions,
545
+ result: UpdateResult,
546
+ _resolveTrust: ResolveTrustFn,
547
+ ): Promise<Result<void, UserError | GitError | ScanError | NetworkError>> {
548
+ const groups = groupSkillsByRepo(skills);
549
+
550
+ for (const group of groups) {
551
+ if (group.type === "linked") {
552
+ options.onProgress?.(group.skill.name, "linked");
553
+ continue;
554
+ }
555
+
556
+ if (group.type === "npm") {
557
+ options.onProgress?.(group.skill.name, "checking");
558
+ const r = await updateNpmSkill(group.skill, installed, options, result, _resolveTrust);
559
+ if (!r.ok) return r;
560
+ } else if (group.type === "git-standalone") {
561
+ options.onProgress?.(group.skill.name, "checking");
562
+ const r = await updateGitSkill(group.skill, installed, options, result, _resolveTrust);
563
+ if (!r.ok) return r;
564
+ } else {
565
+ const r = await updateGitSkillGroup(group.repo, group.skills, installed, options, result, _resolveTrust);
566
+ if (!r.ok) return r;
567
+ }
568
+ }
569
+
570
+ return ok(undefined);
571
+ }
572
+
413
573
  export async function updateSkill(
414
574
  options: UpdateOptions = {},
575
+ _resolveTrust: ResolveTrustFn = resolveTrust,
415
576
  ): Promise<Result<UpdateResult, UserError | GitError | ScanError | NetworkError>> {
416
577
  debug("updateSkill", { name: options.name ?? "all" });
417
- const installedResult = await loadInstalled();
418
- if (!installedResult.ok) return installedResult;
419
- const installed = installedResult.value;
420
578
 
421
- let skills = installed.skills;
579
+ // Load global installed
580
+ const globalInstalledResult = await loadInstalled();
581
+ if (!globalInstalledResult.ok) return globalInstalledResult;
582
+ const globalInstalled = globalInstalledResult.value;
583
+
584
+ // Optionally load project installed
585
+ let projectInstalled: InstalledJson | null = null;
586
+ if (options.projectRoot) {
587
+ const r = await loadInstalled(options.projectRoot);
588
+ if (!r.ok) return r;
589
+ projectInstalled = r.value;
590
+ }
591
+
592
+ // Filter by name if specified — check both files
593
+ let globalSkills = globalInstalled.skills;
594
+ let projectSkills = projectInstalled?.skills ?? [];
595
+
422
596
  if (options.name) {
423
- const found = skills.filter((s) => s.name === options.name);
424
- if (found.length === 0) {
597
+ globalSkills = globalSkills.filter((s) => s.name === options.name);
598
+ projectSkills = projectSkills.filter((s) => s.name === options.name);
599
+ if (globalSkills.length === 0 && projectSkills.length === 0) {
425
600
  return err(
426
601
  new UserError(
427
602
  `Skill '${options.name}' is not installed.`,
@@ -429,31 +604,28 @@ export async function updateSkill(
429
604
  ),
430
605
  );
431
606
  }
432
- skills = found;
433
607
  }
434
608
 
435
609
  const result: UpdateResult = { updated: [], skipped: [], upToDate: [] };
436
610
 
437
- for (const record of skills) {
438
- if (record.scope === "linked") {
439
- options.onProgress?.(record.name, "linked");
440
- continue;
441
- }
611
+ // Process global skills
612
+ const globalPass = await runUpdatePass(globalSkills, globalInstalled, options, result, _resolveTrust);
613
+ if (!globalPass.ok) return globalPass;
442
614
 
443
- options.onProgress?.(record.name, "checking");
444
-
445
- // Dispatch to adapter-specific update handler
446
- if (record.repo?.startsWith("npm:")) {
447
- const npmResult = await updateNpmSkill(record, installed, options, result);
448
- if (!npmResult.ok) return npmResult;
449
- } else {
450
- const gitResult = await updateGitSkill(record, installed, options, result);
451
- if (!gitResult.ok) return gitResult;
452
- }
615
+ // Process project skills
616
+ if (projectInstalled) {
617
+ const projectPass = await runUpdatePass(projectSkills, projectInstalled, { ...options, projectRoot: options.projectRoot }, result, _resolveTrust);
618
+ if (!projectPass.ok) return projectPass;
453
619
  }
454
620
 
455
- const saveResult = await saveInstalled(installed);
456
- if (!saveResult.ok) return saveResult;
621
+ // Save both files
622
+ const globalSave = await saveInstalled(globalInstalled);
623
+ if (!globalSave.ok) return globalSave;
624
+
625
+ if (projectInstalled && options.projectRoot) {
626
+ const projectSave = await saveInstalled(projectInstalled, options.projectRoot);
627
+ if (!projectSave.ok) return projectSave;
628
+ }
457
629
 
458
630
  return ok(result);
459
631
  }