ai-nexus 1.3.21 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -136,6 +136,8 @@ alwaysApply: false
136
136
 
137
137
  개별 룰 파일들이 단일 `AGENTS.md` 파일로 자동 병합되며, 세션 시작 시 로드됩니다. 동적 로딩 없음.
138
138
 
139
+ > **Codex 사용자: 필요한 룰만 선택하세요.** 모든 룰이 매 세션마다 로딩되므로, 너무 많이 설치하면 토큰이 낭비됩니다. 대화형 설치 마법사(`npx ai-nexus install`)에서 필요한 카테고리와 파일만 선택하세요. 권장 시작 세트: `rules/essential.md`, `rules/commit.md`, `rules/security.md`.
140
+
139
141
  ---
140
142
 
141
143
  ## 명령어
@@ -199,35 +201,13 @@ description: 이 룰을 로드할 시점 (시맨틱 라우터가 사용)
199
201
 
200
202
  ---
201
203
 
202
- ## 설치 모드
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
- ## 로컬 우선
204
+ ## 업데이트 & 로컬 우선
226
205
 
227
- 사용자의 커스터마이징은 항상 안전합니다:
206
+ 룰은 독립적인 복사본으로 설치됩니다. 사용자의 커스터마이징은 항상 안전합니다:
228
207
 
229
208
  - **기존 파일은 절대 덮어쓰지 않음** (install, update 모두)
230
209
  - 소스에서 새 파일만 추가
210
+ - `npx ai-nexus update`로 최신 패키지의 새 룰을 동기화
231
211
  - `--force`로 강제 덮어쓰기 (백업 먼저!)
232
212
 
233
213
  ```bash
@@ -238,6 +218,8 @@ npx ai-nexus update
238
218
  npx ai-nexus update --force
239
219
  ```
240
220
 
221
+ > **symlink 모드에서 마이그레이션?** `npx ai-nexus update`만 실행하면 symlink이 자동으로 복사본으로 변환됩니다.
222
+
241
223
  ---
242
224
 
243
225
  ## 디렉토리 구조
@@ -251,8 +233,8 @@ npx ai-nexus update --force
251
233
  .claude/ # Claude Code
252
234
  ├── hooks/semantic-router.cjs
253
235
  ├── settings.json
254
- ├── rules/ .ai-nexus/config/rules
255
- └── commands/ .ai-nexus/config/commands
236
+ ├── rules/ # .ai-nexus/config/rules에서 복사
237
+ └── commands/ # .ai-nexus/config/commands에서 복사
256
238
 
257
239
  .cursor/rules/ # Cursor (.mdc 파일)
258
240
  ├── essential.mdc
@@ -272,7 +254,6 @@ npx ai-nexus install
272
254
  # 선택: Claude Code, Cursor
273
255
  # 선택: rules, commands, hooks, settings
274
256
  # 템플릿: React/Next.js
275
- # 모드: symlink
276
257
  ```
277
258
 
278
259
  ### 팀 설정
package/README.md CHANGED
@@ -136,6 +136,8 @@ After conversion, **Cursor's built-in semantic search** handles rule filtering
136
136
 
137
137
  Individual rule files are aggregated into a single `AGENTS.md` file, which is loaded at session start. No dynamic loading.
138
138
 
139
+ > **Codex users: select only the rules you need.** Since all rules are loaded every session, installing too many wastes tokens. Use the interactive wizard (`npx ai-nexus install`) to pick only relevant categories and files. Recommended starting set: `rules/essential.md`, `rules/commit.md`, `rules/security.md`.
140
+
139
141
  ---
140
142
 
141
143
  ## Commands
@@ -199,35 +201,13 @@ Your rule content...
199
201
 
200
202
  ---
201
203
 
202
- ## Installation Modes
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
204
+ ## Update & Local Priority
226
205
 
227
- Your customizations are always safe:
206
+ Rules are installed as independent copies. Your customizations are always safe:
228
207
 
229
208
  - **Existing files are never overwritten** during install or update
230
209
  - Only new files from source are added
210
+ - `npx ai-nexus update` syncs new rules from the latest package
231
211
  - Use `--force` to override (backup first!)
232
212
 
233
213
  ```bash
@@ -238,6 +218,8 @@ npx ai-nexus update
238
218
  npx ai-nexus update --force
239
219
  ```
240
220
 
221
+ > **Migrating from symlink mode?** Just run `npx ai-nexus update` — symlinks are automatically converted to copies.
222
+
241
223
  ---
242
224
 
243
225
  ## Directory Structure
@@ -251,8 +233,8 @@ npx ai-nexus update --force
251
233
  .claude/ # Claude Code
252
234
  ├── hooks/semantic-router.cjs
253
235
  ├── settings.json
254
- ├── rules/ .ai-nexus/config/rules
255
- └── commands/ .ai-nexus/config/commands
236
+ ├── rules/ # Copied from .ai-nexus/config/rules
237
+ └── commands/ # Copied from .ai-nexus/config/commands
256
238
 
257
239
  .cursor/rules/ # Cursor (.mdc files)
258
240
  ├── essential.mdc
@@ -272,7 +254,6 @@ npx ai-nexus install
272
254
  # Select: Claude Code, Cursor
273
255
  # Select: rules, commands, hooks, settings
274
256
  # Template: React/Next.js
275
- # Mode: symlink
276
257
  ```
277
258
 
278
259
  ### 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:'));
@@ -26,10 +26,40 @@ export async function update(options = {}) {
26
26
  process.exit(1);
27
27
  }
28
28
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
29
- console.log(` Mode: ${meta.mode}`);
30
29
  console.log(` Sources: ${meta.sources.map(s => s.name).join(', ')}\n`);
31
- // 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
+ }
32
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
33
63
  const sourcesDir = path.join(configPath, 'sources');
34
64
  for (const source of meta.sources) {
35
65
  if (source.type === 'external' && source.url) {
@@ -44,50 +74,47 @@ export async function update(options = {}) {
44
74
  else {
45
75
  console.log(` - Already up to date`);
46
76
  }
47
- }
48
- }
49
- }
50
- // Sync config to .claude/
51
- const configDir = path.join(configPath, 'config');
52
- const targetDir = scope === 'global'
53
- ? os.homedir()
54
- : process.cwd();
55
- const claudeDir = path.join(targetDir, '.claude');
56
- if (meta.mode === 'symlink') {
57
- // Sync new builtin files to ~/.ai-nexus/config/
58
- const builtinConfigDir = path.join(PACKAGE_ROOT, 'config');
59
- const categories = ['rules', 'commands', 'skills', 'agents', 'contexts', 'hooks'];
60
- let addedCount = 0;
61
- for (const category of categories) {
62
- const srcDir = path.join(builtinConfigDir, category);
63
- if (!fs.existsSync(srcDir))
64
- continue;
65
- const destDir = path.join(configDir, category);
66
- ensureDir(destDir);
67
- const srcFiles = scanDir(srcDir);
68
- for (const [rel, content] of Object.entries(srcFiles)) {
69
- const dest = path.join(destDir, rel);
70
- if (!fs.existsSync(dest)) {
71
- ensureDir(path.dirname(dest));
72
- fs.writeFileSync(dest, content);
73
- addedCount++;
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;
74
103
  }
75
104
  }
76
105
  }
77
- if (addedCount > 0) {
78
- console.log(chalk.green(` + ${addedCount} new files added`));
79
- hasChanges = true;
80
- }
81
106
  }
82
- if (meta.mode === 'copy') {
83
- // Copy mode: compare and update files
84
- const sourceFiles = scanDir(configDir);
85
- const installedFiles = scanDir(claudeDir);
86
- const diff = compareConfigs(sourceFiles, installedFiles);
87
- 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) {
88
113
  console.log('\n✅ Already up to date!\n');
89
114
  return;
90
115
  }
116
+ }
117
+ else {
91
118
  console.log('\n Changes detected:');
92
119
  if (diff.added.length > 0)
93
120
  console.log(chalk.green(` + ${diff.added.length} new files`));
@@ -95,113 +122,128 @@ export async function update(options = {}) {
95
122
  console.log(chalk.yellow(` ~ ${diff.modified.length} modified files`));
96
123
  if (diff.removed.length > 0)
97
124
  console.log(chalk.red(` - ${diff.removed.length} removed in source`));
98
- // Determine which files to update
99
- const filesToAdd = diff.added;
100
- let filesToUpdate;
101
- let filesToRemove = [];
102
- if (options.force) {
103
- // Force mode: update everything, but warn about user-edited files
104
- const userEdited = detectUserEdits(diff.modified, claudeDir, meta.fileHashes);
105
- if (userEdited.length > 0) {
106
- console.log(chalk.red(`\n WARNING: ${userEdited.length} file(s) have local edits that will be overwritten:`));
107
- for (const f of userEdited) {
108
- console.log(chalk.red(` - ${f}`));
109
- }
110
- const { proceed } = await inquirer.prompt([
111
- {
112
- type: 'confirm',
113
- name: 'proceed',
114
- message: 'Overwrite these user-edited files?',
115
- default: false,
116
- },
117
- ]);
118
- if (!proceed) {
119
- filesToUpdate = diff.modified.filter(f => !userEdited.includes(f));
120
- }
121
- else {
122
- filesToUpdate = diff.modified;
123
- }
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));
124
147
  }
125
148
  else {
126
149
  filesToUpdate = diff.modified;
127
150
  }
128
- filesToRemove = diff.removed;
129
151
  }
130
- else if (options.addOnly) {
131
- // Add-only mode: only add new files
132
- filesToUpdate = [];
133
- filesToRemove = [];
152
+ else {
153
+ filesToUpdate = diff.modified;
134
154
  }
135
- else if (options.interactive && diff.modified.length > 0) {
136
- // Interactive mode: ask for each modified file
137
- console.log(chalk.cyan('\n Modified files (choose which to overwrite):\n'));
138
- 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([
139
178
  {
140
- type: 'checkbox',
141
- name: 'selectedFiles',
142
- message: 'Select files to overwrite',
143
- choices: diff.modified.map(f => ({
144
- name: f,
145
- value: f,
146
- checked: false,
147
- })),
179
+ type: 'confirm',
180
+ name: 'removeFiles',
181
+ message: `Remove ${diff.removed.length} files that no longer exist in source?`,
182
+ default: false,
148
183
  },
149
184
  ]);
150
- filesToUpdate = selectedFiles;
151
- if (diff.removed.length > 0) {
152
- const { removeFiles } = await inquirer.prompt([
153
- {
154
- type: 'confirm',
155
- name: 'removeFiles',
156
- message: `Remove ${diff.removed.length} files that no longer exist in source?`,
157
- default: false,
158
- },
159
- ]);
160
- filesToRemove = removeFiles ? diff.removed : [];
161
- }
185
+ filesToRemove = removeFiles ? diff.removed : [];
162
186
  }
163
- else {
164
- // Default: add new files, skip modified, keep removed
165
- // (merge mode)
166
- filesToUpdate = [];
167
- filesToRemove = [];
168
- if (diff.modified.length > 0) {
169
- console.log(chalk.gray(`\n Skipping ${diff.modified.length} modified files (use --force to overwrite)`));
170
- }
171
- }
172
- // Apply changes
173
- let addedCount = 0;
174
- let updatedCount = 0;
175
- let removedCount = 0;
176
- for (const rel of filesToAdd) {
177
- const src = path.join(configDir, rel);
178
- const dest = path.join(claudeDir, rel);
179
- ensureDir(path.dirname(dest));
180
- fs.copyFileSync(src, dest);
181
- 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)`));
182
193
  }
183
- for (const rel of filesToUpdate) {
184
- const src = path.join(configDir, rel);
185
- const dest = path.join(claudeDir, rel);
186
- ensureDir(path.dirname(dest));
187
- fs.copyFileSync(src, dest);
188
- 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++;
189
218
  }
190
- for (const rel of filesToRemove) {
191
- const dest = path.join(claudeDir, rel);
192
- if (fs.existsSync(dest)) {
193
- fs.unlinkSync(dest);
194
- 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++;
195
243
  }
196
244
  }
197
- if (addedCount > 0 || updatedCount > 0 || removedCount > 0) {
198
- console.log('\n Applied:');
199
- if (addedCount > 0)
200
- console.log(chalk.green(` + ${addedCount} files added`));
201
- if (updatedCount > 0)
202
- console.log(chalk.yellow(` ~ ${updatedCount} files updated`));
203
- if (removedCount > 0)
204
- console.log(chalk.red(` - ${removedCount} files removed`));
245
+ if (hooksAdded > 0) {
246
+ console.log(chalk.green(` + ${hooksAdded} new hooks added`));
205
247
  hasChanges = true;
206
248
  }
207
249
  }
@@ -231,11 +273,10 @@ export async function update(options = {}) {
231
273
  hasChanges = true;
232
274
  }
233
275
  }
234
- // Update metadata (refresh file hashes for copy mode)
276
+ // Update metadata
235
277
  meta.updatedAt = new Date().toISOString();
236
- if (meta.mode === 'copy') {
237
- meta.fileHashes = computeFileHashes(claudeDir);
238
- }
278
+ delete meta.mode; // remove deprecated field
279
+ meta.fileHashes = computeFileHashes(claudeDir);
239
280
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
240
281
  if (hasChanges) {
241
282
  console.log('\n✅ Update complete!\n');
@@ -244,6 +285,44 @@ export async function update(options = {}) {
244
285
  console.log('\n✅ Already up to date!\n');
245
286
  }
246
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
+ }
247
326
  function detectUserEdits(modifiedFiles, claudeDir, savedHashes) {
248
327
  if (!savedHashes)
249
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',
@@ -94,6 +87,25 @@ const CATEGORY_LABELS = {
94
87
  function stripFrontmatter(content) {
95
88
  return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '').trim();
96
89
  }
90
+ /**
91
+ * Collect all .md files from a category directory, including SKILL.md in subdirectories.
92
+ */
93
+ function collectMdFiles(catDir) {
94
+ const files = [];
95
+ const entries = fs.readdirSync(catDir, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ if (entry.isFile() && entry.name.endsWith('.md')) {
98
+ files.push(entry.name);
99
+ }
100
+ else if (entry.isDirectory()) {
101
+ const skillMd = path.join(catDir, entry.name, 'SKILL.md');
102
+ if (fs.existsSync(skillMd)) {
103
+ files.push(path.join(entry.name, 'SKILL.md'));
104
+ }
105
+ }
106
+ }
107
+ return files;
108
+ }
97
109
  /**
98
110
  * Aggregate rule files into a single AGENTS.md content string.
99
111
  * If selectedFiles is provided, only those files are included.
@@ -112,8 +124,7 @@ export function aggregateToAgentsMd(configDir, selectedFiles) {
112
124
  files = selectedFiles[category];
113
125
  }
114
126
  else {
115
- // Scan all .md files in the category directory
116
- files = fs.readdirSync(catDir).filter(f => f.endsWith('.md'));
127
+ files = collectMdFiles(catDir);
117
128
  }
118
129
  if (files.length === 0)
119
130
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-nexus",
3
- "version": "1.3.21",
3
+ "version": "1.4.1",
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": {