@our2ndbrain/cli 1.1.3 → 2026.4.4
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/00_Dashboard/01_All_Tasks.md +17 -15
- package/99_System/Templates/tpl_daily_note.md +24 -7
- package/99_System/Templates/tpl_member_tasks.md +11 -5
- package/99_System/Templates/tpl_member_todo.md +5 -0
- package/CHANGELOG.md +17 -2
- package/README.md +236 -27
- package/bin/2ndbrain.js +30 -1
- package/package.json +11 -6
- package/src/commands/check.js +199 -0
- package/src/commands/completion.js +35 -1
- package/src/commands/init.js +3 -3
- package/src/commands/member.js +3 -1
- package/src/commands/update.js +25 -4
- package/src/commands/watch.js +212 -0
- package/src/index.js +4 -0
- package/src/lib/config.js +1 -0
- package/src/lib/files.js +48 -14
- package/AGENTS.md +0 -193
- package/CLAUDE.md +0 -153
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2ndBrain CLI - Check Command
|
|
3
|
+
*
|
|
4
|
+
* Cross-platform environment check. All platform detection is handled
|
|
5
|
+
* in Node.js — agents never need to write shell/PowerShell scripts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { is2ndBrainProject } = require('../lib/config');
|
|
12
|
+
|
|
13
|
+
const MIN_NODE_MAJOR = 18;
|
|
14
|
+
|
|
15
|
+
function commandExists(cmd) {
|
|
16
|
+
try {
|
|
17
|
+
const isWin = process.platform === 'win32';
|
|
18
|
+
if (isWin) {
|
|
19
|
+
execFileSync('where', [cmd], { stdio: 'pipe' });
|
|
20
|
+
} else {
|
|
21
|
+
execFileSync('which', [cmd], { stdio: 'pipe' });
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getCommandVersion(cmd, args = ['--version']) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync(cmd, args, { stdio: 'pipe', encoding: 'utf8' }).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkObsidianInstalled() {
|
|
38
|
+
switch (process.platform) {
|
|
39
|
+
case 'darwin':
|
|
40
|
+
return fs.existsSync('/Applications/Obsidian.app');
|
|
41
|
+
case 'win32': {
|
|
42
|
+
const localAppData = process.env.LOCALAPPDATA || '';
|
|
43
|
+
return fs.existsSync(path.join(localAppData, 'Programs', 'Obsidian', 'Obsidian.exe'));
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return commandExists('obsidian');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getObsidianInstallHint() {
|
|
51
|
+
switch (process.platform) {
|
|
52
|
+
case 'darwin':
|
|
53
|
+
return 'brew install --cask obsidian (或从 https://obsidian.md/ 下载)';
|
|
54
|
+
case 'win32':
|
|
55
|
+
return 'winget install Obsidian.MD.Obsidian (或从 https://obsidian.md/ 下载)';
|
|
56
|
+
default:
|
|
57
|
+
return '从 https://obsidian.md/ 下载 AppImage';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getNodeInstallHint() {
|
|
62
|
+
switch (process.platform) {
|
|
63
|
+
case 'darwin':
|
|
64
|
+
return 'brew install node';
|
|
65
|
+
case 'win32':
|
|
66
|
+
return 'winget install OpenJS.NodeJS.LTS (或从 https://nodejs.org/ 下载)';
|
|
67
|
+
default:
|
|
68
|
+
return 'sudo apt install nodejs npm (Debian/Ubuntu) 或从 https://nodejs.org/ 下载';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getObsidianCLIPathHint() {
|
|
73
|
+
switch (process.platform) {
|
|
74
|
+
case 'darwin':
|
|
75
|
+
return '在 ~/.zshrc 中添加:\nexport PATH="$PATH:/Applications/Obsidian.app/Contents/MacOS"';
|
|
76
|
+
case 'win32':
|
|
77
|
+
return '系统设置 → 环境变量 → Path → 新增:\nC:\\Users\\<用户名>\\AppData\\Local\\Programs\\Obsidian\\';
|
|
78
|
+
default:
|
|
79
|
+
return 'sudo ln -s /opt/obsidian/obsidian /usr/local/bin/obsidian';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function check(targetPath, _options, log) {
|
|
84
|
+
const results = [];
|
|
85
|
+
let allPassed = true;
|
|
86
|
+
|
|
87
|
+
log.info('\n🧠 2ndBrain 环境检测\n');
|
|
88
|
+
|
|
89
|
+
// 1. Node.js version
|
|
90
|
+
const nodeVersion = process.version;
|
|
91
|
+
const nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10);
|
|
92
|
+
if (nodeMajor >= MIN_NODE_MAJOR) {
|
|
93
|
+
results.push({ ok: true, label: `Node.js ${nodeVersion}` });
|
|
94
|
+
} else {
|
|
95
|
+
results.push({
|
|
96
|
+
ok: false,
|
|
97
|
+
label: `Node.js ${nodeVersion} (需要 >= ${MIN_NODE_MAJOR})`,
|
|
98
|
+
hint: getNodeInstallHint(),
|
|
99
|
+
});
|
|
100
|
+
allPassed = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Git
|
|
104
|
+
const gitVersion = getCommandVersion('git');
|
|
105
|
+
if (gitVersion) {
|
|
106
|
+
results.push({ ok: true, label: `Git ${gitVersion.replace('git version ', '')}` });
|
|
107
|
+
} else {
|
|
108
|
+
results.push({
|
|
109
|
+
ok: false,
|
|
110
|
+
label: 'Git 未安装',
|
|
111
|
+
hint: process.platform === 'win32'
|
|
112
|
+
? 'winget install Git.Git'
|
|
113
|
+
: process.platform === 'darwin'
|
|
114
|
+
? 'brew install git'
|
|
115
|
+
: 'sudo apt install git',
|
|
116
|
+
});
|
|
117
|
+
allPassed = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Obsidian app
|
|
121
|
+
if (checkObsidianInstalled()) {
|
|
122
|
+
results.push({ ok: true, label: 'Obsidian 已安装' });
|
|
123
|
+
} else {
|
|
124
|
+
results.push({
|
|
125
|
+
ok: false,
|
|
126
|
+
label: 'Obsidian 未安装',
|
|
127
|
+
hint: getObsidianInstallHint(),
|
|
128
|
+
});
|
|
129
|
+
allPassed = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 4. Obsidian CLI
|
|
133
|
+
if (commandExists('obsidian')) {
|
|
134
|
+
const obsVersion = getCommandVersion('obsidian');
|
|
135
|
+
results.push({ ok: true, label: `Obsidian CLI ${obsVersion || '可用'}` });
|
|
136
|
+
} else {
|
|
137
|
+
results.push({
|
|
138
|
+
ok: false,
|
|
139
|
+
label: 'Obsidian CLI 未配置',
|
|
140
|
+
hint: `在 Obsidian Settings > General > CLI 中启用,然后配置 PATH:\n${getObsidianCLIPathHint()}`,
|
|
141
|
+
});
|
|
142
|
+
// Obsidian CLI is optional, don't fail the whole check
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 5. 2ndBrain project (if path given)
|
|
146
|
+
const resolvedPath = path.resolve(targetPath);
|
|
147
|
+
if (fs.existsSync(resolvedPath)) {
|
|
148
|
+
if (is2ndBrainProject(resolvedPath)) {
|
|
149
|
+
results.push({ ok: true, label: `2ndBrain 知识库: ${resolvedPath}` });
|
|
150
|
+
} else {
|
|
151
|
+
results.push({
|
|
152
|
+
ok: false,
|
|
153
|
+
label: `${resolvedPath} 不是 2ndBrain 知识库`,
|
|
154
|
+
hint: `运行: 2ndbrain init ${targetPath}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 6. Agent CLI (openclaw or claude)
|
|
160
|
+
if (commandExists('openclaw')) {
|
|
161
|
+
results.push({ ok: true, label: 'OpenClaw CLI 可用' });
|
|
162
|
+
} else if (commandExists('claude')) {
|
|
163
|
+
results.push({ ok: true, label: 'Claude CLI 可用' });
|
|
164
|
+
} else {
|
|
165
|
+
results.push({
|
|
166
|
+
ok: false,
|
|
167
|
+
label: 'Agent CLI 未找到 (openclaw / claude)',
|
|
168
|
+
hint: '安装 OpenClaw: https://docs.openclaw.ai/start\n或 Claude Code: https://code.claude.com',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Print results
|
|
173
|
+
for (const r of results) {
|
|
174
|
+
if (r.ok) {
|
|
175
|
+
log.success(` ✓ ${r.label}`);
|
|
176
|
+
} else {
|
|
177
|
+
log.error(` ✗ ${r.label}`);
|
|
178
|
+
if (r.hint) {
|
|
179
|
+
log.warn(` → ${r.hint.split('\n').join('\n → ')}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
log.info('');
|
|
185
|
+
|
|
186
|
+
if (allPassed) {
|
|
187
|
+
log.success('所有必要组件已就绪 ✓\n');
|
|
188
|
+
} else {
|
|
189
|
+
log.warn('部分组件缺失,请按上方提示安装\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return allPassed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = check;
|
|
196
|
+
module.exports.MIN_NODE_MAJOR = MIN_NODE_MAJOR;
|
|
197
|
+
module.exports.commandExists = commandExists;
|
|
198
|
+
module.exports.getCommandVersion = getCommandVersion;
|
|
199
|
+
module.exports.checkObsidianInstalled = checkObsidianInstalled;
|
|
@@ -25,7 +25,7 @@ _2ndbrain_completions() {
|
|
|
25
25
|
local cmd="\${COMP_WORDS[1]}"
|
|
26
26
|
|
|
27
27
|
# Top-level commands
|
|
28
|
-
local commands="init update remove member completion"
|
|
28
|
+
local commands="init update remove member check watch completion"
|
|
29
29
|
|
|
30
30
|
case "\$cmd" in
|
|
31
31
|
init)
|
|
@@ -55,6 +55,14 @@ _2ndbrain_completions() {
|
|
|
55
55
|
member)
|
|
56
56
|
COMPREPLY=( $(compgen -W "-f --force --no-config -h --help" -- "\$cur") )
|
|
57
57
|
;;
|
|
58
|
+
check)
|
|
59
|
+
COMPREPLY=( $(compgen -W "-h --help" -- "\$cur") )
|
|
60
|
+
[[ -z "\$cur" || "\$cur" != -* ]] && COMPREPLY+=( $(compgen -d -- "\$cur") )
|
|
61
|
+
;;
|
|
62
|
+
watch)
|
|
63
|
+
COMPREPLY=( $(compgen -W "-i --interval --once -h --help" -- "\$cur") )
|
|
64
|
+
[[ -z "\$cur" || "\$cur" != -* ]] && COMPREPLY+=( $(compgen -d -- "\$cur") )
|
|
65
|
+
;;
|
|
58
66
|
completion)
|
|
59
67
|
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\$cur") )
|
|
60
68
|
;;
|
|
@@ -78,6 +86,8 @@ _2ndbrain() {
|
|
|
78
86
|
'update:Update framework files from template'
|
|
79
87
|
'remove:Remove framework files (preserves user data)'
|
|
80
88
|
'member:Initialize a new member directory'
|
|
89
|
+
'check:Check environment prerequisites'
|
|
90
|
+
'watch:Watch To-Do files and trigger inbox organization'
|
|
81
91
|
'completion:Generate shell completion script'
|
|
82
92
|
)
|
|
83
93
|
|
|
@@ -122,6 +132,18 @@ _2ndbrain() {
|
|
|
122
132
|
'1:name:' \\
|
|
123
133
|
'2:path:_directories'
|
|
124
134
|
;;
|
|
135
|
+
check)
|
|
136
|
+
_arguments \\
|
|
137
|
+
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
138
|
+
'1:path:_directories'
|
|
139
|
+
;;
|
|
140
|
+
watch)
|
|
141
|
+
_arguments \\
|
|
142
|
+
'(-i --interval)'{-i,--interval}'[Debounce interval in minutes]:minutes:' \\
|
|
143
|
+
'--once[Exit after first triggered organization]' \\
|
|
144
|
+
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
145
|
+
'1:path:_directories'
|
|
146
|
+
;;
|
|
125
147
|
completion)
|
|
126
148
|
_arguments \\
|
|
127
149
|
'1:shell:(bash zsh fish)'
|
|
@@ -144,6 +166,8 @@ complete -c 2ndbrain -n '__fish_use_subcommand' -a 'init' -d 'Initialize a new 2
|
|
|
144
166
|
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'update' -d 'Update framework files from template'
|
|
145
167
|
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'remove' -d 'Remove framework files'
|
|
146
168
|
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'member' -d 'Initialize a new member directory'
|
|
169
|
+
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'check' -d 'Check environment prerequisites'
|
|
170
|
+
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'watch' -d 'Watch To-Do files and trigger inbox organization'
|
|
147
171
|
complete -c 2ndbrain -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion script'
|
|
148
172
|
|
|
149
173
|
# Global options
|
|
@@ -173,6 +197,16 @@ complete -c 2ndbrain -n '__fish_seen_subcommand_from member' -s f -l force -d 'F
|
|
|
173
197
|
complete -c 2ndbrain -n '__fish_seen_subcommand_from member' -l no-config -d 'Skip Obsidian config update'
|
|
174
198
|
complete -c 2ndbrain -n '__fish_seen_subcommand_from member' -s h -l help -d 'Show help'
|
|
175
199
|
|
|
200
|
+
# check options
|
|
201
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from check' -s h -l help -d 'Show help'
|
|
202
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from check' -a '(__fish_complete_directories)'
|
|
203
|
+
|
|
204
|
+
# watch options
|
|
205
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from watch' -s i -l interval -d 'Debounce interval in minutes' -r
|
|
206
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from watch' -l once -d 'Exit after first triggered organization'
|
|
207
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from watch' -s h -l help -d 'Show help'
|
|
208
|
+
complete -c 2ndbrain -n '__fish_seen_subcommand_from watch' -a '(__fish_complete_directories)'
|
|
209
|
+
|
|
176
210
|
# completion options
|
|
177
211
|
complete -c 2ndbrain -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
178
212
|
`;
|
package/src/commands/init.js
CHANGED
|
@@ -166,9 +166,9 @@ async function init(targetPath, options, log) {
|
|
|
166
166
|
|
|
167
167
|
// Scan existing directory for smart integration
|
|
168
168
|
const scan = await scanExistingDirectory(resolvedPath);
|
|
169
|
-
const
|
|
169
|
+
const isIntegrationMode = !isEmpty && scan.hasFiles && !isExistingProject;
|
|
170
170
|
|
|
171
|
-
if (
|
|
171
|
+
if (isIntegrationMode) {
|
|
172
172
|
log.info('');
|
|
173
173
|
log.info('Integration mode: Merging 2ndBrain framework into existing vault');
|
|
174
174
|
if (scan.hasUserDataDirs.size > 0) {
|
|
@@ -289,7 +289,7 @@ async function init(targetPath, options, log) {
|
|
|
289
289
|
|
|
290
290
|
// Summary
|
|
291
291
|
log.info('');
|
|
292
|
-
log.success(
|
|
292
|
+
log.success(isIntegrationMode ? '2ndBrain framework integrated!' : '2ndBrain project initialized!');
|
|
293
293
|
log.info(` Created: ${fileResult.copied.length} files`);
|
|
294
294
|
if (fileResult.unchanged.length > 0) {
|
|
295
295
|
log.info(` Skipped: ${fileResult.unchanged.length} existing files`);
|
package/src/commands/member.js
CHANGED
|
@@ -18,6 +18,7 @@ const PLACEHOLDER = '{{MEMBER_NAME}}';
|
|
|
18
18
|
* Member files configuration
|
|
19
19
|
*/
|
|
20
20
|
const MEMBER_FILES = [
|
|
21
|
+
{ template: 'tpl_member_todo.md', output: '00_To-Do.md' },
|
|
21
22
|
{ template: 'tpl_member_tasks.md', output: '01_Tasks.md' },
|
|
22
23
|
{ template: 'tpl_member_done.md', output: '09_Done.md' },
|
|
23
24
|
];
|
|
@@ -28,7 +29,7 @@ const MEMBER_FILES = [
|
|
|
28
29
|
* @param {string} targetPath - Target project path
|
|
29
30
|
* @param {Object} options - Command options
|
|
30
31
|
* @param {boolean} [options.force] - Force overwrite existing member
|
|
31
|
-
* @param {boolean} [options.
|
|
32
|
+
* @param {boolean} [options.config] - Whether to update Obsidian config (set false by --no-config)
|
|
32
33
|
* @param {Object} log - Logger object
|
|
33
34
|
*/
|
|
34
35
|
async function member(memberName, targetPath, options, log) {
|
|
@@ -110,6 +111,7 @@ async function member(memberName, targetPath, options, log) {
|
|
|
110
111
|
log.success('🎉 Member init complete!');
|
|
111
112
|
log.info('');
|
|
112
113
|
log.info('Created files:');
|
|
114
|
+
log.info(` - 10_Inbox/${memberName}/00_To-Do.md (append-only task list)`);
|
|
113
115
|
log.info(` - 10_Inbox/${memberName}/01_Tasks.md (personal dashboard)`);
|
|
114
116
|
log.info(` - 10_Inbox/${memberName}/09_Done.md (done records)`);
|
|
115
117
|
log.info('');
|
package/src/commands/update.js
CHANGED
|
@@ -13,7 +13,12 @@ const {
|
|
|
13
13
|
SMART_COPY_DIRS,
|
|
14
14
|
is2ndBrainProject,
|
|
15
15
|
} = require('../lib/config');
|
|
16
|
-
const {
|
|
16
|
+
const {
|
|
17
|
+
copyFilesSmart,
|
|
18
|
+
copyFileWithCompare,
|
|
19
|
+
createFile,
|
|
20
|
+
resolveSourcePath,
|
|
21
|
+
} = require('../lib/files');
|
|
17
22
|
const { generateDiff, formatDiffForTerminal, areContentsEqual, isBinaryFile, isLargeFile, LARGE_FILE_THRESHOLD } = require('../lib/diff');
|
|
18
23
|
const { confirm, confirmBatchUpdates, confirmFile, confirmBinaryFile, confirmLargeFile } = require('../lib/prompt');
|
|
19
24
|
const { copyObsidianDirSmart, MERGE_STRATEGIES } = require('../lib/obsidian');
|
|
@@ -214,11 +219,12 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
|
|
|
214
219
|
// Apply all changes
|
|
215
220
|
for (const change of changes) {
|
|
216
221
|
const src = path.join(templateRoot, change.file);
|
|
222
|
+
const resolvedSrc = await resolveTemplateSourcePath(src, change.file);
|
|
217
223
|
const dest = path.join(resolvedPath, change.file);
|
|
218
224
|
|
|
219
225
|
// Handle special cases
|
|
220
226
|
if (change.large && !options.yes) {
|
|
221
|
-
const stats = await fs.stat(
|
|
227
|
+
const stats = await fs.stat(resolvedSrc);
|
|
222
228
|
const confirmed = await confirmLargeFile(change.file, stats.size);
|
|
223
229
|
if (!confirmed) {
|
|
224
230
|
log.warn(` ~ ${change.file} (skipped)`);
|
|
@@ -240,11 +246,12 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
|
|
|
240
246
|
for (const change of changes) {
|
|
241
247
|
const file = change.file;
|
|
242
248
|
const src = path.join(templateRoot, file);
|
|
249
|
+
const resolvedSrc = await resolveTemplateSourcePath(src, file);
|
|
243
250
|
const dest = path.join(resolvedPath, file);
|
|
244
251
|
|
|
245
252
|
// Handle special files
|
|
246
253
|
if (change.large) {
|
|
247
|
-
const stats = await fs.stat(
|
|
254
|
+
const stats = await fs.stat(resolvedSrc);
|
|
248
255
|
const confirmed = await confirmLargeFile(file, stats.size);
|
|
249
256
|
if (!confirmed) {
|
|
250
257
|
log.warn(` ~ ${file} (skipped)`);
|
|
@@ -272,7 +279,7 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
|
|
|
272
279
|
|
|
273
280
|
const [oldContent, newContent] = await Promise.all([
|
|
274
281
|
fs.readFile(dest, 'utf8').catch(() => ''),
|
|
275
|
-
fs.readFile(
|
|
282
|
+
fs.readFile(resolvedSrc, 'utf8'),
|
|
276
283
|
]);
|
|
277
284
|
|
|
278
285
|
const diffText = generateDiff(oldContent, newContent, file, file);
|
|
@@ -300,6 +307,20 @@ async function performUpdate(resolvedPath, templateRoot, options, log) {
|
|
|
300
307
|
await updateMemberDashboards(resolvedPath, templateRoot, options, log);
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the actual template source path for a framework file.
|
|
312
|
+
* @param {string} src - Preferred source file path
|
|
313
|
+
* @param {string} file - Relative framework file path for error messages
|
|
314
|
+
* @returns {Promise<string>} Resolved source file path
|
|
315
|
+
*/
|
|
316
|
+
async function resolveTemplateSourcePath(src, file) {
|
|
317
|
+
const resolvedSrc = await resolveSourcePath(src);
|
|
318
|
+
if (!resolvedSrc) {
|
|
319
|
+
throw new Error(`Template source file not found: ${file}`);
|
|
320
|
+
}
|
|
321
|
+
return resolvedSrc;
|
|
322
|
+
}
|
|
323
|
+
|
|
303
324
|
/**
|
|
304
325
|
* Update .obsidian directory with smart merge strategies
|
|
305
326
|
* @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