@crustjs/skills 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
@@ -5,7 +5,7 @@ Package and install agent skills for AI coding assistants.
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- bun add @crustjs/skills
8
+ npm install @crustjs/skills
9
9
  ```
10
10
 
11
11
  ## Documentation
package/dist/index.d.ts CHANGED
@@ -1,29 +1,4 @@
1
1
  import { CommandDefinition, CommandSnapshot, ExtensionFactory } from "@crustjs/core";
2
- //#region src/build.d.ts
3
- /** Options for rendering a skill source. */
4
- interface WriteSkillsOptions {
5
- /** Application whose command tree is rendered into a generated skill. Omit to write only `extras`. */
6
- readonly app?: {
7
- snapshot(): Promise<CommandSnapshot>;
8
- };
9
- /** `skills` directory that receives one subdirectory per skill. */
10
- readonly outDir: string;
11
- /** Version recorded in the generated skill's SKILL.md metadata. Omitted when absent. */
12
- readonly version?: string;
13
- /** Generated skill name. Defaults to the root command name. */
14
- readonly name?: string;
15
- /** Generated skill description. Defaults to the root command description. */
16
- readonly description?: string;
17
- /** Hand-authored skill directories included alongside the generated skill. */
18
- readonly extras?: readonly (string | URL)[];
19
- }
20
- /**
21
- * Renders generated and authored skills into a package-ready skill source.
22
- */
23
- export declare function writeSkills({ app, ...options }: WriteSkillsOptions): Promise<readonly string[]>;
24
- /** Renders skills from a Command Snapshot prepared in this or another process. */
25
- export declare function writeSkillsFromSnapshot(snapshot: CommandSnapshot, options: Omit<WriteSkillsOptions, "app">): Promise<readonly string[]>;
26
- //#endregion
27
2
  //#region src/agents.d.ts
28
3
  type AgentClass = "universal" | "additional";
29
4
  type Scope = "global" | "project";
@@ -95,10 +70,11 @@ interface SkillStatusResult {
95
70
  status: SkillLinkStatus;
96
71
  }>;
97
72
  }
98
- /** Options for the skills extension. */
73
+ /**
74
+ * Options for the skills extension. Packaged skills are read at runtime from
75
+ * `resolveArtifactDir("skills")` (`@crustjs/core`), the directory `crust build` stages.
76
+ */
99
77
  interface SkillOptions {
100
- /** Packaged skills directory read at runtime for discovery and installation. */
101
- distDir: string | URL;
102
78
  /** Hand-authored skill directories (URL, absolute, or package-root-relative path) built alongside the generated skill. */
103
79
  extras?: readonly (string | URL)[];
104
80
  /** Generated command skill name. Defaults to the root command name. */
@@ -116,10 +92,35 @@ interface SkillOptions {
116
92
  defaultScope?: Scope;
117
93
  /** Repair stale or dangling owned links before commands run. @default true */
118
94
  autoUpdate?: boolean;
119
- /** Name of the interactive management command. @default "skill" */
95
+ /** Name of the interactive management command. The default includes a `skill` alias. @default "skills" */
120
96
  command?: string;
121
97
  }
122
98
  //#endregion
99
+ //#region src/build.d.ts
100
+ /** Options for rendering a skill source. */
101
+ interface WriteSkillsOptions {
102
+ /** Application whose command tree is rendered into a generated skill. Omit to write only `extras`. */
103
+ readonly app?: {
104
+ snapshot(): Promise<CommandSnapshot>;
105
+ };
106
+ /** `skills` directory that receives one subdirectory per skill. */
107
+ readonly outDir: string;
108
+ /** Version recorded in the generated skill's SKILL.md metadata. Omitted when absent. */
109
+ readonly version?: string;
110
+ /** Generated skill name. Defaults to the root command name. */
111
+ readonly name?: string;
112
+ /** Generated skill description. Defaults to the root command description. */
113
+ readonly description?: string;
114
+ /** Hand-authored skill directories included alongside the generated skill. */
115
+ readonly extras?: readonly (string | URL)[];
116
+ }
117
+ /**
118
+ * Renders generated and authored skills into a package-ready skill source.
119
+ */
120
+ export declare function writeSkills({ app, ...options }: WriteSkillsOptions): Promise<readonly string[]>;
121
+ /** Renders skills from a Command Snapshot prepared in this or another process. */
122
+ export declare function writeSkillsFromSnapshot(snapshot: CommandSnapshot, options: Omit<WriteSkillsOptions, "app">): Promise<readonly string[]>;
123
+ //#endregion
123
124
  //#region src/errors.d.ts
124
125
  export declare class SkillSourceConflictError extends Error {
125
126
  override readonly name = "SkillSourceConflictError";
@@ -145,7 +146,7 @@ export declare const skill: ExtensionFactory<[options: SkillOptions], {}, [], []
145
146
  export declare function installSkill(options: InstallSkillOptions): Promise<InstallSkillResult>;
146
147
  /** Unlinks only agent-directory entries carrying the requested skill's ownership signature. */
147
148
  export declare function uninstallSkill(options: UninstallSkillOptions): Promise<UninstallSkillResult>;
148
- /** Reports the ownership and health of each requested agent-directory entry. */
149
+ /** Reports ownership and health. Only ENOENT is missing; other filesystem errors reject. */
149
150
  export declare function getSkillStatus(options: SkillStatusOptions): Promise<SkillStatusResult>;
150
151
  //#endregion
151
152
  //#region src/skill-name.d.ts
@@ -158,21 +159,19 @@ export declare function getSkillStatus(options: SkillStatusOptions): Promise<Ski
158
159
  export declare function isValidSkillName(name: string): boolean;
159
160
  //#endregion
160
161
  //#region src/source.d.ts
162
+ /** The packaged skills root is missing or holds no skills: the CLI has not been built yet. */
161
163
  export declare class SkillSourceUnavailableError extends Error {
162
164
  override readonly name = "SkillSourceUnavailableError";
163
165
  }
164
- /**
165
- * Resolves a logical packaged skill-source root. When the package path is
166
- * unavailable, falls back to the executable directory: absolute and URL
167
- * sources by their basename, relative sources by the same relative path.
168
- */
169
- export declare function resolveSkillSource(source: string | URL): string;
170
166
  interface PackagedSkill {
171
167
  readonly sourceDir: string;
172
168
  readonly name: string;
173
169
  readonly description: string;
174
170
  }
175
- /** Reads every self-describing skill directory in a packaged skill source. */
176
- export declare function loadPackagedSkills(source: string | URL): readonly PackagedSkill[];
171
+ /**
172
+ * Reads every self-describing skill directory under `root`, the absolute
173
+ * packaged skills directory (normally `resolveArtifactDir("skills")`).
174
+ */
175
+ export declare function loadPackagedSkills(root: string): readonly PackagedSkill[];
177
176
  //#endregion
178
177
  export type { AgentClass, AgentResult, AgentTarget, InstallSkillOptions, InstallSkillResult, InstallStatus, PackagedSkill, Scope, SkillLinkStatus, SkillOptions, SkillStatusOptions, SkillStatusResult, UninstallSkillOptions, UninstallSkillResult, UninstallStatus, WriteSkillsOptions };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { lstat, mkdir, readFile, readdir, readlink, realpath, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
2
2
  import { basename, delimiter, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
3
- import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { buildCommandDocumentation, formatDefault, sectionsFor } from "@crustjs/core/tooling";
6
6
  import { defineCommand, defineExtension, defineExtensionId } from "@crustjs/core";
@@ -41,7 +41,7 @@ function isWithin(parent, child) {
41
41
  * @returns The absolute path of the nearest enclosing directory containing
42
42
  * `package.json`, or `null` if the filesystem root is reached first.
43
43
  *
44
- * @internal Used only by {@link resolveSourceDir}.
44
+ * @internal Shared by {@link resolveSourceDir} and `resolveArtifactDir`.
45
45
  */
46
46
  function findNearestPackageRoot(startPath) {
47
47
  let current = resolve(startPath);
@@ -258,6 +258,56 @@ var SkillConflictError = class extends Error {
258
258
  }
259
259
  };
260
260
  //#endregion
261
+ //#region ../utils/src/artifacts.ts
262
+ /**
263
+ * Set by `crust build` while it prepares the Command Snapshot: the absolute
264
+ * entry-isolated directory Extension build hooks write into. Core reads it
265
+ * to run the hooks; `resolveArtifactDir` reads it so sections evaluated during
266
+ * that run see the artifacts being built instead of the wiped `.crust/root`.
267
+ */
268
+ const BUILD_OUT_DIR_ENV = "CRUST_INTERNAL_BUILD_OUT_DIR";
269
+ /** True inside a `bun build --compile` or `deno compile` executable. */
270
+ function isCompiledExecutable() {
271
+ const { Bun, Deno } = globalThis;
272
+ const bunMain = Bun?.main ?? "";
273
+ return bunMain.startsWith("/$bunfs/") || /^[A-Za-z]:[\\/]~BUN[\\/]/.test(bunMain) || Deno?.build?.standalone === true;
274
+ }
275
+ /**
276
+ * Absolute path of a build artifact or `crust.include` directory shipped with
277
+ * this CLI. `name` is a top-level directory name such as `"skills"`.
278
+ *
279
+ * The artifact path is computed from how the CLI is running — never probed:
280
+ * - Compiled executable (Bun or Deno): `<dir of the executable>/<name>`, which
281
+ * is a platform package's `bin/` or wherever the binary was placed.
282
+ * - Crust-built Node bundle: `<name>` next to the bundle's `bin/` directory,
283
+ * i.e. `.crust/root/<name>` in place and `<installed root>/<name>` after install.
284
+ * - Snapshot preparation inside `crust build`: `<build output dir>/<name>`, the
285
+ * artifacts earlier Extension build hooks wrote in this same build.
286
+ * - Source (`bun run`, `node`, `deno run`): `.crust/root/<name>` under the
287
+ * nearest package root of the real `process.argv[1]` entrypoint (following source
288
+ * links) — the output of the last `crust build`.
289
+ *
290
+ * @throws {Error} when `name` is not a single path segment, or in source mode
291
+ * when the entrypoint cannot be resolved or has no enclosing `package.json`.
292
+ */
293
+ function resolveArtifactDir(name) {
294
+ if (name === "" || name === "." || name === ".." || /[\\/]/.test(name)) throw new Error(`Artifact name must be a single directory name, got ${JSON.stringify(name)}.`);
295
+ if (isCompiledExecutable()) return join(dirname(process.execPath), name);
296
+ if (process.env.CRUST_INTERNAL_BUILD === "1") return resolve(fileURLToPath(import.meta.url), "..", "..", name);
297
+ const buildOutDir = process.env[BUILD_OUT_DIR_ENV];
298
+ if (buildOutDir) return join(buildOutDir, name);
299
+ const entrypoint = process.argv[1];
300
+ let sourceEntrypoint = entrypoint;
301
+ if (entrypoint) try {
302
+ sourceEntrypoint = realpathSync(entrypoint);
303
+ } catch (cause) {
304
+ throw new Error(`Could not resolve artifact "${name}": could not resolve source entrypoint "${entrypoint}".`, { cause });
305
+ }
306
+ const packageRoot = sourceEntrypoint ? findNearestPackageRoot(sourceEntrypoint) : null;
307
+ if (!packageRoot) throw new Error(`Could not resolve artifact "${name}": no package.json was found above ${entrypoint ? `entrypoint "${entrypoint}"` : "process.argv[1] (unset)"}.`);
308
+ return join(packageRoot, ".crust", "root", name);
309
+ }
310
+ //#endregion
261
311
  //#region ../utils/src/process.ts
262
312
  /** Resolve a bare executable name from PATH (and PATHEXT on Windows). */
263
313
  function which(command) {
@@ -567,6 +617,12 @@ function resolveAgentPath(agent, scope, name) {
567
617
  return join(cfg.globalSkillsDir(homedir()), name);
568
618
  }
569
619
  //#endregion
620
+ //#region ../utils/src/error.ts
621
+ /** Narrows a caught Node.js system error to its stable errno contract. */
622
+ function isErrnoException(error) {
623
+ return error instanceof Error && "code" in error && typeof error.code === "string";
624
+ }
625
+ //#endregion
570
626
  //#region src/link.ts
571
627
  /** Returns whether a symlink target carries Crust's skill ownership signature. */
572
628
  function isOwnedSkillLink(target, name) {
@@ -593,47 +649,11 @@ function isValidSkillName(name) {
593
649
  return name.length >= 1 && name.length <= 64 && SKILL_NAME_PATTERN.test(name);
594
650
  }
595
651
  //#endregion
596
- //#region ../utils/src/error.ts
597
- /** Narrows a caught Node.js system error to its stable errno contract. */
598
- function isErrnoException(error) {
599
- return error instanceof Error && "code" in error && typeof error.code === "string";
600
- }
601
- //#endregion
602
652
  //#region src/source.ts
653
+ /** The packaged skills root is missing or holds no skills: the CLI has not been built yet. */
603
654
  var SkillSourceUnavailableError = class extends Error {
604
655
  name = "SkillSourceUnavailableError";
605
656
  };
606
- function directoryPath(path) {
607
- try {
608
- return statSync(path).isDirectory() ? path : null;
609
- } catch {
610
- return null;
611
- }
612
- }
613
- function fallbackName(source) {
614
- if (source instanceof URL) return basename(fileURLToPath(source));
615
- return isAbsolute(source) ? basename(source) : source;
616
- }
617
- /**
618
- * Resolves a logical packaged skill-source root. When the package path is
619
- * unavailable, falls back to the executable directory: absolute and URL
620
- * sources by their basename, relative sources by the same relative path.
621
- */
622
- function resolveSkillSource(source) {
623
- if (source instanceof URL && source.protocol !== "file:") throw new Error(`Skill source URL must use file: protocol, got "${source.protocol}".`);
624
- let primary;
625
- try {
626
- primary = resolveSourceDir(source);
627
- } catch {}
628
- if (primary) {
629
- const resolved = directoryPath(primary);
630
- if (resolved) return resolved;
631
- }
632
- const fallback = join(dirname(process.execPath), fallbackName(source));
633
- const resolvedFallback = directoryPath(fallback);
634
- if (resolvedFallback) return resolvedFallback;
635
- throw new SkillSourceUnavailableError(`Could not resolve skill source${primary ? ` at "${primary}"` : ""} or executable-relative fallback "${fallback}".`);
636
- }
637
657
  function readSkillFrontmatter(sourceDir) {
638
658
  let content;
639
659
  try {
@@ -644,11 +664,20 @@ function readSkillFrontmatter(sourceDir) {
644
664
  }
645
665
  return requireSkillFrontmatter(probeFrontmatter(content), `Skill source directory "${sourceDir}"`);
646
666
  }
647
- /** Reads every self-describing skill directory in a packaged skill source. */
648
- function loadPackagedSkills(source) {
649
- const root = resolveSkillSource(source);
667
+ /**
668
+ * Reads every self-describing skill directory under `root`, the absolute
669
+ * packaged skills directory (normally `resolveArtifactDir("skills")`).
670
+ */
671
+ function loadPackagedSkills(root) {
672
+ let entries;
673
+ try {
674
+ entries = readdirSync(root, { withFileTypes: true });
675
+ } catch (error) {
676
+ if (!isErrnoException(error) || error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
677
+ throw new SkillSourceUnavailableError(`Packaged skills not found at "${root}". Run \`crust build\` first.`, { cause: error });
678
+ }
650
679
  const skills = [];
651
- for (const entry of readdirSync(root, { withFileTypes: true })) {
680
+ for (const entry of entries) {
652
681
  if (!entry.isDirectory()) continue;
653
682
  const sourceDir = join(root, entry.name);
654
683
  if (!existsSync(join(sourceDir, "SKILL.md"))) continue;
@@ -660,7 +689,7 @@ function loadPackagedSkills(source) {
660
689
  description: frontmatter.description
661
690
  });
662
691
  }
663
- if (skills.length === 0) throw new SkillSourceUnavailableError(`Skill source "${root}" does not contain any skill directories.`);
692
+ if (skills.length === 0) throw new SkillSourceUnavailableError(`Packaged skills at "${root}" do not contain any skill directories. Run \`crust build\` first.`);
664
693
  return skills.sort((a, b) => a.name.localeCompare(b.name));
665
694
  }
666
695
  //#endregion
@@ -680,7 +709,8 @@ async function pathExists(path) {
680
709
  try {
681
710
  await stat(path);
682
711
  return true;
683
- } catch {
712
+ } catch (error) {
713
+ if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
684
714
  return false;
685
715
  }
686
716
  }
@@ -688,7 +718,8 @@ async function inspectLink(outputDir, name, expectedSourceDir) {
688
718
  let entry;
689
719
  try {
690
720
  entry = await lstat(outputDir);
691
- } catch {
721
+ } catch (error) {
722
+ if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
692
723
  return { status: "absent" };
693
724
  }
694
725
  if (!entry.isSymbolicLink()) return { status: "conflict" };
@@ -760,7 +791,7 @@ async function uninstallSkill(options) {
760
791
  }
761
792
  return { agents: results };
762
793
  }
763
- /** Reports the ownership and health of each requested agent-directory entry. */
794
+ /** Reports ownership and health. Only ENOENT is missing; other filesystem errors reject. */
764
795
  async function getSkillStatus(options) {
765
796
  const agents = options.agents ?? [...ALL_AGENTS];
766
797
  const scope = resolveEffectiveScope(options.scope ?? "global");
@@ -803,9 +834,10 @@ function planReconcile(options) {
803
834
  //#endregion
804
835
  //#region src/extension.ts
805
836
  const SKILLS = defineExtensionId("crust:skills");
806
- const DEFAULT_SKILL_COMMAND_NAME = "skill";
837
+ const DEFAULT_SKILL_COMMAND_NAME = "skills";
807
838
  const SKILLS_SECTION_TITLE = "Agent skills";
808
839
  const DEFAULT_SKILL_SCOPE = "global";
840
+ const SKILLS_ARTIFACT = "skills";
809
841
  async function resolveScope(rawScope, options) {
810
842
  if (rawScope) return rawScope;
811
843
  if (options.defaultScope) return options.defaultScope;
@@ -855,7 +887,7 @@ async function repairInstalledSkill(packagedSkill, scope, io, report = false) {
855
887
  async function autoRepairSkills(options, io) {
856
888
  let skills;
857
889
  try {
858
- skills = loadPackagedSkills(options.distDir);
890
+ skills = loadPackagedSkills(resolveArtifactDir(SKILLS_ARTIFACT));
859
891
  } catch (error) {
860
892
  if (!(error instanceof SkillSourceUnavailableError)) io.stderr(yellow(`Skipping skill link repair: ${error instanceof Error ? error.message : String(error)}`));
861
893
  return;
@@ -868,27 +900,28 @@ async function autoRepairSkills(options, io) {
868
900
  io.stderr(yellow(`Skipping skill link repair [${packagedSkill.name}]: ${error instanceof Error ? error.message : String(error)}`));
869
901
  }
870
902
  }
871
- function formatSkillDocumentation(source, commandName, appName) {
903
+ function formatSkillDocumentation(commandName, appName) {
872
904
  try {
873
- return loadPackagedSkills(source).map((packagedSkill) => {
905
+ return loadPackagedSkills(resolveArtifactDir(SKILLS_ARTIFACT)).map((packagedSkill) => {
874
906
  const sourcePath = isWithin(process.cwd(), packagedSkill.sourceDir) ? relative(process.cwd(), packagedSkill.sourceDir) || "." : packagedSkill.sourceDir;
875
907
  return `${packagedSkill.name} — ${packagedSkill.description}\n Source: ${sourcePath}`;
876
908
  }).join("\n\n");
877
909
  } catch (error) {
878
- if (error instanceof SkillSourceUnavailableError) return `The packaged skills directory is unavailable. Run \`${appName} ${commandName}\` to link packaged skills into an agent directory.`;
910
+ if (error instanceof SkillSourceUnavailableError) return `${error.message} Then run \`${appName} ${commandName}\` to link packaged skills into an agent directory.`;
879
911
  return `Packaged skills could not be read. Run \`${appName} ${commandName}\` for details.`;
880
912
  }
881
913
  }
882
914
  async function buildSkills(options, context) {
883
- const { writeSkills, writeSkillsFromSnapshot } = await Promise.resolve().then(() => build_exports);
884
- const writeOptions = {
885
- outDir: join(context.outDir, "skills"),
915
+ const { renderSkills } = await Promise.resolve().then(() => build_exports);
916
+ return (await renderSkills(options.generated === false ? void 0 : context.snapshot, {
886
917
  version: context.snapshot.meta.version,
887
918
  name: options.name,
888
919
  description: options.description,
889
920
  extras: options.extras
890
- };
891
- return (options.generated === false ? await writeSkills(writeOptions) : await writeSkillsFromSnapshot(context.snapshot, writeOptions)).map((file) => join("skills", file));
921
+ })).map((file) => ({
922
+ path: join(SKILLS_ARTIFACT, file.path),
923
+ content: file.content
924
+ }));
892
925
  }
893
926
  const skill = defineExtension(SKILLS, (options) => {
894
927
  const commandName = options.command ?? DEFAULT_SKILL_COMMAND_NAME;
@@ -897,7 +930,7 @@ const skill = defineExtension(SKILLS, (options) => {
897
930
  sections: (snapshot) => [{
898
931
  command: [],
899
932
  title: SKILLS_SECTION_TITLE,
900
- body: formatSkillDocumentation(options.distDir, commandName, snapshot.meta.name),
933
+ body: formatSkillDocumentation(commandName, snapshot.meta.name),
901
934
  except: [SKILLS]
902
935
  }],
903
936
  build: (context) => buildSkills(options, context),
@@ -1002,7 +1035,10 @@ async function reconcileSkill(opts) {
1002
1035
  if (toInstall.length === 0 && toUninstall.length === 0) io.stdout(dim(`No changes [${packagedSkill.name}].`));
1003
1036
  }
1004
1037
  function buildSkillCommand(commandName, options) {
1005
- return defineCommand(commandName, { description: "Manage agent skill installations" }, (command) => command.flags({
1038
+ return defineCommand(commandName, {
1039
+ description: "Manage agent skill installations",
1040
+ aliases: commandName === DEFAULT_SKILL_COMMAND_NAME ? ["skill"] : []
1041
+ }, (command) => command.flags({
1006
1042
  name: "scope",
1007
1043
  type: "string",
1008
1044
  choices: ["project", "global"],
@@ -1018,11 +1054,11 @@ function buildSkillCommand(commandName, options) {
1018
1054
  description: "Update scope (project or global)"
1019
1055
  }).action(async (context) => {
1020
1056
  const scope = await resolveScope(context.flags.scope, options);
1021
- for (const packagedSkill of loadPackagedSkills(options.distDir)) await repairInstalledSkill(packagedSkill, scope, context, true);
1057
+ for (const packagedSkill of loadPackagedSkills(resolveArtifactDir(SKILLS_ARTIFACT))) await repairInstalledSkill(packagedSkill, scope, context, true);
1022
1058
  }))).action(async (context) => {
1023
1059
  const installAll = context.flags.all === true;
1024
1060
  const scope = installAll ? context.flags.scope ?? options.defaultScope ?? DEFAULT_SKILL_SCOPE : await resolveScope(context.flags.scope, options);
1025
- for (const packagedSkill of loadPackagedSkills(options.distDir)) await reconcileSkill({
1061
+ for (const packagedSkill of loadPackagedSkills(resolveArtifactDir(SKILLS_ARTIFACT))) await reconcileSkill({
1026
1062
  packagedSkill,
1027
1063
  scope,
1028
1064
  installAll,
@@ -1065,7 +1101,7 @@ function normalizeArg(arg) {
1065
1101
  variadic: arg.variadic
1066
1102
  };
1067
1103
  if (arg.description !== void 0) result.description = arg.description;
1068
- if (arg.default !== void 0) result.default = serializeDefault(arg.default);
1104
+ if (arg.default !== void 0) result.default = formatDefault(arg.default);
1069
1105
  return result;
1070
1106
  }
1071
1107
  function normalizeFlag(flag) {
@@ -1077,15 +1113,12 @@ function normalizeFlag(flag) {
1077
1113
  multiple: flag.multiple
1078
1114
  };
1079
1115
  if (flag.description !== void 0) result.description = flag.description;
1080
- if (flag.default !== void 0) result.default = serializeDefault(flag.default);
1116
+ if (flag.default !== void 0) result.default = formatDefault(flag.default);
1081
1117
  return result;
1082
1118
  }
1083
1119
  function manifestType(type) {
1084
1120
  return type === "number" || type === "boolean" ? type : "string";
1085
1121
  }
1086
- function serializeDefault(value) {
1087
- return formatDefault(value);
1088
- }
1089
1122
  //#endregion
1090
1123
  //#region src/render.ts
1091
1124
  /**
@@ -1295,21 +1328,12 @@ function renderArgsTable(args) {
1295
1328
  for (const arg of args) {
1296
1329
  const name = arg.variadic ? `${arg.name}...` : arg.name;
1297
1330
  const required = arg.required ? "Yes" : "No";
1298
- const desc = escapeTableCell(formatArgDescription(arg));
1331
+ const desc = escapeTableCell(formatFieldDescription(arg));
1299
1332
  lines.push(`| \`${name}\` | ${arg.type} | ${required} | ${desc} |`);
1300
1333
  }
1301
1334
  return lines;
1302
1335
  }
1303
1336
  /**
1304
- * Formats the description cell for an argument, including default value.
1305
- */
1306
- function formatArgDescription(arg) {
1307
- const parts = [];
1308
- if (arg.description) parts.push(arg.description);
1309
- if (arg.default !== void 0) parts.push(`Default: \`${arg.default}\``);
1310
- return parts.join(". ") || "-";
1311
- }
1312
- /**
1313
1337
  * Renders a markdown table for named flags.
1314
1338
  */
1315
1339
  function renderFlagsTable(flags) {
@@ -1319,20 +1343,16 @@ function renderFlagsTable(flags) {
1319
1343
  for (const flag of flags) {
1320
1344
  const name = flag.spellings.map((spelling) => `\`${spelling}\``).join(", ");
1321
1345
  const required = flag.required ? "Yes" : "No";
1322
- const desc = escapeTableCell(formatFlagDescription(flag));
1346
+ const desc = escapeTableCell(formatFieldDescription(flag));
1323
1347
  lines.push(`| ${name} | ${flag.type} | ${required} | ${desc} |`);
1324
1348
  }
1325
1349
  return lines;
1326
1350
  }
1327
- /**
1328
- * Formats the description cell for a flag, including default value
1329
- * and multiplicity.
1330
- */
1331
- function formatFlagDescription(flag) {
1351
+ function formatFieldDescription(field) {
1332
1352
  const parts = [];
1333
- if (flag.description) parts.push(flag.description);
1334
- if (flag.multiple) parts.push("Can be specified multiple times");
1335
- if (flag.default !== void 0) parts.push(`Default: \`${flag.default}\``);
1353
+ if (field.description) parts.push(field.description);
1354
+ if ("multiple" in field && field.multiple) parts.push("Can be specified multiple times");
1355
+ if (field.default !== void 0) parts.push(`Default: \`${field.default}\``);
1336
1356
  return parts.join(". ") || "-";
1337
1357
  }
1338
1358
  /**
@@ -1370,6 +1390,7 @@ function findNode(root, path) {
1370
1390
  //#endregion
1371
1391
  //#region src/build.ts
1372
1392
  var build_exports = /* @__PURE__ */ __exportAll({
1393
+ renderSkills: () => renderSkills,
1373
1394
  writeSkills: () => writeSkills,
1374
1395
  writeSkillsFromSnapshot: () => writeSkillsFromSnapshot
1375
1396
  });
@@ -1384,18 +1405,37 @@ async function writeSkillsFromSnapshot(snapshot, options) {
1384
1405
  return await writeSkillSource(snapshot, options);
1385
1406
  }
1386
1407
  async function writeSkillSource(snapshot, options) {
1387
- if (snapshot === void 0 && (options.extras?.length ?? 0) === 0) throw new Error("Nothing to write: provide an app or at least one extra skill directory.");
1388
1408
  const outDir = resolve(options.outDir);
1389
1409
  if (basename(outDir) !== "skills") throw new Error(`Skill source outDir "${outDir}" must be named "skills".`);
1390
- const skills = /* @__PURE__ */ new Map();
1391
- const authoredNames = /* @__PURE__ */ new Set();
1392
1410
  for (const sourceDir of options.extras ?? []) {
1393
1411
  const resolved = resolveSourceDir(sourceDir);
1394
1412
  if (isWithin(outDir, resolved)) throw new Error(`Extra skill directory "${resolved}" is inside outDir "${outDir}", which is replaced on every build. Move authored skills outside the build output.`);
1413
+ }
1414
+ const files = await renderSkills(snapshot, options);
1415
+ const cwd = resolve(".");
1416
+ if (outDir === dirname(outDir) || isWithin(outDir, cwd)) throw new Error(`Refusing to replace "${outDir}": outDir must be a dedicated directory, not the filesystem root, the working directory, or an ancestor of it.`);
1417
+ await rm(outDir, {
1418
+ recursive: true,
1419
+ force: true
1420
+ });
1421
+ for (const file of files) {
1422
+ const filePath = join(outDir, file.path);
1423
+ await mkdir(dirname(filePath), { recursive: true });
1424
+ await writeFile(filePath, file.content);
1425
+ }
1426
+ return files.map((file) => file.path);
1427
+ }
1428
+ /**
1429
+ * Renders generated and authored skills as `<skill>/<file>` paths relative to a
1430
+ * `skills` directory, without writing them.
1431
+ */
1432
+ async function renderSkills(snapshot, options) {
1433
+ if (snapshot === void 0 && (options.extras?.length ?? 0) === 0) throw new Error("Nothing to write: provide an app or at least one extra skill directory.");
1434
+ const skills = /* @__PURE__ */ new Map();
1435
+ for (const sourceDir of options.extras ?? []) {
1395
1436
  const bundle = await loadBundleFiles(sourceDir);
1396
1437
  validateSkillName(bundle.frontmatter.name);
1397
- if (authoredNames.has(bundle.frontmatter.name)) throw new SkillSourceConflictError(bundle.frontmatter.name);
1398
- authoredNames.add(bundle.frontmatter.name);
1438
+ if (skills.has(bundle.frontmatter.name)) throw new SkillSourceConflictError(bundle.frontmatter.name);
1399
1439
  skills.set(bundle.frontmatter.name, bundle.files);
1400
1440
  }
1401
1441
  if (snapshot) {
@@ -1404,32 +1444,19 @@ async function writeSkillSource(snapshot, options) {
1404
1444
  description: options.description ?? snapshot.meta.description ?? "",
1405
1445
  version: options.version
1406
1446
  };
1407
- if (!authoredNames.has(generatedMeta.name)) {
1447
+ if (!skills.has(generatedMeta.name)) {
1408
1448
  validateSkillName(generatedMeta.name);
1409
1449
  requireSkillFrontmatter(generatedMeta, `Skill "${generatedMeta.name}"`);
1410
1450
  skills.set(generatedMeta.name, renderSkill(buildManifest(snapshot), generatedMeta));
1411
1451
  }
1412
1452
  }
1413
- const cwd = resolve(".");
1414
- if (outDir === dirname(outDir) || isWithin(outDir, cwd)) throw new Error(`Refusing to replace "${outDir}": outDir must be a dedicated directory, not the filesystem root, the working directory, or an ancestor of it.`);
1415
- await rm(outDir, {
1416
- recursive: true,
1417
- force: true
1418
- });
1419
- const written = [];
1420
- for (const [name, files] of skills) written.push(...(await writeFiles(join(outDir, name), files)).map((path) => join(name, path)));
1421
- return written;
1453
+ return [...skills].flatMap(([name, files]) => files.map((file) => ({
1454
+ path: join(name, file.path),
1455
+ content: file.content
1456
+ })));
1422
1457
  }
1423
1458
  function validateSkillName(name) {
1424
1459
  if (!isValidSkillName(name)) throw new Error(`Invalid skill name "${name}": must be 1–64 lowercase alphanumeric characters and hyphens, no leading/trailing/consecutive hyphens.`);
1425
1460
  }
1426
- async function writeFiles(baseDir, files) {
1427
- for (const file of files) {
1428
- const filePath = join(baseDir, file.path);
1429
- await mkdir(dirname(filePath), { recursive: true });
1430
- await writeFile(filePath, file.content);
1431
- }
1432
- return files.map((file) => file.path);
1433
- }
1434
1461
  //#endregion
1435
- export { SkillConflictError, SkillSourceConflictError, SkillSourceUnavailableError, detectInstalledAgents, getAdditionalAgents, getSkillStatus, getUniversalAgents, installSkill, isUniversalAgent, isValidSkillName, loadPackagedSkills, resolveSkillSource, skill, uninstallSkill, writeSkills, writeSkillsFromSnapshot };
1462
+ export { SkillConflictError, SkillSourceConflictError, SkillSourceUnavailableError, detectInstalledAgents, getAdditionalAgents, getSkillStatus, getUniversalAgents, installSkill, isUniversalAgent, isValidSkillName, loadPackagedSkills, skill, uninstallSkill, writeSkills, writeSkillsFromSnapshot };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/skills",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Package and install agent skills for AI coding assistants.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -45,20 +45,20 @@
45
45
  "postpack": "rm -f LICENSE"
46
46
  },
47
47
  "dependencies": {
48
- "@crustjs/progress": "^0.1.0",
49
- "@crustjs/prompts": "^0.2.0",
50
- "@crustjs/style": "^0.3.0"
48
+ "@crustjs/progress": "^0.1.1",
49
+ "@crustjs/prompts": "^0.2.2",
50
+ "@crustjs/style": "^0.3.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@crustjs/config": "0.0.0",
54
- "@crustjs/core": "0.2.0",
55
- "@crustjs/extensions": "0.2.0",
56
- "@crustjs/testing": "0.1.0",
54
+ "@crustjs/core": "0.3.0",
55
+ "@crustjs/extensions": "0.3.0",
56
+ "@crustjs/testing": "0.1.2",
57
57
  "@crustjs/utils": "0.0.0",
58
58
  "tsdown": "^0.23.0"
59
59
  },
60
60
  "peerDependencies": {
61
- "@crustjs/core": "^0.2.0",
61
+ "@crustjs/core": "^0.3.0",
62
62
  "typescript": "^7.0.0"
63
63
  },
64
64
  "peerDependenciesMeta": {
@@ -67,7 +67,7 @@
67
67
  }
68
68
  },
69
69
  "engines": {
70
- "bun": ">=1.3.14",
70
+ "bun": ">=1.4.0",
71
71
  "node": ">=22",
72
72
  "deno": ">=2.8"
73
73
  }