@our2ndbrain/cli 1.1.3

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 (47) hide show
  1. package/.obsidian/.2ndbrain-manifest.json +8 -0
  2. package/.obsidian/app.json +6 -0
  3. package/.obsidian/appearance.json +1 -0
  4. package/.obsidian/community-plugins.json +4 -0
  5. package/.obsidian/core-plugins.json +33 -0
  6. package/.obsidian/graph.json +22 -0
  7. package/.obsidian/plugins/calendar/data.json +10 -0
  8. package/.obsidian/plugins/calendar/main.js +4459 -0
  9. package/.obsidian/plugins/calendar/manifest.json +10 -0
  10. package/.obsidian/plugins/obsidian-custom-attachment-location/data.json +32 -0
  11. package/.obsidian/plugins/obsidian-custom-attachment-location/main.js +575 -0
  12. package/.obsidian/plugins/obsidian-custom-attachment-location/manifest.json +11 -0
  13. package/.obsidian/plugins/obsidian-custom-attachment-location/styles.css +1 -0
  14. package/.obsidian/plugins/obsidian-git/data.json +62 -0
  15. package/.obsidian/plugins/obsidian-git/main.js +426 -0
  16. package/.obsidian/plugins/obsidian-git/manifest.json +10 -0
  17. package/.obsidian/plugins/obsidian-git/obsidian_askpass.sh +23 -0
  18. package/.obsidian/plugins/obsidian-git/styles.css +629 -0
  19. package/.obsidian/plugins/obsidian-tasks-plugin/main.js +504 -0
  20. package/.obsidian/plugins/obsidian-tasks-plugin/manifest.json +12 -0
  21. package/.obsidian/plugins/obsidian-tasks-plugin/styles.css +1 -0
  22. package/.obsidian/types.json +28 -0
  23. package/00_Dashboard/01_All_Tasks.md +118 -0
  24. package/00_Dashboard/09_All_Done.md +42 -0
  25. package/10_Inbox/Agents/Journal.md +1 -0
  26. package/99_System/Scripts/init_member.sh +108 -0
  27. package/99_System/Templates/tpl_daily_note.md +13 -0
  28. package/99_System/Templates/tpl_member_done.md +32 -0
  29. package/99_System/Templates/tpl_member_tasks.md +97 -0
  30. package/AGENTS.md +193 -0
  31. package/CHANGELOG.md +67 -0
  32. package/CLAUDE.md +153 -0
  33. package/LICENSE +201 -0
  34. package/README.md +636 -0
  35. package/bin/2ndbrain.js +117 -0
  36. package/package.json +56 -0
  37. package/src/commands/completion.js +198 -0
  38. package/src/commands/init.js +308 -0
  39. package/src/commands/member.js +123 -0
  40. package/src/commands/remove.js +88 -0
  41. package/src/commands/update.js +507 -0
  42. package/src/index.js +17 -0
  43. package/src/lib/config.js +112 -0
  44. package/src/lib/diff.js +222 -0
  45. package/src/lib/files.js +340 -0
  46. package/src/lib/obsidian.js +366 -0
  47. package/src/lib/prompt.js +182 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * 2ndBrain CLI - Smart Obsidian Directory Updates
3
+ *
4
+ * Provides intelligent merging for .obsidian directory that preserves user
5
+ * configurations while adding new plugins from templates.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs-extra');
10
+ const chalk = require('chalk');
11
+
12
+ /**
13
+ * Merge strategies for different file types
14
+ */
15
+ const MERGE_STRATEGIES = {
16
+ /**
17
+ * ARRAY_UNION - Merge arrays, add new items, preserve user items
18
+ * Used for: community-plugins.json
19
+ */
20
+ ARRAY_UNION: 'ARRAY_UNION',
21
+
22
+ /**
23
+ * ADD_ONLY - Only add new files/directories, never overwrite
24
+ * Used for: plugins directory (never overwrite user plugin configs)
25
+ */
26
+ ADD_ONLY: 'ADD_ONLY',
27
+
28
+ /**
29
+ * TEMPLATE_WINS - Template replaces user config
30
+ * Used for: other JSON files where template is authoritative
31
+ */
32
+ TEMPLATE_WINS: 'TEMPLATE_WINS',
33
+ };
34
+
35
+ /**
36
+ * Manifest filename for merge strategies
37
+ */
38
+ const MANIFEST_FILE = '.2ndbrain-manifest.json';
39
+
40
+ /**
41
+ * Get the merge strategy manifest from template
42
+ * @param {string} obsidianTemplatePath - Path to .obsidian in template
43
+ * @returns {Promise<Object>} Manifest object
44
+ */
45
+ async function getManifest(obsidianTemplatePath) {
46
+ const manifestPath = path.join(obsidianTemplatePath, MANIFEST_FILE);
47
+
48
+ try {
49
+ const content = await fs.readFile(manifestPath, 'utf8');
50
+ return JSON.parse(content);
51
+ } catch (err) {
52
+ // If no manifest exists, return default strategies
53
+ return {
54
+ version: '1.0.0',
55
+ description: '2ndBrain Obsidian directory merge manifest',
56
+ strategies: {
57
+ 'community-plugins.json': 'ARRAY_UNION',
58
+ 'plugins': 'ADD_ONLY',
59
+ },
60
+ deprecatedPlugins: [],
61
+ };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get all files recursively in a directory
67
+ * @param {string} dirPath - Directory path
68
+ * @returns {Promise<string[]>} Array of relative file paths
69
+ */
70
+ async function getAllFiles(dirPath) {
71
+ const files = [];
72
+
73
+ if (!(await fs.pathExists(dirPath))) {
74
+ return files;
75
+ }
76
+
77
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
78
+
79
+ for (const entry of entries) {
80
+ const fullPath = path.join(dirPath, entry.name);
81
+
82
+ if (entry.isDirectory()) {
83
+ const subFiles = await getAllFiles(fullPath);
84
+ files.push(...subFiles.map(f => path.join(entry.name, f)));
85
+ } else {
86
+ files.push(entry.name);
87
+ }
88
+ }
89
+
90
+ return files;
91
+ }
92
+
93
+ /**
94
+ * Merge community plugins using array union strategy
95
+ * Adds new plugins from template, preserves user-added plugins
96
+ * @param {string} userPluginsPath - Path to user's community-plugins.json
97
+ * @param {string} templatePluginsPath - Path to template's community-plugins.json
98
+ * @param {Object} manifest - Merge manifest
99
+ * @returns {Promise<Object>} Result with status and details
100
+ */
101
+ async function mergeCommunityPlugins(userPluginsPath, templatePluginsPath, manifest) {
102
+ let userPlugins = [];
103
+ let templatePlugins = [];
104
+
105
+ // Read user plugins
106
+ if (await fs.pathExists(userPluginsPath)) {
107
+ try {
108
+ const content = await fs.readFile(userPluginsPath, 'utf8');
109
+ userPlugins = JSON.parse(content);
110
+ } catch (err) {
111
+ // If file is invalid, start fresh
112
+ userPlugins = [];
113
+ }
114
+ }
115
+
116
+ // Read template plugins
117
+ if (await fs.pathExists(templatePluginsPath)) {
118
+ try {
119
+ const content = await fs.readFile(templatePluginsPath, 'utf8');
120
+ templatePlugins = JSON.parse(content);
121
+ } catch (err) {
122
+ templatePlugins = [];
123
+ }
124
+ }
125
+
126
+ // Union merge: template plugins + user plugins (excluding duplicates)
127
+ const mergedPlugins = [
128
+ ...new Set([
129
+ ...templatePlugins,
130
+ ...userPlugins.filter(p => !templatePlugins.includes(p)),
131
+ ]),
132
+ ];
133
+
134
+ // Sort for consistency
135
+ mergedPlugins.sort();
136
+
137
+ // Detect changes
138
+ const hasChanges = (
139
+ mergedPlugins.length !== userPlugins.length ||
140
+ !mergedPlugins.every(p => userPlugins.includes(p))
141
+ );
142
+
143
+ if (!hasChanges) {
144
+ return {
145
+ action: 'unchanged',
146
+ oldPlugins: userPlugins,
147
+ newPlugins: userPlugins,
148
+ added: [],
149
+ removed: [],
150
+ };
151
+ }
152
+
153
+ const added = mergedPlugins.filter(p => !userPlugins.includes(p));
154
+ const removed = userPlugins.filter(p => !mergedPlugins.includes(p));
155
+
156
+ return {
157
+ action: 'merged',
158
+ oldPlugins: userPlugins,
159
+ newPlugins: mergedPlugins,
160
+ added,
161
+ removed,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Process a single file in the .obsidian directory
167
+ * @param {string} relativePath - Relative path from .obsidian root
168
+ * @param {string} obsidianTemplatePath - Template .obsidian path
169
+ * @param {string} obsidianTargetPath - Target .obsidian path
170
+ * @param {Object} manifest - Merge manifest
171
+ * @param {Object} options - Options
172
+ * @param {boolean} options.dryRun - Dry run mode
173
+ * @returns {Promise<Object>} Result object
174
+ */
175
+ async function processObsidianFile(relativePath, obsidianTemplatePath, obsidianTargetPath, manifest, options = {}) {
176
+ const templateFilePath = path.join(obsidianTemplatePath, relativePath);
177
+ const targetFilePath = path.join(obsidianTargetPath, relativePath);
178
+
179
+ // Skip the manifest file itself
180
+ if (relativePath === MANIFEST_FILE) {
181
+ return { action: 'skip', file: relativePath, reason: 'internal manifest' };
182
+ }
183
+
184
+ // Check if this path or parent has a merge strategy
185
+ let strategy = null;
186
+
187
+ // Direct file strategy
188
+ if (manifest.strategies[relativePath]) {
189
+ strategy = manifest.strategies[relativePath];
190
+ } else {
191
+ // Check directory strategies
192
+ const pathParts = relativePath.split(path.sep);
193
+ for (let i = pathParts.length - 1; i >= 0; i--) {
194
+ const dirPath = pathParts.slice(0, i).join(path.sep);
195
+ if (manifest.strategies[dirPath]) {
196
+ strategy = manifest.strategies[dirPath];
197
+ break;
198
+ }
199
+ }
200
+ }
201
+
202
+ // Handle community-plugins.json specially
203
+ if (relativePath === 'community-plugins.json') {
204
+ const result = await mergeCommunityPlugins(targetFilePath, templateFilePath, manifest);
205
+
206
+ if (!options.dryRun && result.action === 'merged') {
207
+ await fs.ensureDir(path.dirname(targetFilePath));
208
+ await fs.writeFile(targetFilePath, JSON.stringify(result.newPlugins, null, 2) + '\n');
209
+ }
210
+
211
+ return {
212
+ action: result.action,
213
+ file: relativePath,
214
+ added: result.added,
215
+ removed: result.removed,
216
+ strategy: 'ARRAY_UNION',
217
+ };
218
+ }
219
+
220
+ // For ADD_ONLY strategy, only add if target doesn't exist
221
+ if (strategy === MERGE_STRATEGIES.ADD_ONLY) {
222
+ if (await fs.pathExists(targetFilePath)) {
223
+ return { action: 'preserved', file: relativePath, reason: 'user file' };
224
+ }
225
+ // File doesn't exist, copy it
226
+ if (!options.dryRun) {
227
+ await fs.ensureDir(path.dirname(targetFilePath));
228
+ await fs.copy(templateFilePath, targetFilePath);
229
+ }
230
+ return { action: 'added', file: relativePath };
231
+ }
232
+
233
+ // For TEMPLATE_WINS or no strategy, copy if different
234
+ const templateExists = await fs.pathExists(templateFilePath);
235
+ const targetExists = await fs.pathExists(targetFilePath);
236
+
237
+ if (!templateExists) {
238
+ return { action: 'skip', file: relativePath, reason: 'not in template' };
239
+ }
240
+
241
+ if (!targetExists) {
242
+ if (!options.dryRun) {
243
+ await fs.ensureDir(path.dirname(targetFilePath));
244
+ await fs.copy(templateFilePath, targetFilePath);
245
+ }
246
+ return { action: 'added', file: relativePath };
247
+ }
248
+
249
+ // Compare contents
250
+ const [templateContent, targetContent] = await Promise.all([
251
+ fs.readFile(templateFilePath, 'utf8'),
252
+ fs.readFile(targetFilePath, 'utf8'),
253
+ ]);
254
+
255
+ if (templateContent === targetContent) {
256
+ return { action: 'unchanged', file: relativePath };
257
+ }
258
+
259
+ // TEMPLATE_WINS: replace with template
260
+ if (!options.dryRun) {
261
+ await fs.copy(templateFilePath, targetFilePath);
262
+ }
263
+
264
+ return {
265
+ action: 'updated',
266
+ file: relativePath,
267
+ strategy: strategy || 'TEMPLATE_WINS',
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Smart copy of .obsidian directory with merge strategies
273
+ * @param {string} obsidianTemplatePath - Template .obsidian path
274
+ * @param {string} obsidianTargetPath - Target .obsidian path
275
+ * @param {Object} options - Options
276
+ * @param {boolean} options.dryRun - Dry run mode
277
+ * @param {Function} options.onProgress - Callback for progress updates (file, action, detail)
278
+ * @returns {Promise<Object>} Result with counts and changes
279
+ */
280
+ async function copyObsidianDirSmart(obsidianTemplatePath, obsidianTargetPath, options = {}) {
281
+ const { dryRun = false, onProgress } = options;
282
+
283
+ // Ensure target directory exists
284
+ if (!dryRun) {
285
+ await fs.ensureDir(obsidianTargetPath);
286
+ }
287
+
288
+ // Get manifest
289
+ const manifest = await getManifest(obsidianTemplatePath);
290
+
291
+ // Get all template files
292
+ const templateFiles = await getAllFiles(obsidianTemplatePath);
293
+
294
+ // Process each file
295
+ const results = {
296
+ added: [],
297
+ updated: [],
298
+ merged: [],
299
+ unchanged: [],
300
+ preserved: [],
301
+ skipped: [],
302
+ };
303
+
304
+ for (const relativePath of templateFiles) {
305
+ const result = await processObsidianFile(
306
+ relativePath,
307
+ obsidianTemplatePath,
308
+ obsidianTargetPath,
309
+ manifest,
310
+ { dryRun }
311
+ );
312
+
313
+ // Categorize result
314
+ switch (result.action) {
315
+ case 'added':
316
+ results.added.push(result);
317
+ if (onProgress) onProgress(result.file, 'added', result);
318
+ break;
319
+ case 'updated':
320
+ results.updated.push(result);
321
+ if (onProgress) onProgress(result.file, 'updated', result);
322
+ break;
323
+ case 'merged':
324
+ results.merged.push(result);
325
+ if (onProgress) onProgress(result.file, 'merged', result);
326
+ break;
327
+ case 'unchanged':
328
+ results.unchanged.push(result);
329
+ if (onProgress) onProgress(result.file, 'unchanged', result);
330
+ break;
331
+ case 'preserved':
332
+ results.preserved.push(result);
333
+ if (onProgress) onProgress(result.file, 'preserved', result);
334
+ break;
335
+ case 'skip':
336
+ results.skipped.push(result);
337
+ break;
338
+ }
339
+ }
340
+
341
+ // Check for user files not in template (should be preserved)
342
+ const targetFiles = await fs.pathExists(obsidianTargetPath)
343
+ ? await getAllFiles(obsidianTargetPath)
344
+ : [];
345
+
346
+ for (const relativePath of targetFiles) {
347
+ if (relativePath === MANIFEST_FILE) continue;
348
+ if (templateFiles.includes(relativePath)) continue;
349
+
350
+ // This is a user-only file
351
+ results.preserved.push({ file: relativePath, reason: 'user-only' });
352
+ if (onProgress) onProgress(relativePath, 'preserved', { reason: 'user-only' });
353
+ }
354
+
355
+ return results;
356
+ }
357
+
358
+ module.exports = {
359
+ MERGE_STRATEGIES,
360
+ MANIFEST_FILE,
361
+ getManifest,
362
+ getAllFiles,
363
+ mergeCommunityPlugins,
364
+ processObsidianFile,
365
+ copyObsidianDirSmart,
366
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 2ndBrain CLI - Interactive Prompt Utilities
3
+ *
4
+ * Provides user confirmation and selection prompts for CLI interactions.
5
+ */
6
+
7
+ const readline = require('readline');
8
+ const chalk = require('chalk');
9
+
10
+ /**
11
+ * Create a readline interface
12
+ * @returns {readline.Interface}
13
+ */
14
+ function createInterface() {
15
+ return readline.createInterface({
16
+ input: process.stdin,
17
+ output: process.stdout,
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Ask a Yes/No confirmation question
23
+ * @param {string} question - Question to ask
24
+ * @param {boolean} [default=true] - Default answer if user presses Enter
25
+ * @returns {Promise<boolean>} True if user confirms, false otherwise
26
+ */
27
+ function confirm(question, defaultValue = true) {
28
+ return new Promise((resolve) => {
29
+ const rl = createInterface();
30
+ const prompt = defaultValue ? ' [Y/n]: ' : ' [y/N]: ';
31
+
32
+ rl.question(`${question}${prompt}`, (answer) => {
33
+ rl.close();
34
+
35
+ if (answer.trim() === '') {
36
+ resolve(defaultValue);
37
+ return;
38
+ }
39
+
40
+ const normalized = answer.trim().toLowerCase();
41
+ resolve(normalized === 'y' || normalized === 'yes');
42
+ });
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Ask a selection question with options
48
+ * @param {string} question - Question to ask
49
+ * @param {Array<{label: string, value: any, description?: string}>} options - Options to display
50
+ * @param {number|string} [default] - Default option value if user presses Enter
51
+ * @returns {Promise<any>} Selected value
52
+ */
53
+ function select(question, options, defaultValue) {
54
+ return new Promise((resolve) => {
55
+ const rl = createInterface();
56
+
57
+ console.log('');
58
+ console.log(chalk.cyan(question));
59
+
60
+ options.forEach((opt, idx) => {
61
+ const isDefault = defaultValue !== undefined && opt.value === defaultValue;
62
+ const marker = isDefault ? ' (default)' : '';
63
+ const num = `${idx + 1})`.padStart(3);
64
+ const label = `${num} ${opt.label}${marker}`;
65
+
66
+ if (opt.description) {
67
+ console.log(` ${chalk.dim(label)} - ${opt.description}`);
68
+ } else {
69
+ console.log(` ${label}`);
70
+ }
71
+ });
72
+
73
+ rl.question(chalk.yellow('\nYour choice: '), (answer) => {
74
+ rl.close();
75
+
76
+ const trimmed = answer.trim();
77
+
78
+ if (trimmed === '' && defaultValue !== undefined) {
79
+ resolve(defaultValue);
80
+ return;
81
+ }
82
+
83
+ const num = parseInt(trimmed, 10);
84
+ if (!isNaN(num) && num >= 1 && num <= options.length) {
85
+ resolve(options[num - 1].value);
86
+ } else {
87
+ // Try to find matching value
88
+ const match = options.find(opt => opt.value === trimmed);
89
+ if (match) {
90
+ resolve(match.value);
91
+ } else {
92
+ console.log(chalk.yellow('Invalid choice, using default (1).'));
93
+ resolve(options[0].value);
94
+ }
95
+ }
96
+ });
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Confirm batch updates with a summary of changes
102
+ * @param {Array<{file: string, added: number, removed: number, binary?: boolean, large?: boolean}>} changes - List of changes
103
+ * @param {Function} log - Logger function
104
+ * @param {Object} chalk - Chalk module for colors
105
+ * @returns {Promise<'all'|'review'|'skip'>} User's choice
106
+ */
107
+ async function confirmBatchUpdates(changes, log, chalk) {
108
+ if (changes.length === 0) {
109
+ return 'skip';
110
+ }
111
+
112
+ console.log('');
113
+ log.info(`${changes.length} file(s) have changes:`);
114
+
115
+ for (const change of changes) {
116
+ if (change.binary) {
117
+ console.log(` * ${chalk.yellow(change.file)} (binary file)`);
118
+ } else if (change.large) {
119
+ console.log(` * ${chalk.yellow(change.file)} (large file, use --force to review)`);
120
+ } else {
121
+ const parts = [];
122
+ if (change.added > 0) parts.push(chalk.green(`+${change.added}`));
123
+ if (change.removed > 0) parts.push(chalk.red(`-${change.removed}`));
124
+ const summary = parts.join(' ');
125
+ console.log(` * ${chalk.yellow(change.file)} (${summary} lines)`);
126
+ }
127
+ }
128
+
129
+ console.log('');
130
+
131
+ const choice = await select('How would you like to proceed?', [
132
+ { label: 'Apply all changes', value: 'all', description: 'Update all changed files without review' },
133
+ { label: 'Review each file individually', value: 'review', description: 'Confirm each file one by one' },
134
+ { label: 'Skip all changes', value: 'skip', description: 'Cancel the update' },
135
+ ], 1);
136
+
137
+ return choice;
138
+ }
139
+
140
+ /**
141
+ * Confirm a single file update
142
+ * @param {string} file - File path
143
+ * @param {boolean} [default=true] - Default answer
144
+ * @returns {Promise<boolean>} True if user wants to update
145
+ */
146
+ function confirmFile(file, defaultValue = true) {
147
+ return confirm(`Update ${chalk.yellow(file)}?`, defaultValue);
148
+ }
149
+
150
+ /**
151
+ * Prompt for handling large files
152
+ * @param {string} file - File path
153
+ * @param {number} size - File size in bytes
154
+ * @returns {Promise<boolean>} True if user wants to proceed
155
+ */
156
+ function confirmLargeFile(file, size) {
157
+ const sizeKB = (size / 1024).toFixed(2);
158
+ console.log('');
159
+ console.log(chalk.yellow(`Warning: ${file} is a large file (${sizeKB} KB).`));
160
+ return confirm('Update this file anyway?', false);
161
+ }
162
+
163
+ /**
164
+ * Prompt for handling binary files
165
+ * @param {string} file - File path
166
+ * @returns {Promise<boolean>} True if user wants to proceed
167
+ */
168
+ function confirmBinaryFile(file) {
169
+ console.log('');
170
+ console.log(chalk.yellow(`Warning: ${file} appears to be a binary file.`));
171
+ console.log(chalk.dim('Binary files cannot show diffs and will be completely replaced.'));
172
+ return confirm('Update this file anyway?', false);
173
+ }
174
+
175
+ module.exports = {
176
+ confirm,
177
+ select,
178
+ confirmBatchUpdates,
179
+ confirmFile,
180
+ confirmLargeFile,
181
+ confirmBinaryFile,
182
+ };