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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/dist/agents/targets.js +111 -0
- package/dist/commands/add.js +121 -0
- package/dist/commands/compile.js +159 -0
- package/dist/commands/doctor.js +205 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/inject.js +24 -0
- package/dist/commands/learn.js +145 -0
- package/dist/commands/list.js +55 -0
- package/dist/commands/remove.js +38 -0
- package/dist/commands/update.js +80 -0
- package/dist/commands/validate.js +82 -0
- package/dist/compiler/agentsmd.js +17 -0
- package/dist/compiler/block.js +25 -0
- package/dist/compiler/claude.js +16 -0
- package/dist/compiler/copilot.js +29 -0
- package/dist/compiler/cursor.js +38 -0
- package/dist/compiler/kiro.js +24 -0
- package/dist/detector/agents.js +24 -0
- package/dist/detector/stack.js +113 -0
- package/dist/index.js +52 -0
- package/dist/registry/cache.js +60 -0
- package/dist/registry/client.js +135 -0
- package/dist/registry/resolver.js +29 -0
- package/dist/registry/types.js +2 -0
- package/dist/templates/bootstrap.js +97 -0
- package/dist/templates/cicada-json.js +11 -0
- package/dist/templates/nymor-json.js +11 -0
- package/dist/utils/manifest.js +32 -0
- package/dist/utils/paths.js +30 -0
- package/dist/utils/skills.js +114 -0
- package/package.json +32 -0
- package/src/agents/targets.ts +141 -0
- package/src/commands/compile.ts +202 -0
- package/src/commands/doctor.ts +253 -0
- package/src/commands/init.ts +113 -0
- package/src/commands/learn.ts +175 -0
- package/src/commands/list.ts +57 -0
- package/src/commands/validate.ts +89 -0
- package/src/compiler/block.ts +26 -0
- package/src/compiler/claude.ts +13 -0
- package/src/compiler/copilot.ts +28 -0
- package/src/compiler/cursor.ts +38 -0
- package/src/compiler/kiro.ts +22 -0
- package/src/detector/agents.ts +26 -0
- package/src/detector/stack.ts +135 -0
- package/src/index.ts +59 -0
- package/src/templates/bootstrap.ts +109 -0
- package/src/templates/nymor-json.ts +15 -0
- package/src/utils/manifest.ts +38 -0
- package/src/utils/paths.ts +25 -0
- package/src/utils/skills.ts +152 -0
- package/tests/compiler/__snapshots__/claude.test.ts.snap +65 -0
- package/tests/compiler/__snapshots__/copilot.test.ts.snap +54 -0
- package/tests/compiler/__snapshots__/cursor.test.ts.snap +62 -0
- package/tests/compiler/__snapshots__/kiro.test.ts.snap +54 -0
- package/tests/compiler/block.test.ts +24 -0
- package/tests/compiler/claude.test.ts +46 -0
- package/tests/compiler/copilot.test.ts +15 -0
- package/tests/compiler/cursor.test.ts +15 -0
- package/tests/compiler/kiro.test.ts +15 -0
- package/tests/detector/agents.test.ts +48 -0
- package/tests/detector/stack.test.ts +29 -0
- package/tests/e2e/init-and-compile.test.ts +227 -0
- package/tests/fixtures/skills/scoped/SKILL.md +18 -0
- package/tests/fixtures/skills/simple/SKILL.md +16 -0
- package/tests/fixtures/skills/with-examples/SKILL.md +18 -0
- package/tests/fixtures/skills/with-examples/examples/example.md +3 -0
- package/tests/fixtures/stacks/django/manage.py +2 -0
- package/tests/fixtures/stacks/django/requirements.txt +1 -0
- package/tests/fixtures/stacks/fastapi/requirements.txt +1 -0
- package/tests/fixtures/stacks/go/go.mod +3 -0
- package/tests/fixtures/stacks/nodejs/package.json +5 -0
- package/tests/fixtures/stacks/react/package.json +5 -0
- package/tests/fixtures/stacks/rust/Cargo.toml +4 -0
- package/tests/fixtures/stacks/vue/package.json +8 -0
- package/tests/fixtures/stacks/vue/vite.config.ts +5 -0
- package/tests/utils/manifest.test.ts +31 -0
- package/tests/utils/paths.test.ts +23 -0
- package/tests/utils/skills.test.ts +49 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
import { AGENT_TARGETS, AgentTarget } from "../agents/targets";
|
|
6
|
+
import { planCompileOutputs } from "./compile";
|
|
7
|
+
import { NymorManifest } from "../utils/manifest";
|
|
8
|
+
import { getManifestPath, getSkillsDir } from "../utils/paths";
|
|
9
|
+
import { listSkillDirectories } from "../utils/skills";
|
|
10
|
+
|
|
11
|
+
const VALID_AGENTS: AgentTarget[] = AGENT_TARGETS.map((target) => target.id);
|
|
12
|
+
|
|
13
|
+
interface CheckResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
label: string;
|
|
16
|
+
filePath: string;
|
|
17
|
+
message: string;
|
|
18
|
+
warn?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ParsedSkillFrontmatter {
|
|
22
|
+
id: string;
|
|
23
|
+
filePath: string;
|
|
24
|
+
name: string;
|
|
25
|
+
globs: string[];
|
|
26
|
+
alwaysApply: boolean;
|
|
27
|
+
valid: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function doctorCommand(): Promise<void> {
|
|
31
|
+
const projectRoot = process.cwd();
|
|
32
|
+
const results: CheckResult[] = [];
|
|
33
|
+
|
|
34
|
+
await checkManifest(projectRoot, results);
|
|
35
|
+
const frontmatters = await checkSkillFrontmatter(projectRoot, results);
|
|
36
|
+
await checkGlobExistence(projectRoot, frontmatters, results);
|
|
37
|
+
checkDuplicateNames(projectRoot, frontmatters, results);
|
|
38
|
+
await checkCompiledOutput(projectRoot, results);
|
|
39
|
+
|
|
40
|
+
for (const result of results) {
|
|
41
|
+
const status = result.warn ? "WARN" : result.ok ? "PASS" : "FAIL";
|
|
42
|
+
console.log(`${status} ${result.label} - ${result.filePath}${result.message ? ` - ${result.message}` : ""}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (results.some((result) => !result.ok && !result.warn)) {
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function checkManifest(projectRoot: string, results: CheckResult[]): Promise<NymorManifest | null> {
|
|
51
|
+
const manifestPath = getManifestPath(projectRoot);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const manifest = (await fs.readJson(manifestPath)) as NymorManifest;
|
|
55
|
+
const errors: string[] = [];
|
|
56
|
+
|
|
57
|
+
if (manifest.version !== "1") {
|
|
58
|
+
errors.push('version must be "1"');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const invalidAgents = (manifest.agents ?? []).filter((agent) => !VALID_AGENTS.includes(agent));
|
|
62
|
+
if (invalidAgents.length > 0) {
|
|
63
|
+
errors.push(`invalid agents: ${invalidAgents.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
results.push({
|
|
67
|
+
ok: errors.length === 0,
|
|
68
|
+
label: "Manifest sanity",
|
|
69
|
+
filePath: manifestPath,
|
|
70
|
+
message: errors.join("; ")
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return errors.length === 0 ? normalizeManifest(manifest) : null;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
results.push({
|
|
76
|
+
ok: false,
|
|
77
|
+
label: "Manifest sanity",
|
|
78
|
+
filePath: manifestPath,
|
|
79
|
+
message: err instanceof Error ? err.message : String(err)
|
|
80
|
+
});
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function checkSkillFrontmatter(projectRoot: string, results: CheckResult[]): Promise<ParsedSkillFrontmatter[]> {
|
|
86
|
+
const skillsDir = getSkillsDir(projectRoot);
|
|
87
|
+
const skillDirs = await listSkillDirectories(skillsDir);
|
|
88
|
+
const parsed: ParsedSkillFrontmatter[] = [];
|
|
89
|
+
|
|
90
|
+
for (const id of skillDirs) {
|
|
91
|
+
const skillPath = path.join(skillsDir, id, "SKILL.md");
|
|
92
|
+
const errors: string[] = [];
|
|
93
|
+
let frontmatter: Record<string, unknown> = {};
|
|
94
|
+
|
|
95
|
+
if (!(await fs.pathExists(skillPath))) {
|
|
96
|
+
errors.push("missing SKILL.md");
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
frontmatter = parseFrontmatter(await fs.readFile(skillPath, "utf8"));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!frontmatter.name) {
|
|
106
|
+
errors.push("missing name");
|
|
107
|
+
}
|
|
108
|
+
if (frontmatter.globs !== undefined && !Array.isArray(frontmatter.globs)) {
|
|
109
|
+
errors.push("globs must be an array");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isValid = errors.length === 0;
|
|
113
|
+
results.push({
|
|
114
|
+
ok: isValid,
|
|
115
|
+
label: "Frontmatter validity",
|
|
116
|
+
filePath: skillPath,
|
|
117
|
+
message: errors.join("; ")
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
parsed.push({
|
|
121
|
+
id,
|
|
122
|
+
filePath: skillPath,
|
|
123
|
+
name: typeof frontmatter.name === "string" ? frontmatter.name : "",
|
|
124
|
+
globs: Array.isArray(frontmatter.globs) ? frontmatter.globs.map(String) : [],
|
|
125
|
+
alwaysApply: Boolean(frontmatter.alwaysApply),
|
|
126
|
+
valid: isValid
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function checkGlobExistence(
|
|
134
|
+
projectRoot: string,
|
|
135
|
+
frontmatters: ParsedSkillFrontmatter[],
|
|
136
|
+
results: CheckResult[]
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
for (const skill of frontmatters) {
|
|
139
|
+
if (!skill.valid || skill.alwaysApply || skill.globs.length === 0) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const missing: string[] = [];
|
|
144
|
+
for (const pattern of skill.globs) {
|
|
145
|
+
const matches = await glob(pattern, {
|
|
146
|
+
cwd: projectRoot,
|
|
147
|
+
nodir: true,
|
|
148
|
+
dot: true,
|
|
149
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.nymor/**"]
|
|
150
|
+
});
|
|
151
|
+
if (matches.length === 0) {
|
|
152
|
+
missing.push(pattern);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
results.push({
|
|
157
|
+
ok: missing.length === 0,
|
|
158
|
+
label: "Glob existence",
|
|
159
|
+
filePath: skill.filePath,
|
|
160
|
+
message: missing.length > 0 ? `no matches: ${missing.join(", ")}` : ""
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkDuplicateNames(
|
|
166
|
+
projectRoot: string,
|
|
167
|
+
frontmatters: ParsedSkillFrontmatter[],
|
|
168
|
+
results: CheckResult[]
|
|
169
|
+
): void {
|
|
170
|
+
const seen = new Map<string, ParsedSkillFrontmatter>();
|
|
171
|
+
|
|
172
|
+
for (const skill of frontmatters.filter((item) => item.valid)) {
|
|
173
|
+
const previous = seen.get(skill.name);
|
|
174
|
+
if (previous) {
|
|
175
|
+
results.push({
|
|
176
|
+
ok: false,
|
|
177
|
+
label: "Duplicate names",
|
|
178
|
+
filePath: skill.filePath,
|
|
179
|
+
message: `duplicates ${previous.filePath}`
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
seen.set(skill.name, skill);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (![...seen.values()].some((skill) => hasDuplicate(skill.name, frontmatters))) {
|
|
187
|
+
results.push({
|
|
188
|
+
ok: true,
|
|
189
|
+
label: "Duplicate names",
|
|
190
|
+
filePath: getSkillsDir(projectRoot),
|
|
191
|
+
message: ""
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function checkCompiledOutput(projectRoot: string, results: CheckResult[]): Promise<void> {
|
|
197
|
+
try {
|
|
198
|
+
const planned = await planCompileOutputs(projectRoot);
|
|
199
|
+
const stale: string[] = [];
|
|
200
|
+
|
|
201
|
+
for (const file of planned) {
|
|
202
|
+
if (!(await fs.pathExists(file.path))) {
|
|
203
|
+
stale.push(file.path);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const actual = await fs.readFile(file.path);
|
|
208
|
+
if (!actual.equals(file.content)) {
|
|
209
|
+
stale.push(file.path);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
results.push({
|
|
214
|
+
ok: stale.length === 0,
|
|
215
|
+
label: "Compiled output staleness",
|
|
216
|
+
filePath: projectRoot,
|
|
217
|
+
message: stale.length > 0 ? "run `nymor compile`" : ""
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
results.push({
|
|
221
|
+
ok: false,
|
|
222
|
+
label: "Compiled output staleness",
|
|
223
|
+
filePath: projectRoot,
|
|
224
|
+
message: err instanceof Error ? err.message : String(err)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseFrontmatter(content: string): Record<string, unknown> {
|
|
230
|
+
const lines = content.split(/\r?\n/);
|
|
231
|
+
if (lines[0]?.trim() !== "---") {
|
|
232
|
+
throw new Error("missing frontmatter");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const endIndex = lines.slice(1).findIndex((line) => line.trim() === "---");
|
|
236
|
+
if (endIndex === -1) {
|
|
237
|
+
throw new Error("frontmatter is not closed");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (yaml.parse(lines.slice(1, endIndex + 1).join("\n")) ?? {}) as Record<string, unknown>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeManifest(manifest: NymorManifest): NymorManifest {
|
|
244
|
+
return {
|
|
245
|
+
version: manifest.version,
|
|
246
|
+
agents: manifest.agents ?? [],
|
|
247
|
+
local: manifest.local ?? []
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hasDuplicate(name: string, frontmatters: ParsedSkillFrontmatter[]): boolean {
|
|
252
|
+
return frontmatters.filter((skill) => skill.valid && skill.name === name).length > 1;
|
|
253
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { AGENT_TARGETS, AgentTarget, DEFAULT_AGENT_TARGETS } from "../agents/targets";
|
|
4
|
+
import { compileCommand } from "./compile";
|
|
5
|
+
import { detectAgents } from "../detector/agents";
|
|
6
|
+
import { createDefaultManifest } from "../templates/nymor-json";
|
|
7
|
+
import { NymorManifest, readManifest, writeManifest } from "../utils/manifest";
|
|
8
|
+
import { getManifestPath, getNymorDir, getSkillsDir } from "../utils/paths";
|
|
9
|
+
|
|
10
|
+
type InitMode = "new" | "update" | "reinit";
|
|
11
|
+
|
|
12
|
+
const AGENT_CHOICES: Array<{ name: string; value: AgentTarget; short: string }> = AGENT_TARGETS.map((target) => ({
|
|
13
|
+
name: `${target.label} (${target.description})`,
|
|
14
|
+
value: target.id,
|
|
15
|
+
short: target.short
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
export async function initCommand(): Promise<void> {
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
const nymorDir = getNymorDir(projectRoot);
|
|
21
|
+
const skillsDir = getSkillsDir(projectRoot);
|
|
22
|
+
|
|
23
|
+
const mode = await resolveInitMode(nymorDir);
|
|
24
|
+
if (!mode) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (mode === "reinit") {
|
|
29
|
+
await fs.remove(nymorDir);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const manifestPath = getManifestPath(projectRoot);
|
|
33
|
+
const existingManifest = (await fs.pathExists(manifestPath)) ? await readManifest(projectRoot) : null;
|
|
34
|
+
const manifest = createDefaultManifest() as NymorManifest;
|
|
35
|
+
manifest.agents = await selectAgentTargets(projectRoot, existingManifest?.agents);
|
|
36
|
+
manifest.local = mode === "reinit" ? [] : existingManifest?.local ?? [];
|
|
37
|
+
await writeManifest(projectRoot, manifest);
|
|
38
|
+
|
|
39
|
+
await fs.ensureDir(skillsDir);
|
|
40
|
+
await compileCommand();
|
|
41
|
+
printSummary(manifest.agents);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveInitMode(nymorDir: string): Promise<InitMode | null> {
|
|
45
|
+
if (!(await fs.pathExists(nymorDir))) {
|
|
46
|
+
return "new";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!process.stdin.isTTY) {
|
|
50
|
+
return "update";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { action } = await inquirer.prompt<{ action: InitMode | "cancel" }>([
|
|
54
|
+
{
|
|
55
|
+
type: "list",
|
|
56
|
+
name: "action",
|
|
57
|
+
message: "Nymor already initialized. What do you want to do?",
|
|
58
|
+
choices: [
|
|
59
|
+
{ name: "Update agent targets", value: "update" },
|
|
60
|
+
{ name: "Reinitialize (overwrites .nymor/)", value: "reinit" },
|
|
61
|
+
{ name: "Cancel", value: "cancel" }
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
return action === "cancel" ? null : action;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function selectAgentTargets(projectRoot: string, existingAgents?: AgentTarget[]): Promise<AgentTarget[]> {
|
|
70
|
+
const detectedAgents = await detectExistingAgentTargets(projectRoot);
|
|
71
|
+
const defaults = existingAgents && existingAgents.length > 0 ? existingAgents : detectedAgents;
|
|
72
|
+
|
|
73
|
+
if (!process.stdin.isTTY) {
|
|
74
|
+
return defaults.length > 0 ? defaults : [...DEFAULT_AGENT_TARGETS];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { agents } = await inquirer.prompt<{ agents: AgentTarget[] }>([
|
|
78
|
+
{
|
|
79
|
+
type: "checkbox",
|
|
80
|
+
name: "agents",
|
|
81
|
+
message: "Which agent outputs should Nymor manage?",
|
|
82
|
+
choices: AGENT_CHOICES,
|
|
83
|
+
default: defaults.length > 0 ? defaults : DEFAULT_AGENT_TARGETS
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
return agents;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function detectExistingAgentTargets(projectRoot: string): Promise<AgentTarget[]> {
|
|
91
|
+
const detected = await detectAgents(projectRoot);
|
|
92
|
+
return AGENT_TARGETS.filter((target) => detected[target.id]).map((target) => target.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printSummary(agents: AgentTarget[]): void {
|
|
96
|
+
const checkMark = "\u2713";
|
|
97
|
+
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(`${checkMark} Nymor initialized`);
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log("Skills directory: .nymor/skills/");
|
|
102
|
+
console.log("Index created: .nymor/index.md");
|
|
103
|
+
console.log(`Agent outputs: ${formatAgentSummary(agents)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatAgentSummary(agents: AgentTarget[]): string {
|
|
107
|
+
if (agents.length === 0) {
|
|
108
|
+
return "none selected";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const labels = new Map<AgentTarget, string>(AGENT_CHOICES.map((choice) => [choice.value, choice.short]));
|
|
112
|
+
return agents.map((agent) => labels.get(agent) ?? agent).join(", ");
|
|
113
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import type { DistinctQuestion } from "inquirer";
|
|
5
|
+
import yaml from "yaml";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { compileCommand } from "./compile";
|
|
8
|
+
import { readManifest, writeManifest } from "../utils/manifest";
|
|
9
|
+
import { getSkillsDir } from "../utils/paths";
|
|
10
|
+
|
|
11
|
+
interface LearnAnswers {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
globs: string;
|
|
15
|
+
alwaysApply: boolean;
|
|
16
|
+
why: string;
|
|
17
|
+
example: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LearnOptions {
|
|
21
|
+
id?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
globs?: string;
|
|
25
|
+
alwaysApply?: boolean;
|
|
26
|
+
why?: string;
|
|
27
|
+
example?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function learnCommand(rule: string, options: LearnOptions = {}): Promise<void> {
|
|
31
|
+
const projectRoot = process.cwd();
|
|
32
|
+
const slug = slugifyRule(options.id ?? rule);
|
|
33
|
+
const skillDir = path.join(getSkillsDir(projectRoot), slug);
|
|
34
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
35
|
+
|
|
36
|
+
if (await fs.pathExists(skillPath)) {
|
|
37
|
+
throw new Error(`A local skill already exists at ${skillPath}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const answers = await promptForSkill(rule, slug, options);
|
|
41
|
+
|
|
42
|
+
const frontmatter = yaml.stringify({
|
|
43
|
+
name: answers.name,
|
|
44
|
+
description: answers.description,
|
|
45
|
+
globs: parseGlobs(answers.globs),
|
|
46
|
+
alwaysApply: answers.alwaysApply
|
|
47
|
+
});
|
|
48
|
+
const content = [
|
|
49
|
+
"---",
|
|
50
|
+
frontmatter.trimEnd(),
|
|
51
|
+
"---",
|
|
52
|
+
"",
|
|
53
|
+
`# Skill: ${answers.name}`,
|
|
54
|
+
"",
|
|
55
|
+
"## Rule",
|
|
56
|
+
rule,
|
|
57
|
+
"",
|
|
58
|
+
"## Why",
|
|
59
|
+
answers.why,
|
|
60
|
+
"",
|
|
61
|
+
"## Example",
|
|
62
|
+
answers.example,
|
|
63
|
+
""
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
await fs.ensureDir(skillDir);
|
|
67
|
+
await fs.writeFile(skillPath, content, "utf8");
|
|
68
|
+
|
|
69
|
+
const manifest = await readManifest(projectRoot);
|
|
70
|
+
if (!manifest.local.includes(slug)) {
|
|
71
|
+
manifest.local.push(slug);
|
|
72
|
+
}
|
|
73
|
+
await writeManifest(projectRoot, manifest);
|
|
74
|
+
|
|
75
|
+
await compileCommand();
|
|
76
|
+
|
|
77
|
+
console.log("");
|
|
78
|
+
console.log(`${pc.green("✓")} Created local skill: ${skillPath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function slugifyRule(rule: string): string {
|
|
82
|
+
const slug = rule
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/['"]/g, "")
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
86
|
+
.replace(/^-+|-+$/g, "")
|
|
87
|
+
.slice(0, 50)
|
|
88
|
+
.replace(/-+$/g, "");
|
|
89
|
+
|
|
90
|
+
return slug || "new-skill";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseGlobs(value: string): string[] {
|
|
94
|
+
return value
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((glob) => glob.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function titleCase(value: string): string {
|
|
101
|
+
return value
|
|
102
|
+
.split("-")
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
|
105
|
+
.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function promptForSkill(rule: string, slug: string, options: LearnOptions): Promise<LearnAnswers> {
|
|
109
|
+
const defaults: LearnAnswers = {
|
|
110
|
+
name: options.name ?? titleCase(slug),
|
|
111
|
+
description: options.description ?? rule,
|
|
112
|
+
globs: options.globs ?? "**/*",
|
|
113
|
+
alwaysApply: options.alwaysApply ?? false,
|
|
114
|
+
why: options.why ?? "TBD - describe why",
|
|
115
|
+
example: options.example ?? "TBD - add an example"
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (!process.stdin.isTTY) {
|
|
119
|
+
return defaults;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const questions: Array<DistinctQuestion<Partial<LearnAnswers>>> = [];
|
|
123
|
+
|
|
124
|
+
if (options.name === undefined) {
|
|
125
|
+
questions.push({
|
|
126
|
+
type: "input",
|
|
127
|
+
name: "name",
|
|
128
|
+
message: "Skill name",
|
|
129
|
+
default: defaults.name
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (options.description === undefined) {
|
|
133
|
+
questions.push({
|
|
134
|
+
type: "input",
|
|
135
|
+
name: "description",
|
|
136
|
+
message: "Description",
|
|
137
|
+
default: defaults.description
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (options.globs === undefined) {
|
|
141
|
+
questions.push({
|
|
142
|
+
type: "input",
|
|
143
|
+
name: "globs",
|
|
144
|
+
message: "Globs",
|
|
145
|
+
default: defaults.globs
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (options.alwaysApply === undefined) {
|
|
149
|
+
questions.push({
|
|
150
|
+
type: "confirm",
|
|
151
|
+
name: "alwaysApply",
|
|
152
|
+
message: "Always apply?",
|
|
153
|
+
default: defaults.alwaysApply
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (options.why === undefined) {
|
|
157
|
+
questions.push({
|
|
158
|
+
type: "input",
|
|
159
|
+
name: "why",
|
|
160
|
+
message: "Why",
|
|
161
|
+
default: defaults.why
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (options.example === undefined) {
|
|
165
|
+
questions.push({
|
|
166
|
+
type: "input",
|
|
167
|
+
name: "example",
|
|
168
|
+
message: "Example",
|
|
169
|
+
default: defaults.example
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const answers = questions.length > 0 ? await inquirer.prompt<Partial<LearnAnswers>>(questions) : {};
|
|
174
|
+
return { ...defaults, ...answers };
|
|
175
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { getIndexJsonPath, getSkillsDir } from "../utils/paths";
|
|
3
|
+
import { loadSkills, SkillIndexEntry } from "../utils/skills";
|
|
4
|
+
import { readManifest } from "../utils/manifest";
|
|
5
|
+
|
|
6
|
+
export async function listCommand(): Promise<void> {
|
|
7
|
+
const projectRoot = process.cwd();
|
|
8
|
+
const skillsDir = getSkillsDir(projectRoot);
|
|
9
|
+
const indexJsonPath = getIndexJsonPath(projectRoot);
|
|
10
|
+
|
|
11
|
+
if (!(await fs.pathExists(skillsDir))) {
|
|
12
|
+
console.log("No skills found. Run nymor init first.");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let entries: SkillIndexEntry[] = [];
|
|
17
|
+
|
|
18
|
+
if (await fs.pathExists(indexJsonPath)) {
|
|
19
|
+
const index = await fs.readJson(indexJsonPath);
|
|
20
|
+
entries = Array.isArray(index.skills) ? index.skills : [];
|
|
21
|
+
} else {
|
|
22
|
+
const skills = await loadSkills(skillsDir);
|
|
23
|
+
entries = skills.map((skill) => ({
|
|
24
|
+
id: skill.id,
|
|
25
|
+
name: skill.frontmatter.name,
|
|
26
|
+
description: skill.frontmatter.description ?? "",
|
|
27
|
+
globs: skill.frontmatter.globs ?? [],
|
|
28
|
+
alwaysApply: Boolean(skill.frontmatter.alwaysApply)
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`Nymor Skills (${entries.length})`);
|
|
33
|
+
console.log("");
|
|
34
|
+
|
|
35
|
+
if (entries.length === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const arrow = "\u2192";
|
|
40
|
+
const manifest = await readManifest(projectRoot);
|
|
41
|
+
const rows = entries.map((entry) => ({
|
|
42
|
+
entry,
|
|
43
|
+
source: manifest.local.includes(entry.id) ? "local" : "unknown"
|
|
44
|
+
}));
|
|
45
|
+
const skillWidth = Math.max("Skill".length, ...rows.map((row) => row.entry.id.length));
|
|
46
|
+
const sourceWidth = Math.max("Source".length, ...rows.map((row) => row.source.length));
|
|
47
|
+
|
|
48
|
+
console.log(` ${"Skill".padEnd(skillWidth)} ${"Source".padEnd(sourceWidth)} Description`);
|
|
49
|
+
console.log(` ${"-".repeat(skillWidth)} ${"-".repeat(sourceWidth)} -----------`);
|
|
50
|
+
|
|
51
|
+
rows.forEach(({ entry, source }) => {
|
|
52
|
+
const slug = entry.id.padEnd(skillWidth, " ");
|
|
53
|
+
const sourceColumn = source.padEnd(sourceWidth, " ");
|
|
54
|
+
const description = entry.description || entry.name || "(no description)";
|
|
55
|
+
console.log(` ${slug} ${sourceColumn} ${arrow} ${description}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import { getIndexJsonPath, getSkillsDir } from "../utils/paths";
|
|
4
|
+
import { listSkillDirectories, parseSkillContent } from "../utils/skills";
|
|
5
|
+
|
|
6
|
+
export async function validateCommand(): Promise<void> {
|
|
7
|
+
const projectRoot = process.cwd();
|
|
8
|
+
const skillsDir = getSkillsDir(projectRoot);
|
|
9
|
+
const indexJsonPath = getIndexJsonPath(projectRoot);
|
|
10
|
+
|
|
11
|
+
if (!(await fs.pathExists(skillsDir))) {
|
|
12
|
+
console.log("No skills found. Run nymor init first.");
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const indexExists = await fs.pathExists(indexJsonPath);
|
|
18
|
+
const indexEntries = indexExists ? await fs.readJson(indexJsonPath) : { skills: [] };
|
|
19
|
+
const indexIds = new Set(
|
|
20
|
+
Array.isArray(indexEntries.skills) ? indexEntries.skills.map((entry: { id: string }) => entry.id) : []
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const skillDirs = await listSkillDirectories(skillsDir);
|
|
24
|
+
|
|
25
|
+
console.log("Validating skills...\n");
|
|
26
|
+
|
|
27
|
+
let errorCount = 0;
|
|
28
|
+
const okMark = "\u2713";
|
|
29
|
+
const errMark = "\u2717";
|
|
30
|
+
|
|
31
|
+
for (const dirName of skillDirs) {
|
|
32
|
+
const skillPath = path.join(skillsDir, dirName, "SKILL.md");
|
|
33
|
+
const errors: string[] = [];
|
|
34
|
+
|
|
35
|
+
if (!(await fs.pathExists(skillPath))) {
|
|
36
|
+
errors.push("missing SKILL.md");
|
|
37
|
+
} else {
|
|
38
|
+
const content = await fs.readFile(skillPath, "utf8");
|
|
39
|
+
try {
|
|
40
|
+
const { frontmatter, body } = parseSkillContent(content, dirName);
|
|
41
|
+
|
|
42
|
+
if (!frontmatter.name) {
|
|
43
|
+
errors.push("missing frontmatter name");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!hasSection(body, "Rule")) {
|
|
47
|
+
errors.push("missing ## Rule section");
|
|
48
|
+
}
|
|
49
|
+
if (!hasSection(body, "Why")) {
|
|
50
|
+
errors.push("missing ## Why section");
|
|
51
|
+
}
|
|
52
|
+
if (!hasSection(body, "Example")) {
|
|
53
|
+
errors.push("missing ## Example section");
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
errors.push(message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (indexExists && !indexIds.has(dirName)) {
|
|
62
|
+
errors.push("not found in index.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (errors.length === 0) {
|
|
66
|
+
console.log(` ${okMark} ${dirName}`);
|
|
67
|
+
} else {
|
|
68
|
+
errorCount += 1;
|
|
69
|
+
console.log(` ${errMark} ${dirName} - ${errors.join("; ")}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!indexExists) {
|
|
74
|
+
errorCount += 1;
|
|
75
|
+
console.log("\nIndex not found. Run nymor compile to regenerate index.json.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (errorCount > 0) {
|
|
79
|
+
console.log(`\n${errorCount} issues found. Fix them or run nymor compile again.`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
} else {
|
|
82
|
+
console.log("\nAll skills look good.");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasSection(content: string, heading: string): boolean {
|
|
87
|
+
const regex = new RegExp(`^##\\s+${heading}\\b`, "m");
|
|
88
|
+
return regex.test(content);
|
|
89
|
+
}
|