@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.
@@ -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
  `;
@@ -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 isIntegrateMode = !isEmpty && scan.hasFiles && !isExistingProject;
169
+ const isIntegrationMode = !isEmpty && scan.hasFiles && !isExistingProject;
170
170
 
171
- if (isIntegrateMode) {
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(isIntegrateMode ? '2ndBrain framework integrated!' : '2ndBrain project initialized!');
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`);
@@ -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.noConfig] - Skip Obsidian config update
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('');
@@ -13,7 +13,12 @@ const {
13
13
  SMART_COPY_DIRS,
14
14
  is2ndBrainProject,
15
15
  } = require('../lib/config');
16
- const { copyFilesSmart, copyFileWithCompare, createFile } = require('../lib/files');
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(src);
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(src);
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(src, 'utf8'),
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
@@ -24,6 +24,7 @@ const FRAMEWORK_FILES = [
24
24
  '99_System/Templates/tpl_daily_note.md',
25
25
  '99_System/Templates/tpl_member_tasks.md',
26
26
  '99_System/Templates/tpl_member_done.md',
27
+ '99_System/Templates/tpl_member_todo.md',
27
28
  '99_System/Scripts/init_member.sh',
28
29
  ];
29
30