@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 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
+ }
@@ -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}`));
@@ -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,4 @@
1
+ export * from "./types.js";
2
+ export * from "./parser.js";
3
+ export * from "./loader.js";
4
+ export * from "./service.js";
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./parser.js";
3
+ export * from "./loader.js";
4
+ export * from "./service.js";
@@ -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();
@@ -3,4 +3,6 @@ export * from "./registry.js";
3
3
  export * from "./list-files.js";
4
4
  export * from "./git-status.js";
5
5
  export * from "./search-codebase.js";
6
+ export * from "./list-skills.js";
7
+ export * from "./load-skill.js";
6
8
  export * from "./file/index.js";
@@ -3,4 +3,6 @@ export * from "./registry.js";
3
3
  export * from "./list-files.js";
4
4
  export * from "./git-status.js";
5
5
  export * from "./search-codebase.js";
6
+ export * from "./list-skills.js";
7
+ export * from "./load-skill.js";
6
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
+ }
@@ -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
+ }
@@ -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.2.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.