@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.
- package/CHANGELOG.md +30 -2
- package/README.md +54 -582
- package/README_en.md +108 -0
- package/bin/2ndbrain.js +30 -1
- package/package.json +14 -11
- package/src/commands/check.js +199 -0
- package/src/commands/completion.js +35 -1
- package/src/commands/init.js +7 -4
- package/src/commands/member.js +3 -1
- package/src/commands/update.js +38 -12
- package/src/commands/watch.js +212 -0
- package/src/index.js +4 -0
- package/src/lib/config.js +46 -2
- package/src/lib/files.js +58 -19
- package/{.obsidian → template/.obsidian}/plugins/obsidian-git/obsidian_askpass.sh +0 -0
- package/{00_Dashboard → template/00_Dashboard}/01_All_Tasks.md +17 -15
- package/template/10_Inbox/.gitkeep +0 -0
- package/template/20_Areas/.gitkeep +0 -0
- package/template/30_Projects/.gitkeep +0 -0
- package/template/40_Resources/.gitkeep +0 -0
- package/template/90_Archives/.gitkeep +0 -0
- package/{99_System → template/99_System}/Scripts/init_member.sh +0 -0
- package/template/99_System/Templates/tpl_daily_note.md +30 -0
- package/{99_System → template/99_System}/Templates/tpl_member_tasks.md +11 -5
- package/template/99_System/Templates/tpl_member_todo.md +5 -0
- package/99_System/Templates/tpl_daily_note.md +0 -13
- package/AGENTS.md +0 -193
- package/CLAUDE.md +0 -153
- /package/{.obsidian → template/.obsidian}/.2ndbrain-manifest.json +0 -0
- /package/{.obsidian → template/.obsidian}/app.json +0 -0
- /package/{.obsidian → template/.obsidian}/appearance.json +0 -0
- /package/{.obsidian → template/.obsidian}/community-plugins.json +0 -0
- /package/{.obsidian → template/.obsidian}/core-plugins.json +0 -0
- /package/{.obsidian → template/.obsidian}/graph.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/calendar/data.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/calendar/main.js +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/calendar/manifest.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/data.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/main.js +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/manifest.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-custom-attachment-location/styles.css +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/data.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/main.js +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/manifest.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-git/styles.css +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/main.js +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/manifest.json +0 -0
- /package/{.obsidian → template/.obsidian}/plugins/obsidian-tasks-plugin/styles.css +0 -0
- /package/{.obsidian → template/.obsidian}/types.json +0 -0
- /package/{00_Dashboard → template/00_Dashboard}/09_All_Done.md +0 -0
- /package/{10_Inbox → template/10_Inbox}/Agents/Journal.md +0 -0
- /package/{99_System → template/99_System}/Templates/tpl_member_done.md +0 -0
package/src/commands/update.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
10
|
-
const
|
|
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}
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
251
|
-
|
|
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}
|
|
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,
|
|
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 =
|
|
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
|
};
|
|
File without changes
|