@rynx-ai/skills 0.1.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.
- package/dist/artifact.d.ts +41 -0
- package/dist/artifact.js +158 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/install.d.ts +37 -0
- package/dist/install.js +204 -0
- package/package.json +29 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SkillRef } from "@rynx-ai/protocol";
|
|
2
|
+
export interface SkillMeta {
|
|
3
|
+
/** Lowercase kebab-case skill id (the directory / `name:` frontmatter). */
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
/** Absolute path to the skill's directory. */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** The skill's install identity from its `.rynx-skill.json` v3 receipt —
|
|
9
|
+
* the exact `SkillRef` that reproduces this install (agent-editor selection
|
|
10
|
+
* copies it verbatim into the spec). Absent when the receipt is missing,
|
|
11
|
+
* not schema 3, or fails the dir-name sanity check. */
|
|
12
|
+
ref?: SkillRef;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `.rynx-skill.json` v3 — the record of ONE install event. Its payload IS a
|
|
16
|
+
* {@link SkillRef} (declaration ≡ receipt, literally): the replayable install
|
|
17
|
+
* recipe plus the content fingerprint observed at commit time. `installedAt`
|
|
18
|
+
* is observation metadata, not identity. An update is simply a new install
|
|
19
|
+
* event (the whole file is rewritten).
|
|
20
|
+
*/
|
|
21
|
+
export interface SkillReceipt {
|
|
22
|
+
schema: 3;
|
|
23
|
+
ref: SkillRef;
|
|
24
|
+
installedAt: string;
|
|
25
|
+
}
|
|
26
|
+
/** Receipt file written next to a skill at install time. */
|
|
27
|
+
export declare const SKILL_RECEIPT_FILE = ".rynx-skill.json";
|
|
28
|
+
/** The raw YAML frontmatter block of a `SKILL.md` (between the leading `---`s), or null. */
|
|
29
|
+
export declare function extractSkillFrontmatter(content: string): string | null;
|
|
30
|
+
/** Parse a `SKILL.md` body's leading YAML frontmatter for name + description. */
|
|
31
|
+
export declare function parseSkillFrontmatter(content: string): {
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
} | null;
|
|
35
|
+
/** Read one skill directory's metadata, or null if it has no valid `SKILL.md`.
|
|
36
|
+
* Merges the install identity (`ref`) from a valid v3 receipt if present. */
|
|
37
|
+
export declare function readSkillMeta(skillDir: string): Promise<SkillMeta | null>;
|
|
38
|
+
/** Structural validation of an untrusted `SkillRef` value (receipt payload). */
|
|
39
|
+
export declare function parseSkillRefValue(value: unknown): SkillRef | null;
|
|
40
|
+
/** Scan a skills root for immediate subdirectories that are valid skills. */
|
|
41
|
+
export declare function scanSkillsDir(root: string): Promise<SkillMeta[]>;
|
package/dist/artifact.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan + parse on-disk skills. A skill is a directory with a `SKILL.md` whose
|
|
3
|
+
* YAML frontmatter declares `name` + `description` (the universal skill format
|
|
4
|
+
* shared by claude/codex/etc.). Used by the daemon's skill catalog, the
|
|
5
|
+
* per-session env build, and any runtime skill discovery.
|
|
6
|
+
*
|
|
7
|
+
* (The codex runtime keeps its own richer scanner in `runtime/codex-skills.ts`;
|
|
8
|
+
* this is the minimal, catalog-facing variant.)
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
/** Receipt file written next to a skill at install time. */
|
|
13
|
+
export const SKILL_RECEIPT_FILE = ".rynx-skill.json";
|
|
14
|
+
/** The raw YAML frontmatter block of a `SKILL.md` (between the leading `---`s), or null. */
|
|
15
|
+
export function extractSkillFrontmatter(content) {
|
|
16
|
+
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/);
|
|
17
|
+
return match ? match[1].trim() : null;
|
|
18
|
+
}
|
|
19
|
+
/** Parse a `SKILL.md` body's leading YAML frontmatter for name + description. */
|
|
20
|
+
export function parseSkillFrontmatter(content) {
|
|
21
|
+
const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/);
|
|
22
|
+
if (!match)
|
|
23
|
+
return null;
|
|
24
|
+
const name = frontmatterScalar(match[1], "name");
|
|
25
|
+
const description = frontmatterScalar(match[1], "description");
|
|
26
|
+
if (!name || !description)
|
|
27
|
+
return null;
|
|
28
|
+
return { name, description };
|
|
29
|
+
}
|
|
30
|
+
function frontmatterScalar(frontmatter, key) {
|
|
31
|
+
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
32
|
+
if (!match)
|
|
33
|
+
return null;
|
|
34
|
+
const value = match[1].trim();
|
|
35
|
+
if (!value)
|
|
36
|
+
return null;
|
|
37
|
+
if (["|", "|-", ">", ">-"].includes(value)) {
|
|
38
|
+
return frontmatterBlockScalar(frontmatter, key);
|
|
39
|
+
}
|
|
40
|
+
return value.replace(/^["'](.*)["']$/, "$1").trim() || null;
|
|
41
|
+
}
|
|
42
|
+
function frontmatterBlockScalar(frontmatter, key) {
|
|
43
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
44
|
+
const start = lines.findIndex((line) => line.match(new RegExp(`^${key}:\\s*[>|]`)));
|
|
45
|
+
if (start < 0)
|
|
46
|
+
return null;
|
|
47
|
+
const body = [];
|
|
48
|
+
for (const line of lines.slice(start + 1)) {
|
|
49
|
+
if (/^\S/.test(line))
|
|
50
|
+
break;
|
|
51
|
+
if (!line.trim()) {
|
|
52
|
+
body.push("");
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
body.push(line.replace(/^\s{2}/, ""));
|
|
56
|
+
}
|
|
57
|
+
return body.join(" ").replace(/\s+/g, " ").trim() || null;
|
|
58
|
+
}
|
|
59
|
+
/** Read one skill directory's metadata, or null if it has no valid `SKILL.md`.
|
|
60
|
+
* Merges the install identity (`ref`) from a valid v3 receipt if present. */
|
|
61
|
+
export async function readSkillMeta(skillDir) {
|
|
62
|
+
let content;
|
|
63
|
+
try {
|
|
64
|
+
content = await readFile(path.join(skillDir, "SKILL.md"), "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const fm = parseSkillFrontmatter(content);
|
|
70
|
+
if (!fm)
|
|
71
|
+
return null;
|
|
72
|
+
const ref = await readReceiptRef(skillDir);
|
|
73
|
+
return { ...fm, dir: skillDir, ...(ref ? { ref } : {}) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read a skill dir's v3 receipt and return its `ref`, or null. Anything that
|
|
77
|
+
* isn't a structurally valid v3 receipt — old schema versions included — is
|
|
78
|
+
* treated as receipt-less (clean break; the owner reinstalls to restore). A
|
|
79
|
+
* dir-name ≠ `ref.name` mismatch (a hand-renamed dir) is also receipt-less,
|
|
80
|
+
* loudly: the receipt no longer describes the directory it sits in.
|
|
81
|
+
*/
|
|
82
|
+
async function readReceiptRef(skillDir) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = JSON.parse(await readFile(path.join(skillDir, SKILL_RECEIPT_FILE), "utf8"));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const receipt = raw;
|
|
91
|
+
if (receipt.schema !== 3)
|
|
92
|
+
return null;
|
|
93
|
+
const ref = parseSkillRefValue(receipt.ref);
|
|
94
|
+
if (!ref)
|
|
95
|
+
return null;
|
|
96
|
+
if (ref.name !== path.basename(skillDir)) {
|
|
97
|
+
console.warn(`[skills] receipt/name mismatch at ${skillDir}: receipt says "${ref.name}" — treating as receipt-less`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return ref;
|
|
101
|
+
}
|
|
102
|
+
/** Structural validation of an untrusted `SkillRef` value (receipt payload). */
|
|
103
|
+
export function parseSkillRefValue(value) {
|
|
104
|
+
if (typeof value !== "object" || value === null)
|
|
105
|
+
return null;
|
|
106
|
+
const v = value;
|
|
107
|
+
if (typeof v.name !== "string" || !v.name.trim())
|
|
108
|
+
return null;
|
|
109
|
+
const install = v.install;
|
|
110
|
+
if (typeof install !== "object" || install === null)
|
|
111
|
+
return null;
|
|
112
|
+
let recipe;
|
|
113
|
+
if (install.tool === "skills-cli") {
|
|
114
|
+
if (typeof install.source !== "string" || !install.source.trim())
|
|
115
|
+
return null;
|
|
116
|
+
if (install.skill !== undefined && typeof install.skill !== "string")
|
|
117
|
+
return null;
|
|
118
|
+
recipe = {
|
|
119
|
+
tool: "skills-cli",
|
|
120
|
+
source: install.source,
|
|
121
|
+
...(typeof install.skill === "string" && install.skill ? { skill: install.skill } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
else if (install.tool === "npx") {
|
|
125
|
+
if (typeof install.package !== "string" || !install.package.trim())
|
|
126
|
+
return null;
|
|
127
|
+
if (typeof install.version !== "string" || !install.version.trim())
|
|
128
|
+
return null;
|
|
129
|
+
if (!Array.isArray(install.args) || install.args.some((a) => typeof a !== "string"))
|
|
130
|
+
return null;
|
|
131
|
+
recipe = { tool: "npx", package: install.package, version: install.version, args: install.args };
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
name: v.name,
|
|
138
|
+
install: recipe,
|
|
139
|
+
...(typeof v.contentHash === "string" && v.contentHash ? { contentHash: v.contentHash } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Scan a skills root for immediate subdirectories that are valid skills. */
|
|
143
|
+
export async function scanSkillsDir(root) {
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return []; // missing root ⇒ empty catalog
|
|
150
|
+
}
|
|
151
|
+
const dirs = entries
|
|
152
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
153
|
+
.map((e) => path.join(root, e.name));
|
|
154
|
+
const metas = await Promise.all(dirs.map(readSkillMeta));
|
|
155
|
+
return metas
|
|
156
|
+
.filter((m) => m !== null)
|
|
157
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
158
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rynx-ai/skills — the skill artifact domain.
|
|
3
|
+
*
|
|
4
|
+
* Everything about on-disk skills (the universal `SKILL.md` format shared by
|
|
5
|
+
* claude/codex/etc.) and nothing else: discovering and parsing them, installing
|
|
6
|
+
* them from replayable recipes, and reading/writing the catalog's
|
|
7
|
+
* `.rynx-skill.json` install-info files. Depends on `@rynx-ai/protocol` (the type
|
|
8
|
+
* home for `SkillRef`/`SkillInstallRecipe`) and node built-ins only.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `@rynx-ai/core` (daemon catalog + per-session env build) and
|
|
11
|
+
* `@rynx-ai/emulator` (its `skill install-rynx` catalog install) — one
|
|
12
|
+
* implementation on both sides of the package boundary.
|
|
13
|
+
*/
|
|
14
|
+
export * from "./artifact.js";
|
|
15
|
+
export * from "./install.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rynx-ai/skills — the skill artifact domain.
|
|
3
|
+
*
|
|
4
|
+
* Everything about on-disk skills (the universal `SKILL.md` format shared by
|
|
5
|
+
* claude/codex/etc.) and nothing else: discovering and parsing them, installing
|
|
6
|
+
* them from replayable recipes, and reading/writing the catalog's
|
|
7
|
+
* `.rynx-skill.json` install-info files. Depends on `@rynx-ai/protocol` (the type
|
|
8
|
+
* home for `SkillRef`/`SkillInstallRecipe`) and node built-ins only.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `@rynx-ai/core` (daemon catalog + per-session env build) and
|
|
11
|
+
* `@rynx-ai/emulator` (its `skill install-rynx` catalog install) — one
|
|
12
|
+
* implementation on both sides of the package boundary.
|
|
13
|
+
*/
|
|
14
|
+
export * from "./artifact.js";
|
|
15
|
+
export * from "./install.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SkillInstallRecipe, SkillRef } from "@rynx-ai/protocol";
|
|
2
|
+
import { type SkillMeta } from "./artifact.js";
|
|
3
|
+
/** One skill an install produced: where it landed + the content hash observed
|
|
4
|
+
* during the install (the skills CLI's `computedHash`), when available. */
|
|
5
|
+
export interface InstalledSkill {
|
|
6
|
+
name: string;
|
|
7
|
+
dir: string;
|
|
8
|
+
contentHash?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Runs `npx <args>` with cwd = the temp install dir. Injectable (tests). */
|
|
11
|
+
export type NpxRunner = (args: string[], opts: {
|
|
12
|
+
cwd: string;
|
|
13
|
+
}) => Promise<void>;
|
|
14
|
+
/** Turn install params into the command line that performs them. Both tools
|
|
15
|
+
* produce an `npx` invocation, run with cwd = the temp install dir. */
|
|
16
|
+
export declare function buildInstallCommand(recipe: SkillInstallRecipe): {
|
|
17
|
+
npxArgs: string[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* INSTALL ONLY: run `recipe`'s install command in a throwaway temp dir and
|
|
21
|
+
* move every skill it produced into `targetDir`. Writes no metadata — the
|
|
22
|
+
* observations come back as the return value. Throws on bad input / failure.
|
|
23
|
+
*/
|
|
24
|
+
export declare function installFromRecipe(recipe: SkillInstallRecipe, targetDir: string, opts?: {
|
|
25
|
+
runNpx?: NpxRunner;
|
|
26
|
+
}): Promise<InstalledSkill[]>;
|
|
27
|
+
/** Write one skill dir's `.rynx-skill.json` v3 install-info file (catalog
|
|
28
|
+
* artifact: the payload IS the SkillRef — declaration ≡ record, literally). */
|
|
29
|
+
export declare function writeSkillInstallInfo(skillDir: string, ref: SkillRef): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* The CATALOG layer: install per `recipe` AND write each produced skill its
|
|
32
|
+
* install-info file — `ref` = these exact install params + the observed
|
|
33
|
+
* content hash. Returns the installed skills' catalog metadata.
|
|
34
|
+
*/
|
|
35
|
+
export declare function installIntoCatalog(recipe: SkillInstallRecipe, catalogRoot: string, opts?: {
|
|
36
|
+
runNpx?: NpxRunner;
|
|
37
|
+
}): Promise<SkillMeta[]>;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill installation from replayable recipes.
|
|
3
|
+
*
|
|
4
|
+
* Layering (rynx RFC: docs/rfc-skillref-recipe-install.md §4):
|
|
5
|
+
*
|
|
6
|
+
* installFromRecipe(recipe, targetDir) — INSTALL ONLY: run the recipe's
|
|
7
|
+
* command in a throwaway temp dir, move the produced skill folders into
|
|
8
|
+
* `targetDir`, and RETURN what was observed (names + content hashes).
|
|
9
|
+
* Writes no metadata anywhere.
|
|
10
|
+
*
|
|
11
|
+
* writeSkillInstallInfo(dir, ref) — write one skill's
|
|
12
|
+
* `.rynx-skill.json` v3 install-info file. A CATALOG artifact only: the
|
|
13
|
+
* Skills page shows it and the agent editor copies its `ref` into specs —
|
|
14
|
+
* session dirs and cache slots never carry it.
|
|
15
|
+
*
|
|
16
|
+
* installIntoCatalog(recipe, catalogRoot) — the composition: install, then
|
|
17
|
+
* write each produced skill an info file recording these exact params +
|
|
18
|
+
* the observed hash.
|
|
19
|
+
*
|
|
20
|
+
* Every install bottoms out at `npx skills add` (the npx recipe variant is a
|
|
21
|
+
* self-installing proxy that runs it in its cwd), so both tools share one
|
|
22
|
+
* pickup path and one hash algorithm; they differ only in the command line.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { promisify } from "node:util";
|
|
29
|
+
import { readSkillMeta, SKILL_RECEIPT_FILE } from "./artifact.js";
|
|
30
|
+
const runNpxDefault = async (args, opts) => {
|
|
31
|
+
await promisify(execFile)("npx", args, { cwd: opts.cwd, timeout: 180_000 }).catch((err) => {
|
|
32
|
+
throw new Error(`skills install failed: ${cliErrorDetail(err)}`);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
/** Reject shell metacharacters / control chars early (clearer than a CLI parse
|
|
36
|
+
* error; args go through execFile — no shell — so this is belt-and-braces).
|
|
37
|
+
* Plain spaces are allowed: a local-directory source is a legal path. */
|
|
38
|
+
function assertSafeArg(label, value) {
|
|
39
|
+
// eslint-disable-next-line no-control-regex
|
|
40
|
+
if (/[;&|`$()<>\x00-\x1f]/.test(value))
|
|
41
|
+
throw new Error(`invalid ${label}: ${value}`);
|
|
42
|
+
}
|
|
43
|
+
/** Turn install params into the command line that performs them. Both tools
|
|
44
|
+
* produce an `npx` invocation, run with cwd = the temp install dir. */
|
|
45
|
+
export function buildInstallCommand(recipe) {
|
|
46
|
+
if (recipe.tool === "skills-cli") {
|
|
47
|
+
const src = recipe.source.trim();
|
|
48
|
+
if (!src)
|
|
49
|
+
throw new Error("missing skill source");
|
|
50
|
+
assertSafeArg("source", src);
|
|
51
|
+
if (recipe.skill?.trim())
|
|
52
|
+
assertSafeArg("skill", recipe.skill.trim());
|
|
53
|
+
const args = ["-y", "skills", "add", src];
|
|
54
|
+
if (recipe.skill?.trim())
|
|
55
|
+
args.push("--skill", recipe.skill.trim());
|
|
56
|
+
// --full-depth: repo sources only scan conventional roots by default;
|
|
57
|
+
// always search the whole tree so skills at non-standard depths (a
|
|
58
|
+
// monorepo's packages/<x>/skills/) install too. Part of the fixed
|
|
59
|
+
// invocation (like --copy), so recipe replays inherit it.
|
|
60
|
+
// --agent universal is a MATERIALIZE TRICK, not a target choice: the CLI
|
|
61
|
+
// must be told some agent layout before it writes files; "universal" is
|
|
62
|
+
// its agent-neutral one (`<cwd>/.agents/skills/`), which we use as the
|
|
63
|
+
// predictable pickup location inside the throwaway temp dir. Which agent
|
|
64
|
+
// actually sees a skill is decided downstream, at materialize time.
|
|
65
|
+
args.push("--full-depth", "--agent", "universal", "--copy", "--yes");
|
|
66
|
+
return { npxArgs: args };
|
|
67
|
+
}
|
|
68
|
+
const pkg = recipe.package.trim();
|
|
69
|
+
const version = recipe.version.trim();
|
|
70
|
+
if (!pkg || !version)
|
|
71
|
+
throw new Error("missing npx package/version");
|
|
72
|
+
assertSafeArg("package", pkg);
|
|
73
|
+
assertSafeArg("version", version);
|
|
74
|
+
return { npxArgs: ["-y", `${pkg}@${version}`, ...recipe.args] };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* INSTALL ONLY: run `recipe`'s install command in a throwaway temp dir and
|
|
78
|
+
* move every skill it produced into `targetDir`. Writes no metadata — the
|
|
79
|
+
* observations come back as the return value. Throws on bad input / failure.
|
|
80
|
+
*/
|
|
81
|
+
export async function installFromRecipe(recipe, targetDir, opts = {}) {
|
|
82
|
+
const staged = await execStagedInstall(buildInstallCommand(recipe).npxArgs, opts.runNpx);
|
|
83
|
+
try {
|
|
84
|
+
if (staged.names.length === 0)
|
|
85
|
+
throw new Error("no skill was installed — check the source identifier");
|
|
86
|
+
await mkdir(targetDir, { recursive: true });
|
|
87
|
+
const installed = [];
|
|
88
|
+
for (const name of staged.names) {
|
|
89
|
+
const target = path.join(targetDir, name);
|
|
90
|
+
await rm(target, { recursive: true, force: true });
|
|
91
|
+
await cp(path.join(staged.root, name), target, { recursive: true });
|
|
92
|
+
installed.push({
|
|
93
|
+
name,
|
|
94
|
+
dir: target,
|
|
95
|
+
...(staged.contentHash[name] ? { contentHash: staged.contentHash[name] } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return installed;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await rm(staged.staging, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Write one skill dir's `.rynx-skill.json` v3 install-info file (catalog
|
|
105
|
+
* artifact: the payload IS the SkillRef — declaration ≡ record, literally). */
|
|
106
|
+
export async function writeSkillInstallInfo(skillDir, ref) {
|
|
107
|
+
const info = { schema: 3, ref, installedAt: new Date().toISOString() };
|
|
108
|
+
await writeFile(path.join(skillDir, SKILL_RECEIPT_FILE), `${JSON.stringify(info, null, 2)}\n`);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The CATALOG layer: install per `recipe` AND write each produced skill its
|
|
112
|
+
* install-info file — `ref` = these exact install params + the observed
|
|
113
|
+
* content hash. Returns the installed skills' catalog metadata.
|
|
114
|
+
*/
|
|
115
|
+
export async function installIntoCatalog(recipe, catalogRoot, opts = {}) {
|
|
116
|
+
const installed = await installFromRecipe(recipe, catalogRoot, opts);
|
|
117
|
+
const metas = [];
|
|
118
|
+
for (const skill of installed) {
|
|
119
|
+
await writeSkillInstallInfo(skill.dir, {
|
|
120
|
+
name: skill.name,
|
|
121
|
+
install: recipe,
|
|
122
|
+
...(skill.contentHash ? { contentHash: skill.contentHash } : {}),
|
|
123
|
+
});
|
|
124
|
+
const meta = await readSkillMeta(skill.dir);
|
|
125
|
+
if (meta)
|
|
126
|
+
metas.push(meta);
|
|
127
|
+
}
|
|
128
|
+
return metas;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Run one `npx` command in a throwaway temp dir, list the skill folders it
|
|
132
|
+
* produced under `.agents/skills/`, and pick up the `skills` CLI's per-skill
|
|
133
|
+
* content hashes from its `skills-lock.json`. The caller moves the folders
|
|
134
|
+
* and owns the temp dir's cleanup.
|
|
135
|
+
*/
|
|
136
|
+
async function execStagedInstall(npxArgs, runNpx = runNpxDefault) {
|
|
137
|
+
const staging = await mkdtemp(path.join(tmpdir(), "rynx-skill-"));
|
|
138
|
+
try {
|
|
139
|
+
await runNpx(npxArgs, { cwd: staging });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
await rm(staging, { recursive: true, force: true });
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
const root = path.join(staging, ".agents", "skills");
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = (await readdir(root, { withFileTypes: true }))
|
|
149
|
+
.filter((e) => e.isDirectory())
|
|
150
|
+
.map((e) => e.name);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
names = [];
|
|
154
|
+
}
|
|
155
|
+
const contentHash = {};
|
|
156
|
+
try {
|
|
157
|
+
const lock = JSON.parse(await readFile(path.join(staging, "skills-lock.json"), "utf8"));
|
|
158
|
+
for (const [name, entry] of Object.entries(lock.skills ?? {})) {
|
|
159
|
+
if (typeof entry?.computedHash === "string")
|
|
160
|
+
contentHash[name] = entry.computedHash;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* no lock → no hashes; the skill is simply uncacheable */
|
|
165
|
+
}
|
|
166
|
+
return { staging, root, names, contentHash };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Extract a readable failure reason from an execFile rejection, stripping the
|
|
170
|
+
* `skills` CLI's clack/ANSI UI noise. Surfaces a headline + the source's
|
|
171
|
+
* available-skills list (the actionable part of an unmatched-`--skill` error).
|
|
172
|
+
*/
|
|
173
|
+
function cliErrorDetail(err) {
|
|
174
|
+
const e = err;
|
|
175
|
+
const raw = [e.stderr, e.stdout].filter(Boolean).join("\n") || e.message || String(err);
|
|
176
|
+
const lines = raw
|
|
177
|
+
.split(/\r?\n/)
|
|
178
|
+
.map((line) => line
|
|
179
|
+
// eslint-disable-next-line no-control-regex
|
|
180
|
+
.replace(new RegExp("\\x1b\\[[0-9;?]*[A-Za-z]", "g"), "") // ANSI/CSI escape sequences
|
|
181
|
+
// eslint-disable-next-line no-control-regex
|
|
182
|
+
.replace(new RegExp("[\x00-\x1f\x7f]", "g"), " ") // stray control chars
|
|
183
|
+
.replace(/[│╮╯╰─■◇◒◐◓◑●◆]/g, "") // clack box-drawing / spinner glyphs
|
|
184
|
+
.replace(/\s+/g, " ")
|
|
185
|
+
.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
// The common failure is an unmatched `--skill`: the CLI prints a headline plus
|
|
188
|
+
// a bulleted list of the source's available skills. Surface both so the user
|
|
189
|
+
// can pick a valid skill, instead of a cryptic tail of the list.
|
|
190
|
+
const bullets = [];
|
|
191
|
+
const rest = [];
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
const match = line.match(/^[-•]\s*(.+)$/);
|
|
194
|
+
if (match)
|
|
195
|
+
bullets.push(match[1].trim());
|
|
196
|
+
else
|
|
197
|
+
rest.push(line);
|
|
198
|
+
}
|
|
199
|
+
const errorLine = rest.find((l) => /not\s*found|fail|does not exist|error|invalid|unknown/i.test(l));
|
|
200
|
+
const detail = bullets.length > 0
|
|
201
|
+
? `${errorLine ? `${errorLine} — ` : ""}available skills: ${bullets.join(", ")}`
|
|
202
|
+
: errorLine || rest.at(-1) || "unknown error";
|
|
203
|
+
return detail.length > 600 ? `${detail.slice(0, 597)}…` : detail;
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rynx-ai/skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The rynx skill artifact domain: discover/parse on-disk skills (SKILL.md), install them from replayable recipes (everything bottoms out at `npx skills add`), and read/write the catalog's `.rynx-skill.json` install-info files. Node built-ins + @rynx-ai/protocol only.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"registry": "https://registry.npmjs.org/",
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@rynx-ai/protocol": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "rm -rf dist && tsc -p tsconfig.json"
|
|
28
|
+
}
|
|
29
|
+
}
|