@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 +1 -1
- package/src/config.ts +25 -8
- package/src/doctor.ts +2 -3
- package/src/frontmatter.test.ts +85 -0
- package/src/frontmatter.ts +26 -7
- package/src/install.npm.test.ts +5 -2
- package/src/install.test.ts +53 -0
- package/src/install.ts +3 -2
- package/src/link.ts +3 -2
- package/src/remove.ts +3 -2
- package/src/schemas/config.ts +2 -0
- package/src/symlink.ts +1 -1
- package/src/taps.ts +104 -3
- package/src/update.test.ts +70 -0
- package/src/update.ts +254 -82
package/package.json
CHANGED
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
|
-
#
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
195
|
-
const file = join(dir, "installed.json");
|
|
204
|
+
const file = getInstalledPath(projectRoot);
|
|
196
205
|
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/frontmatter.ts
CHANGED
|
@@ -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
|
-
|
|
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 === "
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|
package/src/install.npm.test.ts
CHANGED
|
@@ -439,7 +439,10 @@ describe("updateSkill — npm", () => {
|
|
|
439
439
|
integrity: v2.integrity,
|
|
440
440
|
});
|
|
441
441
|
|
|
442
|
-
const result = await updateSkill(
|
|
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
|
});
|
package/src/install.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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);
|
package/src/schemas/config.ts
CHANGED
|
@@ -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
|
package/src/update.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (
|
|
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
|
-
|
|
456
|
-
|
|
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
|
}
|