@our2ndbrain/cli 1.1.3 → 2026.4.5

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/README.md +54 -582
  3. package/README_en.md +108 -0
  4. package/bin/2ndbrain.js +30 -1
  5. package/package.json +14 -11
  6. package/src/commands/check.js +199 -0
  7. package/src/commands/completion.js +35 -1
  8. package/src/commands/init.js +7 -4
  9. package/src/commands/member.js +3 -1
  10. package/src/commands/update.js +38 -12
  11. package/src/commands/watch.js +212 -0
  12. package/src/index.js +4 -0
  13. package/src/lib/config.js +46 -2
  14. package/src/lib/files.js +58 -19
  15. package/{.obsidian → template/.obsidian}/plugins/obsidian-git/obsidian_askpass.sh +0 -0
  16. package/{00_Dashboard → template/00_Dashboard}/01_All_Tasks.md +17 -15
  17. package/template/10_Inbox/.gitkeep +0 -0
  18. package/template/20_Areas/.gitkeep +0 -0
  19. package/template/30_Projects/.gitkeep +0 -0
  20. package/template/40_Resources/.gitkeep +0 -0
  21. package/template/90_Archives/.gitkeep +0 -0
  22. package/{99_System → template/99_System}/Scripts/init_member.sh +0 -0
  23. package/template/99_System/Templates/tpl_daily_note.md +30 -0
  24. package/{99_System → template/99_System}/Templates/tpl_member_tasks.md +11 -5
  25. package/template/99_System/Templates/tpl_member_todo.md +5 -0
  26. package/99_System/Templates/tpl_daily_note.md +0 -13
  27. package/AGENTS.md +0 -193
  28. package/CLAUDE.md +0 -153
  29. /package/{.obsidian → template/.obsidian}/.2ndbrain-manifest.json +0 -0
  30. /package/{.obsidian → template/.obsidian}/app.json +0 -0
  31. /package/{.obsidian → template/.obsidian}/appearance.json +0 -0
  32. /package/{.obsidian → template/.obsidian}/community-plugins.json +0 -0
  33. /package/{.obsidian → template/.obsidian}/core-plugins.json +0 -0
  34. /package/{.obsidian → template/.obsidian}/graph.json +0 -0
  35. /package/{.obsidian → template/.obsidian}/plugins/calendar/data.json +0 -0
  36. /package/{.obsidian → template/.obsidian}/plugins/calendar/main.js +0 -0
  37. /package/{.obsidian → template/.obsidian}/plugins/calendar/manifest.json +0 -0
  38. /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/data.json +0 -0
  39. /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/main.js +0 -0
  40. /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/manifest.json +0 -0
  41. /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/styles.css +0 -0
  42. /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/data.json +0 -0
  43. /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/main.js +0 -0
  44. /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/manifest.json +0 -0
  45. /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/styles.css +0 -0
  46. /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/main.js +0 -0
  47. /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/manifest.json +0 -0
  48. /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/styles.css +0 -0
  49. /package/{.obsidian → template/.obsidian}/types.json +0 -0
  50. /package/{00_Dashboard → template/00_Dashboard}/09_All_Done.md +0 -0
  51. /package/{10_Inbox → template/10_Inbox}/Agents/Journal.md +0 -0
  52. /package/{99_System → template/99_System}/Templates/tpl_member_done.md +0 -0
@@ -8,12 +8,19 @@ const path = require('path');
8
8
  const fs = require('fs-extra');
9
9
  const chalk = require('chalk');
10
10
  const {
11
+ PACKAGE_ROOT,
11
12
  TEMPLATE_ROOT,
12
13
  FRAMEWORK_FILES,
13
14
  SMART_COPY_DIRS,
15
+ getFrameworkSourcePath,
14
16
  is2ndBrainProject,
15
17
  } = require('../lib/config');
16
- const { copyFilesSmart, copyFileWithCompare, createFile } = require('../lib/files');
18
+ const {
19
+ copyFilesSmart,
20
+ copyFileWithCompare,
21
+ createFile,
22
+ resolveSourcePath,
23
+ } = require('../lib/files');
17
24
  const { generateDiff, formatDiffForTerminal, areContentsEqual, isBinaryFile, isLargeFile, LARGE_FILE_THRESHOLD } = require('../lib/diff');
18
25
  const { confirm, confirmBatchUpdates, confirmFile, confirmBinaryFile, confirmLargeFile } = require('../lib/prompt');
19
26
  const { copyObsidianDirSmart, MERGE_STRATEGIES } = require('../lib/obsidian');
@@ -30,6 +37,7 @@ const { copyObsidianDirSmart, MERGE_STRATEGIES } = require('../lib/obsidian');
30
37
  async function update(targetPath, options, log) {
31
38
  const resolvedPath = path.resolve(targetPath);
32
39
  const templateRoot = options.template ? path.resolve(options.template) : TEMPLATE_ROOT;
40
+ const packageRoot = options.template ? path.resolve(templateRoot, '..') : PACKAGE_ROOT;
33
41
 
34
42
  log.info(`Updating 2ndBrain project at: ${resolvedPath}`);
35
43
  log.info(`Using template from: ${templateRoot}`);
@@ -42,21 +50,22 @@ async function update(targetPath, options, log) {
42
50
  }
43
51
 
44
52
  if (options.dryRun) {
45
- await performDryRun(resolvedPath, templateRoot, log);
53
+ await performDryRun(resolvedPath, templateRoot, packageRoot, log);
46
54
  return;
47
55
  }
48
56
 
49
57
  // Main update flow
50
- await performUpdate(resolvedPath, templateRoot, options, log);
58
+ await performUpdate(resolvedPath, templateRoot, packageRoot, options, log);
51
59
  }
52
60
 
53
61
  /**
54
62
  * Perform dry run - show what would be updated
55
63
  * @param {string} resolvedPath - Target project path
56
64
  * @param {string} templateRoot - Template root path
65
+ * @param {string} packageRoot - Package root path
57
66
  * @param {Function} log - Logger function
58
67
  */
59
- async function performDryRun(resolvedPath, templateRoot, log) {
68
+ async function performDryRun(resolvedPath, templateRoot, packageRoot, log) {
60
69
  log.warn('[DRY RUN] No files will be modified.');
61
70
  log.info('');
62
71
 
@@ -64,7 +73,7 @@ async function performDryRun(resolvedPath, templateRoot, log) {
64
73
 
65
74
  const result = await copyFilesSmart(
66
75
  FRAMEWORK_FILES,
67
- templateRoot,
76
+ (file) => getFrameworkSourcePath(file, { templateRoot, packageRoot }),
68
77
  resolvedPath,
69
78
  { dryRun: true },
70
79
  (file, action, detail) => {
@@ -158,17 +167,18 @@ async function performDryRun(resolvedPath, templateRoot, log) {
158
167
  * Perform the actual update with user confirmation
159
168
  * @param {string} resolvedPath - Target project path
160
169
  * @param {string} templateRoot - Template root path
170
+ * @param {string} packageRoot - Package root path
161
171
  * @param {Object} options - Command options
162
172
  * @param {boolean} [options.yes] - Auto-confirm all updates
163
173
  * @param {Function} log - Logger function
164
174
  */
165
- async function performUpdate(resolvedPath, templateRoot, options, log) {
175
+ async function performUpdate(resolvedPath, templateRoot, packageRoot, options, log) {
166
176
  log.info('Analyzing framework files...');
167
177
 
168
178
  // First pass: analyze all files
169
179
  const analysis = await copyFilesSmart(
170
180
  FRAMEWORK_FILES,
171
- templateRoot,
181
+ (file) => getFrameworkSourcePath(file, { templateRoot, packageRoot }),
172
182
  resolvedPath,
173
183
  {},
174
184
  (file, action, detail) => {
@@ -213,12 +223,13 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
213
223
  if (userChoice === 'all') {
214
224
  // Apply all changes
215
225
  for (const change of changes) {
216
- const src = path.join(templateRoot, change.file);
226
+ const src = getFrameworkSourcePath(change.file, { templateRoot, packageRoot });
227
+ const resolvedSrc = await resolveTemplateSourcePath(src, change.file);
217
228
  const dest = path.join(resolvedPath, change.file);
218
229
 
219
230
  // Handle special cases
220
231
  if (change.large && !options.yes) {
221
- const stats = await fs.stat(src);
232
+ const stats = await fs.stat(resolvedSrc);
222
233
  const confirmed = await confirmLargeFile(change.file, stats.size);
223
234
  if (!confirmed) {
224
235
  log.warn(` ~ ${change.file} (skipped)`);
@@ -239,12 +250,13 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
239
250
  // Review each file individually
240
251
  for (const change of changes) {
241
252
  const file = change.file;
242
- const src = path.join(templateRoot, file);
253
+ const src = getFrameworkSourcePath(file, { templateRoot, packageRoot });
254
+ const resolvedSrc = await resolveTemplateSourcePath(src, file);
243
255
  const dest = path.join(resolvedPath, file);
244
256
 
245
257
  // Handle special files
246
258
  if (change.large) {
247
- const stats = await fs.stat(src);
259
+ const stats = await fs.stat(resolvedSrc);
248
260
  const confirmed = await confirmLargeFile(file, stats.size);
249
261
  if (!confirmed) {
250
262
  log.warn(` ~ ${file} (skipped)`);
@@ -272,7 +284,7 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
272
284
 
273
285
  const [oldContent, newContent] = await Promise.all([
274
286
  fs.readFile(dest, 'utf8').catch(() => ''),
275
- fs.readFile(src, 'utf8'),
287
+ fs.readFile(resolvedSrc, 'utf8'),
276
288
  ]);
277
289
 
278
290
  const diffText = generateDiff(oldContent, newContent, file, file);
@@ -300,6 +312,20 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
300
312
  await updateMemberDashboards(resolvedPath, templateRoot, options, log);
301
313
  }
302
314
 
315
+ /**
316
+ * Resolve the actual template source path for a framework file.
317
+ * @param {string} src - Preferred source file path
318
+ * @param {string} file - Relative framework file path for error messages
319
+ * @returns {Promise<string>} Resolved source file path
320
+ */
321
+ async function resolveTemplateSourcePath(src, file) {
322
+ const resolvedSrc = await resolveSourcePath(src);
323
+ if (!resolvedSrc) {
324
+ throw new Error(`Template source file not found: ${file}`);
325
+ }
326
+ return resolvedSrc;
327
+ }
328
+
303
329
  /**
304
330
  * Update .obsidian directory with smart merge strategies
305
331
  * @param {string} resolvedPath - Target project path
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 2ndBrain CLI - Watch Command
3
+ *
4
+ * File watcher that monitors To-Do files for changes and triggers
5
+ * lightweight inbox organization via openclaw/claude agent CLI.
6
+ * Pure Node.js — works on macOS, Windows, and Linux.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execFileSync, spawn } = require('child_process');
12
+ const { is2ndBrainProject } = require('../lib/config');
13
+
14
+ const DEFAULT_INTERVAL_MINUTES = 5;
15
+ const LOCK_FILE = '.2ndbrain-watch-lock';
16
+
17
+ function detectAgentCLI() {
18
+ const isWin = process.platform === 'win32';
19
+ const whichCmd = isWin ? 'where' : 'which';
20
+
21
+ try {
22
+ execFileSync(whichCmd, ['openclaw'], { stdio: 'pipe' });
23
+ return 'openclaw';
24
+ } catch { /* not found */ }
25
+
26
+ try {
27
+ execFileSync(whichCmd, ['claude'], { stdio: 'pipe' });
28
+ return 'claude';
29
+ } catch { /* not found */ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function buildOrganizePrompt(vaultPath) {
35
+ return [
36
+ '你已安装 2ndbrain skill。请按照该 skill 的整理指令集执行:',
37
+ '1. 读取所有 10_Inbox/*/00_To-Do.md 中 ## Inbox 下的未分类任务',
38
+ '2. 分类、打标签、移动到项目 Heading',
39
+ '3. 完成的任务归档到 09_Done.md',
40
+ '4. 生成整理报告写入当日日记',
41
+ `Vault 路径: ${vaultPath}`,
42
+ ].join('\n');
43
+ }
44
+
45
+ function triggerAgent(agentCLI, vaultPath, log) {
46
+ const prompt = buildOrganizePrompt(vaultPath);
47
+
48
+ let child;
49
+ if (agentCLI === 'openclaw') {
50
+ child = spawn('openclaw', ['agent', '--agent', 'main', '--message', prompt, '--local'], {
51
+ stdio: 'ignore',
52
+ detached: true,
53
+ cwd: vaultPath,
54
+ });
55
+ } else {
56
+ child = spawn('claude', ['-p', prompt], {
57
+ stdio: 'ignore',
58
+ detached: true,
59
+ cwd: vaultPath,
60
+ });
61
+ }
62
+
63
+ child.unref();
64
+ log.info(` → 已触发 ${agentCLI} 执行整理 (PID: ${child.pid})`);
65
+ return child.pid;
66
+ }
67
+
68
+ function findToDoFiles(vaultPath) {
69
+ const inboxDir = path.join(vaultPath, '10_Inbox');
70
+ if (!fs.existsSync(inboxDir)) return [];
71
+
72
+ const files = [];
73
+ try {
74
+ const entries = fs.readdirSync(inboxDir, { withFileTypes: true });
75
+ for (const entry of entries) {
76
+ if (!entry.isDirectory() || entry.name === 'Agents') continue;
77
+ const todoFile = path.join(inboxDir, entry.name, '00_To-Do.md');
78
+ if (fs.existsSync(todoFile)) {
79
+ files.push(todoFile);
80
+ }
81
+ }
82
+ } catch { /* ignore read errors */ }
83
+ return files;
84
+ }
85
+
86
+ async function watch(targetPath, options, log) {
87
+ const vaultPath = path.resolve(targetPath);
88
+ const intervalMinutes = options.interval || DEFAULT_INTERVAL_MINUTES;
89
+ const once = options.once || false;
90
+
91
+ if (!is2ndBrainProject(vaultPath)) {
92
+ log.error(`${vaultPath} 不是 2ndBrain 知识库。请先运行: 2ndbrain init ${targetPath}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ const agentCLI = detectAgentCLI();
97
+ if (!agentCLI) {
98
+ log.error('未找到 Agent CLI (openclaw / claude)。');
99
+ log.warn('请安装 OpenClaw (https://docs.openclaw.ai/start) 或 Claude Code (https://code.claude.com)');
100
+ process.exit(1);
101
+ }
102
+
103
+ const todoFiles = findToDoFiles(vaultPath);
104
+ if (todoFiles.length === 0) {
105
+ log.error('未找到任何 To-Do 文件。请先运行: 2ndbrain member <name>');
106
+ process.exit(1);
107
+ }
108
+
109
+ log.info('\n🧠 2ndBrain Watch 模式\n');
110
+ log.info(` 知识库: ${vaultPath}`);
111
+ log.info(` Agent CLI: ${agentCLI}`);
112
+ log.info(` 防抖间隔: ${intervalMinutes} 分钟`);
113
+ log.info(` 监听文件:`);
114
+ for (const f of todoFiles) {
115
+ log.info(` - ${path.relative(vaultPath, f)}`);
116
+ }
117
+ log.info('\n 按 Ctrl+C 退出\n');
118
+
119
+ const lockFile = path.join(vaultPath, LOCK_FILE);
120
+ let debounceTimer = null;
121
+ const watchers = [];
122
+
123
+ function isLocked() {
124
+ if (!fs.existsSync(lockFile)) return false;
125
+ try {
126
+ const lockTime = parseInt(fs.readFileSync(lockFile, 'utf8'), 10);
127
+ // Lock expires after 30 minutes
128
+ if (Date.now() - lockTime > 30 * 60 * 1000) {
129
+ fs.unlinkSync(lockFile);
130
+ return false;
131
+ }
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function acquireLock() {
139
+ fs.writeFileSync(lockFile, String(Date.now()));
140
+ }
141
+
142
+ function releaseLock() {
143
+ try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
144
+ }
145
+
146
+ function onFileChange(filename) {
147
+ if (debounceTimer) {
148
+ clearTimeout(debounceTimer);
149
+ }
150
+
151
+ const now = new Date().toLocaleTimeString();
152
+ log.info(` [${now}] 检测到变化: ${filename}`);
153
+ log.info(` [${now}] 等待 ${intervalMinutes} 分钟后触发整理...`);
154
+
155
+ debounceTimer = setTimeout(() => {
156
+ if (isLocked()) {
157
+ log.warn(' 上一次整理尚未完成,跳过本次触发');
158
+ return;
159
+ }
160
+
161
+ acquireLock();
162
+ const triggerTime = new Date().toLocaleTimeString();
163
+ log.success(` [${triggerTime}] 防抖结束,触发整理`);
164
+
165
+ triggerAgent(agentCLI, vaultPath, log);
166
+
167
+ // Release lock after a reasonable time
168
+ setTimeout(() => releaseLock(), 10 * 60 * 1000);
169
+
170
+ if (once) {
171
+ log.info('\n --once 模式,退出\n');
172
+ cleanup();
173
+ process.exit(0);
174
+ }
175
+ }, intervalMinutes * 60 * 1000);
176
+ }
177
+
178
+ // Set up file watchers
179
+ for (const todoFile of todoFiles) {
180
+ try {
181
+ const watcher = fs.watch(todoFile, (eventType) => {
182
+ if (eventType === 'change') {
183
+ onFileChange(path.relative(vaultPath, todoFile));
184
+ }
185
+ });
186
+ watchers.push(watcher);
187
+ } catch (err) {
188
+ log.warn(` 无法监听 ${path.relative(vaultPath, todoFile)}: ${err.message}`);
189
+ }
190
+ }
191
+
192
+ function cleanup() {
193
+ if (debounceTimer) clearTimeout(debounceTimer);
194
+ for (const w of watchers) {
195
+ try { w.close(); } catch { /* ignore */ }
196
+ }
197
+ releaseLock();
198
+ }
199
+
200
+ process.on('SIGINT', () => {
201
+ log.info('\n\n 正在退出...\n');
202
+ cleanup();
203
+ process.exit(0);
204
+ });
205
+
206
+ process.on('SIGTERM', () => {
207
+ cleanup();
208
+ process.exit(0);
209
+ });
210
+ }
211
+
212
+ module.exports = watch;
package/src/index.js CHANGED
@@ -7,6 +7,8 @@ const update = require('./commands/update');
7
7
  const remove = require('./commands/remove');
8
8
  const member = require('./commands/member');
9
9
  const completion = require('./commands/completion');
10
+ const check = require('./commands/check');
11
+ const watch = require('./commands/watch');
10
12
 
11
13
  module.exports = {
12
14
  init,
@@ -14,4 +16,6 @@ module.exports = {
14
16
  remove,
15
17
  member,
16
18
  completion,
19
+ check,
20
+ watch,
17
21
  };
package/src/lib/config.js CHANGED
@@ -6,8 +6,20 @@
6
6
 
7
7
  const path = require('path');
8
8
 
9
- // Template root directory (npm package root)
10
- const TEMPLATE_ROOT = path.resolve(__dirname, '../../');
9
+ // npm package root containing CLI docs and metadata
10
+ const PACKAGE_ROOT = path.resolve(__dirname, '../..');
11
+
12
+ // Vault template root containing shipped vault assets
13
+ const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, 'template');
14
+
15
+ const PACKAGE_DOC_FILES = [
16
+ 'AGENTS.md',
17
+ 'README.md',
18
+ 'README_en.md',
19
+ 'CHANGELOG.md',
20
+ 'CLAUDE.md',
21
+ 'LICENSE',
22
+ ];
11
23
 
12
24
  /**
13
25
  * Framework files - managed by init/update/remove commands
@@ -16,6 +28,7 @@ const TEMPLATE_ROOT = path.resolve(__dirname, '../../');
16
28
  const FRAMEWORK_FILES = [
17
29
  'AGENTS.md',
18
30
  'README.md',
31
+ 'README_en.md',
19
32
  'CHANGELOG.md',
20
33
  'CLAUDE.md',
21
34
  'LICENSE',
@@ -24,6 +37,7 @@ const FRAMEWORK_FILES = [
24
37
  '99_System/Templates/tpl_daily_note.md',
25
38
  '99_System/Templates/tpl_member_tasks.md',
26
39
  '99_System/Templates/tpl_member_done.md',
40
+ '99_System/Templates/tpl_member_todo.md',
27
41
  '99_System/Scripts/init_member.sh',
28
42
  ];
29
43
 
@@ -83,6 +97,32 @@ function getTemplatePath(relativePath, templateRoot = TEMPLATE_ROOT) {
83
97
  return path.join(templateRoot, relativePath);
84
98
  }
85
99
 
100
+ /**
101
+ * Check whether a framework file is sourced from the package root docs.
102
+ * @param {string} relativePath - Relative framework file path
103
+ * @returns {boolean}
104
+ */
105
+ function isPackageDocFile(relativePath) {
106
+ return PACKAGE_DOC_FILES.includes(relativePath);
107
+ }
108
+
109
+ /**
110
+ * Resolve the source path for a framework file.
111
+ * Package docs come from the npm package root; vault assets come from template/.
112
+ * @param {string} relativePath - Relative framework file path
113
+ * @param {Object} [options]
114
+ * @param {string} [options.templateRoot] - Template directory override
115
+ * @param {string} [options.packageRoot] - Package root override
116
+ * @returns {string} Absolute source path
117
+ */
118
+ function getFrameworkSourcePath(
119
+ relativePath,
120
+ { templateRoot = TEMPLATE_ROOT, packageRoot = PACKAGE_ROOT } = {}
121
+ ) {
122
+ const sourceRoot = isPackageDocFile(relativePath) ? packageRoot : templateRoot;
123
+ return path.join(sourceRoot, relativePath);
124
+ }
125
+
86
126
  /**
87
127
  * Check if a path is a 2ndBrain project
88
128
  * @param {string} targetPath - Path to check
@@ -99,7 +139,9 @@ function is2ndBrainProject(targetPath) {
99
139
  }
100
140
 
101
141
  module.exports = {
142
+ PACKAGE_ROOT,
102
143
  TEMPLATE_ROOT,
144
+ PACKAGE_DOC_FILES,
103
145
  FRAMEWORK_FILES,
104
146
  FRAMEWORK_DIRS,
105
147
  USER_DATA_DIRS,
@@ -108,5 +150,7 @@ module.exports = {
108
150
  INIT_ONLY_FILES,
109
151
  MARKER_FILE,
110
152
  getTemplatePath,
153
+ isPackageDocFile,
154
+ getFrameworkSourcePath,
111
155
  is2ndBrainProject,
112
156
  };
package/src/lib/files.js CHANGED
@@ -8,6 +8,29 @@ const fs = require('fs-extra');
8
8
  const path = require('path');
9
9
  const { compareFiles, summarizeChanges, isBinaryFile, isLargeFile } = require('./diff');
10
10
 
11
+ const README_ALIAS_FILES = new Set(['AGENTS.md', 'CLAUDE.md']);
12
+
13
+ /**
14
+ * Resolve a template source file, falling back to README.md for symlink aliases
15
+ * that are not preserved by npm pack.
16
+ * @param {string} src - Preferred source file path
17
+ * @returns {Promise<string|null>} Resolved source path, or null if unavailable
18
+ */
19
+ async function resolveSourcePath(src) {
20
+ if (await fs.pathExists(src)) {
21
+ return src;
22
+ }
23
+
24
+ if (README_ALIAS_FILES.has(path.basename(src))) {
25
+ const readmePath = path.join(path.dirname(src), 'README.md');
26
+ if (await fs.pathExists(readmePath)) {
27
+ return readmePath;
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
11
34
  /**
12
35
  * Copy a file from source to destination
13
36
  * @param {string} src - Source file path
@@ -22,21 +45,23 @@ async function copyFile(src, dest) {
22
45
  /**
23
46
  * Copy multiple files from template to target
24
47
  * @param {string[]} files - Array of relative file paths
25
- * @param {string} templateRoot - Template root directory
48
+ * @param {string|Function} sourceRootOrResolver - Source root directory or resolver
26
49
  * @param {string} targetRoot - Target root directory
27
50
  * @param {Function} [onFile] - Callback for each file (relativePath, action)
28
51
  * @returns {Promise<{copied: string[], skipped: string[], errors: string[]}>}
29
52
  */
30
- async function copyFiles(files, templateRoot, targetRoot, onFile) {
53
+ async function copyFiles(files, sourceRootOrResolver, targetRoot, onFile) {
31
54
  const result = { copied: [], skipped: [], errors: [] };
32
55
 
33
56
  for (const file of files) {
34
- const src = path.join(templateRoot, file);
57
+ const requestedSrc = typeof sourceRootOrResolver === 'function'
58
+ ? sourceRootOrResolver(file)
59
+ : path.join(sourceRootOrResolver, file);
60
+ const src = await resolveSourcePath(requestedSrc);
35
61
  const dest = path.join(targetRoot, file);
36
62
 
37
63
  try {
38
- // Check if source exists
39
- if (!await fs.pathExists(src)) {
64
+ if (!src) {
40
65
  result.skipped.push(file);
41
66
  if (onFile) onFile(file, 'skip', 'source not found');
42
67
  continue;
@@ -218,25 +243,31 @@ async function compareFilesWrapper(src, dest) {
218
243
  * @param {string} dest - Destination file path
219
244
  * @param {Object} options - Copy options
220
245
  * @param {boolean} [options.force] - Force copy even if equal
246
+ * @param {boolean} [options.dryRun] - Compare only, do not write destination
221
247
  * @returns {Promise<{copied: boolean, unchanged: boolean, error?: string, change?: any}>}
222
248
  */
223
249
  async function copyFileWithCompare(src, dest, options = {}) {
224
250
  try {
225
- // Check if source exists
226
- if (!await fs.pathExists(src)) {
251
+ const resolvedSrc = await resolveSourcePath(src);
252
+ if (!resolvedSrc) {
227
253
  return { copied: false, unchanged: false, error: 'source not found' };
228
254
  }
229
255
 
230
256
  // Compare files if destination exists
231
- const comparison = await compareFilesWrapper(src, dest);
257
+ const comparison = await compareFilesWrapper(resolvedSrc, dest);
232
258
 
233
259
  if (comparison.exists && comparison.equal && !options.force) {
234
260
  return { copied: false, unchanged: true };
235
261
  }
236
262
 
237
- // Perform the copy
238
- await fs.ensureDir(path.dirname(dest));
239
- await fs.copy(src, dest);
263
+ let previousDestContent = '';
264
+ if (comparison.exists && !comparison.binary && !comparison.large) {
265
+ try {
266
+ previousDestContent = await fs.readFile(dest, 'utf8');
267
+ } catch {
268
+ previousDestContent = '';
269
+ }
270
+ }
240
271
 
241
272
  // Generate change summary
242
273
  let change = null;
@@ -247,11 +278,8 @@ async function copyFileWithCompare(src, dest, options = {}) {
247
278
  change = { large: true };
248
279
  } else {
249
280
  try {
250
- const [srcContent, destContent] = await Promise.all([
251
- fs.readFile(src, 'utf8'),
252
- comparison.exists ? fs.readFile(dest, 'utf8') : '',
253
- ]);
254
- const summary = summarizeChanges(destContent, srcContent);
281
+ const srcContent = await fs.readFile(resolvedSrc, 'utf8');
282
+ const summary = summarizeChanges(previousDestContent, srcContent);
255
283
  change = { added: summary.added, removed: summary.removed };
256
284
  } catch {
257
285
  // If we can't read as text, mark as binary
@@ -260,6 +288,14 @@ async function copyFileWithCompare(src, dest, options = {}) {
260
288
  }
261
289
  }
262
290
 
291
+ if (options.dryRun) {
292
+ return { copied: false, unchanged: false, change };
293
+ }
294
+
295
+ // Perform the copy
296
+ await fs.ensureDir(path.dirname(dest));
297
+ await fs.copy(resolvedSrc, dest);
298
+
263
299
  return { copied: true, unchanged: false, change };
264
300
  } catch (err) {
265
301
  return { copied: false, unchanged: false, error: err.message };
@@ -269,7 +305,7 @@ async function copyFileWithCompare(src, dest, options = {}) {
269
305
  /**
270
306
  * Copy multiple files with smart comparison
271
307
  * @param {string[]} files - Array of relative file paths
272
- * @param {string} templateRoot - Template root directory
308
+ * @param {string|Function} sourceRootOrResolver - Source root directory or resolver
273
309
  * @param {string} targetRoot - Target root directory
274
310
  * @param {Object} options - Copy options
275
311
  * @param {boolean} [options.force] - Force copy even if equal
@@ -277,7 +313,7 @@ async function copyFileWithCompare(src, dest, options = {}) {
277
313
  * @param {Function} [onFile] - Callback for each file (relativePath, action, detail)
278
314
  * @returns {Promise<{copied: string[], skipped: string[], unchanged: string[], errors: string[], changes: Array}>}
279
315
  */
280
- async function copyFilesSmart(files, templateRoot, targetRoot, options = {}, onFile) {
316
+ async function copyFilesSmart(files, sourceRootOrResolver, targetRoot, options = {}, onFile) {
281
317
  const result = {
282
318
  copied: [],
283
319
  skipped: [],
@@ -287,7 +323,9 @@ async function copyFilesSmart(files, templateRoot, targetRoot, options = {}, onF
287
323
  };
288
324
 
289
325
  for (const file of files) {
290
- const src = path.join(templateRoot, file);
326
+ const src = typeof sourceRootOrResolver === 'function'
327
+ ? sourceRootOrResolver(file)
328
+ : path.join(sourceRootOrResolver, file);
291
329
  const dest = path.join(targetRoot, file);
292
330
 
293
331
  try {
@@ -335,6 +373,7 @@ module.exports = {
335
373
  createFile,
336
374
  isDirEmpty,
337
375
  compareFilesWrapper,
376
+ resolveSourcePath,
338
377
  copyFileWithCompare,
339
378
  copyFilesSmart,
340
379
  };