@vltpkg/graph 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +62 -3
  2. package/dist/actual/load.js +24 -12
  3. package/dist/build.js +30 -5
  4. package/dist/dependencies.d.ts +11 -0
  5. package/dist/dependencies.js +47 -7
  6. package/dist/filter-nodes-by-query.d.ts +39 -0
  7. package/dist/filter-nodes-by-query.js +100 -0
  8. package/dist/graph.js +3 -0
  9. package/dist/ideal/append-nodes.js +29 -11
  10. package/dist/ideal/get-importer-specs.js +17 -7
  11. package/dist/ideal/remove-satisfied-specs.js +18 -10
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +2 -0
  14. package/dist/install.d.ts +25 -8
  15. package/dist/install.js +25 -1
  16. package/dist/lockfile/load-edges.js +10 -2
  17. package/dist/modifiers.d.ts +11 -0
  18. package/dist/modifiers.js +105 -21
  19. package/dist/reify/add-edge.d.ts +4 -0
  20. package/dist/reify/add-edge.js +13 -7
  21. package/dist/reify/build.d.ts +7 -0
  22. package/dist/reify/check-needed-build.d.ts +5 -0
  23. package/dist/reify/check-needed-build.js +13 -0
  24. package/dist/reify/delete-edge.js +16 -14
  25. package/dist/reify/delete-nodes.d.ts +2 -1
  26. package/dist/reify/delete-nodes.js +4 -2
  27. package/dist/reify/index.d.ts +2 -0
  28. package/dist/reify/index.js +11 -39
  29. package/dist/reify/relink-bins.d.ts +9 -0
  30. package/dist/reify/relink-bins.js +39 -0
  31. package/dist/reify/update-importers-package-json.d.ts +6 -1
  32. package/dist/reify/update-importers-package-json.js +16 -15
  33. package/dist/skills/dirs.d.ts +58 -0
  34. package/dist/skills/dirs.js +211 -0
  35. package/dist/skills/discover.d.ts +73 -0
  36. package/dist/skills/discover.js +238 -0
  37. package/dist/skills/gitignore.d.ts +18 -0
  38. package/dist/skills/gitignore.js +82 -0
  39. package/dist/skills/index.d.ts +124 -0
  40. package/dist/skills/index.js +254 -0
  41. package/dist/skills/lazy.d.ts +8 -0
  42. package/dist/skills/lazy.js +8 -0
  43. package/dist/skills/mask.d.ts +26 -0
  44. package/dist/skills/mask.js +43 -0
  45. package/dist/skills/mounts.d.ts +78 -0
  46. package/dist/skills/mounts.js +162 -0
  47. package/dist/uninstall.d.ts +27 -7
  48. package/dist/uninstall.js +25 -1
  49. package/dist/update.d.ts +24 -4
  50. package/dist/update.js +24 -1
  51. package/dist/visualization/mermaid-output.d.ts +8 -0
  52. package/dist/visualization/mermaid-output.js +25 -8
  53. package/package.json +21 -21
@@ -1,6 +1,7 @@
1
1
  import type { PackageJson } from '@vltpkg/package-json';
2
2
  import type { AddImportersDependenciesMap, RemoveImportersDependenciesMap } from '../dependencies.ts';
3
3
  import type { Graph } from '../graph.ts';
4
+ import type { GraphModifier } from '../modifiers.ts';
4
5
  export type UpdatePackageJsonOptions = {
5
6
  /**
6
7
  * A `Map` in which keys are {@link DepID} linking to another `Map` in which
@@ -37,9 +38,13 @@ export type UpdatePackageJsonOptions = {
37
38
  * Defaults to `^` if not specified. Ignored when `saveExact` is true.
38
39
  */
39
40
  savePrefix?: string;
41
+ /**
42
+ * Graph modifiers, importer edges they govern keep the modifier value.
43
+ */
44
+ modifiers?: GraphModifier;
40
45
  };
41
46
  /**
42
47
  * Updates nodes of a provided {@link Graph} accordingly to the
43
48
  * provided add or remove arguments.
44
49
  */
45
- export declare const updatePackageJson: ({ add, graph, packageJson, remove, saveExact, savePrefix, }: UpdatePackageJsonOptions) => () => void;
50
+ export declare const updatePackageJson: ({ add, graph, packageJson, remove, saveExact, savePrefix, modifiers, }: UpdatePackageJsonOptions) => () => void;
@@ -2,16 +2,10 @@ import { splitDepID } from '@vltpkg/dep-id';
2
2
  import { error } from '@vltpkg/error-cause';
3
3
  import { Spec } from '@vltpkg/spec';
4
4
  import { longDependencyTypes } from '@vltpkg/types';
5
+ import { longDependencyTypeNames } from "../dependencies.js";
5
6
  import { resolveSaveType } from "../resolve-save-type.js";
6
7
  import { calculateSaveValue } from "./calculate-save-value.js";
7
- const depTypesMap = new Map([
8
- ['prod', 'dependencies'],
9
- ['dev', 'devDependencies'],
10
- ['peer', 'peerDependencies'],
11
- ['peerOptional', 'peerDependencies'],
12
- ['optional', 'optionalDependencies'],
13
- ]);
14
- const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
8
+ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix, modifiers) => {
15
9
  const node = graph.nodes.get(nodeId);
16
10
  if (!node) {
17
11
  throw error('Failed to retrieve node', {
@@ -51,16 +45,21 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
51
45
  const [name, dep] = deleteNameOrAddItem;
52
46
  // peerOptional also needs to add peerDependenciesMeta entry
53
47
  const depTypeShort = resolveSaveType(node, name, dep.type);
54
- const depType = depTypesMap.get(depTypeShort);
48
+ const depType = Object.hasOwn(longDependencyTypeNames, depTypeShort) ?
49
+ longDependencyTypeNames[depTypeShort]
50
+ : undefined;
55
51
  if (!depType) {
56
52
  throw error('Failed to retrieve dependency type', {
57
- validOptions: [...depTypesMap.keys()],
53
+ validOptions: Object.keys(longDependencyTypeNames),
58
54
  found: dep.type,
59
55
  });
60
56
  }
61
57
  const edge = node.edgesOut.get(name);
62
58
  if (!edge?.to) {
63
- throw error('Dependency node could not be found');
59
+ throw error(`Dependency node could not be found: ${name}`, {
60
+ spec: dep.spec,
61
+ from: node.location,
62
+ });
64
63
  }
65
64
  const n = edge.to;
66
65
  const [nodeType] = splitDepID(n.id);
@@ -94,8 +93,10 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
94
93
  // the lockfile has to carry the value package.json carries, or a
95
94
  // later install reads back `latest` where the manifest says
96
95
  // `^1.2.3`. anything keyed by the old spec from here on must be
97
- // invalidated at this point.
98
- if (saveValue !== edge.spec.bareSpec) {
96
+ // invalidated at this point. governed edges keep the modifier
97
+ // value instead.
98
+ if (saveValue !== edge.spec.bareSpec &&
99
+ !modifiers?.targetsImporterEdge(node, dep.spec)) {
99
100
  edge.spec = Spec.parse(name, saveValue, dep.spec.options);
100
101
  graph.lockfileStale = true;
101
102
  }
@@ -107,7 +108,7 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
107
108
  * Updates nodes of a provided {@link Graph} accordingly to the
108
109
  * provided add or remove arguments.
109
110
  */
110
- export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact, savePrefix, }) => {
111
+ export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact, savePrefix, modifiers, }) => {
111
112
  const manifestsToUpdate = new Set();
112
113
  const operations = new Set([add, remove]);
113
114
  for (const operation of operations) {
@@ -116,7 +117,7 @@ export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact,
116
117
  // that are nested folders from which the user can also add new
117
118
  // dependencies to
118
119
  for (const nodeId of operation.keys()) {
119
- const manifest = addOrRemoveDeps(nodeId, graph, operation, saveExact, savePrefix);
120
+ const manifest = addOrRemoveDeps(nodeId, graph, operation, saveExact, savePrefix, modifiers);
120
121
  if (manifest) {
121
122
  manifestsToUpdate.add(manifest);
122
123
  }
@@ -0,0 +1,58 @@
1
+ /** Always a target when detecting: read by most agents. */
2
+ export declare const AGENTS_SKILLS = ".agents/skills";
3
+ /** Set (non-empty) inside Claude Code. */
4
+ export declare const CLAUDE_ENV: readonly ["CLAUDECODE", "CLAUDE_CODE"];
5
+ /**
6
+ * Agent folder at the project root → its skills dir.
7
+ */
8
+ export declare const AGENT_ROOTS: readonly (readonly [
9
+ marker: string,
10
+ dir: string
11
+ ])[];
12
+ /**
13
+ * An agent skills dir.
14
+ */
15
+ export type SkillsDir = {
16
+ /** `/`-separated, relative to projectRoot */
17
+ dir: string;
18
+ /** realpath (a missing tail appended as is) */
19
+ real: string;
20
+ /** `<dev>:<ino>` (no inode: the path); unset when missing */
21
+ id?: string;
22
+ };
23
+ /**
24
+ * Result of {@link skillsDirs}.
25
+ */
26
+ export type SkillsDirs = {
27
+ /** dirs to link into */
28
+ targets: SkillsDir[];
29
+ /** existing dirs to look for vlt links in: targets first */
30
+ scan: SkillsDir[];
31
+ /** targets dropped: `<dir> (<reason>)` */
32
+ skipped: string[];
33
+ };
34
+ /**
35
+ * Options for {@link skillsDirs}.
36
+ */
37
+ export type SkillsDirsOptions = {
38
+ projectRoot: string;
39
+ /** `skills-dir`: replaces detection; `[]` none */
40
+ configured?: string[];
41
+ /** default `process.env` */
42
+ env?: Record<string, string | undefined>;
43
+ /** create missing targets */
44
+ create?: boolean;
45
+ };
46
+ /**
47
+ * Throw EUSAGE unless each entry is a dir inside projectRoot, not in
48
+ * `node_modules`.
49
+ */
50
+ export declare const assertSkillsDirs: (projectRoot: string, list?: string[]) => void;
51
+ /**
52
+ * The project's agent skill dirs. `configured` replaces detection:
53
+ * `.agents/skills`, plus `<folder>/skills` for each agent folder at
54
+ * the root, `.claude/skills` inside Claude Code, and existing
55
+ * `.<name>/skills` dirs. Dirs must resolve inside projectRoot, not in
56
+ * `node_modules`; the same dir is listed once.
57
+ */
58
+ export declare const skillsDirs: ({ projectRoot, configured, env, create, }: SkillsDirsOptions) => SkillsDirs;
@@ -0,0 +1,211 @@
1
+ import { error } from '@vltpkg/error-cause';
2
+ import { lstatSync, mkdirSync, readdirSync, realpathSync, statSync, } from 'node:fs';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { norm } from "./mounts.js";
5
+ /** Always a target when detecting: read by most agents. */
6
+ export const AGENTS_SKILLS = '.agents/skills';
7
+ /** Set (non-empty) inside Claude Code. */
8
+ export const CLAUDE_ENV = ['CLAUDECODE', 'CLAUDE_CODE'];
9
+ /**
10
+ * Agent folder at the project root → its skills dir.
11
+ */
12
+ // vercel-labs/skills v1.7.2 src/agents.ts: skillsDir per agent (line), dot roots only
13
+ export const AGENT_ROOTS = [
14
+ ['.adal', '.adal/skills'], // 813
15
+ ['.aider-desk', '.aider-desk/skills'], // 84
16
+ ['.augment', '.augment/skills'], // 140
17
+ ['.autohand', '.autohand/skills'], // 131
18
+ ['.bob', '.bob/skills'], // 149
19
+ ['.claude', '.claude/skills'], // 158
20
+ ['.codeartsdoer', '.codeartsdoer/skills'], // 191
21
+ ['.codebuddy', '.codebuddy/skills'], // 200
22
+ ['.codemaker', '.codemaker/skills'], // 209
23
+ ['.codestudio', '.codestudio/skills'], // 218
24
+ ['.commandcode', '.commandcode/skills'], // 236
25
+ ['.continue', '.continue/skills'], // 245
26
+ ['.cortex', '.cortex/skills'], // 254
27
+ ['.crush', '.crush/skills'], // 263
28
+ ['.devin', '.devin/skills'], // 291
29
+ ['.forge', '.forge/skills'], // 345
30
+ ['.goose', '.goose/skills'], // 382
31
+ ['.grok', '.grok/skills'], // 391
32
+ ['.hermes', '.hermes/skills'], // 400
33
+ ['.iflow', '.iflow/skills'], // 436
34
+ ['.inferencesh', '.inferencesh/skills'], // 409
35
+ ['.jazz', '.jazz/skills'], // 418
36
+ ['.junie', '.junie/skills'], // 427
37
+ ['.kimchi', '.kimchi/skills'], // 456
38
+ ['.kiro', '.kiro/skills'], // 474
39
+ ['.kode', '.kode/skills'], // 483
40
+ ['.lingma', '.lingma/skills'], // 492
41
+ ['.mcpjam', '.mcpjam/skills'], // 511
42
+ ['.minimax', '.minimax/skills'], // 520
43
+ ['.moxby', '.moxby/skills'], // 538
44
+ ['.mux', '.mux/skills'], // 547
45
+ ['.neovate', '.neovate/skills'], // 782
46
+ ['.ona', '.ona/skills'], // 574
47
+ ['.openhands', '.openhands/skills'], // 565
48
+ ['.pochi', '.pochi/skills'], // 791
49
+ ['.posit/assistant', '.posit/assistant/skills'], // 595
50
+ ['.qoder', '.qoder/skills'], // 604,613
51
+ ['.qwen', '.qwen/skills'], // 622
52
+ ['.reasonix', '.reasonix/skills'], // 641
53
+ ['.roo', '.roo/skills'], // 659
54
+ ['.rovodev', '.rovodev/skills'], // 650
55
+ ['.tabnine', '.tabnine/agent/skills'], // 678
56
+ ['.terramind', '.terramind/skills'], // 687
57
+ ['.tinycloud', '.tinycloud/skills'], // 696
58
+ ['.trae', '.trae/skills'], // 705,714
59
+ ['.vibe', '.vibe/skills'], // 529
60
+ ['.windsurf', '.windsurf/skills'], // 732
61
+ ['.zcode', '.zcode/skills'], // 755
62
+ ['.zencoder', '.zencoder/skills'], // 764,773
63
+ ];
64
+ // strictly below root
65
+ const inside = (root, p) => {
66
+ const r = relative(root, p);
67
+ return (!!r && r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r));
68
+ };
69
+ // a `node_modules` segment below root
70
+ const inNodeModules = (root, p) => relative(root, p)
71
+ .split(sep)
72
+ .some(s => s.toLowerCase() === 'node_modules');
73
+ const isDir = (path) => {
74
+ try {
75
+ return statSync(path).isDirectory();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ };
81
+ /**
82
+ * Throw EUSAGE unless each entry is a dir inside projectRoot, not in
83
+ * `node_modules`.
84
+ */
85
+ export const assertSkillsDirs = (projectRoot, list = []) => {
86
+ for (const e of list) {
87
+ const p = resolve(projectRoot, e);
88
+ if (e &&
89
+ (!inside(projectRoot, p) || inNodeModules(projectRoot, p))) {
90
+ throw error('Invalid --skills-dir: must be a dir inside the project, not in node_modules', { code: 'EUSAGE', found: e }, assertSkillsDirs);
91
+ }
92
+ }
93
+ };
94
+ // walk `dir` from the real root, one component at a time, so a
95
+ // symlink out of the project is caught before anything is made in it.
96
+ // A string is the reason it was skipped (fs errors: the code)
97
+ const walk = (realRoot, dir, create) => {
98
+ const parts = dir.split('/');
99
+ let real = realRoot;
100
+ try {
101
+ for (const [i, part] of parts.entries()) {
102
+ const next = join(real, part);
103
+ let st = lstatSync(next, { throwIfNoEntry: false });
104
+ if (!st) {
105
+ if (!create)
106
+ return { dir, real: join(real, ...parts.slice(i)) };
107
+ try {
108
+ mkdirSync(next);
109
+ }
110
+ catch (er) {
111
+ if (er.code !== 'EEXIST')
112
+ throw er;
113
+ }
114
+ st = lstatSync(next);
115
+ }
116
+ if (st.isSymbolicLink()) {
117
+ let r;
118
+ try {
119
+ r = realpathSync(next);
120
+ }
121
+ catch {
122
+ return 'not a directory';
123
+ }
124
+ if (!inside(realRoot, r))
125
+ return 'outside the project';
126
+ if (inNodeModules(realRoot, r))
127
+ return 'inside node_modules';
128
+ if (!isDir(r))
129
+ return 'not a directory';
130
+ real = r;
131
+ }
132
+ else if (st.isDirectory()) {
133
+ real = next;
134
+ }
135
+ else {
136
+ return 'not a directory';
137
+ }
138
+ }
139
+ const { dev, ino } = statSync(real, { bigint: true });
140
+ // some filesystems report no inode: compare paths
141
+ return { dir, real, id: ino ? `${dev}:${ino}` : norm(real) };
142
+ }
143
+ catch (er) {
144
+ return String(er.code);
145
+ }
146
+ };
147
+ /**
148
+ * The project's agent skill dirs. `configured` replaces detection:
149
+ * `.agents/skills`, plus `<folder>/skills` for each agent folder at
150
+ * the root, `.claude/skills` inside Claude Code, and existing
151
+ * `.<name>/skills` dirs. Dirs must resolve inside projectRoot, not in
152
+ * `node_modules`; the same dir is listed once.
153
+ */
154
+ export const skillsDirs = ({ projectRoot, configured, env = process.env, create = false, }) => {
155
+ const realRoot = realpathSync(projectRoot);
156
+ const res = { targets: [], scan: [], skipped: [] };
157
+ const dots = readdirSync(realRoot)
158
+ .filter(e => e.startsWith('.'))
159
+ .sort((a, b) => a.localeCompare(b, 'en'))
160
+ .map(e => `${e}/skills`);
161
+ let list;
162
+ if (configured) {
163
+ assertSkillsDirs(projectRoot, configured);
164
+ list = configured.filter(Boolean);
165
+ }
166
+ else {
167
+ const found = new Set();
168
+ for (const [marker, d] of AGENT_ROOTS) {
169
+ if (isDir(join(realRoot, marker)))
170
+ found.add(d);
171
+ }
172
+ if (CLAUDE_ENV.some(k => env[k]))
173
+ found.add('.claude/skills');
174
+ for (const d of dots) {
175
+ if (isDir(join(realRoot, d)))
176
+ found.add(d);
177
+ }
178
+ list = [
179
+ AGENTS_SKILLS,
180
+ ...[...found].sort((a, b) => a.localeCompare(b, 'en')),
181
+ ];
182
+ }
183
+ const dirs = new Set(list.map(e => relative(projectRoot, resolve(projectRoot, e))
184
+ .split(sep)
185
+ .join('/')));
186
+ const seen = new Set();
187
+ for (const dir of dirs) {
188
+ const d = walk(realRoot, dir, create);
189
+ if (typeof d === 'string') {
190
+ res.skipped.push(`${dir} (${d})`);
191
+ }
192
+ else if (!seen.has(d.id ?? d.real)) {
193
+ seen.add(d.id ?? d.real);
194
+ res.targets.push(d);
195
+ if (d.id)
196
+ res.scan.push(d);
197
+ }
198
+ }
199
+ for (const dir of [
200
+ AGENTS_SKILLS,
201
+ ...AGENT_ROOTS.map(([, d]) => d),
202
+ ...dots,
203
+ ]) {
204
+ const d = walk(realRoot, dir, false);
205
+ if (typeof d !== 'string' && d.id && !seen.has(d.id)) {
206
+ seen.add(d.id);
207
+ res.scan.push(d);
208
+ }
209
+ }
210
+ return res;
211
+ };
@@ -0,0 +1,73 @@
1
+ import type { DepID } from '@vltpkg/dep-id';
2
+ import type { PathScurry } from 'path-scurry';
3
+ import type { Node } from '../node.ts';
4
+ import type { SkillMount } from './mounts.ts';
5
+ /**
6
+ * An agent skill (a dir with a `SKILL.md`) shipped by an installed
7
+ * package. See https://agentskills.io/specification
8
+ */
9
+ export type Skill = {
10
+ /** package name, e.g. `@s/bar` */
11
+ name: string;
12
+ /**
13
+ * name a direct dep is installed under, when not `name`, as `vlt ls`
14
+ * shows it
15
+ */
16
+ alias?: string;
17
+ /** package version */
18
+ version?: string;
19
+ /** package DepID */
20
+ id: DepID;
21
+ /**
22
+ * skill name: its dir name (`skills/<dir>`, `dist/skills/<dir>`,
23
+ * `.agents/skills/<dir>`), or the root SKILL.md `name`
24
+ */
25
+ skill: string;
26
+ /**
27
+ * `SKILL.md` frontmatter `name`, when it differs from `skill`
28
+ * (matched by `:skill()`)
29
+ */
30
+ frontmatterName?: string;
31
+ /** frontmatter description (folded to one line, control chars stripped) */
32
+ description?: string;
33
+ /** realpath of the dir holding SKILL.md */
34
+ path: string;
35
+ /** dirs holding a vlt link named `skill` to `path` */
36
+ linked: string[];
37
+ };
38
+ /**
39
+ * Result of `discoverSkills`.
40
+ */
41
+ export type DiscoveredSkills = {
42
+ /** skills of the picked version of each package, sorted by name */
43
+ skills: Skill[];
44
+ /** every scanned package version, w/ the skills it ships */
45
+ scanned: Map<DepID, Pick<Skill, 'skill' | 'path'>[]>;
46
+ };
47
+ /**
48
+ * The `SKILL.md` frontmatter fields vlt reads.
49
+ */
50
+ export type SkillFrontmatter = {
51
+ name?: string;
52
+ description?: string;
53
+ };
54
+ /**
55
+ * Read `name` and `description` from a `SKILL.md` YAML frontmatter.
56
+ * Minimal line reader: top-level keys, plain/quoted/folded values.
57
+ */
58
+ export declare const readFrontmatter: (file: string) => SkillFrontmatter;
59
+ /** Ships agent skills, as `discoverSkills` finds them. */
60
+ export declare const shipsSkills: (node: Node) => boolean;
61
+ /**
62
+ * Find the agent skills shipped by the given installed packages.
63
+ *
64
+ * Only packages in the vlt store and the project's workspaces (not its
65
+ * root) are scanned, for a root `SKILL.md`, then `<dir>/SKILL.md` in
66
+ * `skills/`, `dist/skills/` and `.agents/skills/`: the first skill of a
67
+ * name wins. One version per package is picked, even if it ships none:
68
+ * a workspace or dep of the main importer, else a dep of any importer,
69
+ * else the highest version, then DepID. Same-name skills of different
70
+ * packages are all returned, sorted by rank then DepID (`linkSkills`
71
+ * resolves them). `mounts` set each skill's `linked` dirs.
72
+ */
73
+ export declare const discoverSkills: (nodes: Iterable<Node>, scurry: PathScurry, mounts?: SkillMount[]) => DiscoveredSkills;
@@ -0,0 +1,238 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { splitDepID } from '@vltpkg/dep-id';
4
+ import { parse } from '@vltpkg/semver';
5
+ import { lstat, norm, packageKey, realOr } from "./mounts.js";
6
+ // safe single path segment, no dotfiles or scopes
7
+ const skillDirName = /^[a-z0-9][a-z0-9._-]*$/i;
8
+ // agentskills.io `name`
9
+ const specName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ const topKey = /^([A-Za-z0-9_-]+):/;
11
+ const blockIndicator = /^[>|][-+1-9]*$/;
12
+ const fold = (inline, more) => {
13
+ let value = [blockIndicator.test(inline) ? '' : inline, ...more]
14
+ .filter(Boolean)
15
+ .join(' ');
16
+ const q = value[0];
17
+ if (value.length > 1 &&
18
+ (q === '"' || q === "'") &&
19
+ value.endsWith(q)) {
20
+ value = value.slice(1, -1).trim();
21
+ }
22
+ return value || undefined;
23
+ };
24
+ /**
25
+ * Read `name` and `description` from a `SKILL.md` YAML frontmatter.
26
+ * Minimal line reader: top-level keys, plain/quoted/folded values.
27
+ */
28
+ export const readFrontmatter = (file) => {
29
+ let text;
30
+ try {
31
+ text = readFileSync(file, 'utf8');
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ const lines = text.split(/\r?\n/);
37
+ if (lines[0]?.trim() !== '---')
38
+ return {};
39
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
40
+ if (end === -1)
41
+ return {};
42
+ const fm = {};
43
+ let key;
44
+ let inline = '';
45
+ let more = [];
46
+ const flush = () => {
47
+ if (key)
48
+ fm[key] = fold(inline, more);
49
+ key = undefined;
50
+ };
51
+ for (const line of lines.slice(1, end)) {
52
+ if (!line.trim())
53
+ continue;
54
+ const m = topKey.exec(line);
55
+ if (m) {
56
+ flush();
57
+ const k = m[1];
58
+ if (k === 'name' || k === 'description') {
59
+ key = k;
60
+ inline = line.slice(k.length + 1).trim();
61
+ // drop a ` # comment` from unquoted values
62
+ const hash = inline.search(/(?:^|\s)#/);
63
+ if (hash !== -1 && !/^["']/.test(inline)) {
64
+ inline = inline.slice(0, hash).trim();
65
+ }
66
+ more = [];
67
+ }
68
+ }
69
+ else if (/^\s/.test(line)) {
70
+ if (key)
71
+ more.push(line.trim());
72
+ }
73
+ else {
74
+ flush();
75
+ }
76
+ }
77
+ flush();
78
+ return fm;
79
+ };
80
+ // package-controlled text printed to terminals
81
+ const sanitize = (s) => {
82
+ const clean = s
83
+ ?.replace(/\p{Cc}/gu, ' ')
84
+ .replace(/\s+/g, ' ')
85
+ .trim();
86
+ return clean ? clean.slice(0, 1024) : undefined;
87
+ };
88
+ const lc = (s) => s.toLowerCase();
89
+ const isDir = (path) => !!lstat(path)?.isDirectory();
90
+ const isFile = (path) => !!lstat(path)?.isFile();
91
+ // `skills/SKILL.md` would make the dir look like a skill
92
+ const isSkillMd = (name) => name.toLowerCase() === 'skill.md';
93
+ // where packages ship skills, in precedence order (a name found first
94
+ // wins), after a root SKILL.md
95
+ const shipDirs = ['skills', 'dist/skills', '.agents/skills'];
96
+ // a real dir, no symlink on the way: skills stay inside the package
97
+ const subdir = (dir, sub) => {
98
+ let path = dir;
99
+ for (const seg of sub.split('/')) {
100
+ path = resolve(path, seg);
101
+ if (!isDir(path))
102
+ return undefined;
103
+ }
104
+ return path;
105
+ };
106
+ const scan = (dir, pkgName) => {
107
+ const found = [];
108
+ const has = (skill) => found.some(f => lc(f.skill) === lc(skill));
109
+ const root = resolve(dir, 'SKILL.md');
110
+ if (isFile(root)) {
111
+ const { name, description } = readFrontmatter(root);
112
+ const skill = name && name.length <= 64 && specName.test(name) ?
113
+ name
114
+ : pkgName.slice(pkgName.lastIndexOf('/') + 1);
115
+ if (skillDirName.test(skill) && !isSkillMd(skill)) {
116
+ found.push({ skill, path: dir, name, description });
117
+ }
118
+ }
119
+ for (const sub of shipDirs) {
120
+ const skillsDir = subdir(dir, sub);
121
+ if (!skillsDir)
122
+ continue;
123
+ // one per name, any case
124
+ for (const d of readdirSync(skillsDir).sort()) {
125
+ if (!skillDirName.test(d) || isSkillMd(d) || has(d))
126
+ continue;
127
+ const path = resolve(skillsDir, d);
128
+ const file = resolve(path, 'SKILL.md');
129
+ if (!isDir(path) || !isFile(file))
130
+ continue;
131
+ const { name, description } = readFrontmatter(file);
132
+ found.push({ skill: d, path, name, description });
133
+ }
134
+ }
135
+ return found;
136
+ };
137
+ // 0: a workspace or dep of the main importer, 1: dep of any
138
+ // importer, 2: transitive
139
+ const rank = (node) => {
140
+ if (node.importer)
141
+ return 0;
142
+ let r = 2;
143
+ for (const { from } of node.edgesIn) {
144
+ if (from.mainImporter)
145
+ return 0;
146
+ if (from.importer)
147
+ r = 1;
148
+ }
149
+ return r;
150
+ };
151
+ const ver = (node) => node.version ? parse(node.version) : undefined;
152
+ // higher version first, if both valid
153
+ const byVersion = (a, b) => {
154
+ const va = ver(a);
155
+ const vb = ver(b);
156
+ return va && vb ? vb.compare(va) : 0;
157
+ };
158
+ const compare = (a, b) => rank(a) - rank(b) ||
159
+ byVersion(a, b) ||
160
+ a.id.localeCompare(b.id, 'en');
161
+ // the name a direct dep is installed under, if not its own
162
+ const aliasOf = (node) => {
163
+ const edges = [...node.edgesIn];
164
+ const n = (edges.find(e => e.from.mainImporter) ??
165
+ edges.find(e => e.from.importer))?.name;
166
+ return n === node.name ? undefined : n;
167
+ };
168
+ // store pkgs and workspaces, not the project root; lockfile keeps a
169
+ // tarball's path as its location
170
+ const scannable = (node) => node.importer ?
171
+ !node.mainImporter
172
+ : node.inVltStore() && splitDepID(node.id)[0] !== 'file';
173
+ /** Ships agent skills, as `discoverSkills` finds them. */
174
+ export const shipsSkills = (node) => scannable(node) &&
175
+ scan(realOr(resolve(node.projectRoot, node.location)), node.name)
176
+ .length > 0;
177
+ /**
178
+ * Find the agent skills shipped by the given installed packages.
179
+ *
180
+ * Only packages in the vlt store and the project's workspaces (not its
181
+ * root) are scanned, for a root `SKILL.md`, then `<dir>/SKILL.md` in
182
+ * `skills/`, `dist/skills/` and `.agents/skills/`: the first skill of a
183
+ * name wins. One version per package is picked, even if it ships none:
184
+ * a workspace or dep of the main importer, else a dep of any importer,
185
+ * else the highest version, then DepID. Same-name skills of different
186
+ * packages are all returned, sorted by rank then DepID (`linkSkills`
187
+ * resolves them). `mounts` set each skill's `linked` dirs.
188
+ */
189
+ export const discoverSkills = (nodes, scurry, mounts = []) => {
190
+ const key = (name, path) => `${lc(name)}\0${norm(path)}`;
191
+ const linkedIn = new Map();
192
+ for (const m of mounts) {
193
+ const k = key(m.name, realOr(m.target));
194
+ const dirs = linkedIn.get(k) ?? [];
195
+ if (!dirs.includes(m.dir.dir))
196
+ dirs.push(m.dir.dir);
197
+ linkedIn.set(k, dirs);
198
+ }
199
+ const scanned = new Map();
200
+ const byPkg = new Map();
201
+ for (const node of nodes) {
202
+ if (!scannable(node))
203
+ continue;
204
+ const found = scan(realOr(node.resolvedLocation(scurry)), node.name);
205
+ scanned.set(node.id, found.map(({ skill, path }) => ({ skill, path })));
206
+ const key = packageKey(node.id);
207
+ const list = byPkg.get(key) ?? [];
208
+ list.push({ node, found });
209
+ byPkg.set(key, list);
210
+ }
211
+ const rows = [];
212
+ for (const list of byPkg.values()) {
213
+ const { node, found } = list.reduce((a, b) => compare(a.node, b.node) <= 0 ? a : b);
214
+ for (const { skill, path, name, description } of found) {
215
+ const fm = sanitize(name);
216
+ rows.push([
217
+ {
218
+ name: node.name,
219
+ alias: aliasOf(node),
220
+ version: node.version,
221
+ id: node.id,
222
+ skill,
223
+ ...(fm && lc(fm) !== lc(skill) && { frontmatterName: fm }),
224
+ description: sanitize(description),
225
+ path,
226
+ linked: linkedIn.get(key(skill, path)) ?? [],
227
+ },
228
+ rank(node),
229
+ ]);
230
+ }
231
+ }
232
+ const skills = rows
233
+ .sort(([a, ra], [b, rb]) => lc(a.skill).localeCompare(lc(b.skill), 'en') ||
234
+ ra - rb ||
235
+ a.id.localeCompare(b.id, 'en'))
236
+ .map(([s]) => s);
237
+ return { skills, scanned };
238
+ };