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