ai-nexus 1.3.20 → 1.4.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/README.ko.md CHANGED
@@ -199,35 +199,13 @@ description: 이 룰을 로드할 시점 (시맨틱 라우터가 사용)
199
199
 
200
200
  ---
201
201
 
202
- ## 설치 모드
202
+ ## 업데이트 & 로컬 우선
203
203
 
204
- ### Symlink (기본값)
205
-
206
- ```bash
207
- npx ai-nexus install
208
- ```
209
-
210
- - 룰이 소스에 링크 → `update`로 즉시 동기화
211
- - 룰을 직접 수정 불가 (소스에서 수정)
212
-
213
- ### Copy
214
-
215
- ```bash
216
- npx ai-nexus install --copy
217
- ```
218
-
219
- - 룰이 독립적인 복사본
220
- - 로컬에서 자유롭게 수정 가능
221
- - `update`는 새 파일만 추가, 기존 파일 덮어쓰지 않음
222
-
223
- ---
224
-
225
- ## 로컬 우선
226
-
227
- 사용자의 커스터마이징은 항상 안전합니다:
204
+ 룰은 독립적인 복사본으로 설치됩니다. 사용자의 커스터마이징은 항상 안전합니다:
228
205
 
229
206
  - **기존 파일은 절대 덮어쓰지 않음** (install, update 모두)
230
207
  - 소스에서 새 파일만 추가
208
+ - `npx ai-nexus update`로 최신 패키지의 새 룰을 동기화
231
209
  - `--force`로 강제 덮어쓰기 (백업 먼저!)
232
210
 
233
211
  ```bash
@@ -238,6 +216,8 @@ npx ai-nexus update
238
216
  npx ai-nexus update --force
239
217
  ```
240
218
 
219
+ > **symlink 모드에서 마이그레이션?** `npx ai-nexus update`만 실행하면 symlink이 자동으로 복사본으로 변환됩니다.
220
+
241
221
  ---
242
222
 
243
223
  ## 디렉토리 구조
@@ -251,8 +231,8 @@ npx ai-nexus update --force
251
231
  .claude/ # Claude Code
252
232
  ├── hooks/semantic-router.cjs
253
233
  ├── settings.json
254
- ├── rules/ .ai-nexus/config/rules
255
- └── commands/ .ai-nexus/config/commands
234
+ ├── rules/ # .ai-nexus/config/rules에서 복사
235
+ └── commands/ # .ai-nexus/config/commands에서 복사
256
236
 
257
237
  .cursor/rules/ # Cursor (.mdc 파일)
258
238
  ├── essential.mdc
@@ -272,7 +252,6 @@ npx ai-nexus install
272
252
  # 선택: Claude Code, Cursor
273
253
  # 선택: rules, commands, hooks, settings
274
254
  # 템플릿: React/Next.js
275
- # 모드: symlink
276
255
  ```
277
256
 
278
257
  ### 팀 설정
package/README.md CHANGED
@@ -199,35 +199,13 @@ Your rule content...
199
199
 
200
200
  ---
201
201
 
202
- ## Installation Modes
202
+ ## Update & Local Priority
203
203
 
204
- ### Symlink (default)
205
-
206
- ```bash
207
- npx ai-nexus install
208
- ```
209
-
210
- - Rules link to source → `update` syncs instantly
211
- - Cannot edit rules directly (edit source instead)
212
-
213
- ### Copy
214
-
215
- ```bash
216
- npx ai-nexus install --copy
217
- ```
218
-
219
- - Rules are independent copies
220
- - Can edit locally
221
- - `update` only adds new files, never overwrites
222
-
223
- ---
224
-
225
- ## Local Priority
226
-
227
- Your customizations are always safe:
204
+ Rules are installed as independent copies. Your customizations are always safe:
228
205
 
229
206
  - **Existing files are never overwritten** during install or update
230
207
  - Only new files from source are added
208
+ - `npx ai-nexus update` syncs new rules from the latest package
231
209
  - Use `--force` to override (backup first!)
232
210
 
233
211
  ```bash
@@ -238,6 +216,8 @@ npx ai-nexus update
238
216
  npx ai-nexus update --force
239
217
  ```
240
218
 
219
+ > **Migrating from symlink mode?** Just run `npx ai-nexus update` — symlinks are automatically converted to copies.
220
+
241
221
  ---
242
222
 
243
223
  ## Directory Structure
@@ -251,8 +231,8 @@ npx ai-nexus update --force
251
231
  .claude/ # Claude Code
252
232
  ├── hooks/semantic-router.cjs
253
233
  ├── settings.json
254
- ├── rules/ .ai-nexus/config/rules
255
- └── commands/ .ai-nexus/config/commands
234
+ ├── rules/ # Copied from .ai-nexus/config/rules
235
+ └── commands/ # Copied from .ai-nexus/config/commands
256
236
 
257
237
  .cursor/rules/ # Cursor (.mdc files)
258
238
  ├── essential.mdc
@@ -272,7 +252,6 @@ npx ai-nexus install
272
252
  # Select: Claude Code, Cursor
273
253
  # Select: rules, commands, hooks, settings
274
254
  # Template: React/Next.js
275
- # Mode: symlink
276
255
  ```
277
256
 
278
257
  ### Team Setup
package/bin/ai-rules.cjs CHANGED
@@ -16,7 +16,6 @@ program
16
16
  .command('init')
17
17
  .description('Initialize rules in current project (.claude/, .codex/)')
18
18
  .option('--rules <url>', 'Git repository URL for rules (e.g., github.com/org/my-rules)')
19
- .option('--copy', 'Copy files instead of symlink')
20
19
  .option('-q, --quick', 'Quick mode (skip interactive wizard)')
21
20
  .option('-y, --yes', 'Skip prompts and use defaults')
22
21
  .action(async (options) => {
@@ -33,7 +32,6 @@ program
33
32
  .command('install')
34
33
  .description('Install rules globally (~/.claude/, ~/.codex/)')
35
34
  .option('--rules <url>', 'Git repository URL for rules')
36
- .option('--copy', 'Copy files instead of symlink')
37
35
  .option('-q, --quick', 'Quick mode (skip interactive wizard)')
38
36
  .action(async (options) => {
39
37
  if (options.quick || options.rules) {
@@ -15,15 +15,14 @@ export async function doctor() {
15
15
  status: 'ok',
16
16
  message: `Found ${install.scope} installation at ${install.configPath}`,
17
17
  });
18
- // Check mode
19
18
  const metaPath = path.join(install.configPath, 'meta.json');
20
19
  if (fs.existsSync(metaPath)) {
21
20
  try {
22
- const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
21
+ JSON.parse(fs.readFileSync(metaPath, 'utf8'));
23
22
  results.push({
24
- name: 'Mode',
23
+ name: 'Metadata',
25
24
  status: 'ok',
26
- message: `${meta.mode} mode`,
25
+ message: 'meta.json valid',
27
26
  });
28
27
  }
29
28
  catch {
@@ -84,7 +83,7 @@ export async function doctor() {
84
83
  name: dir.label,
85
84
  status: 'warn',
86
85
  message: `Missing: ${missing.join(', ')}`,
87
- fix: 'Run: ai-nexus install --copy',
86
+ fix: 'Run: ai-nexus install',
88
87
  });
89
88
  }
90
89
  }
@@ -5,7 +5,7 @@ import { createRequire } from 'module';
5
5
  import inquirer from 'inquirer';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
- import { getTargetDir, getConfigPath, ensureDir, createSymlink, scanDir, computeFileHashes, aggregateToAgentsMd, } from '../utils/files.js';
8
+ import { getTargetDir, getConfigPath, ensureDir, scanDir, computeFileHashes, aggregateToAgentsMd, } from '../utils/files.js';
9
9
  import { scanConfigDir } from '../utils/config-scanner.js';
10
10
  // Convert .md to .mdc format for Cursor
11
11
  function convertToMdc(content, filename) {
@@ -135,30 +135,11 @@ export async function initInteractive() {
135
135
  choices: TEMPLATES,
136
136
  },
137
137
  ]);
138
- // Step 6: Select install method
139
- const { method } = await inquirer.prompt([
140
- {
141
- type: 'list',
142
- name: 'method',
143
- message: 'Select install method',
144
- choices: [
145
- {
146
- name: '🔗 symlink (auto-update via ai-nexus update)',
147
- value: 'symlink',
148
- },
149
- {
150
- name: '📄 copy (independent copy)',
151
- value: 'copy',
152
- },
153
- ],
154
- },
155
- ]);
156
- // Step 7: Confirmation
138
+ // Step 6: Confirmation
157
139
  console.log(chalk.cyan('\n📋 Installation Summary\n'));
158
140
  console.log(chalk.gray('─'.repeat(40)));
159
141
  console.log(` Scope: ${scope === 'global' ? 'Global (~/)' : 'Project (./)'}`);
160
142
  console.log(` Tools: ${tools.join(', ')}`);
161
- console.log(` Method: ${method === 'symlink' ? 'symlink' : 'copy'}`);
162
143
  console.log(` Template: ${template || 'None'}`);
163
144
  console.log(` Categories:`);
164
145
  let totalFiles = 0;
@@ -190,7 +171,6 @@ export async function initInteractive() {
190
171
  categories,
191
172
  selectedFiles,
192
173
  template,
193
- method,
194
174
  });
195
175
  spinner.succeed('Installation complete!');
196
176
  }
@@ -212,16 +192,12 @@ export async function initInteractive() {
212
192
  if (tools.includes('cursor')) {
213
193
  console.log(` Cursor: ${path.join(targetDir, '.cursor/rules')}`);
214
194
  }
215
- console.log(` Mode: ${method}`);
216
195
  if (template) {
217
196
  console.log(` Template: ${template}`);
218
197
  }
219
198
  console.log(chalk.gray('─'.repeat(40)));
220
199
  // Next steps
221
200
  console.log(chalk.cyan('\n📋 Next Steps:\n'));
222
- if (method === 'symlink') {
223
- console.log(chalk.white(' 1. Run "ai-nexus update" to sync latest rules'));
224
- }
225
201
  const hasApiKey = !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
226
202
  if (!hasApiKey && tools.includes('claude') && categories.includes('hooks')) {
227
203
  console.log(chalk.white(' 2. Enable AI-powered rule selection (optional):'));
@@ -235,7 +211,7 @@ export async function initInteractive() {
235
211
  console.log();
236
212
  }
237
213
  async function install(selections) {
238
- const { scope, tools, categories, selectedFiles, template, method } = selections;
214
+ const { scope, tools, categories, selectedFiles, template } = selections;
239
215
  const targetDir = getTargetDir(scope);
240
216
  const aiRulesDir = getConfigPath(scope);
241
217
  const configDir = path.join(aiRulesDir, 'config');
@@ -298,42 +274,36 @@ async function install(selections) {
298
274
  const targetPath = path.join(toolDir, category);
299
275
  if (!fs.existsSync(sourceDir))
300
276
  continue;
301
- // Local priority: skip if directory exists and is not a symlink
277
+ // Local priority: skip if directory already exists
302
278
  if (fs.existsSync(targetPath)) {
303
279
  try {
304
280
  const stat = fs.lstatSync(targetPath);
305
- if (!stat.isSymbolicLink()) {
281
+ if (stat.isSymbolicLink()) {
282
+ // Remove existing symlink to replace with copy
283
+ fs.unlinkSync(targetPath);
284
+ }
285
+ else {
306
286
  // Existing directory - only add new files
307
- if (method === 'copy') {
308
- const files = scanDir(sourceDir);
309
- for (const [rel, content] of Object.entries(files)) {
310
- const dest = path.join(targetPath, rel);
311
- if (!fs.existsSync(dest)) {
312
- ensureDir(path.dirname(dest));
313
- fs.writeFileSync(dest, content);
314
- }
287
+ const files = scanDir(sourceDir);
288
+ for (const [rel, content] of Object.entries(files)) {
289
+ const dest = path.join(targetPath, rel);
290
+ if (!fs.existsSync(dest)) {
291
+ ensureDir(path.dirname(dest));
292
+ fs.writeFileSync(dest, content);
315
293
  }
316
294
  }
317
295
  continue;
318
296
  }
319
- // Symlink - remove and recreate
320
- fs.unlinkSync(targetPath);
321
297
  }
322
298
  catch {
323
299
  // Ignore errors
324
300
  }
325
301
  }
326
- if (method === 'symlink') {
327
- createSymlink(sourceDir, targetPath);
328
- }
329
- else {
330
- // Copy mode
331
- const files = scanDir(sourceDir);
332
- for (const [rel, content] of Object.entries(files)) {
333
- const dest = path.join(targetPath, rel);
334
- ensureDir(path.dirname(dest));
335
- fs.writeFileSync(dest, content);
336
- }
302
+ const files = scanDir(sourceDir);
303
+ for (const [rel, content] of Object.entries(files)) {
304
+ const dest = path.join(targetPath, rel);
305
+ ensureDir(path.dirname(dest));
306
+ fs.writeFileSync(dest, content);
337
307
  }
338
308
  }
339
309
  // Install hooks for Claude Code
@@ -386,16 +356,15 @@ async function install(selections) {
386
356
  }
387
357
  }
388
358
  }
389
- // Save metadata (compute file hashes for copy mode conflict detection)
359
+ // Save metadata
390
360
  const claudeDir = path.join(targetDir, '.claude');
391
361
  const meta = {
392
362
  version: require(path.join(PACKAGE_ROOT, 'package.json')).version,
393
- mode: method,
394
363
  tools,
395
364
  template,
396
365
  sources: [{ name: 'builtin', type: 'builtin' }],
397
366
  selectedFiles,
398
- ...(method === 'copy' ? { fileHashes: computeFileHashes(claudeDir) } : {}),
367
+ fileHashes: computeFileHashes(claudeDir),
399
368
  createdAt: new Date().toISOString(),
400
369
  updatedAt: new Date().toISOString(),
401
370
  };
@@ -1,7 +1,6 @@
1
1
  interface InitOptions {
2
2
  scope: 'project' | 'global';
3
3
  rules?: string;
4
- copy?: boolean;
5
4
  }
6
5
  export declare function init(options: InitOptions): Promise<void>;
7
6
  export {};
@@ -3,17 +3,16 @@ import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { createRequire } from 'module';
5
5
  import chalk from 'chalk';
6
- import { getTargetDir, getConfigPath, ensureDir, createSymlink, scanDir, computeFileHashes, } from '../utils/files.js';
6
+ import { getTargetDir, getConfigPath, ensureDir, scanDir, computeFileHashes, } from '../utils/files.js';
7
7
  import { cloneRepo, getRepoName, normalizeGitUrl } from '../utils/git.js';
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const PACKAGE_ROOT = path.resolve(__dirname, '../..');
10
10
  const require = createRequire(import.meta.url);
11
11
  export async function init(options) {
12
- const { scope, rules: rulesUrl, copy: copyMode } = options;
12
+ const { scope, rules: rulesUrl } = options;
13
13
  const targetDir = getTargetDir(scope);
14
14
  const aiRulesDir = getConfigPath(scope);
15
15
  const configDir = path.join(aiRulesDir, 'config');
16
- const mode = copyMode ? 'copy' : 'symlink';
17
16
  console.log(chalk.bold(`\n ai-nexus ${scope === 'global' ? 'global' : 'project'} setup\n`));
18
17
  // Create .ai-nexus directory
19
18
  ensureDir(aiRulesDir);
@@ -66,35 +65,30 @@ export async function init(options) {
66
65
  if (!fs.existsSync(sourceDir))
67
66
  continue;
68
67
  const fileCount = countFiles(sourceDir);
69
- // Local priority: skip if directory exists and is not a symlink
68
+ // Local priority: skip if directory already exists
70
69
  if (fs.existsSync(targetPath)) {
71
70
  try {
72
71
  const stat = fs.lstatSync(targetPath);
73
- if (!stat.isSymbolicLink()) {
72
+ if (stat.isSymbolicLink()) {
73
+ // Remove existing symlink to replace with copy
74
+ fs.unlinkSync(targetPath);
75
+ }
76
+ else {
74
77
  results.push({ name: category, action: 'skipped', fileCount });
75
78
  continue;
76
79
  }
77
- // Remove existing symlink to recreate
78
- fs.unlinkSync(targetPath);
79
80
  }
80
81
  catch {
81
82
  // Ignore errors
82
83
  }
83
84
  }
84
- if (mode === 'symlink') {
85
- createSymlink(sourceDir, targetPath);
86
- results.push({ name: category, action: 'symlink', fileCount });
87
- }
88
- else {
89
- // Copy mode
90
- const files = scanDir(sourceDir);
91
- for (const [rel, content] of Object.entries(files)) {
92
- const dest = path.join(targetPath, rel);
93
- ensureDir(path.dirname(dest));
94
- fs.writeFileSync(dest, content);
95
- }
96
- results.push({ name: category, action: 'copied', fileCount });
85
+ const files = scanDir(sourceDir);
86
+ for (const [rel, content] of Object.entries(files)) {
87
+ const dest = path.join(targetPath, rel);
88
+ ensureDir(path.dirname(dest));
89
+ fs.writeFileSync(dest, content);
97
90
  }
91
+ results.push({ name: category, action: 'copied', fileCount });
98
92
  }
99
93
  // Install hooks
100
94
  const hooksSourceDir = path.join(configDir, 'hooks');
@@ -132,22 +126,20 @@ export async function init(options) {
132
126
  // Save metadata
133
127
  const meta = {
134
128
  version: require(path.join(PACKAGE_ROOT, 'package.json')).version,
135
- mode,
136
129
  sources,
137
- ...(mode === 'copy' ? { fileHashes: computeFileHashes(claudeDir) } : {}),
130
+ fileHashes: computeFileHashes(claudeDir),
138
131
  createdAt: new Date().toISOString(),
139
132
  updatedAt: new Date().toISOString(),
140
133
  };
141
134
  fs.writeFileSync(path.join(aiRulesDir, 'meta.json'), JSON.stringify(meta, null, 2));
142
135
  // Print structured summary
143
- printSummary(claudeDir, mode, results);
136
+ printSummary(claudeDir, results);
144
137
  }
145
- function printSummary(claudeDir, mode, results) {
138
+ function printSummary(claudeDir, results) {
146
139
  const installed = results.filter(r => r.action !== 'skipped');
147
140
  const skipped = results.filter(r => r.action === 'skipped');
148
141
  console.log(chalk.green.bold('\n Setup complete!\n'));
149
- console.log(` Location: ${claudeDir}`);
150
- console.log(` Mode: ${mode}\n`);
142
+ console.log(` Location: ${claudeDir}\n`);
151
143
  // Installed items
152
144
  if (installed.length > 0) {
153
145
  console.log(chalk.bold(' Installed:'));
@@ -1,11 +1,14 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { fileURLToPath } from 'url';
4
5
  import chalk from 'chalk';
5
6
  import inquirer from 'inquirer';
6
7
  import { detectInstall, scanDir, compareConfigs, ensureDir, computeFileHashes, aggregateToAgentsMd } from '../utils/files.js';
7
8
  import crypto from 'crypto';
8
9
  import { updateRepo } from '../utils/git.js';
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const PACKAGE_ROOT = path.resolve(__dirname, '../..');
9
12
  export async function update(options = {}) {
10
13
  const install = detectInstall();
11
14
  if (!install) {
@@ -23,10 +26,40 @@ export async function update(options = {}) {
23
26
  process.exit(1);
24
27
  }
25
28
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
26
- console.log(` Mode: ${meta.mode}`);
27
29
  console.log(` Sources: ${meta.sources.map(s => s.name).join(', ')}\n`);
28
- // Update external sources
30
+ const configDir = path.join(configPath, 'config');
31
+ const targetDir = scope === 'global' ? os.homedir() : process.cwd();
32
+ const claudeDir = path.join(targetDir, '.claude');
33
+ // Migrate from symlink if needed
34
+ if (meta.mode === 'symlink') {
35
+ migrateFromSymlink(claudeDir, meta, metaPath);
36
+ }
29
37
  let hasChanges = false;
38
+ // Step 1: Sync new builtin files to .ai-nexus/config/
39
+ const builtinConfigDir = path.join(PACKAGE_ROOT, 'config');
40
+ const categories = ['rules', 'commands', 'skills', 'agents', 'contexts', 'hooks'];
41
+ let builtinAdded = 0;
42
+ for (const category of categories) {
43
+ const srcDir = path.join(builtinConfigDir, category);
44
+ if (!fs.existsSync(srcDir))
45
+ continue;
46
+ const destDir = path.join(configDir, category);
47
+ ensureDir(destDir);
48
+ const srcFiles = scanDir(srcDir);
49
+ for (const [rel, content] of Object.entries(srcFiles)) {
50
+ const dest = path.join(destDir, rel);
51
+ if (!fs.existsSync(dest)) {
52
+ ensureDir(path.dirname(dest));
53
+ fs.writeFileSync(dest, content);
54
+ builtinAdded++;
55
+ }
56
+ }
57
+ }
58
+ if (builtinAdded > 0) {
59
+ console.log(chalk.green(` + ${builtinAdded} new builtin files synced`));
60
+ hasChanges = true;
61
+ }
62
+ // Step 2: Update external sources and re-merge new files
30
63
  const sourcesDir = path.join(configPath, 'sources');
31
64
  for (const source of meta.sources) {
32
65
  if (source.type === 'external' && source.url) {
@@ -41,32 +74,47 @@ export async function update(options = {}) {
41
74
  else {
42
75
  console.log(` - Already up to date`);
43
76
  }
77
+ // Re-merge new files from external source to config
78
+ const externalConfigDir = fs.existsSync(path.join(repoPath, 'config'))
79
+ ? path.join(repoPath, 'config')
80
+ : repoPath;
81
+ let extAdded = 0;
82
+ for (const category of categories) {
83
+ const srcCat = path.join(externalConfigDir, category);
84
+ if (!fs.existsSync(srcCat))
85
+ continue;
86
+ const destCat = path.join(configDir, category);
87
+ ensureDir(destCat);
88
+ const srcFiles = scanDir(srcCat);
89
+ for (const [rel, content] of Object.entries(srcFiles)) {
90
+ const prefixed = `${source.name}-${path.basename(rel)}`;
91
+ const destRel = path.join(path.dirname(rel), prefixed);
92
+ const dest = path.join(destCat, destRel === prefixed ? prefixed : destRel);
93
+ if (!fs.existsSync(dest)) {
94
+ ensureDir(path.dirname(dest));
95
+ fs.writeFileSync(dest, content);
96
+ extAdded++;
97
+ }
98
+ }
99
+ }
100
+ if (extAdded > 0) {
101
+ console.log(chalk.green(` + ${extAdded} new files from ${source.name}`));
102
+ hasChanges = true;
103
+ }
44
104
  }
45
105
  }
46
106
  }
47
- // Sync config to .claude/
48
- const configDir = path.join(configPath, 'config');
49
- const targetDir = scope === 'global'
50
- ? os.homedir()
51
- : process.cwd();
52
- const claudeDir = path.join(targetDir, '.claude');
53
- if (meta.mode === 'symlink') {
54
- const hasExternal = meta.sources.some(s => s.type === 'external');
55
- if (!hasExternal && !hasChanges) {
56
- console.log(chalk.gray(' Symlink mode with built-in rules only.'));
57
- console.log(chalk.gray(' Built-in rules update when you run:'));
58
- console.log(chalk.bold(' npm update -g ai-nexus\n'));
59
- }
60
- }
61
- if (meta.mode === 'copy') {
62
- // Copy mode: compare and update files
63
- const sourceFiles = scanDir(configDir);
64
- const installedFiles = scanDir(claudeDir);
65
- const diff = compareConfigs(sourceFiles, installedFiles);
66
- if (diff.added.length === 0 && diff.modified.length === 0 && diff.removed.length === 0) {
107
+ // Step 3: Compare .ai-nexus/config vs .claude/ and sync
108
+ const sourceFiles = scanDir(configDir);
109
+ const installedFiles = scanDir(claudeDir);
110
+ const diff = compareConfigs(sourceFiles, installedFiles);
111
+ if (diff.added.length === 0 && diff.modified.length === 0 && diff.removed.length === 0) {
112
+ if (!hasChanges) {
67
113
  console.log('\n✅ Already up to date!\n');
68
114
  return;
69
115
  }
116
+ }
117
+ else {
70
118
  console.log('\n Changes detected:');
71
119
  if (diff.added.length > 0)
72
120
  console.log(chalk.green(` + ${diff.added.length} new files`));
@@ -74,113 +122,128 @@ export async function update(options = {}) {
74
122
  console.log(chalk.yellow(` ~ ${diff.modified.length} modified files`));
75
123
  if (diff.removed.length > 0)
76
124
  console.log(chalk.red(` - ${diff.removed.length} removed in source`));
77
- // Determine which files to update
78
- const filesToAdd = diff.added;
79
- let filesToUpdate;
80
- let filesToRemove = [];
81
- if (options.force) {
82
- // Force mode: update everything, but warn about user-edited files
83
- const userEdited = detectUserEdits(diff.modified, claudeDir, meta.fileHashes);
84
- if (userEdited.length > 0) {
85
- console.log(chalk.red(`\n WARNING: ${userEdited.length} file(s) have local edits that will be overwritten:`));
86
- for (const f of userEdited) {
87
- console.log(chalk.red(` - ${f}`));
88
- }
89
- const { proceed } = await inquirer.prompt([
90
- {
91
- type: 'confirm',
92
- name: 'proceed',
93
- message: 'Overwrite these user-edited files?',
94
- default: false,
95
- },
96
- ]);
97
- if (!proceed) {
98
- filesToUpdate = diff.modified.filter(f => !userEdited.includes(f));
99
- }
100
- else {
101
- filesToUpdate = diff.modified;
102
- }
125
+ }
126
+ // Determine which files to update
127
+ const filesToAdd = diff.added;
128
+ let filesToUpdate;
129
+ let filesToRemove = [];
130
+ if (options.force) {
131
+ const userEdited = detectUserEdits(diff.modified, claudeDir, meta.fileHashes);
132
+ if (userEdited.length > 0) {
133
+ console.log(chalk.red(`\n WARNING: ${userEdited.length} file(s) have local edits that will be overwritten:`));
134
+ for (const f of userEdited) {
135
+ console.log(chalk.red(` - ${f}`));
136
+ }
137
+ const { proceed } = await inquirer.prompt([
138
+ {
139
+ type: 'confirm',
140
+ name: 'proceed',
141
+ message: 'Overwrite these user-edited files?',
142
+ default: false,
143
+ },
144
+ ]);
145
+ if (!proceed) {
146
+ filesToUpdate = diff.modified.filter(f => !userEdited.includes(f));
103
147
  }
104
148
  else {
105
149
  filesToUpdate = diff.modified;
106
150
  }
107
- filesToRemove = diff.removed;
108
151
  }
109
- else if (options.addOnly) {
110
- // Add-only mode: only add new files
111
- filesToUpdate = [];
112
- filesToRemove = [];
152
+ else {
153
+ filesToUpdate = diff.modified;
113
154
  }
114
- else if (options.interactive && diff.modified.length > 0) {
115
- // Interactive mode: ask for each modified file
116
- console.log(chalk.cyan('\n Modified files (choose which to overwrite):\n'));
117
- const { selectedFiles } = await inquirer.prompt([
155
+ filesToRemove = diff.removed;
156
+ }
157
+ else if (options.addOnly) {
158
+ filesToUpdate = [];
159
+ filesToRemove = [];
160
+ }
161
+ else if (options.interactive && diff.modified.length > 0) {
162
+ console.log(chalk.cyan('\n Modified files (choose which to overwrite):\n'));
163
+ const { selectedFiles } = await inquirer.prompt([
164
+ {
165
+ type: 'checkbox',
166
+ name: 'selectedFiles',
167
+ message: 'Select files to overwrite',
168
+ choices: diff.modified.map(f => ({
169
+ name: f,
170
+ value: f,
171
+ checked: false,
172
+ })),
173
+ },
174
+ ]);
175
+ filesToUpdate = selectedFiles;
176
+ if (diff.removed.length > 0) {
177
+ const { removeFiles } = await inquirer.prompt([
118
178
  {
119
- type: 'checkbox',
120
- name: 'selectedFiles',
121
- message: 'Select files to overwrite',
122
- choices: diff.modified.map(f => ({
123
- name: f,
124
- value: f,
125
- checked: false,
126
- })),
179
+ type: 'confirm',
180
+ name: 'removeFiles',
181
+ message: `Remove ${diff.removed.length} files that no longer exist in source?`,
182
+ default: false,
127
183
  },
128
184
  ]);
129
- filesToUpdate = selectedFiles;
130
- if (diff.removed.length > 0) {
131
- const { removeFiles } = await inquirer.prompt([
132
- {
133
- type: 'confirm',
134
- name: 'removeFiles',
135
- message: `Remove ${diff.removed.length} files that no longer exist in source?`,
136
- default: false,
137
- },
138
- ]);
139
- filesToRemove = removeFiles ? diff.removed : [];
140
- }
185
+ filesToRemove = removeFiles ? diff.removed : [];
141
186
  }
142
- else {
143
- // Default: add new files, skip modified, keep removed
144
- // (merge mode)
145
- filesToUpdate = [];
146
- filesToRemove = [];
147
- if (diff.modified.length > 0) {
148
- console.log(chalk.gray(`\n Skipping ${diff.modified.length} modified files (use --force to overwrite)`));
149
- }
150
- }
151
- // Apply changes
152
- let addedCount = 0;
153
- let updatedCount = 0;
154
- let removedCount = 0;
155
- for (const rel of filesToAdd) {
156
- const src = path.join(configDir, rel);
157
- const dest = path.join(claudeDir, rel);
158
- ensureDir(path.dirname(dest));
159
- fs.copyFileSync(src, dest);
160
- addedCount++;
187
+ }
188
+ else {
189
+ filesToUpdate = [];
190
+ filesToRemove = [];
191
+ if (diff.modified.length > 0) {
192
+ console.log(chalk.gray(`\n Skipping ${diff.modified.length} modified files (use --force to overwrite)`));
161
193
  }
162
- for (const rel of filesToUpdate) {
163
- const src = path.join(configDir, rel);
164
- const dest = path.join(claudeDir, rel);
165
- ensureDir(path.dirname(dest));
166
- fs.copyFileSync(src, dest);
167
- updatedCount++;
194
+ }
195
+ // Apply changes
196
+ let addedCount = 0;
197
+ let updatedCount = 0;
198
+ let removedCount = 0;
199
+ for (const rel of filesToAdd) {
200
+ const src = path.join(configDir, rel);
201
+ const dest = path.join(claudeDir, rel);
202
+ ensureDir(path.dirname(dest));
203
+ fs.copyFileSync(src, dest);
204
+ addedCount++;
205
+ }
206
+ for (const rel of filesToUpdate) {
207
+ const src = path.join(configDir, rel);
208
+ const dest = path.join(claudeDir, rel);
209
+ ensureDir(path.dirname(dest));
210
+ fs.copyFileSync(src, dest);
211
+ updatedCount++;
212
+ }
213
+ for (const rel of filesToRemove) {
214
+ const dest = path.join(claudeDir, rel);
215
+ if (fs.existsSync(dest)) {
216
+ fs.unlinkSync(dest);
217
+ removedCount++;
168
218
  }
169
- for (const rel of filesToRemove) {
170
- const dest = path.join(claudeDir, rel);
171
- if (fs.existsSync(dest)) {
172
- fs.unlinkSync(dest);
173
- removedCount++;
219
+ }
220
+ if (addedCount > 0 || updatedCount > 0 || removedCount > 0) {
221
+ console.log('\n Applied:');
222
+ if (addedCount > 0)
223
+ console.log(chalk.green(` + ${addedCount} files added`));
224
+ if (updatedCount > 0)
225
+ console.log(chalk.yellow(` ~ ${updatedCount} files updated`));
226
+ if (removedCount > 0)
227
+ console.log(chalk.red(` - ${removedCount} files removed`));
228
+ hasChanges = true;
229
+ }
230
+ // Step 4: Sync hooks to .claude/hooks/ (new files only)
231
+ const hooksConfigDir = path.join(configDir, 'hooks');
232
+ const hooksTargetDir = path.join(claudeDir, 'hooks');
233
+ if (fs.existsSync(hooksConfigDir)) {
234
+ ensureDir(hooksTargetDir);
235
+ let hooksAdded = 0;
236
+ const hookFiles = scanDir(hooksConfigDir);
237
+ for (const [rel, content] of Object.entries(hookFiles)) {
238
+ const dest = path.join(hooksTargetDir, rel);
239
+ if (!fs.existsSync(dest)) {
240
+ ensureDir(path.dirname(dest));
241
+ fs.writeFileSync(dest, content);
242
+ hooksAdded++;
174
243
  }
175
244
  }
176
- if (addedCount > 0 || updatedCount > 0 || removedCount > 0) {
177
- console.log('\n Applied:');
178
- if (addedCount > 0)
179
- console.log(chalk.green(` + ${addedCount} files added`));
180
- if (updatedCount > 0)
181
- console.log(chalk.yellow(` ~ ${updatedCount} files updated`));
182
- if (removedCount > 0)
183
- console.log(chalk.red(` - ${removedCount} files removed`));
245
+ if (hooksAdded > 0) {
246
+ console.log(chalk.green(` + ${hooksAdded} new hooks added`));
184
247
  hasChanges = true;
185
248
  }
186
249
  }
@@ -210,11 +273,10 @@ export async function update(options = {}) {
210
273
  hasChanges = true;
211
274
  }
212
275
  }
213
- // Update metadata (refresh file hashes for copy mode)
276
+ // Update metadata
214
277
  meta.updatedAt = new Date().toISOString();
215
- if (meta.mode === 'copy') {
216
- meta.fileHashes = computeFileHashes(claudeDir);
217
- }
278
+ delete meta.mode; // remove deprecated field
279
+ meta.fileHashes = computeFileHashes(claudeDir);
218
280
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
219
281
  if (hasChanges) {
220
282
  console.log('\n✅ Update complete!\n');
@@ -223,6 +285,44 @@ export async function update(options = {}) {
223
285
  console.log('\n✅ Already up to date!\n');
224
286
  }
225
287
  }
288
+ function migrateFromSymlink(claudeDir, meta, metaPath) {
289
+ console.log(chalk.yellow(' ⚡ Migrating from symlink to copy mode...\n'));
290
+ const categories = ['rules', 'commands', 'skills', 'agents', 'contexts'];
291
+ let migrated = 0;
292
+ for (const category of categories) {
293
+ const targetPath = path.join(claudeDir, category);
294
+ if (!fs.existsSync(targetPath))
295
+ continue;
296
+ try {
297
+ const stat = fs.lstatSync(targetPath);
298
+ if (!stat.isSymbolicLink())
299
+ continue;
300
+ // Read files through symlink before removing it
301
+ const files = scanDir(targetPath);
302
+ // Remove symlink
303
+ fs.unlinkSync(targetPath);
304
+ // Create directory and copy files
305
+ ensureDir(targetPath);
306
+ for (const [rel, content] of Object.entries(files)) {
307
+ const dest = path.join(targetPath, rel);
308
+ ensureDir(path.dirname(dest));
309
+ fs.writeFileSync(dest, content);
310
+ }
311
+ migrated++;
312
+ }
313
+ catch {
314
+ // Skip on error
315
+ }
316
+ }
317
+ // Update meta
318
+ delete meta.mode;
319
+ meta.fileHashes = computeFileHashes(claudeDir);
320
+ meta.updatedAt = new Date().toISOString();
321
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
322
+ if (migrated > 0) {
323
+ console.log(chalk.green(` ✓ Migrated ${migrated} symlinks to copies\n`));
324
+ }
325
+ }
226
326
  function detectUserEdits(modifiedFiles, claudeDir, savedHashes) {
227
327
  if (!savedHashes)
228
328
  return [];
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@ export interface MetaSource {
5
5
  }
6
6
  export interface DotrulesMeta {
7
7
  version: string;
8
- mode: 'symlink' | 'copy';
8
+ mode?: 'symlink' | 'copy';
9
9
  sources: MetaSource[];
10
10
  tools?: string[];
11
11
  template?: string | null;
@@ -14,7 +14,6 @@ export declare function getTargetDir(scope: 'project' | 'global'): string;
14
14
  export declare function getConfigPath(scope: 'project' | 'global'): string;
15
15
  export declare function ensureDir(dir: string): void;
16
16
  export declare function copyFile(src: string, dest: string): void;
17
- export declare function createSymlink(target: string, linkPath: string): void;
18
17
  /**
19
18
  * Aggregate rule files into a single AGENTS.md content string.
20
19
  * If selectedFiles is provided, only those files are included.
@@ -76,13 +76,6 @@ export function copyFile(src, dest) {
76
76
  ensureDir(path.dirname(dest));
77
77
  fs.copyFileSync(src, dest);
78
78
  }
79
- export function createSymlink(target, linkPath) {
80
- ensureDir(path.dirname(linkPath));
81
- if (fs.existsSync(linkPath)) {
82
- fs.rmSync(linkPath, { recursive: true });
83
- }
84
- fs.symlinkSync(target, linkPath);
85
- }
86
79
  const AGENTS_CATEGORIES = ['rules', 'commands', 'skills', 'agents', 'contexts'];
87
80
  const CATEGORY_LABELS = {
88
81
  rules: 'Rules',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-nexus",
3
- "version": "1.3.20",
3
+ "version": "1.4.0",
4
4
  "description": "Unified rule manager for Claude Code, Cursor, and Codex - write once, use everywhere, save tokens",
5
5
  "main": "dist/index.js",
6
6
  "bin": {