dig-burrow 1.2.0

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 @@
1
+ 1.2.0
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { parseArgs } = require('node:util');
5
+
6
+ const core = require('./lib/core.cjs');
7
+ const storage = require('./lib/warren.cjs');
8
+ const tree = require('./lib/mongoose.cjs');
9
+ const render = require('./lib/render.cjs');
10
+ const { init } = require('./lib/init.cjs');
11
+ const version = require('./lib/version.cjs');
12
+
13
+ /**
14
+ * Resolve terminal width from --width flag or process.stdout.columns.
15
+ * @param {object} values - Parsed CLI values (may have values.width)
16
+ * @returns {number} Terminal width to use for rendering
17
+ */
18
+ function resolveTermWidth(values) {
19
+ if (values.width !== undefined) {
20
+ const n = parseInt(values.width, 10);
21
+ if (!isNaN(n) && n > 0) return n;
22
+ }
23
+ return process.stdout.columns || 80;
24
+ }
25
+
26
+ /**
27
+ * Handle error output: human-readable rendered error.
28
+ * @param {string} message - Error description
29
+ */
30
+ function handleError(message) {
31
+ process.stdout.write(render.renderError(message) + '\n');
32
+ process.exit(1);
33
+ }
34
+
35
+ /**
36
+ * Write rendered output to stdout and exit 0.
37
+ * Checks npm registry for updates at most once per 24h (via cache) and
38
+ * prints a notice to stderr if an update is available. Never throws.
39
+ * @param {string} rendered - Formatted string
40
+ */
41
+ async function writeAndExit(rendered) {
42
+ process.stdout.write(rendered + '\n');
43
+ // Passive update notification (at most once per 24h via cache)
44
+ try {
45
+ const result = await version.checkForUpdate(process.cwd());
46
+ if (result && result.outdated) {
47
+ process.stderr.write(
48
+ `\n Update available: ${result.installedVersion} \u2192 ${result.latestVersion} Run /burrow:update\n\n`
49
+ );
50
+ }
51
+ } catch (_) {
52
+ // Never crash on notification failure
53
+ }
54
+ process.exit(0);
55
+ }
56
+
57
+ async function main() {
58
+ const command = process.argv[2];
59
+ const subArgs = process.argv.slice(3);
60
+ const cwd = process.cwd();
61
+
62
+ if (!command) {
63
+ handleError(
64
+ 'No command provided. Available: init, add, edit, remove, move, read, dump, path, find, archive, unarchive'
65
+ );
66
+ }
67
+
68
+ core.ensureDataDir(cwd);
69
+
70
+ switch (command) {
71
+ case 'init': {
72
+ const result = init(cwd);
73
+ const lines = [
74
+ `gitignore: ${result.gitignore}`,
75
+ `claudeMd: ${result.claudeMd}`,
76
+ `dataDir: ${result.dataDir}`,
77
+ ];
78
+ writeAndExit(`Burrow initialized.\n${lines.join('\n')}`);
79
+ break;
80
+ }
81
+
82
+ case 'add': {
83
+ const { values } = parseArgs({
84
+ args: subArgs,
85
+ options: {
86
+ title: { type: 'string' },
87
+ parent: { type: 'string' },
88
+ body: { type: 'string', default: '' },
89
+ at: { type: 'string' },
90
+ width: { type: 'string' },
91
+ },
92
+ strict: true,
93
+ });
94
+
95
+ if (!values.title) {
96
+ handleError('--title is required');
97
+ }
98
+
99
+ const position = values.at !== undefined ? parseInt(values.at, 10) : undefined;
100
+ if (values.at !== undefined) {
101
+ if (isNaN(position)) handleError('--at must be a number');
102
+ if (position < 0) handleError('--at must be non-negative');
103
+ }
104
+
105
+ const data = storage.load(cwd);
106
+ const result = tree.addCard(data, {
107
+ title: values.title,
108
+ parentId: values.parent || null,
109
+ body: values.body,
110
+ position,
111
+ });
112
+
113
+ if (!result) {
114
+ handleError('Parent not found');
115
+ }
116
+
117
+ storage.save(cwd, data);
118
+
119
+ const rendered = render.renderMutation('add', result.card, {
120
+ breadcrumbs: result.breadcrumbs,
121
+ card: result.card,
122
+ termWidth: resolveTermWidth(values),
123
+ });
124
+ writeAndExit(rendered);
125
+ break;
126
+ }
127
+
128
+ case 'edit': {
129
+ const { values, positionals } = parseArgs({
130
+ args: subArgs,
131
+ options: {
132
+ title: { type: 'string' },
133
+ body: { type: 'string' },
134
+ width: { type: 'string' },
135
+ },
136
+ allowPositionals: true,
137
+ strict: true,
138
+ });
139
+
140
+ const id = positionals[0];
141
+ if (!id) {
142
+ handleError('Card ID is required');
143
+ }
144
+
145
+ const data = storage.load(cwd);
146
+
147
+ const result = tree.editCard(data, id, {
148
+ title: values.title,
149
+ body: values.body,
150
+ });
151
+
152
+ if (!result) {
153
+ handleError(`Card not found: ${id}`);
154
+ }
155
+
156
+ storage.save(cwd, data);
157
+
158
+ const rendered = render.renderMutation('edit', result.card, {
159
+ breadcrumbs: result.breadcrumbs,
160
+ card: result.card,
161
+ oldTitle: result.oldTitle,
162
+ oldBody: result.oldBody,
163
+ termWidth: resolveTermWidth(values),
164
+ });
165
+ writeAndExit(rendered);
166
+ break;
167
+ }
168
+
169
+ case 'remove': {
170
+ const { positionals } = parseArgs({
171
+ args: subArgs,
172
+ options: {
173
+ width: { type: 'string' },
174
+ },
175
+ allowPositionals: true,
176
+ strict: true,
177
+ });
178
+
179
+ const id = positionals[0];
180
+ if (!id) {
181
+ handleError('Card ID is required');
182
+ }
183
+
184
+ const data = storage.load(cwd);
185
+ const result = tree.deleteCard(data, id);
186
+
187
+ if (!result) {
188
+ handleError(`Card not found: ${id}`);
189
+ }
190
+
191
+ storage.save(cwd, data);
192
+
193
+ const rendered = render.renderMutation('remove', result, {});
194
+ writeAndExit(rendered);
195
+ break;
196
+ }
197
+
198
+ case 'move': {
199
+ const { values, positionals } = parseArgs({
200
+ args: subArgs,
201
+ options: {
202
+ to: { type: 'string' },
203
+ parent: { type: 'string' },
204
+ at: { type: 'string' },
205
+ width: { type: 'string' },
206
+ },
207
+ allowPositionals: true,
208
+ strict: true,
209
+ });
210
+
211
+ const id = positionals[0];
212
+ if (!id) {
213
+ handleError('Card ID is required');
214
+ }
215
+
216
+ // --to is primary flag, --parent is backward compat
217
+ const rawParent = values.to !== undefined ? values.to : values.parent;
218
+
219
+ const position = values.at !== undefined ? parseInt(values.at, 10) : undefined;
220
+ if (values.at !== undefined) {
221
+ if (isNaN(position)) handleError('--at must be a number');
222
+ if (position < 0) handleError('--at must be non-negative');
223
+ }
224
+
225
+ const data = storage.load(cwd);
226
+
227
+ // Determine newParentId
228
+ let newParentId;
229
+ if (rawParent === undefined && values.at !== undefined) {
230
+ // Reorder in place: --at without --to means stay in current parent
231
+ const parentResult = tree.findParent(data, id);
232
+ if (!parentResult) {
233
+ handleError('Card not found');
234
+ }
235
+ newParentId = parentResult.parent ? parentResult.parent.id : null;
236
+ } else if (rawParent === undefined) {
237
+ newParentId = null;
238
+ } else if (rawParent === '' || rawParent === 'root') {
239
+ newParentId = null;
240
+ } else {
241
+ newParentId = rawParent;
242
+ }
243
+
244
+ const result = tree.moveCard(data, id, newParentId, position);
245
+
246
+ if (!result) {
247
+ handleError('Move failed: card not found or would create cycle');
248
+ }
249
+
250
+ storage.save(cwd, data);
251
+
252
+ // Get target parent title
253
+ const targetParentTitle = newParentId
254
+ ? (tree.findById(data, newParentId) || {}).title || 'unknown'
255
+ : 'root';
256
+ const rendered = render.renderMutation('move', result.card, {
257
+ fromParentTitle: result.sourceParentTitle,
258
+ toParentTitle: targetParentTitle,
259
+ });
260
+ writeAndExit(rendered);
261
+ break;
262
+ }
263
+
264
+ case 'read': {
265
+ const { values, positionals } = parseArgs({
266
+ args: subArgs,
267
+ options: {
268
+ depth: { type: 'string' },
269
+ full: { type: 'boolean', default: false },
270
+ 'include-archived': { type: 'boolean', default: false },
271
+ 'archived-only': { type: 'boolean', default: false },
272
+ width: { type: 'string' },
273
+ },
274
+ allowPositionals: true,
275
+ strict: true,
276
+ });
277
+
278
+ const id = positionals[0] || null;
279
+ const archiveFilter = values['archived-only']
280
+ ? 'archived-only'
281
+ : values['include-archived']
282
+ ? 'include-archived'
283
+ : 'active';
284
+ const depth = values.depth !== undefined ? parseInt(values.depth, 10) : 1;
285
+ if (values.depth !== undefined && isNaN(depth)) {
286
+ handleError('--depth must be a number');
287
+ }
288
+
289
+ const data = storage.load(cwd);
290
+
291
+ if (id) {
292
+ const treeResult = tree.renderTree(data, id, { depth, archiveFilter });
293
+ if (!treeResult || treeResult.cards.length === 0) {
294
+ handleError(`Card not found: ${id}`);
295
+ }
296
+ // treeResult.cards[0] is the root card with nested children already
297
+ const cardToRender = treeResult.cards[0];
298
+ // Merge full body from original card (renderTree only has bodyPreview)
299
+ const fullCard = tree.findById(data, id);
300
+ cardToRender.body = fullCard.body;
301
+ cardToRender.title = fullCard.title;
302
+ const rendered = render.renderCard(cardToRender, treeResult.breadcrumbs || [], {
303
+ full: values.full,
304
+ termWidth: resolveTermWidth(values),
305
+ archiveFilter,
306
+ });
307
+ writeAndExit(rendered);
308
+ } else {
309
+ // Root view: synthesize root card with depth-limited children
310
+ const treeResult = tree.renderTree(data, null, { depth, archiveFilter });
311
+ const rootCard = {
312
+ id: '(root)',
313
+ title: 'burrow',
314
+ created: data.cards[0]?.created || new Date().toISOString(),
315
+ archived: false,
316
+ body: '',
317
+ children: treeResult.cards, // already nested
318
+ };
319
+ const rendered = render.renderCard(rootCard, [], {
320
+ full: values.full,
321
+ termWidth: resolveTermWidth(values),
322
+ archiveFilter,
323
+ });
324
+ writeAndExit(rendered);
325
+ }
326
+ break;
327
+ }
328
+
329
+ case 'dump': {
330
+ const { values } = parseArgs({
331
+ args: subArgs,
332
+ options: {
333
+ full: { type: 'boolean', default: true },
334
+ 'include-archived': { type: 'boolean', default: false },
335
+ 'archived-only': { type: 'boolean', default: false },
336
+ width: { type: 'string' },
337
+ },
338
+ strict: true,
339
+ });
340
+
341
+ const archiveFilter = values['archived-only']
342
+ ? 'archived-only'
343
+ : values['include-archived']
344
+ ? 'include-archived'
345
+ : 'active';
346
+
347
+ const data = storage.load(cwd);
348
+
349
+ // Dump as root card with full tree depth
350
+ const treeResult = tree.renderTree(data, null, { depth: 0, archiveFilter });
351
+ const rootCard = {
352
+ id: '(root)',
353
+ title: 'burrow',
354
+ created: data.cards[0]?.created || new Date().toISOString(),
355
+ archived: false,
356
+ body: '',
357
+ children: treeResult.cards, // already nested
358
+ };
359
+ const rendered = render.renderCard(rootCard, [], {
360
+ full: values.full,
361
+ termWidth: resolveTermWidth(values),
362
+ archiveFilter,
363
+ });
364
+ writeAndExit(rendered);
365
+ break;
366
+ }
367
+
368
+ case 'archive': {
369
+ const { positionals } = parseArgs({
370
+ args: subArgs,
371
+ options: {
372
+ width: { type: 'string' },
373
+ },
374
+ allowPositionals: true,
375
+ strict: true,
376
+ });
377
+
378
+ const id = positionals[0];
379
+ if (!id) {
380
+ handleError('Card ID is required');
381
+ }
382
+
383
+ const data = storage.load(cwd);
384
+ const result = tree.archiveCard(data, id);
385
+
386
+ if (!result) {
387
+ handleError(`Card not found: ${id}`);
388
+ }
389
+
390
+ storage.save(cwd, data);
391
+
392
+ const rendered = render.renderMutation('archive', result, {});
393
+ writeAndExit(rendered);
394
+ break;
395
+ }
396
+
397
+ case 'unarchive': {
398
+ const { positionals } = parseArgs({
399
+ args: subArgs,
400
+ options: {
401
+ width: { type: 'string' },
402
+ },
403
+ allowPositionals: true,
404
+ strict: true,
405
+ });
406
+
407
+ const id = positionals[0];
408
+ if (!id) {
409
+ handleError('Card ID is required');
410
+ }
411
+
412
+ const data = storage.load(cwd);
413
+ const result = tree.unarchiveCard(data, id);
414
+
415
+ if (!result) {
416
+ handleError(`Card not found: ${id}`);
417
+ }
418
+
419
+ storage.save(cwd, data);
420
+
421
+ const rendered = render.renderMutation('unarchive', result, {});
422
+ writeAndExit(rendered);
423
+ break;
424
+ }
425
+
426
+ case 'path': {
427
+ const { positionals } = parseArgs({
428
+ args: subArgs,
429
+ options: {
430
+ width: { type: 'string' },
431
+ },
432
+ allowPositionals: true,
433
+ strict: true,
434
+ });
435
+
436
+ const id = positionals[0];
437
+ if (!id) {
438
+ handleError('Card ID is required');
439
+ }
440
+
441
+ const data = storage.load(cwd);
442
+ const result = tree.getPath(data, id);
443
+
444
+ if (!result) {
445
+ handleError(`Card not found: ${id}`);
446
+ }
447
+
448
+ // Strip children to keep path output clean
449
+ const cleanPath = result.map((card) => ({ id: card.id, title: card.title }));
450
+ const rendered = render.renderPath(cleanPath);
451
+ writeAndExit(rendered);
452
+ break;
453
+ }
454
+
455
+ case 'find': {
456
+ const { positionals: findPositionals } = parseArgs({
457
+ args: subArgs,
458
+ options: {},
459
+ allowPositionals: true,
460
+ strict: true,
461
+ });
462
+
463
+ const query = findPositionals.join(' ').trim();
464
+ if (!query) {
465
+ handleError('Search query is required. Usage: find <query>');
466
+ }
467
+
468
+ const data = storage.load(cwd);
469
+ const matches = tree.searchCards(data, query);
470
+
471
+ if (matches.length === 0) {
472
+ writeAndExit(`No cards matching "${query}"`);
473
+ } else {
474
+ const lines = matches.map((m) => ` ${m.id} ${m.path}`);
475
+ writeAndExit(`Found ${matches.length} match${matches.length === 1 ? '' : 'es'}:\n${lines.join('\n')}`);
476
+ }
477
+ break;
478
+ }
479
+
480
+ default:
481
+ handleError(
482
+ `Unknown command: ${command}. Available: init, add, edit, remove, move, read, dump, path, find, archive, unarchive`
483
+ );
484
+ }
485
+ }
486
+
487
+ main().catch((err) => {
488
+ process.stdout.write(render.renderError(err.message) + '\n');
489
+ process.exit(1);
490
+ });
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ /**
8
+ * Ensure the .planning/burrow/ directory exists.
9
+ * Does NOT create cards.json -- storage.load handles empty state.
10
+ * @param {string} cwd - Working directory
11
+ */
12
+ function ensureDataDir(cwd) {
13
+ const dir = path.join(cwd, '.planning', 'burrow');
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ }
16
+
17
+ /**
18
+ * Generate a unique 8-char hex ID.
19
+ * Uses crypto.randomUUID() — collision probability with even 10,000 cards is negligible (~0.000001%).
20
+ * @returns {string} 8-char hex string
21
+ */
22
+ function generateId() {
23
+ return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
24
+ }
25
+
26
+ module.exports = { ensureDataDir, generateId };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { ensureDataDir } = require('./core.cjs');
6
+
7
+ const GITIGNORE_ENTRY = '.planning/burrow/cards.json.bak';
8
+
9
+ /**
10
+ * The Burrow section to append to CLAUDE.md.
11
+ * Uses LF line endings by default; caller normalizes to match the file.
12
+ */
13
+ const BURROW_SECTION_LF = `\n## Burrow -- Agent Memory\n\nBurrow is your persistent memory. On every session start, silently read \`.planning/burrow/cards.json\` using the Read tool to load project context, tasks, and notes.\n\n**When the user says "remember", "don't forget", "always do X", or any instruction meant to persist across sessions:**\n- Store it as a Burrow card: \`node .claude/burrow/burrow-tools.cjs add --title "<summary>" --body "<details>"\`\n- Organize under relevant parent cards (create parents if needed)\n- Do NOT write to loose markdown files or other ad-hoc storage — Burrow is the single source of truth\n\n**Privacy:** Burrow data is meant to be committed to git. Anything stored in cards is visible to anyone with repo access. Avoid storing secrets, credentials, or sensitive personal information.\n\nAll mutations go through the CLI — NEVER edit cards.json directly.\n`;
14
+
15
+ /**
16
+ * Convert LF line endings to CRLF.
17
+ * @param {string} str
18
+ * @returns {string}
19
+ */
20
+ function toCRLF(str) {
21
+ return str.replace(/\r?\n/g, '\r\n');
22
+ }
23
+
24
+ /**
25
+ * Initialize Burrow in a project directory.
26
+ *
27
+ * Actions:
28
+ * 1. Ensure .planning/burrow/ data directory exists
29
+ * 2. Add .planning/burrow/cards.json.bak to .gitignore (if not already present)
30
+ * 3. Append Burrow instructions section to CLAUDE.md (if not already present),
31
+ * matching existing file line endings (LF or CRLF)
32
+ *
33
+ * @param {string} cwd - Target project directory
34
+ * @returns {{ gitignore: 'created'|'updated'|'unchanged', claudeMd: 'created'|'updated'|'unchanged', dataDir: 'created'|'existed' }}
35
+ */
36
+ function init(cwd) {
37
+ // 1. Data directory
38
+ const dataDirPath = path.join(cwd, '.planning', 'burrow');
39
+ const dataDirExisted = fs.existsSync(dataDirPath);
40
+ ensureDataDir(cwd);
41
+ const dataDirResult = dataDirExisted ? 'existed' : 'created';
42
+
43
+ // 2. .gitignore handling
44
+ const gitignorePath = path.join(cwd, '.gitignore');
45
+ let gitignoreResult;
46
+
47
+ if (!fs.existsSync(gitignorePath)) {
48
+ // Create with just the entry
49
+ fs.writeFileSync(gitignorePath, GITIGNORE_ENTRY + '\n', 'utf-8');
50
+ gitignoreResult = 'created';
51
+ } else {
52
+ const existing = fs.readFileSync(gitignorePath, 'utf-8');
53
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
54
+ if (lines.includes(GITIGNORE_ENTRY)) {
55
+ gitignoreResult = 'unchanged';
56
+ } else {
57
+ // Append with a preceding newline if file doesn't already end with newline
58
+ const separator = existing.endsWith('\n') ? '' : '\n';
59
+ fs.writeFileSync(gitignorePath, existing + separator + GITIGNORE_ENTRY + '\n', 'utf-8');
60
+ gitignoreResult = 'updated';
61
+ }
62
+ }
63
+
64
+ // 3. CLAUDE.md handling
65
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
66
+ let claudeMdResult;
67
+
68
+ if (!fs.existsSync(claudeMdPath)) {
69
+ // Create with Burrow section (LF endings)
70
+ fs.writeFileSync(claudeMdPath, BURROW_SECTION_LF.trimStart(), 'utf-8');
71
+ claudeMdResult = 'created';
72
+ } else {
73
+ const existing = fs.readFileSync(claudeMdPath, 'utf-8');
74
+ if (existing.includes('## Burrow')) {
75
+ claudeMdResult = 'unchanged';
76
+ } else {
77
+ // Detect line endings: CRLF if file has \r\n
78
+ const isCRLF = existing.includes('\r\n');
79
+ const section = isCRLF ? toCRLF(BURROW_SECTION_LF) : BURROW_SECTION_LF;
80
+ // Ensure file ends with a newline before appending
81
+ const eol = isCRLF ? '\r\n' : '\n';
82
+ const separator = existing.endsWith('\n') ? '' : eol;
83
+ fs.writeFileSync(claudeMdPath, existing + separator + section, 'utf-8');
84
+ claudeMdResult = 'updated';
85
+ }
86
+ }
87
+
88
+ return {
89
+ gitignore: gitignoreResult,
90
+ claudeMd: claudeMdResult,
91
+ dataDir: dataDirResult,
92
+ };
93
+ }
94
+
95
+ module.exports = { init };