@agentrules/core 0.0.10 → 0.1.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/dist/index.js CHANGED
@@ -1,41 +1,9 @@
1
1
  import { ZodError, z } from "zod";
2
2
  import { createTwoFilesPatch } from "diff";
3
3
 
4
- //#region src/constants.ts
5
- /**
6
- * Shared constants for agentrules presets and registry.
7
- */
8
- /** Filename for preset configuration */
9
- const PRESET_CONFIG_FILENAME = "agentrules.json";
10
- /** Directory name for preset metadata (README, LICENSE, etc.) */
11
- const AGENT_RULES_DIR = ".agentrules";
12
- /** JSON Schema URL for preset configuration */
13
- const PRESET_SCHEMA_URL = "https://agentrules.directory/schema/agentrules.json";
14
- /** API root path segment */
15
- const API_PATH = "api";
16
- /** Default version identifier for latest preset version */
17
- const LATEST_VERSION = "latest";
18
- /**
19
- * API endpoint paths (relative to registry base URL).
20
- */
21
- const API_ENDPOINTS = {
22
- presets: {
23
- base: `${API_PATH}/presets`,
24
- get: (slug, platform, version = LATEST_VERSION) => `${API_PATH}/presets/${encodeURIComponent(slug)}/${encodeURIComponent(platform)}/${encodeURIComponent(version)}`,
25
- unpublish: (slug, platform, version) => `${API_PATH}/presets/${encodeURIComponent(slug)}/${encodeURIComponent(platform)}/${encodeURIComponent(version)}`
26
- },
27
- auth: {
28
- session: `${API_PATH}/auth/get-session`,
29
- deviceCode: `${API_PATH}/auth/device/code`,
30
- deviceToken: `${API_PATH}/auth/device/token`
31
- }
32
- };
33
-
34
- //#endregion
35
4
  //#region src/platform/types.ts
36
5
  /**
37
- * Single source of truth for platform IDs.
38
- * Add new platforms here - types and config will follow.
6
+ * Platform and rule type definitions.
39
7
  */
40
8
  const PLATFORM_ID_TUPLE = [
41
9
  "opencode",
@@ -46,30 +14,169 @@ const PLATFORM_ID_TUPLE = [
46
14
 
47
15
  //#endregion
48
16
  //#region src/platform/config.ts
49
- /** List of supported platform IDs as a readonly tuple */
50
17
  const PLATFORM_IDS = PLATFORM_ID_TUPLE;
51
18
  /**
52
- * Platform-specific configuration.
53
- * Single source of truth for all platform paths.
19
+ * Platform configuration including supported rule types and install paths.
54
20
  */
55
21
  const PLATFORMS = {
56
22
  opencode: {
23
+ label: "OpenCode",
57
24
  projectDir: ".opencode",
58
- globalDir: "~/.config/opencode"
59
- },
60
- codex: {
61
- projectDir: ".codex",
62
- globalDir: "~/.codex"
25
+ globalDir: "~/.config/opencode",
26
+ types: {
27
+ instruction: {
28
+ description: "Project instructions",
29
+ format: "markdown",
30
+ extension: "md",
31
+ projectPath: "AGENTS.md",
32
+ globalPath: "~/.config/opencode/AGENTS.md"
33
+ },
34
+ agent: {
35
+ description: "Specialized AI agent",
36
+ format: "markdown",
37
+ extension: "md",
38
+ projectPath: ".opencode/agent/{name}.md",
39
+ globalPath: "~/.config/opencode/agent/{name}.md"
40
+ },
41
+ command: {
42
+ description: "Custom slash command",
43
+ format: "markdown",
44
+ extension: "md",
45
+ projectPath: ".opencode/command/{name}.md",
46
+ globalPath: "~/.config/opencode/command/{name}.md"
47
+ },
48
+ tool: {
49
+ description: "Custom tool",
50
+ format: "typescript",
51
+ extension: "ts",
52
+ projectPath: ".opencode/tool/{name}.ts",
53
+ globalPath: "~/.config/opencode/tool/{name}.ts"
54
+ }
55
+ }
63
56
  },
64
57
  claude: {
58
+ label: "Claude Code",
65
59
  projectDir: ".claude",
66
- globalDir: "~/.claude"
60
+ globalDir: "~/.claude",
61
+ types: {
62
+ instruction: {
63
+ description: "Project instructions",
64
+ format: "markdown",
65
+ extension: "md",
66
+ projectPath: "CLAUDE.md",
67
+ globalPath: "~/.claude/CLAUDE.md"
68
+ },
69
+ command: {
70
+ description: "Custom slash command",
71
+ format: "markdown",
72
+ extension: "md",
73
+ projectPath: ".claude/commands/{name}.md",
74
+ globalPath: "~/.claude/commands/{name}.md"
75
+ },
76
+ skill: {
77
+ description: "Custom skill",
78
+ format: "markdown",
79
+ extension: "md",
80
+ projectPath: ".claude/skills/{name}/SKILL.md",
81
+ globalPath: "~/.claude/skills/{name}/SKILL.md"
82
+ }
83
+ }
67
84
  },
68
85
  cursor: {
86
+ label: "Cursor",
69
87
  projectDir: ".cursor",
70
- globalDir: "~/.cursor"
88
+ globalDir: "~/.cursor",
89
+ types: {
90
+ instruction: {
91
+ description: "Project instructions",
92
+ format: "markdown",
93
+ extension: "md",
94
+ projectPath: "AGENTS.md",
95
+ globalPath: null
96
+ },
97
+ rule: {
98
+ description: "Custom rule",
99
+ format: "mdc",
100
+ extension: "mdc",
101
+ projectPath: ".cursor/rules/{name}.mdc",
102
+ globalPath: null
103
+ },
104
+ command: {
105
+ description: "Custom slash command",
106
+ format: "markdown",
107
+ extension: "md",
108
+ projectPath: ".cursor/commands/{name}.md",
109
+ globalPath: "~/.cursor/commands/{name}.md"
110
+ }
111
+ }
112
+ },
113
+ codex: {
114
+ label: "Codex",
115
+ projectDir: ".codex",
116
+ globalDir: "~/.codex",
117
+ types: {
118
+ instruction: {
119
+ description: "Project instructions",
120
+ format: "markdown",
121
+ extension: "md",
122
+ projectPath: "AGENTS.md",
123
+ globalPath: "~/.codex/AGENTS.md"
124
+ },
125
+ command: {
126
+ description: "Custom prompt",
127
+ format: "markdown",
128
+ extension: "md",
129
+ projectPath: null,
130
+ globalPath: "~/.codex/prompts/{name}.md"
131
+ }
132
+ }
71
133
  }
72
134
  };
135
+ /** Valid rule types for each platform. Must be kept in sync with PLATFORMS. */
136
+ const PLATFORM_RULE_TYPES = {
137
+ opencode: [
138
+ "instruction",
139
+ "command",
140
+ "agent",
141
+ "tool"
142
+ ],
143
+ claude: [
144
+ "instruction",
145
+ "command",
146
+ "skill"
147
+ ],
148
+ codex: ["instruction", "command"],
149
+ cursor: [
150
+ "instruction",
151
+ "command",
152
+ "rule"
153
+ ]
154
+ };
155
+ /** Get valid rule types for a specific platform */
156
+ function getValidRuleTypes(platform) {
157
+ return PLATFORM_RULE_TYPES[platform];
158
+ }
159
+ /** Check if a type is valid for a given platform */
160
+ function isValidRuleType(platform, type) {
161
+ return PLATFORM_RULE_TYPES[platform].includes(type);
162
+ }
163
+ /** Get the configuration for a specific platform + type combination */
164
+ function getRuleTypeConfig(platform, type) {
165
+ const platformConfig = PLATFORMS[platform];
166
+ return platformConfig.types[type];
167
+ }
168
+ /** Get the install path for a rule, replacing {name} placeholder */
169
+ function getInstallPath(platform, type, name, location = "project") {
170
+ const config = getRuleTypeConfig(platform, type);
171
+ if (!config) return null;
172
+ const pathTemplate = location === "project" ? config.projectPath : config.globalPath;
173
+ if (!pathTemplate) return null;
174
+ return pathTemplate.replace("{name}", name);
175
+ }
176
+ /** Get platform configuration */
177
+ function getPlatformConfig(platform) {
178
+ return PLATFORMS[platform];
179
+ }
73
180
 
74
181
  //#endregion
75
182
  //#region src/platform/utils.ts
@@ -117,25 +224,56 @@ function toUint8Array(payload) {
117
224
  }
118
225
 
119
226
  //#endregion
120
- //#region src/preset/schema.ts
227
+ //#region src/schemas/common.ts
121
228
  const PLATFORM_ID_SET = new Set(PLATFORM_IDS);
122
- const VERSION_REGEX = /^[1-9]\d*\.\d+$/;
123
- const platformIdSchema = z.enum(PLATFORM_IDS);
124
- const titleSchema = z.string().trim().min(1, "Title is required").max(80, "Title must be 80 characters or less");
125
- const descriptionSchema = z.string().trim().min(1, "Description is required").max(500, "Description must be 500 characters or less");
126
- const versionSchema = z.string().trim().regex(VERSION_REGEX, "Version must be in MAJOR.MINOR format (e.g., 1.3)");
127
- const majorVersionSchema = z.number().int().positive("Major version must be a positive integer");
128
229
  const TAG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
129
230
  const TAG_ERROR = "Must be lowercase alphanumeric with hyphens (e.g., my-tag)";
231
+ /**
232
+ * Schema for a single tag.
233
+ * - Max 35 characters
234
+ * - Lowercase alphanumeric with hyphens
235
+ * - Platform names blocked (redundant with platform field)
236
+ */
130
237
  const tagSchema = z.string().trim().min(1, "Tag cannot be empty").max(35, "Tag must be 35 characters or less").regex(TAG_REGEX, TAG_ERROR).refine((tag) => !PLATFORM_ID_SET.has(tag), { message: "Platform names cannot be used as tags (redundant with platform field)" });
238
+ /**
239
+ * Schema for tags array.
240
+ * - 1-10 tags required
241
+ */
131
242
  const tagsSchema = z.array(tagSchema).min(1, "At least one tag is required").max(10, "Maximum 10 tags allowed");
243
+ const NAME_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
244
+ const NAME_ERROR = "Must be lowercase alphanumeric with hyphens (e.g., my-preset)";
245
+ /**
246
+ * Schema for preset/rule name.
247
+ * - Max 64 characters
248
+ * - Lowercase alphanumeric with hyphens
249
+ */
250
+ const nameSchema = z.string().trim().min(1, "Name is required").max(64, "Name must be 64 characters or less").regex(NAME_REGEX, NAME_ERROR);
251
+ /**
252
+ * Schema for display title.
253
+ * - Max 80 characters
254
+ */
255
+ const titleSchema = z.string().trim().min(1, "Title is required").max(80, "Title must be 80 characters or less");
256
+ /**
257
+ * Schema for description.
258
+ * - Max 500 characters
259
+ */
260
+ const descriptionSchema = z.string().trim().max(500, "Description must be 500 characters or less");
261
+
262
+ //#endregion
263
+ //#region src/preset/schema.ts
264
+ const VERSION_REGEX$1 = /^[1-9]\d*\.\d+$/;
265
+ const platformIdSchema = z.enum(PLATFORM_IDS);
266
+ /**
267
+ * Schema for required description (presets require a description).
268
+ * Extends base descriptionSchema with min(1) constraint.
269
+ */
270
+ const requiredDescriptionSchema = descriptionSchema.min(1, "Description is required");
271
+ const versionSchema = z.string().trim().regex(VERSION_REGEX$1, "Version must be in MAJOR.MINOR format (e.g., 1.3)");
272
+ const majorVersionSchema = z.number().int().positive("Major version must be a positive integer");
132
273
  const featureSchema = z.string().trim().min(1, "Feature cannot be empty").max(100, "Feature must be 100 characters or less");
133
274
  const featuresSchema = z.array(featureSchema).max(5, "Maximum 5 features allowed");
134
275
  const installMessageSchema = z.string().trim().max(2e3, "Install message must be 2000 characters or less");
135
276
  const contentSchema = z.string();
136
- const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
137
- const SLUG_ERROR = "Must be lowercase alphanumeric with hyphens (e.g., my-preset)";
138
- const slugSchema = z.string().trim().min(1, "Name is required").max(64, "Name must be 64 characters or less").regex(SLUG_REGEX, SLUG_ERROR);
139
277
  const COMMON_LICENSES = [
140
278
  "MIT",
141
279
  "Apache-2.0",
@@ -148,60 +286,101 @@ const licenseSchema = z.string().trim().min(1, "License is required").max(128, "
148
286
  const pathSchema = z.string().trim().min(1);
149
287
  const ignorePatternSchema = z.string().trim().min(1, "Ignore pattern cannot be empty");
150
288
  const ignoreSchema = z.array(ignorePatternSchema).max(50, "Maximum 50 ignore patterns allowed");
289
+ /**
290
+ * Schema for agentrulesDir - directory containing metadata files (README, LICENSE, INSTALL).
291
+ * Defaults to ".agentrules" if not specified.
292
+ * Use "." to read metadata from the project root.
293
+ */
294
+ const agentrulesPathSchema = z.string().trim().min(1, "agentrulesDir cannot be empty");
295
+ /**
296
+ * Platform entry - either a platform ID string or an object with optional path.
297
+ *
298
+ * Examples:
299
+ * - "opencode" (shorthand, uses default directory)
300
+ * - { platform: "opencode", path: "rules" } (custom path)
301
+ */
302
+ const platformEntryObjectSchema = z.object({
303
+ platform: platformIdSchema,
304
+ path: pathSchema.optional()
305
+ }).strict();
306
+ const platformEntrySchema = z.union([platformIdSchema, platformEntryObjectSchema]);
307
+ /**
308
+ * Preset config schema.
309
+ *
310
+ * Uses a unified `platforms` array that accepts either:
311
+ * - Platform ID strings: `["opencode", "claude"]`
312
+ * - Objects with optional path: `[{ platform: "opencode", path: "rules" }]`
313
+ * - Mixed: `["opencode", { platform: "claude", path: "my-claude" }]`
314
+ */
151
315
  const presetConfigSchema = z.object({
152
316
  $schema: z.string().optional(),
153
- name: slugSchema,
317
+ name: nameSchema,
154
318
  title: titleSchema,
155
319
  version: majorVersionSchema.optional(),
156
- description: descriptionSchema,
320
+ description: requiredDescriptionSchema,
157
321
  tags: tagsSchema,
158
322
  features: featuresSchema.optional(),
159
323
  license: licenseSchema,
160
- platform: platformIdSchema,
161
- path: pathSchema.optional(),
162
- ignore: ignoreSchema.optional()
324
+ ignore: ignoreSchema.optional(),
325
+ agentrulesDir: agentrulesPathSchema.optional(),
326
+ platforms: z.array(platformEntrySchema).min(1, "At least one platform is required")
163
327
  }).strict();
164
328
  const bundledFileSchema = z.object({
165
329
  path: z.string().min(1),
166
330
  size: z.number().int().nonnegative(),
167
331
  checksum: z.string().length(64),
168
- contents: z.string()
332
+ content: z.string()
169
333
  });
170
334
  /**
171
- * Schema for what clients send to publish a preset.
335
+ * Schema for per-platform variant in publish input.
336
+ */
337
+ const publishVariantInputSchema = z.object({
338
+ platform: platformIdSchema,
339
+ files: z.array(bundledFileSchema).min(1),
340
+ readmeContent: contentSchema.optional(),
341
+ licenseContent: contentSchema.optional(),
342
+ installMessage: installMessageSchema.optional()
343
+ });
344
+ /**
345
+ * Schema for what clients send to publish a preset (multi-platform).
346
+ *
347
+ * One publish call creates ONE version with ALL platform variants.
172
348
  * Version is optional major version. Registry assigns full MAJOR.MINOR.
349
+ *
350
+ * Note: Clients send `name` (e.g., "my-preset"), and the registry defines the format of the slug.
351
+ * For example, a namespaced slug could be returned as "username/my-preset"
173
352
  */
174
353
  const presetPublishInputSchema = z.object({
175
- slug: z.string().trim().min(1),
176
- platform: platformIdSchema,
354
+ name: nameSchema,
177
355
  title: titleSchema,
178
- description: descriptionSchema,
356
+ description: requiredDescriptionSchema,
179
357
  tags: tagsSchema,
180
358
  license: licenseSchema,
181
- licenseContent: contentSchema.optional(),
182
- readmeContent: contentSchema.optional(),
183
359
  features: featuresSchema.optional(),
184
- installMessage: installMessageSchema.optional(),
185
- files: z.array(bundledFileSchema).min(1),
360
+ variants: z.array(publishVariantInputSchema).min(1, "At least one platform variant is required"),
186
361
  version: majorVersionSchema.optional()
187
362
  });
188
363
  /**
189
364
  * Schema for what registries store and return.
190
- * Includes version (required) - full MAJOR.MINOR format assigned by registry.
191
- */
192
- const presetBundleSchema = presetPublishInputSchema.omit({ version: true }).extend({ version: versionSchema });
193
- const presetSchema = presetBundleSchema.omit({
194
- files: true,
195
- readmeContent: true,
196
- licenseContent: true,
197
- installMessage: true
365
+ * Includes full namespaced slug and version assigned by registry.
366
+ */
367
+ const presetBundleSchema = presetPublishInputSchema.omit({
368
+ name: true,
369
+ version: true
198
370
  }).extend({
199
- name: z.string().trim().min(1),
200
- bundleUrl: z.string().trim().min(1),
201
- fileCount: z.number().int().nonnegative(),
202
- totalSize: z.number().int().nonnegative()
371
+ slug: z.string().trim().min(1),
372
+ version: versionSchema
203
373
  });
204
- const presetIndexSchema = z.record(z.string(), presetSchema);
374
+
375
+ //#endregion
376
+ //#region src/preset/types.ts
377
+ /**
378
+ * Normalize a raw platform entry to the object form.
379
+ */
380
+ function normalizePlatformEntry(entry) {
381
+ if (typeof entry === "string") return { platform: entry };
382
+ return entry;
383
+ }
205
384
 
206
385
  //#endregion
207
386
  //#region src/builder/utils.ts
@@ -210,9 +389,10 @@ function cleanInstallMessage(value) {
210
389
  const trimmed = value.trim();
211
390
  return trimmed.length > 0 ? trimmed : void 0;
212
391
  }
213
- function encodeItemName(slug, platform) {
214
- return `${slug}.${platform}`;
215
- }
392
+ /**
393
+ * Validate raw preset config from JSON.
394
+ * Returns the raw config shape (before normalization).
395
+ */
216
396
  function validatePresetConfig(config, slug) {
217
397
  try {
218
398
  return presetConfigSchema.parse(config);
@@ -235,7 +415,7 @@ const NAME_PATTERN = /^[a-z0-9-]+$/;
235
415
  * Directory name for bundle files in static registry output.
236
416
  * Used by `agentrules registry build` to structure output.
237
417
  */
238
- const STATIC_BUNDLE_DIR = "r";
418
+ const STATIC_BUNDLE_DIR = "registry";
239
419
  /**
240
420
  * Compute SHA-256 hash using Web Crypto API (works in browser and Node.js 15+)
241
421
  */
@@ -246,121 +426,128 @@ async function sha256(data) {
246
426
  }
247
427
  /**
248
428
  * Builds a PresetPublishInput from preset input.
249
- * Used by CLI to prepare data for publishing to a registry.
429
+ *
430
+ * PresetInput always has platformFiles array (unified format).
250
431
  */
251
432
  async function buildPresetPublishInput(options) {
252
- const { preset: presetInput, version } = options;
253
- if (!NAME_PATTERN.test(presetInput.slug)) throw new Error(`Invalid slug "${presetInput.slug}". Slugs must be lowercase kebab-case.`);
254
- const presetConfig = validatePresetConfig(presetInput.config, presetInput.slug);
255
- const platform = presetConfig.platform;
256
- ensureKnownPlatform(platform, presetInput.slug);
257
- if (presetInput.files.length === 0) throw new Error(`Preset ${presetInput.slug} does not include any files.`);
258
- const files = await createBundledFilesFromInputs(presetInput.files);
259
- const installMessage = cleanInstallMessage(presetInput.installMessage);
260
- const features = presetConfig.features ?? [];
261
- const readmeContent = presetInput.readmeContent?.trim() || void 0;
262
- const licenseContent = presetInput.licenseContent?.trim() || void 0;
263
- const majorVersion = version ?? presetConfig.version;
433
+ const { preset, version } = options;
434
+ if (!NAME_PATTERN.test(preset.name)) throw new Error(`Invalid name "${preset.name}". Names must be lowercase kebab-case.`);
435
+ const config = validatePresetConfig(preset.config, preset.name);
436
+ const majorVersion = version ?? config.version;
437
+ const platforms = preset.config.platforms;
438
+ if (platforms.length === 0) throw new Error(`Preset ${preset.name} must specify at least one platform.`);
439
+ for (const entry of platforms) ensureKnownPlatform(entry.platform, preset.name);
440
+ const variants = [];
441
+ for (const entry of platforms) {
442
+ const platformData = preset.platformFiles.find((pf) => pf.platform === entry.platform);
443
+ if (!platformData) throw new Error(`Preset ${preset.name} is missing files for platform "${entry.platform}".`);
444
+ if (platformData.files.length === 0) throw new Error(`Preset ${preset.name} has no files for platform "${entry.platform}".`);
445
+ const files = await createBundledFilesFromInputs(platformData.files);
446
+ variants.push({
447
+ platform: entry.platform,
448
+ files,
449
+ readmeContent: platformData.readmeContent?.trim() || preset.readmeContent?.trim() || void 0,
450
+ licenseContent: platformData.licenseContent?.trim() || preset.licenseContent?.trim() || void 0,
451
+ installMessage: cleanInstallMessage(platformData.installMessage) || cleanInstallMessage(preset.installMessage)
452
+ });
453
+ }
264
454
  return {
265
- slug: presetInput.slug,
266
- platform,
267
- title: presetConfig.title,
268
- description: presetConfig.description,
269
- tags: presetConfig.tags ?? [],
270
- license: presetConfig.license,
271
- licenseContent,
272
- readmeContent,
273
- features,
274
- installMessage,
275
- files,
455
+ name: preset.name,
456
+ title: config.title,
457
+ description: config.description,
458
+ tags: config.tags ?? [],
459
+ license: config.license,
460
+ features: config.features ?? [],
461
+ variants,
276
462
  ...majorVersion !== void 0 && { version: majorVersion }
277
463
  };
278
464
  }
279
465
  /**
280
- * Builds a static registry with entries, index, and bundles.
281
- * Used for building static registry files (e.g., community-presets).
282
- * Each preset uses its version from config (default: major 1, minor 0).
466
+ * Builds a static registry with items and bundles.
467
+ *
468
+ * Uses the same model as dynamic publishing:
469
+ * - Each PresetInput (single or multi-platform) becomes one item
470
+ * - Each platform variant becomes one bundle
283
471
  */
284
472
  async function buildPresetRegistry(options) {
285
473
  const bundleBase = normalizeBundleBase(options.bundleBase);
286
- const entries = [];
474
+ const items = [];
287
475
  const bundles = [];
288
476
  for (const presetInput of options.presets) {
289
- if (!NAME_PATTERN.test(presetInput.slug)) throw new Error(`Invalid slug "${presetInput.slug}". Slugs must be lowercase kebab-case.`);
290
- const presetConfig = validatePresetConfig(presetInput.config, presetInput.slug);
291
- const platform = presetConfig.platform;
292
- ensureKnownPlatform(platform, presetInput.slug);
293
- if (presetInput.files.length === 0) throw new Error(`Preset ${presetInput.slug} does not include any files.`);
294
- const files = await createBundledFilesFromInputs(presetInput.files);
295
- const totalSize = files.reduce((sum, file) => sum + file.size, 0);
296
- const installMessage = cleanInstallMessage(presetInput.installMessage);
297
- const features = presetConfig.features ?? [];
298
- const readmeContent = presetInput.readmeContent?.trim() || void 0;
299
- const licenseContent = presetInput.licenseContent?.trim() || void 0;
300
- const majorVersion = presetConfig.version ?? 1;
301
- const version = `${majorVersion}.0`;
302
- const entry = {
303
- name: encodeItemName(presetInput.slug, platform),
304
- slug: presetInput.slug,
305
- platform,
306
- title: presetConfig.title,
307
- version,
308
- description: presetConfig.description,
309
- tags: presetConfig.tags ?? [],
310
- license: presetConfig.license,
311
- features,
312
- bundleUrl: getBundlePath(bundleBase, presetInput.slug, platform, version),
313
- fileCount: files.length,
314
- totalSize
315
- };
316
- entries.push(entry);
317
- const bundle = {
318
- slug: presetInput.slug,
319
- platform,
320
- title: presetConfig.title,
321
- version,
322
- description: presetConfig.description,
323
- tags: presetConfig.tags ?? [],
324
- license: presetConfig.license,
325
- licenseContent,
326
- readmeContent,
327
- features,
328
- installMessage,
329
- files
477
+ const publishInput = await buildPresetPublishInput({ preset: presetInput });
478
+ const slug = publishInput.name;
479
+ const version = `${publishInput.version ?? 1}.0`;
480
+ const presetVariants = publishInput.variants.map((v) => ({
481
+ platform: v.platform,
482
+ bundleUrl: getBundlePath(bundleBase, slug, v.platform, version),
483
+ fileCount: v.files.length,
484
+ totalSize: v.files.reduce((sum, f) => sum + f.content.length, 0)
485
+ }));
486
+ presetVariants.sort((a, b) => a.platform.localeCompare(b.platform));
487
+ const item = {
488
+ kind: "preset",
489
+ slug,
490
+ name: publishInput.title,
491
+ title: publishInput.title,
492
+ description: publishInput.description,
493
+ tags: publishInput.tags,
494
+ license: publishInput.license,
495
+ features: publishInput.features ?? [],
496
+ versions: [{
497
+ version,
498
+ isLatest: true,
499
+ variants: presetVariants
500
+ }]
330
501
  };
331
- bundles.push(bundle);
502
+ items.push(item);
503
+ for (const variant of publishInput.variants) {
504
+ const bundle = {
505
+ name: publishInput.name,
506
+ slug,
507
+ platform: variant.platform,
508
+ title: publishInput.title,
509
+ version,
510
+ description: publishInput.description,
511
+ tags: publishInput.tags,
512
+ license: publishInput.license,
513
+ features: publishInput.features,
514
+ files: variant.files,
515
+ readmeContent: variant.readmeContent,
516
+ licenseContent: variant.licenseContent,
517
+ installMessage: variant.installMessage
518
+ };
519
+ bundles.push(bundle);
520
+ }
332
521
  }
333
- sortBySlugAndPlatform(entries);
334
- sortBySlugAndPlatform(bundles);
335
- const index = entries.reduce((acc, entry) => {
336
- acc[entry.name] = entry;
337
- return acc;
338
- }, {});
522
+ items.sort((a, b) => a.slug.localeCompare(b.slug));
523
+ bundles.sort((a, b) => {
524
+ if (a.slug === b.slug) return a.platform.localeCompare(b.platform);
525
+ return a.slug.localeCompare(b.slug);
526
+ });
339
527
  return {
340
- entries,
341
- index,
528
+ items,
342
529
  bundles
343
530
  };
344
531
  }
345
532
  async function createBundledFilesFromInputs(files) {
346
533
  const results = await Promise.all(files.map(async (file) => {
347
- const payload = normalizeFilePayload(file.contents);
348
- const contents = encodeFilePayload(payload, file.path);
534
+ const payload = normalizeFilePayload(file.content);
535
+ const content = encodeFilePayload(payload, file.path);
349
536
  const checksum = await sha256(payload);
350
537
  return {
351
538
  path: toPosixPath(file.path),
352
539
  size: payload.length,
353
540
  checksum,
354
- contents
541
+ content
355
542
  };
356
543
  }));
357
544
  return results.sort((a, b) => a.path.localeCompare(b.path));
358
545
  }
359
- function normalizeFilePayload(contents) {
360
- if (typeof contents === "string") return new TextEncoder().encode(contents);
361
- if (contents instanceof ArrayBuffer) return new Uint8Array(contents);
362
- if (ArrayBuffer.isView(contents)) return new Uint8Array(contents.buffer, contents.byteOffset, contents.byteLength);
363
- return new Uint8Array(contents);
546
+ function normalizeFilePayload(content) {
547
+ if (typeof content === "string") return new TextEncoder().encode(content);
548
+ if (content instanceof ArrayBuffer) return new Uint8Array(content);
549
+ if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
550
+ return new Uint8Array(content);
364
551
  }
365
552
  function encodeFilePayload(data, filePath) {
366
553
  const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -370,36 +557,22 @@ function encodeFilePayload(data, filePath) {
370
557
  throw new Error(`Binary files are not supported: "${filePath}". Only UTF-8 text files are allowed.`);
371
558
  }
372
559
  }
373
- /**
374
- * Normalize bundle base by removing trailing slashes.
375
- * Returns empty string if base is undefined/empty (use default relative path).
376
- */
377
560
  function normalizeBundleBase(base) {
378
561
  if (!base) return "";
379
562
  return base.replace(/\/+$/, "");
380
563
  }
381
- /**
382
- * Returns the bundle URL/path for a preset.
383
- * Format: {base}/{STATIC_BUNDLE_DIR}/{slug}/{platform}/{version}
384
- */
385
- function getBundlePath(base, slug, platform, version = LATEST_VERSION) {
564
+ function getBundlePath(base, slug, platform, version) {
386
565
  const prefix = base ? `${base}/` : "";
387
566
  return `${prefix}${STATIC_BUNDLE_DIR}/${slug}/${platform}/${version}`;
388
567
  }
389
568
  function ensureKnownPlatform(platform, slug) {
390
569
  if (!isSupportedPlatform(platform)) throw new Error(`Unknown platform "${platform}" in ${slug}. Supported: ${PLATFORM_IDS.join(", ")}`);
391
570
  }
392
- function sortBySlugAndPlatform(items) {
393
- items.sort((a, b) => {
394
- if (a.slug === b.slug) return a.platform.localeCompare(b.platform);
395
- return a.slug.localeCompare(b.slug);
396
- });
397
- }
398
571
 
399
572
  //#endregion
400
573
  //#region src/client/bundle.ts
401
574
  function decodeBundledFile(file) {
402
- return encodeUtf8(file.contents);
575
+ return encodeUtf8(file.content);
403
576
  }
404
577
  async function verifyBundledFileChecksum(file, payload) {
405
578
  const bytes = toUint8Array(payload);
@@ -427,39 +600,48 @@ async function sha256Hex(payload) {
427
600
  }
428
601
 
429
602
  //#endregion
430
- //#region src/client/registry.ts
603
+ //#region src/constants.ts
604
+ /**
605
+ * Shared constants for agentrules presets and registry.
606
+ */
607
+ /** Filename for preset configuration */
608
+ const PRESET_CONFIG_FILENAME = "agentrules.json";
609
+ /** Directory name for preset metadata (README, LICENSE, etc.) */
610
+ const AGENT_RULES_DIR = ".agentrules";
611
+ /** JSON Schema URL for preset configuration */
612
+ const PRESET_SCHEMA_URL = "https://agentrules.directory/schema/agentrules.json";
613
+ /** API root path segment */
614
+ const API_PATH = "api";
615
+ /** Default version identifier for latest preset version */
616
+ const LATEST_VERSION = "latest";
431
617
  /**
432
- * Resolves a preset from the registry via API endpoint.
433
- * Returns the entry metadata and the absolute bundle URL.
618
+ * API endpoint paths (relative to registry base URL).
434
619
  *
435
- * @param baseUrl - Registry base URL
436
- * @param slug - Preset slug
437
- * @param platform - Target platform
438
- * @param version - Version to resolve (defaults to "latest")
620
+ * Note on slug handling:
621
+ * - Slugs may contain slashes (e.g., "username/my-preset") which flow through as path segments
622
+ * - The client is responsible for validating values before making requests
439
623
  */
440
- async function resolvePreset(baseUrl, slug, platform, version = LATEST_VERSION) {
441
- const apiUrl = new URL(API_ENDPOINTS.presets.get(slug, platform, version), baseUrl);
442
- const response = await fetch(apiUrl);
443
- if (!response.ok) {
444
- if (response.status === 404) {
445
- const versionInfo = version === LATEST_VERSION ? "" : ` version "${version}"`;
446
- throw new Error(`Preset "${slug}"${versionInfo} for platform "${platform}" was not found in the registry.`);
447
- }
448
- throw new Error(`Failed to resolve preset (${response.status} ${response.statusText}).`);
449
- }
450
- try {
451
- const preset = await response.json();
452
- const resolvedBundleUrl = new URL(preset.bundleUrl, baseUrl).toString();
453
- return {
454
- preset,
455
- bundleUrl: resolvedBundleUrl
456
- };
457
- } catch (error) {
458
- throw new Error(`Unable to parse preset response: ${error.message}`);
459
- }
460
- }
624
+ const API_ENDPOINTS = {
625
+ presets: {
626
+ base: `${API_PATH}/presets`,
627
+ unpublish: (slug, version) => `${API_PATH}/presets/${slug}/${version}`
628
+ },
629
+ auth: {
630
+ session: `${API_PATH}/auth/get-session`,
631
+ deviceCode: `${API_PATH}/auth/device/code`,
632
+ deviceToken: `${API_PATH}/auth/device/token`
633
+ },
634
+ rules: {
635
+ base: `${API_PATH}/rules`,
636
+ bySlug: (slug) => `${API_PATH}/rules/${slug}`
637
+ },
638
+ items: { get: (slug) => `${API_PATH}/items/${slug}` }
639
+ };
640
+
641
+ //#endregion
642
+ //#region src/client/registry.ts
461
643
  /**
462
- * Fetches a bundle from an absolute URL or resolves it relative to the registry.
644
+ * Fetches a bundle from an absolute URL.
463
645
  */
464
646
  async function fetchBundle(bundleUrl) {
465
647
  const response = await fetch(bundleUrl);
@@ -470,6 +652,232 @@ async function fetchBundle(bundleUrl) {
470
652
  throw new Error(`Unable to parse bundle JSON: ${error.message}`);
471
653
  }
472
654
  }
655
+ /**
656
+ * Resolves a slug to get all versions and platform variants.
657
+ *
658
+ * @param baseUrl - Registry base URL
659
+ * @param slug - Content slug (may contain slashes, e.g., "username/my-preset")
660
+ * @param version - Optional version filter (server may ignore for static registries)
661
+ * @returns Resolved data, or null if not found
662
+ * @throws Error on network/server errors
663
+ */
664
+ async function resolveSlug(baseUrl, slug, version) {
665
+ const url = new URL(API_ENDPOINTS.items.get(slug), baseUrl);
666
+ if (version) url.searchParams.set("version", version);
667
+ let response;
668
+ try {
669
+ response = await fetch(url);
670
+ } catch (error) {
671
+ throw new Error(`Failed to connect to registry: ${error.message}`);
672
+ }
673
+ if (response.status === 404) return null;
674
+ if (!response.ok) {
675
+ let errorMessage = `Registry returned ${response.status} ${response.statusText}`;
676
+ try {
677
+ const body = await response.json();
678
+ if (body && typeof body === "object" && "error" in body && typeof body.error === "string") errorMessage = body.error;
679
+ } catch {}
680
+ throw new Error(errorMessage);
681
+ }
682
+ const data = await response.json();
683
+ if (data.kind === "preset") {
684
+ for (const ver of data.versions) for (const variant of ver.variants) if ("bundleUrl" in variant) variant.bundleUrl = new URL(variant.bundleUrl, baseUrl).toString();
685
+ }
686
+ return data;
687
+ }
688
+
689
+ //#endregion
690
+ //#region src/resolve/schema.ts
691
+ const VERSION_REGEX = /^[1-9]\d*\.\d+$/;
692
+ const presetVariantBundleSchema = z.object({
693
+ platform: platformIdSchema,
694
+ bundleUrl: z.string().min(1),
695
+ fileCount: z.number().int().nonnegative(),
696
+ totalSize: z.number().int().nonnegative()
697
+ });
698
+ const presetVariantInlineSchema = z.object({
699
+ platform: platformIdSchema,
700
+ content: z.string().min(1),
701
+ fileCount: z.number().int().nonnegative(),
702
+ totalSize: z.number().int().nonnegative()
703
+ });
704
+ const presetVariantSchema = z.union([presetVariantBundleSchema, presetVariantInlineSchema]);
705
+ const ruleVariantSchema = z.object({
706
+ platform: platformIdSchema,
707
+ type: z.string().min(1),
708
+ content: z.string().min(1)
709
+ });
710
+ const presetVersionSchema = z.object({
711
+ version: z.string().regex(VERSION_REGEX, "Version must be MAJOR.MINOR format"),
712
+ isLatest: z.boolean(),
713
+ publishedAt: z.string().datetime().optional(),
714
+ variants: z.array(presetVariantSchema).min(1)
715
+ });
716
+ const ruleVersionSchema = z.object({
717
+ version: z.string().regex(VERSION_REGEX, "Version must be MAJOR.MINOR format"),
718
+ isLatest: z.boolean(),
719
+ publishedAt: z.string().datetime().optional(),
720
+ variants: z.array(ruleVariantSchema).min(1)
721
+ });
722
+ const resolvedPresetSchema = z.object({
723
+ kind: z.literal("preset"),
724
+ slug: z.string().min(1),
725
+ name: z.string().min(1),
726
+ title: z.string().min(1),
727
+ description: z.string(),
728
+ tags: z.array(z.string()),
729
+ license: z.string(),
730
+ features: z.array(z.string()),
731
+ versions: z.array(presetVersionSchema).min(1)
732
+ });
733
+ const resolvedRuleSchema = z.object({
734
+ kind: z.literal("rule"),
735
+ slug: z.string().min(1),
736
+ name: z.string().min(1),
737
+ title: z.string().min(1),
738
+ description: z.string(),
739
+ tags: z.array(z.string()),
740
+ versions: z.array(ruleVersionSchema).min(1)
741
+ });
742
+ const resolveResponseSchema = z.discriminatedUnion("kind", [resolvedPresetSchema, resolvedRuleSchema]);
743
+
744
+ //#endregion
745
+ //#region src/resolve/utils.ts
746
+ /**
747
+ * Type guard for preset
748
+ */
749
+ function isPreset(item) {
750
+ return item.kind === "preset";
751
+ }
752
+ /**
753
+ * Type guard for rule
754
+ */
755
+ function isRule(item) {
756
+ return item.kind === "rule";
757
+ }
758
+ /**
759
+ * Type guard for preset variant with bundleUrl
760
+ */
761
+ function hasBundle(variant) {
762
+ return "bundleUrl" in variant;
763
+ }
764
+ /**
765
+ * Type guard for preset variant with inline content
766
+ */
767
+ function hasInlineContent(variant) {
768
+ return "content" in variant;
769
+ }
770
+ /**
771
+ * Get the latest version from a resolved preset
772
+ */
773
+ function getLatestPresetVersion(item) {
774
+ return item.versions.find((v) => v.isLatest);
775
+ }
776
+ /**
777
+ * Get the latest version from a resolved rule
778
+ */
779
+ function getLatestRuleVersion(item) {
780
+ return item.versions.find((v) => v.isLatest);
781
+ }
782
+ /**
783
+ * Get a specific version from a resolved preset
784
+ */
785
+ function getPresetVersion(item, version) {
786
+ return item.versions.find((v) => v.version === version);
787
+ }
788
+ /**
789
+ * Get a specific version from a resolved rule
790
+ */
791
+ function getRuleVersion(item, version) {
792
+ return item.versions.find((v) => v.version === version);
793
+ }
794
+ /**
795
+ * Get a specific platform variant from a preset version
796
+ */
797
+ function getPresetVariant(version, platform) {
798
+ return version.variants.find((v) => v.platform === platform);
799
+ }
800
+ /**
801
+ * Get a specific platform variant from a rule version
802
+ */
803
+ function getRuleVariant(version, platform) {
804
+ return version.variants.find((v) => v.platform === platform);
805
+ }
806
+ /**
807
+ * Get all available platforms for a preset version
808
+ */
809
+ function getPresetPlatforms(version) {
810
+ return version.variants.map((v) => v.platform);
811
+ }
812
+ /**
813
+ * Get all available platforms for a rule version
814
+ */
815
+ function getRulePlatforms(version) {
816
+ return version.variants.map((v) => v.platform);
817
+ }
818
+ /**
819
+ * Check if a platform is available in any version of a preset
820
+ */
821
+ function presetHasPlatform(item, platform) {
822
+ return item.versions.some((v) => v.variants.some((variant) => variant.platform === platform));
823
+ }
824
+ /**
825
+ * Check if a platform is available in any version of a rule
826
+ */
827
+ function ruleHasPlatform(item, platform) {
828
+ return item.versions.some((v) => v.variants.some((variant) => variant.platform === platform));
829
+ }
830
+
831
+ //#endregion
832
+ //#region src/rule/schema.ts
833
+ /**
834
+ * Rule-specific schema aliases.
835
+ * All use shared schemas for consistency with presets:
836
+ * - name: max 64 chars, lowercase kebab-case
837
+ * - title: max 80 chars
838
+ * - description: max 500 chars (optional for rules)
839
+ * - tags: max 35 chars each, 1-10 required, platform names blocked
840
+ */
841
+ const ruleNameSchema = nameSchema;
842
+ const ruleTitleSchema = titleSchema;
843
+ const ruleDescriptionSchema = descriptionSchema;
844
+ const ruleTagSchema = tagSchema;
845
+ const ruleTagsSchema = tagsSchema;
846
+ const rulePlatformSchema = z.enum(PLATFORM_IDS);
847
+ const ruleTypeSchema = z.string().trim().min(1).max(32);
848
+ const ruleContentSchema = z.string().min(1, "Content is required").max(1e5, "Content must be 100KB or less");
849
+ /** Common fields shared across all platform-type combinations */
850
+ const ruleCommonFields = {
851
+ name: ruleNameSchema,
852
+ title: ruleTitleSchema,
853
+ description: ruleDescriptionSchema.optional(),
854
+ content: ruleContentSchema,
855
+ tags: ruleTagsSchema
856
+ };
857
+ /**
858
+ * Discriminated union schema for platform + type combinations.
859
+ * Each platform has its own set of valid types.
860
+ */
861
+ const rulePlatformTypeSchema = z.discriminatedUnion("platform", [
862
+ z.object({
863
+ platform: z.literal("opencode"),
864
+ type: z.enum(PLATFORM_RULE_TYPES.opencode)
865
+ }),
866
+ z.object({
867
+ platform: z.literal("claude"),
868
+ type: z.enum(PLATFORM_RULE_TYPES.claude)
869
+ }),
870
+ z.object({
871
+ platform: z.literal("cursor"),
872
+ type: z.enum(PLATFORM_RULE_TYPES.cursor)
873
+ }),
874
+ z.object({
875
+ platform: z.literal("codex"),
876
+ type: z.enum(PLATFORM_RULE_TYPES.codex)
877
+ })
878
+ ]);
879
+ /** Schema for rule creation with discriminated union for platform+type */
880
+ const ruleCreateInputSchema = z.object(ruleCommonFields).and(rulePlatformTypeSchema);
473
881
 
474
882
  //#endregion
475
883
  //#region src/utils/diff.ts
@@ -495,4 +903,4 @@ function normalizeBundlePath(value) {
495
903
  }
496
904
 
497
905
  //#endregion
498
- export { AGENT_RULES_DIR, API_ENDPOINTS, COMMON_LICENSES, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PLATFORM_ID_TUPLE, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, bundledFileSchema, cleanInstallMessage, createDiffPreview, decodeBundledFile, decodeUtf8, descriptionSchema, encodeItemName, encodeUtf8, fetchBundle, getPlatformFromDir, isLikelyText, isPlatformDir, isSupportedPlatform, licenseSchema, normalizeBundlePath, normalizePlatformInput, platformIdSchema, presetBundleSchema, presetConfigSchema, presetIndexSchema, presetPublishInputSchema, presetSchema, resolvePreset, slugSchema, tagSchema, tagsSchema, titleSchema, toPosixPath, toUint8Array, toUtf8String, validatePresetConfig, verifyBundledFileChecksum };
906
+ export { AGENT_RULES_DIR, API_ENDPOINTS, COMMON_LICENSES, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PLATFORM_ID_TUPLE, PLATFORM_RULE_TYPES, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, bundledFileSchema, cleanInstallMessage, createDiffPreview, decodeBundledFile, decodeUtf8, descriptionSchema, encodeUtf8, fetchBundle, getInstallPath, getLatestPresetVersion, getLatestRuleVersion, getPlatformConfig, getPlatformFromDir, getPresetPlatforms, getPresetVariant, getPresetVersion, getRulePlatforms, getRuleTypeConfig, getRuleVariant, getRuleVersion, getValidRuleTypes, hasBundle, hasInlineContent, isLikelyText, isPlatformDir, isPreset, isRule, isSupportedPlatform, isValidRuleType, licenseSchema, nameSchema, normalizeBundlePath, normalizePlatformEntry, normalizePlatformInput, platformIdSchema, presetBundleSchema, presetConfigSchema, presetHasPlatform, presetPublishInputSchema, presetVariantSchema, presetVersionSchema, publishVariantInputSchema, requiredDescriptionSchema, resolveResponseSchema, resolveSlug, resolvedPresetSchema, resolvedRuleSchema, ruleContentSchema, ruleCreateInputSchema, ruleDescriptionSchema, ruleHasPlatform, ruleNameSchema, rulePlatformSchema, rulePlatformTypeSchema, ruleTagSchema, ruleTagsSchema, ruleTitleSchema, ruleTypeSchema, ruleVariantSchema, ruleVersionSchema, tagSchema, tagsSchema, titleSchema, toPosixPath, toUint8Array, toUtf8String, validatePresetConfig, verifyBundledFileChecksum };