rulekeeper 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.
package/dist/index.js ADDED
@@ -0,0 +1,1879 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { join as join5 } from "path";
8
+
9
+ // src/lib/config.ts
10
+ import { parse, stringify } from "yaml";
11
+
12
+ // src/lib/paths.ts
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+ import envPaths from "env-paths";
16
+ var paths = envPaths("rulekeeper", { suffix: "" });
17
+ function getConfigDir() {
18
+ return paths.config;
19
+ }
20
+ function getConfigPath() {
21
+ return join(getConfigDir(), "config.yaml");
22
+ }
23
+ function getClaudeDir(projectPath = process.cwd()) {
24
+ return join(projectPath, ".claude");
25
+ }
26
+ function getRulekeeperDir(projectPath = process.cwd()) {
27
+ return join(projectPath, ".rulekeeper");
28
+ }
29
+ function getManifestPath(projectPath = process.cwd()) {
30
+ return join(getRulekeeperDir(projectPath), "manifest.yaml");
31
+ }
32
+ function getHomeDir() {
33
+ return homedir();
34
+ }
35
+ function expandTilde(path) {
36
+ if (path.startsWith("~")) {
37
+ return join(getHomeDir(), path.slice(1));
38
+ }
39
+ return path;
40
+ }
41
+
42
+ // src/lib/files.ts
43
+ import { existsSync, mkdirSync } from "fs";
44
+ import { readFile, writeFile, copyFile as fsCopyFile, readdir, unlink, rm } from "fs/promises";
45
+ import { dirname, basename } from "path";
46
+ function ensureDir(dirPath) {
47
+ if (!existsSync(dirPath)) {
48
+ mkdirSync(dirPath, { recursive: true });
49
+ }
50
+ }
51
+ function fileExists(filePath) {
52
+ return existsSync(filePath);
53
+ }
54
+ async function readTextFile(filePath) {
55
+ return await readFile(filePath, "utf-8");
56
+ }
57
+ async function writeTextFile(filePath, content) {
58
+ ensureDir(dirname(filePath));
59
+ await writeFile(filePath, content, "utf-8");
60
+ }
61
+ async function copyFile(source, dest) {
62
+ ensureDir(dirname(dest));
63
+ await fsCopyFile(source, dest);
64
+ }
65
+ async function deleteFile(filePath) {
66
+ if (existsSync(filePath)) {
67
+ await unlink(filePath);
68
+ }
69
+ }
70
+ async function listFiles(dirPath, extension) {
71
+ if (!existsSync(dirPath)) {
72
+ return [];
73
+ }
74
+ const entries = await readdir(dirPath, { withFileTypes: true });
75
+ let files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
76
+ if (extension) {
77
+ files = files.filter((file) => file.endsWith(extension));
78
+ }
79
+ return files;
80
+ }
81
+ function getRuleName(filename) {
82
+ return basename(filename, ".md");
83
+ }
84
+ function getRuleFilename(ruleName) {
85
+ const baseName = ruleName.toLowerCase().endsWith(".md") ? ruleName.slice(0, -3) : ruleName;
86
+ return `${baseName}.md`;
87
+ }
88
+ function normalizeRuleName(ruleName) {
89
+ const name = ruleName.toLowerCase().endsWith(".md") ? ruleName.slice(0, -3) : ruleName;
90
+ return name.toLowerCase();
91
+ }
92
+ function findRuleMatch(input, availableRules) {
93
+ const normalizedInput = normalizeRuleName(input);
94
+ const match = availableRules.find(
95
+ (rule) => normalizeRuleName(rule) === normalizedInput
96
+ );
97
+ return match ?? null;
98
+ }
99
+
100
+ // src/types/config.ts
101
+ var DEFAULT_CONFIG = {
102
+ version: 1,
103
+ source: {
104
+ type: "local",
105
+ path: ""
106
+ },
107
+ settings: {
108
+ autoPull: true,
109
+ pullFrequency: "daily"
110
+ }
111
+ };
112
+
113
+ // src/types/manifest.ts
114
+ var DEFAULT_MANIFEST = {
115
+ version: 1,
116
+ rules: {}
117
+ };
118
+
119
+ // src/lib/config.ts
120
+ async function loadConfig() {
121
+ const configPath = getConfigPath();
122
+ if (!fileExists(configPath)) {
123
+ return null;
124
+ }
125
+ const content = await readTextFile(configPath);
126
+ const config = parse(content);
127
+ return config;
128
+ }
129
+ async function saveConfig(config) {
130
+ const configPath = getConfigPath();
131
+ ensureDir(getConfigDir());
132
+ const content = stringify(config, { indent: 2 });
133
+ await writeTextFile(configPath, content);
134
+ }
135
+ async function configExists() {
136
+ return fileExists(getConfigPath());
137
+ }
138
+ function createConfig(options) {
139
+ const config = {
140
+ ...DEFAULT_CONFIG,
141
+ source: {
142
+ type: options.sourceType,
143
+ path: options.sourcePath,
144
+ remote: options.remote
145
+ },
146
+ settings: {
147
+ autoPull: options.autoPull ?? true,
148
+ pullFrequency: options.pullFrequency ?? "daily"
149
+ }
150
+ };
151
+ if (options.shell) {
152
+ config.platform = { shell: options.shell };
153
+ }
154
+ return config;
155
+ }
156
+ async function updateConfigLastPull() {
157
+ const config = await loadConfig();
158
+ if (config) {
159
+ config.settings.lastPull = (/* @__PURE__ */ new Date()).toISOString();
160
+ await saveConfig(config);
161
+ }
162
+ }
163
+
164
+ // src/lib/manifest.ts
165
+ import { parse as parse2, stringify as stringify2 } from "yaml";
166
+ async function loadManifest(projectPath = process.cwd()) {
167
+ const manifestPath = getManifestPath(projectPath);
168
+ if (!fileExists(manifestPath)) {
169
+ return null;
170
+ }
171
+ const content = await readTextFile(manifestPath);
172
+ const manifest = parse2(content);
173
+ return manifest;
174
+ }
175
+ async function saveManifest(manifest, projectPath = process.cwd()) {
176
+ const manifestPath = getManifestPath(projectPath);
177
+ ensureDir(getRulekeeperDir(projectPath));
178
+ const content = stringify2(manifest, { indent: 2 });
179
+ await writeTextFile(manifestPath, content);
180
+ }
181
+ async function getOrCreateManifest(projectPath = process.cwd()) {
182
+ const manifest = await loadManifest(projectPath);
183
+ return manifest ?? { ...DEFAULT_MANIFEST };
184
+ }
185
+ function createRuleEntry(options) {
186
+ const now = (/* @__PURE__ */ new Date()).toISOString();
187
+ return {
188
+ file: options.file,
189
+ sourceHash: options.sourceHash,
190
+ localHash: options.localHash,
191
+ status: options.status ?? "synced",
192
+ installedAt: now,
193
+ updatedAt: now
194
+ };
195
+ }
196
+ function updateRuleEntry(entry, updates) {
197
+ return {
198
+ ...entry,
199
+ ...updates,
200
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
201
+ };
202
+ }
203
+ async function updateRuleInManifest(ruleName, updates, projectPath = process.cwd()) {
204
+ const manifest = await loadManifest(projectPath);
205
+ if (manifest && manifest.rules[ruleName]) {
206
+ manifest.rules[ruleName] = updateRuleEntry(manifest.rules[ruleName], updates);
207
+ await saveManifest(manifest, projectPath);
208
+ }
209
+ }
210
+
211
+ // src/lib/source.ts
212
+ import { join as join3 } from "path";
213
+
214
+ // src/lib/hash.ts
215
+ import { createHash } from "crypto";
216
+ import { readFile as readFile2 } from "fs/promises";
217
+ async function hashFile(filePath) {
218
+ const content = await readFile2(filePath);
219
+ const hash = createHash("sha256").update(content).digest("hex");
220
+ return `sha256:${hash}`;
221
+ }
222
+
223
+ // src/lib/source.ts
224
+ async function getAvailableRules(config) {
225
+ const sourcePath = config.source.path;
226
+ if (!fileExists(sourcePath)) {
227
+ return [];
228
+ }
229
+ const files = await listFiles(sourcePath, ".md");
230
+ return files.map((file) => ({
231
+ name: getRuleName(file),
232
+ file,
233
+ path: join3(sourcePath, file)
234
+ }));
235
+ }
236
+ async function getRuleSourcePath(ruleName, config) {
237
+ const filename = getRuleFilename(ruleName);
238
+ const rulePath = join3(config.source.path, filename);
239
+ if (!fileExists(rulePath)) {
240
+ return null;
241
+ }
242
+ return rulePath;
243
+ }
244
+ function isGitUrl(url) {
245
+ return url.startsWith("git@") || url.startsWith("https://github.com") || url.startsWith("https://gitlab.com") || url.startsWith("https://bitbucket.org") || url.endsWith(".git");
246
+ }
247
+
248
+ // src/lib/git.ts
249
+ import simpleGit from "simple-git";
250
+ import { join as join4 } from "path";
251
+ function isGitRepo(path) {
252
+ return fileExists(join4(path, ".git"));
253
+ }
254
+ async function cloneRepo(url, targetPath) {
255
+ const git = simpleGit();
256
+ await git.clone(url, targetPath);
257
+ }
258
+ async function pullRepo(path) {
259
+ const git = simpleGit(path);
260
+ await git.pull();
261
+ }
262
+ async function hasRemote(path) {
263
+ if (!isGitRepo(path)) return false;
264
+ const git = simpleGit(path);
265
+ try {
266
+ const remotes = await git.getRemotes();
267
+ return remotes.length > 0;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ async function getRemoteUrl(path) {
273
+ var _a;
274
+ if (!isGitRepo(path)) return null;
275
+ const git = simpleGit(path);
276
+ try {
277
+ const remotes = await git.getRemotes(true);
278
+ const origin = remotes.find((r) => r.name === "origin");
279
+ return ((_a = origin == null ? void 0 : origin.refs) == null ? void 0 : _a.fetch) ?? null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+ function shouldPullFromRemote(config) {
285
+ if (!config.settings.autoPull) return false;
286
+ if (config.settings.pullFrequency === "never") return false;
287
+ if (!config.settings.lastPull) return true;
288
+ const lastPull = new Date(config.settings.lastPull);
289
+ const now = /* @__PURE__ */ new Date();
290
+ const hoursSinceLastPull = (now.getTime() - lastPull.getTime()) / (1e3 * 60 * 60);
291
+ switch (config.settings.pullFrequency) {
292
+ case "always":
293
+ return true;
294
+ case "daily":
295
+ return hoursSinceLastPull >= 24;
296
+ case "weekly":
297
+ return hoursSinceLastPull >= 168;
298
+ default:
299
+ return false;
300
+ }
301
+ }
302
+ async function pullSourceIfNeeded(config) {
303
+ const sourcePath = config.source.path;
304
+ if (!isGitRepo(sourcePath)) {
305
+ return { pulled: false };
306
+ }
307
+ if (!shouldPullFromRemote(config)) {
308
+ return { pulled: false };
309
+ }
310
+ if (!await hasRemote(sourcePath)) {
311
+ return { pulled: false };
312
+ }
313
+ try {
314
+ await pullRepo(sourcePath);
315
+ return { pulled: true };
316
+ } catch (error) {
317
+ const message = error instanceof Error ? error.message : String(error);
318
+ return { pulled: false, error: message };
319
+ }
320
+ }
321
+
322
+ // src/lib/platform.ts
323
+ function isWindows() {
324
+ return process.platform === "win32";
325
+ }
326
+ function isWSL() {
327
+ if (process.platform !== "linux") return false;
328
+ return !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
329
+ }
330
+ function detectWindowsShell() {
331
+ if (isWSL()) {
332
+ return "wsl";
333
+ }
334
+ if (!isWindows()) return null;
335
+ if (process.env.MSYSTEM) {
336
+ return "gitbash";
337
+ }
338
+ return "standard";
339
+ }
340
+
341
+ // src/ui/format.ts
342
+ import pc from "picocolors";
343
+ function formatRuleStatus(status2) {
344
+ switch (status2) {
345
+ case "synced":
346
+ return pc.green("\u2713");
347
+ case "outdated":
348
+ return pc.yellow("\u2193");
349
+ case "diverged":
350
+ return pc.red("\u26A0");
351
+ case "detached":
352
+ return pc.dim("\u25CB");
353
+ default:
354
+ return pc.dim("?");
355
+ }
356
+ }
357
+ function formatRuleStatusText(status2) {
358
+ switch (status2) {
359
+ case "synced":
360
+ return pc.green("synced");
361
+ case "outdated":
362
+ return pc.yellow("outdated");
363
+ case "diverged":
364
+ return pc.red("diverged");
365
+ case "detached":
366
+ return pc.dim("detached");
367
+ default:
368
+ return pc.dim("unknown");
369
+ }
370
+ }
371
+ function formatRuleLine(name, status2, details) {
372
+ const icon = formatRuleStatus(status2);
373
+ const statusText = formatRuleStatusText(status2);
374
+ const detailsPart = details ? pc.dim(` (${details})`) : "";
375
+ return ` ${icon} ${name.padEnd(20)} ${statusText}${detailsPart}`;
376
+ }
377
+ function formatPath(path) {
378
+ return pc.cyan(path);
379
+ }
380
+ function formatCommand(command) {
381
+ return pc.cyan(`\`${command}\``);
382
+ }
383
+ function formatHeader(text2) {
384
+ return pc.bold(text2);
385
+ }
386
+ function formatDiffAdd(line) {
387
+ return pc.green(`+ ${line}`);
388
+ }
389
+ function formatDiffRemove(line) {
390
+ return pc.red(`- ${line}`);
391
+ }
392
+ function formatDiffContext(line) {
393
+ return pc.dim(` ${line}`);
394
+ }
395
+ function formatDiffHeader(line) {
396
+ return pc.cyan(line);
397
+ }
398
+
399
+ // src/ui/messages.ts
400
+ import pc2 from "picocolors";
401
+ var messages = {
402
+ // General
403
+ notConfigured: `RuleKeeper is not configured. Run ${formatCommand("rk init")} first.`,
404
+ noManifest: `No RuleKeeper manifest found. Run ${formatCommand("rk add")} first.`,
405
+ noClaudeDir: `No .claude/ directory found. Are you in a project root?`,
406
+ // Init
407
+ initWelcome: "Welcome to RuleKeeper!",
408
+ initSuccess: "RuleKeeper is now configured.",
409
+ initAlreadyExists: "RuleKeeper is already configured. Use --force to reconfigure.",
410
+ // Source
411
+ sourceNotFound: (path) => `Source path does not exist: ${formatPath(path)}`,
412
+ sourceInvalid: "Invalid source path or URL.",
413
+ sourceUpdated: (path) => `Source updated to: ${formatPath(path)}`,
414
+ // Add
415
+ addSuccess: (count) => `Added ${count} rule${count !== 1 ? "s" : ""}.`,
416
+ addConflict: (files) => `The following files already exist in .claude/:
417
+ ${files.map((f) => ` - ${f}`).join("\n")}`,
418
+ addNoRules: "No rules specified. Use --all to add all available rules.",
419
+ ruleNotFound: (name) => `Rule '${name}' not found in source.`,
420
+ // Remove
421
+ removeSuccess: (count) => `Removed ${count} rule${count !== 1 ? "s" : ""}.`,
422
+ removeNotInstalled: (name) => `Rule '${name}' is not installed.`,
423
+ // Status
424
+ statusAllSynced: "All rules are synced.",
425
+ statusNeedsAttention: (count) => `${count} rule${count !== 1 ? "s" : ""} need${count === 1 ? "s" : ""} attention. Run ${formatCommand("rk pull")} to update.`,
426
+ statusOffline: pc2.dim("\u26A0 Could not check for updates (offline?)"),
427
+ // Pull
428
+ pullSuccess: (count) => `${count} rule${count !== 1 ? "s" : ""} updated.`,
429
+ pullSkipped: (count) => `${count} rule${count !== 1 ? "s" : ""} skipped.`,
430
+ pullDetached: (count) => `${count} rule${count !== 1 ? "s" : ""} detached.`,
431
+ pullUpToDate: "All rules are up to date.",
432
+ pullDiverged: (name) => `${name}.md has local changes`,
433
+ pullDivergedAndOutdated: (name) => `${name}.md has local changes AND source has been updated`,
434
+ // Detach/Attach
435
+ detachSuccess: (name) => `Rule '${name}' is now detached.`,
436
+ detachAlready: (name) => `Rule '${name}' is already detached.`,
437
+ attachSuccess: (name) => `Rule '${name}' is now attached.`,
438
+ attachAlready: (name) => `Rule '${name}' is already attached.`,
439
+ // Diff
440
+ diffNoDifference: (name) => `No difference found for '${name}'.`,
441
+ diffNoRules: "No diverged rules to show.",
442
+ // Doctor
443
+ doctorAllGood: "All checks passed.",
444
+ doctorIssuesFound: (count) => `${count} issue${count !== 1 ? "s" : ""} found.`,
445
+ // Errors
446
+ errorGeneric: (err) => `Error: ${err}`,
447
+ errorMissingSource: (name) => `Source file for '${name}' no longer exists.`,
448
+ errorMissingLocal: (name) => `Local file for '${name}' is missing.`
449
+ };
450
+
451
+ // src/ui/prompts.ts
452
+ import * as p from "@clack/prompts";
453
+ import pc3 from "picocolors";
454
+ function isCancel2(value) {
455
+ return p.isCancel(value);
456
+ }
457
+ async function selectSourceType() {
458
+ return await p.select({
459
+ message: "Where are your Claude rules stored?",
460
+ options: [
461
+ { value: "local", label: "Local folder", hint: "Already cloned git repo or static folder" },
462
+ { value: "git", label: "Git repository", hint: "Clone now from URL" }
463
+ ]
464
+ });
465
+ }
466
+ async function inputSourcePath() {
467
+ return await p.text({
468
+ message: "Enter the path to your rules folder:",
469
+ placeholder: "~/Documents/claude-rules",
470
+ validate: (value) => {
471
+ if (!value) return "Path is required";
472
+ return void 0;
473
+ }
474
+ });
475
+ }
476
+ async function inputGitUrl() {
477
+ return await p.text({
478
+ message: "Enter the Git repository URL:",
479
+ placeholder: "git@github.com:username/claude-rules.git",
480
+ validate: (value) => {
481
+ if (!value) return "URL is required";
482
+ return void 0;
483
+ }
484
+ });
485
+ }
486
+ async function inputClonePath() {
487
+ return await p.text({
488
+ message: "Where should the repository be cloned?",
489
+ placeholder: "~/Documents/claude-rules",
490
+ validate: (value) => {
491
+ if (!value) return "Path is required";
492
+ return void 0;
493
+ }
494
+ });
495
+ }
496
+ async function selectPullFrequency() {
497
+ return await p.select({
498
+ message: "How often should RuleKeeper check for updates?",
499
+ options: [
500
+ { value: "daily", label: "Daily", hint: "Recommended" },
501
+ { value: "always", label: "Always", hint: "Every command" },
502
+ { value: "weekly", label: "Weekly" },
503
+ { value: "never", label: "Never", hint: "Manual only" }
504
+ ]
505
+ });
506
+ }
507
+ async function selectWindowsShell() {
508
+ return await p.select({
509
+ message: "Which shell environment are you using?",
510
+ options: [
511
+ { value: "standard", label: "Standard (CMD/PowerShell)" },
512
+ { value: "gitbash", label: "Git Bash" },
513
+ { value: "wsl", label: "WSL (Windows Subsystem for Linux)" }
514
+ ]
515
+ });
516
+ }
517
+ async function selectRulesToAdd(available, installed) {
518
+ const options = available.map((rule) => ({
519
+ value: rule,
520
+ label: rule,
521
+ hint: installed.includes(rule) ? "already installed" : void 0
522
+ }));
523
+ return await p.multiselect({
524
+ message: "Select rules to add:",
525
+ options,
526
+ required: true
527
+ });
528
+ }
529
+ async function handleDivergedRule(ruleName, sourceAlsoChanged, isSingleRule) {
530
+ const message = sourceAlsoChanged ? `${ruleName}.md has local changes AND source has been updated` : `${ruleName}.md has local changes`;
531
+ p.log.warn(message);
532
+ const options = [
533
+ {
534
+ value: "overwrite",
535
+ label: "Overwrite",
536
+ hint: "Replace with source version (local changes will be lost)"
537
+ },
538
+ {
539
+ value: "detach",
540
+ label: "Detach",
541
+ hint: "Keep local version, stop tracking this rule"
542
+ }
543
+ ];
544
+ if (sourceAlsoChanged) {
545
+ options.push({
546
+ value: "view-diff",
547
+ label: "View diff",
548
+ hint: "See differences before deciding"
549
+ });
550
+ }
551
+ options.push(
552
+ isSingleRule ? { value: "cancel", label: "Cancel", hint: "Abort this operation" } : { value: "skip", label: "Skip", hint: "Do nothing for now" }
553
+ );
554
+ const action = await p.select({
555
+ message: "What would you like to do?",
556
+ options
557
+ });
558
+ if (p.isCancel(action)) {
559
+ return isSingleRule ? "cancel" : "skip";
560
+ }
561
+ return action;
562
+ }
563
+ async function handleMissingSource(ruleName) {
564
+ p.log.warn(`${ruleName}.md - source file no longer exists`);
565
+ return await p.select({
566
+ message: "What would you like to do?",
567
+ options: [
568
+ { value: "keep", label: "Keep local", hint: "Detach and keep the local file" },
569
+ { value: "remove", label: "Remove", hint: "Delete from project and manifest" }
570
+ ]
571
+ });
572
+ }
573
+ async function handleMissingLocal(ruleName) {
574
+ p.log.warn(`${ruleName}.md - local file missing`);
575
+ return await p.select({
576
+ message: "What would you like to do?",
577
+ options: [
578
+ { value: "restore", label: "Restore", hint: "Copy from source" },
579
+ { value: "remove", label: "Remove", hint: "Remove from manifest" }
580
+ ]
581
+ });
582
+ }
583
+ async function handleAttachDiffers(ruleName) {
584
+ p.log.warn(`${ruleName}.md differs from source version`);
585
+ return await p.select({
586
+ message: "What would you like to do?",
587
+ options: [
588
+ { value: "overwrite", label: "Overwrite local", hint: "Replace with source version" },
589
+ { value: "keep", label: "Keep local", hint: "Attach but keep local version (will show as diverged)" },
590
+ { value: "cancel", label: "Cancel", hint: "Keep detached" }
591
+ ]
592
+ });
593
+ }
594
+ function spinner2() {
595
+ return p.spinner();
596
+ }
597
+ function intro2(message) {
598
+ p.intro(pc3.bgCyan(pc3.black(` ${message} `)));
599
+ }
600
+ function outro2(message) {
601
+ p.outro(message);
602
+ }
603
+ var log2 = p.log;
604
+
605
+ // src/commands/init.ts
606
+ async function init(options = {}) {
607
+ intro2(messages.initWelcome);
608
+ if (!options.force && await configExists()) {
609
+ log2.warn(messages.initAlreadyExists);
610
+ return;
611
+ }
612
+ const sourceType = await selectSourceType();
613
+ if (isCancel2(sourceType)) {
614
+ log2.info("Setup cancelled.");
615
+ return;
616
+ }
617
+ let sourcePath;
618
+ let remote;
619
+ let detectedSourceType = sourceType;
620
+ if (sourceType === "git") {
621
+ while (true) {
622
+ const gitUrl = await inputGitUrl();
623
+ if (isCancel2(gitUrl)) {
624
+ log2.info("Setup cancelled.");
625
+ return;
626
+ }
627
+ const clonePath = await inputClonePath();
628
+ if (isCancel2(clonePath)) {
629
+ log2.info("Setup cancelled.");
630
+ return;
631
+ }
632
+ const expandedClonePath = expandTilde(clonePath);
633
+ const s = spinner2();
634
+ s.start("Cloning repository...");
635
+ try {
636
+ ensureDir(join5(expandedClonePath, ".."));
637
+ await cloneRepo(gitUrl, expandedClonePath);
638
+ s.stop("Repository cloned successfully.");
639
+ sourcePath = expandedClonePath;
640
+ remote = gitUrl;
641
+ break;
642
+ } catch (error) {
643
+ s.stop("Failed to clone repository.");
644
+ log2.error(error instanceof Error ? error.message : String(error));
645
+ log2.info("Please try again.");
646
+ }
647
+ }
648
+ } else {
649
+ while (true) {
650
+ const localPath = await inputSourcePath();
651
+ if (isCancel2(localPath)) {
652
+ log2.info("Setup cancelled.");
653
+ return;
654
+ }
655
+ const expandedPath = expandTilde(localPath);
656
+ if (!fileExists(expandedPath)) {
657
+ log2.error(messages.sourceNotFound(expandedPath));
658
+ log2.info("Please try again.");
659
+ continue;
660
+ }
661
+ sourcePath = expandedPath;
662
+ if (isGitRepo(expandedPath)) {
663
+ const detectedRemote = await getRemoteUrl(expandedPath);
664
+ if (detectedRemote) {
665
+ detectedSourceType = "git";
666
+ remote = detectedRemote;
667
+ log2.info(`Detected git repository with remote: ${detectedRemote}`);
668
+ }
669
+ }
670
+ break;
671
+ }
672
+ }
673
+ const pullFrequency = await selectPullFrequency();
674
+ if (isCancel2(pullFrequency)) {
675
+ log2.info("Setup cancelled.");
676
+ return;
677
+ }
678
+ let shell;
679
+ if (isWindows()) {
680
+ const detectedShell = detectWindowsShell();
681
+ if (detectedShell) {
682
+ log2.info(`Detected shell: ${detectedShell}`);
683
+ shell = detectedShell;
684
+ } else {
685
+ const selectedShell = await selectWindowsShell();
686
+ if (isCancel2(selectedShell)) {
687
+ log2.info("Setup cancelled.");
688
+ return;
689
+ }
690
+ shell = selectedShell;
691
+ }
692
+ }
693
+ const config = createConfig({
694
+ sourceType: detectedSourceType,
695
+ sourcePath,
696
+ remote,
697
+ autoPull: true,
698
+ pullFrequency,
699
+ shell
700
+ });
701
+ await saveConfig(config);
702
+ outro2(messages.initSuccess);
703
+ }
704
+
705
+ // src/commands/add.ts
706
+ import { join as join6 } from "path";
707
+ async function add(rules, options = {}) {
708
+ const config = await loadConfig();
709
+ if (!config) {
710
+ log2.error(messages.notConfigured);
711
+ process.exit(1);
712
+ }
713
+ const s = spinner2();
714
+ s.start("Checking for updates...");
715
+ const pullResult = await pullSourceIfNeeded(config);
716
+ if (pullResult.pulled) {
717
+ await updateConfigLastPull();
718
+ }
719
+ s.stop(pullResult.error ? messages.statusOffline : "Source is up to date.");
720
+ const availableRules = await getAvailableRules(config);
721
+ if (availableRules.length === 0) {
722
+ log2.error("No rules found in source.");
723
+ process.exit(1);
724
+ }
725
+ const manifest = await loadManifest();
726
+ const installedRules = manifest ? Object.keys(manifest.rules) : [];
727
+ let rulesToAdd;
728
+ if (options.all) {
729
+ rulesToAdd = availableRules.map((r) => r.name);
730
+ } else if (rules.length > 0) {
731
+ rulesToAdd = rules;
732
+ } else {
733
+ const selected = await selectRulesToAdd(
734
+ availableRules.map((r) => r.name),
735
+ installedRules
736
+ );
737
+ if (isCancel2(selected)) {
738
+ log2.info("Cancelled.");
739
+ return;
740
+ }
741
+ rulesToAdd = selected;
742
+ }
743
+ if (rulesToAdd.length === 0) {
744
+ log2.warn(messages.addNoRules);
745
+ return;
746
+ }
747
+ const availableRuleNames = availableRules.map((r) => r.name);
748
+ const resolvedRules = [];
749
+ const invalidRules = [];
750
+ for (const rule of rulesToAdd) {
751
+ const match = findRuleMatch(rule, availableRuleNames);
752
+ if (match) {
753
+ resolvedRules.push(match);
754
+ } else {
755
+ invalidRules.push(rule);
756
+ }
757
+ }
758
+ if (invalidRules.length > 0) {
759
+ for (const rule of invalidRules) {
760
+ log2.error(messages.ruleNotFound(rule));
761
+ }
762
+ process.exit(1);
763
+ }
764
+ const claudeDir = getClaudeDir();
765
+ ensureDir(claudeDir);
766
+ const updatedManifest = await getOrCreateManifest();
767
+ let addedCount = 0;
768
+ let skippedCount = 0;
769
+ for (const rule of resolvedRules) {
770
+ const sourcePath = await getRuleSourcePath(rule, config);
771
+ if (!sourcePath) continue;
772
+ const filename = getRuleFilename(rule);
773
+ const targetPath = join6(claudeDir, filename);
774
+ const sourceHash = await hashFile(sourcePath);
775
+ if (fileExists(targetPath)) {
776
+ const localHash = await hashFile(targetPath);
777
+ const entry = updatedManifest.rules[rule];
778
+ if (entry && localHash === entry.localHash) {
779
+ await copyFile(sourcePath, targetPath);
780
+ const newHash = await hashFile(targetPath);
781
+ updatedManifest.rules[rule] = createRuleEntry({
782
+ file: filename,
783
+ sourceHash: newHash,
784
+ localHash: newHash
785
+ });
786
+ log2.success(`Updated ${filename}`);
787
+ addedCount++;
788
+ continue;
789
+ }
790
+ if (!entry || localHash !== entry.localHash) {
791
+ const action = await handleDivergedRule(rule, false, false);
792
+ if (isCancel2(action) || action === "skip") {
793
+ skippedCount++;
794
+ continue;
795
+ }
796
+ if (action === "detach") {
797
+ updatedManifest.rules[rule] = createRuleEntry({
798
+ file: filename,
799
+ sourceHash,
800
+ localHash,
801
+ status: "detached"
802
+ });
803
+ updatedManifest.rules[rule].detachedAt = (/* @__PURE__ */ new Date()).toISOString();
804
+ log2.info(`${filename} kept as detached`);
805
+ continue;
806
+ }
807
+ }
808
+ }
809
+ try {
810
+ await copyFile(sourcePath, targetPath);
811
+ const hash = await hashFile(targetPath);
812
+ updatedManifest.rules[rule] = createRuleEntry({
813
+ file: filename,
814
+ sourceHash: hash,
815
+ localHash: hash
816
+ });
817
+ log2.success(`Added ${filename}`);
818
+ addedCount++;
819
+ } catch (error) {
820
+ log2.error(`Failed to add ${filename}: ${error instanceof Error ? error.message : String(error)}`);
821
+ }
822
+ }
823
+ await saveManifest(updatedManifest);
824
+ if (addedCount > 0) {
825
+ log2.info(messages.addSuccess(addedCount));
826
+ }
827
+ if (skippedCount > 0) {
828
+ log2.info(`${skippedCount} rule(s) skipped.`);
829
+ }
830
+ }
831
+
832
+ // src/commands/remove.ts
833
+ import { join as join7 } from "path";
834
+ async function remove(rules, options = {}) {
835
+ const config = await loadConfig();
836
+ if (!config) {
837
+ log2.error(messages.notConfigured);
838
+ process.exit(1);
839
+ }
840
+ const manifest = await loadManifest();
841
+ if (!manifest) {
842
+ log2.error(messages.noManifest);
843
+ process.exit(1);
844
+ }
845
+ if (rules.length === 0) {
846
+ log2.error("No rules specified.");
847
+ process.exit(1);
848
+ }
849
+ const claudeDir = getClaudeDir();
850
+ const manifestRules = Object.keys(manifest.rules);
851
+ let removedCount = 0;
852
+ for (const rule of rules) {
853
+ const match = findRuleMatch(rule, manifestRules);
854
+ if (!match) {
855
+ log2.warn(messages.removeNotInstalled(rule));
856
+ continue;
857
+ }
858
+ const filename = getRuleFilename(match);
859
+ const targetPath = join7(claudeDir, filename);
860
+ if (!options.keepFile) {
861
+ try {
862
+ await deleteFile(targetPath);
863
+ } catch (error) {
864
+ log2.warn(`Could not delete ${filename}: ${error instanceof Error ? error.message : String(error)}`);
865
+ }
866
+ }
867
+ delete manifest.rules[match];
868
+ log2.success(`Removed ${match}`);
869
+ removedCount++;
870
+ }
871
+ await saveManifest(manifest);
872
+ if (removedCount > 0) {
873
+ log2.info(messages.removeSuccess(removedCount));
874
+ }
875
+ }
876
+
877
+ // src/commands/status.ts
878
+ import { join as join8 } from "path";
879
+ async function status() {
880
+ const config = await loadConfig();
881
+ if (!config) {
882
+ log2.error(messages.notConfigured);
883
+ process.exit(1);
884
+ }
885
+ const manifest = await loadManifest();
886
+ if (!manifest) {
887
+ log2.error(messages.noManifest);
888
+ process.exit(1);
889
+ }
890
+ const s = spinner2();
891
+ s.start("Checking for updates...");
892
+ const pullResult = await pullSourceIfNeeded(config);
893
+ if (pullResult.pulled) {
894
+ await updateConfigLastPull();
895
+ }
896
+ s.stop("");
897
+ if (pullResult.error) {
898
+ log2.warn(messages.statusOffline);
899
+ }
900
+ const ruleNames = Object.keys(manifest.rules);
901
+ if (ruleNames.length === 0) {
902
+ log2.info("No rules installed.");
903
+ return;
904
+ }
905
+ console.log("");
906
+ console.log(formatHeader("RuleKeeper Status"));
907
+ console.log("");
908
+ const claudeDir = getClaudeDir();
909
+ let needsAttention = 0;
910
+ for (const ruleName of ruleNames.sort()) {
911
+ const entry = manifest.rules[ruleName];
912
+ const filename = getRuleFilename(ruleName);
913
+ const localPath = join8(claudeDir, filename);
914
+ const sourcePath = join8(config.source.path, filename);
915
+ const result = await checkRuleStatus(entry, localPath, sourcePath);
916
+ let details;
917
+ switch (result.status) {
918
+ case "outdated":
919
+ details = "source updated";
920
+ needsAttention++;
921
+ break;
922
+ case "diverged":
923
+ details = "local changes detected";
924
+ needsAttention++;
925
+ break;
926
+ case "detached":
927
+ break;
928
+ case "synced":
929
+ break;
930
+ }
931
+ if (!result.localExists) {
932
+ details = "local file missing";
933
+ needsAttention++;
934
+ } else if (!result.sourceExists) {
935
+ details = "source file missing";
936
+ needsAttention++;
937
+ }
938
+ console.log(formatRuleLine(ruleName + ".md", result.status, details));
939
+ }
940
+ console.log("");
941
+ if (needsAttention > 0) {
942
+ log2.info(messages.statusNeedsAttention(needsAttention));
943
+ } else {
944
+ log2.success(messages.statusAllSynced);
945
+ }
946
+ }
947
+ async function checkRuleStatus(entry, localPath, sourcePath) {
948
+ const localExists = fileExists(localPath);
949
+ const sourceExists = fileExists(sourcePath);
950
+ if (entry.status === "detached") {
951
+ return {
952
+ status: "detached",
953
+ localChanged: false,
954
+ sourceChanged: false,
955
+ localExists,
956
+ sourceExists
957
+ };
958
+ }
959
+ if (!localExists || !sourceExists) {
960
+ return {
961
+ status: "diverged",
962
+ localChanged: !localExists,
963
+ sourceChanged: !sourceExists,
964
+ localExists,
965
+ sourceExists
966
+ };
967
+ }
968
+ const currentLocalHash = await hashFile(localPath);
969
+ const currentSourceHash = await hashFile(sourcePath);
970
+ const localChanged = currentLocalHash !== entry.localHash;
971
+ const sourceChanged = currentSourceHash !== entry.sourceHash;
972
+ let status2;
973
+ if (localChanged) {
974
+ status2 = "diverged";
975
+ } else if (sourceChanged) {
976
+ status2 = "outdated";
977
+ } else {
978
+ status2 = "synced";
979
+ }
980
+ return {
981
+ status: status2,
982
+ localChanged,
983
+ sourceChanged,
984
+ localExists,
985
+ sourceExists,
986
+ currentLocalHash,
987
+ currentSourceHash
988
+ };
989
+ }
990
+
991
+ // src/commands/pull.ts
992
+ import { join as join10 } from "path";
993
+
994
+ // src/commands/diff.ts
995
+ import { join as join9 } from "path";
996
+ async function diff(rules, options = {}) {
997
+ const config = await loadConfig();
998
+ if (!config) {
999
+ log2.error(messages.notConfigured);
1000
+ process.exit(1);
1001
+ }
1002
+ const manifest = await loadManifest();
1003
+ if (!manifest) {
1004
+ log2.error(messages.noManifest);
1005
+ process.exit(1);
1006
+ }
1007
+ const claudeDir = getClaudeDir();
1008
+ let rulesToDiff;
1009
+ if (options.all) {
1010
+ rulesToDiff = [];
1011
+ for (const [ruleName, entry] of Object.entries(manifest.rules)) {
1012
+ if (entry.status === "detached") continue;
1013
+ const filename = getRuleFilename(ruleName);
1014
+ const localPath = join9(claudeDir, filename);
1015
+ if (!fileExists(localPath)) continue;
1016
+ const currentLocalHash = await hashFile(localPath);
1017
+ if (currentLocalHash !== entry.localHash) {
1018
+ rulesToDiff.push(ruleName);
1019
+ }
1020
+ }
1021
+ if (rulesToDiff.length === 0) {
1022
+ log2.info(messages.diffNoRules);
1023
+ return;
1024
+ }
1025
+ } else if (rules.length > 0) {
1026
+ const manifestRules = Object.keys(manifest.rules);
1027
+ rulesToDiff = [];
1028
+ for (const rule of rules) {
1029
+ const match = findRuleMatch(rule, manifestRules);
1030
+ if (match) {
1031
+ rulesToDiff.push(match);
1032
+ } else {
1033
+ log2.warn(`Rule '${rule}' not found in manifest`);
1034
+ }
1035
+ }
1036
+ } else {
1037
+ log2.error("Specify a rule name or use --all to see all diverged rules.");
1038
+ process.exit(1);
1039
+ }
1040
+ for (const ruleName of rulesToDiff) {
1041
+ const entry = manifest.rules[ruleName];
1042
+ if (!entry) continue;
1043
+ const filename = getRuleFilename(ruleName);
1044
+ const localPath = join9(claudeDir, filename);
1045
+ const sourcePath = join9(config.source.path, filename);
1046
+ await showRuleDiff(ruleName, sourcePath, localPath);
1047
+ }
1048
+ }
1049
+ async function showDiff(rules) {
1050
+ return diff(rules, {});
1051
+ }
1052
+ async function showRuleDiff(ruleName, sourcePath, localPath) {
1053
+ const sourceExists = fileExists(sourcePath);
1054
+ const localExists = fileExists(localPath);
1055
+ console.log("");
1056
+ console.log(formatHeader(`Comparing ${ruleName}.md`));
1057
+ console.log("");
1058
+ if (!sourceExists && !localExists) {
1059
+ log2.warn("Both source and local files are missing");
1060
+ return;
1061
+ }
1062
+ if (!sourceExists) {
1063
+ log2.warn("Source file no longer exists");
1064
+ console.log(formatDiffHeader(`--- source (missing)`));
1065
+ console.log(formatDiffHeader(`+++ local (${formatPath(localPath)})`));
1066
+ return;
1067
+ }
1068
+ if (!localExists) {
1069
+ log2.warn("Local file is missing");
1070
+ console.log(formatDiffHeader(`--- source (${formatPath(sourcePath)})`));
1071
+ console.log(formatDiffHeader(`+++ local (missing)`));
1072
+ return;
1073
+ }
1074
+ const sourceContent = await readTextFile(sourcePath);
1075
+ const localContent = await readTextFile(localPath);
1076
+ if (sourceContent === localContent) {
1077
+ log2.info(messages.diffNoDifference(ruleName));
1078
+ return;
1079
+ }
1080
+ console.log(formatDiffHeader(`--- source (${sourcePath})`));
1081
+ console.log(formatDiffHeader(`+++ local (${localPath})`));
1082
+ console.log("");
1083
+ const sourceLines = sourceContent.split("\n");
1084
+ const localLines = localContent.split("\n");
1085
+ const diff2 = computeSimpleDiff(sourceLines, localLines);
1086
+ for (const line of diff2) {
1087
+ if (line.type === "add") {
1088
+ console.log(formatDiffAdd(line.content));
1089
+ } else if (line.type === "remove") {
1090
+ console.log(formatDiffRemove(line.content));
1091
+ } else {
1092
+ console.log(formatDiffContext(line.content));
1093
+ }
1094
+ }
1095
+ console.log("");
1096
+ }
1097
+ function computeSimpleDiff(source, local) {
1098
+ const result = [];
1099
+ const maxLen = Math.max(source.length, local.length);
1100
+ const sourceSet2 = new Set(source);
1101
+ const localSet = new Set(local);
1102
+ let si = 0;
1103
+ let li = 0;
1104
+ while (si < source.length || li < local.length) {
1105
+ if (si >= source.length) {
1106
+ result.push({ type: "add", content: local[li] });
1107
+ li++;
1108
+ } else if (li >= local.length) {
1109
+ result.push({ type: "remove", content: source[si] });
1110
+ si++;
1111
+ } else if (source[si] === local[li]) {
1112
+ result.push({ type: "context", content: source[si] });
1113
+ si++;
1114
+ li++;
1115
+ } else if (!localSet.has(source[si])) {
1116
+ result.push({ type: "remove", content: source[si] });
1117
+ si++;
1118
+ } else if (!sourceSet2.has(local[li])) {
1119
+ result.push({ type: "add", content: local[li] });
1120
+ li++;
1121
+ } else {
1122
+ result.push({ type: "remove", content: source[si] });
1123
+ result.push({ type: "add", content: local[li] });
1124
+ si++;
1125
+ li++;
1126
+ }
1127
+ }
1128
+ return result;
1129
+ }
1130
+
1131
+ // src/commands/pull.ts
1132
+ async function pull(rules, options = {}) {
1133
+ const config = await loadConfig();
1134
+ if (!config) {
1135
+ log2.error(messages.notConfigured);
1136
+ process.exit(1);
1137
+ }
1138
+ const manifest = await loadManifest();
1139
+ if (!manifest) {
1140
+ log2.error(messages.noManifest);
1141
+ process.exit(1);
1142
+ }
1143
+ const s = spinner2();
1144
+ s.start("Checking for updates...");
1145
+ const pullResult = await pullSourceIfNeeded(config);
1146
+ if (pullResult.pulled) {
1147
+ await updateConfigLastPull();
1148
+ }
1149
+ s.stop("");
1150
+ if (pullResult.error) {
1151
+ log2.warn(messages.statusOffline);
1152
+ }
1153
+ const manifestRules = Object.keys(manifest.rules);
1154
+ let rulesToProcess;
1155
+ if (rules.length > 0) {
1156
+ rulesToProcess = [];
1157
+ for (const rule of rules) {
1158
+ const match = findRuleMatch(rule, manifestRules);
1159
+ if (match) {
1160
+ rulesToProcess.push(match);
1161
+ } else {
1162
+ log2.warn(`Rule '${rule}' not found in manifest`);
1163
+ }
1164
+ }
1165
+ } else {
1166
+ rulesToProcess = manifestRules;
1167
+ }
1168
+ const results = {
1169
+ updated: [],
1170
+ skipped: [],
1171
+ detached: [],
1172
+ failed: []
1173
+ };
1174
+ const claudeDir = getClaudeDir();
1175
+ const isSingleRule = rules.length === 1;
1176
+ for (const ruleName of rulesToProcess) {
1177
+ const entry = manifest.rules[ruleName];
1178
+ if (!entry) {
1179
+ log2.warn(`Rule '${ruleName}' not found in manifest`);
1180
+ results.failed.push(ruleName);
1181
+ continue;
1182
+ }
1183
+ if (entry.status === "detached" && !options.includeDetached) {
1184
+ log2.info(`\u25CB ${ruleName}.md (detached - skipped)`);
1185
+ results.skipped.push(ruleName);
1186
+ continue;
1187
+ }
1188
+ const filename = getRuleFilename(ruleName);
1189
+ const sourcePath = join10(config.source.path, filename);
1190
+ const localPath = join10(claudeDir, filename);
1191
+ if (!fileExists(sourcePath)) {
1192
+ const action = await handleMissingSource(ruleName);
1193
+ if (isCancel2(action)) {
1194
+ results.skipped.push(ruleName);
1195
+ continue;
1196
+ }
1197
+ if (action === "keep") {
1198
+ manifest.rules[ruleName] = {
1199
+ ...entry,
1200
+ status: "detached",
1201
+ detachedAt: (/* @__PURE__ */ new Date()).toISOString()
1202
+ };
1203
+ results.detached.push(ruleName);
1204
+ } else if (action === "remove") {
1205
+ await deleteFile(localPath);
1206
+ delete manifest.rules[ruleName];
1207
+ log2.success(`Removed ${ruleName}`);
1208
+ }
1209
+ continue;
1210
+ }
1211
+ if (!fileExists(localPath)) {
1212
+ const action = await handleMissingLocal(ruleName);
1213
+ if (isCancel2(action)) {
1214
+ results.skipped.push(ruleName);
1215
+ continue;
1216
+ }
1217
+ if (action === "restore") {
1218
+ await copyFile(sourcePath, localPath);
1219
+ const hash = await hashFile(localPath);
1220
+ manifest.rules[ruleName] = {
1221
+ ...entry,
1222
+ sourceHash: hash,
1223
+ localHash: hash,
1224
+ status: "synced",
1225
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1226
+ };
1227
+ log2.success(`Restored ${ruleName}.md`);
1228
+ results.updated.push(ruleName);
1229
+ } else if (action === "remove") {
1230
+ delete manifest.rules[ruleName];
1231
+ log2.success(`Removed ${ruleName} from manifest`);
1232
+ }
1233
+ continue;
1234
+ }
1235
+ const result = await pullRule(
1236
+ ruleName,
1237
+ entry,
1238
+ sourcePath,
1239
+ localPath,
1240
+ manifest,
1241
+ options,
1242
+ isSingleRule
1243
+ );
1244
+ switch (result) {
1245
+ case "updated":
1246
+ log2.success(`\u2713 ${ruleName}.md updated`);
1247
+ results.updated.push(ruleName);
1248
+ break;
1249
+ case "detached":
1250
+ log2.info(`\u25CB ${ruleName}.md detached`);
1251
+ results.detached.push(ruleName);
1252
+ break;
1253
+ case "skipped":
1254
+ results.skipped.push(ruleName);
1255
+ break;
1256
+ case "cancelled":
1257
+ await saveManifest(manifest);
1258
+ return;
1259
+ }
1260
+ }
1261
+ await saveManifest(manifest);
1262
+ console.log("");
1263
+ if (results.updated.length > 0) {
1264
+ log2.success(messages.pullSuccess(results.updated.length));
1265
+ }
1266
+ if (results.detached.length > 0) {
1267
+ log2.info(messages.pullDetached(results.detached.length));
1268
+ }
1269
+ if (results.failed.length > 0) {
1270
+ log2.error(`${results.failed.length} rule(s) failed`);
1271
+ }
1272
+ if (results.updated.length === 0 && results.detached.length === 0 && results.failed.length === 0) {
1273
+ log2.success(messages.pullUpToDate);
1274
+ }
1275
+ }
1276
+ async function pullRule(ruleName, entry, sourcePath, localPath, manifest, options, isSingleRule) {
1277
+ const currentLocalHash = await hashFile(localPath);
1278
+ const currentSourceHash = await hashFile(sourcePath);
1279
+ const localChanged = currentLocalHash !== entry.localHash;
1280
+ const sourceChanged = currentSourceHash !== entry.sourceHash;
1281
+ if (!localChanged && !sourceChanged) {
1282
+ return "skipped";
1283
+ }
1284
+ if (!localChanged && sourceChanged) {
1285
+ await copyFile(sourcePath, localPath);
1286
+ manifest.rules[ruleName] = {
1287
+ ...entry,
1288
+ sourceHash: currentSourceHash,
1289
+ localHash: currentSourceHash,
1290
+ status: "synced",
1291
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1292
+ };
1293
+ return "updated";
1294
+ }
1295
+ if (localChanged) {
1296
+ if (options.force) {
1297
+ await copyFile(sourcePath, localPath);
1298
+ const newHash = await hashFile(localPath);
1299
+ manifest.rules[ruleName] = {
1300
+ ...entry,
1301
+ sourceHash: currentSourceHash,
1302
+ localHash: newHash,
1303
+ status: "synced",
1304
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1305
+ };
1306
+ return "updated";
1307
+ }
1308
+ let action = await handleDivergedRule(ruleName, sourceChanged, isSingleRule);
1309
+ while (action === "view-diff") {
1310
+ await showDiff([ruleName]);
1311
+ action = await handleDivergedRule(ruleName, sourceChanged, isSingleRule);
1312
+ }
1313
+ if (isCancel2(action)) {
1314
+ return isSingleRule ? "cancelled" : "skipped";
1315
+ }
1316
+ switch (action) {
1317
+ case "overwrite":
1318
+ await copyFile(sourcePath, localPath);
1319
+ const newHash = await hashFile(localPath);
1320
+ manifest.rules[ruleName] = {
1321
+ ...entry,
1322
+ sourceHash: currentSourceHash,
1323
+ localHash: newHash,
1324
+ status: "synced",
1325
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1326
+ };
1327
+ return "updated";
1328
+ case "detach":
1329
+ manifest.rules[ruleName] = {
1330
+ ...entry,
1331
+ status: "detached",
1332
+ detachedAt: (/* @__PURE__ */ new Date()).toISOString()
1333
+ };
1334
+ return "detached";
1335
+ case "skip":
1336
+ case "cancel":
1337
+ return isSingleRule ? "cancelled" : "skipped";
1338
+ }
1339
+ }
1340
+ return "skipped";
1341
+ }
1342
+
1343
+ // src/commands/list.ts
1344
+ import pc4 from "picocolors";
1345
+ async function list(options = {}) {
1346
+ const config = await loadConfig();
1347
+ if (!config) {
1348
+ log2.error(messages.notConfigured);
1349
+ process.exit(1);
1350
+ }
1351
+ const s = spinner2();
1352
+ s.start("Checking source...");
1353
+ const pullResult = await pullSourceIfNeeded(config);
1354
+ if (pullResult.pulled) {
1355
+ await updateConfigLastPull();
1356
+ }
1357
+ s.stop("");
1358
+ if (pullResult.error) {
1359
+ log2.warn(messages.statusOffline);
1360
+ }
1361
+ const manifest = await loadManifest();
1362
+ const installedRules = manifest ? Object.keys(manifest.rules) : [];
1363
+ if (options.installed) {
1364
+ if (installedRules.length === 0) {
1365
+ log2.info("No rules installed.");
1366
+ return;
1367
+ }
1368
+ console.log("");
1369
+ console.log(formatHeader("Installed Rules"));
1370
+ console.log("");
1371
+ for (const rule of installedRules.sort()) {
1372
+ const entry = manifest.rules[rule];
1373
+ const statusHint = entry.status !== "synced" ? pc4.dim(` (${entry.status})`) : "";
1374
+ console.log(` ${rule}.md${statusHint}`);
1375
+ }
1376
+ } else {
1377
+ const availableRules = await getAvailableRules(config);
1378
+ if (availableRules.length === 0) {
1379
+ log2.info("No rules found in source.");
1380
+ return;
1381
+ }
1382
+ console.log("");
1383
+ console.log(formatHeader("Available Rules"));
1384
+ console.log("");
1385
+ for (const rule of availableRules.sort((a, b) => a.name.localeCompare(b.name))) {
1386
+ const installed = installedRules.includes(rule.name);
1387
+ const icon = installed ? pc4.green("\u2713") : pc4.dim("\u25CB");
1388
+ const hint = installed ? pc4.dim(" (installed)") : "";
1389
+ console.log(` ${icon} ${rule.name}.md${hint}`);
1390
+ }
1391
+ console.log("");
1392
+ console.log(pc4.dim(`Source: ${config.source.path}`));
1393
+ }
1394
+ console.log("");
1395
+ }
1396
+
1397
+ // src/commands/detach.ts
1398
+ async function detach(ruleName) {
1399
+ const config = await loadConfig();
1400
+ if (!config) {
1401
+ log2.error(messages.notConfigured);
1402
+ process.exit(1);
1403
+ }
1404
+ const manifest = await loadManifest();
1405
+ if (!manifest) {
1406
+ log2.error(messages.noManifest);
1407
+ process.exit(1);
1408
+ }
1409
+ const match = findRuleMatch(ruleName, Object.keys(manifest.rules));
1410
+ if (!match) {
1411
+ log2.error(`Rule '${ruleName}' not found in manifest.`);
1412
+ process.exit(1);
1413
+ }
1414
+ const entry = manifest.rules[match];
1415
+ if (entry.status === "detached") {
1416
+ log2.warn(messages.detachAlready(match));
1417
+ return;
1418
+ }
1419
+ await updateRuleInManifest(match, {
1420
+ status: "detached",
1421
+ detachedAt: (/* @__PURE__ */ new Date()).toISOString()
1422
+ });
1423
+ log2.success(messages.detachSuccess(match));
1424
+ }
1425
+
1426
+ // src/commands/attach.ts
1427
+ import { join as join11 } from "path";
1428
+ async function attach(ruleName) {
1429
+ const config = await loadConfig();
1430
+ if (!config) {
1431
+ log2.error(messages.notConfigured);
1432
+ process.exit(1);
1433
+ }
1434
+ const manifest = await loadManifest();
1435
+ if (!manifest) {
1436
+ log2.error(messages.noManifest);
1437
+ process.exit(1);
1438
+ }
1439
+ const match = findRuleMatch(ruleName, Object.keys(manifest.rules));
1440
+ if (!match) {
1441
+ log2.error(`Rule '${ruleName}' not found in manifest.`);
1442
+ process.exit(1);
1443
+ }
1444
+ const entry = manifest.rules[match];
1445
+ if (entry.status !== "detached") {
1446
+ log2.warn(messages.attachAlready(match));
1447
+ return;
1448
+ }
1449
+ const claudeDir = getClaudeDir();
1450
+ const filename = getRuleFilename(match);
1451
+ const localPath = join11(claudeDir, filename);
1452
+ const sourcePath = join11(config.source.path, filename);
1453
+ if (!fileExists(sourcePath)) {
1454
+ log2.error(messages.errorMissingSource(match));
1455
+ process.exit(1);
1456
+ }
1457
+ if (!fileExists(localPath)) {
1458
+ log2.error(messages.errorMissingLocal(match));
1459
+ process.exit(1);
1460
+ }
1461
+ const localHash = await hashFile(localPath);
1462
+ const sourceHash = await hashFile(sourcePath);
1463
+ if (localHash === sourceHash) {
1464
+ await updateRuleInManifest(match, {
1465
+ status: "synced",
1466
+ sourceHash,
1467
+ localHash,
1468
+ detachedAt: void 0
1469
+ });
1470
+ log2.success(messages.attachSuccess(match));
1471
+ return;
1472
+ }
1473
+ const action = await handleAttachDiffers(match);
1474
+ if (isCancel2(action)) {
1475
+ log2.info("Cancelled.");
1476
+ return;
1477
+ }
1478
+ switch (action) {
1479
+ case "overwrite":
1480
+ await copyFile(sourcePath, localPath);
1481
+ const newHash = await hashFile(localPath);
1482
+ await updateRuleInManifest(match, {
1483
+ status: "synced",
1484
+ sourceHash,
1485
+ localHash: newHash,
1486
+ detachedAt: void 0
1487
+ });
1488
+ log2.success(messages.attachSuccess(match));
1489
+ break;
1490
+ case "keep":
1491
+ await updateRuleInManifest(match, {
1492
+ status: "diverged",
1493
+ sourceHash,
1494
+ localHash,
1495
+ detachedAt: void 0
1496
+ });
1497
+ log2.success(`${match} attached (will show as diverged)`);
1498
+ break;
1499
+ case "cancel":
1500
+ log2.info("Cancelled.");
1501
+ break;
1502
+ }
1503
+ }
1504
+
1505
+ // src/commands/source.ts
1506
+ import { join as join12 } from "path";
1507
+ async function sourceShow() {
1508
+ const config = await loadConfig();
1509
+ if (!config) {
1510
+ log2.error(messages.notConfigured);
1511
+ process.exit(1);
1512
+ }
1513
+ console.log("");
1514
+ console.log(formatHeader("Source Configuration"));
1515
+ console.log("");
1516
+ console.log(` Type: ${config.source.type}`);
1517
+ console.log(` Path: ${formatPath(config.source.path)}`);
1518
+ if (config.source.remote) {
1519
+ console.log(` Remote: ${config.source.remote}`);
1520
+ } else if (isGitRepo(config.source.path)) {
1521
+ const remote = await getRemoteUrl(config.source.path);
1522
+ if (remote) {
1523
+ console.log(` Remote: ${remote}`);
1524
+ }
1525
+ }
1526
+ console.log("");
1527
+ console.log(` Auto-pull: ${config.settings.autoPull ? "enabled" : "disabled"}`);
1528
+ console.log(` Frequency: ${config.settings.pullFrequency}`);
1529
+ if (config.settings.lastPull) {
1530
+ console.log(` Last pull: ${new Date(config.settings.lastPull).toLocaleString()}`);
1531
+ }
1532
+ console.log("");
1533
+ }
1534
+ async function sourceSet(pathOrUrl) {
1535
+ var _a;
1536
+ const config = await loadConfig();
1537
+ if (!config) {
1538
+ log2.error(messages.notConfigured);
1539
+ process.exit(1);
1540
+ }
1541
+ if (isGitUrl(pathOrUrl)) {
1542
+ const s = spinner2();
1543
+ s.start("Cloning repository...");
1544
+ const repoName = ((_a = pathOrUrl.split("/").pop()) == null ? void 0 : _a.replace(".git", "")) || "claude-rules";
1545
+ const clonePath = expandTilde(`~/Documents/${repoName}`);
1546
+ try {
1547
+ ensureDir(join12(clonePath, ".."));
1548
+ await cloneRepo(pathOrUrl, clonePath);
1549
+ s.stop("Repository cloned successfully.");
1550
+ config.source = {
1551
+ type: "git",
1552
+ path: clonePath,
1553
+ remote: pathOrUrl
1554
+ };
1555
+ } catch (error) {
1556
+ s.stop("Failed to clone repository.");
1557
+ log2.error(error instanceof Error ? error.message : String(error));
1558
+ process.exit(1);
1559
+ }
1560
+ } else {
1561
+ const expandedPath = expandTilde(pathOrUrl);
1562
+ if (!fileExists(expandedPath)) {
1563
+ log2.error(messages.sourceNotFound(expandedPath));
1564
+ process.exit(1);
1565
+ }
1566
+ config.source = {
1567
+ type: "local",
1568
+ path: expandedPath
1569
+ };
1570
+ if (isGitRepo(expandedPath)) {
1571
+ const remote = await getRemoteUrl(expandedPath);
1572
+ if (remote) {
1573
+ config.source.type = "git";
1574
+ config.source.remote = remote;
1575
+ }
1576
+ }
1577
+ }
1578
+ await saveConfig(config);
1579
+ log2.success(messages.sourceUpdated(config.source.path));
1580
+ }
1581
+ async function sourcePull() {
1582
+ const config = await loadConfig();
1583
+ if (!config) {
1584
+ log2.error(messages.notConfigured);
1585
+ process.exit(1);
1586
+ }
1587
+ if (!isGitRepo(config.source.path)) {
1588
+ log2.error("Source is not a git repository.");
1589
+ process.exit(1);
1590
+ }
1591
+ if (!await hasRemote(config.source.path)) {
1592
+ log2.error("Source repository has no remote configured.");
1593
+ process.exit(1);
1594
+ }
1595
+ const s = spinner2();
1596
+ s.start("Pulling from remote...");
1597
+ try {
1598
+ await pullRepo(config.source.path);
1599
+ s.stop("Source updated successfully.");
1600
+ config.settings.lastPull = (/* @__PURE__ */ new Date()).toISOString();
1601
+ await saveConfig(config);
1602
+ } catch (error) {
1603
+ s.stop("Failed to pull from remote.");
1604
+ log2.error(error instanceof Error ? error.message : String(error));
1605
+ process.exit(1);
1606
+ }
1607
+ }
1608
+ async function sourceConfig(options) {
1609
+ const config = await loadConfig();
1610
+ if (!config) {
1611
+ log2.error(messages.notConfigured);
1612
+ process.exit(1);
1613
+ }
1614
+ if (options.autoPull === void 0 && options.frequency === void 0) {
1615
+ console.log("");
1616
+ console.log(formatHeader("Pull Settings"));
1617
+ console.log("");
1618
+ console.log(` Auto-pull: ${config.settings.autoPull ? "enabled" : "disabled"}`);
1619
+ console.log(` Frequency: ${config.settings.pullFrequency}`);
1620
+ if (config.settings.lastPull) {
1621
+ console.log(` Last pull: ${new Date(config.settings.lastPull).toLocaleString()}`);
1622
+ }
1623
+ console.log("");
1624
+ console.log(" Use --auto-pull and --frequency to change settings.");
1625
+ console.log("");
1626
+ return;
1627
+ }
1628
+ if (options.autoPull !== void 0) {
1629
+ config.settings.autoPull = options.autoPull;
1630
+ }
1631
+ if (options.frequency !== void 0) {
1632
+ config.settings.pullFrequency = options.frequency;
1633
+ }
1634
+ await saveConfig(config);
1635
+ log2.success("Settings updated.");
1636
+ console.log("");
1637
+ console.log(` Auto-pull: ${config.settings.autoPull ? "enabled" : "disabled"}`);
1638
+ console.log(` Frequency: ${config.settings.pullFrequency}`);
1639
+ console.log("");
1640
+ }
1641
+
1642
+ // src/commands/doctor.ts
1643
+ import { join as join13 } from "path";
1644
+ import pc5 from "picocolors";
1645
+ async function doctor() {
1646
+ console.log("");
1647
+ console.log(formatHeader("RuleKeeper Diagnostics"));
1648
+ console.log("");
1649
+ const results = [];
1650
+ const configPath = getConfigPath();
1651
+ if (fileExists(configPath)) {
1652
+ results.push({
1653
+ name: "Global config",
1654
+ status: "pass",
1655
+ message: `Found at ${formatPath(configPath)}`
1656
+ });
1657
+ } else {
1658
+ results.push({
1659
+ name: "Global config",
1660
+ status: "fail",
1661
+ message: `Not found. Run ${pc5.cyan("rk init")} to configure.`
1662
+ });
1663
+ }
1664
+ const config = await loadConfig();
1665
+ if (config) {
1666
+ if (fileExists(config.source.path)) {
1667
+ results.push({
1668
+ name: "Source path",
1669
+ status: "pass",
1670
+ message: formatPath(config.source.path)
1671
+ });
1672
+ } else {
1673
+ results.push({
1674
+ name: "Source path",
1675
+ status: "fail",
1676
+ message: `Path does not exist: ${formatPath(config.source.path)}`
1677
+ });
1678
+ }
1679
+ if (fileExists(config.source.path)) {
1680
+ const availableRules = await getAvailableRules(config);
1681
+ if (availableRules.length > 0) {
1682
+ results.push({
1683
+ name: "Source rules",
1684
+ status: "pass",
1685
+ message: `${availableRules.length} rule(s) available`
1686
+ });
1687
+ } else {
1688
+ results.push({
1689
+ name: "Source rules",
1690
+ status: "warn",
1691
+ message: "No .md files found in source"
1692
+ });
1693
+ }
1694
+ }
1695
+ if (config.source.type === "git" && fileExists(config.source.path)) {
1696
+ if (isGitRepo(config.source.path)) {
1697
+ if (await hasRemote(config.source.path)) {
1698
+ results.push({
1699
+ name: "Git remote",
1700
+ status: "pass",
1701
+ message: "Remote configured"
1702
+ });
1703
+ } else {
1704
+ results.push({
1705
+ name: "Git remote",
1706
+ status: "warn",
1707
+ message: "No remote configured - cannot auto-pull"
1708
+ });
1709
+ }
1710
+ } else {
1711
+ results.push({
1712
+ name: "Git repository",
1713
+ status: "fail",
1714
+ message: "Source is configured as git but is not a git repository"
1715
+ });
1716
+ }
1717
+ }
1718
+ }
1719
+ const claudeDir = getClaudeDir();
1720
+ const rulekeeperDir = getRulekeeperDir();
1721
+ if (fileExists(claudeDir)) {
1722
+ results.push({
1723
+ name: ".claude directory",
1724
+ status: "pass",
1725
+ message: "Found in current project"
1726
+ });
1727
+ } else {
1728
+ results.push({
1729
+ name: ".claude directory",
1730
+ status: "warn",
1731
+ message: "Not found - not in a Claude Code project?"
1732
+ });
1733
+ }
1734
+ const manifest = await loadManifest();
1735
+ if (manifest) {
1736
+ const ruleCount = Object.keys(manifest.rules).length;
1737
+ results.push({
1738
+ name: "RuleKeeper manifest",
1739
+ status: "pass",
1740
+ message: `${ruleCount} rule(s) tracked`
1741
+ });
1742
+ if (config && ruleCount > 0) {
1743
+ let missingCount = 0;
1744
+ for (const [ruleName, entry] of Object.entries(manifest.rules)) {
1745
+ const localPath = join13(claudeDir, entry.file);
1746
+ if (!fileExists(localPath)) {
1747
+ missingCount++;
1748
+ }
1749
+ }
1750
+ if (missingCount > 0) {
1751
+ results.push({
1752
+ name: "Rule files",
1753
+ status: "warn",
1754
+ message: `${missingCount} tracked rule(s) missing local files`
1755
+ });
1756
+ } else {
1757
+ results.push({
1758
+ name: "Rule files",
1759
+ status: "pass",
1760
+ message: "All tracked rules have local files"
1761
+ });
1762
+ }
1763
+ }
1764
+ } else if (fileExists(claudeDir)) {
1765
+ results.push({
1766
+ name: "RuleKeeper manifest",
1767
+ status: "warn",
1768
+ message: `No manifest. Run ${pc5.cyan("rk add")} to start tracking rules.`
1769
+ });
1770
+ }
1771
+ let passCount = 0;
1772
+ let warnCount = 0;
1773
+ let failCount = 0;
1774
+ for (const result of results) {
1775
+ let icon;
1776
+ let color;
1777
+ switch (result.status) {
1778
+ case "pass":
1779
+ icon = "\u2713";
1780
+ color = pc5.green;
1781
+ passCount++;
1782
+ break;
1783
+ case "warn":
1784
+ icon = "\u26A0";
1785
+ color = pc5.yellow;
1786
+ warnCount++;
1787
+ break;
1788
+ case "fail":
1789
+ icon = "\u2717";
1790
+ color = pc5.red;
1791
+ failCount++;
1792
+ break;
1793
+ }
1794
+ console.log(` ${color(icon)} ${result.name}: ${result.message}`);
1795
+ }
1796
+ console.log("");
1797
+ if (failCount > 0) {
1798
+ log2.error(messages.doctorIssuesFound(failCount));
1799
+ } else if (warnCount > 0) {
1800
+ log2.warn(`${warnCount} warning(s) found.`);
1801
+ } else {
1802
+ log2.success(messages.doctorAllGood);
1803
+ }
1804
+ }
1805
+
1806
+ // src/index.ts
1807
+ var program = new Command();
1808
+ program.name("rulekeeper").description("Sync and manage Claude Code rules across projects").version("0.1.0");
1809
+ program.command("init").description("Interactive global setup").option("-f, --force", "Overwrite existing configuration").action(async (options) => {
1810
+ await init({ force: options.force });
1811
+ });
1812
+ program.command("add [rules...]").description("Add rules to current project").option("-a, --all", "Add all available rules").action(async (rules, options) => {
1813
+ await add(rules, { all: options.all });
1814
+ });
1815
+ program.command("remove <rules...>").description("Remove rules from project").option("-k, --keep-file", "Keep the local file, only remove from manifest").action(async (rules, options) => {
1816
+ await remove(rules, { keepFile: options.keepFile });
1817
+ });
1818
+ program.command("status").description("Show rule states in current project").action(async () => {
1819
+ await status();
1820
+ });
1821
+ program.command("pull [rules...]").description("Update rules from source").option("-f, --force", "Overwrite diverged rules without prompting").option("--include-detached", "Include detached rules").action(async (rules, options) => {
1822
+ await pull(rules, {
1823
+ force: options.force,
1824
+ includeDetached: options.includeDetached
1825
+ });
1826
+ });
1827
+ program.command("diff [rule]").description("Show differences between local and source").option("-a, --all", "Show diff for all diverged rules").action(async (rule, options) => {
1828
+ const rules = rule ? [rule] : [];
1829
+ await diff(rules, { all: options.all });
1830
+ });
1831
+ program.command("list").description("List available rules").option("-i, --installed", "Show only installed rules").action(async (options) => {
1832
+ await list({ installed: options.installed });
1833
+ });
1834
+ program.command("detach <rule>").description("Mark rule as intentionally diverged").action(async (rule) => {
1835
+ await detach(rule);
1836
+ });
1837
+ program.command("attach <rule>").description("Resume tracking a detached rule").action(async (rule) => {
1838
+ await attach(rule);
1839
+ });
1840
+ var sourceCmd = program.command("source").description("Manage source configuration");
1841
+ sourceCmd.command("show").description("Show current source configuration").action(async () => {
1842
+ await sourceShow();
1843
+ });
1844
+ sourceCmd.command("set <path-or-url>").description("Change source location").action(async (pathOrUrl) => {
1845
+ await sourceSet(pathOrUrl);
1846
+ });
1847
+ sourceCmd.command("pull").description("Manually pull from git remote").action(async () => {
1848
+ await sourcePull();
1849
+ });
1850
+ sourceCmd.command("config").description("View or update pull settings").option("--auto-pull <boolean>", "Enable or disable auto-pull (on/off)").option("--frequency <freq>", "Set pull frequency (always, daily, weekly)").action(async (options) => {
1851
+ let autoPull;
1852
+ if (options.autoPull !== void 0) {
1853
+ const val = options.autoPull.toLowerCase();
1854
+ if (val === "on" || val === "true" || val === "1") {
1855
+ autoPull = true;
1856
+ } else if (val === "off" || val === "false" || val === "0") {
1857
+ autoPull = false;
1858
+ } else {
1859
+ console.error("Invalid value for --auto-pull. Use: on, off, true, false");
1860
+ process.exit(1);
1861
+ }
1862
+ }
1863
+ let frequency;
1864
+ if (options.frequency !== void 0) {
1865
+ const val = options.frequency.toLowerCase();
1866
+ if (val === "always" || val === "daily" || val === "weekly") {
1867
+ frequency = val;
1868
+ } else {
1869
+ console.error("Invalid value for --frequency. Use: always, daily, weekly");
1870
+ process.exit(1);
1871
+ }
1872
+ }
1873
+ await sourceConfig({ autoPull, frequency });
1874
+ });
1875
+ program.command("doctor").description("Diagnose setup issues").action(async () => {
1876
+ await doctor();
1877
+ });
1878
+ program.parse();
1879
+ //# sourceMappingURL=index.js.map