nymor 1.0.1

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/dist/agents/targets.js +111 -0
  4. package/dist/commands/add.js +121 -0
  5. package/dist/commands/compile.js +159 -0
  6. package/dist/commands/doctor.js +205 -0
  7. package/dist/commands/init.js +98 -0
  8. package/dist/commands/inject.js +24 -0
  9. package/dist/commands/learn.js +145 -0
  10. package/dist/commands/list.js +55 -0
  11. package/dist/commands/remove.js +38 -0
  12. package/dist/commands/update.js +80 -0
  13. package/dist/commands/validate.js +82 -0
  14. package/dist/compiler/agentsmd.js +17 -0
  15. package/dist/compiler/block.js +25 -0
  16. package/dist/compiler/claude.js +16 -0
  17. package/dist/compiler/copilot.js +29 -0
  18. package/dist/compiler/cursor.js +38 -0
  19. package/dist/compiler/kiro.js +24 -0
  20. package/dist/detector/agents.js +24 -0
  21. package/dist/detector/stack.js +113 -0
  22. package/dist/index.js +52 -0
  23. package/dist/registry/cache.js +60 -0
  24. package/dist/registry/client.js +135 -0
  25. package/dist/registry/resolver.js +29 -0
  26. package/dist/registry/types.js +2 -0
  27. package/dist/templates/bootstrap.js +97 -0
  28. package/dist/templates/cicada-json.js +11 -0
  29. package/dist/templates/nymor-json.js +11 -0
  30. package/dist/utils/manifest.js +32 -0
  31. package/dist/utils/paths.js +30 -0
  32. package/dist/utils/skills.js +114 -0
  33. package/package.json +32 -0
  34. package/src/agents/targets.ts +141 -0
  35. package/src/commands/compile.ts +202 -0
  36. package/src/commands/doctor.ts +253 -0
  37. package/src/commands/init.ts +113 -0
  38. package/src/commands/learn.ts +175 -0
  39. package/src/commands/list.ts +57 -0
  40. package/src/commands/validate.ts +89 -0
  41. package/src/compiler/block.ts +26 -0
  42. package/src/compiler/claude.ts +13 -0
  43. package/src/compiler/copilot.ts +28 -0
  44. package/src/compiler/cursor.ts +38 -0
  45. package/src/compiler/kiro.ts +22 -0
  46. package/src/detector/agents.ts +26 -0
  47. package/src/detector/stack.ts +135 -0
  48. package/src/index.ts +59 -0
  49. package/src/templates/bootstrap.ts +109 -0
  50. package/src/templates/nymor-json.ts +15 -0
  51. package/src/utils/manifest.ts +38 -0
  52. package/src/utils/paths.ts +25 -0
  53. package/src/utils/skills.ts +152 -0
  54. package/tests/compiler/__snapshots__/claude.test.ts.snap +65 -0
  55. package/tests/compiler/__snapshots__/copilot.test.ts.snap +54 -0
  56. package/tests/compiler/__snapshots__/cursor.test.ts.snap +62 -0
  57. package/tests/compiler/__snapshots__/kiro.test.ts.snap +54 -0
  58. package/tests/compiler/block.test.ts +24 -0
  59. package/tests/compiler/claude.test.ts +46 -0
  60. package/tests/compiler/copilot.test.ts +15 -0
  61. package/tests/compiler/cursor.test.ts +15 -0
  62. package/tests/compiler/kiro.test.ts +15 -0
  63. package/tests/detector/agents.test.ts +48 -0
  64. package/tests/detector/stack.test.ts +29 -0
  65. package/tests/e2e/init-and-compile.test.ts +227 -0
  66. package/tests/fixtures/skills/scoped/SKILL.md +18 -0
  67. package/tests/fixtures/skills/simple/SKILL.md +16 -0
  68. package/tests/fixtures/skills/with-examples/SKILL.md +18 -0
  69. package/tests/fixtures/skills/with-examples/examples/example.md +3 -0
  70. package/tests/fixtures/stacks/django/manage.py +2 -0
  71. package/tests/fixtures/stacks/django/requirements.txt +1 -0
  72. package/tests/fixtures/stacks/fastapi/requirements.txt +1 -0
  73. package/tests/fixtures/stacks/go/go.mod +3 -0
  74. package/tests/fixtures/stacks/nodejs/package.json +5 -0
  75. package/tests/fixtures/stacks/react/package.json +5 -0
  76. package/tests/fixtures/stacks/rust/Cargo.toml +4 -0
  77. package/tests/fixtures/stacks/vue/package.json +8 -0
  78. package/tests/fixtures/stacks/vue/vite.config.ts +5 -0
  79. package/tests/utils/manifest.test.ts +31 -0
  80. package/tests/utils/paths.test.ts +23 -0
  81. package/tests/utils/skills.test.ts +49 -0
  82. package/tsconfig.json +14 -0
  83. package/vitest.config.ts +8 -0
@@ -0,0 +1,227 @@
1
+ import { execFileSync, spawnSync } from "child_process";
2
+ import os from "os";
3
+ import path from "path";
4
+ import fs from "fs-extra";
5
+ import { beforeAll, describe, expect, it } from "vitest";
6
+
7
+ const repoRoot = path.resolve(__dirname, "..", "..");
8
+ const cliPath = path.join(repoRoot, "dist", "index.js");
9
+
10
+ describe("nymor CLI", () => {
11
+ beforeAll(() => {
12
+ execFileSync("npm", ["run", "build"], { cwd: repoRoot, stdio: "inherit" });
13
+ }, 60_000);
14
+
15
+ it("initializes empty local memory, compiles idempotently, and passes doctor", async () => {
16
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-e2e-"));
17
+ await fs.writeJson(path.join(projectRoot, "package.json"), { name: "fixture", version: "1.0.0" });
18
+
19
+ runCli(["init"], projectRoot);
20
+
21
+ await expect(fs.readdir(path.join(projectRoot, ".nymor", "skills"))).resolves.toEqual([]);
22
+ await expect(fs.readJson(path.join(projectRoot, "nymor.json"))).resolves.toMatchObject({ local: [] });
23
+ await expect(fs.pathExists(path.join(projectRoot, ".nymor", "skills", "commit-conventions"))).resolves.toBe(false);
24
+
25
+ runCli(["compile"], projectRoot);
26
+ const before = await readTree(projectRoot);
27
+ runCli(["compile"], projectRoot);
28
+ const after = await readTree(projectRoot);
29
+
30
+ expect(after).toEqual(before);
31
+ runCli(["doctor"], projectRoot);
32
+ }, 60_000);
33
+
34
+ it("hides internal and removed commands from public help", () => {
35
+ const result = spawnSync(process.execPath, [cliPath, "--help"], {
36
+ cwd: repoRoot,
37
+ encoding: "utf8"
38
+ });
39
+
40
+ expect(result.status).toBe(0);
41
+ expect(result.stdout).toContain("nymor");
42
+ expect(result.stdout).toContain("init");
43
+ expect(result.stdout).not.toContain("learn");
44
+ expect(result.stdout).not.toContain("add");
45
+ expect(result.stdout).not.toContain("remove");
46
+ expect(result.stdout).not.toContain("update");
47
+ });
48
+
49
+ it("keeps the README focused on Nymor repo memory", async () => {
50
+ const readme = await fs.readFile(path.join(repoRoot, "README.md"), "utf8");
51
+
52
+ expect(readme).toContain("# Nymor");
53
+ expect(readme).toContain("/nymor-learn");
54
+ for (const forbidden of ["Cicada", "cicada", "registry", "draft", "approve", "starter skills", "nymor learn"]) {
55
+ expect(readme).not.toContain(forbidden);
56
+ }
57
+ });
58
+
59
+ it("keeps the hidden learn fallback working", async () => {
60
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-learn-"));
61
+ await fs.writeJson(path.join(projectRoot, "nymor.json"), {
62
+ version: "1",
63
+ agents: [],
64
+ local: []
65
+ });
66
+
67
+ runCli(
68
+ [
69
+ "learn",
70
+ "Use Server Actions for all mutations",
71
+ "--id",
72
+ "server-actions-only",
73
+ "--name",
74
+ "Server Actions Only",
75
+ "--description",
76
+ "Use this when changing app mutations",
77
+ "--globs",
78
+ "app/**/*.ts,app/**/*.tsx",
79
+ "--why",
80
+ "Keeps mutations close to UI and simplifies auth.",
81
+ "--example",
82
+ "Prefer an exported 'use server' action over a new API route."
83
+ ],
84
+ projectRoot
85
+ );
86
+
87
+ const skillPath = path.join(projectRoot, ".nymor", "skills", "server-actions-only", "SKILL.md");
88
+ await expect(fs.readFile(skillPath, "utf8")).resolves.toContain("Keeps mutations close to UI and simplifies auth.");
89
+ await expect(fs.readJson(path.join(projectRoot, "nymor.json"))).resolves.toMatchObject({
90
+ local: ["server-actions-only"]
91
+ });
92
+ }, 60_000);
93
+
94
+ it("writes Nymor outputs for the common agent set", async () => {
95
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-agents-"));
96
+ await fs.writeJson(path.join(projectRoot, "nymor.json"), {
97
+ version: "1",
98
+ agents: ["claude", "cursor", "copilot", "kiro", "agents-md", "gemini", "windsurf", "goose", "opencode"],
99
+ local: ["demo"]
100
+ });
101
+ await writeDemoSkill(projectRoot);
102
+
103
+ runCli(["compile"], projectRoot);
104
+
105
+ await expect(fs.pathExists(path.join(projectRoot, ".claude", "skills", "demo", "SKILL.md"))).resolves.toBe(true);
106
+ await expect(fs.pathExists(path.join(projectRoot, ".cursor", "rules", "nymor-demo.mdc"))).resolves.toBe(true);
107
+ await expect(fs.pathExists(path.join(projectRoot, ".github", "instructions", "nymor-demo.instructions.md"))).resolves.toBe(true);
108
+ await expect(fs.pathExists(path.join(projectRoot, ".github", "prompts", "nymor-learn.prompt.md"))).resolves.toBe(true);
109
+ await expect(fs.pathExists(path.join(projectRoot, ".kiro", "steering", "nymor-demo.md"))).resolves.toBe(true);
110
+ await expect(fs.pathExists(path.join(projectRoot, ".goose", "skills", "demo", "SKILL.md"))).resolves.toBe(true);
111
+ await expect(fs.pathExists(path.join(projectRoot, ".opencode", "skill", "demo", "SKILL.md"))).resolves.toBe(true);
112
+
113
+ await expect(fs.readFile(path.join(projectRoot, ".claude", "commands", "nymor-learn.md"), "utf8")).resolves.toContain(
114
+ "This looks like a reusable repo rule. Want me to capture it with /nymor-learn?"
115
+ );
116
+ await expect(fs.readFile(path.join(projectRoot, ".cursor", "commands", "nymor-learn.md"), "utf8")).resolves.toContain(
117
+ "Only create a skill after the user explicitly invokes /nymor-learn."
118
+ );
119
+ await expect(fs.readFile(path.join(projectRoot, ".github", "prompts", "nymor-learn.prompt.md"), "utf8")).resolves.toContain(
120
+ "Only create a skill after the user explicitly invokes /nymor-learn."
121
+ );
122
+ await expect(fs.readFile(path.join(projectRoot, "AGENTS.md"), "utf8")).resolves.toContain("<!-- nymor:start -->");
123
+ await expect(fs.readFile(path.join(projectRoot, "GEMINI.md"), "utf8")).resolves.toContain("/nymor-learn");
124
+ await expect(fs.readFile(path.join(projectRoot, ".windsurf", "rules", "nymor.md"), "utf8")).resolves.toContain(
125
+ ".nymor/skills/"
126
+ );
127
+ }, 60_000);
128
+
129
+ it("doctor flags broken globs", async () => {
130
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-doctor-"));
131
+ await fs.writeJson(path.join(projectRoot, "nymor.json"), {
132
+ version: "1",
133
+ agents: [],
134
+ local: ["broken"]
135
+ });
136
+ await fs.outputFile(
137
+ path.join(projectRoot, ".nymor", "skills", "broken", "SKILL.md"),
138
+ [
139
+ "---",
140
+ "name: Broken Glob",
141
+ "globs:",
142
+ " - nope/**/*.fake",
143
+ "alwaysApply: false",
144
+ "---",
145
+ "",
146
+ "## Rule",
147
+ "Use matching globs.",
148
+ "",
149
+ "## Why",
150
+ "Doctor should catch broken scope.",
151
+ "",
152
+ "## Example",
153
+ "nope"
154
+ ].join("\n"),
155
+ "utf8"
156
+ );
157
+
158
+ runCli(["compile"], projectRoot);
159
+ const result = spawnSync(process.execPath, [cliPath, "doctor"], {
160
+ cwd: projectRoot,
161
+ encoding: "utf8"
162
+ });
163
+
164
+ expect(result.status).toBe(1);
165
+ expect(`${result.stdout}${result.stderr}`).toContain("no matches: nope/**/*.fake");
166
+ }, 60_000);
167
+ });
168
+
169
+ function runCli(args: string[], cwd: string, env: Record<string, string> = {}): void {
170
+ execFileSync(process.execPath, [cliPath, ...args], {
171
+ cwd,
172
+ env: { ...process.env, ...env },
173
+ stdio: "pipe"
174
+ });
175
+ }
176
+
177
+ async function writeDemoSkill(projectRoot: string): Promise<void> {
178
+ await fs.outputFile(
179
+ path.join(projectRoot, ".nymor", "skills", "demo", "SKILL.md"),
180
+ [
181
+ "---",
182
+ "name: Demo",
183
+ "description: Demo skill",
184
+ "globs:",
185
+ " - \"**/*\"",
186
+ "alwaysApply: true",
187
+ "---",
188
+ "",
189
+ "## Rule",
190
+ "Use demos.",
191
+ "",
192
+ "## Why",
193
+ "For tests.",
194
+ "",
195
+ "## Example",
196
+ "demo"
197
+ ].join("\n"),
198
+ "utf8"
199
+ );
200
+ }
201
+
202
+ async function readTree(root: string): Promise<Record<string, string>> {
203
+ const files = (await listFiles(root)).filter((file) => !file.includes(`${path.sep}.git${path.sep}`));
204
+ const output: Record<string, string> = {};
205
+
206
+ for (const file of files) {
207
+ output[path.relative(root, file)] = await fs.readFile(file, "utf8");
208
+ }
209
+
210
+ return output;
211
+ }
212
+
213
+ async function listFiles(root: string): Promise<string[]> {
214
+ const entries = await fs.readdir(root, { withFileTypes: true });
215
+ const files: string[] = [];
216
+
217
+ for (const entry of entries) {
218
+ const entryPath = path.join(root, entry.name);
219
+ if (entry.isDirectory()) {
220
+ files.push(...(await listFiles(entryPath)));
221
+ } else {
222
+ files.push(entryPath);
223
+ }
224
+ }
225
+
226
+ return files.sort();
227
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: Scoped Skill
3
+ description: Applies to TypeScript source files.
4
+ globs:
5
+ - "src/**/*.ts"
6
+ alwaysApply: false
7
+ ---
8
+
9
+ # Skill: Scoped Skill
10
+
11
+ ## Rule
12
+ Use explicit return types on exported functions.
13
+
14
+ ## Why
15
+ Exported APIs should be clear at the boundary.
16
+
17
+ ## Example
18
+ export function run(): void {}
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: Simple Skill
3
+ description: Applies everywhere.
4
+ alwaysApply: true
5
+ ---
6
+
7
+ # Skill: Simple Skill
8
+
9
+ ## Rule
10
+ Always keep the code readable.
11
+
12
+ ## Why
13
+ Readable code is easier to review and maintain.
14
+
15
+ ## Example
16
+ Prefer a named helper over a dense inline expression.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: With Examples
3
+ description: Includes extra example files.
4
+ globs:
5
+ - "src/**/*.ts"
6
+ alwaysApply: false
7
+ ---
8
+
9
+ # Skill: With Examples
10
+
11
+ ## Rule
12
+ Keep examples near the skill.
13
+
14
+ ## Why
15
+ Examples make a convention concrete.
16
+
17
+ ## Example
18
+ See examples/example.md.
@@ -0,0 +1,3 @@
1
+ # Example
2
+
3
+ Use a small fixture alongside the skill.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env python
2
+ print("django")
@@ -0,0 +1 @@
1
+ django==5.0.0
@@ -0,0 +1 @@
1
+ fastapi==0.110.0
@@ -0,0 +1,3 @@
1
+ module example.com/fixture
2
+
3
+ go 1.22
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "express": "^4.18.0"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "react": "^18.2.0"
4
+ }
5
+ }
@@ -0,0 +1,4 @@
1
+ [package]
2
+ name = "fixture"
3
+ version = "0.1.0"
4
+ edition = "2021"
@@ -0,0 +1,8 @@
1
+ {
2
+ "dependencies": {
3
+ "vue": "^3.4.0"
4
+ },
5
+ "devDependencies": {
6
+ "vite": "^5.0.0"
7
+ }
8
+ }
@@ -0,0 +1,5 @@
1
+ import vue from "@vitejs/plugin-vue";
2
+
3
+ export default {
4
+ plugins: [vue()]
5
+ };
@@ -0,0 +1,31 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import fs from "fs-extra";
4
+ import { describe, expect, it } from "vitest";
5
+ import { DEFAULT_AGENT_TARGETS } from "../../src/agents/targets";
6
+ import { readManifest, writeManifest } from "../../src/utils/manifest";
7
+
8
+ describe("manifest utilities", () => {
9
+ it("returns defaults when nymor.json is missing", async () => {
10
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-manifest-"));
11
+
12
+ await expect(readManifest(projectRoot)).resolves.toMatchObject({
13
+ version: "1",
14
+ agents: DEFAULT_AGENT_TARGETS,
15
+ local: []
16
+ });
17
+ });
18
+
19
+ it("roundtrips manifest JSON", async () => {
20
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "nymor-manifest-"));
21
+ const manifest = {
22
+ version: "1",
23
+ agents: ["claude" as const],
24
+ local: ["local-demo"]
25
+ };
26
+
27
+ await writeManifest(projectRoot, manifest);
28
+
29
+ await expect(readManifest(projectRoot)).resolves.toEqual(manifest);
30
+ });
31
+ });
@@ -0,0 +1,23 @@
1
+ import path from "path";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ getIndexJsonPath,
5
+ getIndexMarkdownPath,
6
+ getManifestPath,
7
+ getNymorDir,
8
+ getRepoRoot,
9
+ getSkillsDir
10
+ } from "../../src/utils/paths";
11
+
12
+ describe("path utilities", () => {
13
+ it("derives project paths from a root", () => {
14
+ const root = path.join(path.sep, "tmp", "project");
15
+
16
+ expect(getRepoRoot(root)).toBe(root);
17
+ expect(getNymorDir(root)).toBe(path.join(root, ".nymor"));
18
+ expect(getSkillsDir(root)).toBe(path.join(root, ".nymor", "skills"));
19
+ expect(getIndexMarkdownPath(root)).toBe(path.join(root, ".nymor", "index.md"));
20
+ expect(getIndexJsonPath(root)).toBe(path.join(root, ".nymor", "index.json"));
21
+ expect(getManifestPath(root)).toBe(path.join(root, "nymor.json"));
22
+ });
23
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildSkillIndex, parseSkillContent } from "../../src/utils/skills";
3
+
4
+ describe("parseSkillContent", () => {
5
+ it("normalizes valid frontmatter", () => {
6
+ const parsed = parseSkillContent(
7
+ ["---", "name: Demo", "globs:", " - src/**/*.ts", "alwaysApply: true", "---", "", "## Rule", "Use demos."].join("\n"),
8
+ "demo"
9
+ );
10
+
11
+ expect(parsed.frontmatter).toEqual({
12
+ name: "Demo",
13
+ description: "",
14
+ globs: ["src/**/*.ts"],
15
+ alwaysApply: true
16
+ });
17
+ expect(parsed.body).toContain("## Rule");
18
+ });
19
+
20
+ it("throws when frontmatter is missing", () => {
21
+ expect(() => parseSkillContent("## Rule", "missing")).toThrow("missing frontmatter");
22
+ });
23
+
24
+ it("throws when frontmatter is malformed", () => {
25
+ expect(() => parseSkillContent(["---", "name: [", "---"].join("\n"), "bad")).toThrow();
26
+ });
27
+
28
+ it("throws when name is missing", () => {
29
+ expect(() => parseSkillContent(["---", "description: nope", "---"].join("\n"), "bad")).toThrow("missing a name");
30
+ });
31
+ });
32
+
33
+ describe("buildSkillIndex", () => {
34
+ it("renders markdown and json entries", () => {
35
+ const index = buildSkillIndex([
36
+ {
37
+ id: "demo",
38
+ dirPath: "/tmp/demo",
39
+ skillPath: "/tmp/demo/SKILL.md",
40
+ frontmatter: { name: "Demo", description: "Desc", globs: ["src/**/*.ts"], alwaysApply: false },
41
+ body: "body",
42
+ raw: "raw"
43
+ }
44
+ ]);
45
+
46
+ expect(index.markdown).toContain("| Demo | demo | Desc | src/**/*.ts | no |");
47
+ expect(JSON.parse(index.json).skills[0].id).toBe("demo");
48
+ });
49
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "outDir": "./dist",
6
+ "rootDir": "./src",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "resolveJsonModule": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src/**/*"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ clearMocks: true,
6
+ restoreMocks: true
7
+ }
8
+ });