@nylorun/agents 0.2.0-beta → 0.4.0-beta

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,171 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import matter from "gray-matter";
4
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export class SkillsError extends Error {
6
+ code;
7
+ diagnostics;
8
+ constructor(code, message, diagnostics = []) {
9
+ super(message);
10
+ this.name = "SkillsError";
11
+ this.code = code;
12
+ this.diagnostics = diagnostics;
13
+ }
14
+ }
15
+ /** Resolve a skills catalog directory; throws when missing or not a directory. */
16
+ export function resolveSkillsRoot(directory) {
17
+ const resolved = resolve(directory);
18
+ if (!existsSync(resolved))
19
+ throw new SkillsError("skills.missing", `Skills directory does not exist: ${directory}`);
20
+ const real = realpathSync(resolved);
21
+ if (!statSync(real).isDirectory())
22
+ throw new SkillsError("skills.missing", `Skills path is not a directory: ${directory}`);
23
+ return real;
24
+ }
25
+ /**
26
+ * Load Agent Skills (agentskills.io) from a catalog directory.
27
+ * Each immediate subdirectory with a valid SKILL.md becomes one skill.
28
+ */
29
+ export function loadSkillsFromDirectory(directory, diagnostics, options = {}) {
30
+ const root = resolveSkillsRoot(directory);
31
+ const boundary = options.boundary ?? root;
32
+ const prefix = options.codePrefix ?? "skills";
33
+ const skills = {};
34
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
35
+ if (!entry.isDirectory() || entry.name.startsWith("."))
36
+ continue;
37
+ if (entry.name === "node_modules" || entry.name === ".git")
38
+ continue;
39
+ const skillDir = join(root, entry.name);
40
+ const directoryReal = realInside(boundary, skillDir);
41
+ if (!directoryReal) {
42
+ diagnostics.push({
43
+ severity: "warning",
44
+ code: `${prefix}.skill-skipped`,
45
+ message: `Skipped skill '${entry.name}' because its directory escapes the package`,
46
+ path: skillDir,
47
+ });
48
+ continue;
49
+ }
50
+ const skillFile = join(directoryReal, "SKILL.md");
51
+ if (!existsSync(skillFile))
52
+ continue;
53
+ const skillReal = realInside(boundary, skillFile);
54
+ if (!skillReal || !statSync(skillReal).isFile()) {
55
+ diagnostics.push({
56
+ severity: "warning",
57
+ code: `${prefix}.skill-skipped`,
58
+ message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
59
+ path: skillFile,
60
+ });
61
+ continue;
62
+ }
63
+ const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
64
+ if (!parsed) {
65
+ diagnostics.push({
66
+ severity: "warning",
67
+ code: `${prefix}.skill-skipped`,
68
+ message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
69
+ path: skillFile,
70
+ });
71
+ continue;
72
+ }
73
+ if (skills[parsed.name]) {
74
+ diagnostics.push({
75
+ severity: "warning",
76
+ code: `${prefix}.skill-skipped`,
77
+ message: `Skipped duplicate skill '${parsed.name}'`,
78
+ path: skillFile,
79
+ });
80
+ continue;
81
+ }
82
+ const resources = readResources(boundary, directoryReal, diagnostics, prefix);
83
+ skills[parsed.name] = {
84
+ name: parsed.name,
85
+ description: parsed.description,
86
+ instructions: parsed.instructions,
87
+ ...(Object.keys(resources).length === 0 ? {} : { resources }),
88
+ };
89
+ }
90
+ return skills;
91
+ }
92
+ export function parseSkill(text, directoryName) {
93
+ let parsed;
94
+ try {
95
+ parsed = matter(text);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ const name = asTrimmedString(parsed.data.name);
101
+ const description = asTrimmedString(parsed.data.description);
102
+ if (!name || !description || description.length > 1024)
103
+ return undefined;
104
+ if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
105
+ return undefined;
106
+ return {
107
+ name,
108
+ description,
109
+ instructions: parsed.content.replace(/^\r?\n/, ""),
110
+ };
111
+ }
112
+ function asTrimmedString(value) {
113
+ return typeof value === "string" ? value.trim() : "";
114
+ }
115
+ function readResources(boundary, directory, diagnostics, prefix) {
116
+ const resources = {};
117
+ const walk = (current, depth) => {
118
+ if (depth > 6)
119
+ return;
120
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
121
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
122
+ continue;
123
+ const full = join(current, entry.name);
124
+ if (entry.isDirectory()) {
125
+ const real = realInside(boundary, full);
126
+ if (!real)
127
+ continue;
128
+ walk(real, depth + 1);
129
+ continue;
130
+ }
131
+ if (!entry.isFile() && !entry.isSymbolicLink())
132
+ continue;
133
+ if (depth === 0 && entry.name === "SKILL.md")
134
+ continue;
135
+ const real = realInside(boundary, full);
136
+ if (!real || !statSync(real).isFile()) {
137
+ diagnostics.push({
138
+ severity: "warning",
139
+ code: `${prefix}.resource-denied`,
140
+ message: `Denied resource outside the package: ${entry.name}`,
141
+ path: full,
142
+ });
143
+ continue;
144
+ }
145
+ const bytes = readFileSync(real);
146
+ if (bytes.includes(0))
147
+ continue;
148
+ const rel = relative(directory, real).split(sep).join("/");
149
+ if (!rel || rel.startsWith(".."))
150
+ continue;
151
+ resources[rel] = bytes.toString("utf8");
152
+ }
153
+ };
154
+ walk(directory, 0);
155
+ return resources;
156
+ }
157
+ function realInside(root, candidate) {
158
+ if (!existsSync(candidate))
159
+ return undefined;
160
+ try {
161
+ const real = realpathSync(candidate);
162
+ return isInside(root, real) ? real : undefined;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ function isInside(root, target) {
169
+ const rel = relative(root, target);
170
+ return (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)));
171
+ }
@@ -0,0 +1,28 @@
1
+ import type { CapabilityDeclaration } from "@nylorun/core/define";
2
+ import { type SkillDiagnostic } from "./load.js";
3
+ export interface SkillsOptions {
4
+ /** Capability id. Defaults to the catalog directory basename. */
5
+ readonly id?: string;
6
+ }
7
+ export interface SkillsCapability extends CapabilityDeclaration {
8
+ readonly diagnostics: readonly SkillDiagnostic[];
9
+ /** Absolute catalog directory that was loaded. */
10
+ readonly root: string;
11
+ }
12
+ /**
13
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
14
+ *
15
+ * Expected layout:
16
+ * ```
17
+ * assistant-skills/
18
+ * lookup-order/SKILL.md
19
+ * refund/
20
+ * SKILL.md
21
+ * references/policy.md
22
+ * ```
23
+ *
24
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
25
+ * resources) so callers do not duplicate content. The harness injects
26
+ * `load_skill` / `read_skill_resource` from the records.
27
+ */
28
+ export declare function skills(directory: string, options?: SkillsOptions): SkillsCapability;
@@ -0,0 +1,47 @@
1
+ import { basename } from "node:path";
2
+ import { formatSkillCatalog, SKILLS_USAGE } from "./catalog.js";
3
+ import { loadSkillsFromDirectory, resolveSkillsRoot, } from "./load.js";
4
+ /**
5
+ * Load an agentskills.io catalog folder and return one capability for `Agent.use`.
6
+ *
7
+ * Expected layout:
8
+ * ```
9
+ * assistant-skills/
10
+ * lookup-order/SKILL.md
11
+ * refund/
12
+ * SKILL.md
13
+ * references/policy.md
14
+ * ```
15
+ *
16
+ * Sets both `skills` (manifest names/descriptions) and `skillRecords` (bodies +
17
+ * resources) so callers do not duplicate content. The harness injects
18
+ * `load_skill` / `read_skill_resource` from the records.
19
+ */
20
+ export function skills(directory, options = {}) {
21
+ const root = resolveSkillsRoot(directory);
22
+ const diagnostics = [];
23
+ const records = loadSkillsFromDirectory(root, diagnostics);
24
+ const listed = Object.values(records);
25
+ const id = options.id ?? basename(root);
26
+ if (listed.length === 0) {
27
+ return { id, root, diagnostics };
28
+ }
29
+ const skillManifest = Object.fromEntries(listed.map((skill) => [
30
+ skill.name,
31
+ { name: skill.name, description: skill.description },
32
+ ]));
33
+ return {
34
+ id,
35
+ root,
36
+ instructions: [
37
+ SKILLS_USAGE,
38
+ formatSkillCatalog(listed.map((skill) => ({
39
+ name: skill.name,
40
+ description: skill.description,
41
+ }))),
42
+ ],
43
+ skills: skillManifest,
44
+ skillRecords: records,
45
+ diagnostics,
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/agents",
3
- "version": "0.2.0-beta",
3
+ "version": "0.4.0-beta",
4
4
  "description": "Nylorun agent definitions, session client and connected executor",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,15 +35,16 @@
35
35
  "test": "vitest run --passWithNoTests"
36
36
  },
37
37
  "dependencies": {
38
- "@nylorun/core": "0.1.1-beta"
38
+ "@nylorun/core": "0.3.0-beta",
39
+ "gray-matter": "^4.0.3"
39
40
  },
40
41
  "peerDependencies": {
41
- "zod": "^4.5.4"
42
+ "zod": "^4.6.5"
42
43
  },
43
44
  "devDependencies": {
44
- "typescript": "npm:@typescript/typescript6@^6.0.2",
45
45
  "@types/node": "^24.0.0",
46
- "vitest": "^5.0.0"
46
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
47
+ "vitest": "^5.0.1"
47
48
  },
48
49
  "engines": {
49
50
  "node": ">=24"