crewly 1.20.114 → 1.20.115

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Builds the public marketplace registry (config/skills/registry.json) from
3
+ * the skill directories under config/skills/agent/marketplace/.
4
+ *
5
+ * Pure, filesystem-reading logic shared by scripts/generate-registry.ts (which
6
+ * writes the file) and the registry guard test (which checks the committed file
7
+ * is up to date). It lives under config/ so jest runs its tests.
8
+ *
9
+ * @module config/skills/marketplace-registry
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
13
+ import path from 'path';
14
+ import YAML from 'yaml';
15
+ import { MARKETPLACE_CONSTANTS } from '../constants.js';
16
+
17
+ /** Repo-relative directory holding marketplace skills. */
18
+ export const MARKETPLACE_SKILLS_REL_DIR = 'config/skills/agent/marketplace';
19
+
20
+ /** Manifest fields the registry reads, from SKILL.md frontmatter or skill.json. */
21
+ export interface SkillManifest {
22
+ id?: string;
23
+ name?: string;
24
+ description?: string;
25
+ version?: string;
26
+ category?: string;
27
+ author?: string;
28
+ license?: string;
29
+ tags?: string[];
30
+ skillType?: string;
31
+ assignableRoles?: string[];
32
+ triggers?: string[];
33
+ }
34
+
35
+ /** One public registry entry. */
36
+ export interface RegistryItem {
37
+ id: string;
38
+ type: 'skill';
39
+ name: string;
40
+ description: string;
41
+ author: string;
42
+ version: string;
43
+ category: string;
44
+ tags: string[];
45
+ license: string;
46
+ downloads: number;
47
+ rating: number;
48
+ createdAt: string;
49
+ updatedAt: string;
50
+ source: string;
51
+ assets: { archive: string; checksum: string; sizeBytes: number };
52
+ metadata: { skillType?: string; assignableRoles?: string[]; triggers?: string[]; files: string[] };
53
+ }
54
+
55
+ /** The public registry document. */
56
+ export interface Registry {
57
+ schemaVersion: number;
58
+ lastUpdated: string;
59
+ cdnBaseUrl: string;
60
+ source: string;
61
+ items: RegistryItem[];
62
+ }
63
+
64
+ /** A marketplace directory that could not be listed, and why. */
65
+ export interface SkippedDir {
66
+ dir: string;
67
+ reason: string;
68
+ }
69
+
70
+ /**
71
+ * Parse the YAML frontmatter of a SKILL.md file.
72
+ *
73
+ * @param markdown - SKILL.md contents
74
+ * @returns The frontmatter object, or null when the file has none
75
+ */
76
+ export function parseFrontmatter(markdown: string): Record<string, unknown> | null {
77
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
78
+ if (!match) return null;
79
+ const parsed = YAML.parse(match[1]) as unknown;
80
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
81
+ }
82
+
83
+ /**
84
+ * Read a skill directory's manifest. SKILL.md frontmatter wins field by field
85
+ * (the CLI installs SKILL.md first); skill.json fills anything it lacks.
86
+ *
87
+ * @param skillDir - Absolute skill directory
88
+ * @returns The merged manifest, or null when the directory has neither file
89
+ */
90
+ export function readSkillManifest(skillDir: string): SkillManifest | null {
91
+ const mdPath = path.join(skillDir, 'SKILL.md');
92
+ const jsonPath = path.join(skillDir, 'skill.json');
93
+ const fromMd = existsSync(mdPath) ? parseFrontmatter(readFileSync(mdPath, 'utf-8')) : null;
94
+ const fromJson = existsSync(jsonPath) ? (JSON.parse(readFileSync(jsonPath, 'utf-8')) as Record<string, unknown>) : null;
95
+ if (!fromMd && !fromJson) return null;
96
+ return { ...(fromJson ?? {}), ...(fromMd ?? {}) } as SkillManifest;
97
+ }
98
+
99
+ /**
100
+ * Files a skill must not ship: tests (`*.test.*`), test fixtures (`mock-*`)
101
+ * and packaging hints (`.crewlyignore`).
102
+ */
103
+ export const SKILL_FILE_EXCLUDES: ReadonlyArray<RegExp> = [/\.test\./, /^mock-/, /^\.crewlyignore$/];
104
+
105
+ /**
106
+ * The files the CLI should download for a skill: every regular file directly
107
+ * in the directory, minus SKILL_FILE_EXCLUDES, sorted.
108
+ *
109
+ * The CLI fetches `metadata.files` (plus SKILL.md and skill.json) from GitHub
110
+ * raw content; without the list it fetches only SKILL.md, execute.sh,
111
+ * skill.json and instructions.md, so e.g. nano-banana-image's generate.sh was
112
+ * never installed.
113
+ *
114
+ * Deliberately FLAT: files in subdirectories (e.g. remotion-video/templates/)
115
+ * are not listed. The shipped CLI writes each listed file without creating
116
+ * parent directories, so a nested path would fail the whole install on every
117
+ * CLI released so far. List them only after the CLI creates parent dirs AND
118
+ * the older CLIs are no longer in use.
119
+ *
120
+ * @param skillDir - Absolute skill directory
121
+ * @returns File names relative to the skill directory
122
+ */
123
+ export function listSkillFiles(skillDir: string): string[] {
124
+ return readdirSync(skillDir, { withFileTypes: true })
125
+ .filter((e) => e.isFile() && !SKILL_FILE_EXCLUDES.some((re) => re.test(e.name)))
126
+ .map((e) => e.name)
127
+ .sort((a, b) => a.localeCompare(b));
128
+ }
129
+
130
+ /** Total size in bytes of the regular files directly inside a directory. */
131
+ function directorySize(dir: string): number {
132
+ return readdirSync(dir)
133
+ .map((f) => statSync(path.join(dir, f)))
134
+ .filter((s) => s.isFile())
135
+ .reduce((sum, s) => sum + s.size, 0);
136
+ }
137
+
138
+ /** The fields derived from the skill's files (everything except dates and counters). */
139
+ function contentOf(item: RegistryItem): string {
140
+ return JSON.stringify({ ...item, createdAt: undefined, updatedAt: undefined, downloads: undefined, rating: undefined });
141
+ }
142
+
143
+ /**
144
+ * Build the registry from the marketplace directories.
145
+ *
146
+ * Ids are stable: a skill the registry already lists (matched by source path)
147
+ * keeps its published id, because users and published docs install it by that
148
+ * id (e.g. `crewly install agent-send-pdf-to-slack`). A new skill gets its
149
+ * directory name, the id the CLI installs under. The same id must be used by
150
+ * crewlyai.com's registry, or the CLI cannot pair the two entries.
151
+ * Name, description and version come from the manifest.
152
+ *
153
+ * Skills outside the marketplace directory (e.g. config/skills/agent/browse-stealth)
154
+ * are listed only when the committed registry already lists them: their entry
155
+ * is rebuilt from the same source directory, or reported as skipped when that
156
+ * directory is gone. Items are sorted by id.
157
+ *
158
+ * Entries that already exist (matched by source path) keep createdAt,
159
+ * downloads and rating; updatedAt changes only when the entry's content
160
+ * changes, and lastUpdated only when any entry changes. Re-running with no
161
+ * skill changes therefore reproduces the committed file byte for byte.
162
+ *
163
+ * @param repoRoot - Repository root
164
+ * @param previous - The currently committed registry, if any
165
+ * @param now - ISO timestamp for new or changed entries
166
+ * @returns The registry and the directories that could not be listed
167
+ */
168
+ export function buildRegistry(
169
+ repoRoot: string,
170
+ previous: Registry | null,
171
+ now: string
172
+ ): { registry: Registry; skipped: SkippedDir[] } {
173
+ const baseDir = path.join(repoRoot, MARKETPLACE_SKILLS_REL_DIR);
174
+ const previousBySource = new Map((previous?.items ?? []).map((i) => [i.source, i]));
175
+ const skipped: SkippedDir[] = [];
176
+ const items: RegistryItem[] = [];
177
+
178
+ const dirs = readdirSync(baseDir, { withFileTypes: true })
179
+ .filter((e) => e.isDirectory())
180
+ .map((e) => e.name)
181
+ .sort((a, b) => a.localeCompare(b));
182
+
183
+ // Marketplace directories, plus sources outside it that the registry already lists.
184
+ const sources = dirs.map((dir) => `${MARKETPLACE_SKILLS_REL_DIR}/${dir}`);
185
+ for (const prior of previous?.items ?? []) {
186
+ if (!prior.source.startsWith(`${MARKETPLACE_SKILLS_REL_DIR}/`) && !sources.includes(prior.source)) {
187
+ sources.push(prior.source);
188
+ }
189
+ }
190
+
191
+ for (const source of sources) {
192
+ const dir = path.basename(source);
193
+ const skillDir = path.join(repoRoot, source);
194
+ if (!existsSync(skillDir)) {
195
+ skipped.push({ dir: source, reason: 'listed in the registry but the directory no longer exists' });
196
+ continue;
197
+ }
198
+ const manifest = readSkillManifest(skillDir);
199
+ if (!manifest) {
200
+ skipped.push({ dir: source, reason: 'no SKILL.md frontmatter or skill.json' });
201
+ continue;
202
+ }
203
+ if (!manifest.name) {
204
+ skipped.push({ dir: source, reason: 'manifest has no name' });
205
+ continue;
206
+ }
207
+ const prior = previousBySource.get(source);
208
+ const draft: RegistryItem = {
209
+ id: prior?.id ?? dir,
210
+ type: 'skill',
211
+ name: manifest.name,
212
+ description: manifest.description ?? '',
213
+ author: manifest.author || 'Crewly Team',
214
+ version: manifest.version || '1.0.0',
215
+ category: (manifest.category && MARKETPLACE_CONSTANTS.CATEGORY_MAP[manifest.category]) || 'development',
216
+ tags: manifest.tags ?? [],
217
+ license: manifest.license || 'MIT',
218
+ downloads: 0,
219
+ rating: 0,
220
+ createdAt: now,
221
+ updatedAt: now,
222
+ source,
223
+ assets: { archive: source, checksum: '', sizeBytes: directorySize(skillDir) },
224
+ metadata: {
225
+ skillType: manifest.skillType,
226
+ assignableRoles: manifest.assignableRoles,
227
+ triggers: manifest.triggers,
228
+ files: listSkillFiles(skillDir),
229
+ },
230
+ };
231
+ if (prior) {
232
+ draft.createdAt = prior.createdAt;
233
+ draft.downloads = prior.downloads;
234
+ draft.rating = prior.rating;
235
+ if (contentOf(prior) === contentOf(draft)) draft.updatedAt = prior.updatedAt;
236
+ }
237
+ items.push(draft);
238
+ }
239
+ items.sort((a, b) => a.id.localeCompare(b.id));
240
+
241
+ const unchanged =
242
+ previous !== null &&
243
+ previous.items.length === items.length &&
244
+ previous.items.every((p, i) => JSON.stringify(p) === JSON.stringify(items[i]));
245
+
246
+ return {
247
+ registry: {
248
+ schemaVersion: MARKETPLACE_CONSTANTS.SCHEMA_VERSION,
249
+ lastUpdated: unchanged ? previous.lastUpdated : now,
250
+ cdnBaseUrl: MARKETPLACE_CONSTANTS.PUBLIC_CDN_BASE,
251
+ source: 'github',
252
+ items,
253
+ },
254
+ skipped,
255
+ };
256
+ }