@cruxy/cli 0.2.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +39 -1
  2. package/dist/agent/loop.js +2 -1
  3. package/dist/cli/commands/config.js +2 -3
  4. package/dist/cli/commands/index.js +5 -2
  5. package/dist/cli/commands/run.js +21 -13
  6. package/dist/cli/commands/skills.d.ts +8 -0
  7. package/dist/cli/commands/skills.js +51 -0
  8. package/dist/cli/program.js +26 -4
  9. package/dist/cli/repl.js +14 -1
  10. package/dist/config/manager.js +5 -4
  11. package/dist/constants.d.ts +13 -0
  12. package/dist/constants.js +13 -0
  13. package/dist/errors/boundary.d.ts +43 -0
  14. package/dist/errors/boundary.js +73 -0
  15. package/dist/errors/constructors.d.ts +27 -0
  16. package/dist/errors/constructors.js +246 -0
  17. package/dist/errors/format.d.ts +31 -0
  18. package/dist/errors/format.js +60 -0
  19. package/dist/errors/index.d.ts +4 -0
  20. package/dist/errors/index.js +4 -0
  21. package/dist/errors/types.d.ts +74 -0
  22. package/dist/errors/types.js +107 -0
  23. package/dist/index.js +8 -5
  24. package/dist/indexing/embedder.js +3 -3
  25. package/dist/indexing/service.js +4 -1
  26. package/dist/skills/index.d.ts +4 -0
  27. package/dist/skills/index.js +4 -0
  28. package/dist/skills/loader.d.ts +43 -0
  29. package/dist/skills/loader.js +0 -0
  30. package/dist/skills/parser.d.ts +31 -0
  31. package/dist/skills/parser.js +98 -0
  32. package/dist/skills/service.d.ts +41 -0
  33. package/dist/skills/service.js +92 -0
  34. package/dist/skills/types.d.ts +94 -0
  35. package/dist/skills/types.js +21 -0
  36. package/dist/tools/file/paths.d.ts +4 -2
  37. package/dist/tools/file/paths.js +5 -3
  38. package/dist/tools/index.d.ts +2 -0
  39. package/dist/tools/index.js +2 -0
  40. package/dist/tools/list-skills.d.ts +9 -0
  41. package/dist/tools/list-skills.js +34 -0
  42. package/dist/tools/load-skill.d.ts +21 -0
  43. package/dist/tools/load-skill.js +49 -0
  44. package/dist/tools/registry.js +4 -0
  45. package/package.json +3 -2
  46. package/skills/git-commit/SKILL.md +60 -0
  47. package/skills/using-skills/SKILL.md +62 -0
@@ -0,0 +1,31 @@
1
+ import { CruxyError } from "../errors/index.js";
2
+ import { type SkillFrontmatter } from "./types.js";
3
+ /**
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
8
+ */
9
+ export declare class SkillValidationError extends CruxyError {
10
+ constructor(message: string);
11
+ }
12
+ /** The validated frontmatter plus the markdown body that followed it. */
13
+ export interface ParsedSkill {
14
+ frontmatter: SkillFrontmatter;
15
+ body: string;
16
+ }
17
+ /**
18
+ * Parse and validate a SKILL.md.
19
+ *
20
+ * The frontmatter is a deliberately small, strict subset of YAML — `key: value`
21
+ * scalars, optional surrounding quotes, `#` comment lines — which is all the
22
+ * two-field schema needs. Anything outside that subset (block scalars, nested
23
+ * maps, a line without a colon, a missing/unterminated block) is a loud error
24
+ * rather than a best-effort guess, so a skill never loads with a
25
+ * silently-misread description.
26
+ *
27
+ * @param text the raw file contents
28
+ * @param dirName the skill directory's basename; `name` must equal it
29
+ * @throws {SkillValidationError} on any malformed or invalid input
30
+ */
31
+ export declare function parseSkill(text: string, dirName: string): ParsedSkill;
@@ -0,0 +1,98 @@
1
+ import { CruxyError, ErrorCode } from "../errors/index.js";
2
+ import { SkillFrontmatterSchema } from "./types.js";
3
+ /**
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
8
+ */
9
+ export class SkillValidationError extends CruxyError {
10
+ constructor(message) {
11
+ super({
12
+ code: ErrorCode.SkillInvalid,
13
+ title: message,
14
+ nextSteps: [
15
+ "see the built-in `using-skills` skill for the SKILL.md rules",
16
+ ],
17
+ });
18
+ this.name = "SkillValidationError";
19
+ }
20
+ }
21
+ // Opening `---` line, frontmatter content (non-greedy), closing `---` line, body.
22
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
23
+ /**
24
+ * Parse and validate a SKILL.md.
25
+ *
26
+ * The frontmatter is a deliberately small, strict subset of YAML — `key: value`
27
+ * scalars, optional surrounding quotes, `#` comment lines — which is all the
28
+ * two-field schema needs. Anything outside that subset (block scalars, nested
29
+ * maps, a line without a colon, a missing/unterminated block) is a loud error
30
+ * rather than a best-effort guess, so a skill never loads with a
31
+ * silently-misread description.
32
+ *
33
+ * @param text the raw file contents
34
+ * @param dirName the skill directory's basename; `name` must equal it
35
+ * @throws {SkillValidationError} on any malformed or invalid input
36
+ */
37
+ export function parseSkill(text, dirName) {
38
+ if (!/^---[ \t]*\r?\n/.test(text)) {
39
+ throw new SkillValidationError("SKILL.md must begin with a YAML frontmatter block delimited by '---'");
40
+ }
41
+ const match = FRONTMATTER_RE.exec(text);
42
+ if (!match) {
43
+ throw new SkillValidationError("frontmatter block is not terminated by a closing '---' line");
44
+ }
45
+ const raw = parseFrontmatterBlock(match[1]);
46
+ const body = match[2] ?? "";
47
+ const result = SkillFrontmatterSchema.safeParse(raw);
48
+ if (!result.success) {
49
+ throw new SkillValidationError(`invalid frontmatter: ${formatZodError(result.error)}`);
50
+ }
51
+ const frontmatter = result.data;
52
+ if (frontmatter.name !== dirName) {
53
+ throw new SkillValidationError(`frontmatter name "${frontmatter.name}" must match the skill directory name "${dirName}"`);
54
+ }
55
+ return { frontmatter, body };
56
+ }
57
+ /** Parse the frontmatter block into a flat string map, strictly. */
58
+ function parseFrontmatterBlock(block) {
59
+ const out = {};
60
+ for (const rawLine of block.split(/\r?\n/)) {
61
+ const line = rawLine.trim();
62
+ if (line === "" || line.startsWith("#"))
63
+ continue;
64
+ const colon = line.indexOf(":");
65
+ if (colon === -1) {
66
+ throw new SkillValidationError(`invalid frontmatter line (expected "key: value"): ${JSON.stringify(rawLine)}`);
67
+ }
68
+ const key = line.slice(0, colon).trim();
69
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) {
70
+ throw new SkillValidationError(`invalid frontmatter key: ${JSON.stringify(key)}`);
71
+ }
72
+ if (key in out) {
73
+ throw new SkillValidationError(`duplicate frontmatter key: ${JSON.stringify(key)}`);
74
+ }
75
+ out[key] = stripQuotes(line.slice(colon + 1).trim());
76
+ }
77
+ return out;
78
+ }
79
+ /** Strip a single pair of matching surrounding quotes, if present. */
80
+ function stripQuotes(value) {
81
+ if (value.length >= 2) {
82
+ const first = value[0];
83
+ const last = value[value.length - 1];
84
+ if ((first === '"' || first === "'") && first === last) {
85
+ return value.slice(1, -1);
86
+ }
87
+ }
88
+ return value;
89
+ }
90
+ /** Render a zod error to a compact, single-line reason. */
91
+ function formatZodError(error) {
92
+ return error.issues
93
+ .map((issue) => {
94
+ const path = issue.path.join(".");
95
+ return path ? `${path}: ${issue.message}` : issue.message;
96
+ })
97
+ .join("; ");
98
+ }
@@ -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();
@@ -1,10 +1,12 @@
1
+ import { CruxyError } from "../../errors/index.js";
1
2
  import type { ToolContext } from "../types.js";
2
3
  /**
3
4
  * Thrown when a tool argument resolves to a path outside the project root —
4
5
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
5
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
6
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
7
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
6
8
  */
7
- export declare class PathEscapeError extends Error {
9
+ export declare class PathEscapeError extends CruxyError {
8
10
  constructor(message: string);
9
11
  }
10
12
  /**
@@ -1,13 +1,15 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { CruxyError, ErrorCode } from "../../errors/index.js";
3
4
  /**
4
5
  * Thrown when a tool argument resolves to a path outside the project root —
5
6
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
6
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
7
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
8
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
7
9
  */
8
- export class PathEscapeError extends Error {
10
+ export class PathEscapeError extends CruxyError {
9
11
  constructor(message) {
10
- super(message);
12
+ super({ code: ErrorCode.PathEscape, title: message });
11
13
  this.name = "PathEscapeError";
12
14
  }
13
15
  }
@@ -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.4.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.