@cruxy/cli 0.1.1 → 0.3.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/README.md +35 -1
- package/dist/cli/commands/index.d.ts +7 -0
- package/dist/cli/commands/index.js +59 -0
- package/dist/cli/commands/skills.d.ts +8 -0
- package/dist/cli/commands/skills.js +51 -0
- package/dist/cli/program.js +4 -0
- package/dist/config/schema.d.ts +199 -0
- package/dist/config/schema.js +55 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +13 -0
- package/dist/indexing/chunker.d.ts +28 -0
- package/dist/indexing/chunker.js +65 -0
- package/dist/indexing/embedder.d.ts +98 -0
- package/dist/indexing/embedder.js +140 -0
- package/dist/indexing/index.d.ts +9 -0
- package/dist/indexing/index.js +9 -0
- package/dist/indexing/indexer.d.ts +45 -0
- package/dist/indexing/indexer.js +104 -0
- package/dist/indexing/retriever.d.ts +32 -0
- package/dist/indexing/retriever.js +53 -0
- package/dist/indexing/service.d.ts +49 -0
- package/dist/indexing/service.js +132 -0
- package/dist/indexing/store.d.ts +103 -0
- package/dist/indexing/store.js +279 -0
- package/dist/indexing/types.d.ts +71 -0
- package/dist/indexing/types.js +6 -0
- package/dist/indexing/util.d.ts +34 -0
- package/dist/indexing/util.js +97 -0
- package/dist/indexing/walker.d.ts +42 -0
- package/dist/indexing/walker.js +166 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/loader.d.ts +42 -0
- package/dist/skills/loader.js +0 -0
- package/dist/skills/parser.d.ts +29 -0
- package/dist/skills/parser.js +90 -0
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +92 -0
- package/dist/skills/types.d.ts +94 -0
- package/dist/skills/types.js +21 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/list-skills.d.ts +9 -0
- package/dist/tools/list-skills.js +34 -0
- package/dist/tools/load-skill.d.ts +21 -0
- package/dist/tools/load-skill.js +49 -0
- package/dist/tools/registry.js +6 -0
- package/dist/tools/search-codebase.d.ts +25 -0
- package/dist/tools/search-codebase.js +70 -0
- package/package.json +6 -2
- package/skills/git-commit/SKILL.md +60 -0
- package/skills/using-skills/SKILL.md +62 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globToRegexBody, isBinary } from "./util.js";
|
|
4
|
+
/** Directories never descended into, regardless of ignore files. */
|
|
5
|
+
const ALWAYS_IGNORE_DIRS = new Set([".git", "node_modules", ".cruxy"]);
|
|
6
|
+
const DEFAULT_IGNORE_FILES = [".gitignore", ".cruxyignore"];
|
|
7
|
+
/**
|
|
8
|
+
* Hard denylist for secret-bearing files. Applied independently of ignore files
|
|
9
|
+
* (and of negation rules), so secrets are never indexed even when untracked.
|
|
10
|
+
* Matched against the project-relative POSIX path.
|
|
11
|
+
*/
|
|
12
|
+
const SECRET_PATTERNS = [
|
|
13
|
+
/(^|\/)\.env($|\.|rc)/i, // .env, .env.local, .env.production, .envrc
|
|
14
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/, // ssh private keys
|
|
15
|
+
/\.(pem|key|pfx|p12|p8|keystore|jks|asc|gpg)$/i, // keys / certs / keystores
|
|
16
|
+
/(^|\/)\.(npmrc|netrc|pgpass)$/i, // credential dotfiles
|
|
17
|
+
/(^|\/)\.aws\/credentials$/i,
|
|
18
|
+
];
|
|
19
|
+
function isSecretPath(relPath) {
|
|
20
|
+
return SECRET_PATTERNS.some((re) => re.test(relPath));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A compiled set of gitignore-style patterns, matched against paths relative to
|
|
24
|
+
* the file's own directory. Supports comments, blank lines, `!` negation,
|
|
25
|
+
* leading-`/` anchoring, trailing-`/` directory-only rules, and `*`/`**`/`?`.
|
|
26
|
+
*/
|
|
27
|
+
export class IgnoreMatcher {
|
|
28
|
+
rules = [];
|
|
29
|
+
constructor(patterns) {
|
|
30
|
+
for (const line of patterns)
|
|
31
|
+
this.add(line);
|
|
32
|
+
}
|
|
33
|
+
add(raw) {
|
|
34
|
+
// Strip a trailing CR and unescaped trailing whitespace.
|
|
35
|
+
let line = raw.replace(/\r$/, "").replace(/(?<!\\)\s+$/, "");
|
|
36
|
+
if (line === "" || line.startsWith("#"))
|
|
37
|
+
return;
|
|
38
|
+
let negate = false;
|
|
39
|
+
if (line.startsWith("!")) {
|
|
40
|
+
negate = true;
|
|
41
|
+
line = line.slice(1);
|
|
42
|
+
}
|
|
43
|
+
// Unescape a leading "\#" / "\!".
|
|
44
|
+
line = line.replace(/^\\([#!])/, "$1");
|
|
45
|
+
let dirOnly = false;
|
|
46
|
+
if (line.endsWith("/")) {
|
|
47
|
+
dirOnly = true;
|
|
48
|
+
line = line.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
const anchored = line.startsWith("/");
|
|
51
|
+
if (anchored)
|
|
52
|
+
line = line.slice(1);
|
|
53
|
+
// A pattern with an internal separator is anchored to this directory;
|
|
54
|
+
// otherwise it may match at any depth.
|
|
55
|
+
const hasInternalSlash = line.includes("/");
|
|
56
|
+
const prefix = anchored || hasInternalSlash ? "" : "(?:.*/)?";
|
|
57
|
+
// Trailing "(?:/.*)?" lets a directory match also cover its contents.
|
|
58
|
+
const re = new RegExp(`^${prefix}${globToRegexBody(line)}(?:/.*)?$`);
|
|
59
|
+
this.rules.push({ re, negate, dirOnly });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Decide whether `relPath` is ignored: `true`/`false` when a rule matches
|
|
63
|
+
* (last match wins), or `undefined` when no rule applies.
|
|
64
|
+
*/
|
|
65
|
+
decide(relPath, isDir) {
|
|
66
|
+
let decision;
|
|
67
|
+
for (const rule of this.rules) {
|
|
68
|
+
if (rule.dirOnly && !isDir)
|
|
69
|
+
continue;
|
|
70
|
+
if (rule.re.test(relPath))
|
|
71
|
+
decision = !rule.negate;
|
|
72
|
+
}
|
|
73
|
+
return decision;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the ignore decision for a path against the full stack (shallow→deep);
|
|
78
|
+
* the deepest scope that makes a decision wins, mirroring git's precedence.
|
|
79
|
+
*/
|
|
80
|
+
function isIgnored(stack, absPath, isDir) {
|
|
81
|
+
let ignored = false;
|
|
82
|
+
for (const { baseDir, matcher } of stack) {
|
|
83
|
+
const rel = path.relative(baseDir, absPath).split(path.sep).join("/");
|
|
84
|
+
const decision = matcher.decide(rel, isDir);
|
|
85
|
+
if (decision !== undefined)
|
|
86
|
+
ignored = decision;
|
|
87
|
+
}
|
|
88
|
+
return ignored;
|
|
89
|
+
}
|
|
90
|
+
async function loadIgnoreScope(dir, ignoreFileNames) {
|
|
91
|
+
const patterns = [];
|
|
92
|
+
for (const name of ignoreFileNames) {
|
|
93
|
+
try {
|
|
94
|
+
const text = await fs.readFile(path.join(dir, name), "utf8");
|
|
95
|
+
patterns.push(...text.split("\n"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* no such ignore file in this directory */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return patterns.length > 0
|
|
102
|
+
? { baseDir: dir, matcher: new IgnoreMatcher(patterns) }
|
|
103
|
+
: null;
|
|
104
|
+
}
|
|
105
|
+
async function isBinaryFile(absPath) {
|
|
106
|
+
const fh = await fs.open(absPath, "r");
|
|
107
|
+
try {
|
|
108
|
+
const buf = Buffer.alloc(4096);
|
|
109
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
110
|
+
return isBinary(buf.subarray(0, bytesRead));
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await fh.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Walk `root` and yield every indexable text file. Directories in the
|
|
118
|
+
* always-ignore set are skipped wholesale; ignore files accumulate down the
|
|
119
|
+
* tree; secret-bearing, oversized, and binary files are filtered out.
|
|
120
|
+
*/
|
|
121
|
+
export async function* walkRepo(root, opts) {
|
|
122
|
+
const absRoot = path.resolve(root);
|
|
123
|
+
const ignoreFileNames = opts.ignoreFileNames ?? DEFAULT_IGNORE_FILES;
|
|
124
|
+
yield* walkDir(absRoot, absRoot, [], ignoreFileNames, opts.maxFileBytes);
|
|
125
|
+
}
|
|
126
|
+
async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
|
|
127
|
+
const scope = await loadIgnoreScope(dir, ignoreFileNames);
|
|
128
|
+
const stack = scope ? [...parentStack, scope] : parentStack;
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return; // unreadable directory — skip
|
|
135
|
+
}
|
|
136
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const absPath = path.join(dir, entry.name);
|
|
139
|
+
const relPath = path.relative(root, absPath).split(path.sep).join("/");
|
|
140
|
+
const isDir = entry.isDirectory();
|
|
141
|
+
if (isDir && ALWAYS_IGNORE_DIRS.has(entry.name))
|
|
142
|
+
continue;
|
|
143
|
+
if (!isDir && !entry.isFile())
|
|
144
|
+
continue; // symlinks, sockets, fifos, etc.
|
|
145
|
+
if (isSecretPath(relPath))
|
|
146
|
+
continue;
|
|
147
|
+
if (isIgnored(stack, absPath, isDir))
|
|
148
|
+
continue;
|
|
149
|
+
if (isDir) {
|
|
150
|
+
yield* walkDir(absPath, root, stack, ignoreFileNames, maxFileBytes);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let size;
|
|
154
|
+
try {
|
|
155
|
+
size = (await fs.stat(absPath)).size;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (size > maxFileBytes)
|
|
161
|
+
continue;
|
|
162
|
+
if (await isBinaryFile(absPath))
|
|
163
|
+
continue;
|
|
164
|
+
yield { relPath, absPath, size };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Skill, type SkillCatalog, type SkillError, type SkillSource } from "./types.js";
|
|
2
|
+
/** The three source directories the loader scans. */
|
|
3
|
+
export interface LoaderSources {
|
|
4
|
+
/** `<cwd>/.cruxy/skills` */
|
|
5
|
+
project: string;
|
|
6
|
+
/** `~/.cruxy/skills` */
|
|
7
|
+
user: string;
|
|
8
|
+
/** `<pkg>/skills` (shipped). */
|
|
9
|
+
builtin: string;
|
|
10
|
+
}
|
|
11
|
+
/** A valid skill discovered in a source, before precedence is resolved. */
|
|
12
|
+
interface SkillCandidate {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
source: SkillSource;
|
|
16
|
+
dir: string;
|
|
17
|
+
}
|
|
18
|
+
/** Thrown by `getSkill` when no catalog entry matches the requested name. */
|
|
19
|
+
export declare class SkillNotFoundError extends Error {
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Scan all three sources, validate every SKILL.md, and resolve precedence into a
|
|
24
|
+
* {@link SkillCatalog}. Validation failures become {@link SkillError}s (excluded
|
|
25
|
+
* from the catalog, surfaced in `cruxy skills --status`) rather than throwing —
|
|
26
|
+
* one bad skill never breaks the rest.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadCatalog(sources: LoaderSources): Promise<SkillCatalog>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve candidates into a catalog. Pure (no I/O) so precedence and collision
|
|
31
|
+
* rules are directly testable. `candidates` must arrive in precedence order
|
|
32
|
+
* (project first); the first occurrence of a name wins across sources, while a
|
|
33
|
+
* second occurrence *within the same source* is a loud, excluded error.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolvePrecedence(candidates: SkillCandidate[], baseErrors?: SkillError[]): SkillCatalog;
|
|
36
|
+
/**
|
|
37
|
+
* Read a skill's full body and resolve its asset paths on demand. The body is
|
|
38
|
+
* re-read from disk every call (never cached), so edits are always live. Throws
|
|
39
|
+
* {@link SkillNotFoundError} if `name` isn't in the catalog.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getSkill(catalog: SkillCatalog, name: string): Promise<Skill>;
|
|
42
|
+
export {};
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type SkillFrontmatter } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when a SKILL.md is malformed or fails validation. The message is
|
|
4
|
+
* actionable (it names the problem) and is caught by the loader, which records
|
|
5
|
+
* it as a `SkillError` and excludes the skill — loud, never silently skipped.
|
|
6
|
+
*/
|
|
7
|
+
export declare class SkillValidationError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
/** The validated frontmatter plus the markdown body that followed it. */
|
|
11
|
+
export interface ParsedSkill {
|
|
12
|
+
frontmatter: SkillFrontmatter;
|
|
13
|
+
body: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse and validate a SKILL.md.
|
|
17
|
+
*
|
|
18
|
+
* The frontmatter is a deliberately small, strict subset of YAML — `key: value`
|
|
19
|
+
* scalars, optional surrounding quotes, `#` comment lines — which is all the
|
|
20
|
+
* two-field schema needs. Anything outside that subset (block scalars, nested
|
|
21
|
+
* maps, a line without a colon, a missing/unterminated block) is a loud error
|
|
22
|
+
* rather than a best-effort guess, so a skill never loads with a
|
|
23
|
+
* silently-misread description.
|
|
24
|
+
*
|
|
25
|
+
* @param text the raw file contents
|
|
26
|
+
* @param dirName the skill directory's basename; `name` must equal it
|
|
27
|
+
* @throws {SkillValidationError} on any malformed or invalid input
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseSkill(text: string, dirName: string): ParsedSkill;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { SkillFrontmatterSchema } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when a SKILL.md is malformed or fails validation. The message is
|
|
4
|
+
* actionable (it names the problem) and is caught by the loader, which records
|
|
5
|
+
* it as a `SkillError` and excludes the skill — loud, never silently skipped.
|
|
6
|
+
*/
|
|
7
|
+
export class SkillValidationError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "SkillValidationError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Opening `---` line, frontmatter content (non-greedy), closing `---` line, body.
|
|
14
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
15
|
+
/**
|
|
16
|
+
* Parse and validate a SKILL.md.
|
|
17
|
+
*
|
|
18
|
+
* The frontmatter is a deliberately small, strict subset of YAML — `key: value`
|
|
19
|
+
* scalars, optional surrounding quotes, `#` comment lines — which is all the
|
|
20
|
+
* two-field schema needs. Anything outside that subset (block scalars, nested
|
|
21
|
+
* maps, a line without a colon, a missing/unterminated block) is a loud error
|
|
22
|
+
* rather than a best-effort guess, so a skill never loads with a
|
|
23
|
+
* silently-misread description.
|
|
24
|
+
*
|
|
25
|
+
* @param text the raw file contents
|
|
26
|
+
* @param dirName the skill directory's basename; `name` must equal it
|
|
27
|
+
* @throws {SkillValidationError} on any malformed or invalid input
|
|
28
|
+
*/
|
|
29
|
+
export function parseSkill(text, dirName) {
|
|
30
|
+
if (!/^---[ \t]*\r?\n/.test(text)) {
|
|
31
|
+
throw new SkillValidationError("SKILL.md must begin with a YAML frontmatter block delimited by '---'");
|
|
32
|
+
}
|
|
33
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
34
|
+
if (!match) {
|
|
35
|
+
throw new SkillValidationError("frontmatter block is not terminated by a closing '---' line");
|
|
36
|
+
}
|
|
37
|
+
const raw = parseFrontmatterBlock(match[1]);
|
|
38
|
+
const body = match[2] ?? "";
|
|
39
|
+
const result = SkillFrontmatterSchema.safeParse(raw);
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
throw new SkillValidationError(`invalid frontmatter: ${formatZodError(result.error)}`);
|
|
42
|
+
}
|
|
43
|
+
const frontmatter = result.data;
|
|
44
|
+
if (frontmatter.name !== dirName) {
|
|
45
|
+
throw new SkillValidationError(`frontmatter name "${frontmatter.name}" must match the skill directory name "${dirName}"`);
|
|
46
|
+
}
|
|
47
|
+
return { frontmatter, body };
|
|
48
|
+
}
|
|
49
|
+
/** Parse the frontmatter block into a flat string map, strictly. */
|
|
50
|
+
function parseFrontmatterBlock(block) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const rawLine of block.split(/\r?\n/)) {
|
|
53
|
+
const line = rawLine.trim();
|
|
54
|
+
if (line === "" || line.startsWith("#"))
|
|
55
|
+
continue;
|
|
56
|
+
const colon = line.indexOf(":");
|
|
57
|
+
if (colon === -1) {
|
|
58
|
+
throw new SkillValidationError(`invalid frontmatter line (expected "key: value"): ${JSON.stringify(rawLine)}`);
|
|
59
|
+
}
|
|
60
|
+
const key = line.slice(0, colon).trim();
|
|
61
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) {
|
|
62
|
+
throw new SkillValidationError(`invalid frontmatter key: ${JSON.stringify(key)}`);
|
|
63
|
+
}
|
|
64
|
+
if (key in out) {
|
|
65
|
+
throw new SkillValidationError(`duplicate frontmatter key: ${JSON.stringify(key)}`);
|
|
66
|
+
}
|
|
67
|
+
out[key] = stripQuotes(line.slice(colon + 1).trim());
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Strip a single pair of matching surrounding quotes, if present. */
|
|
72
|
+
function stripQuotes(value) {
|
|
73
|
+
if (value.length >= 2) {
|
|
74
|
+
const first = value[0];
|
|
75
|
+
const last = value[value.length - 1];
|
|
76
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
77
|
+
return value.slice(1, -1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
/** Render a zod error to a compact, single-line reason. */
|
|
83
|
+
function formatZodError(error) {
|
|
84
|
+
return error.issues
|
|
85
|
+
.map((issue) => {
|
|
86
|
+
const path = issue.path.join(".");
|
|
87
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
88
|
+
})
|
|
89
|
+
.join("; ");
|
|
90
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type LoaderSources } from "./loader.js";
|
|
2
|
+
import { type Skill, type SkillCatalogEntry, type SkillStatus } from "./types.js";
|
|
3
|
+
/** Logger surface the service reports through. */
|
|
4
|
+
interface SkillServiceLogger {
|
|
5
|
+
debug(message: string): void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A ready-to-use skills catalog for one project root. It resolves the three
|
|
9
|
+
* sources, caches the catalog, and refreshes lazily when a source directory's
|
|
10
|
+
* mtime changes (i.e. a skill was added or removed). Bodies are never cached —
|
|
11
|
+
* {@link SkillService.load} re-reads them on demand, so body edits are always
|
|
12
|
+
* live. Build one with {@link getSkillService}, which caches per cwd.
|
|
13
|
+
*/
|
|
14
|
+
export interface SkillService {
|
|
15
|
+
/** Catalog entries (name + description + source), refreshed if stale. */
|
|
16
|
+
list(): Promise<SkillCatalogEntry[]>;
|
|
17
|
+
/** Load one skill's body + resolved asset paths. Throws if unknown. */
|
|
18
|
+
load(name: string): Promise<Skill>;
|
|
19
|
+
/** Snapshot for `cruxy skills --status`: entries, errors, and source dirs. */
|
|
20
|
+
status(): Promise<SkillStatus>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Explicit dependency overrides for tests — the only way to point the loader at
|
|
24
|
+
* fixture directories. Production callers never pass this, so they always use
|
|
25
|
+
* the real project/user/builtin sources.
|
|
26
|
+
*/
|
|
27
|
+
export interface SkillServiceDeps {
|
|
28
|
+
sources?: Partial<LoaderSources>;
|
|
29
|
+
}
|
|
30
|
+
/** The default source directories for a project root. */
|
|
31
|
+
export declare function defaultSources(cwd: string): LoaderSources;
|
|
32
|
+
/**
|
|
33
|
+
* Get (or build) the {@link SkillService} for a project root, cached per
|
|
34
|
+
* resolved cwd. Construction is cheap (just path resolution); the catalog is
|
|
35
|
+
* built lazily on first use and refreshed on source-mtime change. `deps` is for
|
|
36
|
+
* explicit test injection only (see {@link SkillServiceDeps}).
|
|
37
|
+
*/
|
|
38
|
+
export declare function getSkillService(cwd: string, logger: SkillServiceLogger, deps?: SkillServiceDeps): SkillService;
|
|
39
|
+
/** Drop all cached services. For tests and process teardown. */
|
|
40
|
+
export declare function resetSkillServices(): void;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalDir } from "../config/index.js";
|
|
4
|
+
import { BUILTIN_SKILLS_DIR, GLOBAL_DIR_NAME, SKILLS_DIR_NAME, } from "../constants.js";
|
|
5
|
+
import { getSkill, loadCatalog } from "./loader.js";
|
|
6
|
+
import { SOURCE_PRECEDENCE, } from "./types.js";
|
|
7
|
+
/** The default source directories for a project root. */
|
|
8
|
+
export function defaultSources(cwd) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
return {
|
|
11
|
+
project: path.join(root, GLOBAL_DIR_NAME, SKILLS_DIR_NAME),
|
|
12
|
+
user: path.join(globalDir(), SKILLS_DIR_NAME),
|
|
13
|
+
builtin: BUILTIN_SKILLS_DIR,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
class SkillServiceImpl {
|
|
17
|
+
sources;
|
|
18
|
+
logger;
|
|
19
|
+
catalog = null;
|
|
20
|
+
signature = "";
|
|
21
|
+
constructor(sources, logger) {
|
|
22
|
+
this.sources = sources;
|
|
23
|
+
this.logger = logger;
|
|
24
|
+
}
|
|
25
|
+
async ensureFresh() {
|
|
26
|
+
const signature = await computeSignature(this.sources);
|
|
27
|
+
if (this.catalog === null || signature !== this.signature) {
|
|
28
|
+
this.logger.debug("(re)building skill catalog");
|
|
29
|
+
this.catalog = await loadCatalog(this.sources);
|
|
30
|
+
this.signature = signature;
|
|
31
|
+
}
|
|
32
|
+
return this.catalog;
|
|
33
|
+
}
|
|
34
|
+
async list() {
|
|
35
|
+
return (await this.ensureFresh()).entries;
|
|
36
|
+
}
|
|
37
|
+
async load(name) {
|
|
38
|
+
return getSkill(await this.ensureFresh(), name);
|
|
39
|
+
}
|
|
40
|
+
async status() {
|
|
41
|
+
const catalog = await this.ensureFresh();
|
|
42
|
+
return {
|
|
43
|
+
entries: catalog.entries,
|
|
44
|
+
errors: catalog.errors,
|
|
45
|
+
sources: SOURCE_PRECEDENCE.map((source) => ({
|
|
46
|
+
source,
|
|
47
|
+
dir: this.sources[source],
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ── per-cwd cache ─────────────────────────────────────────────────────────────
|
|
53
|
+
const cache = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* Get (or build) the {@link SkillService} for a project root, cached per
|
|
56
|
+
* resolved cwd. Construction is cheap (just path resolution); the catalog is
|
|
57
|
+
* built lazily on first use and refreshed on source-mtime change. `deps` is for
|
|
58
|
+
* explicit test injection only (see {@link SkillServiceDeps}).
|
|
59
|
+
*/
|
|
60
|
+
export function getSkillService(cwd, logger, deps) {
|
|
61
|
+
const root = path.resolve(cwd);
|
|
62
|
+
let service = cache.get(root);
|
|
63
|
+
if (!service) {
|
|
64
|
+
const sources = { ...defaultSources(root), ...(deps?.sources ?? {}) };
|
|
65
|
+
service = new SkillServiceImpl(sources, logger);
|
|
66
|
+
cache.set(root, service);
|
|
67
|
+
}
|
|
68
|
+
return service;
|
|
69
|
+
}
|
|
70
|
+
/** Drop all cached services. For tests and process teardown. */
|
|
71
|
+
export function resetSkillServices() {
|
|
72
|
+
cache.clear();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A cheap change signature: the mtime of each source directory. A directory's
|
|
76
|
+
* mtime changes when an entry is added or removed, so this catches new/deleted
|
|
77
|
+
* skills. (Editing a body in place doesn't change it — but `load` always
|
|
78
|
+
* re-reads bodies fresh, so only the cached catalog's name/description can lag.)
|
|
79
|
+
*/
|
|
80
|
+
async function computeSignature(sources) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
for (const source of SOURCE_PRECEDENCE) {
|
|
83
|
+
try {
|
|
84
|
+
const stat = await fs.stat(sources[source]);
|
|
85
|
+
parts.push(`${source}:${stat.mtimeMs}`);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
parts.push(`${source}:absent`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return parts.join("|");
|
|
92
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Shared types for the skills system (C.18). A *skill* is a directory holding a
|
|
4
|
+
* `SKILL.md` — YAML frontmatter (name + description) plus a markdown
|
|
5
|
+
* instructions body — and optional `scripts/` and `reference/` asset subdirs.
|
|
6
|
+
*
|
|
7
|
+
* Progressive disclosure is the whole point: only `name` + `description` enter
|
|
8
|
+
* the agent's context by default (the catalog); the full body is read on demand
|
|
9
|
+
* by `load_skill`. Bodies are never held in the cached catalog.
|
|
10
|
+
*/
|
|
11
|
+
/** Where a skill was discovered, highest precedence first. */
|
|
12
|
+
export type SkillSource = "project" | "user" | "builtin";
|
|
13
|
+
/** Precedence order: project overrides user overrides builtin. */
|
|
14
|
+
export declare const SOURCE_PRECEDENCE: readonly SkillSource[];
|
|
15
|
+
/**
|
|
16
|
+
* Strict schema for SKILL.md frontmatter. `.strict()` rejects unknown keys, so a
|
|
17
|
+
* typo'd field is a loud error rather than silently ignored. `name` is
|
|
18
|
+
* kebab-case and must equal the skill's directory name (enforced by the parser).
|
|
19
|
+
*/
|
|
20
|
+
export declare const SkillFrontmatterSchema: z.ZodObject<{
|
|
21
|
+
name: z.ZodString;
|
|
22
|
+
description: z.ZodString;
|
|
23
|
+
}, "strict", z.ZodTypeAny, {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
}, {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
}>;
|
|
30
|
+
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
|
|
31
|
+
/**
|
|
32
|
+
* The cheap catalog entry the agent sees by default — name, one-line
|
|
33
|
+
* description, and which source won. No body.
|
|
34
|
+
*/
|
|
35
|
+
export interface SkillCatalogEntry {
|
|
36
|
+
name: string;
|
|
37
|
+
description: string;
|
|
38
|
+
source: SkillSource;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A fully-loaded skill, returned by `load_skill`/`getSkill`. Carries the
|
|
42
|
+
* markdown body plus resolved, in-bounds asset paths.
|
|
43
|
+
*/
|
|
44
|
+
export interface Skill {
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
source: SkillSource;
|
|
48
|
+
/** Markdown instructions (everything after the frontmatter block). */
|
|
49
|
+
body: string;
|
|
50
|
+
/** Absolute path of the skill directory — the root for its assets. */
|
|
51
|
+
assetRoot: string;
|
|
52
|
+
/** Project-relative-to-assetRoot POSIX paths of files under `scripts/`. */
|
|
53
|
+
scripts: string[];
|
|
54
|
+
/** Project-relative-to-assetRoot POSIX paths of files under `reference/`. */
|
|
55
|
+
references: string[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A validation failure for one candidate skill. Collected (never thrown past the
|
|
59
|
+
* loader) so one bad skill can't break the catalog; surfaced loudly in
|
|
60
|
+
* `cruxy skills --status` and excluded from the catalog.
|
|
61
|
+
*/
|
|
62
|
+
export interface SkillError {
|
|
63
|
+
source: SkillSource;
|
|
64
|
+
/** Absolute path of the offending skill directory. */
|
|
65
|
+
dir: string;
|
|
66
|
+
/** The directory's basename (the would-be skill name), for display. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Actionable, human-readable reason. */
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The resolved catalog: valid entries (deduped by precedence), the validation
|
|
73
|
+
* errors that were excluded, and a name→location index used by `getSkill` to
|
|
74
|
+
* read a body on demand. Bodies are intentionally absent.
|
|
75
|
+
*/
|
|
76
|
+
export interface SkillCatalog {
|
|
77
|
+
entries: SkillCatalogEntry[];
|
|
78
|
+
errors: SkillError[];
|
|
79
|
+
/** name → winning skill's directory + source (for on-demand body reads). */
|
|
80
|
+
byName: Map<string, {
|
|
81
|
+
dir: string;
|
|
82
|
+
source: SkillSource;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
/** Snapshot for `cruxy skills --status`. */
|
|
86
|
+
export interface SkillStatus {
|
|
87
|
+
entries: SkillCatalogEntry[];
|
|
88
|
+
errors: SkillError[];
|
|
89
|
+
/** The three source directories, in precedence order. */
|
|
90
|
+
sources: {
|
|
91
|
+
source: SkillSource;
|
|
92
|
+
dir: string;
|
|
93
|
+
}[];
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Precedence order: project overrides user overrides builtin. */
|
|
3
|
+
export const SOURCE_PRECEDENCE = [
|
|
4
|
+
"project",
|
|
5
|
+
"user",
|
|
6
|
+
"builtin",
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Strict schema for SKILL.md frontmatter. `.strict()` rejects unknown keys, so a
|
|
10
|
+
* typo'd field is a loud error rather than silently ignored. `name` is
|
|
11
|
+
* kebab-case and must equal the skill's directory name (enforced by the parser).
|
|
12
|
+
*/
|
|
13
|
+
export const SkillFrontmatterSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
name: z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, "must be kebab-case: lowercase letters, digits, and hyphens, starting with a letter or digit"),
|
|
19
|
+
description: z.string().min(1),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -2,4 +2,7 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./registry.js";
|
|
3
3
|
export * from "./list-files.js";
|
|
4
4
|
export * from "./git-status.js";
|
|
5
|
+
export * from "./search-codebase.js";
|
|
6
|
+
export * from "./list-skills.js";
|
|
7
|
+
export * from "./load-skill.js";
|
|
5
8
|
export * from "./file/index.js";
|
package/dist/tools/index.js
CHANGED
|
@@ -2,4 +2,7 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./registry.js";
|
|
3
3
|
export * from "./list-files.js";
|
|
4
4
|
export * from "./git-status.js";
|
|
5
|
+
export * from "./search-codebase.js";
|
|
6
|
+
export * from "./list-skills.js";
|
|
7
|
+
export * from "./load-skill.js";
|
|
5
8
|
export * from "./file/index.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* List the available skills (name + one-line description + source). Read-only —
|
|
5
|
+
* no approval — like the other discovery tools. This is the cheap half of
|
|
6
|
+
* progressive disclosure: only the catalog enters context here; the agent calls
|
|
7
|
+
* `load_skill` to pull a skill's full instructions when a task matches.
|
|
8
|
+
*/
|
|
9
|
+
export declare const listSkillsTool: Tool<z.ZodObject<Record<string, never>>>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getSkillService } from "../skills/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* List the available skills (name + one-line description + source). Read-only —
|
|
5
|
+
* no approval — like the other discovery tools. This is the cheap half of
|
|
6
|
+
* progressive disclosure: only the catalog enters context here; the agent calls
|
|
7
|
+
* `load_skill` to pull a skill's full instructions when a task matches.
|
|
8
|
+
*/
|
|
9
|
+
export const listSkillsTool = {
|
|
10
|
+
name: "list_skills",
|
|
11
|
+
description: "List the specialized skills available for this project (name, description, and source: project/user/builtin). Call this when a task might match a skill; then call load_skill to load the one that fits. Read-only and requires no approval.",
|
|
12
|
+
parameters: z.object({}),
|
|
13
|
+
async execute(_input, ctx) {
|
|
14
|
+
try {
|
|
15
|
+
const entries = await getSkillService(ctx.cwd, ctx.logger).list();
|
|
16
|
+
if (entries.length === 0) {
|
|
17
|
+
return {
|
|
18
|
+
ok: true,
|
|
19
|
+
output: "(no skills available — add one under .cruxy/skills/ or ~/.cruxy/skills/)",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, output: formatCatalog(entries) };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return { ok: false, error: err.message };
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/** Render the catalog as a compact, model-readable list. */
|
|
30
|
+
function formatCatalog(entries) {
|
|
31
|
+
return entries
|
|
32
|
+
.map((e) => `- ${e.name} [${e.source}] — ${e.description}`)
|
|
33
|
+
.join("\n");
|
|
34
|
+
}
|