@agentrules/cli 0.0.14 → 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 +1060 -808
  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, getInstallPath, 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,10 @@ 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)}`;
688
866
  log.debug(`DELETE ${url}`);
689
867
  try {
690
868
  const response = await fetch(url, {
@@ -713,12 +891,52 @@ async function unpublishPreset(baseUrl, token, slug, platform, version$1) {
713
891
  }
714
892
 
715
893
  //#endregion
716
- //#region src/lib/api/rule.ts
717
- async function getRule(baseUrl, slug) {
718
- const url = `${baseUrl}${API_ENDPOINTS.rule.get(slug)}`;
719
- log.debug(`GET ${url}`);
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)}`;
934
+ log.debug(`DELETE ${url}`);
720
935
  try {
721
- const response = await fetch(url);
936
+ const response = await fetch(url, {
937
+ method: "DELETE",
938
+ headers: { Authorization: `Bearer ${token}` }
939
+ });
722
940
  log.debug(`Response status: ${response.status}`);
723
941
  if (!response.ok) {
724
942
  const errorData = await response.json();
@@ -764,201 +982,43 @@ async function fetchSession(baseUrl, token) {
764
982
  }
765
983
 
766
984
  //#endregion
767
- //#region src/lib/config.ts
768
- /** Directory for CLI configuration and credentials (e.g., ~/.agentrules/) */
769
- const CONFIG_DIRNAME = ".agentrules";
770
- const CONFIG_FILENAME = "config.json";
771
- const CONFIG_HOME_ENV = "AGENT_RULES_HOME";
772
- const DEFAULT_REGISTRY_ALIAS = "main";
773
- const DEFAULT_REGISTRY_URL = "https://agentrules.directory/";
774
- const DEFAULT_CONFIG = {
775
- defaultRegistry: DEFAULT_REGISTRY_ALIAS,
776
- registries: { [DEFAULT_REGISTRY_ALIAS]: { url: DEFAULT_REGISTRY_URL } }
777
- };
778
- async function loadConfig() {
779
- await ensureConfigDir();
780
- const configPath = getConfigPath();
781
- log.debug(`Loading config from ${configPath}`);
985
+ //#region src/lib/credentials.ts
986
+ const chmodAsync = promisify(chmod);
987
+ const CREDENTIALS_FILENAME = "credentials.json";
988
+ /**
989
+ * Gets the path to the credentials file
990
+ */
991
+ function getCredentialsPath() {
992
+ return join(getConfigDir(), CREDENTIALS_FILENAME);
993
+ }
994
+ /**
995
+ * Normalizes a registry URL for use as a credentials key.
996
+ * - Lowercases hostname (case-insensitive per spec)
997
+ * - Normalizes default ports (443 for https, 80 for http)
998
+ * - Preserves path (case-sensitive)
999
+ * - Strips trailing slash
1000
+ * - Strips query string and fragment (not relevant for auth)
1001
+ */
1002
+ function normalizeUrl(url) {
1003
+ const parsed = new URL(url);
1004
+ const path$1 = parsed.pathname.replace(/\/$/, "");
1005
+ return parsed.origin + path$1;
1006
+ }
1007
+ /**
1008
+ * Loads the entire credentials store
1009
+ */
1010
+ async function loadStore() {
1011
+ const credentialsPath = getCredentialsPath();
782
1012
  try {
783
- await access(configPath, constants.F_OK);
784
- } catch (error$2) {
785
- if (isNodeError(error$2) && error$2.code === "ENOENT") {
786
- log.debug("Config file not found, creating default config");
787
- await writeDefaultConfig();
788
- return structuredClone(DEFAULT_CONFIG);
789
- }
790
- throw error$2 instanceof Error ? error$2 : new Error(String(error$2));
1013
+ await access(credentialsPath, constants.F_OK);
1014
+ } catch {
1015
+ log.debug(`Credentials file not found at ${credentialsPath}`);
1016
+ return {};
791
1017
  }
792
- const raw = await readFile(configPath, "utf8");
793
- let parsed = {};
794
1018
  try {
795
- parsed = JSON.parse(raw);
796
- } catch (error$2) {
797
- throw new Error(`Failed to parse ${configPath}: ${error$2.message}`);
798
- }
799
- return mergeWithDefaults(parsed);
800
- }
801
- async function saveConfig(config) {
802
- await ensureConfigDir();
803
- const configPath = getConfigPath();
804
- log.debug(`Saving config to ${configPath}`);
805
- const serialized = JSON.stringify(config, null, 2);
806
- await writeFile(configPath, serialized, "utf8");
807
- }
808
- function getConfigPath() {
809
- return join(getConfigDir(), CONFIG_FILENAME);
810
- }
811
- function getConfigDir() {
812
- const customDir = process.env[CONFIG_HOME_ENV];
813
- if (customDir && customDir.trim().length > 0) return customDir;
814
- return join(homedir(), CONFIG_DIRNAME);
815
- }
816
- /**
817
- * Normalizes a registry URL to a base URL with trailing slash.
818
- *
819
- * Examples:
820
- * - "https://example.com" → "https://example.com/"
821
- * - "https://example.com/custom/" → "https://example.com/custom/"
822
- * - "https://example.com/custom" → "https://example.com/custom/"
823
- */
824
- function normalizeRegistryUrl(input) {
825
- try {
826
- const parsed = new URL(input);
827
- if (!parsed.pathname.endsWith("/")) parsed.pathname = `${parsed.pathname}/`;
828
- return parsed.toString();
829
- } catch (error$2) {
830
- throw new Error(`Invalid registry URL "${input}": ${error$2.message}`);
831
- }
832
- }
833
- async function ensureConfigDir() {
834
- const dir = getConfigDir();
835
- await mkdir(dir, { recursive: true });
836
- }
837
- async function writeDefaultConfig() {
838
- const configPath = getConfigPath();
839
- await mkdir(dirname(configPath), { recursive: true });
840
- await writeFile(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf8");
841
- }
842
- function isNodeError(error$2) {
843
- return error$2 instanceof Error && typeof error$2.code === "string";
844
- }
845
- function mergeWithDefaults(partial) {
846
- const registries = {
847
- ...DEFAULT_CONFIG.registries,
848
- ...partial.registries ?? {}
849
- };
850
- if (!registries[DEFAULT_REGISTRY_ALIAS]) registries[DEFAULT_REGISTRY_ALIAS] = structuredClone(DEFAULT_CONFIG.registries[DEFAULT_REGISTRY_ALIAS]);
851
- return {
852
- defaultRegistry: partial.defaultRegistry ?? DEFAULT_CONFIG.defaultRegistry,
853
- registries
854
- };
855
- }
856
-
857
- //#endregion
858
- //#region src/commands/registry/manage.ts
859
- const REGISTRY_ALIAS_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/i;
860
- async function listRegistries() {
861
- const config = await loadConfig();
862
- const items = Object.entries(config.registries).map(([alias, settings]) => ({
863
- alias,
864
- ...settings,
865
- isDefault: alias === config.defaultRegistry
866
- })).sort((a, b) => a.alias.localeCompare(b.alias));
867
- log.debug(`Loaded ${items.length} registries`);
868
- return items;
869
- }
870
- async function addRegistry(alias, url, options = {}) {
871
- const normalizedAlias = normalizeAlias(alias);
872
- const normalizedUrl = normalizeRegistryUrl(url);
873
- const config = await loadConfig();
874
- if (config.registries[normalizedAlias] && !options.overwrite) throw new Error(`Registry "${normalizedAlias}" already exists. Re-run with --force to overwrite.`);
875
- const isUpdate = !!config.registries[normalizedAlias];
876
- config.registries[normalizedAlias] = { url: normalizedUrl };
877
- if (!config.defaultRegistry || options.makeDefault) {
878
- config.defaultRegistry = normalizedAlias;
879
- log.debug(`Set "${normalizedAlias}" as default registry`);
880
- }
881
- await saveConfig(config);
882
- log.debug(`${isUpdate ? "Updated" : "Added"} registry "${normalizedAlias}" -> ${normalizedUrl}`);
883
- return config.registries[normalizedAlias];
884
- }
885
- async function removeRegistry(alias, options = {}) {
886
- const normalizedAlias = normalizeAlias(alias);
887
- const config = await loadConfig();
888
- if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" was not found.`);
889
- if (normalizedAlias === DEFAULT_REGISTRY_ALIAS) throw new Error("The built-in main registry cannot be removed. Point it somewhere else instead.");
890
- const isDefault = normalizedAlias === config.defaultRegistry;
891
- if (isDefault && !options.allowDefaultRemoval) throw new Error(`Registry "${normalizedAlias}" is currently the default. Re-run with --force to remove it.`);
892
- delete config.registries[normalizedAlias];
893
- log.debug(`Removed registry "${normalizedAlias}"`);
894
- if (isDefault) {
895
- config.defaultRegistry = pickFallbackDefault(config, normalizedAlias);
896
- log.debug(`Default registry changed to "${config.defaultRegistry}"`);
897
- }
898
- await saveConfig(config);
899
- return {
900
- removedDefault: isDefault,
901
- nextDefault: config.defaultRegistry
902
- };
903
- }
904
- async function useRegistry(alias) {
905
- const normalizedAlias = normalizeAlias(alias);
906
- const config = await loadConfig();
907
- if (!config.registries[normalizedAlias]) throw new Error(`Registry "${normalizedAlias}" is not defined.`);
908
- config.defaultRegistry = normalizedAlias;
909
- await saveConfig(config);
910
- log.debug(`Switched default registry to "${normalizedAlias}"`);
911
- }
912
- function normalizeAlias(alias) {
913
- const trimmed = alias.trim();
914
- if (!trimmed) throw new Error("Alias is required.");
915
- const normalized = trimmed.toLowerCase();
916
- if (!REGISTRY_ALIAS_PATTERN.test(normalized)) throw new Error("Aliases may only contain letters, numbers, dashes, and underscores.");
917
- return normalized;
918
- }
919
- function pickFallbackDefault(config, removedAlias) {
920
- const fallback = Object.keys(config.registries).find((alias) => alias !== removedAlias);
921
- return fallback ?? DEFAULT_REGISTRY_ALIAS;
922
- }
923
-
924
- //#endregion
925
- //#region src/lib/credentials.ts
926
- const chmodAsync = promisify(chmod);
927
- const CREDENTIALS_FILENAME = "credentials.json";
928
- /**
929
- * Gets the path to the credentials file
930
- */
931
- function getCredentialsPath() {
932
- return join(getConfigDir(), CREDENTIALS_FILENAME);
933
- }
934
- /**
935
- * Normalizes a registry URL for use as a credentials key.
936
- * - Lowercases hostname (case-insensitive per spec)
937
- * - Normalizes default ports (443 for https, 80 for http)
938
- * - Preserves path (case-sensitive)
939
- * - Strips trailing slash
940
- * - Strips query string and fragment (not relevant for auth)
941
- */
942
- function normalizeUrl(url) {
943
- const parsed = new URL(url);
944
- const path$1 = parsed.pathname.replace(/\/$/, "");
945
- return parsed.origin + path$1;
946
- }
947
- /**
948
- * Loads the entire credentials store
949
- */
950
- async function loadStore() {
951
- const credentialsPath = getCredentialsPath();
952
- try {
953
- await access(credentialsPath, constants$1.F_OK);
954
- } catch {
955
- log.debug(`Credentials file not found at ${credentialsPath}`);
956
- return {};
957
- }
958
- try {
959
- const raw = await readFile(credentialsPath, "utf8");
960
- const store = JSON.parse(raw);
961
- return store;
1019
+ const raw = await readFile(credentialsPath, "utf8");
1020
+ const store = JSON.parse(raw);
1021
+ return store;
962
1022
  } catch (error$2) {
963
1023
  log.debug(`Failed to load credentials: ${getErrorMessage(error$2)}`);
964
1024
  return {};
@@ -1042,7 +1102,7 @@ async function clearAllCredentials() {
1042
1102
  async function createAppContext(options = {}) {
1043
1103
  const { url: explicitUrl, registryAlias } = options;
1044
1104
  log.debug("Loading app context");
1045
- const config = await loadConfig();
1105
+ const config = await loadConfig$1();
1046
1106
  const registry$1 = explicitUrl ? resolveExplicitUrl(explicitUrl) : resolveRegistry(config, registryAlias);
1047
1107
  log.debug(`Active registry: ${registry$1.alias} → ${registry$1.url}`);
1048
1108
  const credentials = await getCredentials(registry$1.url);
@@ -1127,274 +1187,150 @@ async function initAppContext(options = {}) {
1127
1187
  }
1128
1188
 
1129
1189
  //#endregion
1130
- //#region src/commands/auth/login.ts
1131
- const execAsync = promisify(exec);
1132
- const CLIENT_ID = "agentrules-cli";
1133
- /**
1134
- * Performs device code flow login to an agentrules registry.
1135
- */
1136
- async function login(options = {}) {
1137
- const { noBrowser = false, onDeviceCode, onBrowserOpen, onPollingStart, onAuthorized } = options;
1190
+ //#region src/commands/add.ts
1191
+ async function add(options) {
1138
1192
  const ctx = useAppContext();
1139
- const { url: registryUrl } = ctx.registry;
1140
- log.debug(`Authenticating with ${registryUrl}`);
1141
- try {
1142
- log.debug("Requesting device code");
1143
- const codeResult = await requestDeviceCode({
1144
- issuer: registryUrl,
1145
- clientId: CLIENT_ID
1146
- });
1147
- if (codeResult.success === false) return {
1148
- success: false,
1149
- error: codeResult.error
1150
- };
1151
- const { data: deviceAuthResponse, config } = codeResult;
1152
- const formattedCode = formatUserCode(deviceAuthResponse.user_code);
1153
- onDeviceCode?.({
1154
- userCode: formattedCode,
1155
- verificationUri: deviceAuthResponse.verification_uri,
1156
- verificationUriComplete: deviceAuthResponse.verification_uri_complete
1157
- });
1158
- let browserOpened = false;
1159
- if (!noBrowser) try {
1160
- const urlToOpen = deviceAuthResponse.verification_uri_complete ?? deviceAuthResponse.verification_uri;
1161
- log.debug(`Opening browser: ${urlToOpen}`);
1162
- await openBrowser(urlToOpen);
1163
- browserOpened = true;
1164
- } catch {
1165
- log.debug("Failed to open browser");
1166
- }
1167
- onBrowserOpen?.(browserOpened);
1168
- log.debug("Waiting for user authorization");
1169
- onPollingStart?.();
1170
- const pollResult = await pollForToken({
1171
- config,
1172
- deviceAuthorizationResponse: deviceAuthResponse
1173
- });
1174
- if (pollResult.success === false) return {
1175
- success: false,
1176
- error: pollResult.error
1177
- };
1178
- onAuthorized?.();
1179
- const token = pollResult.token.access_token;
1180
- log.debug("Fetching user info");
1181
- const session = await fetchSession(registryUrl, token);
1182
- const user = session?.user;
1183
- const sessionExpiresAt = session?.session?.expiresAt;
1184
- const expiresAt = sessionExpiresAt ?? (pollResult.token.expires_in ? new Date(Date.now() + pollResult.token.expires_in * 1e3).toISOString() : void 0);
1185
- log.debug("Saving credentials");
1186
- await saveCredentials(registryUrl, {
1187
- token,
1188
- expiresAt,
1189
- userId: user?.id,
1190
- userName: user?.name,
1191
- 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
1192
1205
  });
1193
- return {
1194
- success: true,
1195
- user: user ? {
1196
- id: user.id,
1197
- name: user.name,
1198
- email: user.email
1199
- } : void 0
1200
- };
1201
- } catch (error$2) {
1202
- return {
1203
- success: false,
1204
- error: getErrorMessage(error$2)
1205
- };
1206
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
+ });
1207
1213
  }
1208
- /**
1209
- * Format user code as XXXX-XXXX for display.
1210
- */
1211
- function formatUserCode(code$1) {
1212
- const cleaned = code$1.toUpperCase().replace(/[^A-Z0-9]/g, "");
1213
- if (cleaned.length <= 4) return cleaned;
1214
- return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`;
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}".`);
1219
+ }
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}.`);
1227
+ return {
1228
+ selectedVersion,
1229
+ selectedVariant
1230
+ };
1215
1231
  }
1216
- /**
1217
- * Opens a URL in the default browser.
1218
- */
1219
- async function openBrowser(url) {
1220
- const platform = process.platform;
1221
- const commands = {
1222
- darwin: `open "${url}"`,
1223
- win32: `start "" "${url}"`,
1224
- linux: `xdg-open "${url}"`
1225
- };
1226
- const command$1 = commands[platform];
1227
- if (!command$1) throw new Error(`Unsupported platform: ${platform}`);
1228
- await execAsync(command$1);
1229
- }
1230
-
1231
- //#endregion
1232
- //#region src/commands/auth/logout.ts
1233
- /**
1234
- * Logs out by clearing stored credentials
1235
- */
1236
- async function logout(options = {}) {
1237
- const { all = false } = options;
1238
- if (all) {
1239
- log.debug("Clearing all stored credentials");
1240
- await clearAllCredentials();
1241
- return {
1242
- success: true,
1243
- hadCredentials: true
1244
- };
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}".`);
1245
1237
  }
1246
- const ctx = useAppContext();
1247
- const { url: registryUrl } = ctx.registry;
1248
- const hadCredentials = ctx.credentials !== null;
1249
- if (hadCredentials) {
1250
- log.debug(`Clearing credentials for ${registryUrl}`);
1251
- await clearCredentials(registryUrl);
1252
- } else log.debug(`No credentials found for ${registryUrl}`);
1253
- return {
1254
- success: true,
1255
- hadCredentials
1256
- };
1257
- }
1258
-
1259
- //#endregion
1260
- //#region src/commands/auth/whoami.ts
1261
- /**
1262
- * Returns information about the currently authenticated user
1263
- */
1264
- async function whoami() {
1265
- const ctx = useAppContext();
1266
- const { url: registryUrl } = ctx.registry;
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}.`);
1267
1245
  return {
1268
- success: true,
1269
- loggedIn: ctx.isLoggedIn,
1270
- user: ctx.user ?? void 0,
1271
- registryUrl,
1272
- expiresAt: ctx.credentials?.expiresAt
1246
+ selectedVersion,
1247
+ selectedVariant
1273
1248
  };
1274
1249
  }
1275
-
1276
- //#endregion
1277
- //#region src/commands/preset/add.ts
1278
- async function addPreset(options) {
1279
- const ctx = useAppContext();
1280
- const { alias: registryAlias, url: registryUrl } = ctx.registry;
1281
- const dryRun = Boolean(options.dryRun);
1282
- const { slug, platform, version: version$1 } = parsePresetInput(options.preset, options.platform, options.version);
1283
- log.debug(`Resolving preset ${slug} for platform ${platform}${version$1 ? ` (version ${version$1})` : ""}`);
1284
- const { preset, bundleUrl } = await resolvePreset(registryUrl, slug, platform, version$1);
1285
- log.debug(`Downloading bundle from ${bundleUrl}`);
1286
- const bundle = await fetchBundle(bundleUrl);
1287
- 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}).`);
1288
- 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);
1289
1262
  log.debug(`Writing ${bundle.files.length} files to ${target.root}`);
1290
- 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, {
1291
1275
  force: Boolean(options.force),
1292
1276
  skipConflicts: Boolean(options.skipConflicts),
1293
1277
  noBackup: Boolean(options.noBackup),
1294
- dryRun
1278
+ dryRun: options.dryRun
1295
1279
  });
1296
1280
  return {
1297
- preset,
1281
+ kind: "preset",
1282
+ resolved,
1283
+ version: version$1,
1284
+ variant,
1298
1285
  bundle,
1299
1286
  files: writeStats.files,
1300
- conflicts: writeStats.conflicts,
1301
1287
  backups: writeStats.backups,
1302
1288
  targetRoot: target.root,
1303
1289
  targetLabel: target.label,
1304
- registryAlias,
1305
- dryRun
1306
- };
1307
- }
1308
- /**
1309
- * Parses preset input to extract slug, platform, and version.
1310
- * Supports formats:
1311
- * - "my-preset" (requires explicit platform)
1312
- * - "my-preset.claude" (platform inferred from suffix)
1313
- * - "my-preset@1.0" (with version)
1314
- * - "my-preset.claude@1.0" (platform and version)
1315
- *
1316
- * Version can also be provided via --version flag (takes precedence).
1317
- */
1318
- function parsePresetInput(input, explicitPlatform, explicitVersion) {
1319
- let normalized = input.toLowerCase().trim();
1320
- let parsedVersion;
1321
- const atIndex = normalized.lastIndexOf("@");
1322
- if (atIndex > 0) {
1323
- parsedVersion = normalized.slice(atIndex + 1);
1324
- normalized = normalized.slice(0, atIndex);
1325
- }
1326
- const version$1 = explicitVersion ?? parsedVersion;
1327
- if (explicitPlatform) {
1328
- const parts$1 = normalized.split(".");
1329
- const maybePlatform$1 = parts$1.at(-1);
1330
- if (maybePlatform$1 && isSupportedPlatform(maybePlatform$1)) return {
1331
- slug: parts$1.slice(0, -1).join("."),
1332
- platform: explicitPlatform,
1333
- version: version$1
1334
- };
1335
- return {
1336
- slug: normalized,
1337
- platform: explicitPlatform,
1338
- version: version$1
1339
- };
1340
- }
1341
- const parts = normalized.split(".");
1342
- const maybePlatform = parts.at(-1);
1343
- if (maybePlatform && isSupportedPlatform(maybePlatform)) return {
1344
- slug: parts.slice(0, -1).join("."),
1345
- platform: maybePlatform,
1346
- version: version$1
1290
+ registryAlias: options.registryAlias,
1291
+ dryRun: options.dryRun
1347
1292
  };
1348
- throw new Error(`Platform not specified. Use --platform <platform> or specify as <slug>.<platform> (e.g., "${input}.claude").`);
1349
1293
  }
1350
- function resolveInstallTarget(platform, options) {
1351
- const { projectDir, globalDir } = PLATFORMS[platform];
1352
- if (options.directory) {
1353
- const customRoot = resolve(expandHome$1(options.directory));
1354
- return {
1355
- root: customRoot,
1356
- mode: "custom",
1357
- platform,
1358
- projectDir,
1359
- label: `custom directory ${customRoot}`
1360
- };
1361
- }
1362
- if (options.global) {
1363
- if (!globalDir) throw new Error(`Platform "${platform}" does not support global installation`);
1364
- const globalRoot = resolve(expandHome$1(globalDir));
1365
- return {
1366
- root: globalRoot,
1367
- mode: "global",
1368
- platform,
1369
- projectDir,
1370
- label: `global path ${globalRoot}`
1371
- };
1372
- }
1373
- 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
+ });
1374
1307
  return {
1375
- root: projectRoot,
1376
- mode: "project",
1377
- platform,
1378
- projectDir,
1379
- 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
1380
1318
  };
1381
1319
  }
1382
- 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) {
1383
1325
  const files = [];
1384
- const conflicts = [];
1385
1326
  const backups = [];
1386
- if (!behavior.dryRun) await mkdir(target.root, { recursive: true });
1387
- for (const file of bundle.files) {
1388
- const decoded = decodeBundledFile(file);
1389
- const data = Buffer.from(decoded);
1390
- await verifyBundledFileChecksum(file, data);
1391
- const destResult = computeDestinationPath(file.path, target);
1392
- const destination = destResult.path;
1393
- if (!behavior.dryRun) await mkdir(dirname(destination), { recursive: true });
1394
- const existing = await readExistingFile$1(destination);
1395
- const relativePath = relativize(destination, target.root);
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 });
1330
+ const existing = await readExistingFile(destination);
1331
+ const relativePath = relativize(destination, root);
1396
1332
  if (!existing) {
1397
- if (!behavior.dryRun) await writeFile(destination, data);
1333
+ if (!options.dryRun) await writeFile(destination, content);
1398
1334
  files.push({
1399
1335
  path: relativePath,
1400
1336
  status: "created"
@@ -1402,7 +1338,7 @@ async function writeBundleFiles(bundle, target, behavior) {
1402
1338
  log.debug(`Created: ${relativePath}`);
1403
1339
  continue;
1404
1340
  }
1405
- if (existing.equals(data)) {
1341
+ if (existing.equals(content)) {
1406
1342
  files.push({
1407
1343
  path: relativePath,
1408
1344
  status: "unchanged"
@@ -1410,18 +1346,19 @@ async function writeBundleFiles(bundle, target, behavior) {
1410
1346
  log.debug(`Unchanged: ${relativePath}`);
1411
1347
  continue;
1412
1348
  }
1413
- if (behavior.force) {
1414
- if (!behavior.noBackup) {
1349
+ const diff = renderDiffPreview(relativePath, existing, content);
1350
+ if (options.force) {
1351
+ if (!options.noBackup) {
1415
1352
  const backupPath = `${destination}.bak`;
1416
1353
  const relativeBackupPath = `${relativePath}.bak`;
1417
- if (!behavior.dryRun) await copyFile(destination, backupPath);
1354
+ if (!options.dryRun) await copyFile(destination, backupPath);
1418
1355
  backups.push({
1419
1356
  originalPath: relativePath,
1420
1357
  backupPath: relativeBackupPath
1421
1358
  });
1422
1359
  log.debug(`Backed up: ${relativePath} → ${relativeBackupPath}`);
1423
1360
  }
1424
- if (!behavior.dryRun) await writeFile(destination, data);
1361
+ if (!options.dryRun) await writeFile(destination, content);
1425
1362
  files.push({
1426
1363
  path: relativePath,
1427
1364
  status: "overwritten"
@@ -1429,31 +1366,78 @@ async function writeBundleFiles(bundle, target, behavior) {
1429
1366
  log.debug(`Overwritten: ${relativePath}`);
1430
1367
  continue;
1431
1368
  }
1432
- conflicts.push({
1433
- path: relativePath,
1434
- diff: renderDiffPreview(relativePath, existing, data)
1435
- });
1436
- files.push({
1437
- path: relativePath,
1438
- status: "conflict"
1439
- });
1440
- 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
+ }
1441
1384
  }
1442
1385
  return {
1443
1386
  files,
1444
- conflicts,
1445
1387
  backups
1446
1388
  };
1447
1389
  }
1448
1390
  /**
1449
- * Compute destination path for a bundled file.
1450
- *
1451
- * Bundle files are stored with paths relative to the platform directory
1452
- * (e.g., "AGENTS.md", "commands/test.md") and installed to:
1453
- * - Project/custom: <root>/<projectDir>/<path> (e.g., .opencode/AGENTS.md)
1454
- * - 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.
1455
1393
  */
1456
- 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) {
1457
1441
  const normalized = normalizeBundlePath(pathInput);
1458
1442
  if (!normalized) throw new Error(`Unable to derive destination for ${pathInput}. The computed relative path is empty.`);
1459
1443
  let relativePath;
@@ -1461,9 +1445,38 @@ function computeDestinationPath(pathInput, target) {
1461
1445
  else relativePath = `${target.projectDir}/${normalized}`;
1462
1446
  const destination = resolve(target.root, relativePath);
1463
1447
  ensureWithinRoot(destination, target.root);
1464
- 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
+ };
1465
1478
  }
1466
- async function readExistingFile$1(pathname) {
1479
+ async function readExistingFile(pathname) {
1467
1480
  try {
1468
1481
  return await readFile(pathname);
1469
1482
  } catch (error$2) {
@@ -1492,16 +1505,171 @@ function ensureWithinRoot(candidate, root) {
1492
1505
  if (candidate === root) return;
1493
1506
  if (!candidate.startsWith(normalizedRoot)) throw new Error(`Refusing to write outside of ${root}. Derived path: ${candidate}`);
1494
1507
  }
1495
- function expandHome$1(value) {
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
+ */
1515
+ function expandHome(value) {
1496
1516
  if (value.startsWith("~")) {
1497
1517
  const remainder = value.slice(1);
1498
- if (!remainder) return homedir();
1499
- if (remainder.startsWith("/") || remainder.startsWith("\\")) return `${homedir()}${remainder}`;
1500
- 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}`;
1501
1522
  }
1502
1523
  return value;
1503
1524
  }
1504
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
+ };
1642
+ }
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
+ };
1671
+ }
1672
+
1505
1673
  //#endregion
1506
1674
  //#region src/lib/fs.ts
1507
1675
  async function fileExists(path$1) {
@@ -1563,61 +1731,116 @@ async function resolveConfigPath(inputPath) {
1563
1731
  return inputPath;
1564
1732
  }
1565
1733
  /**
1566
- * Load a preset from a directory containing agentrules.json.
1734
+ * Load and normalize a preset config file.
1567
1735
  *
1568
- * Config in platform dir (e.g., .claude/agentrules.json):
1569
- * - Preset files: siblings of config
1570
- * - 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[].
1571
1738
  *
1572
- * Config at repo root:
1573
- * - Preset files: in .claude/ (or `path` from config)
1574
- * - Metadata: .agentrules/ subfolder
1739
+ * @throws Error if config file is missing, invalid JSON, or fails validation
1575
1740
  */
1576
- async function loadPreset(presetDir) {
1577
- const configPath = join(presetDir, PRESET_CONFIG_FILENAME);
1578
- if (!await fileExists(configPath)) throw new Error(`Config file not found: ${configPath}`);
1579
- 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}`);
1580
1746
  let configJson;
1581
1747
  try {
1582
1748
  configJson = JSON.parse(configRaw);
1583
- } catch {
1584
- 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)}`);
1585
1751
  }
1586
1752
  const configObj = configJson;
1587
1753
  const identifier = typeof configObj?.name === "string" ? configObj.name : configPath;
1588
- const config = validatePresetConfig(configJson, identifier);
1589
- const name = config.name;
1590
- const dirName = basename(presetDir);
1591
- const isConfigInPlatformDir = isPlatformDir(dirName);
1592
- let filesDir;
1593
- let metadataDir;
1594
- if (isConfigInPlatformDir) {
1595
- filesDir = presetDir;
1596
- metadataDir = join(presetDir, AGENT_RULES_DIR);
1597
- log.debug(`Config in platform dir: files in ${filesDir}, metadata in ${metadataDir}`);
1598
- } else {
1599
- const platformDir = config.path ?? PLATFORMS[config.platform].projectDir;
1600
- filesDir = join(presetDir, platformDir);
1601
- metadataDir = join(presetDir, AGENT_RULES_DIR);
1602
- log.debug(`Config at repo root: files in ${filesDir}, metadata in ${metadataDir}`);
1603
- if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir}. Create the directory or set "path" in ${PRESET_CONFIG_FILENAME}.`);
1604
- }
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);
1605
1805
  let installMessage;
1606
1806
  let readmeContent;
1607
1807
  let licenseContent;
1608
- if (await directoryExists(metadataDir)) {
1609
- installMessage = await readFileIfExists(join(metadataDir, INSTALL_FILENAME));
1610
- readmeContent = await readFileIfExists(join(metadataDir, README_FILENAME));
1611
- 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));
1612
1812
  }
1613
1813
  const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...config.ignore ?? []];
1614
- const rootExclude = [PRESET_CONFIG_FILENAME, AGENT_RULES_DIR];
1615
- const files = await collectFiles(filesDir, rootExclude, ignorePatterns);
1616
- 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
+ }
1617
1840
  return {
1618
- name,
1841
+ name: config.name,
1619
1842
  config,
1620
- files,
1843
+ platformFiles,
1621
1844
  installMessage,
1622
1845
  readmeContent,
1623
1846
  licenseContent
@@ -1667,11 +1890,11 @@ async function collectFiles(dir, rootExclude, ignorePatterns, root) {
1667
1890
  const nested = await collectFiles(fullPath, rootExclude, ignorePatterns, configRoot);
1668
1891
  files.push(...nested);
1669
1892
  } else if (entry.isFile()) {
1670
- const contents = await readFile(fullPath, "utf8");
1893
+ const content = await readFile(fullPath, "utf8");
1671
1894
  const relativePath = relative(configRoot, fullPath);
1672
1895
  files.push({
1673
1896
  path: relativePath,
1674
- contents
1897
+ content
1675
1898
  });
1676
1899
  }
1677
1900
  }
@@ -1804,7 +2027,7 @@ async function initPreset(options) {
1804
2027
  description,
1805
2028
  tags: options.tags ?? [],
1806
2029
  license,
1807
- platform
2030
+ platforms: [platform]
1808
2031
  };
1809
2032
  let createdDir;
1810
2033
  if (await directoryExists(platformDir)) log.debug(`Platform directory exists: ${platformDir}`);
@@ -1920,10 +2143,10 @@ async function initInteractive(options) {
1920
2143
  }
1921
2144
  const result = await p.group({
1922
2145
  name: () => p.text({
1923
- message: "Preset name (slug)",
2146
+ message: "Preset name",
1924
2147
  placeholder: normalizeName(defaultName),
1925
2148
  defaultValue: normalizeName(defaultName),
1926
- validate: check(slugSchema)
2149
+ validate: check(nameSchema)
1927
2150
  }),
1928
2151
  title: ({ results }) => {
1929
2152
  const defaultTitle = titleOption ?? toTitleCase(results.name ?? defaultName);
@@ -2001,78 +2224,56 @@ async function initInteractive(options) {
2001
2224
  //#endregion
2002
2225
  //#region src/commands/preset/validate.ts
2003
2226
  async function validatePreset(options) {
2004
- const configPath = await resolveConfigPath(options.path);
2005
- log.debug(`Resolved config path: ${configPath}`);
2006
2227
  const errors = [];
2007
2228
  const warnings = [];
2008
- const configRaw = await readFile(configPath, "utf8").catch(() => null);
2009
- if (configRaw === null) {
2010
- errors.push(`Config file not found: ${configPath}`);
2011
- log.debug("Config file read failed");
2012
- return {
2013
- valid: false,
2014
- configPath,
2015
- preset: null,
2016
- errors,
2017
- warnings
2018
- };
2019
- }
2020
- log.debug("Config file read successfully");
2021
- let configJson;
2022
- try {
2023
- configJson = JSON.parse(configRaw);
2024
- log.debug("JSON parsed successfully");
2025
- } catch (e) {
2026
- errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
2027
- log.debug(`JSON parse error: ${e instanceof Error ? e.message : String(e)}`);
2028
- return {
2029
- valid: false,
2030
- configPath,
2031
- preset: null,
2032
- errors,
2033
- warnings
2034
- };
2035
- }
2036
- let preset;
2229
+ let configPath;
2230
+ let config;
2231
+ let configDir;
2232
+ let isInProjectMode;
2037
2233
  try {
2038
- preset = validatePresetConfig(configJson, configPath);
2039
- 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");
2040
2240
  } catch (e) {
2041
- errors.push(e instanceof Error ? e.message : String(e));
2042
- 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";
2043
2245
  return {
2044
2246
  valid: false,
2045
- configPath,
2247
+ configPath: fallbackPath,
2046
2248
  preset: null,
2047
2249
  errors,
2048
2250
  warnings
2049
2251
  };
2050
2252
  }
2051
- log.debug(`Preset name: ${preset.name}`);
2052
- const presetDir = dirname(configPath);
2053
- const platform = preset.platform;
2054
- log.debug(`Checking platform: ${platform}`);
2055
- if (isSupportedPlatform(platform)) {
2056
- const dirName = basename(presetDir);
2057
- const isInProjectMode = isPlatformDir(dirName);
2058
- 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}`);
2059
2263
  else {
2060
- const filesPath = preset.path ?? PLATFORMS[platform].projectDir;
2061
- const filesDir = join(presetDir, filesPath);
2264
+ const filesPath = customPath ?? PLATFORMS[platform].projectDir;
2265
+ const filesDir = join(configDir, filesPath);
2062
2266
  const filesExists = await directoryExists(filesDir);
2063
- log.debug(`Standalone mode: files directory check: ${filesDir} - ${filesExists ? "exists" : "not found"}`);
2064
- 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}`);
2065
2269
  }
2066
- } else {
2067
- errors.push(`Unknown platform "${platform}". Supported: ${PLATFORM_IDS.join(", ")}`);
2068
- log.debug(`Platform "${platform}" is not supported`);
2069
2270
  }
2070
- const hasPlaceholderTags = preset.tags?.some((t) => t.startsWith("//"));
2071
- 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("//"));
2072
2273
  if (hasPlaceholderTags) {
2073
2274
  errors.push("Replace placeholder comments in tags before publishing.");
2074
2275
  log.debug("Found placeholder comments in tags");
2075
- } else if (!preset.tags || preset.tags.length === 0) {
2276
+ } else if (!config.tags || config.tags.length === 0) {
2076
2277
  errors.push("At least one tag is required.");
2077
2278
  log.debug("No tags specified");
2078
2279
  }
@@ -2085,7 +2286,7 @@ async function validatePreset(options) {
2085
2286
  return {
2086
2287
  valid: isValid,
2087
2288
  configPath,
2088
- preset: isValid ? preset : null,
2289
+ preset: isValid ? config : null,
2089
2290
  errors,
2090
2291
  warnings
2091
2292
  };
@@ -2093,8 +2294,8 @@ async function validatePreset(options) {
2093
2294
 
2094
2295
  //#endregion
2095
2296
  //#region src/commands/publish.ts
2096
- /** Maximum size per bundle in bytes (1MB) */
2097
- 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;
2098
2299
  /**
2099
2300
  * Formats bytes as human-readable string
2100
2301
  */
@@ -2136,7 +2337,8 @@ async function publish(options = {}) {
2136
2337
  let presetInput;
2137
2338
  try {
2138
2339
  presetInput = await loadPreset(presetDir);
2139
- log.debug(`Loaded preset "${presetInput.name}" 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}`);
2140
2342
  } catch (error$2) {
2141
2343
  const message = getErrorMessage(error$2);
2142
2344
  spinner$1.fail("Failed to load preset");
@@ -2146,63 +2348,74 @@ async function publish(options = {}) {
2146
2348
  error: message
2147
2349
  };
2148
2350
  }
2149
- spinner$1.update("Building bundle...");
2351
+ spinner$1.update("Building platform bundles...");
2150
2352
  let publishInput;
2151
2353
  try {
2152
2354
  publishInput = await buildPresetPublishInput({
2153
2355
  preset: presetInput,
2154
2356
  version: version$1
2155
2357
  });
2156
- 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}`);
2157
2360
  } catch (error$2) {
2158
2361
  const message = getErrorMessage(error$2);
2159
- spinner$1.fail("Failed to build bundle");
2362
+ spinner$1.fail("Failed to build platform bundles");
2160
2363
  log.error(message);
2161
2364
  return {
2162
2365
  success: false,
2163
2366
  error: message
2164
2367
  };
2165
2368
  }
2166
- const inputJson = JSON.stringify(publishInput);
2167
- const inputSize = Buffer.byteLength(inputJson, "utf8");
2168
- const fileCount = publishInput.files.length;
2169
- log.debug(`Publish input size: ${formatBytes(inputSize)}, files: ${fileCount}`);
2170
- if (inputSize > MAX_BUNDLE_SIZE_BYTES) {
2171
- const errorMessage = `Bundle exceeds maximum size (${formatBytes(inputSize)} > ${formatBytes(MAX_BUNDLE_SIZE_BYTES)})`;
2172
- spinner$1.fail("Bundle too large");
2173
- log.error(errorMessage);
2174
- return {
2175
- success: false,
2176
- error: errorMessage
2177
- };
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
+ }
2178
2386
  }
2387
+ log.debug(`Total publish size: ${formatBytes(totalSize)}, files: ${totalFileCount}, platforms: ${platformList}`);
2179
2388
  if (dryRun) {
2180
2389
  spinner$1.success("Dry run complete");
2181
2390
  log.print("");
2182
2391
  log.print(ui.header("Publish Preview"));
2183
2392
  log.print(ui.keyValue("Preset", publishInput.title));
2184
2393
  log.print(ui.keyValue("Name", publishInput.name));
2185
- log.print(ui.keyValue("Platform", publishInput.platform));
2394
+ log.print(ui.keyValue("Platforms", platformList));
2186
2395
  log.print(ui.keyValue("Version", version$1 ? `${version$1}.x (auto-assigned minor)` : "1.x (auto-assigned)"));
2187
- log.print(ui.keyValue("Files", `${fileCount} file${fileCount === 1 ? "" : "s"}`));
2188
- log.print(ui.keyValue("Size", formatBytes(inputSize)));
2189
- log.print("");
2190
- log.print(ui.header("Files to publish", fileCount));
2191
- 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)));
2192
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
+ }
2193
2406
  log.print(ui.hint("Run without --dry-run to publish."));
2194
2407
  return {
2195
2408
  success: true,
2196
2409
  preview: {
2197
2410
  slug: publishInput.name,
2198
- platform: publishInput.platform,
2411
+ platforms: publishInput.variants.map((v) => v.platform),
2199
2412
  title: publishInput.title,
2200
- totalSize: inputSize,
2201
- fileCount
2413
+ totalSize,
2414
+ fileCount: totalFileCount
2202
2415
  }
2203
2416
  };
2204
2417
  }
2205
- spinner$1.update(`Publishing ${publishInput.title} (${publishInput.platform})...`);
2418
+ spinner$1.update(`Publishing ${publishInput.title} (${platformList})...`);
2206
2419
  if (!ctx.credentials) throw new Error("Credentials should exist at this point");
2207
2420
  const result = await publishPreset(ctx.registry.url, ctx.credentials.token, publishInput);
2208
2421
  if (!result.success) {
@@ -2226,23 +2439,27 @@ async function publish(options = {}) {
2226
2439
  }
2227
2440
  const { data } = result;
2228
2441
  const action = data.isNewPreset ? "Published new preset" : "Published";
2229
- 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})`);
2230
2444
  log.print("");
2231
- log.print(ui.header("Published files", fileCount));
2232
- log.print(ui.fileTree(publishInput.files));
2233
- const presetName$1 = `${data.slug}.${data.platform}`;
2234
- 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
+ }
2235
2452
  log.info("");
2236
- log.info(ui.keyValue("Now live at", ui.link(presetRegistryUrl)));
2453
+ log.info(ui.keyValue("Now live at", ui.link(data.url)));
2237
2454
  return {
2238
2455
  success: true,
2239
2456
  preset: {
2240
2457
  slug: data.slug,
2241
- platform: data.platform,
2242
2458
  title: data.title,
2243
2459
  version: data.version,
2244
2460
  isNewPreset: data.isNewPreset,
2245
- bundleUrl: data.bundleUrl
2461
+ variants: data.variants,
2462
+ url: data.url
2246
2463
  }
2247
2464
  };
2248
2465
  }
@@ -2271,7 +2488,7 @@ async function buildRegistry(options) {
2271
2488
  });
2272
2489
  if (validateOnly || !outputDir) return {
2273
2490
  presets: presets.length,
2274
- entries: result.entries.length,
2491
+ items: result.items.length,
2275
2492
  bundles: result.bundles.length,
2276
2493
  outputDir: null,
2277
2494
  validateOnly
@@ -2286,23 +2503,20 @@ async function buildRegistry(options) {
2286
2503
  await writeFile(join(bundleDir, bundle.version), bundleJson);
2287
2504
  await writeFile(join(bundleDir, LATEST_VERSION), bundleJson);
2288
2505
  }
2289
- for (const entry of result.entries) {
2290
- const apiPresetDir = join(outputDir, API_ENDPOINTS.presets.base, entry.slug, entry.platform);
2291
- await mkdir(apiPresetDir, { recursive: true });
2292
- const entryJson = JSON.stringify(entry, null, indent$1);
2293
- await writeFile(join(apiPresetDir, entry.version), entryJson);
2294
- 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);
2295
2511
  }
2296
2512
  const registryJson = JSON.stringify({
2297
2513
  $schema: "https://agentrules.directory/schema/registry.json",
2298
- items: result.entries
2514
+ items: result.items
2299
2515
  }, null, indent$1);
2300
2516
  await writeFile(join(outputDir, "registry.json"), registryJson);
2301
- const indexJson = JSON.stringify(result.index, null, indent$1);
2302
- await writeFile(join(outputDir, "registry.index.json"), indexJson);
2303
2517
  return {
2304
2518
  presets: presets.length,
2305
- entries: result.entries.length,
2519
+ items: result.items.length,
2306
2520
  bundles: result.bundles.length,
2307
2521
  outputDir,
2308
2522
  validateOnly: false
@@ -2326,128 +2540,146 @@ async function discoverPresetDirs(inputDir) {
2326
2540
  }
2327
2541
 
2328
2542
  //#endregion
2329
- //#region src/commands/rule/add.ts
2330
- async function addRule(options) {
2543
+ //#region src/commands/share.ts
2544
+ async function share(options = {}) {
2331
2545
  const ctx = useAppContext();
2332
- const dryRun = Boolean(options.dryRun);
2333
- log.debug(`Fetching rule: ${options.slug}`);
2334
- const result = await getRule(ctx.registry.url, options.slug);
2335
- if (!result.success) throw new Error(result.error);
2336
- const rule = result.data;
2337
- const platform = rule.platform;
2338
- const targetPath = resolveTargetPath(platform, rule.type, rule.slug, {
2339
- global: options.global,
2340
- directory: options.directory
2341
- });
2342
- log.debug(`Target path: ${targetPath}`);
2343
- const existing = await readExistingFile(targetPath);
2344
- if (existing !== null) {
2345
- if (existing === rule.content) return {
2346
- slug: rule.slug,
2347
- platform: rule.platform,
2348
- type: rule.type,
2349
- title: rule.title,
2350
- targetPath,
2351
- status: "unchanged",
2352
- dryRun
2353
- };
2354
- if (!options.force) return {
2355
- slug: rule.slug,
2356
- platform: rule.platform,
2357
- type: rule.type,
2358
- title: rule.title,
2359
- targetPath,
2360
- status: "conflict",
2361
- dryRun
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
2362
2552
  };
2363
- if (!dryRun) {
2364
- await mkdir(dirname(targetPath), { recursive: true });
2365
- await writeFile(targetPath, rule.content, "utf-8");
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
+ };
2366
2566
  }
2567
+ }
2568
+ if (!options.name) {
2569
+ const error$2 = "Name is required. Use --name <name>";
2570
+ log.error(error$2);
2367
2571
  return {
2368
- slug: rule.slug,
2369
- platform: rule.platform,
2370
- type: rule.type,
2371
- title: rule.title,
2372
- targetPath,
2373
- status: "overwritten",
2374
- dryRun
2572
+ success: false,
2573
+ error: error$2
2375
2574
  };
2376
2575
  }
2377
- if (!dryRun) {
2378
- await mkdir(dirname(targetPath), { recursive: true });
2379
- await writeFile(targetPath, rule.content, "utf-8");
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
+ };
2380
2583
  }
2381
- return {
2382
- slug: rule.slug,
2383
- platform: rule.platform,
2384
- type: rule.type,
2385
- title: rule.title,
2386
- targetPath,
2387
- status: "created",
2388
- dryRun
2389
- };
2390
- }
2391
- function resolveTargetPath(platform, type, slug, options) {
2392
- const location = options.global ? "global" : "project";
2393
- const pathTemplate = getInstallPath(platform, type, slug, location);
2394
- if (!pathTemplate) {
2395
- const locationLabel = options.global ? "globally" : "to a project";
2396
- throw new Error(`Rule type "${type}" cannot be installed ${locationLabel} for platform "${platform}"`);
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
+ };
2397
2591
  }
2398
- if (options.directory) {
2399
- const resolvedTemplate = pathTemplate.replace("{name}", slug);
2400
- const filename = resolvedTemplate.split("/").pop() ?? `${slug}.md`;
2401
- return resolve(expandHome(options.directory), filename);
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
+ };
2402
2600
  }
2403
- const expanded = expandHome(pathTemplate);
2404
- if (expanded.startsWith("/")) return expanded;
2405
- return resolve(process.cwd(), expanded);
2406
- }
2407
- async function readExistingFile(pathname) {
2408
- try {
2409
- return await readFile(pathname, "utf-8");
2410
- } catch (error$2) {
2411
- if (error$2.code === "ENOENT") return null;
2412
- throw error$2;
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
+ };
2413
2608
  }
2414
- }
2415
- function expandHome(value) {
2416
- if (value.startsWith("~")) {
2417
- const remainder = value.slice(1);
2418
- if (!remainder) return homedir();
2419
- if (remainder.startsWith("/") || remainder.startsWith("\\")) return `${homedir()}${remainder}`;
2420
- return `${homedir()}/${remainder}`;
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
+ };
2421
2616
  }
2422
- return value;
2423
- }
2424
- /**
2425
- * Check if input looks like a rule reference (r/ prefix)
2426
- */
2427
- function isRuleReference(input) {
2428
- return input.toLowerCase().startsWith("r/");
2429
- }
2430
- /**
2431
- * Extract slug from rule reference (removes r/ prefix)
2432
- */
2433
- function extractRuleSlug(input) {
2434
- if (isRuleReference(input)) return input.slice(2);
2435
- return input;
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
+ };
2436
2668
  }
2437
2669
 
2438
2670
  //#endregion
2439
2671
  //#region src/commands/unpublish.ts
2440
2672
  /**
2441
- * Parses preset input to extract slug, platform, and version.
2673
+ * Parses preset input to extract slug and version.
2442
2674
  * Supports formats:
2443
- * - "my-preset.claude@1.0" (platform and version in string)
2444
- * - "my-preset@1.0" (requires explicit platform)
2445
- * - "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)
2446
2678
  *
2447
- * Explicit --platform and --version flags take precedence.
2679
+ * Explicit --version flag takes precedence.
2448
2680
  */
2449
- function parseUnpublishInput(input, explicitPlatform, explicitVersion) {
2450
- let normalized = input.toLowerCase().trim();
2681
+ function parseUnpublishInput(input, explicitVersion) {
2682
+ let normalized = input.trim();
2451
2683
  let parsedVersion;
2452
2684
  const atIndex = normalized.lastIndexOf("@");
2453
2685
  if (atIndex > 0) {
@@ -2455,28 +2687,17 @@ function parseUnpublishInput(input, explicitPlatform, explicitVersion) {
2455
2687
  normalized = normalized.slice(0, atIndex);
2456
2688
  }
2457
2689
  const version$1 = explicitVersion ?? parsedVersion;
2458
- const parts = normalized.split(".");
2459
- const maybePlatform = parts.at(-1);
2460
- let slug;
2461
- let platform;
2462
- if (maybePlatform && isSupportedPlatform(maybePlatform)) {
2463
- slug = parts.slice(0, -1).join(".");
2464
- platform = explicitPlatform ?? maybePlatform;
2465
- } else {
2466
- slug = normalized;
2467
- platform = explicitPlatform;
2468
- }
2469
2690
  return {
2470
- slug,
2471
- platform,
2691
+ slug: normalized,
2472
2692
  version: version$1
2473
2693
  };
2474
2694
  }
2475
2695
  /**
2476
- * 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.
2477
2698
  */
2478
2699
  async function unpublish(options) {
2479
- const { slug, platform, version: version$1 } = parseUnpublishInput(options.preset, options.platform, options.version);
2700
+ const { slug, version: version$1 } = parseUnpublishInput(options.preset, options.version);
2480
2701
  if (!slug) {
2481
2702
  log.error("Preset slug is required");
2482
2703
  return {
@@ -2484,21 +2705,14 @@ async function unpublish(options) {
2484
2705
  error: "Preset slug is required"
2485
2706
  };
2486
2707
  }
2487
- if (!platform) {
2488
- log.error("Platform is required. Use --platform or specify as <slug>.<platform>@<version>");
2489
- return {
2490
- success: false,
2491
- error: "Platform is required"
2492
- };
2493
- }
2494
2708
  if (!version$1) {
2495
- 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>");
2496
2710
  return {
2497
2711
  success: false,
2498
2712
  error: "Version is required"
2499
2713
  };
2500
2714
  }
2501
- log.debug(`Unpublishing preset: ${slug}.${platform}@${version$1}`);
2715
+ log.debug(`Unpublishing preset: ${slug}@${version$1}`);
2502
2716
  const ctx = useAppContext();
2503
2717
  if (!(ctx.isLoggedIn && ctx.credentials)) {
2504
2718
  const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
@@ -2509,8 +2723,8 @@ async function unpublish(options) {
2509
2723
  };
2510
2724
  }
2511
2725
  log.debug(`Authenticated, unpublishing from ${ctx.registry.url}`);
2512
- const spinner$1 = await log.spinner(`Unpublishing ${ui.code(slug)}.${platform} ${ui.version(version$1)}...`);
2513
- 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);
2514
2728
  if (!result.success) {
2515
2729
  spinner$1.fail("Unpublish failed");
2516
2730
  log.error(result.error);
@@ -2521,18 +2735,63 @@ async function unpublish(options) {
2521
2735
  };
2522
2736
  }
2523
2737
  const { data } = result;
2524
- spinner$1.success(`Unpublished ${ui.code(data.slug)}.${data.platform} ${ui.version(data.version)}`);
2525
- 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."));
2526
2740
  return {
2527
2741
  success: true,
2528
2742
  preset: {
2529
2743
  slug: data.slug,
2530
- platform: data.platform,
2531
2744
  version: data.version
2532
2745
  }
2533
2746
  };
2534
2747
  }
2535
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
+
2536
2795
  //#endregion
2537
2796
  //#region src/help-agent/publish.ts
2538
2797
  /**
@@ -2819,47 +3078,14 @@ program.name("agentrules").description("The AI Agent Directory CLI").version(pac
2819
3078
  log.debug(`Failed to init context: ${getErrorMessage(error$2)}`);
2820
3079
  }
2821
3080
  }).showHelpAfterError();
2822
- program.command("add <item>").description("Download and install a preset or rule from the registry (use r/<slug> for rules)").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("-V, --version <version>", "Install a specific version (presets only)").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) => {
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) => {
2823
3082
  const dryRun = Boolean(options.dryRun);
2824
- if (isRuleReference(item)) {
2825
- const slug = extractRuleSlug(item);
2826
- const spinner$2 = await log.spinner(`Fetching rule "${slug}"...`);
2827
- try {
2828
- const result$1 = await addRule({
2829
- slug,
2830
- global: Boolean(options.global),
2831
- directory: options.dir,
2832
- force: Boolean(options.force || options.yes),
2833
- dryRun
2834
- });
2835
- spinner$2.stop();
2836
- if (result$1.status === "conflict") {
2837
- log.error(`File already exists: ${result$1.targetPath}. Use ${ui.command("--force")} to overwrite.`);
2838
- process.exitCode = 1;
2839
- return;
2840
- }
2841
- if (result$1.status === "unchanged") {
2842
- log.info(`Already up to date: ${ui.path(result$1.targetPath)}`);
2843
- return;
2844
- }
2845
- const verb$1 = dryRun ? "Would install" : "Installed";
2846
- const action = result$1.status === "overwritten" ? "updated" : "created";
2847
- log.print(ui.fileStatus(action, result$1.targetPath, { dryRun }));
2848
- log.print("");
2849
- log.success(`${verb$1} ${ui.bold(result$1.title)} ${ui.muted(`(${result$1.platform}/${result$1.type})`)}`);
2850
- if (dryRun) log.print(ui.hint("\nDry run complete. No files were written."));
2851
- } catch (err) {
2852
- spinner$2.stop();
2853
- throw err;
2854
- }
2855
- return;
2856
- }
2857
3083
  const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
2858
- const spinner$1 = await log.spinner("Fetching preset...");
3084
+ const spinner$1 = await log.spinner("Resolving...");
2859
3085
  let result;
2860
3086
  try {
2861
- result = await addPreset({
2862
- preset: item,
3087
+ result = await add({
3088
+ slug: item,
2863
3089
  platform,
2864
3090
  version: options.version,
2865
3091
  global: Boolean(options.global),
@@ -2874,22 +3100,28 @@ program.command("add <item>").description("Download and install a preset or rule
2874
3100
  throw err;
2875
3101
  }
2876
3102
  spinner$1.stop();
2877
- 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;
2878
3105
  if (hasBlockingConflicts) {
2879
- 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`;
2880
3107
  const forceHint = `Use ${ui.command("--force")} to overwrite ${ui.muted("(--no-backup to skip backups)")}`;
2881
3108
  log.error(`${count$1} conflicts. ${forceHint}`);
2882
3109
  log.print("");
2883
- for (const conflict of result.conflicts.slice(0, 3)) {
3110
+ for (const conflict of conflicts.slice(0, 3)) {
2884
3111
  log.print(` ${ui.muted("•")} ${conflict.path}`);
2885
3112
  if (conflict.diff) log.print(conflict.diff.split("\n").map((l) => ` ${l}`).join("\n"));
2886
3113
  }
2887
- 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`)}`);
2888
3115
  log.print("");
2889
3116
  log.print(forceHint);
2890
3117
  process.exitCode = 1;
2891
3118
  return;
2892
3119
  }
3120
+ const allUnchanged = result.files.every((f) => f.status === "unchanged");
3121
+ if (allUnchanged) {
3122
+ log.info("Already up to date.");
3123
+ return;
3124
+ }
2893
3125
  if (result.backups.length > 0) {
2894
3126
  log.print("");
2895
3127
  for (const backup of result.backups) log.print(ui.backupStatus(backup.originalPath, backup.backupPath, { dryRun }));
@@ -2902,10 +3134,12 @@ program.command("add <item>").description("Download and install a preset or rule
2902
3134
  }
2903
3135
  log.print("");
2904
3136
  const verb = dryRun ? "Would install" : "Installed";
2905
- log.success(`${verb} ${ui.bold(result.preset.title)} ${ui.muted(`for ${result.preset.platform}`)}`);
2906
- 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`);
2907
3141
  if (dryRun) log.print(ui.hint("\nDry run complete. No files were written."));
2908
- if (result.bundle.installMessage) log.print(`\n${result.bundle.installMessage}`);
3142
+ if (result.kind === "preset" && result.bundle.installMessage) log.print(`\n${result.bundle.installMessage}`);
2909
3143
  }));
2910
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) => {
2911
3145
  const targetDir = directory ?? process.cwd();
@@ -2971,10 +3205,11 @@ program.command("validate").description("Validate an agentrules.json configurati
2971
3205
  const result = await validatePreset({ path: path$1 });
2972
3206
  if (result.valid && result.preset) {
2973
3207
  const p$1 = result.preset;
3208
+ const platforms = p$1.platforms.map((entry) => entry.platform).join(", ");
2974
3209
  log.success(p$1.title);
2975
3210
  log.print(ui.keyValue("Description", p$1.description));
2976
3211
  log.print(ui.keyValue("License", p$1.license));
2977
- log.print(ui.keyValue("Platform", p$1.platform));
3212
+ log.print(ui.keyValue("Platforms", platforms));
2978
3213
  if (p$1.tags?.length) log.print(ui.keyValue("Tags", p$1.tags.join(", ")));
2979
3214
  } else if (!result.valid) log.error(`Invalid: ${ui.path(result.configPath)}`);
2980
3215
  if (result.errors.length > 0) {
@@ -3011,15 +3246,15 @@ registry.command("build").description("Build registry from preset directories").
3011
3246
  validateOnly: options.validateOnly
3012
3247
  });
3013
3248
  if (result.validateOnly) {
3014
- 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`)}`);
3015
3250
  return;
3016
3251
  }
3017
3252
  if (!result.outputDir) {
3018
- 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`);
3019
3254
  log.print(ui.hint(`Use ${ui.command("--out <path>")} to write files`));
3020
3255
  return;
3021
3256
  }
3022
- 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`)}`);
3023
3258
  log.print(ui.keyValue("Output", ui.path(result.outputDir)));
3024
3259
  }));
3025
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) => {
@@ -3097,7 +3332,7 @@ program.command("whoami").description("Show the currently authenticated user").a
3097
3332
  log.print(ui.keyValue("Session", `expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? "" : "s"}`));
3098
3333
  }
3099
3334
  }));
3100
- 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) => {
3101
3336
  const result = await publish({
3102
3337
  path: path$1,
3103
3338
  version: options.version,
@@ -3105,11 +3340,28 @@ program.command("publish").description("Publish a preset to the registry").argum
3105
3340
  });
3106
3341
  if (!result.success) process.exitCode = 1;
3107
3342
  }));
3108
- 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) => {
3109
- 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) => {
3110
3363
  const result = await unpublish({
3111
3364
  preset,
3112
- platform,
3113
3365
  version: options.version
3114
3366
  });
3115
3367
  if (!result.success) process.exitCode = 1;