skvlt 0.9.9

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.
@@ -0,0 +1,78 @@
1
+ import type {
2
+ LockFile,
3
+ ManifestBuildResult,
4
+ ManifestScope,
5
+ } from "./manifest-types";
6
+
7
+ export type { LockFile, ManifestBuildResult, ManifestScope };
8
+
9
+ const manifestHeaderComment =
10
+ "# Generated by Skills Vault: https://github.com/xixu-me/skills-vault";
11
+
12
+ function encodeDoubleQuotedYamlScalar(value: string): string {
13
+ return JSON.stringify(value);
14
+ }
15
+
16
+ /**
17
+ * Groups installed skills by source and renders the canonical manifest snapshot.
18
+ */
19
+ export function buildManifest(
20
+ installedSkillNames: string[],
21
+ lockFile: LockFile,
22
+ scope: ManifestScope = "global",
23
+ ): ManifestBuildResult {
24
+ const lockSkills = lockFile.skills ?? {};
25
+ const missingSources: string[] = [];
26
+ const groups = new Map<string, string[]>();
27
+
28
+ for (const skillName of installedSkillNames) {
29
+ const source = lockSkills[skillName]?.source;
30
+ if (!source) {
31
+ missingSources.push(skillName);
32
+ continue;
33
+ }
34
+
35
+ const group = groups.get(source);
36
+ if (group) {
37
+ group.push(skillName);
38
+ } else {
39
+ groups.set(source, [skillName]);
40
+ }
41
+ }
42
+
43
+ if (missingSources.length > 0) {
44
+ throw new Error(
45
+ `Installed skill(s) missing tracked source in lock file: ${missingSources.join(", ")}`,
46
+ );
47
+ }
48
+
49
+ const sortedSources = Array.from(groups.keys()).sort((left, right) =>
50
+ left.localeCompare(right),
51
+ );
52
+ const lines: string[] = [manifestHeaderComment];
53
+
54
+ lines.push(`total_sources: ${sortedSources.length}`);
55
+ lines.push(`total_skills: ${installedSkillNames.length}`);
56
+ lines.push(`scope: ${encodeDoubleQuotedYamlScalar(scope)}`);
57
+ lines.push("");
58
+ lines.push("sources:");
59
+
60
+ for (const source of sortedSources) {
61
+ const skills = [...(groups.get(source) ?? [])].sort((left, right) =>
62
+ left.localeCompare(right),
63
+ );
64
+ lines.push(` ${encodeDoubleQuotedYamlScalar(source)}:`);
65
+ lines.push(` count: ${skills.length}`);
66
+ lines.push(" skills:");
67
+ for (const skill of skills) {
68
+ lines.push(` - ${encodeDoubleQuotedYamlScalar(skill)}`);
69
+ }
70
+ lines.push("");
71
+ }
72
+
73
+ return {
74
+ yaml: `${lines.join("\n")}\n`,
75
+ totalSources: sortedSources.length,
76
+ totalSkills: installedSkillNames.length,
77
+ };
78
+ }
@@ -0,0 +1,27 @@
1
+ export type ManifestScope = "global" | "project";
2
+
3
+ export type ManifestSourceEntry = {
4
+ count: number;
5
+ skills: string[];
6
+ };
7
+
8
+ export type Manifest = {
9
+ scope: ManifestScope;
10
+ totalSources: number;
11
+ totalSkills: number;
12
+ sources: Map<string, ManifestSourceEntry>;
13
+ };
14
+
15
+ export type LockSkillEntry = {
16
+ source?: string;
17
+ };
18
+
19
+ export type LockFile = {
20
+ skills?: Record<string, LockSkillEntry>;
21
+ };
22
+
23
+ export type ManifestBuildResult = {
24
+ yaml: string;
25
+ totalSources: number;
26
+ totalSkills: number;
27
+ };
@@ -0,0 +1,200 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { formatCliPath } from "../paths/format-cli-path";
3
+ import type {
4
+ Manifest,
5
+ ManifestScope,
6
+ ManifestSourceEntry,
7
+ } from "./manifest-types";
8
+
9
+ export type { Manifest, ManifestScope, ManifestSourceEntry };
10
+
11
+ function decodeYamlScalar(value: string): string {
12
+ return value.replaceAll("''", "'");
13
+ }
14
+
15
+ function decodeDoubleQuotedYamlScalar(value: string): string {
16
+ return JSON.parse(`"${value}"`) as string;
17
+ }
18
+
19
+ function parseQuotedSourceLine(line: string): string | null {
20
+ let match = line.match(/^ '((?:[^']|'')*)':$/);
21
+ if (match) {
22
+ return decodeYamlScalar(match[1]);
23
+ }
24
+
25
+ match = line.match(/^ "((?:[^"\\]|\\.)*)":$/);
26
+ if (match) {
27
+ return decodeDoubleQuotedYamlScalar(match[1]);
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ function parseQuotedSkillLine(line: string): string | null {
34
+ let match = line.match(/^ - '((?:[^']|'')*)'$/);
35
+ if (match) {
36
+ return decodeYamlScalar(match[1]);
37
+ }
38
+
39
+ match = line.match(/^ - "((?:[^"\\]|\\.)*)"$/);
40
+ if (match) {
41
+ return decodeDoubleQuotedYamlScalar(match[1]);
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Parses manifest text into a validated in-memory model.
49
+ */
50
+ export function parseManifestText(text: string, sourceLabel: string): Manifest {
51
+ const lines = text.split(/\r?\n/);
52
+ const sources = new Map<string, ManifestSourceEntry>();
53
+
54
+ let totalSources: number | null = null;
55
+ let totalSkills: number | null = null;
56
+ let scope: ManifestScope | null = null;
57
+ let currentSource: string | null = null;
58
+
59
+ lines.forEach((rawLine, index) => {
60
+ const line = rawLine.trimEnd();
61
+ const lineNumber = index + 1;
62
+ const trimmedLine = line.trim();
63
+
64
+ if (trimmedLine.length === 0 || trimmedLine.startsWith("#")) {
65
+ return;
66
+ }
67
+
68
+ let match = line.match(/^total_sources:\s+(\d+)$/);
69
+ if (match) {
70
+ totalSources = Number(match[1]);
71
+ return;
72
+ }
73
+
74
+ match = line.match(/^total_skills:\s+(\d+)$/);
75
+ if (match) {
76
+ totalSkills = Number(match[1]);
77
+ return;
78
+ }
79
+
80
+ match = line.match(/^scope:\s+'(global|project)'$/);
81
+ if (match) {
82
+ scope = match[1] as ManifestScope;
83
+ return;
84
+ }
85
+
86
+ match = line.match(/^scope:\s+"(global|project)"$/);
87
+ if (match) {
88
+ scope = match[1] as ManifestScope;
89
+ return;
90
+ }
91
+
92
+ if (line === "sources:") {
93
+ return;
94
+ }
95
+
96
+ const parsedSource = parseQuotedSourceLine(line);
97
+ if (parsedSource !== null) {
98
+ currentSource = parsedSource;
99
+ sources.set(currentSource, { count: Number.NaN, skills: [] });
100
+ return;
101
+ }
102
+
103
+ match = line.match(/^ count:\s+(\d+)$/);
104
+ if (match) {
105
+ if (!currentSource) {
106
+ throw new Error(
107
+ `Found count before source at ${sourceLabel}:${lineNumber}`,
108
+ );
109
+ }
110
+ const entry = sources.get(currentSource);
111
+ if (!entry) {
112
+ throw new Error(
113
+ `Internal parser error at ${sourceLabel}:${lineNumber}`,
114
+ );
115
+ }
116
+ entry.count = Number(match[1]);
117
+ return;
118
+ }
119
+
120
+ if (line === " skills:") {
121
+ if (!currentSource) {
122
+ throw new Error(
123
+ `Found skills block before source at ${sourceLabel}:${lineNumber}`,
124
+ );
125
+ }
126
+ return;
127
+ }
128
+
129
+ const parsedSkill = parseQuotedSkillLine(line);
130
+ if (parsedSkill !== null) {
131
+ if (!currentSource) {
132
+ throw new Error(
133
+ `Found skill entry before source at ${sourceLabel}:${lineNumber}`,
134
+ );
135
+ }
136
+ const entry = sources.get(currentSource);
137
+ if (!entry) {
138
+ throw new Error(
139
+ `Internal parser error at ${sourceLabel}:${lineNumber}`,
140
+ );
141
+ }
142
+ entry.skills.push(parsedSkill);
143
+ return;
144
+ }
145
+
146
+ throw new Error(
147
+ `Unsupported manifest line at ${sourceLabel}:${lineNumber} -> ${line}`,
148
+ );
149
+ });
150
+
151
+ if (totalSources === null || totalSkills === null) {
152
+ throw new Error(
153
+ `Manifest is missing total_sources or total_skills: ${sourceLabel}`,
154
+ );
155
+ }
156
+
157
+ let actualSkillCount = 0;
158
+ for (const [source, entry] of sources) {
159
+ actualSkillCount += entry.skills.length;
160
+ if (!Number.isFinite(entry.count)) {
161
+ throw new Error(`Source '${source}' is missing count in ${sourceLabel}`);
162
+ }
163
+ if (entry.count !== entry.skills.length) {
164
+ throw new Error(
165
+ `Source '${source}' count mismatch in ${sourceLabel}: declared ${entry.count}, actual ${entry.skills.length}`,
166
+ );
167
+ }
168
+ }
169
+
170
+ if (totalSources !== sources.size) {
171
+ throw new Error(
172
+ `Manifest total_sources mismatch in ${sourceLabel}: declared ${totalSources}, actual ${sources.size}`,
173
+ );
174
+ }
175
+
176
+ if (totalSkills !== actualSkillCount) {
177
+ throw new Error(
178
+ `Manifest total_skills mismatch in ${sourceLabel}: declared ${totalSkills}, actual ${actualSkillCount}`,
179
+ );
180
+ }
181
+
182
+ return {
183
+ // Older manifests did not record scope, so global remains the safe default.
184
+ scope: scope ?? "global",
185
+ totalSources,
186
+ totalSkills,
187
+ sources,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Reads and parses a manifest file from disk.
193
+ */
194
+ export function parseManifest(path: string): Manifest {
195
+ if (!existsSync(path)) {
196
+ throw new Error(`Manifest not found: ${formatCliPath(path)}`);
197
+ }
198
+
199
+ return parseManifestText(readFileSync(path, "utf8"), path);
200
+ }
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const defaultManifestOptionPath = "./skvlt.yaml";
5
+
6
+ /**
7
+ * Resolves the default manifest path against the caller's working directory.
8
+ */
9
+ export function resolveDefaultManifestPath(baseDir: string = process.cwd()) {
10
+ return join(baseDir, "skvlt.yaml");
11
+ }
12
+
13
+ export const defaultGlobalLockFilePath = join(
14
+ homedir(),
15
+ ".agents",
16
+ ".skill-lock.json",
17
+ );
18
+
19
+ export const defaultGlobalSkillsPath = join(homedir(), ".agents", "skills");
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Normalizes filesystem paths for human-facing CLI output on the current host.
3
+ */
4
+ export function formatCliPath(
5
+ filePath: string,
6
+ platform: NodeJS.Platform = process.platform,
7
+ ): string {
8
+ if (platform !== "win32") {
9
+ return filePath;
10
+ }
11
+
12
+ return filePath.replaceAll("/", "\\");
13
+ }
@@ -0,0 +1,27 @@
1
+ import { runBunx, type BunxRunner } from "./run-bunx";
2
+
3
+ /**
4
+ * Lists installed skill names for the selected scope via the upstream Skills CLI.
5
+ */
6
+ export async function listInstalledSkillNames(
7
+ projectScope: boolean,
8
+ runBunxCommand: BunxRunner = runBunx,
9
+ ): Promise<string[]> {
10
+ const args = ["skills", "ls", "--json"];
11
+ if (!projectScope) {
12
+ args.splice(2, 0, "-g");
13
+ }
14
+
15
+ const result = await runBunxCommand(args);
16
+ if (result.exitCode !== 0) {
17
+ throw new Error(
18
+ `Unable to list installed skills for ${projectScope ? "project" : "global"} scope.\n${result.stderr || result.stdout}`,
19
+ );
20
+ }
21
+
22
+ const items = JSON.parse(result.stdout) as Array<{ name?: string }>;
23
+ return items
24
+ .map((item) => item.name)
25
+ .filter((name): name is string => typeof name === "string")
26
+ .sort((left, right) => left.localeCompare(right));
27
+ }
@@ -0,0 +1,27 @@
1
+ export type BunxResult = {
2
+ stdout: string;
3
+ stderr: string;
4
+ exitCode: number;
5
+ };
6
+
7
+ export type BunxRunner = (args: string[]) => Promise<BunxResult>;
8
+
9
+ /**
10
+ * Runs `bunx` in the current workspace and captures its full text output.
11
+ */
12
+ export async function runBunx(args: string[]): Promise<BunxResult> {
13
+ const proc = Bun.spawn({
14
+ cmd: ["bunx", ...args],
15
+ cwd: process.cwd(),
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+
20
+ const [stdout, stderr, exitCode] = await Promise.all([
21
+ new Response(proc.stdout).text(),
22
+ new Response(proc.stderr).text(),
23
+ proc.exited,
24
+ ]);
25
+
26
+ return { stdout, stderr, exitCode };
27
+ }