@open-domain-specification/skill 0.1.10

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,80 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { Workspace } from "@open-domain-specification/core";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ generateReferences,
8
+ readBundle,
9
+ skillRoot,
10
+ } from "../scripts/generate.mts";
11
+
12
+ const file = (path: string) => readFileSync(join(skillRoot, path), "utf8");
13
+
14
+ describe("SKILL.md", () => {
15
+ const skill = file("SKILL.md");
16
+ const frontmatter = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(skill);
17
+
18
+ it("has a name and a description in its frontmatter", () => {
19
+ expect(frontmatter).not.toBeNull();
20
+ expect(frontmatter![1]).toMatch(/^name: ods-authoring$/m);
21
+ expect(frontmatter![1]).toMatch(/^description: >/m);
22
+ });
23
+
24
+ it("stays short enough to load unconditionally", () => {
25
+ expect(frontmatter![2].split("\n").length).toBeLessThan(300);
26
+ });
27
+
28
+ it("only points at references and examples that exist", () => {
29
+ const paths = new Set(readBundle().map((f) => f.path));
30
+ for (const [, ref] of skill.matchAll(
31
+ /`((?:references|examples)\/[\w./-]+)`/g,
32
+ ))
33
+ expect(paths, ref).toContain(ref);
34
+ });
35
+ });
36
+
37
+ describe("generated references", () => {
38
+ it("are committed up to date with core", () => {
39
+ for (const generated of generateReferences())
40
+ expect(file(generated.path), generated.path).toBe(generated.content);
41
+ });
42
+ });
43
+
44
+ describe("dsl-api.md", () => {
45
+ it("names only methods that exist on the core classes", () => {
46
+ const require = createRequire(import.meta.url);
47
+ const corePkg = dirname(
48
+ require.resolve("@open-domain-specification/core/package.json"),
49
+ );
50
+ const source = readFileSync(join(corePkg, "src/workspace.ts"), "utf8");
51
+ const doc = file("references/dsl-api.md");
52
+ const methods = [...doc.matchAll(/\| `\.?(?:new )?(\w+)\(/g)].map(
53
+ (m) => m[1],
54
+ );
55
+ expect(methods.length).toBeGreaterThan(20);
56
+ for (const method of methods) {
57
+ if (method === "Workspace") continue;
58
+ expect(source, method).toMatch(new RegExp(`\\b${method}\\(`));
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("examples", () => {
64
+ it("minimal.ods.json loads and validates clean", () => {
65
+ const json = JSON.parse(file("examples/minimal.ods.json"));
66
+ const ws = Workspace.fromSchema(json);
67
+ expect(ws.validate()).toEqual([]);
68
+ });
69
+
70
+ it("minimal.workspace.ts builds the same model as minimal.ods.json", async () => {
71
+ const { workspace } = await import(
72
+ "../skill/examples/minimal.workspace.ts"
73
+ );
74
+ expect(workspace.validate()).toEqual([]);
75
+ const { $schema: _s, ...json } = JSON.parse(
76
+ file("examples/minimal.ods.json"),
77
+ );
78
+ expect(workspace.toSchema()).toEqual(json);
79
+ });
80
+ });
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./install";
2
+ export * from "./targets";
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ installSkill,
4
+ isInstalled,
5
+ rulesSnippet,
6
+ SKILL_VERSION,
7
+ skillFiles,
8
+ } from "./install";
9
+ import { SKILL_NAME, skillDir, TARGETS } from "./targets";
10
+
11
+ describe("skillFiles", () => {
12
+ it("contains the skill entry point, references and examples", () => {
13
+ const paths = skillFiles().map((f) => f.path);
14
+ expect(paths).toContain("SKILL.md");
15
+ expect(paths).toContain("references/model-reference.md");
16
+ expect(paths).toContain("references/validation-rules.md");
17
+ expect(paths).toContain("examples/minimal.ods.json");
18
+ });
19
+
20
+ it("stamps SKILL.md with the package version", () => {
21
+ const skill = skillFiles().find((f) => f.path === "SKILL.md")!;
22
+ expect(skill.content).toContain(
23
+ `<!-- ods-skill-version: ${SKILL_VERSION} -->`,
24
+ );
25
+ });
26
+ });
27
+
28
+ describe("installSkill", () => {
29
+ it.each(TARGETS.map((t) => t.id))(
30
+ "writes under %s's skills folder",
31
+ async (target) => {
32
+ const written = new Map<string, string>();
33
+ const paths = await installSkill({
34
+ root: "/proj",
35
+ target,
36
+ write: async (p, c) => {
37
+ written.set(p, c);
38
+ },
39
+ });
40
+ expect(
41
+ paths.every((p) => p.startsWith(`/proj/${skillDir(target)}/`)),
42
+ ).toBe(true);
43
+ expect(written.has(`/proj/${skillDir(target)}/SKILL.md`)).toBe(true);
44
+ expect(skillDir(target).endsWith(`/skills/${SKILL_NAME}`)).toBe(true);
45
+ },
46
+ );
47
+ });
48
+
49
+ describe("isInstalled", () => {
50
+ const at = (content?: string) => async () => content;
51
+
52
+ it("is missing without a SKILL.md", async () => {
53
+ expect(await isInstalled("/p", "claude", at(undefined))).toBe("missing");
54
+ });
55
+
56
+ it("is stale when the stamp differs", async () => {
57
+ expect(
58
+ await isInstalled(
59
+ "/p",
60
+ "claude",
61
+ at("x\n<!-- ods-skill-version: 0.0.0 -->\n"),
62
+ ),
63
+ ).toBe("stale");
64
+ });
65
+
66
+ it("is current after installing", async () => {
67
+ const files = new Map<string, string>();
68
+ await installSkill({
69
+ root: "/p",
70
+ target: "codex",
71
+ write: async (p, c) => {
72
+ files.set(p, c);
73
+ },
74
+ });
75
+ expect(await isInstalled("/p", "codex", async (p) => files.get(p))).toBe(
76
+ "current",
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("rulesSnippet", () => {
82
+ it("points at the installed SKILL.md for the target", () => {
83
+ expect(rulesSnippet("claude")).toContain(
84
+ ".claude/skills/ods-authoring/SKILL.md",
85
+ );
86
+ expect(rulesSnippet()).toContain(".agents/skills/ods-authoring/SKILL.md");
87
+ });
88
+ });
package/src/install.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { BUNDLE, SKILL_VERSION } from "./bundle.generated";
2
+ import { SKILL_NAME, type SkillTarget, skillDir } from "./targets";
3
+
4
+ export type SkillFile = { path: string; content: string };
5
+
6
+ const STAMP = /<!-- ods-skill-version: ([^\s]+) -->/;
7
+
8
+ function stamp(content: string): string {
9
+ return `${content.trimEnd()}\n\n<!-- ods-skill-version: ${SKILL_VERSION} -->\n`;
10
+ }
11
+
12
+ /** Every file of the skill bundle, paths relative to the skill folder. */
13
+ export function skillFiles(): SkillFile[] {
14
+ return BUNDLE.map((f) =>
15
+ f.path === "SKILL.md" ? { ...f, content: stamp(f.content) } : { ...f },
16
+ );
17
+ }
18
+
19
+ export type InstallOptions = {
20
+ /** The project or home folder the skill is installed into. */
21
+ root: string;
22
+ target: SkillTarget;
23
+ /** Writes one file; receives an absolute-ish path built from `root`. */
24
+ write: (path: string, content: string) => Promise<void>;
25
+ /** Override the files to install; defaults to the bundle. */
26
+ files?: SkillFile[];
27
+ };
28
+
29
+ /** Writes the bundle into `<root>/<target skills dir>/ods-authoring/` and returns the paths written. */
30
+ export async function installSkill(options: InstallOptions): Promise<string[]> {
31
+ const base = `${options.root}/${skillDir(options.target)}`;
32
+ const written: string[] = [];
33
+ for (const file of options.files ?? skillFiles()) {
34
+ const path = `${base}/${file.path}`;
35
+ await options.write(path, file.content);
36
+ written.push(path);
37
+ }
38
+ return written;
39
+ }
40
+
41
+ export type InstallState = "missing" | "stale" | "current";
42
+
43
+ /** Compares the version stamp of an installed SKILL.md with this package's. */
44
+ export async function isInstalled(
45
+ root: string,
46
+ target: SkillTarget,
47
+ read: (path: string) => Promise<string | undefined>,
48
+ ): Promise<InstallState> {
49
+ const content = await read(`${root}/${skillDir(target)}/SKILL.md`);
50
+ if (content === undefined) return "missing";
51
+ return STAMP.exec(content)?.[1] === SKILL_VERSION ? "current" : "stale";
52
+ }
53
+
54
+ /** A paragraph for AGENTS.md or copilot-instructions.md pointing agents at the installed skill. */
55
+ export function rulesSnippet(target: SkillTarget = "agents"): string {
56
+ return [
57
+ "## Domain model (Open Domain Specification)",
58
+ "",
59
+ `This project keeps its domain model as an Open Domain Specification workspace. Before creating or editing anything under \`.ods/\` or a TypeScript file that builds a \`Workspace\` from \`@open-domain-specification/core\`, read \`${skillDir(target)}/SKILL.md\` and follow it: detect whether the model is authored as JSON or via the TypeScript DSL, interview the user in plain language before modelling, and validate after every change.`,
60
+ "",
61
+ ].join("\n");
62
+ }
63
+
64
+ export { SKILL_NAME, SKILL_VERSION };
package/src/targets.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Where each agent looks for skills, relative to a project or home folder. */
2
+ export type SkillTarget = "claude" | "agents" | "codex";
3
+
4
+ /** The folder name of the skill under every `skills/` directory. */
5
+ export const SKILL_NAME = "ods-authoring";
6
+
7
+ export const TARGETS: ReadonlyArray<{
8
+ id: SkillTarget;
9
+ label: string;
10
+ /** The skills directory, relative to the root the skill is installed into. */
11
+ dir: string;
12
+ }> = [
13
+ { id: "claude", label: "Claude Code", dir: ".claude/skills" },
14
+ { id: "agents", label: "Agent Skills (.agents)", dir: ".agents/skills" },
15
+ { id: "codex", label: "OpenAI Codex (.codex)", dir: ".codex/skills" },
16
+ ];
17
+
18
+ export const TARGET_DIRS: Record<SkillTarget, string> = Object.fromEntries(
19
+ TARGETS.map((t) => [t.id, t.dir]),
20
+ ) as Record<SkillTarget, string>;
21
+
22
+ /** The directory the skill lands in for a target, relative to `root`. */
23
+ export function skillDir(target: SkillTarget): string {
24
+ return `${TARGET_DIRS[target]}/${SKILL_NAME}`;
25
+ }