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 +9 -28
- package/README.md +9 -28
- package/bin/ai-rules.cjs +0 -2
- package/dist/commands/doctor.js +4 -5
- package/dist/commands/init-interactive.js +22 -53
- package/dist/commands/init.d.ts +0 -1
- package/dist/commands/init.js +18 -26
- package/dist/commands/update.js +215 -136
- package/dist/types.d.ts +1 -1
- package/dist/utils/files.d.ts +0 -1
- package/dist/utils/files.js +20 -9
- package/package.json +1 -1
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/
|
|
255
|
-
└── 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
|
-
##
|
|
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/
|
|
255
|
-
└── 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) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
21
|
+
JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
23
22
|
results.push({
|
|
24
|
-
name: '
|
|
23
|
+
name: 'Metadata',
|
|
25
24
|
status: 'ok',
|
|
26
|
-
message:
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
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
|
-
|
|
367
|
+
fileHashes: computeFileHashes(claudeDir),
|
|
399
368
|
createdAt: new Date().toISOString(),
|
|
400
369
|
updatedAt: new Date().toISOString(),
|
|
401
370
|
};
|
package/dist/commands/init.d.ts
CHANGED
package/dist/commands/init.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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,
|
|
136
|
+
printSummary(claudeDir, results);
|
|
144
137
|
}
|
|
145
|
-
function printSummary(claudeDir,
|
|
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:'));
|
package/dist/commands/update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
filesToUpdate = [];
|
|
133
|
-
filesToRemove = [];
|
|
152
|
+
else {
|
|
153
|
+
filesToUpdate = diff.modified;
|
|
134
154
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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: '
|
|
141
|
-
name: '
|
|
142
|
-
message:
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 (
|
|
198
|
-
console.log(
|
|
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
|
|
276
|
+
// Update metadata
|
|
235
277
|
meta.updatedAt = new Date().toISOString();
|
|
236
|
-
|
|
237
|
-
|
|
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
package/dist/utils/files.d.ts
CHANGED
|
@@ -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.
|
package/dist/utils/files.js
CHANGED
|
@@ -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
|
-
|
|
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;
|