@cruxy/cli 0.2.0 → 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 +18 -1
- package/dist/cli/commands/skills.d.ts +8 -0
- package/dist/cli/commands/skills.js +51 -0
- package/dist/cli/program.js +2 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +13 -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 +2 -0
- package/dist/tools/index.js +2 -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 +4 -0
- package/package.json +3 -2
- package/skills/git-commit/SKILL.md +60 -0
- package/skills/using-skills/SKILL.md +62 -0
package/README.md
CHANGED
|
@@ -25,12 +25,18 @@ cruxy run # interactive session
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
27
27
|
- **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
|
|
28
|
-
`grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase
|
|
28
|
+
`grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
|
|
29
|
+
`list_skills`, `load_skill`.
|
|
29
30
|
- **Codebase index** — a local, incremental semantic index (`cruxy index`)
|
|
30
31
|
behind the `search_codebase` tool. Embeddings run on-device via fastembed
|
|
31
32
|
(ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
|
|
32
33
|
`.cruxy/index.db`. Only changed files are re-embedded; `.gitignore` /
|
|
33
34
|
`.cruxyignore` are respected and secrets (`.env*`, keys) are never indexed.
|
|
35
|
+
- **Skills** — reusable, task-specific instructions in a `SKILL.md`
|
|
36
|
+
(frontmatter + markdown), discovered via `list_skills` and pulled on demand
|
|
37
|
+
with `load_skill` (progressive disclosure: only name + description are in
|
|
38
|
+
context until a skill is loaded). Layered project > user > builtin, strictly
|
|
39
|
+
validated, and never auto-executed.
|
|
34
40
|
- **Agent** — streaming output, multi-turn interactive sessions, context
|
|
35
41
|
compaction, and awareness of git state and project instructions (`CRUXY.md`).
|
|
36
42
|
- **Safety** — a single approval gate with diff previews that fails closed;
|
|
@@ -53,6 +59,17 @@ The index refreshes itself lazily the first time the agent calls
|
|
|
53
59
|
`search_codebase`, so it works even without running `cruxy index` first. The
|
|
54
60
|
bge-small embedding model (~130 MB) downloads and caches on first use.
|
|
55
61
|
|
|
62
|
+
### Skills
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cruxy skills # list the resolved skill catalog
|
|
66
|
+
cruxy skills --status # show source directories and validation errors
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
|
|
70
|
+
`~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
|
|
71
|
+
built-in `using-skills` skill for the authoring rules.
|
|
72
|
+
|
|
56
73
|
The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
|
|
57
74
|
provider-agnostic, built over `fetch`, with no vendor SDKs.
|
|
58
75
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy skills` — list the skills available to the agent (the same catalog the
|
|
4
|
+
* `list_skills` tool sees). `--status` additionally shows the three source
|
|
5
|
+
* directories in precedence order and any validation errors (skills that were
|
|
6
|
+
* excluded for being malformed).
|
|
7
|
+
*/
|
|
8
|
+
export declare function skillsCommand(): Command;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { getSkillService, resetSkillServices } from "../../skills/index.js";
|
|
4
|
+
import { logger } from "../../utils/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* `cruxy skills` — list the skills available to the agent (the same catalog the
|
|
7
|
+
* `list_skills` tool sees). `--status` additionally shows the three source
|
|
8
|
+
* directories in precedence order and any validation errors (skills that were
|
|
9
|
+
* excluded for being malformed).
|
|
10
|
+
*/
|
|
11
|
+
export function skillsCommand() {
|
|
12
|
+
return new Command("skills")
|
|
13
|
+
.description("list the skills available to the agent")
|
|
14
|
+
.option("--status", "show source directories and validation errors")
|
|
15
|
+
.action(async (opts) => {
|
|
16
|
+
const service = getSkillService(process.cwd(), logger);
|
|
17
|
+
const status = await service.status();
|
|
18
|
+
if (status.entries.length === 0) {
|
|
19
|
+
logger.print(pc.dim("no skills found"));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
for (const entry of status.entries) {
|
|
23
|
+
logger.print(`${pc.bold(entry.name)} ${pc.dim(`[${entry.source}]`)}`);
|
|
24
|
+
logger.print(` ${entry.description}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (!opts.status) {
|
|
28
|
+
if (status.errors.length > 0) {
|
|
29
|
+
logger.print(pc.yellow(`\n${status.errors.length} skill(s) failed validation — run ${pc.bold("cruxy skills --status")} for details`));
|
|
30
|
+
}
|
|
31
|
+
resetSkillServices();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
logger.print(`\n${pc.bold("sources")} ${pc.dim("(precedence high → low):")}`);
|
|
35
|
+
for (const { source, dir } of status.sources) {
|
|
36
|
+
logger.print(` ${source.padEnd(8)} ${pc.dim(dir)}`);
|
|
37
|
+
}
|
|
38
|
+
logger.print("");
|
|
39
|
+
if (status.errors.length === 0) {
|
|
40
|
+
logger.print(pc.green("no validation errors"));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
logger.print(pc.yellow(`validation errors (${status.errors.length}):`));
|
|
44
|
+
for (const err of status.errors) {
|
|
45
|
+
logger.print(` ${pc.red("✗")} ${pc.bold(err.name)} ${pc.dim(`[${err.source}]`)}: ${err.message}`);
|
|
46
|
+
logger.print(` ${pc.dim(err.dir)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
resetSkillServices();
|
|
50
|
+
});
|
|
51
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -5,6 +5,7 @@ import { logger } from "../utils/logger.js";
|
|
|
5
5
|
import { runCommand } from "./commands/run.js";
|
|
6
6
|
import { configCommand } from "./commands/config.js";
|
|
7
7
|
import { indexCommand } from "./commands/index.js";
|
|
8
|
+
import { skillsCommand } from "./commands/skills.js";
|
|
8
9
|
export function buildProgram() {
|
|
9
10
|
const program = new Command();
|
|
10
11
|
program
|
|
@@ -26,6 +27,7 @@ export function buildProgram() {
|
|
|
26
27
|
program.addCommand(runCommand());
|
|
27
28
|
program.addCommand(configCommand());
|
|
28
29
|
program.addCommand(indexCommand());
|
|
30
|
+
program.addCommand(skillsCommand());
|
|
29
31
|
// Default action: no subcommand -> interactive entrypoint (stub for now).
|
|
30
32
|
program.action(() => {
|
|
31
33
|
logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
|
package/dist/constants.d.ts
CHANGED
|
@@ -9,3 +9,16 @@ export declare const CONFIG_FILE_NAME = "config.json";
|
|
|
9
9
|
export declare const PROJECT_CONFIG_FILENAMES: string[];
|
|
10
10
|
/** Project-instruction filenames, checked in order (first match wins). */
|
|
11
11
|
export declare const PROJECT_INSTRUCTION_FILENAMES: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Name of the skills subdirectory, used under the project dir
|
|
14
|
+
* (`<cwd>/.cruxy/skills`), the global dir (`~/.cruxy/skills`), and the package
|
|
15
|
+
* root for shipped builtins.
|
|
16
|
+
*/
|
|
17
|
+
export declare const SKILLS_DIR_NAME = "skills";
|
|
18
|
+
/**
|
|
19
|
+
* Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
|
|
20
|
+
* Anchored the same way as the package.json lookup above: both `dist/` and
|
|
21
|
+
* `src/` sit one level below the package root, so `../skills` resolves to the
|
|
22
|
+
* shipped `skills/` directory in dev, in `dist`, and when published.
|
|
23
|
+
*/
|
|
24
|
+
export declare const BUILTIN_SKILLS_DIR: string;
|
package/dist/constants.js
CHANGED
|
@@ -29,3 +29,16 @@ export const PROJECT_CONFIG_FILENAMES = [
|
|
|
29
29
|
];
|
|
30
30
|
/** Project-instruction filenames, checked in order (first match wins). */
|
|
31
31
|
export const PROJECT_INSTRUCTION_FILENAMES = ["CRUXY.md", "AGENTS.md"];
|
|
32
|
+
/**
|
|
33
|
+
* Name of the skills subdirectory, used under the project dir
|
|
34
|
+
* (`<cwd>/.cruxy/skills`), the global dir (`~/.cruxy/skills`), and the package
|
|
35
|
+
* root for shipped builtins.
|
|
36
|
+
*/
|
|
37
|
+
export const SKILLS_DIR_NAME = "skills";
|
|
38
|
+
/**
|
|
39
|
+
* Absolute path of the shipped builtin skills directory (`<pkg>/skills`).
|
|
40
|
+
* Anchored the same way as the package.json lookup above: both `dist/` and
|
|
41
|
+
* `src/` sit one level below the package root, so `../skills` resolves to the
|
|
42
|
+
* shipped `skills/` directory in dev, in `dist`, and when published.
|
|
43
|
+
*/
|
|
44
|
+
export const BUILTIN_SKILLS_DIR = join(__dirname, "..", SKILLS_DIR_NAME);
|
|
@@ -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
package/dist/tools/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool } from "./types.js";
|
|
3
|
+
declare const parameters: z.ZodObject<{
|
|
4
|
+
name: z.ZodString;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
name: string;
|
|
7
|
+
}, {
|
|
8
|
+
name: string;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Load a skill's full instructions on demand — the expensive half of
|
|
12
|
+
* progressive disclosure. Returns the markdown body plus the absolute asset root
|
|
13
|
+
* and the relative paths of any `scripts/` and `reference/` files, so the agent
|
|
14
|
+
* can read or run them through the existing read_file / run_command tools.
|
|
15
|
+
*
|
|
16
|
+
* Read-only — no approval. Loading a skill never executes anything: scripts are
|
|
17
|
+
* surfaced as paths only and run, if at all, through run_command (which applies
|
|
18
|
+
* the normal approval gate).
|
|
19
|
+
*/
|
|
20
|
+
export declare const loadSkillTool: Tool<typeof parameters>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getSkillService } from "../skills/index.js";
|
|
3
|
+
const parameters = z.object({
|
|
4
|
+
name: z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.describe("The exact name of the skill to load (from list_skills)."),
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* Load a skill's full instructions on demand — the expensive half of
|
|
11
|
+
* progressive disclosure. Returns the markdown body plus the absolute asset root
|
|
12
|
+
* and the relative paths of any `scripts/` and `reference/` files, so the agent
|
|
13
|
+
* can read or run them through the existing read_file / run_command tools.
|
|
14
|
+
*
|
|
15
|
+
* Read-only — no approval. Loading a skill never executes anything: scripts are
|
|
16
|
+
* surfaced as paths only and run, if at all, through run_command (which applies
|
|
17
|
+
* the normal approval gate).
|
|
18
|
+
*/
|
|
19
|
+
export const loadSkillTool = {
|
|
20
|
+
name: "load_skill",
|
|
21
|
+
description: "Load the full instructions for a skill by name (discover names with list_skills). Returns the skill's markdown body plus the paths of any bundled scripts/reference files. Read-only and requires no approval — it never runs anything; use run_command to execute a script the skill describes.",
|
|
22
|
+
parameters,
|
|
23
|
+
async execute(input, ctx) {
|
|
24
|
+
try {
|
|
25
|
+
const skill = await getSkillService(ctx.cwd, ctx.logger).load(input.name);
|
|
26
|
+
return { ok: true, output: formatSkill(skill) };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return { ok: false, error: err.message };
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
/** Render a loaded skill: instructions, then an asset reference footer. */
|
|
34
|
+
function formatSkill(skill) {
|
|
35
|
+
const parts = [
|
|
36
|
+
`# Skill: ${skill.name} (${skill.source})`,
|
|
37
|
+
"",
|
|
38
|
+
skill.body.trim(),
|
|
39
|
+
];
|
|
40
|
+
const footer = [`Asset root: ${skill.assetRoot}`];
|
|
41
|
+
if (skill.scripts.length > 0) {
|
|
42
|
+
footer.push(`Scripts (run via run_command — not auto-executed): ${skill.scripts.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
if (skill.references.length > 0) {
|
|
45
|
+
footer.push(`References (read via read_file): ${skill.references.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
parts.push("", "---", footer.join("\n"));
|
|
48
|
+
return parts.join("\n");
|
|
49
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -4,6 +4,8 @@ import { gitStatusTool } from "./git-status.js";
|
|
|
4
4
|
import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
|
|
5
5
|
import { runCommandTool } from "./shell/index.js";
|
|
6
6
|
import { searchCodebaseTool } from "./search-codebase.js";
|
|
7
|
+
import { listSkillsTool } from "./list-skills.js";
|
|
8
|
+
import { loadSkillTool } from "./load-skill.js";
|
|
7
9
|
/**
|
|
8
10
|
* In-memory catalogue of the tools available to the agent. Names are unique;
|
|
9
11
|
* `toToolSpecs()` projects the catalogue into the `@cruxy/sdk` wire format.
|
|
@@ -61,5 +63,7 @@ export function buildDefaultRegistry() {
|
|
|
61
63
|
registry.register(gitStatusTool);
|
|
62
64
|
registry.register(runCommandTool);
|
|
63
65
|
registry.register(searchCodebaseTool);
|
|
66
|
+
registry.register(listSkillsTool);
|
|
67
|
+
registry.register(loadSkillTool);
|
|
64
68
|
return registry;
|
|
65
69
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cruxy": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"dist"
|
|
10
|
+
"dist",
|
|
11
|
+
"skills"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=20"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: git-commit
|
|
3
|
+
description: Write a conventional commit message that passes this repo's commitlint. Use when committing, writing a commit message, or opening a PR in cruxy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing commit messages for cruxy
|
|
7
|
+
|
|
8
|
+
This repo enforces [Conventional Commits](https://www.conventionalcommits.org)
|
|
9
|
+
via commitlint (a husky `commit-msg` hook) plus changesets. Follow this to get a
|
|
10
|
+
message that passes on the first try.
|
|
11
|
+
|
|
12
|
+
## Format
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
<type>(<scope>): <subject>
|
|
16
|
+
|
|
17
|
+
<body>
|
|
18
|
+
|
|
19
|
+
<footer>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **type** — one of `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `perf`,
|
|
23
|
+
`build`, `ci`, `style`, `revert`.
|
|
24
|
+
- **scope** _(optional, but if present must be in the allow-list)_ —
|
|
25
|
+
`cli`, `sdk`, `ui`, `api`, `auth`, `billing`, `web`, `dashboard`, `docs`,
|
|
26
|
+
`ide`, `infra`, `pricing`, `repo`, `ci`. The canonical list lives in
|
|
27
|
+
`commitlint.config.cjs` (`scope-enum`).
|
|
28
|
+
- **subject** — imperative mood, no trailing period.
|
|
29
|
+
|
|
30
|
+
## The rule people trip on
|
|
31
|
+
|
|
32
|
+
**The subject must NOT start with a capital letter.** commitlint's
|
|
33
|
+
`subject-case` rejects sentence-case / start-case / pascal-case / upper-case, so
|
|
34
|
+
a subject beginning with an uppercase letter fails the hook (and CI). A tag like
|
|
35
|
+
`C.18` cannot be the first token.
|
|
36
|
+
|
|
37
|
+
- ✅ `feat(cli): codebase indexing + search_codebase (C.18)`
|
|
38
|
+
- ❌ `feat(cli): C.18 codebase indexing` — capital `C` ⇒ sentence-case ⇒ rejected.
|
|
39
|
+
|
|
40
|
+
Put any phase/issue tag in a parenthetical at the end, or lowercase the lead.
|
|
41
|
+
|
|
42
|
+
## Body and footer
|
|
43
|
+
|
|
44
|
+
- Wrap the body at ~72 columns; explain _what_ and _why_, not _how_.
|
|
45
|
+
- A breaking change uses `!` after the type/scope (`feat(cli)!: …`) and/or a
|
|
46
|
+
`BREAKING CHANGE:` footer.
|
|
47
|
+
- Co-author trailers go in the footer, e.g.
|
|
48
|
+
`Co-Authored-By: Name <email>`.
|
|
49
|
+
|
|
50
|
+
## Don't forget the changeset
|
|
51
|
+
|
|
52
|
+
User-facing changes need a changeset: run `pnpm changeset` (or add a file under
|
|
53
|
+
`.changeset/`) describing the change and the semver bump for the affected
|
|
54
|
+
package (e.g. `@cruxy/cli` minor).
|
|
55
|
+
|
|
56
|
+
## Quick check
|
|
57
|
+
|
|
58
|
+
If a commit is rejected with `subject must not be sentence-case … [subject-case]`,
|
|
59
|
+
lowercase the first word of the subject (or move the capitalized tag into a
|
|
60
|
+
trailing parenthetical) and recommit.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: using-skills
|
|
3
|
+
description: How cruxy skills work and how to author a SKILL.md. Use when the user asks about skills or wants to create or edit one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skills in cruxy
|
|
7
|
+
|
|
8
|
+
A **skill** is a directory containing a `SKILL.md` — YAML frontmatter plus a
|
|
9
|
+
markdown instructions body — that packages reusable, task-specific know-how for
|
|
10
|
+
the agent. Discover skills with the `list_skills` tool and pull a skill's full
|
|
11
|
+
instructions with `load_skill` when a task matches its description.
|
|
12
|
+
|
|
13
|
+
## Progressive disclosure
|
|
14
|
+
|
|
15
|
+
Only each skill's `name` and `description` are visible by default (a cheap
|
|
16
|
+
catalog). The full body is loaded **on demand** via `load_skill`, so a large
|
|
17
|
+
library of skills costs almost nothing until one is actually used. Write the
|
|
18
|
+
`description` so the agent can tell, from one line, _when_ to reach for the
|
|
19
|
+
skill.
|
|
20
|
+
|
|
21
|
+
## Authoring a SKILL.md
|
|
22
|
+
|
|
23
|
+
Create `skills/<name>/SKILL.md`:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
---
|
|
27
|
+
name: <name>
|
|
28
|
+
description: <one line — what it does and when to use it>
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# instructions
|
|
32
|
+
…the body the agent reads after load_skill…
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rules (all enforced, fail-loud):
|
|
36
|
+
|
|
37
|
+
- `name` is kebab-case (`^[a-z0-9][a-z0-9-]*$`) and **must equal the directory
|
|
38
|
+
name**.
|
|
39
|
+
- `description` is required and non-empty.
|
|
40
|
+
- Frontmatter is strict: unknown keys, missing fields, or malformed YAML are
|
|
41
|
+
reported in `cruxy skills --status` and the skill is excluded from the
|
|
42
|
+
catalog.
|
|
43
|
+
|
|
44
|
+
## Sources and precedence
|
|
45
|
+
|
|
46
|
+
Skills are discovered from three layers; on a name collision the higher layer
|
|
47
|
+
wins:
|
|
48
|
+
|
|
49
|
+
1. **project** — `<cwd>/.cruxy/skills/`
|
|
50
|
+
2. **user** — `~/.cruxy/skills/`
|
|
51
|
+
3. **builtin** — shipped with the CLI
|
|
52
|
+
|
|
53
|
+
Run `cruxy skills` to see the resolved catalog, or `cruxy skills --status` to see
|
|
54
|
+
the source directories and any validation errors.
|
|
55
|
+
|
|
56
|
+
## Bundled assets
|
|
57
|
+
|
|
58
|
+
A skill may include `scripts/` and `reference/` subdirectories. `load_skill`
|
|
59
|
+
returns their paths, but **nothing is auto-executed** — the agent runs a script
|
|
60
|
+
through `run_command` (which goes through the normal approval gate) and reads a
|
|
61
|
+
reference through `read_file`. Skills are data, not a privileged execution path,
|
|
62
|
+
and may only reference files inside their own directory.
|