@agentrules/cli 0.0.13 → 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.
Files changed (3) hide show
  1. package/README.md +3 -6
  2. package/dist/index.js +1063 -639
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "module";
3
- import { AGENT_RULES_DIR, API_ENDPOINTS, COMMON_LICENSES, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, createDiffPreview, decodeBundledFile, descriptionSchema, fetchBundle, getPlatformFromDir, isLikelyText, isPlatformDir, isSupportedPlatform, licenseSchema, normalizeBundlePath, normalizePlatformInput, resolvePreset, slugSchema, tagsSchema, titleSchema, toUtf8String, validatePresetConfig, verifyBundledFileChecksum } from "@agentrules/core";
3
+ import { AGENT_RULES_DIR, API_ENDPOINTS, COMMON_LICENSES, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, createDiffPreview, decodeBundledFile, descriptionSchema, fetchBundle, getInstallPath, getLatestPresetVersion, getLatestRuleVersion, getPlatformFromDir, getPresetVariant, getPresetVersion, getRuleVariant, getRuleVersion, getValidRuleTypes, hasBundle, isLikelyText, isPlatformDir, isSupportedPlatform, licenseSchema, nameSchema, normalizeBundlePath, normalizePlatformEntry, normalizePlatformInput, resolveSlug, tagsSchema, titleSchema, toUtf8String, validatePresetConfig, verifyBundledFileChecksum } from "@agentrules/core";
4
4
  import { Command } from "commander";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "path";
6
- import { exec } from "child_process";
7
- import { promisify } from "util";
8
- import * as client from "openid-client";
9
6
  import chalk from "chalk";
10
- import { chmod, constants } from "fs";
11
- import { access, constants as constants$1, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
7
+ import { access, constants, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
12
8
  import { homedir } from "os";
9
+ import { chmod, constants as constants$1 } from "fs";
10
+ import * as client from "openid-client";
11
+ import { promisify } from "util";
12
+ import { exec } from "child_process";
13
13
  import * as p from "@clack/prompts";
14
14
 
15
15
  //#region src/lib/ui.ts
@@ -317,10 +317,16 @@ function relativeTime(date) {
317
317
  }
318
318
  /**
319
319
  * Formats an array of files as a tree structure
320
+ *
321
+ * By default shows folder-level sizes only. Pass `showFileSizes: true` to also show
322
+ * individual file sizes.
320
323
  */
321
- function fileTree(files) {
324
+ function fileTree(files, options) {
325
+ const { header: headerTitle, showFileSizes = false, showFolderSizes = false } = options;
322
326
  const root = {
323
327
  name: "",
328
+ totalSize: 0,
329
+ isFile: false,
324
330
  children: new Map()
325
331
  };
326
332
  for (const file of files) {
@@ -333,7 +339,9 @@ function fileTree(files) {
333
339
  if (!child) {
334
340
  child = {
335
341
  name: part,
336
- size: isFile ? file.size : void 0,
342
+ fileSize: isFile ? file.size : void 0,
343
+ totalSize: 0,
344
+ isFile,
337
345
  children: new Map()
338
346
  };
339
347
  current.children.set(part, child);
@@ -341,11 +349,22 @@ function fileTree(files) {
341
349
  current = child;
342
350
  }
343
351
  }
352
+ function calculateSizes(node) {
353
+ if (node.isFile) node.totalSize = node.fileSize ?? 0;
354
+ else node.totalSize = Array.from(node.children.values()).reduce((sum, child) => sum + calculateSizes(child), 0);
355
+ return node.totalSize;
356
+ }
357
+ calculateSizes(root);
344
358
  const lines = [];
359
+ const countStr = theme.muted(`(${files.length})`);
360
+ const sizeStr = showFolderSizes ? ` ${theme.info(`(${formatBytes$1(root.totalSize)} total)`)}` : "";
361
+ lines.push(`${theme.title(headerTitle)} ${countStr}${sizeStr}`);
345
362
  function renderNode(node, prefix, isLast) {
346
363
  const connector = isLast ? "└── " : "├── ";
347
- const sizeStr = node.size !== void 0 ? muted(` (${formatBytes$1(node.size)})`) : "";
348
- lines.push(`${prefix}${connector}${node.name}${sizeStr}`);
364
+ let nodeSizeStr = "";
365
+ if (node.isFile && showFileSizes) nodeSizeStr = theme.info(` (${formatBytes$1(node.totalSize)})`);
366
+ else if (!node.isFile && showFolderSizes) nodeSizeStr = theme.info(` (${formatBytes$1(node.totalSize)})`);
367
+ lines.push(`${prefix}${connector}${node.name}${nodeSizeStr}`);
349
368
  const children = Array.from(node.children.values());
350
369
  const newPrefix = prefix + (isLast ? " " : "│ ");
351
370
  children.forEach((child, index) => {
@@ -516,6 +535,164 @@ const log = {
516
535
  spinner
517
536
  };
518
537
 
538
+ //#endregion
539
+ //#region src/lib/config.ts
540
+ /** Directory for CLI configuration and credentials (e.g., ~/.agentrules/) */
541
+ const CONFIG_DIRNAME = ".agentrules";
542
+ const CONFIG_FILENAME = "config.json";
543
+ const CONFIG_HOME_ENV = "AGENT_RULES_HOME";
544
+ const DEFAULT_REGISTRY_ALIAS = "main";
545
+ const DEFAULT_REGISTRY_URL = "https://agentrules.directory/";
546
+ const DEFAULT_CONFIG = {
547
+ defaultRegistry: DEFAULT_REGISTRY_ALIAS,
548
+ registries: { [DEFAULT_REGISTRY_ALIAS]: { url: DEFAULT_REGISTRY_URL } }
549
+ };
550
+ async function loadConfig$1() {
551
+ await ensureConfigDir();
552
+ const configPath = getConfigPath();
553
+ log.debug(`Loading config from ${configPath}`);
554
+ try {
555
+ await access(configPath, constants$1.F_OK);
556
+ } catch (error$2) {
557
+ if (isNodeError(error$2) && error$2.code === "ENOENT") {
558
+ log.debug("Config file not found, creating default config");
559
+ await writeDefaultConfig();
560
+ return structuredClone(DEFAULT_CONFIG);
561
+ }
562
+ throw error$2 instanceof Error ? error$2 : new Error(String(error$2));
563
+ }
564
+ const raw = await readFile(configPath, "utf8");
565
+ let parsed = {};
566
+ try {
567
+ parsed = JSON.parse(raw);
568
+ } catch (error$2) {
569
+ throw new Error(`Failed to parse ${configPath}: ${error$2.message}`);
570
+ }
571
+ return mergeWithDefaults(parsed);
572
+ }
573
+ async function saveConfig(config) {
574
+ await ensureConfigDir();
575
+ const configPath = getConfigPath();
576
+ log.debug(`Saving config to ${configPath}`);
577
+ const serialized = JSON.stringify(config, null, 2);
578
+ await writeFile(configPath, serialized, "utf8");
579
+ }
580
+ function getConfigPath() {
581
+ return join(getConfigDir(), CONFIG_FILENAME);
582
+ }
583
+ function getConfigDir() {
584
+ const customDir = process.env[CONFIG_HOME_ENV];
585
+ if (customDir && customDir.trim().length > 0) return customDir;
586
+ return join(homedir(), CONFIG_DIRNAME);
587
+ }
588
+ /**
589
+ * Normalizes a registry URL to a base URL with trailing slash.
590
+ *
591
+ * Examples:
592
+ * - "https://example.com" → "https://example.com/"
593
+ * - "https://example.com/custom/" → "https://example.com/custom/"
594
+ * - "https://example.com/custom" → "https://example.com/custom/"
595
+ */
596
+ function normalizeRegistryUrl(input) {
597
+ try {
598
+ const parsed = new URL(input);
599
+ if (!parsed.pathname.endsWith("/")) parsed.pathname = `${parsed.pathname}/`;
600
+ return parsed.toString();
601
+ } catch (error$2) {
602
+ throw new Error(`Invalid registry URL "${input}": ${error$2.message}`);
603
+ }
604
+ }
605
+ async function ensureConfigDir() {
606
+ const dir = getConfigDir();
607
+ await mkdir(dir, { recursive: true });
608
+ }
609
+ async function writeDefaultConfig() {
610
+ const configPath = getConfigPath();
611
+ await mkdir(dirname(configPath), { recursive: true });
612
+ await writeFile(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf8");
613
+ }
614
+ function isNodeError(error$2) {
615
+ return error$2 instanceof Error && typeof error$2.code === "string";
616
+ }
617
+ function mergeWithDefaults(partial) {
618
+ const registries = {
619
+ ...DEFAULT_CONFIG.registries,
620
+ ...partial.registries ?? {}
621
+ };
622
+ if (!registries[DEFAULT_REGISTRY_ALIAS]) registries[DEFAULT_REGISTRY_ALIAS] = structuredClone(DEFAULT_CONFIG.registries[DEFAULT_REGISTRY_ALIAS]);
623
+ return {
624
+ defaultRegistry: partial.defaultRegistry ?? DEFAULT_CONFIG.defaultRegistry,
625
+ registries
626
+ };
627
+ }
628
+
629
+ //#endregion
630
+ //#region src/commands/registry/manage.ts
631
+ const REGISTRY_ALIAS_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/i;
632
+ async function listRegistries() {
633
+ const config = await loadConfig$1();
634
+ const items = Object.entries(config.registries).map(([alias, settings]) => ({
635
+ alias,
636
+ ...settings,
637
+ isDefault: alias === config.defaultRegistry
638
+ })).sort((a, b) => a.alias.localeCompare(b.alias));
639
+ log.debug(`Loaded ${items.length} registries`);
640
+ return items;
641
+ }
642
+ async function addRegistry(alias, url, options = {}) {
643
+ const normalizedAlias = normalizeAlias(alias);
644
+ const normalizedUrl = normalizeRegistryUrl(url);
645
+ const config = await loadConfig$1();
646
+ if (config.registries[normalizedAlias] && !options.overwrite) throw new Error(`Registry "${normalizedAlias}" already exists. Re-run with --force to overwrite.`);
647
+ const isUpdate = !!config.registries[normalizedAlias];
648
+ config.registries[normalizedAlias] = { url: normalizedUrl };
649
+ if (!config.defaultRegistry || options.makeDefault) {
650
+ config.defaultRegistry = normalizedAlias;
651
+ log.debug(`Set "${normalizedAlias}" as default registry`);
652
+ }
653
+ await saveConfig(config);
654
+ log.debug(`${isUpdate ? "Updated" : "Added"} registry "${normalizedAlias}" -> ${normalizedUrl}`);
655
+ return config.registries[normalizedAlias];
656
+ }
657
+ async function removeRegistry(alias, options = {}) {
658
+ const normalizedAlias = normalizeAlias(alias);
659
+ const config = await loadConfig$1();
660
+ if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" was not found.`);
661
+ if (normalizedAlias === DEFAULT_REGISTRY_ALIAS) throw new Error("The built-in main registry cannot be removed. Point it somewhere else instead.");
662
+ const isDefault = normalizedAlias === config.defaultRegistry;
663
+ if (isDefault && !options.allowDefaultRemoval) throw new Error(`Registry "${normalizedAlias}" is currently the default. Re-run with --force to remove it.`);
664
+ delete config.registries[normalizedAlias];
665
+ log.debug(`Removed registry "${normalizedAlias}"`);
666
+ if (isDefault) {
667
+ config.defaultRegistry = pickFallbackDefault(config, normalizedAlias);
668
+ log.debug(`Default registry changed to "${config.defaultRegistry}"`);
669
+ }
670
+ await saveConfig(config);
671
+ return {
672
+ removedDefault: isDefault,
673
+ nextDefault: config.defaultRegistry
674
+ };
675
+ }
676
+ async function useRegistry(alias) {
677
+ const normalizedAlias = normalizeAlias(alias);
678
+ const config = await loadConfig$1();
679
+ if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" is not defined.`);
680
+ config.defaultRegistry = normalizedAlias;
681
+ await saveConfig(config);
682
+ log.debug(`Switched default registry to "${normalizedAlias}"`);
683
+ }
684
+ function normalizeAlias(alias) {
685
+ const trimmed = alias.trim();
686
+ if (!trimmed) throw new Error("Alias is required.");
687
+ const normalized = trimmed.toLowerCase();
688
+ if (!REGISTRY_ALIAS_PATTERN.test(normalized)) throw new Error("Aliases may only contain letters, numbers, dashes, and underscores.");
689
+ return normalized;
690
+ }
691
+ function pickFallbackDefault(config, removedAlias) {
692
+ const fallback = Object.keys(config.registries).find((alias) => alias !== removedAlias);
693
+ return fallback ?? DEFAULT_REGISTRY_ALIAS;
694
+ }
695
+
519
696
  //#endregion
520
697
  //#region src/lib/url.ts
521
698
  /**
@@ -682,9 +859,78 @@ async function publishPreset(baseUrl, token, input) {
682
859
  }
683
860
  /**
684
861
  * Unpublishes a preset version from the registry.
862
+ * This unpublishes all platform variants for the specified version.
685
863
  */
686
- async function unpublishPreset(baseUrl, token, slug, platform, version$1) {
687
- const url = `${baseUrl}${API_ENDPOINTS.presets.unpublish(slug, platform, version$1)}`;
864
+ async function unpublishPreset(baseUrl, token, slug, version$1) {
865
+ const url = `${baseUrl}${API_ENDPOINTS.presets.unpublish(slug, version$1)}`;
866
+ log.debug(`DELETE ${url}`);
867
+ try {
868
+ const response = await fetch(url, {
869
+ method: "DELETE",
870
+ headers: { Authorization: `Bearer ${token}` }
871
+ });
872
+ log.debug(`Response status: ${response.status}`);
873
+ if (!response.ok) {
874
+ const errorData = await response.json();
875
+ return {
876
+ success: false,
877
+ error: errorData.error || `HTTP ${response.status}`
878
+ };
879
+ }
880
+ const data = await response.json();
881
+ return {
882
+ success: true,
883
+ data
884
+ };
885
+ } catch (error$2) {
886
+ return {
887
+ success: false,
888
+ error: `Failed to connect to registry: ${getErrorMessage(error$2)}`
889
+ };
890
+ }
891
+ }
892
+
893
+ //#endregion
894
+ //#region src/lib/api/rules.ts
895
+ /**
896
+ * Publish a rule (create or update).
897
+ * The registry handles create vs update automatically.
898
+ */
899
+ async function publishRule(baseUrl, token, input) {
900
+ const url = `${baseUrl}${API_ENDPOINTS.rules.base}`;
901
+ log.debug(`POST ${url}`);
902
+ try {
903
+ const response = await fetch(url, {
904
+ method: "POST",
905
+ headers: {
906
+ "Content-Type": "application/json",
907
+ Authorization: `Bearer ${token}`
908
+ },
909
+ body: JSON.stringify(input)
910
+ });
911
+ log.debug(`Response status: ${response.status}`);
912
+ if (!response.ok) {
913
+ const errorData = await response.json();
914
+ return {
915
+ success: false,
916
+ error: errorData.error || `HTTP ${response.status}`,
917
+ issues: errorData.issues
918
+ };
919
+ }
920
+ const data = await response.json();
921
+ return {
922
+ success: true,
923
+ data
924
+ };
925
+ } catch (error$2) {
926
+ return {
927
+ success: false,
928
+ error: `Failed to connect to registry: ${getErrorMessage(error$2)}`
929
+ };
930
+ }
931
+ }
932
+ async function deleteRule(baseUrl, token, slug) {
933
+ const url = `${baseUrl}${API_ENDPOINTS.rules.bySlug(slug)}`;
688
934
  log.debug(`DELETE ${url}`);
689
935
  try {
690
936
  const response = await fetch(url, {
@@ -735,164 +981,6 @@ async function fetchSession(baseUrl, token) {
735
981
  }
736
982
  }
737
983
 
738
- //#endregion
739
- //#region src/lib/config.ts
740
- /** Directory for CLI configuration and credentials (e.g., ~/.agentrules/) */
741
- const CONFIG_DIRNAME = ".agentrules";
742
- const CONFIG_FILENAME = "config.json";
743
- const CONFIG_HOME_ENV = "AGENT_RULES_HOME";
744
- const DEFAULT_REGISTRY_ALIAS = "main";
745
- const DEFAULT_REGISTRY_URL = "https://agentrules.directory/";
746
- const DEFAULT_CONFIG = {
747
- defaultRegistry: DEFAULT_REGISTRY_ALIAS,
748
- registries: { [DEFAULT_REGISTRY_ALIAS]: { url: DEFAULT_REGISTRY_URL } }
749
- };
750
- async function loadConfig() {
751
- await ensureConfigDir();
752
- const configPath = getConfigPath();
753
- log.debug(`Loading config from ${configPath}`);
754
- try {
755
- await access(configPath, constants.F_OK);
756
- } catch (error$2) {
757
- if (isNodeError(error$2) && error$2.code === "ENOENT") {
758
- log.debug("Config file not found, creating default config");
759
- await writeDefaultConfig();
760
- return structuredClone(DEFAULT_CONFIG);
761
- }
762
- throw error$2 instanceof Error ? error$2 : new Error(String(error$2));
763
- }
764
- const raw = await readFile(configPath, "utf8");
765
- let parsed = {};
766
- try {
767
- parsed = JSON.parse(raw);
768
- } catch (error$2) {
769
- throw new Error(`Failed to parse ${configPath}: ${error$2.message}`);
770
- }
771
- return mergeWithDefaults(parsed);
772
- }
773
- async function saveConfig(config) {
774
- await ensureConfigDir();
775
- const configPath = getConfigPath();
776
- log.debug(`Saving config to ${configPath}`);
777
- const serialized = JSON.stringify(config, null, 2);
778
- await writeFile(configPath, serialized, "utf8");
779
- }
780
- function getConfigPath() {
781
- return join(getConfigDir(), CONFIG_FILENAME);
782
- }
783
- function getConfigDir() {
784
- const customDir = process.env[CONFIG_HOME_ENV];
785
- if (customDir && customDir.trim().length > 0) return customDir;
786
- return join(homedir(), CONFIG_DIRNAME);
787
- }
788
- /**
789
- * Normalizes a registry URL to a base URL with trailing slash.
790
- *
791
- * Examples:
792
- * - "https://example.com" → "https://example.com/"
793
- * - "https://example.com/custom/" → "https://example.com/custom/"
794
- * - "https://example.com/custom" → "https://example.com/custom/"
795
- */
796
- function normalizeRegistryUrl(input) {
797
- try {
798
- const parsed = new URL(input);
799
- if (!parsed.pathname.endsWith("/")) parsed.pathname = `${parsed.pathname}/`;
800
- return parsed.toString();
801
- } catch (error$2) {
802
- throw new Error(`Invalid registry URL "${input}": ${error$2.message}`);
803
- }
804
- }
805
- async function ensureConfigDir() {
806
- const dir = getConfigDir();
807
- await mkdir(dir, { recursive: true });
808
- }
809
- async function writeDefaultConfig() {
810
- const configPath = getConfigPath();
811
- await mkdir(dirname(configPath), { recursive: true });
812
- await writeFile(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf8");
813
- }
814
- function isNodeError(error$2) {
815
- return error$2 instanceof Error && typeof error$2.code === "string";
816
- }
817
- function mergeWithDefaults(partial) {
818
- const registries = {
819
- ...DEFAULT_CONFIG.registries,
820
- ...partial.registries ?? {}
821
- };
822
- if (!registries[DEFAULT_REGISTRY_ALIAS]) registries[DEFAULT_REGISTRY_ALIAS] = structuredClone(DEFAULT_CONFIG.registries[DEFAULT_REGISTRY_ALIAS]);
823
- return {
824
- defaultRegistry: partial.defaultRegistry ?? DEFAULT_CONFIG.defaultRegistry,
825
- registries
826
- };
827
- }
828
-
829
- //#endregion
830
- //#region src/commands/registry/manage.ts
831
- const REGISTRY_ALIAS_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/i;
832
- async function listRegistries() {
833
- const config = await loadConfig();
834
- const items = Object.entries(config.registries).map(([alias, settings]) => ({
835
- alias,
836
- ...settings,
837
- isDefault: alias === config.defaultRegistry
838
- })).sort((a, b) => a.alias.localeCompare(b.alias));
839
- log.debug(`Loaded ${items.length} registries`);
840
- return items;
841
- }
842
- async function addRegistry(alias, url, options = {}) {
843
- const normalizedAlias = normalizeAlias(alias);
844
- const normalizedUrl = normalizeRegistryUrl(url);
845
- const config = await loadConfig();
846
- if (config.registries[normalizedAlias] && !options.overwrite) throw new Error(`Registry "${normalizedAlias}" already exists. Re-run with --force to overwrite.`);
847
- const isUpdate = !!config.registries[normalizedAlias];
848
- config.registries[normalizedAlias] = { url: normalizedUrl };
849
- if (!config.defaultRegistry || options.makeDefault) {
850
- config.defaultRegistry = normalizedAlias;
851
- log.debug(`Set "${normalizedAlias}" as default registry`);
852
- }
853
- await saveConfig(config);
854
- log.debug(`${isUpdate ? "Updated" : "Added"} registry "${normalizedAlias}" -> ${normalizedUrl}`);
855
- return config.registries[normalizedAlias];
856
- }
857
- async function removeRegistry(alias, options = {}) {
858
- const normalizedAlias = normalizeAlias(alias);
859
- const config = await loadConfig();
860
- if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" was not found.`);
861
- if (normalizedAlias === DEFAULT_REGISTRY_ALIAS) throw new Error("The built-in main registry cannot be removed. Point it somewhere else instead.");
862
- const isDefault = normalizedAlias === config.defaultRegistry;
863
- if (isDefault && !options.allowDefaultRemoval) throw new Error(`Registry "${normalizedAlias}" is currently the default. Re-run with --force to remove it.`);
864
- delete config.registries[normalizedAlias];
865
- log.debug(`Removed registry "${normalizedAlias}"`);
866
- if (isDefault) {
867
- config.defaultRegistry = pickFallbackDefault(config, normalizedAlias);
868
- log.debug(`Default registry changed to "${config.defaultRegistry}"`);
869
- }
870
- await saveConfig(config);
871
- return {
872
- removedDefault: isDefault,
873
- nextDefault: config.defaultRegistry
874
- };
875
- }
876
- async function useRegistry(alias) {
877
- const normalizedAlias = normalizeAlias(alias);
878
- const config = await loadConfig();
879
- if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" is not defined.`);
880
- config.defaultRegistry = normalizedAlias;
881
- await saveConfig(config);
882
- log.debug(`Switched default registry to "${normalizedAlias}"`);
883
- }
884
- function normalizeAlias(alias) {
885
- const trimmed = alias.trim();
886
- if (!trimmed) throw new Error("Alias is required.");
887
- const normalized = trimmed.toLowerCase();
888
- if (!REGISTRY_ALIAS_PATTERN.test(normalized)) throw new Error("Aliases may only contain letters, numbers, dashes, and underscores.");
889
- return normalized;
890
- }
891
- function pickFallbackDefault(config, removedAlias) {
892
- const fallback = Object.keys(config.registries).find((alias) => alias !== removedAlias);
893
- return fallback ?? DEFAULT_REGISTRY_ALIAS;
894
- }
895
-
896
984
  //#endregion
897
985
  //#region src/lib/credentials.ts
898
986
  const chmodAsync = promisify(chmod);
@@ -922,7 +1010,7 @@ function normalizeUrl(url) {
922
1010
  async function loadStore() {
923
1011
  const credentialsPath = getCredentialsPath();
924
1012
  try {
925
- await access(credentialsPath, constants$1.F_OK);
1013
+ await access(credentialsPath, constants.F_OK);
926
1014
  } catch {
927
1015
  log.debug(`Credentials file not found at ${credentialsPath}`);
928
1016
  return {};
@@ -1014,7 +1102,7 @@ async function clearAllCredentials() {
1014
1102
  async function createAppContext(options = {}) {
1015
1103
  const { url: explicitUrl, registryAlias } = options;
1016
1104
  log.debug("Loading app context");
1017
- const config = await loadConfig();
1105
+ const config = await loadConfig$1();
1018
1106
  const registry$1 = explicitUrl ? resolveExplicitUrl(explicitUrl) : resolveRegistry(config, registryAlias);
1019
1107
  log.debug(`Active registry: ${registry$1.alias} → ${registry$1.url}`);
1020
1108
  const credentials = await getCredentials(registry$1.url);
@@ -1099,273 +1187,150 @@ async function initAppContext(options = {}) {
1099
1187
  }
1100
1188
 
1101
1189
  //#endregion
1102
- //#region src/commands/auth/login.ts
1103
- const execAsync = promisify(exec);
1104
- const CLIENT_ID = "agentrules-cli";
1105
- /**
1106
- * Performs device code flow login to an agentrules registry.
1107
- */
1108
- async function login(options = {}) {
1109
- const { noBrowser = false, onDeviceCode, onBrowserOpen, onPollingStart, onAuthorized } = options;
1190
+ //#region src/commands/add.ts
1191
+ async function add(options) {
1110
1192
  const ctx = useAppContext();
1111
- const { url: registryUrl } = ctx.registry;
1112
- log.debug(`Authenticating with ${registryUrl}`);
1113
- try {
1114
- log.debug("Requesting device code");
1115
- const codeResult = await requestDeviceCode({
1116
- issuer: registryUrl,
1117
- clientId: CLIENT_ID
1118
- });
1119
- if (codeResult.success === false) return {
1120
- success: false,
1121
- error: codeResult.error
1122
- };
1123
- const { data: deviceAuthResponse, config } = codeResult;
1124
- const formattedCode = formatUserCode(deviceAuthResponse.user_code);
1125
- onDeviceCode?.({
1126
- userCode: formattedCode,
1127
- verificationUri: deviceAuthResponse.verification_uri,
1128
- verificationUriComplete: deviceAuthResponse.verification_uri_complete
1129
- });
1130
- let browserOpened = false;
1131
- if (!noBrowser) try {
1132
- const urlToOpen = deviceAuthResponse.verification_uri_complete ?? deviceAuthResponse.verification_uri;
1133
- log.debug(`Opening browser: ${urlToOpen}`);
1134
- await openBrowser(urlToOpen);
1135
- browserOpened = true;
1136
- } catch {
1137
- log.debug("Failed to open browser");
1138
- }
1139
- onBrowserOpen?.(browserOpened);
1140
- log.debug("Waiting for user authorization");
1141
- onPollingStart?.();
1142
- const pollResult = await pollForToken({
1143
- config,
1144
- deviceAuthorizationResponse: deviceAuthResponse
1145
- });
1146
- if (pollResult.success === false) return {
1147
- success: false,
1148
- error: pollResult.error
1149
- };
1150
- onAuthorized?.();
1151
- const token = pollResult.token.access_token;
1152
- log.debug("Fetching user info");
1153
- const session = await fetchSession(registryUrl, token);
1154
- const user = session?.user;
1155
- const sessionExpiresAt = session?.session?.expiresAt;
1156
- const expiresAt = sessionExpiresAt ?? (pollResult.token.expires_in ? new Date(Date.now() + pollResult.token.expires_in * 1e3).toISOString() : void 0);
1157
- log.debug("Saving credentials");
1158
- await saveCredentials(registryUrl, {
1159
- token,
1160
- expiresAt,
1161
- userId: user?.id,
1162
- userName: user?.name,
1163
- userEmail: user?.email
1193
+ const { alias: registryAlias, url: registryUrl } = ctx.registry;
1194
+ const dryRun = Boolean(options.dryRun);
1195
+ const { slug, platform, version: version$1 } = parseInput(options.slug, options.platform, options.version);
1196
+ log.debug(`Resolving ${slug}${version$1 ? ` (version ${version$1})` : ""}`);
1197
+ const resolved = await resolveSlug(registryUrl, slug, version$1);
1198
+ if (!resolved) throw new Error(`"${slug}" was not found in the registry.`);
1199
+ if (resolved.kind === "preset") {
1200
+ const { selectedVersion: presetVersion, selectedVariant: presetVariant } = selectPresetVariant(resolved, version$1, platform);
1201
+ return addPreset(resolved, presetVersion, presetVariant, {
1202
+ ...options,
1203
+ registryAlias,
1204
+ dryRun
1164
1205
  });
1165
- return {
1166
- success: true,
1167
- user: user ? {
1168
- id: user.id,
1169
- name: user.name,
1170
- email: user.email
1171
- } : void 0
1172
- };
1173
- } catch (error$2) {
1174
- return {
1175
- success: false,
1176
- error: getErrorMessage(error$2)
1177
- };
1178
1206
  }
1207
+ const { selectedVersion: ruleVersion, selectedVariant: ruleVariant } = selectRuleVariant(resolved, version$1, platform);
1208
+ return addRule(resolved, ruleVersion, ruleVariant, {
1209
+ ...options,
1210
+ registryAlias,
1211
+ dryRun
1212
+ });
1179
1213
  }
1180
- /**
1181
- * Format user code as XXXX-XXXX for display.
1182
- */
1183
- function formatUserCode(code$1) {
1184
- const cleaned = code$1.toUpperCase().replace(/[^A-Z0-9]/g, "");
1185
- if (cleaned.length <= 4) return cleaned;
1186
- return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`;
1187
- }
1188
- /**
1189
- * Opens a URL in the default browser.
1190
- */
1191
- async function openBrowser(url) {
1192
- const platform = process.platform;
1193
- const commands = {
1194
- darwin: `open "${url}"`,
1195
- win32: `start "" "${url}"`,
1196
- linux: `xdg-open "${url}"`
1197
- };
1198
- const command$1 = commands[platform];
1199
- if (!command$1) throw new Error(`Unsupported platform: ${platform}`);
1200
- await execAsync(command$1);
1201
- }
1202
-
1203
- //#endregion
1204
- //#region src/commands/auth/logout.ts
1205
- /**
1206
- * Logs out by clearing stored credentials
1207
- */
1208
- async function logout(options = {}) {
1209
- const { all = false } = options;
1210
- if (all) {
1211
- log.debug("Clearing all stored credentials");
1212
- await clearAllCredentials();
1213
- return {
1214
- success: true,
1215
- hadCredentials: true
1216
- };
1214
+ function selectPresetVariant(resolved, requestedVersion, platform) {
1215
+ const selectedVersion = requestedVersion ? getPresetVersion(resolved, requestedVersion) : getLatestPresetVersion(resolved);
1216
+ if (!selectedVersion) {
1217
+ const versionLabel = requestedVersion ?? "latest";
1218
+ throw new Error(`Version "${versionLabel}" not found for "${resolved.slug}".`);
1217
1219
  }
1218
- const ctx = useAppContext();
1219
- const { url: registryUrl } = ctx.registry;
1220
- const hadCredentials = ctx.credentials !== null;
1221
- if (hadCredentials) {
1222
- log.debug(`Clearing credentials for ${registryUrl}`);
1223
- await clearCredentials(registryUrl);
1224
- } else log.debug(`No credentials found for ${registryUrl}`);
1220
+ if (selectedVersion.variants.length === 0) throw new Error(`No platform variants found for "${resolved.slug}".`);
1221
+ if (selectedVersion.variants.length > 1 && !platform) {
1222
+ const platforms = selectedVersion.variants.map((v) => v.platform).join(", ");
1223
+ throw new Error(`"${resolved.slug}" is available for multiple platforms: ${platforms}. Use --platform <platform> to specify which one.`);
1224
+ }
1225
+ const selectedVariant = platform ? getPresetVariant(selectedVersion, platform) : selectedVersion.variants[0];
1226
+ if (!selectedVariant) throw new Error(`Platform "${platform}" not found for "${resolved.slug}" v${selectedVersion.version}.`);
1225
1227
  return {
1226
- success: true,
1227
- hadCredentials
1228
+ selectedVersion,
1229
+ selectedVariant
1228
1230
  };
1229
1231
  }
1230
-
1231
- //#endregion
1232
- //#region src/commands/auth/whoami.ts
1233
- /**
1234
- * Returns information about the currently authenticated user
1235
- */
1236
- async function whoami() {
1237
- const ctx = useAppContext();
1238
- const { url: registryUrl } = ctx.registry;
1232
+ function selectRuleVariant(resolved, requestedVersion, platform) {
1233
+ const selectedVersion = requestedVersion ? getRuleVersion(resolved, requestedVersion) : getLatestRuleVersion(resolved);
1234
+ if (!selectedVersion) {
1235
+ const versionLabel = requestedVersion ?? "latest";
1236
+ throw new Error(`Version "${versionLabel}" not found for "${resolved.slug}".`);
1237
+ }
1238
+ if (selectedVersion.variants.length === 0) throw new Error(`No platform variants found for "${resolved.slug}".`);
1239
+ if (selectedVersion.variants.length > 1 && !platform) {
1240
+ const platforms = selectedVersion.variants.map((v) => v.platform).join(", ");
1241
+ throw new Error(`"${resolved.slug}" is available for multiple platforms: ${platforms}. Use --platform <platform> to specify which one.`);
1242
+ }
1243
+ const selectedVariant = platform ? getRuleVariant(selectedVersion, platform) : selectedVersion.variants[0];
1244
+ if (!selectedVariant) throw new Error(`Platform "${platform}" not found for "${resolved.slug}" v${selectedVersion.version}.`);
1239
1245
  return {
1240
- success: true,
1241
- loggedIn: ctx.isLoggedIn,
1242
- user: ctx.user ?? void 0,
1243
- registryUrl,
1244
- expiresAt: ctx.credentials?.expiresAt
1246
+ selectedVersion,
1247
+ selectedVariant
1245
1248
  };
1246
1249
  }
1247
-
1248
- //#endregion
1249
- //#region src/commands/preset/add.ts
1250
- async function addPreset(options) {
1251
- const ctx = useAppContext();
1252
- const { alias: registryAlias, url: registryUrl } = ctx.registry;
1253
- const dryRun = Boolean(options.dryRun);
1254
- const { slug, platform, version: version$1 } = parsePresetInput(options.preset, options.platform, options.version);
1255
- log.debug(`Resolving preset ${slug} for platform ${platform}${version$1 ? ` (version ${version$1})` : ""}`);
1256
- const { preset, bundleUrl } = await resolvePreset(registryUrl, slug, platform, version$1);
1257
- log.debug(`Downloading bundle from ${bundleUrl}`);
1258
- const bundle = await fetchBundle(bundleUrl);
1259
- if (bundle.slug !== preset.slug || bundle.platform !== preset.platform) throw new Error(`Preset bundle metadata mismatch for "${preset.name}". Expected slug "${preset.slug}" (${preset.platform}).`);
1260
- const target = resolveInstallTarget(bundle.platform, options);
1250
+ async function addPreset(resolved, version$1, variant, options) {
1251
+ log.debug(`Installing preset: ${variant.platform} v${version$1.version}`);
1252
+ let bundle;
1253
+ if (hasBundle(variant)) {
1254
+ log.debug(`Downloading bundle from ${variant.bundleUrl}`);
1255
+ bundle = await fetchBundle(variant.bundleUrl);
1256
+ } else {
1257
+ log.debug("Using inline bundle content");
1258
+ bundle = JSON.parse(variant.content);
1259
+ }
1260
+ if (bundle.slug !== resolved.slug || bundle.platform !== variant.platform) throw new Error(`Bundle metadata mismatch for "${resolved.slug}". Expected slug "${resolved.slug}" (${variant.platform}).`);
1261
+ const target = resolveInstallTarget(variant.platform, options);
1261
1262
  log.debug(`Writing ${bundle.files.length} files to ${target.root}`);
1262
- const writeStats = await writeBundleFiles(bundle, target, {
1263
+ const filesToWrite = [];
1264
+ for (const file of bundle.files) {
1265
+ const decoded = decodeBundledFile(file);
1266
+ const data = Buffer.from(decoded);
1267
+ await verifyBundledFileChecksum(file, data);
1268
+ const destPath = computePresetDestinationPath(file.path, target);
1269
+ filesToWrite.push({
1270
+ path: destPath,
1271
+ content: data
1272
+ });
1273
+ }
1274
+ const writeStats = await writeFiles(filesToWrite, target.root, {
1263
1275
  force: Boolean(options.force),
1264
1276
  skipConflicts: Boolean(options.skipConflicts),
1265
1277
  noBackup: Boolean(options.noBackup),
1266
- dryRun
1278
+ dryRun: options.dryRun
1267
1279
  });
1268
1280
  return {
1269
- preset,
1281
+ kind: "preset",
1282
+ resolved,
1283
+ version: version$1,
1284
+ variant,
1270
1285
  bundle,
1271
1286
  files: writeStats.files,
1272
- conflicts: writeStats.conflicts,
1273
1287
  backups: writeStats.backups,
1274
1288
  targetRoot: target.root,
1275
1289
  targetLabel: target.label,
1276
- registryAlias,
1277
- dryRun
1278
- };
1279
- }
1280
- /**
1281
- * Parses preset input to extract slug, platform, and version.
1282
- * Supports formats:
1283
- * - "my-preset" (requires explicit platform)
1284
- * - "my-preset.claude" (platform inferred from suffix)
1285
- * - "my-preset@1.0" (with version)
1286
- * - "my-preset.claude@1.0" (platform and version)
1287
- *
1288
- * Version can also be provided via --version flag (takes precedence).
1289
- */
1290
- function parsePresetInput(input, explicitPlatform, explicitVersion) {
1291
- let normalized = input.toLowerCase().trim();
1292
- let parsedVersion;
1293
- const atIndex = normalized.lastIndexOf("@");
1294
- if (atIndex > 0) {
1295
- parsedVersion = normalized.slice(atIndex + 1);
1296
- normalized = normalized.slice(0, atIndex);
1297
- }
1298
- const version$1 = explicitVersion ?? parsedVersion;
1299
- if (explicitPlatform) {
1300
- const parts$1 = normalized.split(".");
1301
- const maybePlatform$1 = parts$1.at(-1);
1302
- if (maybePlatform$1 && isSupportedPlatform(maybePlatform$1)) return {
1303
- slug: parts$1.slice(0, -1).join("."),
1304
- platform: explicitPlatform,
1305
- version: version$1
1306
- };
1307
- return {
1308
- slug: normalized,
1309
- platform: explicitPlatform,
1310
- version: version$1
1311
- };
1312
- }
1313
- const parts = normalized.split(".");
1314
- const maybePlatform = parts.at(-1);
1315
- if (maybePlatform && isSupportedPlatform(maybePlatform)) return {
1316
- slug: parts.slice(0, -1).join("."),
1317
- platform: maybePlatform,
1318
- version: version$1
1290
+ registryAlias: options.registryAlias,
1291
+ dryRun: options.dryRun
1319
1292
  };
1320
- throw new Error(`Platform not specified. Use --platform <platform> or specify as <slug>.<platform> (e.g., "${input}.claude").`);
1321
1293
  }
1322
- function resolveInstallTarget(platform, options) {
1323
- const { projectDir, globalDir } = PLATFORMS[platform];
1324
- if (options.directory) {
1325
- const customRoot = resolve(expandHome(options.directory));
1326
- return {
1327
- root: customRoot,
1328
- mode: "custom",
1329
- platform,
1330
- projectDir,
1331
- label: `custom directory ${customRoot}`
1332
- };
1333
- }
1334
- if (options.global) {
1335
- const globalRoot = resolve(expandHome(globalDir));
1336
- return {
1337
- root: globalRoot,
1338
- mode: "global",
1339
- platform,
1340
- projectDir,
1341
- label: `global path ${globalRoot}`
1342
- };
1343
- }
1344
- const projectRoot = process.cwd();
1294
+ async function addRule(resolved, version$1, variant, options) {
1295
+ log.debug(`Installing rule: ${variant.platform} v${version$1.version}`);
1296
+ const { targetPath, targetRoot, targetLabel } = resolveRuleTarget(variant.platform, variant.type, resolved.name, options);
1297
+ log.debug(`Target path: ${targetPath}`);
1298
+ const writeStats = await writeFiles([{
1299
+ path: targetPath,
1300
+ content: Buffer.from(variant.content, "utf-8")
1301
+ }], targetRoot, {
1302
+ force: Boolean(options.force),
1303
+ skipConflicts: false,
1304
+ noBackup: Boolean(options.noBackup),
1305
+ dryRun: options.dryRun
1306
+ });
1345
1307
  return {
1346
- root: projectRoot,
1347
- mode: "project",
1348
- platform,
1349
- projectDir,
1350
- label: `project root ${projectRoot}`
1308
+ kind: "rule",
1309
+ resolved,
1310
+ version: version$1,
1311
+ variant,
1312
+ files: writeStats.files,
1313
+ backups: writeStats.backups,
1314
+ targetRoot,
1315
+ targetLabel,
1316
+ registryAlias: options.registryAlias,
1317
+ dryRun: options.dryRun
1351
1318
  };
1352
1319
  }
1353
- async function writeBundleFiles(bundle, target, behavior) {
1320
+ /**
1321
+ * Write files with conflict detection, backup support, and diff preview.
1322
+ * Used by both preset and rule installation.
1323
+ */
1324
+ async function writeFiles(filesToWrite, root, options) {
1354
1325
  const files = [];
1355
- const conflicts = [];
1356
1326
  const backups = [];
1357
- if (!behavior.dryRun) await mkdir(target.root, { recursive: true });
1358
- for (const file of bundle.files) {
1359
- const decoded = decodeBundledFile(file);
1360
- const data = Buffer.from(decoded);
1361
- await verifyBundledFileChecksum(file, data);
1362
- const destResult = computeDestinationPath(file.path, target);
1363
- const destination = destResult.path;
1364
- if (!behavior.dryRun) await mkdir(dirname(destination), { recursive: true });
1327
+ if (!options.dryRun) await mkdir(root, { recursive: true });
1328
+ for (const { path: destination, content } of filesToWrite) {
1329
+ if (!options.dryRun) await mkdir(dirname(destination), { recursive: true });
1365
1330
  const existing = await readExistingFile(destination);
1366
- const relativePath = relativize(destination, target.root);
1331
+ const relativePath = relativize(destination, root);
1367
1332
  if (!existing) {
1368
- if (!behavior.dryRun) await writeFile(destination, data);
1333
+ if (!options.dryRun) await writeFile(destination, content);
1369
1334
  files.push({
1370
1335
  path: relativePath,
1371
1336
  status: "created"
@@ -1373,7 +1338,7 @@ async function writeBundleFiles(bundle, target, behavior) {
1373
1338
  log.debug(`Created: ${relativePath}`);
1374
1339
  continue;
1375
1340
  }
1376
- if (existing.equals(data)) {
1341
+ if (existing.equals(content)) {
1377
1342
  files.push({
1378
1343
  path: relativePath,
1379
1344
  status: "unchanged"
@@ -1381,18 +1346,19 @@ async function writeBundleFiles(bundle, target, behavior) {
1381
1346
  log.debug(`Unchanged: ${relativePath}`);
1382
1347
  continue;
1383
1348
  }
1384
- if (behavior.force) {
1385
- if (!behavior.noBackup) {
1349
+ const diff = renderDiffPreview(relativePath, existing, content);
1350
+ if (options.force) {
1351
+ if (!options.noBackup) {
1386
1352
  const backupPath = `${destination}.bak`;
1387
1353
  const relativeBackupPath = `${relativePath}.bak`;
1388
- if (!behavior.dryRun) await copyFile(destination, backupPath);
1354
+ if (!options.dryRun) await copyFile(destination, backupPath);
1389
1355
  backups.push({
1390
1356
  originalPath: relativePath,
1391
1357
  backupPath: relativeBackupPath
1392
1358
  });
1393
1359
  log.debug(`Backed up: ${relativePath} → ${relativeBackupPath}`);
1394
1360
  }
1395
- if (!behavior.dryRun) await writeFile(destination, data);
1361
+ if (!options.dryRun) await writeFile(destination, content);
1396
1362
  files.push({
1397
1363
  path: relativePath,
1398
1364
  status: "overwritten"
@@ -1400,31 +1366,78 @@ async function writeBundleFiles(bundle, target, behavior) {
1400
1366
  log.debug(`Overwritten: ${relativePath}`);
1401
1367
  continue;
1402
1368
  }
1403
- conflicts.push({
1404
- path: relativePath,
1405
- diff: renderDiffPreview(relativePath, existing, data)
1406
- });
1407
- files.push({
1408
- path: relativePath,
1409
- status: "conflict"
1410
- });
1411
- log.debug(`Conflict: ${relativePath}`);
1369
+ if (options.skipConflicts) {
1370
+ files.push({
1371
+ path: relativePath,
1372
+ status: "skipped",
1373
+ diff
1374
+ });
1375
+ log.debug(`Skipped: ${relativePath}`);
1376
+ } else {
1377
+ files.push({
1378
+ path: relativePath,
1379
+ status: "conflict",
1380
+ diff
1381
+ });
1382
+ log.debug(`Conflict: ${relativePath}`);
1383
+ }
1412
1384
  }
1413
1385
  return {
1414
1386
  files,
1415
- conflicts,
1416
1387
  backups
1417
1388
  };
1418
1389
  }
1419
1390
  /**
1420
- * Compute destination path for a bundled file.
1421
- *
1422
- * Bundle files are stored with paths relative to the platform directory
1423
- * (e.g., "AGENTS.md", "commands/test.md") and installed to:
1424
- * - Project/custom: <root>/<projectDir>/<path> (e.g., .opencode/AGENTS.md)
1425
- * - Global: <root>/<path> (e.g., ~/.config/opencode/AGENTS.md)
1391
+ * Parse input to extract slug and version.
1392
+ * Platform must be specified via --platform flag.
1426
1393
  */
1427
- function computeDestinationPath(pathInput, target) {
1394
+ function parseInput(input, explicitPlatform, explicitVersion) {
1395
+ let normalized = input.toLowerCase().trim();
1396
+ let parsedVersion;
1397
+ const atIndex = normalized.lastIndexOf("@");
1398
+ if (atIndex > 0) {
1399
+ parsedVersion = normalized.slice(atIndex + 1);
1400
+ normalized = normalized.slice(0, atIndex);
1401
+ }
1402
+ return {
1403
+ slug: normalized,
1404
+ platform: explicitPlatform,
1405
+ version: explicitVersion ?? parsedVersion
1406
+ };
1407
+ }
1408
+ function resolveInstallTarget(platform, options) {
1409
+ const { projectDir, globalDir } = PLATFORMS[platform];
1410
+ if (options.directory) {
1411
+ const customRoot = resolve(expandHome(options.directory));
1412
+ return {
1413
+ root: customRoot,
1414
+ mode: "custom",
1415
+ platform,
1416
+ projectDir,
1417
+ label: `custom directory ${customRoot}`
1418
+ };
1419
+ }
1420
+ if (options.global) {
1421
+ if (!globalDir) throw new Error(`Platform "${platform}" does not support global installation`);
1422
+ const globalRoot = resolve(expandHome(globalDir));
1423
+ return {
1424
+ root: globalRoot,
1425
+ mode: "global",
1426
+ platform,
1427
+ projectDir,
1428
+ label: `global path ${globalRoot}`
1429
+ };
1430
+ }
1431
+ const projectRoot = process.cwd();
1432
+ return {
1433
+ root: projectRoot,
1434
+ mode: "project",
1435
+ platform,
1436
+ projectDir,
1437
+ label: `project root ${projectRoot}`
1438
+ };
1439
+ }
1440
+ function computePresetDestinationPath(pathInput, target) {
1428
1441
  const normalized = normalizeBundlePath(pathInput);
1429
1442
  if (!normalized) throw new Error(`Unable to derive destination for ${pathInput}. The computed relative path is empty.`);
1430
1443
  let relativePath;
@@ -1432,7 +1445,36 @@ function computeDestinationPath(pathInput, target) {
1432
1445
  else relativePath = `${target.projectDir}/${normalized}`;
1433
1446
  const destination = resolve(target.root, relativePath);
1434
1447
  ensureWithinRoot(destination, target.root);
1435
- return { path: destination };
1448
+ return destination;
1449
+ }
1450
+ function resolveRuleTarget(platform, type, name, options) {
1451
+ const location = options.global ? "global" : "project";
1452
+ const pathTemplate = getInstallPath(platform, type, name, location);
1453
+ if (!pathTemplate) {
1454
+ const locationLabel = options.global ? "globally" : "to a project";
1455
+ throw new Error(`Rule type "${type}" cannot be installed ${locationLabel} for platform "${platform}"`);
1456
+ }
1457
+ if (options.directory) {
1458
+ const customRoot = resolve(expandHome(options.directory));
1459
+ const filename = pathTemplate.split("/").pop() ?? `${name}.md`;
1460
+ const targetPath$1 = resolve(customRoot, filename);
1461
+ ensureWithinRoot(targetPath$1, customRoot);
1462
+ return {
1463
+ targetPath: targetPath$1,
1464
+ targetRoot: customRoot,
1465
+ targetLabel: `custom directory ${customRoot}`
1466
+ };
1467
+ }
1468
+ const expanded = expandHome(pathTemplate);
1469
+ const targetPath = expanded.startsWith("/") ? expanded : resolve(process.cwd(), expanded);
1470
+ const targetRoot = options.global ? dirname(targetPath) : process.cwd();
1471
+ ensureWithinRoot(targetPath, targetRoot);
1472
+ const targetLabel = options.global ? `global path ${targetRoot}` : `project root ${targetRoot}`;
1473
+ return {
1474
+ targetPath,
1475
+ targetRoot,
1476
+ targetLabel
1477
+ };
1436
1478
  }
1437
1479
  async function readExistingFile(pathname) {
1438
1480
  try {
@@ -1463,14 +1505,169 @@ function ensureWithinRoot(candidate, root) {
1463
1505
  if (candidate === root) return;
1464
1506
  if (!candidate.startsWith(normalizedRoot)) throw new Error(`Refusing to write outside of ${root}. Derived path: ${candidate}`);
1465
1507
  }
1508
+ /**
1509
+ * Expands ~ to the user's home directory.
1510
+ *
1511
+ * Uses process.env.HOME (Unix) or process.env.USERPROFILE (Windows) first,
1512
+ * falling back to os.homedir(). This matches shell behavior and allows
1513
+ * tests to override the home directory via environment variables.
1514
+ */
1466
1515
  function expandHome(value) {
1467
1516
  if (value.startsWith("~")) {
1468
1517
  const remainder = value.slice(1);
1469
- if (!remainder) return homedir();
1470
- if (remainder.startsWith("/") || remainder.startsWith("\\")) return `${homedir()}${remainder}`;
1471
- return `${homedir()}/${remainder}`;
1518
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
1519
+ if (!remainder) return home;
1520
+ if (remainder.startsWith("/") || remainder.startsWith("\\")) return `${home}${remainder}`;
1521
+ return `${home}/${remainder}`;
1522
+ }
1523
+ return value;
1524
+ }
1525
+
1526
+ //#endregion
1527
+ //#region src/commands/auth/login.ts
1528
+ const execAsync = promisify(exec);
1529
+ const CLIENT_ID = "agentrules-cli";
1530
+ /**
1531
+ * Performs device code flow login to an agentrules registry.
1532
+ */
1533
+ async function login(options = {}) {
1534
+ const { noBrowser = false, onDeviceCode, onBrowserOpen, onPollingStart, onAuthorized } = options;
1535
+ const ctx = useAppContext();
1536
+ const { url: registryUrl } = ctx.registry;
1537
+ log.debug(`Authenticating with ${registryUrl}`);
1538
+ try {
1539
+ log.debug("Requesting device code");
1540
+ const codeResult = await requestDeviceCode({
1541
+ issuer: registryUrl,
1542
+ clientId: CLIENT_ID
1543
+ });
1544
+ if (codeResult.success === false) return {
1545
+ success: false,
1546
+ error: codeResult.error
1547
+ };
1548
+ const { data: deviceAuthResponse, config } = codeResult;
1549
+ const formattedCode = formatUserCode(deviceAuthResponse.user_code);
1550
+ onDeviceCode?.({
1551
+ userCode: formattedCode,
1552
+ verificationUri: deviceAuthResponse.verification_uri,
1553
+ verificationUriComplete: deviceAuthResponse.verification_uri_complete
1554
+ });
1555
+ let browserOpened = false;
1556
+ if (!noBrowser) try {
1557
+ const urlToOpen = deviceAuthResponse.verification_uri_complete ?? deviceAuthResponse.verification_uri;
1558
+ log.debug(`Opening browser: ${urlToOpen}`);
1559
+ await openBrowser(urlToOpen);
1560
+ browserOpened = true;
1561
+ } catch {
1562
+ log.debug("Failed to open browser");
1563
+ }
1564
+ onBrowserOpen?.(browserOpened);
1565
+ log.debug("Waiting for user authorization");
1566
+ onPollingStart?.();
1567
+ const pollResult = await pollForToken({
1568
+ config,
1569
+ deviceAuthorizationResponse: deviceAuthResponse
1570
+ });
1571
+ if (pollResult.success === false) return {
1572
+ success: false,
1573
+ error: pollResult.error
1574
+ };
1575
+ onAuthorized?.();
1576
+ const token = pollResult.token.access_token;
1577
+ log.debug("Fetching user info");
1578
+ const session = await fetchSession(registryUrl, token);
1579
+ const user = session?.user;
1580
+ const sessionExpiresAt = session?.session?.expiresAt;
1581
+ const expiresAt = sessionExpiresAt ?? (pollResult.token.expires_in ? new Date(Date.now() + pollResult.token.expires_in * 1e3).toISOString() : void 0);
1582
+ log.debug("Saving credentials");
1583
+ await saveCredentials(registryUrl, {
1584
+ token,
1585
+ expiresAt,
1586
+ userId: user?.id,
1587
+ userName: user?.name,
1588
+ userEmail: user?.email
1589
+ });
1590
+ return {
1591
+ success: true,
1592
+ user: user ? {
1593
+ id: user.id,
1594
+ name: user.name,
1595
+ email: user.email
1596
+ } : void 0
1597
+ };
1598
+ } catch (error$2) {
1599
+ return {
1600
+ success: false,
1601
+ error: getErrorMessage(error$2)
1602
+ };
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Format user code as XXXX-XXXX for display.
1607
+ */
1608
+ function formatUserCode(code$1) {
1609
+ const cleaned = code$1.toUpperCase().replace(/[^A-Z0-9]/g, "");
1610
+ if (cleaned.length <= 4) return cleaned;
1611
+ return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`;
1612
+ }
1613
+ /**
1614
+ * Opens a URL in the default browser.
1615
+ */
1616
+ async function openBrowser(url) {
1617
+ const platform = process.platform;
1618
+ const commands = {
1619
+ darwin: `open "${url}"`,
1620
+ win32: `start "" "${url}"`,
1621
+ linux: `xdg-open "${url}"`
1622
+ };
1623
+ const command$1 = commands[platform];
1624
+ if (!command$1) throw new Error(`Unsupported platform: ${platform}`);
1625
+ await execAsync(command$1);
1626
+ }
1627
+
1628
+ //#endregion
1629
+ //#region src/commands/auth/logout.ts
1630
+ /**
1631
+ * Logs out by clearing stored credentials
1632
+ */
1633
+ async function logout(options = {}) {
1634
+ const { all = false } = options;
1635
+ if (all) {
1636
+ log.debug("Clearing all stored credentials");
1637
+ await clearAllCredentials();
1638
+ return {
1639
+ success: true,
1640
+ hadCredentials: true
1641
+ };
1472
1642
  }
1473
- return value;
1643
+ const ctx = useAppContext();
1644
+ const { url: registryUrl } = ctx.registry;
1645
+ const hadCredentials = ctx.credentials !== null;
1646
+ if (hadCredentials) {
1647
+ log.debug(`Clearing credentials for ${registryUrl}`);
1648
+ await clearCredentials(registryUrl);
1649
+ } else log.debug(`No credentials found for ${registryUrl}`);
1650
+ return {
1651
+ success: true,
1652
+ hadCredentials
1653
+ };
1654
+ }
1655
+
1656
+ //#endregion
1657
+ //#region src/commands/auth/whoami.ts
1658
+ /**
1659
+ * Returns information about the currently authenticated user
1660
+ */
1661
+ async function whoami() {
1662
+ const ctx = useAppContext();
1663
+ const { url: registryUrl } = ctx.registry;
1664
+ return {
1665
+ success: true,
1666
+ loggedIn: ctx.isLoggedIn,
1667
+ user: ctx.user ?? void 0,
1668
+ registryUrl,
1669
+ expiresAt: ctx.credentials?.expiresAt
1670
+ };
1474
1671
  }
1475
1672
 
1476
1673
  //#endregion
@@ -1534,61 +1731,116 @@ async function resolveConfigPath(inputPath) {
1534
1731
  return inputPath;
1535
1732
  }
1536
1733
  /**
1537
- * Load a preset from a directory containing agentrules.json.
1734
+ * Load and normalize a preset config file.
1538
1735
  *
1539
- * Config in platform dir (e.g., .claude/agentrules.json):
1540
- * - Preset files: siblings of config
1541
- * - Metadata: .agentrules/ subfolder
1736
+ * This is the single source of truth for reading preset configs.
1737
+ * Always returns a normalized shape with `platforms` as PlatformConfig[].
1542
1738
  *
1543
- * Config at repo root:
1544
- * - Preset files: in .claude/ (or `path` from config)
1545
- * - Metadata: .agentrules/ subfolder
1739
+ * @throws Error if config file is missing, invalid JSON, or fails validation
1546
1740
  */
1547
- async function loadPreset(presetDir) {
1548
- const configPath = join(presetDir, PRESET_CONFIG_FILENAME);
1549
- if (!await fileExists(configPath)) throw new Error(`Config file not found: ${configPath}`);
1550
- const configRaw = await readFile(configPath, "utf8");
1741
+ async function loadConfig(inputPath) {
1742
+ const configPath = await resolveConfigPath(inputPath);
1743
+ log.debug(`Resolved config path: ${configPath}`);
1744
+ const configRaw = await readFile(configPath, "utf8").catch(() => null);
1745
+ if (configRaw === null) throw new Error(`Config file not found: ${configPath}`);
1551
1746
  let configJson;
1552
1747
  try {
1553
1748
  configJson = JSON.parse(configRaw);
1554
- } catch {
1555
- throw new Error(`Invalid JSON in ${configPath}`);
1749
+ } catch (e) {
1750
+ throw new Error(`Invalid JSON in ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
1556
1751
  }
1557
1752
  const configObj = configJson;
1558
1753
  const identifier = typeof configObj?.name === "string" ? configObj.name : configPath;
1559
- const config = validatePresetConfig(configJson, identifier);
1560
- const slug = config.name;
1561
- const dirName = basename(presetDir);
1562
- const isConfigInPlatformDir = isPlatformDir(dirName);
1563
- let filesDir;
1564
- let metadataDir;
1565
- if (isConfigInPlatformDir) {
1566
- filesDir = presetDir;
1567
- metadataDir = join(presetDir, AGENT_RULES_DIR);
1568
- log.debug(`Config in platform dir: files in ${filesDir}, metadata in ${metadataDir}`);
1569
- } else {
1570
- const platformDir = config.path ?? PLATFORMS[config.platform].projectDir;
1571
- filesDir = join(presetDir, platformDir);
1572
- metadataDir = join(presetDir, AGENT_RULES_DIR);
1573
- log.debug(`Config at repo root: files in ${filesDir}, metadata in ${metadataDir}`);
1574
- if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir}. Create the directory or set "path" in ${PRESET_CONFIG_FILENAME}.`);
1575
- }
1754
+ const rawConfig = validatePresetConfig(configJson, identifier);
1755
+ const platforms = rawConfig.platforms.map(normalizePlatformEntry);
1756
+ if (platforms.length === 0) throw new Error(`Config must have at least one platform in the "platforms" array.`);
1757
+ const config = {
1758
+ $schema: rawConfig.$schema,
1759
+ name: rawConfig.name,
1760
+ title: rawConfig.title,
1761
+ description: rawConfig.description,
1762
+ license: rawConfig.license,
1763
+ version: rawConfig.version,
1764
+ tags: rawConfig.tags,
1765
+ features: rawConfig.features,
1766
+ ignore: rawConfig.ignore,
1767
+ agentrulesDir: rawConfig.agentrulesDir,
1768
+ platforms
1769
+ };
1770
+ const configDir = dirname(configPath);
1771
+ const dirName = basename(configDir);
1772
+ const isInProjectMode = isPlatformDir(dirName);
1773
+ if (isInProjectMode && platforms.length > 1) throw new Error(`Multi-platform configs must be placed at project root, not inside a platform directory like "${dirName}".`);
1774
+ const platformNames = platforms.map((p$1) => p$1.platform).join(", ");
1775
+ log.debug(`Loaded config: ${config.name}, platforms: ${platformNames}, mode: ${isInProjectMode ? "in-project" : "standalone"}`);
1776
+ return {
1777
+ configPath,
1778
+ config,
1779
+ configDir,
1780
+ isInProjectMode
1781
+ };
1782
+ }
1783
+ /**
1784
+ * Load a preset from a directory containing agentrules.json.
1785
+ *
1786
+ * Always returns normalized PresetInput format with platformFiles array.
1787
+ *
1788
+ * Config in platform dir (e.g., .claude/agentrules.json):
1789
+ * - Preset files: siblings of config
1790
+ * - Extras (README, LICENSE, INSTALL): agentrulesDir subfolder (default: .agentrules/)
1791
+ *
1792
+ * Config at repo root:
1793
+ * - Files in each platform's directory (or custom path if specified)
1794
+ * - Extras (README, LICENSE, INSTALL): agentrulesDir subfolder (default: .agentrules/)
1795
+ *
1796
+ * Use agentrulesDir: "." to read extras from the config directory itself
1797
+ * (useful for dedicated preset repos where README.md should be at root).
1798
+ */
1799
+ async function loadPreset(presetDir) {
1800
+ const { config, configDir, isInProjectMode } = await loadConfig(join(presetDir, PRESET_CONFIG_FILENAME));
1801
+ const { platforms } = config;
1802
+ const agentrulesDir = config.agentrulesDir ?? AGENT_RULES_DIR;
1803
+ const isAgentrulesAtRoot = agentrulesDir === ".";
1804
+ const agentrulesPath = isAgentrulesAtRoot ? configDir : join(configDir, agentrulesDir);
1576
1805
  let installMessage;
1577
1806
  let readmeContent;
1578
1807
  let licenseContent;
1579
- if (await directoryExists(metadataDir)) {
1580
- installMessage = await readFileIfExists(join(metadataDir, INSTALL_FILENAME));
1581
- readmeContent = await readFileIfExists(join(metadataDir, README_FILENAME));
1582
- licenseContent = await readFileIfExists(join(metadataDir, LICENSE_FILENAME));
1808
+ if (isAgentrulesAtRoot || await directoryExists(agentrulesPath)) {
1809
+ installMessage = await readFileIfExists(join(agentrulesPath, INSTALL_FILENAME));
1810
+ readmeContent = await readFileIfExists(join(agentrulesPath, README_FILENAME));
1811
+ licenseContent = await readFileIfExists(join(agentrulesPath, LICENSE_FILENAME));
1583
1812
  }
1584
1813
  const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...config.ignore ?? []];
1585
- const rootExclude = [PRESET_CONFIG_FILENAME, AGENT_RULES_DIR];
1586
- const files = await collectFiles(filesDir, rootExclude, ignorePatterns);
1587
- if (files.length === 0) throw new Error(`No files found in ${filesDir}. Presets must include at least one file.`);
1814
+ const platformFiles = [];
1815
+ for (const entry of platforms) {
1816
+ const { platform, path: customPath } = entry;
1817
+ let filesDir;
1818
+ if (isInProjectMode) {
1819
+ filesDir = configDir;
1820
+ log.debug(`Config in platform dir: files for ${platform} in ${filesDir}`);
1821
+ } else {
1822
+ const platformDir = customPath ?? PLATFORMS[platform].projectDir;
1823
+ filesDir = join(configDir, platformDir);
1824
+ log.debug(`Config at repo root: files for ${platform} in ${filesDir}`);
1825
+ if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir}. Create the directory or set "path" in the platform entry.`);
1826
+ }
1827
+ const rootExclude = isAgentrulesAtRoot ? [
1828
+ PRESET_CONFIG_FILENAME,
1829
+ README_FILENAME,
1830
+ LICENSE_FILENAME,
1831
+ INSTALL_FILENAME
1832
+ ] : [PRESET_CONFIG_FILENAME, agentrulesDir];
1833
+ const files = await collectFiles(filesDir, rootExclude, ignorePatterns);
1834
+ if (files.length === 0) throw new Error(`No files found in ${filesDir}. Presets must include at least one file.`);
1835
+ platformFiles.push({
1836
+ platform,
1837
+ files
1838
+ });
1839
+ }
1588
1840
  return {
1589
- slug,
1841
+ name: config.name,
1590
1842
  config,
1591
- files,
1843
+ platformFiles,
1592
1844
  installMessage,
1593
1845
  readmeContent,
1594
1846
  licenseContent
@@ -1638,11 +1890,11 @@ async function collectFiles(dir, rootExclude, ignorePatterns, root) {
1638
1890
  const nested = await collectFiles(fullPath, rootExclude, ignorePatterns, configRoot);
1639
1891
  files.push(...nested);
1640
1892
  } else if (entry.isFile()) {
1641
- const contents = await readFile(fullPath, "utf8");
1893
+ const content = await readFile(fullPath, "utf8");
1642
1894
  const relativePath = relative(configRoot, fullPath);
1643
1895
  files.push({
1644
1896
  path: relativePath,
1645
- contents
1897
+ content
1646
1898
  });
1647
1899
  }
1648
1900
  }
@@ -1775,7 +2027,7 @@ async function initPreset(options) {
1775
2027
  description,
1776
2028
  tags: options.tags ?? [],
1777
2029
  license,
1778
- platform
2030
+ platforms: [platform]
1779
2031
  };
1780
2032
  let createdDir;
1781
2033
  if (await directoryExists(platformDir)) log.debug(`Platform directory exists: ${platformDir}`);
@@ -1891,10 +2143,10 @@ async function initInteractive(options) {
1891
2143
  }
1892
2144
  const result = await p.group({
1893
2145
  name: () => p.text({
1894
- message: "Preset name (slug)",
2146
+ message: "Preset name",
1895
2147
  placeholder: normalizeName(defaultName),
1896
2148
  defaultValue: normalizeName(defaultName),
1897
- validate: check(slugSchema)
2149
+ validate: check(nameSchema)
1898
2150
  }),
1899
2151
  title: ({ results }) => {
1900
2152
  const defaultTitle = titleOption ?? toTitleCase(results.name ?? defaultName);
@@ -1972,78 +2224,56 @@ async function initInteractive(options) {
1972
2224
  //#endregion
1973
2225
  //#region src/commands/preset/validate.ts
1974
2226
  async function validatePreset(options) {
1975
- const configPath = await resolveConfigPath(options.path);
1976
- log.debug(`Resolved config path: ${configPath}`);
1977
2227
  const errors = [];
1978
2228
  const warnings = [];
1979
- const configRaw = await readFile(configPath, "utf8").catch(() => null);
1980
- if (configRaw === null) {
1981
- errors.push(`Config file not found: ${configPath}`);
1982
- log.debug("Config file read failed");
1983
- return {
1984
- valid: false,
1985
- configPath,
1986
- preset: null,
1987
- errors,
1988
- warnings
1989
- };
1990
- }
1991
- log.debug("Config file read successfully");
1992
- let configJson;
1993
- try {
1994
- configJson = JSON.parse(configRaw);
1995
- log.debug("JSON parsed successfully");
1996
- } catch (e) {
1997
- errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
1998
- log.debug(`JSON parse error: ${e instanceof Error ? e.message : String(e)}`);
1999
- return {
2000
- valid: false,
2001
- configPath,
2002
- preset: null,
2003
- errors,
2004
- warnings
2005
- };
2006
- }
2007
- let preset;
2229
+ let configPath;
2230
+ let config;
2231
+ let configDir;
2232
+ let isInProjectMode;
2008
2233
  try {
2009
- preset = validatePresetConfig(configJson, configPath);
2010
- log.debug("Preset config validation passed");
2234
+ const result = await loadConfig(options.path);
2235
+ configPath = result.configPath;
2236
+ config = result.config;
2237
+ configDir = result.configDir;
2238
+ isInProjectMode = result.isInProjectMode;
2239
+ log.debug("Config loaded and normalized successfully");
2011
2240
  } catch (e) {
2012
- errors.push(e instanceof Error ? e.message : String(e));
2013
- log.debug(`Preset config validation failed: ${e instanceof Error ? e.message : String(e)}`);
2241
+ const message = e instanceof Error ? e.message : String(e);
2242
+ errors.push(message);
2243
+ log.debug(`Config load failed: ${message}`);
2244
+ const fallbackPath = options.path ?? "agentrules.json";
2014
2245
  return {
2015
2246
  valid: false,
2016
- configPath,
2247
+ configPath: fallbackPath,
2017
2248
  preset: null,
2018
2249
  errors,
2019
2250
  warnings
2020
2251
  };
2021
2252
  }
2022
- log.debug(`Preset slug: ${preset.name}`);
2023
- const presetDir = dirname(configPath);
2024
- const platform = preset.platform;
2025
- log.debug(`Checking platform: ${platform}`);
2026
- if (isSupportedPlatform(platform)) {
2027
- const dirName = basename(presetDir);
2028
- const isInProjectMode = isPlatformDir(dirName);
2029
- if (isInProjectMode) log.debug(`In-project mode: files expected in ${presetDir}`);
2253
+ log.debug(`Preset name: ${config.name}`);
2254
+ for (const entry of config.platforms) {
2255
+ const { platform, path: customPath } = entry;
2256
+ log.debug(`Checking platform: ${platform}`);
2257
+ if (!isSupportedPlatform(platform)) {
2258
+ errors.push(`Unknown platform "${platform}". Supported: ${PLATFORM_IDS.join(", ")}`);
2259
+ log.debug(`Platform "${platform}" is not supported`);
2260
+ continue;
2261
+ }
2262
+ if (isInProjectMode) log.debug(`In-project mode: files expected in ${configDir}`);
2030
2263
  else {
2031
- const filesPath = preset.path ?? PLATFORMS[platform].projectDir;
2032
- const filesDir = join(presetDir, filesPath);
2264
+ const filesPath = customPath ?? PLATFORMS[platform].projectDir;
2265
+ const filesDir = join(configDir, filesPath);
2033
2266
  const filesExists = await directoryExists(filesDir);
2034
- log.debug(`Standalone mode: files directory check: ${filesDir} - ${filesExists ? "exists" : "not found"}`);
2035
- if (!filesExists) errors.push(`Files directory not found: ${filesPath}`);
2267
+ log.debug(`Standalone mode: files directory check for ${platform}: ${filesDir} - ${filesExists ? "exists" : "not found"}`);
2268
+ if (!filesExists) errors.push(`Files directory not found for ${platform}: ${filesPath}`);
2036
2269
  }
2037
- } else {
2038
- errors.push(`Unknown platform "${platform}". Supported: ${PLATFORM_IDS.join(", ")}`);
2039
- log.debug(`Platform "${platform}" is not supported`);
2040
2270
  }
2041
- const hasPlaceholderTags = preset.tags?.some((t) => t.startsWith("//"));
2042
- const hasPlaceholderFeatures = preset.features?.some((f) => f.startsWith("//"));
2271
+ const hasPlaceholderTags = config.tags?.some((t) => t.startsWith("//"));
2272
+ const hasPlaceholderFeatures = config.features?.some((f) => f.startsWith("//"));
2043
2273
  if (hasPlaceholderTags) {
2044
2274
  errors.push("Replace placeholder comments in tags before publishing.");
2045
2275
  log.debug("Found placeholder comments in tags");
2046
- } else if (!preset.tags || preset.tags.length === 0) {
2276
+ } else if (!config.tags || config.tags.length === 0) {
2047
2277
  errors.push("At least one tag is required.");
2048
2278
  log.debug("No tags specified");
2049
2279
  }
@@ -2056,7 +2286,7 @@ async function validatePreset(options) {
2056
2286
  return {
2057
2287
  valid: isValid,
2058
2288
  configPath,
2059
- preset: isValid ? preset : null,
2289
+ preset: isValid ? config : null,
2060
2290
  errors,
2061
2291
  warnings
2062
2292
  };
@@ -2064,8 +2294,8 @@ async function validatePreset(options) {
2064
2294
 
2065
2295
  //#endregion
2066
2296
  //#region src/commands/publish.ts
2067
- /** Maximum size per bundle in bytes (1MB) */
2068
- const MAX_BUNDLE_SIZE_BYTES = 1 * 1024 * 1024;
2297
+ /** Maximum size per variant/platform bundle in bytes (1MB) */
2298
+ const MAX_VARIANT_SIZE_BYTES = 1 * 1024 * 1024;
2069
2299
  /**
2070
2300
  * Formats bytes as human-readable string
2071
2301
  */
@@ -2107,7 +2337,8 @@ async function publish(options = {}) {
2107
2337
  let presetInput;
2108
2338
  try {
2109
2339
  presetInput = await loadPreset(presetDir);
2110
- log.debug(`Loaded preset "${presetInput.slug}" for platform ${presetInput.config.platform}`);
2340
+ const platforms$1 = presetInput.config.platforms.map((p$1) => p$1.platform).join(", ");
2341
+ log.debug(`Loaded preset "${presetInput.name}" for platforms: ${platforms$1}`);
2111
2342
  } catch (error$2) {
2112
2343
  const message = getErrorMessage(error$2);
2113
2344
  spinner$1.fail("Failed to load preset");
@@ -2117,63 +2348,74 @@ async function publish(options = {}) {
2117
2348
  error: message
2118
2349
  };
2119
2350
  }
2120
- spinner$1.update("Building bundle...");
2351
+ spinner$1.update("Building platform bundles...");
2121
2352
  let publishInput;
2122
2353
  try {
2123
2354
  publishInput = await buildPresetPublishInput({
2124
2355
  preset: presetInput,
2125
2356
  version: version$1
2126
2357
  });
2127
- log.debug(`Built publish input for ${publishInput.platform}`);
2358
+ const platforms$1 = publishInput.variants.map((v) => v.platform).join(", ");
2359
+ log.debug(`Built publish input for platforms: ${platforms$1}`);
2128
2360
  } catch (error$2) {
2129
2361
  const message = getErrorMessage(error$2);
2130
- spinner$1.fail("Failed to build bundle");
2362
+ spinner$1.fail("Failed to build platform bundles");
2131
2363
  log.error(message);
2132
2364
  return {
2133
2365
  success: false,
2134
2366
  error: message
2135
2367
  };
2136
2368
  }
2137
- const inputJson = JSON.stringify(publishInput);
2138
- const inputSize = Buffer.byteLength(inputJson, "utf8");
2139
- const fileCount = publishInput.files.length;
2140
- log.debug(`Publish input size: ${formatBytes(inputSize)}, files: ${fileCount}`);
2141
- if (inputSize > MAX_BUNDLE_SIZE_BYTES) {
2142
- const errorMessage = `Bundle exceeds maximum size (${formatBytes(inputSize)} > ${formatBytes(MAX_BUNDLE_SIZE_BYTES)})`;
2143
- spinner$1.fail("Bundle too large");
2144
- log.error(errorMessage);
2145
- return {
2146
- success: false,
2147
- error: errorMessage
2148
- };
2369
+ const totalFileCount = publishInput.variants.reduce((sum, v) => sum + v.files.length, 0);
2370
+ const platformList = publishInput.variants.map((v) => v.platform).join(", ");
2371
+ let totalSize = 0;
2372
+ for (const variant of publishInput.variants) {
2373
+ const variantJson = JSON.stringify(variant);
2374
+ const variantSize = Buffer.byteLength(variantJson, "utf8");
2375
+ totalSize += variantSize;
2376
+ log.debug(`Variant ${variant.platform}: ${formatBytes(variantSize)}, ${variant.files.length} files`);
2377
+ if (variantSize > MAX_VARIANT_SIZE_BYTES) {
2378
+ const errorMessage = `Files for "${variant.platform}" exceed maximum size (${formatBytes(variantSize)} > ${formatBytes(MAX_VARIANT_SIZE_BYTES)})`;
2379
+ spinner$1.fail("Platform bundle too large");
2380
+ log.error(errorMessage);
2381
+ return {
2382
+ success: false,
2383
+ error: errorMessage
2384
+ };
2385
+ }
2149
2386
  }
2387
+ log.debug(`Total publish size: ${formatBytes(totalSize)}, files: ${totalFileCount}, platforms: ${platformList}`);
2150
2388
  if (dryRun) {
2151
2389
  spinner$1.success("Dry run complete");
2152
2390
  log.print("");
2153
2391
  log.print(ui.header("Publish Preview"));
2154
2392
  log.print(ui.keyValue("Preset", publishInput.title));
2155
- log.print(ui.keyValue("Slug", publishInput.slug));
2156
- log.print(ui.keyValue("Platform", publishInput.platform));
2393
+ log.print(ui.keyValue("Name", publishInput.name));
2394
+ log.print(ui.keyValue("Platforms", platformList));
2157
2395
  log.print(ui.keyValue("Version", version$1 ? `${version$1}.x (auto-assigned minor)` : "1.x (auto-assigned)"));
2158
- log.print(ui.keyValue("Files", `${fileCount} file${fileCount === 1 ? "" : "s"}`));
2159
- log.print(ui.keyValue("Size", formatBytes(inputSize)));
2160
- log.print("");
2161
- log.print(ui.header("Files to publish", fileCount));
2162
- log.print(ui.fileTree(publishInput.files));
2396
+ log.print(ui.keyValue("Files", `${totalFileCount} file${totalFileCount === 1 ? "" : "s"}`));
2397
+ log.print(ui.keyValue("Size", formatBytes(totalSize)));
2163
2398
  log.print("");
2399
+ for (const variant of publishInput.variants) {
2400
+ log.print(ui.fileTree(variant.files, {
2401
+ showFolderSizes: true,
2402
+ header: `Files for ${variant.platform}`
2403
+ }));
2404
+ log.print("");
2405
+ }
2164
2406
  log.print(ui.hint("Run without --dry-run to publish."));
2165
2407
  return {
2166
2408
  success: true,
2167
2409
  preview: {
2168
- slug: publishInput.slug,
2169
- platform: publishInput.platform,
2410
+ slug: publishInput.name,
2411
+ platforms: publishInput.variants.map((v) => v.platform),
2170
2412
  title: publishInput.title,
2171
- totalSize: inputSize,
2172
- fileCount
2413
+ totalSize,
2414
+ fileCount: totalFileCount
2173
2415
  }
2174
2416
  };
2175
2417
  }
2176
- spinner$1.update(`Publishing ${publishInput.title} (${publishInput.platform})...`);
2418
+ spinner$1.update(`Publishing ${publishInput.title} (${platformList})...`);
2177
2419
  if (!ctx.credentials) throw new Error("Credentials should exist at this point");
2178
2420
  const result = await publishPreset(ctx.registry.url, ctx.credentials.token, publishInput);
2179
2421
  if (!result.success) {
@@ -2197,23 +2439,27 @@ async function publish(options = {}) {
2197
2439
  }
2198
2440
  const { data } = result;
2199
2441
  const action = data.isNewPreset ? "Published new preset" : "Published";
2200
- spinner$1.success(`${action} ${ui.code(data.slug)} ${ui.version(data.version)} (${data.platform})`);
2442
+ const platforms = data.variants.map((v) => v.platform).join(", ");
2443
+ spinner$1.success(`${action} ${ui.code(data.slug)} ${ui.version(data.version)} (${platforms})`);
2201
2444
  log.print("");
2202
- log.print(ui.header("Published files", fileCount));
2203
- log.print(ui.fileTree(publishInput.files));
2204
- const presetName$1 = `${data.slug}.${data.platform}`;
2205
- const presetRegistryUrl = `${ctx.registry.url}preset/${presetName$1}`;
2445
+ for (const variant of publishInput.variants) {
2446
+ log.print(ui.fileTree(variant.files, {
2447
+ showFolderSizes: true,
2448
+ header: `Published files for ${variant.platform}`
2449
+ }));
2450
+ log.print("");
2451
+ }
2206
2452
  log.info("");
2207
- log.info(ui.keyValue("Now live at", ui.link(presetRegistryUrl)));
2453
+ log.info(ui.keyValue("Now live at", ui.link(data.url)));
2208
2454
  return {
2209
2455
  success: true,
2210
2456
  preset: {
2211
2457
  slug: data.slug,
2212
- platform: data.platform,
2213
2458
  title: data.title,
2214
2459
  version: data.version,
2215
2460
  isNewPreset: data.isNewPreset,
2216
- bundleUrl: data.bundleUrl
2461
+ variants: data.variants,
2462
+ url: data.url
2217
2463
  }
2218
2464
  };
2219
2465
  }
@@ -2242,7 +2488,7 @@ async function buildRegistry(options) {
2242
2488
  });
2243
2489
  if (validateOnly || !outputDir) return {
2244
2490
  presets: presets.length,
2245
- entries: result.entries.length,
2491
+ items: result.items.length,
2246
2492
  bundles: result.bundles.length,
2247
2493
  outputDir: null,
2248
2494
  validateOnly
@@ -2257,23 +2503,20 @@ async function buildRegistry(options) {
2257
2503
  await writeFile(join(bundleDir, bundle.version), bundleJson);
2258
2504
  await writeFile(join(bundleDir, LATEST_VERSION), bundleJson);
2259
2505
  }
2260
- for (const entry of result.entries) {
2261
- const apiPresetDir = join(outputDir, API_ENDPOINTS.presets.base, entry.slug, entry.platform);
2262
- await mkdir(apiPresetDir, { recursive: true });
2263
- const entryJson = JSON.stringify(entry, null, indent$1);
2264
- await writeFile(join(apiPresetDir, entry.version), entryJson);
2265
- await writeFile(join(apiPresetDir, LATEST_VERSION), entryJson);
2506
+ for (const item of result.items) {
2507
+ const itemJson = JSON.stringify(item, null, indent$1);
2508
+ const itemPath = join(outputDir, API_ENDPOINTS.items.get(item.slug));
2509
+ await mkdir(join(itemPath, ".."), { recursive: true });
2510
+ await writeFile(itemPath, itemJson);
2266
2511
  }
2267
2512
  const registryJson = JSON.stringify({
2268
2513
  $schema: "https://agentrules.directory/schema/registry.json",
2269
- items: result.entries
2514
+ items: result.items
2270
2515
  }, null, indent$1);
2271
2516
  await writeFile(join(outputDir, "registry.json"), registryJson);
2272
- const indexJson = JSON.stringify(result.index, null, indent$1);
2273
- await writeFile(join(outputDir, "registry.index.json"), indexJson);
2274
2517
  return {
2275
2518
  presets: presets.length,
2276
- entries: result.entries.length,
2519
+ items: result.items.length,
2277
2520
  bundles: result.bundles.length,
2278
2521
  outputDir,
2279
2522
  validateOnly: false
@@ -2296,19 +2539,147 @@ async function discoverPresetDirs(inputDir) {
2296
2539
  return presetDirs.sort();
2297
2540
  }
2298
2541
 
2542
+ //#endregion
2543
+ //#region src/commands/share.ts
2544
+ async function share(options = {}) {
2545
+ const ctx = useAppContext();
2546
+ if (!(ctx.isLoggedIn && ctx.credentials)) {
2547
+ const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
2548
+ log.error(error$2);
2549
+ return {
2550
+ success: false,
2551
+ error: error$2
2552
+ };
2553
+ }
2554
+ let content = options.content;
2555
+ if (options.file) {
2556
+ const filePath = resolve(options.file);
2557
+ try {
2558
+ content = await readFile(filePath, "utf-8");
2559
+ } catch {
2560
+ const error$2 = `Failed to read file: ${options.file}`;
2561
+ log.error(error$2);
2562
+ return {
2563
+ success: false,
2564
+ error: error$2
2565
+ };
2566
+ }
2567
+ }
2568
+ if (!options.name) {
2569
+ const error$2 = "Name is required. Use --name <name>";
2570
+ log.error(error$2);
2571
+ return {
2572
+ success: false,
2573
+ error: error$2
2574
+ };
2575
+ }
2576
+ if (!options.platform) {
2577
+ const error$2 = `Platform is required. Use --platform <${PLATFORM_IDS.join("|")}>`;
2578
+ log.error(error$2);
2579
+ return {
2580
+ success: false,
2581
+ error: error$2
2582
+ };
2583
+ }
2584
+ if (!PLATFORM_IDS.includes(options.platform)) {
2585
+ const error$2 = `Invalid platform "${options.platform}". Valid platforms: ${PLATFORM_IDS.join(", ")}`;
2586
+ log.error(error$2);
2587
+ return {
2588
+ success: false,
2589
+ error: error$2
2590
+ };
2591
+ }
2592
+ const validTypes = getValidRuleTypes(options.platform);
2593
+ if (!options.type) {
2594
+ const error$2 = `Type is required. Use --type <${validTypes.join("|")}>`;
2595
+ log.error(error$2);
2596
+ return {
2597
+ success: false,
2598
+ error: error$2
2599
+ };
2600
+ }
2601
+ if (!validTypes.includes(options.type)) {
2602
+ const error$2 = `Invalid type "${options.type}" for platform "${options.platform}". Valid types: ${validTypes.join(", ")}`;
2603
+ log.error(error$2);
2604
+ return {
2605
+ success: false,
2606
+ error: error$2
2607
+ };
2608
+ }
2609
+ if (!options.title) {
2610
+ const error$2 = "Title is required. Use --title <title>";
2611
+ log.error(error$2);
2612
+ return {
2613
+ success: false,
2614
+ error: error$2
2615
+ };
2616
+ }
2617
+ if (!content) {
2618
+ const error$2 = "Content is required. Provide a file path or use --content";
2619
+ log.error(error$2);
2620
+ return {
2621
+ success: false,
2622
+ error: error$2
2623
+ };
2624
+ }
2625
+ if (!options.tags || options.tags.length === 0) {
2626
+ const error$2 = "At least one tag is required. Use --tags <tag1,tag2,...>";
2627
+ log.error(error$2);
2628
+ return {
2629
+ success: false,
2630
+ error: error$2
2631
+ };
2632
+ }
2633
+ const spinner$1 = await log.spinner(`Publishing rule "${options.name}"...`);
2634
+ const result = await publishRule(ctx.registry.url, ctx.credentials.token, {
2635
+ name: options.name,
2636
+ platform: options.platform,
2637
+ type: options.type,
2638
+ title: options.title,
2639
+ description: options.description,
2640
+ content,
2641
+ tags: options.tags
2642
+ });
2643
+ if (!result.success) {
2644
+ spinner$1.fail("Publish failed");
2645
+ log.error(result.error);
2646
+ if (result.issues) for (const issue of result.issues) log.error(` ${issue.path}: ${issue.message}`);
2647
+ return {
2648
+ success: false,
2649
+ error: result.error
2650
+ };
2651
+ }
2652
+ const action = result.data.isNew ? "Created" : "Updated";
2653
+ spinner$1.success(`${action} rule ${ui.code(result.data.slug)}`);
2654
+ log.print("");
2655
+ log.print(ui.keyValue("Now live at", ui.link(result.data.url)));
2656
+ log.print("");
2657
+ log.print(ui.keyValue("Install command", ui.code(`npx @agentrules/cli add ${result.data.slug}`)));
2658
+ return {
2659
+ success: true,
2660
+ rule: {
2661
+ slug: result.data.slug,
2662
+ platform: result.data.platform,
2663
+ type: result.data.type,
2664
+ title: result.data.title,
2665
+ isNew: result.data.isNew
2666
+ }
2667
+ };
2668
+ }
2669
+
2299
2670
  //#endregion
2300
2671
  //#region src/commands/unpublish.ts
2301
2672
  /**
2302
- * Parses preset input to extract slug, platform, and version.
2673
+ * Parses preset input to extract slug and version.
2303
2674
  * Supports formats:
2304
- * - "my-preset.claude@1.0" (platform and version in string)
2305
- * - "my-preset@1.0" (requires explicit platform)
2306
- * - "my-preset.claude" (requires explicit version)
2675
+ * - "my-preset@1.0" (slug and version)
2676
+ * - "username/my-preset@1.0" (namespaced slug and version)
2677
+ * - "my-preset" (requires explicit --version)
2307
2678
  *
2308
- * Explicit --platform and --version flags take precedence.
2679
+ * Explicit --version flag takes precedence.
2309
2680
  */
2310
- function parseUnpublishInput(input, explicitPlatform, explicitVersion) {
2311
- let normalized = input.toLowerCase().trim();
2681
+ function parseUnpublishInput(input, explicitVersion) {
2682
+ let normalized = input.trim();
2312
2683
  let parsedVersion;
2313
2684
  const atIndex = normalized.lastIndexOf("@");
2314
2685
  if (atIndex > 0) {
@@ -2316,28 +2687,17 @@ function parseUnpublishInput(input, explicitPlatform, explicitVersion) {
2316
2687
  normalized = normalized.slice(0, atIndex);
2317
2688
  }
2318
2689
  const version$1 = explicitVersion ?? parsedVersion;
2319
- const parts = normalized.split(".");
2320
- const maybePlatform = parts.at(-1);
2321
- let slug;
2322
- let platform;
2323
- if (maybePlatform && isSupportedPlatform(maybePlatform)) {
2324
- slug = parts.slice(0, -1).join(".");
2325
- platform = explicitPlatform ?? maybePlatform;
2326
- } else {
2327
- slug = normalized;
2328
- platform = explicitPlatform;
2329
- }
2330
2690
  return {
2331
- slug,
2332
- platform,
2691
+ slug: normalized,
2333
2692
  version: version$1
2334
2693
  };
2335
2694
  }
2336
2695
  /**
2337
- * Unpublishes a preset version from the registry
2696
+ * Unpublishes a preset version from the registry.
2697
+ * This unpublishes all platform variants for the specified version.
2338
2698
  */
2339
2699
  async function unpublish(options) {
2340
- const { slug, platform, version: version$1 } = parseUnpublishInput(options.preset, options.platform, options.version);
2700
+ const { slug, version: version$1 } = parseUnpublishInput(options.preset, options.version);
2341
2701
  if (!slug) {
2342
2702
  log.error("Preset slug is required");
2343
2703
  return {
@@ -2345,21 +2705,14 @@ async function unpublish(options) {
2345
2705
  error: "Preset slug is required"
2346
2706
  };
2347
2707
  }
2348
- if (!platform) {
2349
- log.error("Platform is required. Use --platform or specify as <slug>.<platform>@<version>");
2350
- return {
2351
- success: false,
2352
- error: "Platform is required"
2353
- };
2354
- }
2355
2708
  if (!version$1) {
2356
- log.error("Version is required. Use --version or specify as <slug>.<platform>@<version>");
2709
+ log.error("Version is required. Use --version or specify as <slug>@<version>");
2357
2710
  return {
2358
2711
  success: false,
2359
2712
  error: "Version is required"
2360
2713
  };
2361
2714
  }
2362
- log.debug(`Unpublishing preset: ${slug}.${platform}@${version$1}`);
2715
+ log.debug(`Unpublishing preset: ${slug}@${version$1}`);
2363
2716
  const ctx = useAppContext();
2364
2717
  if (!(ctx.isLoggedIn && ctx.credentials)) {
2365
2718
  const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
@@ -2370,8 +2723,8 @@ async function unpublish(options) {
2370
2723
  };
2371
2724
  }
2372
2725
  log.debug(`Authenticated, unpublishing from ${ctx.registry.url}`);
2373
- const spinner$1 = await log.spinner(`Unpublishing ${ui.code(slug)}.${platform} ${ui.version(version$1)}...`);
2374
- const result = await unpublishPreset(ctx.registry.url, ctx.credentials.token, slug, platform, version$1);
2726
+ const spinner$1 = await log.spinner(`Unpublishing ${ui.code(slug)} ${ui.version(version$1)}...`);
2727
+ const result = await unpublishPreset(ctx.registry.url, ctx.credentials.token, slug, version$1);
2375
2728
  if (!result.success) {
2376
2729
  spinner$1.fail("Unpublish failed");
2377
2730
  log.error(result.error);
@@ -2382,18 +2735,63 @@ async function unpublish(options) {
2382
2735
  };
2383
2736
  }
2384
2737
  const { data } = result;
2385
- spinner$1.success(`Unpublished ${ui.code(data.slug)}.${data.platform} ${ui.version(data.version)}`);
2386
- log.info(ui.hint("This version can no longer be republished."));
2738
+ spinner$1.success(`Unpublished ${ui.code(data.slug)} ${ui.version(data.version)}`);
2739
+ log.info(ui.hint("This version and all its platform variants have been removed."));
2387
2740
  return {
2388
2741
  success: true,
2389
2742
  preset: {
2390
2743
  slug: data.slug,
2391
- platform: data.platform,
2392
2744
  version: data.version
2393
2745
  }
2394
2746
  };
2395
2747
  }
2396
2748
 
2749
+ //#endregion
2750
+ //#region src/commands/unshare.ts
2751
+ /**
2752
+ * Unshares a rule from the registry (soft delete)
2753
+ */
2754
+ async function unshare(options) {
2755
+ const slug = options.slug?.trim().toLowerCase();
2756
+ if (!slug) {
2757
+ const error$2 = "Rule slug is required. Usage: agentrules unshare <slug>";
2758
+ log.error(error$2);
2759
+ return {
2760
+ success: false,
2761
+ error: error$2
2762
+ };
2763
+ }
2764
+ log.debug(`Unsharing rule: ${slug}`);
2765
+ const ctx = useAppContext();
2766
+ if (!(ctx.isLoggedIn && ctx.credentials)) {
2767
+ const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
2768
+ log.error(error$2);
2769
+ return {
2770
+ success: false,
2771
+ error: error$2
2772
+ };
2773
+ }
2774
+ log.debug(`Authenticated, unsharing from ${ctx.registry.url}`);
2775
+ const spinner$1 = await log.spinner(`Unsharing ${ui.code(slug)}...`);
2776
+ const result = await deleteRule(ctx.registry.url, ctx.credentials.token, slug);
2777
+ if (!result.success) {
2778
+ spinner$1.fail("Unshare failed");
2779
+ log.error(result.error);
2780
+ if (result.error.includes("connect")) log.info(ui.hint("Check your network connection and try again."));
2781
+ return {
2782
+ success: false,
2783
+ error: result.error
2784
+ };
2785
+ }
2786
+ const { data } = result;
2787
+ spinner$1.success(`Unshared ${ui.code(data.slug)}`);
2788
+ log.info(ui.hint("The rule has been removed from the registry."));
2789
+ return {
2790
+ success: true,
2791
+ rule: { slug: data.slug }
2792
+ };
2793
+ }
2794
+
2397
2795
  //#endregion
2398
2796
  //#region src/help-agent/publish.ts
2399
2797
  /**
@@ -2680,14 +3078,14 @@ program.name("agentrules").description("The AI Agent Directory CLI").version(pac
2680
3078
  log.debug(`Failed to init context: ${getErrorMessage(error$2)}`);
2681
3079
  }
2682
3080
  }).showHelpAfterError();
2683
- program.command("add <preset>").description("Download and install a preset from the registry").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("-V, --version <version>", "Install a specific version").option("-r, --registry <alias>", "Use a specific registry alias").option("-g, --global", "Install to global directory").option("--dir <path>", "Install to a custom directory").option("-f, --force", "Overwrite existing files (backs up originals)").option("-y, --yes", "Alias for --force").option("--dry-run", "Preview changes without writing").option("--skip-conflicts", "Skip conflicting files").option("--no-backup", "Don't backup files before overwriting (use with --force)").action(handle(async (preset, options) => {
2684
- const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
3081
+ program.command("add <item>").description("Download and install a preset or rule from the registry").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("--version <version>", "Install a specific version (or use slug@version)").option("-r, --registry <alias>", "Use a specific registry alias").option("-g, --global", "Install to global directory").option("--dir <path>", "Install to a custom directory").option("-f, --force", "Overwrite existing files (backs up originals)").option("-y, --yes", "Alias for --force").option("--dry-run", "Preview changes without writing").option("--skip-conflicts", "Skip conflicting files (presets only)").option("--no-backup", "Don't backup files before overwriting (use with --force)").action(handle(async (item, options) => {
2685
3082
  const dryRun = Boolean(options.dryRun);
2686
- const spinner$1 = await log.spinner("Fetching preset...");
3083
+ const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
3084
+ const spinner$1 = await log.spinner("Resolving...");
2687
3085
  let result;
2688
3086
  try {
2689
- result = await addPreset({
2690
- preset,
3087
+ result = await add({
3088
+ slug: item,
2691
3089
  platform,
2692
3090
  version: options.version,
2693
3091
  global: Boolean(options.global),
@@ -2702,22 +3100,28 @@ program.command("add <preset>").description("Download and install a preset from
2702
3100
  throw err;
2703
3101
  }
2704
3102
  spinner$1.stop();
2705
- const hasBlockingConflicts = result.conflicts.length > 0 && !options.skipConflicts && !dryRun;
3103
+ const conflicts = result.files.filter((f) => f.status === "conflict");
3104
+ const hasBlockingConflicts = conflicts.length > 0 && !options.skipConflicts && !dryRun;
2706
3105
  if (hasBlockingConflicts) {
2707
- const count$1 = result.conflicts.length === 1 ? "1 file has" : `${result.conflicts.length} files have`;
3106
+ const count$1 = conflicts.length === 1 ? "1 file has" : `${conflicts.length} files have`;
2708
3107
  const forceHint = `Use ${ui.command("--force")} to overwrite ${ui.muted("(--no-backup to skip backups)")}`;
2709
3108
  log.error(`${count$1} conflicts. ${forceHint}`);
2710
3109
  log.print("");
2711
- for (const conflict of result.conflicts.slice(0, 3)) {
3110
+ for (const conflict of conflicts.slice(0, 3)) {
2712
3111
  log.print(` ${ui.muted("•")} ${conflict.path}`);
2713
3112
  if (conflict.diff) log.print(conflict.diff.split("\n").map((l) => ` ${l}`).join("\n"));
2714
3113
  }
2715
- if (result.conflicts.length > 3) log.print(`\n ${ui.muted(`...and ${result.conflicts.length - 3} more`)}`);
3114
+ if (conflicts.length > 3) log.print(`\n ${ui.muted(`...and ${conflicts.length - 3} more`)}`);
2716
3115
  log.print("");
2717
3116
  log.print(forceHint);
2718
3117
  process.exitCode = 1;
2719
3118
  return;
2720
3119
  }
3120
+ const allUnchanged = result.files.every((f) => f.status === "unchanged");
3121
+ if (allUnchanged) {
3122
+ log.info("Already up to date.");
3123
+ return;
3124
+ }
2721
3125
  if (result.backups.length > 0) {
2722
3126
  log.print("");
2723
3127
  for (const backup of result.backups) log.print(ui.backupStatus(backup.originalPath, backup.backupPath, { dryRun }));
@@ -2730,10 +3134,12 @@ program.command("add <preset>").description("Download and install a preset from
2730
3134
  }
2731
3135
  log.print("");
2732
3136
  const verb = dryRun ? "Would install" : "Installed";
2733
- log.success(`${verb} ${ui.bold(result.preset.title)} ${ui.muted(`for ${result.preset.platform}`)}`);
2734
- if (result.conflicts.length > 0 && options.skipConflicts) log.warn(`${result.conflicts.length} conflicting file${result.conflicts.length === 1 ? "" : "s"} skipped`);
3137
+ const kindLabel = result.kind === "rule" ? `(${result.variant.platform}/${result.variant.type})` : `for ${result.variant.platform}`;
3138
+ log.success(`${verb} ${ui.bold(result.resolved.title)} ${ui.muted(kindLabel)}`);
3139
+ const skippedConflicts = result.files.filter((f) => f.status === "skipped");
3140
+ if (skippedConflicts.length > 0) log.warn(`${skippedConflicts.length} conflicting file${skippedConflicts.length === 1 ? "" : "s"} skipped`);
2735
3141
  if (dryRun) log.print(ui.hint("\nDry run complete. No files were written."));
2736
- if (result.bundle.installMessage) log.print(`\n${result.bundle.installMessage}`);
3142
+ if (result.kind === "preset" && result.bundle.installMessage) log.print(`\n${result.bundle.installMessage}`);
2737
3143
  }));
2738
3144
  program.command("init").description("Initialize a new preset").argument("[directory]", "Directory to initialize (created if it doesn't exist)").option("-y, --yes", "Accept defaults without prompting").option("-n, --name <name>", "Preset name").option("-t, --title <title>", "Display title").option("--description <text>", "Preset description").option("-p, --platform <platform>", "Target platform (opencode, claude, cursor, codex)").option("-l, --license <license>", "License (e.g., MIT)").option("-f, --force", "Overwrite existing agentrules.json").action(handle(async (directory, options) => {
2739
3145
  const targetDir = directory ?? process.cwd();
@@ -2799,10 +3205,11 @@ program.command("validate").description("Validate an agentrules.json configurati
2799
3205
  const result = await validatePreset({ path: path$1 });
2800
3206
  if (result.valid && result.preset) {
2801
3207
  const p$1 = result.preset;
3208
+ const platforms = p$1.platforms.map((entry) => entry.platform).join(", ");
2802
3209
  log.success(p$1.title);
2803
3210
  log.print(ui.keyValue("Description", p$1.description));
2804
3211
  log.print(ui.keyValue("License", p$1.license));
2805
- log.print(ui.keyValue("Platform", p$1.platform));
3212
+ log.print(ui.keyValue("Platforms", platforms));
2806
3213
  if (p$1.tags?.length) log.print(ui.keyValue("Tags", p$1.tags.join(", ")));
2807
3214
  } else if (!result.valid) log.error(`Invalid: ${ui.path(result.configPath)}`);
2808
3215
  if (result.errors.length > 0) {
@@ -2839,15 +3246,15 @@ registry.command("build").description("Build registry from preset directories").
2839
3246
  validateOnly: options.validateOnly
2840
3247
  });
2841
3248
  if (result.validateOnly) {
2842
- log.success(`Validated ${result.presets} preset${result.presets === 1 ? "" : "s"} ${ui.muted(`→ ${result.entries} entries`)}`);
3249
+ log.success(`Validated ${result.presets} preset${result.presets === 1 ? "" : "s"} ${ui.muted(`→ ${result.items} items`)}`);
2843
3250
  return;
2844
3251
  }
2845
3252
  if (!result.outputDir) {
2846
- log.info(`Found ${result.presets} preset${result.presets === 1 ? "" : "s"} → ${result.entries} entries`);
3253
+ log.info(`Found ${result.presets} preset${result.presets === 1 ? "" : "s"} → ${result.items} items`);
2847
3254
  log.print(ui.hint(`Use ${ui.command("--out <path>")} to write files`));
2848
3255
  return;
2849
3256
  }
2850
- log.success(`Built ${result.presets} preset${result.presets === 1 ? "" : "s"} ${ui.muted(`→ ${result.entries} entries, ${result.bundles} bundles`)}`);
3257
+ log.success(`Built ${result.presets} preset${result.presets === 1 ? "" : "s"} ${ui.muted(`→ ${result.items} items, ${result.bundles} bundles`)}`);
2851
3258
  log.print(ui.keyValue("Output", ui.path(result.outputDir)));
2852
3259
  }));
2853
3260
  registry.command("add <alias> <url>").description("Add or update a registry endpoint").option("-f, --force", "Overwrite existing entry").option("-d, --default", "Set as default registry").action(handle(async (alias, url, options) => {
@@ -2925,7 +3332,7 @@ program.command("whoami").description("Show the currently authenticated user").a
2925
3332
  log.print(ui.keyValue("Session", `expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? "" : "s"}`));
2926
3333
  }
2927
3334
  }));
2928
- program.command("publish").description("Publish a preset to the registry").argument("[path]", "Path to agentrules.json or directory containing it").option("-V, --version <major>", "Major version", Number.parseInt).option("--dry-run", "Preview what would be published without publishing").action(handle(async (path$1, options) => {
3335
+ program.command("publish").description("Publish a preset to the registry").argument("[path]", "Path to agentrules.json or directory containing it").option("--version <major>", "Major version (overrides config)", Number.parseInt).option("--dry-run", "Preview what would be published without publishing").action(handle(async (path$1, options) => {
2929
3336
  const result = await publish({
2930
3337
  path: path$1,
2931
3338
  version: options.version,
@@ -2933,11 +3340,28 @@ program.command("publish").description("Publish a preset to the registry").argum
2933
3340
  });
2934
3341
  if (!result.success) process.exitCode = 1;
2935
3342
  }));
2936
- program.command("unpublish").description("Remove a preset version from the registry").argument("<preset>", "Preset to unpublish (e.g., my-preset.claude@1.0 or my-preset@1.0)").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("-V, --version <version>", "Version to unpublish").action(handle(async (preset, options) => {
2937
- const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
3343
+ program.command("share").description("Share a rule to the registry").argument("[file]", "Path to file containing rule content").requiredOption("-n, --name <name>", "Rule name (URL identifier)").requiredOption("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").requiredOption("-t, --type <type>", "Rule type (instruction, agent, command, tool, skill, rule)").requiredOption("--title <title>", "Display title").requiredOption("--tags <tags>", "Comma-separated tags (e.g., typescript,react,testing)").option("--description <text>", "Optional description").option("-c, --content <content>", "Rule content (or provide file path)").action(handle(async (file, options) => {
3344
+ const platform = normalizePlatformInput(options.platform);
3345
+ const tags = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
3346
+ const result = await share({
3347
+ name: options.name,
3348
+ platform,
3349
+ type: options.type,
3350
+ title: options.title,
3351
+ description: options.description,
3352
+ content: options.content,
3353
+ file,
3354
+ tags
3355
+ });
3356
+ if (!result.success) process.exitCode = 1;
3357
+ }));
3358
+ program.command("unshare").description("Remove a rule from the registry").argument("<slug>", "Rule slug to unshare").action(handle(async (slug) => {
3359
+ const result = await unshare({ slug });
3360
+ if (!result.success) process.exitCode = 1;
3361
+ }));
3362
+ program.command("unpublish").description("Remove a preset version from the registry (removes all platform variants)").argument("<preset>", "Preset to unpublish (e.g., my-preset@1.0.0)").option("--version <version>", "Version to unpublish (or use preset@version)").action(handle(async (preset, options) => {
2938
3363
  const result = await unpublish({
2939
3364
  preset,
2940
- platform,
2941
3365
  version: options.version
2942
3366
  });
2943
3367
  if (!result.success) process.exitCode = 1;