@agentrules/cli 0.0.10 → 0.0.12

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 +78 -16
  2. package/dist/index.js +422 -258
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -34,7 +34,8 @@ agentrules add <preset> --platform <platform> [options]
34
34
  | `-g, --global` | Install to global config directory |
35
35
  | `--dir <path>` | Install to a custom directory |
36
36
  | `-r, --registry <alias>` | Use a specific registry |
37
- | `-f, --force` | Overwrite existing files |
37
+ | `-f, --force` | Overwrite existing files (backs up originals to `.bak`) |
38
+ | `--no-backup` | Don't backup files before overwriting (use with `--force`) |
38
39
  | `--dry-run` | Preview changes without writing |
39
40
  | `--skip-conflicts` | Skip files that already exist |
40
41
 
@@ -63,7 +64,7 @@ agentrules add agentic-dev-starter --platform opencode --dry-run
63
64
 
64
65
  ### `agentrules init [directory]`
65
66
 
66
- Initialize a new preset.
67
+ Initialize a preset config in a platform directory. The command guides you through the required fields for publishing.
67
68
 
68
69
  ```bash
69
70
  agentrules init [directory] [options]
@@ -73,7 +74,7 @@ agentrules init [directory] [options]
73
74
 
74
75
  | Option | Description |
75
76
  |--------|-------------|
76
- | `-n, --name <name>` | Preset name (default: directory name, or `my-preset`) |
77
+ | `-n, --name <name>` | Preset name (default: `my-preset`) |
77
78
  | `-t, --title <title>` | Display title |
78
79
  | `--description <text>` | Preset description |
79
80
  | `-p, --platform <platform>` | Target platform |
@@ -84,30 +85,69 @@ agentrules init [directory] [options]
84
85
  **Examples:**
85
86
 
86
87
  ```bash
87
- # Create a new preset directory and initialize (interactive prompts)
88
- agentrules init my-preset
89
- cd my-preset
90
-
91
- # Initialize in current directory
88
+ # Initialize in your existing platform directory
89
+ cd .opencode
92
90
  agentrules init
93
91
 
94
- # Set defaults for prompts
95
- agentrules init my-preset --name awesome-rules --platform opencode
92
+ # Initialize in a specific platform directory
93
+ agentrules init .claude
96
94
 
97
95
  # Accept all defaults, skip prompts
98
- agentrules init my-preset --yes
96
+ agentrules init .opencode --yes
97
+ ```
98
+
99
+ After running `init`, your preset structure is:
100
+
101
+ ```
102
+ .opencode/
103
+ ├── agentrules.json # Preset config (created by init)
104
+ ├── AGENTS.md # Your config files (included in bundle)
105
+ ├── commands/
106
+ │ └── review.md
107
+ └── .agentrules/ # Optional metadata folder
108
+ ├── README.md # Shown on registry page
109
+ ├── LICENSE.md # Full license text
110
+ └── INSTALL.txt # Shown after install
111
+ ```
112
+
113
+ ### Preset Config Fields
114
+
115
+ | Field | Required | Description |
116
+ |-------|----------|-------------|
117
+ | `name` | Yes | URL-safe identifier (lowercase, hyphens) |
118
+ | `title` | Yes | Display name |
119
+ | `description` | Yes | Short description (max 500 chars) |
120
+ | `license` | Yes | SPDX license identifier (e.g., `MIT`) |
121
+ | `platform` | Yes | Target platform: `opencode`, `claude`, `cursor`, `codex` |
122
+ | `version` | No | Major version (default: 1) |
123
+ | `tags` | No | Up to 10 tags for discoverability |
124
+ | `features` | No | Up to 5 key features to highlight |
125
+ | `ignore` | No | Additional patterns to exclude from bundle |
126
+
127
+ ### Auto-Excluded Files
128
+
129
+ These files are automatically excluded from bundles:
130
+ - `node_modules/`, `.git/`, `.DS_Store`
131
+ - Lock files: `package-lock.json`, `bun.lockb`, `pnpm-lock.yaml`, `*.lock`
132
+
133
+ Use the `ignore` field for additional exclusions:
134
+
135
+ ```json
136
+ {
137
+ "ignore": ["*.log", "test-fixtures", "*.tmp"]
138
+ }
99
139
  ```
100
140
 
101
141
  ### `agentrules validate [path]`
102
142
 
103
- Validate a preset configuration.
143
+ Validate a preset configuration before publishing.
104
144
 
105
145
  ```bash
106
146
  # Validate current directory
107
147
  agentrules validate
108
148
 
109
149
  # Validate a specific path
110
- agentrules validate ./my-preset
150
+ agentrules validate .opencode
111
151
  ```
112
152
 
113
153
  ---
@@ -161,14 +201,36 @@ agentrules publish --dry-run
161
201
 
162
202
  **Versioning:** Presets use `MAJOR.MINOR` versioning. You set the major version, and the registry auto-increments the minor version on each publish.
163
203
 
164
- ### `agentrules unpublish <name>`
204
+ ### `agentrules unpublish <preset>`
205
+
206
+ Remove a specific version of a preset from the registry. Requires authentication.
207
+
208
+ ```bash
209
+ agentrules unpublish <preset> [options]
210
+ ```
211
+
212
+ **Options:**
213
+
214
+ | Option | Description |
215
+ |--------|-------------|
216
+ | `-p, --platform <platform>` | Target platform (if not in preset string) |
217
+ | `-V, --version <version>` | Version to unpublish (if not in preset string) |
165
218
 
166
- Remove a preset from the registry. Requires authentication.
219
+ **Examples:**
167
220
 
168
221
  ```bash
169
- agentrules unpublish my-preset
222
+ # Full format: slug.platform@version
223
+ agentrules unpublish my-preset.opencode@1.0
224
+
225
+ # With flags
226
+ agentrules unpublish my-preset --platform opencode --version 1.0
227
+
228
+ # Mixed: version in string, platform as flag
229
+ agentrules unpublish my-preset@1.0 --platform opencode
170
230
  ```
171
231
 
232
+ **Note:** Unpublished versions cannot be republished with the same version number.
233
+
172
234
  ---
173
235
 
174
236
  ## Registry Management
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "module";
3
+ import { AGENT_RULES_DIR, API_ENDPOINTS, COMMON_LICENSES, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, createDiffPreview, decodeBundledFile, descriptionSchema, fetchBundle, getPlatformFromDir, isLikelyText, isPlatformDir, isSupportedPlatform, licenseSchema, normalizeBundlePath, normalizePlatformInput, resolvePreset, slugSchema, titleSchema, toUtf8String, validatePresetConfig, verifyBundledFileChecksum } from "@agentrules/core";
3
4
  import { Command } from "commander";
4
5
  import { basename, dirname, join, relative, resolve, sep } from "path";
5
6
  import { exec } from "child_process";
6
7
  import { promisify } from "util";
7
- import { API_ENDPOINTS, COMMON_LICENSES, CONFIG_DIR_NAME, LATEST_VERSION, PLATFORMS, PLATFORM_IDS, PRESET_CONFIG_FILENAME, PRESET_SCHEMA_URL, STATIC_BUNDLE_DIR, buildPresetPublishInput, buildPresetRegistry, createDiffPreview, decodeBundledFile, descriptionSchema, fetchBundle, isLikelyText, isSupportedPlatform, licenseSchema, normalizeBundlePath, normalizePlatformInput, resolvePreset, slugSchema, titleSchema, toUtf8String, validatePresetConfig, verifyBundledFileChecksum } from "@agentrules/core";
8
8
  import * as client from "openid-client";
9
9
  import chalk from "chalk";
10
10
  import { chmod, constants } from "fs";
11
- import { access, constants as constants$1, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
11
+ import { access, constants as constants$1, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises";
12
12
  import { homedir } from "os";
13
13
  import * as p from "@clack/prompts";
14
14
 
@@ -221,6 +221,14 @@ function fileStatus(status, filePath, options = {}) {
221
221
  return `${config.style(config.symbol)} ${config.style(pad(label, 14))} ${filePath}`;
222
222
  }
223
223
  /**
224
+ * Format a backup status line
225
+ * e.g., "↪ backed up .opencode/AGENT_RULES.md → .opencode/AGENT_RULES.md.bak"
226
+ */
227
+ function backupStatus(originalPath, backupPath, options = {}) {
228
+ const label = options.dryRun ? "would backup" : "backed up";
229
+ return `${theme.info("↪")} ${theme.info(pad(label, 14))} ${originalPath} ${symbols.arrow} ${backupPath}`;
230
+ }
231
+ /**
224
232
  * Step indicator for multi-step operations
225
233
  * e.g., "[1/3] Fetching registry..."
226
234
  */
@@ -335,6 +343,7 @@ const ui = {
335
343
  warning,
336
344
  info: info$1,
337
345
  fileStatus,
346
+ backupStatus,
338
347
  step,
339
348
  brand,
340
349
  banner,
@@ -575,6 +584,19 @@ function formatPollingError(err) {
575
584
  return "Lost connection to server. Please try again.";
576
585
  }
577
586
 
587
+ //#endregion
588
+ //#region src/lib/errors.ts
589
+ /**
590
+ * Error utilities
591
+ */
592
+ /**
593
+ * Safely extract an error message from any error type.
594
+ */
595
+ function getErrorMessage(error$2) {
596
+ if (error$2 instanceof Error) return error$2.message;
597
+ return String(error$2);
598
+ }
599
+
578
600
  //#endregion
579
601
  //#region src/lib/api/presets.ts
580
602
  /**
@@ -608,10 +630,9 @@ async function publishPreset(baseUrl, token, input) {
608
630
  data
609
631
  };
610
632
  } catch (error$2) {
611
- const message = error$2 instanceof Error ? error$2.message : String(error$2);
612
633
  return {
613
634
  success: false,
614
- error: `Failed to connect to registry: ${message}`
635
+ error: `Failed to connect to registry: ${getErrorMessage(error$2)}`
615
636
  };
616
637
  }
617
638
  }
@@ -640,10 +661,9 @@ async function unpublishPreset(baseUrl, token, slug, platform, version$1) {
640
661
  data
641
662
  };
642
663
  } catch (error$2) {
643
- const message = error$2 instanceof Error ? error$2.message : String(error$2);
644
664
  return {
645
665
  success: false,
646
- error: `Failed to connect to registry: ${message}`
666
+ error: `Failed to connect to registry: ${getErrorMessage(error$2)}`
647
667
  };
648
668
  }
649
669
  }
@@ -673,6 +693,7 @@ async function fetchSession(baseUrl, token) {
673
693
 
674
694
  //#endregion
675
695
  //#region src/lib/config.ts
696
+ /** Directory for CLI configuration and credentials (e.g., ~/.agentrules/) */
676
697
  const CONFIG_DIRNAME = ".agentrules";
677
698
  const CONFIG_FILENAME = "config.json";
678
699
  const CONFIG_HOME_ENV = "AGENT_RULES_HOME";
@@ -867,7 +888,7 @@ async function loadStore() {
867
888
  const store = JSON.parse(raw);
868
889
  return store;
869
890
  } catch (error$2) {
870
- log.debug(`Failed to load credentials: ${error$2 instanceof Error ? error$2.message : String(error$2)}`);
891
+ log.debug(`Failed to load credentials: ${getErrorMessage(error$2)}`);
871
892
  return {};
872
893
  }
873
894
  }
@@ -981,7 +1002,7 @@ async function createAppContext(options = {}) {
981
1002
  log.debug("Saved fetched user info to credentials");
982
1003
  }
983
1004
  } catch (error$2) {
984
- log.debug(`Failed to fetch user info: ${error$2 instanceof Error ? error$2.message : String(error$2)}`);
1005
+ log.debug(`Failed to fetch user info: ${getErrorMessage(error$2)}`);
985
1006
  }
986
1007
  }
987
1008
  log.debug(`App context loaded: isLoggedIn=${isLoggedIn}, user=${user?.name ?? "none"}`);
@@ -1018,10 +1039,11 @@ function resolveRegistry(config, alias) {
1018
1039
  }
1019
1040
  let globalContext = null;
1020
1041
  /**
1021
- * Gets the global app context, or null if not initialized.
1022
- * Use this in commands to access cached config, registry, and auth state.
1042
+ * Gets the global app context.
1043
+ * Throws if context has not been initialized via initAppContext().
1023
1044
  */
1024
1045
  function useAppContext() {
1046
+ if (!globalContext) throw new Error("App context not initialized");
1025
1047
  return globalContext;
1026
1048
  }
1027
1049
  /**
@@ -1042,7 +1064,6 @@ const CLIENT_ID = "agentrules-cli";
1042
1064
  async function login(options = {}) {
1043
1065
  const { noBrowser = false, onDeviceCode, onBrowserOpen, onPollingStart, onAuthorized } = options;
1044
1066
  const ctx = useAppContext();
1045
- if (!ctx) throw new Error("App context not initialized");
1046
1067
  const { url: registryUrl } = ctx.registry;
1047
1068
  log.debug(`Authenticating with ${registryUrl}`);
1048
1069
  try {
@@ -1106,10 +1127,9 @@ async function login(options = {}) {
1106
1127
  } : void 0
1107
1128
  };
1108
1129
  } catch (error$2) {
1109
- const message = error$2 instanceof Error ? error$2.message : String(error$2);
1110
1130
  return {
1111
1131
  success: false,
1112
- error: message
1132
+ error: getErrorMessage(error$2)
1113
1133
  };
1114
1134
  }
1115
1135
  }
@@ -1152,7 +1172,6 @@ async function logout(options = {}) {
1152
1172
  };
1153
1173
  }
1154
1174
  const ctx = useAppContext();
1155
- if (!ctx) throw new Error("App context not initialized");
1156
1175
  const { url: registryUrl } = ctx.registry;
1157
1176
  const hadCredentials = ctx.credentials !== null;
1158
1177
  if (hadCredentials) {
@@ -1172,7 +1191,6 @@ async function logout(options = {}) {
1172
1191
  */
1173
1192
  async function whoami() {
1174
1193
  const ctx = useAppContext();
1175
- if (!ctx) throw new Error("App context not initialized");
1176
1194
  const { url: registryUrl } = ctx.registry;
1177
1195
  return {
1178
1196
  success: true,
@@ -1187,7 +1205,6 @@ async function whoami() {
1187
1205
  //#region src/commands/preset/add.ts
1188
1206
  async function addPreset(options) {
1189
1207
  const ctx = useAppContext();
1190
- if (!ctx) throw new Error("App context not initialized");
1191
1208
  const { alias: registryAlias, url: registryUrl } = ctx.registry;
1192
1209
  const dryRun = Boolean(options.dryRun);
1193
1210
  const { slug, platform, version: version$1 } = parsePresetInput(options.preset, options.platform, options.version);
@@ -1201,6 +1218,7 @@ async function addPreset(options) {
1201
1218
  const writeStats = await writeBundleFiles(bundle, target, {
1202
1219
  force: Boolean(options.force),
1203
1220
  skipConflicts: Boolean(options.skipConflicts),
1221
+ noBackup: Boolean(options.noBackup),
1204
1222
  dryRun
1205
1223
  });
1206
1224
  return {
@@ -1208,6 +1226,7 @@ async function addPreset(options) {
1208
1226
  bundle,
1209
1227
  files: writeStats.files,
1210
1228
  conflicts: writeStats.conflicts,
1229
+ backups: writeStats.backups,
1211
1230
  targetRoot: target.root,
1212
1231
  targetLabel: target.label,
1213
1232
  registryAlias,
@@ -1290,20 +1309,13 @@ function resolveInstallTarget(platform, options) {
1290
1309
  async function writeBundleFiles(bundle, target, behavior) {
1291
1310
  const files = [];
1292
1311
  const conflicts = [];
1312
+ const backups = [];
1293
1313
  if (!behavior.dryRun) await mkdir(target.root, { recursive: true });
1294
1314
  for (const file of bundle.files) {
1295
1315
  const decoded = decodeBundledFile(file);
1296
1316
  const data = Buffer.from(decoded);
1297
1317
  await verifyBundledFileChecksum(file, data);
1298
1318
  const destResult = computeDestinationPath(file.path, target);
1299
- if (destResult.skipped) {
1300
- files.push({
1301
- path: file.path,
1302
- status: "skipped"
1303
- });
1304
- log.debug(`Skipped (root file): ${file.path}`);
1305
- continue;
1306
- }
1307
1319
  const destination = destResult.path;
1308
1320
  if (!behavior.dryRun) await mkdir(dirname(destination), { recursive: true });
1309
1321
  const existing = await readExistingFile(destination);
@@ -1326,6 +1338,16 @@ async function writeBundleFiles(bundle, target, behavior) {
1326
1338
  continue;
1327
1339
  }
1328
1340
  if (behavior.force) {
1341
+ if (!behavior.noBackup) {
1342
+ const backupPath = `${destination}.bak`;
1343
+ const relativeBackupPath = `${relativePath}.bak`;
1344
+ if (!behavior.dryRun) await copyFile(destination, backupPath);
1345
+ backups.push({
1346
+ originalPath: relativePath,
1347
+ backupPath: relativeBackupPath
1348
+ });
1349
+ log.debug(`Backed up: ${relativePath} → ${relativeBackupPath}`);
1350
+ }
1329
1351
  if (!behavior.dryRun) await writeFile(destination, data);
1330
1352
  files.push({
1331
1353
  path: relativePath,
@@ -1346,30 +1368,27 @@ async function writeBundleFiles(bundle, target, behavior) {
1346
1368
  }
1347
1369
  return {
1348
1370
  files,
1349
- conflicts
1371
+ conflicts,
1372
+ backups
1350
1373
  };
1351
1374
  }
1375
+ /**
1376
+ * Compute destination path for a bundled file.
1377
+ *
1378
+ * Bundle files are stored with paths relative to the platform directory
1379
+ * (e.g., "AGENTS.md", "commands/test.md") and installed to:
1380
+ * - Project/custom: <root>/<projectDir>/<path> (e.g., .opencode/AGENTS.md)
1381
+ * - Global: <root>/<path> (e.g., ~/.config/opencode/AGENTS.md)
1382
+ */
1352
1383
  function computeDestinationPath(pathInput, target) {
1353
1384
  const normalized = normalizeBundlePath(pathInput);
1354
- const configPrefix = `${CONFIG_DIR_NAME}/`;
1355
- const isConfigFile = normalized.startsWith(configPrefix);
1356
- if (target.mode === "global" && !isConfigFile) return {
1357
- skipped: true,
1358
- path: null
1359
- };
1385
+ if (!normalized) throw new Error(`Unable to derive destination for ${pathInput}. The computed relative path is empty.`);
1360
1386
  let relativePath;
1361
- if (isConfigFile) {
1362
- const withoutConfigPrefix = normalized.slice(configPrefix.length);
1363
- if (target.mode === "global") relativePath = withoutConfigPrefix;
1364
- else relativePath = `${target.projectDir}/${withoutConfigPrefix}`;
1365
- } else relativePath = normalized;
1366
- if (!relativePath) throw new Error(`Unable to derive destination for ${pathInput}. The computed relative path is empty.`);
1387
+ if (target.mode === "global") relativePath = normalized;
1388
+ else relativePath = `${target.projectDir}/${normalized}`;
1367
1389
  const destination = resolve(target.root, relativePath);
1368
1390
  ensureWithinRoot(destination, target.root);
1369
- return {
1370
- skipped: false,
1371
- path: destination
1372
- };
1391
+ return { path: destination };
1373
1392
  }
1374
1393
  async function readExistingFile(pathname) {
1375
1394
  try {
@@ -1431,6 +1450,22 @@ async function directoryExists(path$1) {
1431
1450
 
1432
1451
  //#endregion
1433
1452
  //#region src/lib/preset-utils.ts
1453
+ const INSTALL_FILENAME = "INSTALL.txt";
1454
+ const README_FILENAME = "README.md";
1455
+ const LICENSE_FILENAME = "LICENSE.md";
1456
+ /**
1457
+ * Files/directories that are always excluded from presets.
1458
+ * These are never useful in a preset bundle.
1459
+ */
1460
+ const DEFAULT_IGNORE_PATTERNS = [
1461
+ "node_modules",
1462
+ ".git",
1463
+ ".DS_Store",
1464
+ "*.lock",
1465
+ "package-lock.json",
1466
+ "bun.lockb",
1467
+ "pnpm-lock.yaml"
1468
+ ];
1434
1469
  /**
1435
1470
  * Normalize a string to a valid preset slug (lowercase kebab-case)
1436
1471
  */
@@ -1454,6 +1489,128 @@ async function resolveConfigPath(inputPath) {
1454
1489
  if (stats?.isDirectory()) return join(inputPath, PRESET_CONFIG_FILENAME);
1455
1490
  return inputPath;
1456
1491
  }
1492
+ /**
1493
+ * Load a preset from a directory containing agentrules.json.
1494
+ *
1495
+ * Config in platform dir (e.g., .claude/agentrules.json):
1496
+ * - Preset files: siblings of config
1497
+ * - Metadata: .agentrules/ subfolder
1498
+ *
1499
+ * Config at repo root:
1500
+ * - Preset files: in .claude/ (or `path` from config)
1501
+ * - Metadata: .agentrules/ subfolder
1502
+ */
1503
+ async function loadPreset(presetDir) {
1504
+ const configPath = join(presetDir, PRESET_CONFIG_FILENAME);
1505
+ if (!await fileExists(configPath)) throw new Error(`Config file not found: ${configPath}`);
1506
+ const configRaw = await readFile(configPath, "utf8");
1507
+ let configJson;
1508
+ try {
1509
+ configJson = JSON.parse(configRaw);
1510
+ } catch {
1511
+ throw new Error(`Invalid JSON in ${configPath}`);
1512
+ }
1513
+ const configObj = configJson;
1514
+ const identifier = typeof configObj?.name === "string" ? configObj.name : configPath;
1515
+ const config = validatePresetConfig(configJson, identifier);
1516
+ const slug = config.name;
1517
+ const dirName = basename(presetDir);
1518
+ const isConfigInPlatformDir = isPlatformDir(dirName);
1519
+ let filesDir;
1520
+ let metadataDir;
1521
+ if (isConfigInPlatformDir) {
1522
+ filesDir = presetDir;
1523
+ metadataDir = join(presetDir, AGENT_RULES_DIR);
1524
+ log.debug(`Config in platform dir: files in ${filesDir}, metadata in ${metadataDir}`);
1525
+ } else {
1526
+ const platformDir = config.path ?? PLATFORMS[config.platform].projectDir;
1527
+ filesDir = join(presetDir, platformDir);
1528
+ metadataDir = join(presetDir, AGENT_RULES_DIR);
1529
+ log.debug(`Config at repo root: files in ${filesDir}, metadata in ${metadataDir}`);
1530
+ if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir}. Create the directory or set "path" in ${PRESET_CONFIG_FILENAME}.`);
1531
+ }
1532
+ let installMessage;
1533
+ let readmeContent;
1534
+ let licenseContent;
1535
+ if (await directoryExists(metadataDir)) {
1536
+ installMessage = await readFileIfExists(join(metadataDir, INSTALL_FILENAME));
1537
+ readmeContent = await readFileIfExists(join(metadataDir, README_FILENAME));
1538
+ licenseContent = await readFileIfExists(join(metadataDir, LICENSE_FILENAME));
1539
+ }
1540
+ const ignorePatterns = [...DEFAULT_IGNORE_PATTERNS, ...config.ignore ?? []];
1541
+ const rootExclude = [PRESET_CONFIG_FILENAME, AGENT_RULES_DIR];
1542
+ const files = await collectFiles(filesDir, rootExclude, ignorePatterns);
1543
+ if (files.length === 0) throw new Error(`No files found in ${filesDir}. Presets must include at least one file.`);
1544
+ return {
1545
+ slug,
1546
+ config,
1547
+ files,
1548
+ installMessage,
1549
+ readmeContent,
1550
+ licenseContent
1551
+ };
1552
+ }
1553
+ /**
1554
+ * Check if a filename matches an ignore pattern.
1555
+ * Supports:
1556
+ * - Exact match: "node_modules"
1557
+ * - Extension match: "*.lock"
1558
+ * - Prefix match: ".git*" (not implemented yet, keeping simple)
1559
+ */
1560
+ function matchesPattern(name, pattern) {
1561
+ if (pattern.startsWith("*.")) {
1562
+ const ext = pattern.slice(1);
1563
+ return name.endsWith(ext);
1564
+ }
1565
+ return name === pattern;
1566
+ }
1567
+ /**
1568
+ * Check if a filename should be ignored based on patterns.
1569
+ */
1570
+ function shouldIgnore(name, patterns) {
1571
+ return patterns.some((pattern) => matchesPattern(name, pattern));
1572
+ }
1573
+ /**
1574
+ * Recursively collect all files from a directory.
1575
+ *
1576
+ * @param dir - Current directory being scanned
1577
+ * @param rootExclude - Entries to exclude at root level only (config, metadata dir)
1578
+ * @param ignorePatterns - Patterns to ignore at all levels
1579
+ * @param root - The root directory (for computing relative paths)
1580
+ */
1581
+ async function collectFiles(dir, rootExclude, ignorePatterns, root) {
1582
+ const configRoot = root ?? dir;
1583
+ const isRoot = configRoot === dir;
1584
+ const entries = await readdir(dir, { withFileTypes: true });
1585
+ const files = [];
1586
+ for (const entry of entries) {
1587
+ if (isRoot && rootExclude.includes(entry.name)) continue;
1588
+ if (shouldIgnore(entry.name, ignorePatterns)) {
1589
+ log.debug(`Ignoring: ${entry.name}`);
1590
+ continue;
1591
+ }
1592
+ const fullPath = join(dir, entry.name);
1593
+ if (entry.isDirectory()) {
1594
+ const nested = await collectFiles(fullPath, rootExclude, ignorePatterns, configRoot);
1595
+ files.push(...nested);
1596
+ } else if (entry.isFile()) {
1597
+ const contents = await readFile(fullPath, "utf8");
1598
+ const relativePath = relative(configRoot, fullPath);
1599
+ files.push({
1600
+ path: relativePath,
1601
+ contents
1602
+ });
1603
+ }
1604
+ }
1605
+ return files;
1606
+ }
1607
+ /**
1608
+ * Read a file if it exists, otherwise return undefined.
1609
+ */
1610
+ async function readFileIfExists(path$1) {
1611
+ if (await fileExists(path$1)) return await readFile(path$1, "utf8");
1612
+ return;
1613
+ }
1457
1614
 
1458
1615
  //#endregion
1459
1616
  //#region src/commands/preset/init.ts
@@ -1461,11 +1618,9 @@ async function resolveConfigPath(inputPath) {
1461
1618
  const PLATFORM_DETECTION_PATHS = {
1462
1619
  opencode: [".opencode"],
1463
1620
  claude: [".claude"],
1464
- cursor: [".cursor", ".cursorrules"],
1621
+ cursor: [".cursor"],
1465
1622
  codex: [".codex"]
1466
1623
  };
1467
- /** Default path for new preset authoring */
1468
- const DEFAULT_FILES_PATH = "files";
1469
1624
  /** Default preset name when none specified */
1470
1625
  const DEFAULT_PRESET_NAME$1 = "my-preset";
1471
1626
  /**
@@ -1488,49 +1643,105 @@ async function detectPlatforms(directory) {
1488
1643
  }
1489
1644
  return detected;
1490
1645
  }
1646
+ /**
1647
+ * Resolve the target platform directory for initialization.
1648
+ *
1649
+ * Detection order (deterministic):
1650
+ * 1. If targetDir itself is a platform directory (e.g., ".claude"), use it directly
1651
+ * 2. Otherwise, detect platform directories inside targetDir
1652
+ *
1653
+ * @param targetDir - The target directory (cwd or user-provided path)
1654
+ * @param platformOverride - Optional platform to use instead of detecting/inferring
1655
+ */
1656
+ async function resolvePlatformDirectory(targetDir, platformOverride) {
1657
+ const targetDirName = basename(targetDir);
1658
+ const targetPlatform = getPlatformFromDir(targetDirName);
1659
+ if (targetPlatform) {
1660
+ const platform$1 = platformOverride ? normalizePlatform(platformOverride) : targetPlatform;
1661
+ return {
1662
+ platformDir: targetDir,
1663
+ platform: platform$1,
1664
+ isTargetPlatformDir: true,
1665
+ detected: []
1666
+ };
1667
+ }
1668
+ const detected = await detectPlatforms(targetDir);
1669
+ let platform;
1670
+ let platformDir;
1671
+ if (platformOverride) {
1672
+ platform = normalizePlatform(platformOverride);
1673
+ const detectedPath = detected.find((d) => d.id === platform)?.path;
1674
+ platformDir = detectedPath ? join(targetDir, detectedPath) : join(targetDir, PLATFORMS[platform].projectDir);
1675
+ } else if (detected.length > 0) {
1676
+ platform = detected[0].id;
1677
+ platformDir = join(targetDir, detected[0].path);
1678
+ } else {
1679
+ platform = "opencode";
1680
+ platformDir = join(targetDir, PLATFORMS.opencode.projectDir);
1681
+ }
1682
+ return {
1683
+ platformDir,
1684
+ platform,
1685
+ isTargetPlatformDir: false,
1686
+ detected
1687
+ };
1688
+ }
1689
+ /**
1690
+ * Check if --platform flag is required for non-interactive mode.
1691
+ * Returns the reason if required, so CLI can show appropriate error.
1692
+ */
1693
+ function requiresPlatformFlag(resolved) {
1694
+ if (resolved.isTargetPlatformDir) return { required: false };
1695
+ if (resolved.detected.length === 0) return {
1696
+ required: true,
1697
+ reason: "no_platforms"
1698
+ };
1699
+ if (resolved.detected.length > 1) return {
1700
+ required: true,
1701
+ reason: "multiple_platforms",
1702
+ platforms: resolved.detected.map((d) => d.id)
1703
+ };
1704
+ return { required: false };
1705
+ }
1706
+ /**
1707
+ * Initialize a preset in a platform directory.
1708
+ *
1709
+ * Structure:
1710
+ * - platformDir/agentrules.json - preset config
1711
+ * - platformDir/* - platform files (added by user)
1712
+ * - platformDir/.agentrules/ - optional metadata folder (README, LICENSE, etc.)
1713
+ */
1491
1714
  async function initPreset(options) {
1492
- const directory = options.directory ?? process.cwd();
1493
- log.debug(`Initializing preset in: ${directory}`);
1715
+ const platformDir = options.directory ?? process.cwd();
1716
+ log.debug(`Initializing preset in: ${platformDir}`);
1717
+ const inferredPlatform = getPlatformFromDir(basename(platformDir));
1718
+ const platform = normalizePlatform(options.platform ?? inferredPlatform ?? "opencode");
1494
1719
  const name = normalizeName(options.name ?? DEFAULT_PRESET_NAME$1);
1495
1720
  const title = options.title ?? toTitleCase(name);
1496
1721
  const description = options.description ?? `${title} preset`;
1497
- const platform = normalizePlatform(options.platform ?? "opencode");
1498
- const detectedPath = options.detectedPath;
1499
1722
  const license = options.license ?? "MIT";
1500
1723
  log.debug(`Preset name: ${name}, platform: ${platform}`);
1501
- const configPath = join(directory, PRESET_CONFIG_FILENAME);
1724
+ const configPath = join(platformDir, PRESET_CONFIG_FILENAME);
1502
1725
  if (!options.force && await fileExists(configPath)) throw new Error(`${PRESET_CONFIG_FILENAME} already exists. Use --force to overwrite.`);
1503
- const defaultPath = PLATFORMS[platform].projectDir;
1504
- const effectivePath = detectedPath ?? DEFAULT_FILES_PATH;
1505
1726
  const preset = {
1506
1727
  $schema: PRESET_SCHEMA_URL,
1507
1728
  name,
1508
1729
  title,
1509
1730
  version: 1,
1510
1731
  description,
1511
- tags: ["// TODO: Replace - Tags help users discover your preset (e.g., typescript, react)"],
1512
- features: ["// TODO: Replace - Features describe what your preset does (e.g., Built-in commands for common workflows)"],
1513
1732
  license,
1514
1733
  platform
1515
1734
  };
1516
- if (effectivePath !== defaultPath) preset.path = effectivePath;
1517
- await mkdir(directory, { recursive: true });
1518
- log.debug(`Created/verified directory: ${directory}`);
1519
- const content = `${JSON.stringify(preset, null, 2)}\n`;
1520
- await writeFile(configPath, content, "utf8");
1521
- log.debug(`Wrote config file: ${configPath}`);
1522
1735
  let createdDir;
1523
- if (detectedPath) log.debug(`Using detected platform directory: ${detectedPath}`);
1736
+ if (await directoryExists(platformDir)) log.debug(`Platform directory exists: ${platformDir}`);
1524
1737
  else {
1525
- const filesPath = effectivePath;
1526
- const fullPath = join(directory, filesPath);
1527
- if (await directoryExists(fullPath)) log.debug(`Files directory already exists: ${filesPath}`);
1528
- else {
1529
- await mkdir(fullPath, { recursive: true });
1530
- createdDir = filesPath;
1531
- log.debug(`Created files directory: ${filesPath}`);
1532
- }
1738
+ await mkdir(platformDir, { recursive: true });
1739
+ createdDir = platformDir;
1740
+ log.debug(`Created platform directory: ${platformDir}`);
1533
1741
  }
1742
+ const content = `${JSON.stringify(preset, null, 2)}\n`;
1743
+ await writeFile(configPath, content, "utf8");
1744
+ log.debug(`Wrote config file: ${configPath}`);
1534
1745
  log.debug("Preset initialization complete.");
1535
1746
  return {
1536
1747
  configPath,
@@ -1562,17 +1773,55 @@ function check(schema) {
1562
1773
  //#region src/commands/preset/init-interactive.ts
1563
1774
  const DEFAULT_PRESET_NAME = "my-preset";
1564
1775
  /**
1565
- * Run interactive init flow with clack prompts
1776
+ * Run interactive init flow with clack prompts.
1777
+ *
1778
+ * If platformDir is provided, init directly in that directory.
1779
+ * Otherwise, detect platform directories and prompt user to select one.
1566
1780
  */
1567
1781
  async function initInteractive(options) {
1568
- const { directory, name: nameOption, title: titleOption, description: descriptionOption, platform: platformOption, license: licenseOption } = options;
1782
+ const { baseDir, platformDir: explicitPlatformDir, name: nameOption, title: titleOption, description: descriptionOption, platform: platformOption, license: licenseOption } = options;
1569
1783
  let { force } = options;
1570
1784
  const defaultName = nameOption ?? DEFAULT_PRESET_NAME;
1571
1785
  p.intro("Create a new preset");
1572
- const configPath = join(directory, PRESET_CONFIG_FILENAME);
1786
+ let targetPlatformDir;
1787
+ let selectedPlatform;
1788
+ if (explicitPlatformDir) {
1789
+ targetPlatformDir = explicitPlatformDir;
1790
+ const dirName = basename(explicitPlatformDir);
1791
+ selectedPlatform = platformOption ?? getPlatformFromDir(dirName) ?? "opencode";
1792
+ } else {
1793
+ const resolved = await resolvePlatformDirectory(baseDir, platformOption);
1794
+ if (resolved.isTargetPlatformDir) {
1795
+ targetPlatformDir = resolved.platformDir;
1796
+ selectedPlatform = resolved.platform;
1797
+ p.note(`Detected platform directory: ${resolved.platform}`, "Using current directory");
1798
+ } else {
1799
+ const detectedMap = new Map(resolved.detected.map((d) => [d.id, d]));
1800
+ if (resolved.detected.length > 0) p.note(resolved.detected.map((d) => `${d.id} → ${d.path}`).join("\n"), "Detected platform directories");
1801
+ const platformChoice = await p.select({
1802
+ message: "Platform",
1803
+ options: PLATFORM_IDS.map((id) => ({
1804
+ value: id,
1805
+ label: detectedMap.has(id) ? `${id} (detected)` : id,
1806
+ hint: detectedMap.get(id)?.path
1807
+ })),
1808
+ initialValue: resolved.platform
1809
+ });
1810
+ if (p.isCancel(platformChoice)) {
1811
+ p.cancel("Cancelled");
1812
+ process.exit(0);
1813
+ }
1814
+ selectedPlatform = platformChoice;
1815
+ if (selectedPlatform !== resolved.platform) {
1816
+ const reResolved = await resolvePlatformDirectory(baseDir, selectedPlatform);
1817
+ targetPlatformDir = reResolved.platformDir;
1818
+ } else targetPlatformDir = resolved.platformDir;
1819
+ }
1820
+ }
1821
+ const configPath = join(targetPlatformDir, PRESET_CONFIG_FILENAME);
1573
1822
  if (!force && await fileExists(configPath)) {
1574
1823
  const overwrite = await p.confirm({
1575
- message: `${PRESET_CONFIG_FILENAME} already exists. Overwrite?`,
1824
+ message: `${PRESET_CONFIG_FILENAME} already exists in ${targetPlatformDir}. Overwrite?`,
1576
1825
  initialValue: false
1577
1826
  });
1578
1827
  if (p.isCancel(overwrite) || !overwrite) {
@@ -1581,9 +1830,6 @@ async function initInteractive(options) {
1581
1830
  }
1582
1831
  force = true;
1583
1832
  }
1584
- const detected = await detectPlatforms(directory);
1585
- const detectedMap = new Map(detected.map((d) => [d.id, d]));
1586
- if (detected.length > 0) p.note(detected.map((d) => `${d.id} → ${d.path}`).join("\n"), "Detected platform directories");
1587
1833
  const result = await p.group({
1588
1834
  name: () => p.text({
1589
1835
  message: "Preset name (slug)",
@@ -1609,18 +1855,6 @@ async function initInteractive(options) {
1609
1855
  validate: check(descriptionSchema)
1610
1856
  });
1611
1857
  },
1612
- platform: () => {
1613
- const defaultPlatform = platformOption ?? (detected.length > 0 ? detected[0].id : "opencode");
1614
- return p.select({
1615
- message: "Platform",
1616
- options: PLATFORM_IDS.map((id) => ({
1617
- value: id,
1618
- label: detectedMap.has(id) ? `${id} (detected)` : id,
1619
- hint: detectedMap.get(id)?.path
1620
- })),
1621
- initialValue: defaultPlatform
1622
- });
1623
- },
1624
1858
  license: async () => {
1625
1859
  const defaultLicense = licenseOption ?? "MIT";
1626
1860
  const choice = await p.select({
@@ -1656,14 +1890,12 @@ async function initInteractive(options) {
1656
1890
  p.cancel("Cancelled");
1657
1891
  return process.exit(0);
1658
1892
  } });
1659
- const detectedPath = detectedMap.get(result.platform)?.path;
1660
1893
  const initOptions = {
1661
- directory,
1894
+ directory: targetPlatformDir,
1662
1895
  name: result.name,
1663
1896
  title: result.title,
1664
1897
  description: result.description,
1665
- platform: result.platform,
1666
- detectedPath,
1898
+ platform: selectedPlatform,
1667
1899
  license: result.license,
1668
1900
  force
1669
1901
  };
@@ -1727,11 +1959,16 @@ async function validatePreset(options) {
1727
1959
  const platform = preset.platform;
1728
1960
  log.debug(`Checking platform: ${platform}`);
1729
1961
  if (isSupportedPlatform(platform)) {
1730
- const filesPath = preset.path ?? PLATFORMS[platform].projectDir;
1731
- const filesDir = join(presetDir, filesPath);
1732
- const filesExists = await directoryExists(filesDir);
1733
- log.debug(`Files directory check: ${filesDir} - ${filesExists ? "exists" : "not found"}`);
1734
- if (!filesExists) errors.push(`Files directory not found: ${filesPath}`);
1962
+ const dirName = basename(presetDir);
1963
+ const isInProjectMode = isPlatformDir(dirName);
1964
+ if (isInProjectMode) log.debug(`In-project mode: files expected in ${presetDir}`);
1965
+ else {
1966
+ const filesPath = preset.path ?? PLATFORMS[platform].projectDir;
1967
+ const filesDir = join(presetDir, filesPath);
1968
+ const filesExists = await directoryExists(filesDir);
1969
+ log.debug(`Standalone mode: files directory check: ${filesDir} - ${filesExists ? "exists" : "not found"}`);
1970
+ if (!filesExists) errors.push(`Files directory not found: ${filesPath}`);
1971
+ }
1735
1972
  } else {
1736
1973
  errors.push(`Unknown platform "${platform}". Supported: ${PLATFORM_IDS.join(", ")}`);
1737
1974
  log.debug(`Platform "${platform}" is not supported`);
@@ -1762,9 +1999,6 @@ async function validatePreset(options) {
1762
1999
 
1763
2000
  //#endregion
1764
2001
  //#region src/commands/publish.ts
1765
- const INSTALL_FILENAME$1 = "INSTALL.txt";
1766
- const README_FILENAME$1 = "README.md";
1767
- const LICENSE_FILENAME$1 = "LICENSE.md";
1768
2002
  /** Maximum size per bundle in bytes (1MB) */
1769
2003
  const MAX_BUNDLE_SIZE_BYTES = 1 * 1024 * 1024;
1770
2004
  /**
@@ -1782,12 +2016,12 @@ async function publish(options = {}) {
1782
2016
  const { path: path$1, version: version$1, dryRun = false } = options;
1783
2017
  log.debug(`Publishing preset from path: ${path$1 ?? process.cwd()}${dryRun ? " (dry run)" : ""}`);
1784
2018
  const ctx = useAppContext();
1785
- if (!ctx) throw new Error("App context not initialized");
1786
2019
  if (!(dryRun || ctx.isLoggedIn && ctx.credentials)) {
1787
- log.error(`Not logged in. Run ${ui.command("agentrules login")} to authenticate.`);
2020
+ const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
2021
+ log.error(error$2);
1788
2022
  return {
1789
2023
  success: false,
1790
- error: `Not logged in. Run ${ui.command("agentrules login")} to authenticate.`
2024
+ error: error$2
1791
2025
  };
1792
2026
  }
1793
2027
  if (!dryRun) log.debug(`Authenticated as user, publishing to ${ctx.registry.url}`);
@@ -1807,15 +2041,15 @@ async function publish(options = {}) {
1807
2041
  spinner$1.update("Loading preset...");
1808
2042
  let presetInput;
1809
2043
  try {
1810
- presetInput = await loadPreset$1(presetDir);
2044
+ presetInput = await loadPreset(presetDir);
1811
2045
  log.debug(`Loaded preset "${presetInput.slug}" for platform ${presetInput.config.platform}`);
1812
2046
  } catch (error$2) {
1813
- const errorMessage = error$2 instanceof Error ? error$2.message : String(error$2);
2047
+ const message = getErrorMessage(error$2);
1814
2048
  spinner$1.fail("Failed to load preset");
1815
- log.error(errorMessage);
2049
+ log.error(message);
1816
2050
  return {
1817
2051
  success: false,
1818
- error: errorMessage
2052
+ error: message
1819
2053
  };
1820
2054
  }
1821
2055
  spinner$1.update("Building bundle...");
@@ -1827,12 +2061,12 @@ async function publish(options = {}) {
1827
2061
  });
1828
2062
  log.debug(`Built publish input for ${publishInput.platform}`);
1829
2063
  } catch (error$2) {
1830
- const errorMessage = error$2 instanceof Error ? error$2.message : String(error$2);
2064
+ const message = getErrorMessage(error$2);
1831
2065
  spinner$1.fail("Failed to build bundle");
1832
- log.error(errorMessage);
2066
+ log.error(message);
1833
2067
  return {
1834
2068
  success: false,
1835
- error: errorMessage
2069
+ error: message
1836
2070
  };
1837
2071
  }
1838
2072
  const inputJson = JSON.stringify(publishInput);
@@ -1912,79 +2146,9 @@ async function publish(options = {}) {
1912
2146
  }
1913
2147
  };
1914
2148
  }
1915
- /**
1916
- * Load a preset from a directory
1917
- */
1918
- async function loadPreset$1(presetDir) {
1919
- const configPath = join(presetDir, PRESET_CONFIG_FILENAME);
1920
- if (!await fileExists(configPath)) throw new Error(`Config file not found: ${configPath}`);
1921
- const configRaw = await readFile(configPath, "utf8");
1922
- let configJson;
1923
- try {
1924
- configJson = JSON.parse(configRaw);
1925
- } catch {
1926
- throw new Error(`Invalid JSON in ${configPath}`);
1927
- }
1928
- const configObj = configJson;
1929
- const identifier = typeof configObj?.name === "string" ? configObj.name : configPath;
1930
- const config = validatePresetConfig(configJson, identifier);
1931
- const slug = config.name;
1932
- const installPath = join(presetDir, INSTALL_FILENAME$1);
1933
- const installMessage = await readFileIfExists$1(installPath);
1934
- const readmePath = join(presetDir, README_FILENAME$1);
1935
- const readmeContent = await readFileIfExists$1(readmePath);
1936
- const licensePath = join(presetDir, LICENSE_FILENAME$1);
1937
- const licenseContent = await readFileIfExists$1(licensePath);
1938
- const filesPath = config.path ?? PLATFORMS[config.platform].projectDir;
1939
- const filesDir = join(presetDir, filesPath);
1940
- if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir} (referenced in ${configPath})`);
1941
- const files = await collectFiles$1(filesDir);
1942
- if (files.length === 0) throw new Error(`No files found in ${filesDir}. Presets must include at least one file.`);
1943
- return {
1944
- slug,
1945
- config,
1946
- files,
1947
- installMessage,
1948
- readmeContent,
1949
- licenseContent
1950
- };
1951
- }
1952
- /**
1953
- * Recursively collect all files from a directory
1954
- */
1955
- async function collectFiles$1(dir, baseDir) {
1956
- const root = baseDir ?? dir;
1957
- const entries = await readdir(dir, { withFileTypes: true });
1958
- const files = [];
1959
- for (const entry of entries) {
1960
- const fullPath = join(dir, entry.name);
1961
- if (entry.isDirectory()) {
1962
- const nested = await collectFiles$1(fullPath, root);
1963
- files.push(...nested);
1964
- } else if (entry.isFile()) {
1965
- const contents = await readFile(fullPath, "utf8");
1966
- const relativePath = relative(root, fullPath);
1967
- files.push({
1968
- path: relativePath,
1969
- contents
1970
- });
1971
- }
1972
- }
1973
- return files;
1974
- }
1975
- /**
1976
- * Read a file if it exists, otherwise return undefined
1977
- */
1978
- async function readFileIfExists$1(path$1) {
1979
- if (await fileExists(path$1)) return await readFile(path$1, "utf8");
1980
- return;
1981
- }
1982
2149
 
1983
2150
  //#endregion
1984
2151
  //#region src/commands/registry/build.ts
1985
- const INSTALL_FILENAME = "INSTALL.txt";
1986
- const README_FILENAME = "README.md";
1987
- const LICENSE_FILENAME = "LICENSE.md";
1988
2152
  async function buildRegistry(options) {
1989
2153
  const inputDir = options.input;
1990
2154
  const outputDir = options.out ?? null;
@@ -2060,69 +2224,49 @@ async function discoverPresetDirs(inputDir) {
2060
2224
  await searchDir(inputDir, 0);
2061
2225
  return presetDirs.sort();
2062
2226
  }
2063
- async function loadPreset(presetDir) {
2064
- const configPath = join(presetDir, PRESET_CONFIG_FILENAME);
2065
- const configRaw = await readFile(configPath, "utf8");
2066
- let configJson;
2067
- try {
2068
- configJson = JSON.parse(configRaw);
2069
- } catch {
2070
- throw new Error(`Invalid JSON in ${configPath}`);
2227
+
2228
+ //#endregion
2229
+ //#region src/commands/unpublish.ts
2230
+ /**
2231
+ * Parses preset input to extract slug, platform, and version.
2232
+ * Supports formats:
2233
+ * - "my-preset.claude@1.0" (platform and version in string)
2234
+ * - "my-preset@1.0" (requires explicit platform)
2235
+ * - "my-preset.claude" (requires explicit version)
2236
+ *
2237
+ * Explicit --platform and --version flags take precedence.
2238
+ */
2239
+ function parseUnpublishInput(input, explicitPlatform, explicitVersion) {
2240
+ let normalized = input.toLowerCase().trim();
2241
+ let parsedVersion;
2242
+ const atIndex = normalized.lastIndexOf("@");
2243
+ if (atIndex > 0) {
2244
+ parsedVersion = normalized.slice(atIndex + 1);
2245
+ normalized = normalized.slice(0, atIndex);
2246
+ }
2247
+ const version$1 = explicitVersion ?? parsedVersion;
2248
+ const parts = normalized.split(".");
2249
+ const maybePlatform = parts.at(-1);
2250
+ let slug;
2251
+ let platform;
2252
+ if (maybePlatform && isSupportedPlatform(maybePlatform)) {
2253
+ slug = parts.slice(0, -1).join(".");
2254
+ platform = explicitPlatform ?? maybePlatform;
2255
+ } else {
2256
+ slug = normalized;
2257
+ platform = explicitPlatform;
2071
2258
  }
2072
- const config = validatePresetConfig(configJson, basename(presetDir));
2073
- const slug = config.name;
2074
- const installPath = join(presetDir, INSTALL_FILENAME);
2075
- const installMessage = await readFileIfExists(installPath);
2076
- const readmePath = join(presetDir, README_FILENAME);
2077
- const readmeContent = await readFileIfExists(readmePath);
2078
- const licensePath = join(presetDir, LICENSE_FILENAME);
2079
- const licenseContent = await readFileIfExists(licensePath);
2080
- const filesPath = config.path ?? PLATFORMS[config.platform].projectDir;
2081
- const filesDir = join(presetDir, filesPath);
2082
- if (!await directoryExists(filesDir)) throw new Error(`Files directory not found: ${filesDir} (referenced in ${configPath})`);
2083
- const files = await collectFiles(filesDir);
2084
- if (files.length === 0) throw new Error(`No files found in ${filesDir}. Presets must include at least one file.`);
2085
2259
  return {
2086
2260
  slug,
2087
- config,
2088
- files,
2089
- installMessage,
2090
- readmeContent,
2091
- licenseContent
2261
+ platform,
2262
+ version: version$1
2092
2263
  };
2093
2264
  }
2094
- async function collectFiles(dir, baseDir) {
2095
- const root = baseDir ?? dir;
2096
- const entries = await readdir(dir, { withFileTypes: true });
2097
- const files = [];
2098
- for (const entry of entries) {
2099
- const fullPath = join(dir, entry.name);
2100
- if (entry.isDirectory()) {
2101
- const nested = await collectFiles(fullPath, root);
2102
- files.push(...nested);
2103
- } else if (entry.isFile()) {
2104
- const contents = await readFile(fullPath, "utf8");
2105
- const relativePath = relative(root, fullPath);
2106
- files.push({
2107
- path: relativePath,
2108
- contents
2109
- });
2110
- }
2111
- }
2112
- return files;
2113
- }
2114
- async function readFileIfExists(path$1) {
2115
- if (await fileExists(path$1)) return await readFile(path$1, "utf8");
2116
- return;
2117
- }
2118
-
2119
- //#endregion
2120
- //#region src/commands/unpublish.ts
2121
2265
  /**
2122
2266
  * Unpublishes a preset version from the registry
2123
2267
  */
2124
2268
  async function unpublish(options) {
2125
- const { slug, platform, version: version$1 } = options;
2269
+ const { slug, platform, version: version$1 } = parseUnpublishInput(options.preset, options.platform, options.version);
2126
2270
  if (!slug) {
2127
2271
  log.error("Preset slug is required");
2128
2272
  return {
@@ -2131,14 +2275,14 @@ async function unpublish(options) {
2131
2275
  };
2132
2276
  }
2133
2277
  if (!platform) {
2134
- log.error("Platform is required");
2278
+ log.error("Platform is required. Use --platform or specify as <slug>.<platform>@<version>");
2135
2279
  return {
2136
2280
  success: false,
2137
2281
  error: "Platform is required"
2138
2282
  };
2139
2283
  }
2140
2284
  if (!version$1) {
2141
- log.error("Version is required");
2285
+ log.error("Version is required. Use --version or specify as <slug>.<platform>@<version>");
2142
2286
  return {
2143
2287
  success: false,
2144
2288
  error: "Version is required"
@@ -2146,12 +2290,12 @@ async function unpublish(options) {
2146
2290
  }
2147
2291
  log.debug(`Unpublishing preset: ${slug}.${platform}@${version$1}`);
2148
2292
  const ctx = useAppContext();
2149
- if (!ctx) throw new Error("App context not initialized");
2150
2293
  if (!(ctx.isLoggedIn && ctx.credentials)) {
2151
- log.error(`Not logged in. Run ${ui.command("agentrules login")} to authenticate.`);
2294
+ const error$2 = "Not logged in. Run `agentrules login` to authenticate.";
2295
+ log.error(error$2);
2152
2296
  return {
2153
2297
  success: false,
2154
- error: `Not logged in. Run ${ui.command("agentrules login")} to authenticate.`
2298
+ error: error$2
2155
2299
  };
2156
2300
  }
2157
2301
  log.debug(`Authenticated, unpublishing from ${ctx.registry.url}`);
@@ -2194,10 +2338,10 @@ program.name("agentrules").description("The AI Agent Directory CLI").version(pac
2194
2338
  url: actionOpts.url
2195
2339
  });
2196
2340
  } catch (error$2) {
2197
- log.debug(`Failed to init context: ${error$2 instanceof Error ? error$2.message : String(error$2)}`);
2341
+ log.debug(`Failed to init context: ${getErrorMessage(error$2)}`);
2198
2342
  }
2199
2343
  }).showHelpAfterError();
2200
- program.command("add <preset>").description("Download and install a preset from the registry").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("-V, --version <version>", "Install a specific version").option("-r, --registry <alias>", "Use a specific registry alias").option("-g, --global", "Install to global directory").option("--dir <path>", "Install to a custom directory").option("-f, --force", "Overwrite existing files").option("-y, --yes", "Alias for --force").option("--dry-run", "Preview changes without writing").option("--skip-conflicts", "Skip conflicting files").action(handle(async (preset, options) => {
2344
+ program.command("add <preset>").description("Download and install a preset from the registry").option("-p, --platform <platform>", "Target platform (opencode, codex, claude, cursor)").option("-V, --version <version>", "Install a specific version").option("-r, --registry <alias>", "Use a specific registry alias").option("-g, --global", "Install to global directory").option("--dir <path>", "Install to a custom directory").option("-f, --force", "Overwrite existing files (backs up originals)").option("-y, --yes", "Alias for --force").option("--dry-run", "Preview changes without writing").option("--skip-conflicts", "Skip conflicting files").option("--no-backup", "Don't backup files before overwriting (use with --force)").action(handle(async (preset, options) => {
2201
2345
  const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
2202
2346
  const dryRun = Boolean(options.dryRun);
2203
2347
  const spinner$1 = await log.spinner("Fetching preset...");
@@ -2211,7 +2355,8 @@ program.command("add <preset>").description("Download and install a preset from
2211
2355
  directory: options.dir,
2212
2356
  force: Boolean(options.force || options.yes),
2213
2357
  dryRun,
2214
- skipConflicts: Boolean(options.skipConflicts)
2358
+ skipConflicts: Boolean(options.skipConflicts),
2359
+ noBackup: options.backup === false
2215
2360
  });
2216
2361
  } catch (err) {
2217
2362
  spinner$1.stop();
@@ -2221,16 +2366,23 @@ program.command("add <preset>").description("Download and install a preset from
2221
2366
  const hasBlockingConflicts = result.conflicts.length > 0 && !options.skipConflicts && !dryRun;
2222
2367
  if (hasBlockingConflicts) {
2223
2368
  const count$1 = result.conflicts.length === 1 ? "1 file has" : `${result.conflicts.length} files have`;
2224
- log.error(`${count$1} conflicts. Use ${ui.command("--force")} to overwrite.`);
2369
+ const forceHint = `Use ${ui.command("--force")} to overwrite ${ui.muted("(--no-backup to skip backups)")}`;
2370
+ log.error(`${count$1} conflicts. ${forceHint}`);
2225
2371
  log.print("");
2226
2372
  for (const conflict of result.conflicts.slice(0, 3)) {
2227
2373
  log.print(` ${ui.muted("•")} ${conflict.path}`);
2228
2374
  if (conflict.diff) log.print(conflict.diff.split("\n").map((l) => ` ${l}`).join("\n"));
2229
2375
  }
2230
2376
  if (result.conflicts.length > 3) log.print(`\n ${ui.muted(`...and ${result.conflicts.length - 3} more`)}`);
2377
+ log.print("");
2378
+ log.print(forceHint);
2231
2379
  process.exitCode = 1;
2232
2380
  return;
2233
2381
  }
2382
+ if (result.backups.length > 0) {
2383
+ log.print("");
2384
+ for (const backup of result.backups) log.print(ui.backupStatus(backup.originalPath, backup.backupPath, { dryRun }));
2385
+ }
2234
2386
  log.print("");
2235
2387
  for (const file of result.files) {
2236
2388
  const status = file.status === "overwritten" ? "updated" : file.status;
@@ -2250,7 +2402,7 @@ program.command("init").description("Initialize a new preset").argument("[direct
2250
2402
  const useInteractive = !options.yes && process.stdin.isTTY;
2251
2403
  if (useInteractive) {
2252
2404
  const result$1 = await initInteractive({
2253
- directory: targetDir,
2405
+ baseDir: targetDir,
2254
2406
  name: options.name ?? defaultName,
2255
2407
  title: options.title,
2256
2408
  description: options.description,
@@ -2262,23 +2414,32 @@ program.command("init").description("Initialize a new preset").argument("[direct
2262
2414
  log.print(`\n${ui.header("Directory created")}`);
2263
2415
  log.print(ui.list([ui.path(result$1.createdDir)]));
2264
2416
  }
2265
- const nextSteps$1 = [];
2266
- if (directory) nextSteps$1.push(`Run ${ui.command(`cd ${directory}`)}`);
2267
- nextSteps$1.push("Add your config files to the files directory", "Replace the placeholder comments in tags and features", `Run ${ui.command("agentrules validate")} to check your preset`);
2417
+ const nextSteps$1 = [
2418
+ "Add your config files to the platform directory",
2419
+ "Add tags (required) and features (recommended) to agentrules.json",
2420
+ `Run ${ui.command("agentrules publish")} to publish your preset`
2421
+ ];
2268
2422
  log.print(`\n${ui.header("Next steps")}`);
2269
2423
  log.print(ui.numberedList(nextSteps$1));
2270
2424
  return;
2271
2425
  }
2272
- const detected = await detectPlatforms(targetDir);
2273
- const platform = options.platform ?? detected[0]?.id ?? "opencode";
2274
- const detectedPath = detected.find((d) => d.id === platform)?.path;
2426
+ const resolved = await resolvePlatformDirectory(targetDir, options.platform);
2427
+ if (!options.platform) {
2428
+ const check$1 = requiresPlatformFlag(resolved);
2429
+ if (check$1.required) {
2430
+ if (check$1.reason === "no_platforms") {
2431
+ const targetDirName = basename(targetDir);
2432
+ log.error(`No platform directory found in "${targetDirName}". Specify --platform (${PLATFORM_IDS.join(", ")}) or run from a platform directory.`);
2433
+ } else log.error(`Multiple platform directories found (${check$1.platforms.join(", ")}). Specify --platform to choose one.`);
2434
+ process.exit(1);
2435
+ }
2436
+ }
2275
2437
  const result = await initPreset({
2276
- directory: targetDir,
2438
+ directory: resolved.platformDir,
2277
2439
  name: options.name ?? defaultName,
2278
2440
  title: options.title,
2279
2441
  description: options.description,
2280
- platform,
2281
- detectedPath,
2442
+ platform: resolved.platform,
2282
2443
  license: options.license,
2283
2444
  force: options.force
2284
2445
  });
@@ -2287,9 +2448,11 @@ program.command("init").description("Initialize a new preset").argument("[direct
2287
2448
  log.print(`\n${ui.header("Directory created")}`);
2288
2449
  log.print(ui.list([ui.path(result.createdDir)]));
2289
2450
  }
2290
- const nextSteps = [];
2291
- if (directory) nextSteps.push(`Run ${ui.command(`cd ${directory}`)}`);
2292
- nextSteps.push("Add your config files to the files directory", "Replace the placeholder comments in tags and features", `Run ${ui.command("agentrules validate")} to check your preset`);
2451
+ const nextSteps = [
2452
+ "Add your config files to the platform directory",
2453
+ "Add tags (required) and features (recommended) to agentrules.json",
2454
+ `Run ${ui.command("agentrules publish")} to publish your preset`
2455
+ ];
2293
2456
  log.print(`\n${ui.header("Next steps")}`);
2294
2457
  log.print(ui.numberedList(nextSteps));
2295
2458
  }));
@@ -2431,11 +2594,12 @@ program.command("publish").description("Publish a preset to the registry").argum
2431
2594
  });
2432
2595
  if (!result.success) process.exitCode = 1;
2433
2596
  }));
2434
- program.command("unpublish").description("Remove a preset version from the registry").argument("<slug>", "Preset slug (e.g., my-preset)").argument("<platform>", "Platform (e.g., opencode, claude)").argument("<version>", "Version to unpublish (e.g., 1.1, 2.3)").action(handle(async (slug, platform, version$1) => {
2597
+ 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) => {
2598
+ const platform = options.platform ? normalizePlatformInput(options.platform) : void 0;
2435
2599
  const result = await unpublish({
2436
- slug,
2600
+ preset,
2437
2601
  platform,
2438
- version: version$1
2602
+ version: options.version
2439
2603
  });
2440
2604
  if (!result.success) process.exitCode = 1;
2441
2605
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentrules/cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "author": "Brian Cheung <bcheung.dev@gmail.com> (https://github.com/bcheung)",
5
5
  "license": "MIT",
6
6
  "homepage": "https://agentrules.directory",
@@ -48,7 +48,7 @@
48
48
  "clean": "rm -rf node_modules dist .turbo"
49
49
  },
50
50
  "dependencies": {
51
- "@agentrules/core": "0.0.8",
51
+ "@agentrules/core": "0.0.9",
52
52
  "@clack/prompts": "^0.11.0",
53
53
  "chalk": "^5.4.1",
54
54
  "commander": "^12.1.0",