skiller 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
File without changes
@@ -78,6 +78,8 @@ const skillerConfigSchema = zod_1.z.object({
78
78
  skills: zod_1.z
79
79
  .object({
80
80
  enabled: zod_1.z.boolean().optional(),
81
+ generate_from_rules: zod_1.z.boolean().optional(),
82
+ prune: zod_1.z.boolean().optional(),
81
83
  })
82
84
  .optional(),
83
85
  rules: zod_1.z
@@ -234,6 +236,9 @@ async function loadConfig(options) {
234
236
  if (typeof rawSkillsSection.generate_from_rules === 'boolean') {
235
237
  skillsConfig.generate_from_rules = rawSkillsSection.generate_from_rules;
236
238
  }
239
+ if (typeof rawSkillsSection.prune === 'boolean') {
240
+ skillsConfig.prune = rawSkillsSection.prune;
241
+ }
237
242
  const rawRulesSection = raw.rules && typeof raw.rules === 'object' && !Array.isArray(raw.rules)
238
243
  ? raw.rules
239
244
  : {};
@@ -41,6 +41,7 @@ exports.propagateSkillsForSkillz = propagateSkillsForSkillz;
41
41
  exports.buildSkillzMcpConfig = buildSkillzMcpConfig;
42
42
  const path = __importStar(require("path"));
43
43
  const fs = __importStar(require("fs/promises"));
44
+ const readline = __importStar(require("readline"));
44
45
  const constants_1 = require("../constants");
45
46
  const SkillsUtils_1 = require("./SkillsUtils");
46
47
  const FrontmatterParser_1 = require("./FrontmatterParser");
@@ -153,11 +154,106 @@ async function findMdcFiles(dir) {
153
154
  }
154
155
  return mdcFiles;
155
156
  }
157
+ /**
158
+ * Prompts the user via readline to confirm deletion of orphaned skills.
159
+ * Returns true if user confirms, false otherwise.
160
+ */
161
+ async function promptForPrune(orphanedSkills) {
162
+ // If not running in a TTY (e.g., CI/CD), skip prompting
163
+ if (!process.stdin.isTTY) {
164
+ (0, constants_1.logWarn)(`Found ${orphanedSkills.length} orphaned skill(s) but not in interactive mode. Set prune = true/false in skiller.toml to handle automatically.`, false);
165
+ return false;
166
+ }
167
+ const rl = readline.createInterface({
168
+ input: process.stdin,
169
+ output: process.stdout,
170
+ });
171
+ return new Promise((resolve) => {
172
+ console.log(`\nFound ${orphanedSkills.length} orphaned skill(s) not generated from rules:`);
173
+ orphanedSkills.forEach((skill) => console.log(` - ${skill}`));
174
+ console.log('');
175
+ rl.question('Delete these orphaned skills? [y/N]: ', (answer) => {
176
+ rl.close();
177
+ const confirmed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
178
+ if (confirmed) {
179
+ console.log('');
180
+ }
181
+ else {
182
+ console.log("\nTip: Add 'prune = true' or 'prune = false' to [skills] in skiller.toml to avoid this prompt.\n");
183
+ }
184
+ resolve(confirmed);
185
+ });
186
+ });
187
+ }
188
+ /**
189
+ * Prunes orphaned skills (skills that exist in .claude/skills but are not generated from any .mdc file).
190
+ * @param skillsDir Path to the skills directory
191
+ * @param generatedSkillNames Set of skill names that were generated from .mdc files
192
+ * @param prune Prune setting: true=auto-delete, false=keep, undefined=prompt
193
+ * @param verbose Whether to log verbose output
194
+ * @param dryRun Whether to perform a dry run
195
+ */
196
+ async function pruneOrphanedSkills(skillsDir, generatedSkillNames, prune, verbose, dryRun) {
197
+ // Check if skills directory exists
198
+ try {
199
+ await fs.access(skillsDir);
200
+ }
201
+ catch {
202
+ // No skills directory, nothing to prune
203
+ return;
204
+ }
205
+ // Get all existing skill directories
206
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
207
+ const existingSkillDirs = entries
208
+ .filter((entry) => entry.isDirectory())
209
+ .map((entry) => entry.name);
210
+ // Find orphaned skills (exist but not in generatedSkillNames)
211
+ const orphanedSkills = existingSkillDirs.filter((name) => !generatedSkillNames.has(name));
212
+ if (orphanedSkills.length === 0) {
213
+ (0, constants_1.logVerboseInfo)('No orphaned skills found', verbose, dryRun);
214
+ return;
215
+ }
216
+ // Determine whether to delete based on prune setting
217
+ let shouldDelete = false;
218
+ if (prune === true) {
219
+ // Auto-delete
220
+ shouldDelete = true;
221
+ (0, constants_1.logVerboseInfo)(`Auto-pruning ${orphanedSkills.length} orphaned skill(s) (prune = true)`, verbose, dryRun);
222
+ }
223
+ else if (prune === false) {
224
+ // Keep orphans
225
+ (0, constants_1.logVerboseInfo)(`Keeping ${orphanedSkills.length} orphaned skill(s) (prune = false)`, verbose, dryRun);
226
+ return;
227
+ }
228
+ else {
229
+ // prune is undefined - prompt user
230
+ shouldDelete = await promptForPrune(orphanedSkills);
231
+ if (!shouldDelete) {
232
+ return;
233
+ }
234
+ }
235
+ // Delete orphaned skills
236
+ for (const skillName of orphanedSkills) {
237
+ const skillPath = path.join(skillsDir, skillName);
238
+ if (dryRun) {
239
+ (0, constants_1.logVerboseInfo)(`DRY RUN: Would remove orphaned skill ${skillName}`, verbose, dryRun);
240
+ }
241
+ else {
242
+ await fs.rm(skillPath, { recursive: true, force: true });
243
+ (0, constants_1.logVerboseInfo)(`Removed orphaned skill ${skillName}`, verbose, dryRun);
244
+ }
245
+ }
246
+ }
156
247
  /**
157
248
  * Generates skills from .mdc rule files with frontmatter.
158
249
  * Creates skill files in the skills directory with @filename references to the original .mdc files.
250
+ * @param projectRoot Root directory of the project
251
+ * @param skillerDir Path to the skiller directory (.claude)
252
+ * @param verbose Whether to log verbose output
253
+ * @param dryRun Whether to perform a dry run
254
+ * @param prune Prune setting: true=auto-delete orphans, false=keep orphans, undefined=prompt
159
255
  */
160
- async function generateSkillsFromRules(projectRoot, skillerDir, verbose, dryRun) {
256
+ async function generateSkillsFromRules(projectRoot, skillerDir, verbose, dryRun, prune) {
161
257
  // Determine skills directory based on skillerDir
162
258
  const skillsDir = path.join(skillerDir, 'skills');
163
259
  // Find all .mdc files in the skiller directory
@@ -168,6 +264,7 @@ async function generateSkillsFromRules(projectRoot, skillerDir, verbose, dryRun)
168
264
  }
169
265
  let generatedCount = 0;
170
266
  const skillsToRemove = [];
267
+ const generatedSkillNames = new Set();
171
268
  for (const mdcFile of mdcFiles) {
172
269
  // Read file content
173
270
  const content = await fs.readFile(mdcFile, 'utf8');
@@ -249,6 +346,7 @@ ${fileReference}
249
346
  }
250
347
  }
251
348
  generatedCount++;
349
+ generatedSkillNames.add(fileName);
252
350
  }
253
351
  if (generatedCount > 0) {
254
352
  (0, constants_1.logVerboseInfo)(`Generated ${generatedCount} skill(s) from .mdc files`, verbose, dryRun);
@@ -277,6 +375,8 @@ ${fileReference}
277
375
  }
278
376
  }
279
377
  }
378
+ // Prune orphaned skills if prune setting is configured
379
+ await pruneOrphanedSkills(skillsDir, generatedSkillNames, prune, verbose, dryRun);
280
380
  }
281
381
  /**
282
382
  * Cleans up skills directories (.claude/skills and .skillz) when skills are disabled.
@@ -41,6 +41,19 @@ const FileSystemUtils = __importStar(require("./FileSystemUtils"));
41
41
  const FileSystemUtils_1 = require("./FileSystemUtils");
42
42
  const hash_1 = require("./hash");
43
43
  const RuleProcessor_1 = require("./RuleProcessor");
44
+ /**
45
+ * Expand environment variables in a string.
46
+ * Supports ${VAR} syntax, replacing with process.env[VAR] or empty string if not found.
47
+ */
48
+ function expandEnvVars(value) {
49
+ return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
50
+ }
51
+ /**
52
+ * Expand environment variables in all values of a Record<string, string>.
53
+ */
54
+ function expandEnvRecord(record) {
55
+ return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, expandEnvVars(v)]));
56
+ }
44
57
  async function loadUnifiedConfig(options) {
45
58
  // Resolve the effective .claude directory (local or global), mirroring the main loader behavior
46
59
  const resolvedSkillerDir = (await FileSystemUtils.findSkillerDir(options.projectRoot, true)) ||
@@ -99,6 +112,9 @@ async function loadUnifiedConfig(options) {
99
112
  if (typeof skillsObj.generate_from_rules === 'boolean') {
100
113
  skillsConfig.generate_from_rules = skillsObj.generate_from_rules;
101
114
  }
115
+ if (typeof skillsObj.prune === 'boolean') {
116
+ skillsConfig.prune = skillsObj.prune;
117
+ }
102
118
  }
103
119
  }
104
120
  // Parse rules configuration
@@ -227,9 +243,10 @@ async function loadUnifiedConfig(options) {
227
243
  if (Array.isArray(serverDef.args)) {
228
244
  server.args = serverDef.args.map(String);
229
245
  }
230
- // Parse env
246
+ // Parse env with ${VAR} expansion
231
247
  if (serverDef.env && typeof serverDef.env === 'object') {
232
- server.env = Object.fromEntries(Object.entries(serverDef.env).filter(([, v]) => typeof v === 'string'));
248
+ const rawEnv = Object.fromEntries(Object.entries(serverDef.env).filter(([, v]) => typeof v === 'string'));
249
+ server.env = expandEnvRecord(rawEnv);
233
250
  }
234
251
  // Parse URL and headers
235
252
  if (typeof serverDef.url === 'string') {
@@ -350,7 +367,8 @@ async function loadUnifiedConfig(options) {
350
367
  if (Array.isArray(def.args))
351
368
  server.args = def.args.map(String);
352
369
  if (def.env && typeof def.env === 'object') {
353
- server.env = Object.fromEntries(Object.entries(def.env).filter(([, v]) => typeof v === 'string'));
370
+ const rawEnv = Object.fromEntries(Object.entries(def.env).filter(([, v]) => typeof v === 'string'));
371
+ server.env = expandEnvRecord(rawEnv);
354
372
  }
355
373
  if (typeof def.url === 'string')
356
374
  server.url = def.url;
package/dist/lib.js CHANGED
@@ -107,10 +107,11 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
107
107
  if (skillsEnabledResolved) {
108
108
  const generateFromRules = rootConfig.skills?.generate_from_rules ?? false;
109
109
  if (generateFromRules) {
110
+ const pruneConfig = rootConfig.skills?.prune;
110
111
  for (const configEntry of hierarchicalConfigs) {
111
112
  const nestedRoot = path.dirname(configEntry.skillerDir);
112
113
  (0, constants_1.logVerbose)(`Generating skills from .mdc files for nested directory: ${nestedRoot}`, verbose);
113
- await generateSkillsFromRules(nestedRoot, configEntry.skillerDir, verbose, dryRun);
114
+ await generateSkillsFromRules(nestedRoot, configEntry.skillerDir, verbose, dryRun, pruneConfig);
114
115
  }
115
116
  }
116
117
  }
@@ -141,7 +142,8 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
141
142
  const generateFromRules = singleConfig.config.skills?.generate_from_rules ?? false;
142
143
  if (generateFromRules) {
143
144
  (0, constants_1.logVerbose)('Generating skills from .mdc files', verbose);
144
- await generateSkillsFromRules(projectRoot, singleConfig.skillerDir, verbose, dryRun);
145
+ const pruneConfig = singleConfig.config.skills?.prune;
146
+ await generateSkillsFromRules(projectRoot, singleConfig.skillerDir, verbose, dryRun, pruneConfig);
145
147
  }
146
148
  }
147
149
  // Always call propagateSkills - it handles cleanup when disabled
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "skiller",
3
- "version": "0.5.6",
3
+ "version": "0.6.1",
4
4
  "description": "Skiller — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "scripts": {
10
+ "postinstall": "[ -n \"$CI\" ] || npx skiller@latest apply",
10
11
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
11
12
  "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
12
13
  "format": "prettier --write \"src/**/*.{ts,tsx,json,md}\"",
@@ -15,7 +16,7 @@
15
16
  "test:coverage": "jest --coverage",
16
17
  "test:integration": "jest tests/e2e/skiller.integration.test.ts --verbose",
17
18
  "build": "tsc",
18
- "release": "pnpm build && pnpm changeset version"
19
+ "release": "pnpm build && pnpm changeset publish"
19
20
  },
20
21
  "repository": {
21
22
  "type": "git",
@@ -50,7 +51,8 @@
50
51
  "skiller": "dist/cli/index.js"
51
52
  },
52
53
  "devDependencies": {
53
- "@changesets/cli": "2.27.11",
54
+ "@changesets/changelog-github": "^0.5.2",
55
+ "@changesets/cli": "2.29.8",
54
56
  "@eslint/js": "^9.39.1",
55
57
  "@types/iarna__toml": "^2.0.5",
56
58
  "@types/jest": "^29.5.14",