noggin-cli 0.1.2

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/noggin.mjs ADDED
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env node
2
+ // Noggin CLI — thin wrapper over noggin-api.mjs.
3
+ //
4
+ // Responsibilities:
5
+ // 1. Parse argv into { verb, positional, flags }.
6
+ // 2. Resolve the noggin file (--file / $NOGGIN_FILE / ~/.noggin.yaml).
7
+ // 3. Translate flags into the verb's typed options object.
8
+ // 4. Invoke the API; catch NogginError; format output.
9
+ //
10
+ // All noggin logic lives in noggin-api.mjs. This file owns nothing about
11
+ // the data model — only how to interpret argv and how to render results
12
+ // for a terminal.
13
+
14
+ import {
15
+ apiPush, apiAdd, apiMove, apiGoto, apiDone, apiPop, apiEdit,
16
+ apiShow, apiNote, apiDelete, apiWhere,
17
+ resolveFile, DEFAULT_FILE, NogginError,
18
+ formatSuccess, formatError,
19
+ } from './noggin-api.mjs';
20
+
21
+ // ── Argument parsing ─────────────────────────────────────────────────────────
22
+
23
+ const VALUE_FLAGS = new Set(['file', 'title', 'before', 'after', 'into']);
24
+ const OPTIONAL_VALUE_FLAGS = new Set(['goto']);
25
+ const BOOL_FLAGS = new Set(['json', 'with-json', 'help', 'no-children', 'with-notes', 'done', 'open', 'recursive', 'with-siblings', 'with-descendants', 'with-all', 'force', 'close-all']);
26
+
27
+ // Mutable handle so fail() can include the verb / file / --json state
28
+ // regardless of where in the dispatch lifecycle the error fires.
29
+ const exitContext = { verb: null, file: null, json: false };
30
+
31
+ function fail(msg, code = 2, errCode = 'noggin-error') {
32
+ if (exitContext.json) {
33
+ const envelope = formatError({
34
+ verb: exitContext.verb,
35
+ file: exitContext.file,
36
+ error: new NogginError(msg, { code: errCode, exitCode: code }),
37
+ });
38
+ process.stderr.write(JSON.stringify(envelope, null, 2) + '\n');
39
+ } else {
40
+ process.stderr.write(`noggin: ${msg}\n`);
41
+ }
42
+ process.exit(code);
43
+ }
44
+
45
+ function looksLikePath(value) {
46
+ const text = String(value ?? '');
47
+ if (text === '.' || text === '..' || text === '-' || text === '+') return true;
48
+ if (text.startsWith('./') || text.startsWith('../')) return true;
49
+ if (text.startsWith('-/') || text.startsWith('+/')) return true;
50
+ // Position sequences. Absolute starts with `/`; bare `1/2/3` is relative
51
+ // (short for `./1/2/3`). looksLikePath only decides "is this an argument
52
+ // that looks like a path?" — the resolver decides what it means.
53
+ if (/^\/?\d+(?:\/\d+)*$/.test(text)) return true;
54
+ return false;
55
+ }
56
+
57
+ function parseFlagToken(token) {
58
+ const eq = token.indexOf('=');
59
+ if (eq < 0) return { key: token.slice(2), value: undefined, hasInlineValue: false };
60
+ return { key: token.slice(2, eq), value: token.slice(eq + 1), hasInlineValue: true };
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const positional = [];
65
+ const flags = {};
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ if (a === '--help' || a === '-h') { flags.help = true; continue; }
69
+ if (a.startsWith('--')) {
70
+ const { key, value, hasInlineValue } = parseFlagToken(a);
71
+ if (BOOL_FLAGS.has(key)) { flags[key] = true; continue; }
72
+ if (OPTIONAL_VALUE_FLAGS.has(key)) {
73
+ if (hasInlineValue) {
74
+ flags[key] = value || true;
75
+ } else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && looksLikePath(argv[i + 1])) {
76
+ flags[key] = argv[i + 1];
77
+ i++;
78
+ } else {
79
+ flags[key] = true;
80
+ }
81
+ continue;
82
+ }
83
+ if (VALUE_FLAGS.has(key)) {
84
+ const val = hasInlineValue ? value : argv[i + 1];
85
+ if (val === undefined || val.startsWith('--')) {
86
+ fail(`flag --${key} requires a value`);
87
+ }
88
+ flags[key] = val;
89
+ if (!hasInlineValue) i++;
90
+ continue;
91
+ }
92
+ fail(`unknown flag: --${key}`);
93
+ } else {
94
+ positional.push(a);
95
+ }
96
+ }
97
+ return { positional, flags };
98
+ }
99
+
100
+ function splitCommand(argv) {
101
+ const leading = [];
102
+ let i = 0;
103
+ while (i < argv.length && (argv[i].startsWith('--') || argv[i] === '-h')) {
104
+ const a = argv[i];
105
+ leading.push(a);
106
+ const parsedFlag = a.startsWith('--') ? parseFlagToken(a) : null;
107
+ const key = a === '--help' || a === '-h' ? 'help' : parsedFlag ? parsedFlag.key : null;
108
+ if (key && VALUE_FLAGS.has(key)) {
109
+ if (parsedFlag?.hasInlineValue) {
110
+ i++;
111
+ } else if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
112
+ fail(`flag --${key} requires a value`);
113
+ } else {
114
+ leading.push(argv[i + 1]);
115
+ i += 2;
116
+ }
117
+ } else if (key && OPTIONAL_VALUE_FLAGS.has(key) && !parsedFlag?.hasInlineValue &&
118
+ argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && looksLikePath(argv[i + 1])) {
119
+ leading.push(argv[i + 1]);
120
+ i += 2;
121
+ } else {
122
+ i++;
123
+ }
124
+ }
125
+ return {
126
+ verb: argv[i],
127
+ args: [...leading, ...argv.slice(i + 1)],
128
+ };
129
+ }
130
+
131
+ // ── Output formatting (terminal-only helpers) ────────────────────────────────
132
+
133
+ /**
134
+ * Format one row from an ItemView. Layout:
135
+ *
136
+ * [indent]<path> (📍)(✅) title (✏️)
137
+ *
138
+ * The absolute path replaces the bracket-position notation so spine
139
+ * ancestors (which only show one item per depth, with trimmed siblings)
140
+ * still self-describe — `/1/3` reads as "third child of root 1" without
141
+ * needing the surrounding peers for context.
142
+ *
143
+ * - 📍 (active) and ✅ (done) sit between the path and the title.
144
+ * - ✏️ (has notes) is appended after the title.
145
+ */
146
+ function formatItemLine(item, activeKey, indent) {
147
+ const leading = [];
148
+ if (item.key === activeKey) leading.push('📍');
149
+ if (item.done) leading.push('✅');
150
+ const prefix = leading.length ? leading.join('') + ' ' : '';
151
+ const trailing = Array.isArray(item.notes) && item.notes.length ? ' ✏️' : '';
152
+ return `${indent}${item.path ?? '?'} ${prefix}${item.title}${trailing}`;
153
+ }
154
+
155
+ /**
156
+ * Render a CurrentTreeView as the human "current tree" view. The view is
157
+ * a recursive tree of nodes — each node has an optional `children` slot
158
+ * that's either `null` (a leaf of this view) or an array of more nodes.
159
+ * Walk it directly: print the node, recurse into children if present,
160
+ * append note bodies when we hit the target.
161
+ */
162
+ function printView(view, opts = {}) {
163
+ if (!view || !Array.isArray(view.items) || view.items.length === 0) {
164
+ process.stdout.write('(no item)\n');
165
+ return;
166
+ }
167
+ const lines = [];
168
+
169
+ function walk(node, depth) {
170
+ const indent = ' '.repeat(depth);
171
+ lines.push(formatItemLine(node, view.activeKey, indent));
172
+ if (Array.isArray(node.children)) {
173
+ for (const kid of node.children) walk(kid, depth + 1);
174
+ }
175
+ if (opts.includeNotes && node.key === view.targetKey) {
176
+ const notes = Array.isArray(node.notes) ? node.notes : [];
177
+ lines.push(`${indent} notes:${notes.length ? '' : ' (none)'}`);
178
+ for (const note of notes) {
179
+ lines.push(`${indent} - ${note.timestamp || '(no timestamp)'}`);
180
+ for (const ln of (note.text || '').split('\n')) lines.push(`${indent} ${ln}`);
181
+ }
182
+ }
183
+ }
184
+
185
+ for (const root of view.items) walk(root, 0);
186
+ process.stdout.write(lines.join('\n') + '\n');
187
+ }
188
+
189
+ function printJson(envelope) {
190
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
191
+ }
192
+
193
+ function emitOutput(flags, human, data) {
194
+ if (flags.json) {
195
+ printJson(formatSuccess({ verb: exitContext.verb, file: exitContext.file, data }));
196
+ return;
197
+ }
198
+ human();
199
+ if (flags['with-json']) {
200
+ process.stdout.write('\n');
201
+ printJson(formatSuccess({ verb: exitContext.verb, file: exitContext.file, data }));
202
+ }
203
+ }
204
+
205
+ /** Render a verb's CurrentTreeView in both human and JSON modes. */
206
+ function emitView(view, flags, opts = {}) {
207
+ if (view === null || view === undefined) {
208
+ emitOutput(flags, () => process.stdout.write('(no item)\n'), view ?? null);
209
+ return;
210
+ }
211
+ emitOutput(
212
+ flags,
213
+ () => printView(view, { includeNotes: Boolean(opts.includeNotes) }),
214
+ view,
215
+ );
216
+ }
217
+
218
+ // ── Flag → API options translators ───────────────────────────────────────────
219
+
220
+ function getFile(flags) {
221
+ const file = resolveFile({ file: flags.file }).file;
222
+ exitContext.file = file;
223
+ return file;
224
+ }
225
+ function hasGoto(flags) { return Object.prototype.hasOwnProperty.call(flags, 'goto'); }
226
+ function gotoOpt(flags) { return hasGoto(flags) ? flags.goto : undefined; }
227
+
228
+ function parsePlacement(flags, commandName) {
229
+ const present = ['before', 'after', 'into'].filter((k) => flags[k] !== undefined);
230
+ if (present.length === 0) return undefined;
231
+ if (present.length > 1) fail(`${commandName}: --before, --after, and --into are mutually exclusive`);
232
+ const kind = present[0];
233
+ return { kind, anchor: flags[kind] };
234
+ }
235
+
236
+ // ── Command dispatch ─────────────────────────────────────────────────────────
237
+
238
+ function cmdPush({ positional, flags }) {
239
+ const title = flags.title || positional.join(' ').trim();
240
+ const file = getFile(flags);
241
+ emitView(apiPush(file, { title }), flags);
242
+ }
243
+
244
+ function cmdAdd({ positional, flags }) {
245
+ const title = flags.title || positional.join(' ').trim();
246
+ const file = getFile(flags);
247
+ emitView(apiAdd(file, {
248
+ title,
249
+ placement: parsePlacement(flags, 'add'),
250
+ goto: gotoOpt(flags),
251
+ }), flags);
252
+ }
253
+
254
+ function cmdMove({ positional, flags }) {
255
+ if (positional.length > 1) fail('move: accepts at most one path');
256
+ const file = getFile(flags);
257
+ emitView(apiMove(file, {
258
+ path: positional[0],
259
+ placement: parsePlacement(flags, 'move'),
260
+ goto: gotoOpt(flags),
261
+ }), flags);
262
+ }
263
+
264
+ function cmdGoto({ positional, flags }) {
265
+ if (!positional[0]) fail('goto: path required');
266
+ const file = getFile(flags);
267
+ emitView(apiGoto(file, { path: positional[0] }), flags);
268
+ }
269
+
270
+ function closeFlags(flags) {
271
+ return {
272
+ force: flags.force === true,
273
+ closeAll: flags['close-all'] === true,
274
+ };
275
+ }
276
+
277
+ function cmdDone({ positional, flags }) {
278
+ if (positional.length > 1) fail('done: accepts at most one path');
279
+ const file = getFile(flags);
280
+ emitView(apiDone(file, {
281
+ path: positional[0],
282
+ ...closeFlags(flags),
283
+ ...(hasGoto(flags) ? { goto: flags.goto } : {}),
284
+ }), flags);
285
+ }
286
+
287
+ function cmdPop({ positional, flags }) {
288
+ if (positional.length > 0) fail('pop: takes no path; pop always operates on the active item');
289
+ if (hasGoto(flags)) fail('pop: --goto is not supported; pop always moves to the active item\'s parent');
290
+ const file = getFile(flags);
291
+ emitView(apiPop(file, closeFlags(flags)), flags);
292
+ }
293
+
294
+ function cmdEdit({ positional, flags }) {
295
+ if (flags.done === true && flags.open === true) {
296
+ fail('edit: --done and --open are mutually exclusive');
297
+ }
298
+ if (positional.length > 1) fail('edit: accepts at most one path');
299
+ const file = getFile(flags);
300
+ const opts = {
301
+ path: positional[0],
302
+ goto: gotoOpt(flags),
303
+ ...closeFlags(flags),
304
+ };
305
+ if (flags.done === true) opts.done = true;
306
+ else if (flags.open === true) opts.done = false;
307
+ if (flags.title !== undefined) opts.title = flags.title;
308
+ emitView(apiEdit(file, opts), flags);
309
+ }
310
+
311
+ function cmdShow({ positional, flags }) {
312
+ const file = getFile(flags);
313
+ const withSiblings = flags['with-siblings'] === true || flags['with-all'] === true;
314
+ const withDescendants = flags['with-descendants'] === true || flags['with-all'] === true;
315
+ const noChildren = flags['no-children'] === true;
316
+ if (withDescendants && noChildren) {
317
+ fail('show: --with-descendants and --no-children are mutually exclusive');
318
+ }
319
+ const view = apiShow(file, {
320
+ path: positional[0],
321
+ includeChildren: !noChildren,
322
+ withSiblings,
323
+ withDescendants,
324
+ goto: gotoOpt(flags),
325
+ });
326
+ if (view === null) {
327
+ emitOutput(flags, () => process.stdout.write('(no active item; pass a path)\n'), null);
328
+ return;
329
+ }
330
+ emitView(view, flags, { includeNotes: flags['with-notes'] === true });
331
+ }
332
+
333
+ function cmdNote({ positional, flags }) {
334
+ const file = getFile(flags);
335
+ let pathArg;
336
+ let textParts = positional;
337
+ if (positional.length > 0 && looksLikePath(positional[0])) {
338
+ pathArg = positional[0];
339
+ textParts = positional.slice(1);
340
+ }
341
+ emitView(apiNote(file, {
342
+ path: pathArg,
343
+ text: textParts.join(' ').trim(),
344
+ goto: gotoOpt(flags),
345
+ }), flags);
346
+ }
347
+
348
+ function cmdDelete({ positional, flags }) {
349
+ if (hasGoto(flags)) fail('delete: --goto is not supported');
350
+ if (positional.length === 0) fail('delete: path required');
351
+ if (positional.length > 1) fail('delete: accepts at most one path');
352
+ const file = getFile(flags);
353
+ const result = apiDelete(file, {
354
+ path: positional[0],
355
+ recursive: flags.recursive === true,
356
+ });
357
+ emitOutput(
358
+ flags,
359
+ () => {
360
+ const tail = result.descendantCount ? ` and ${result.descendantCount} descendant(s)` : '';
361
+ process.stdout.write(`deleted ${result.deleted.path}${tail}\n`);
362
+ if (result.view) printView(result.view);
363
+ else process.stdout.write('(tree is now empty)\n');
364
+ },
365
+ result,
366
+ );
367
+ }
368
+
369
+ function cmdWhere({ flags }) {
370
+ const info = apiWhere({ file: flags.file });
371
+ exitContext.file = info.file;
372
+ emitOutput(
373
+ flags,
374
+ () => {
375
+ process.stdout.write(`${info.file}\n`);
376
+ process.stdout.write(` source: ${info.source}\n`);
377
+ process.stdout.write(` exists: ${info.exists}\n`);
378
+ },
379
+ info,
380
+ );
381
+ }
382
+
383
+ function cmdHelp() {
384
+ process.stdout.write([
385
+ 'noggin — working-memory tree CLI',
386
+ '',
387
+ 'An item has: title, done flag, timestamps, and append-only notes.',
388
+ 'No fixed schema for content. Anything worth saying goes in a note.',
389
+ '',
390
+ 'Addressing:',
391
+ ' path absolute starts with `/` (e.g. "/1/2/3");',
392
+ ' everything else is relative to the active item:',
393
+ ' "." ".." "-" "+" "./X" "../X" "-/X" "+/X" or bare "X/Y" (= "./X/Y")',
394
+ ' tree "<path> 📍✅ title ✏️" — 📍 active, ✅ done (before title),',
395
+ ' ✏️ has notes (after title)',
396
+ '',
397
+ 'Verbs:',
398
+ ' push <title> child of active, becomes active',
399
+ ' add <title> [--before|--after|--into <path>] [--goto [path]]',
400
+ ' child of active by default; placement flags pick a different spot',
401
+ ' move [<path>] (--before|--after|--into <path>) [--goto [path]]',
402
+ ' relocate an item; required placement flag picks the destination',
403
+ ' goto <path> make <path> the active item',
404
+ ' done [<path>] [--force|--close-all]',
405
+ ' mark done, then make the parent active (idempotent);',
406
+ ' --close-all closes any open descendants first;',
407
+ ' --force closes the target anyway, leaving kids open',
408
+ ' pop [--force|--close-all] same as `done` on the active item (no path)',
409
+ ' edit [<path>] [--done|--open] [--title T] [--force|--close-all] [--goto [path]]',
410
+ ' edit an item\'s state and/or title (idempotent);',
411
+ ' --done/--open change lifecycle state;',
412
+ ' --title T renames the item;',
413
+ ' pass at least one of those three',
414
+ ' show [<path>] [--no-children|--with-descendants] [--with-siblings] [--with-all] [--with-notes] [--goto [path]]',
415
+ ' current tree view; --with-notes adds note bodies;',
416
+ ' --with-siblings includes all sibling rows along the spine;',
417
+ ' --with-descendants expands the target subtree recursively;',
418
+ ' --with-all = --with-siblings --with-descendants',
419
+ ' note [<path>] <text…> [--goto [path]]',
420
+ ' append a timestamped note',
421
+ ' delete <path> [--recursive] remove an item; --recursive also removes its subtree',
422
+ ' where print which noggin file would be used and why',
423
+ ' help',
424
+ '',
425
+ 'Item creation flags (push/add):',
426
+ ' --title T title (alternative to positional)',
427
+ '',
428
+ 'Common:',
429
+ ' --file <path> override the file resolution (highest priority)',
430
+ ' --goto [path] move after command; relative paths resolve from target',
431
+ ' --json structured output',
432
+ ' --with-json human output followed by structured output',
433
+ '',
434
+ 'File resolution (highest first):',
435
+ ' 1. --file <path>',
436
+ ` 2. $NOGGIN_FILE env var`,
437
+ ` 3. ${DEFAULT_FILE}`,
438
+ '',
439
+ ].join('\n'));
440
+ }
441
+
442
+ // ── Main ─────────────────────────────────────────────────────────────────────
443
+
444
+ function dispatch(verb, parsed) {
445
+ switch (verb) {
446
+ case 'push': return cmdPush(parsed);
447
+ case 'add': return cmdAdd(parsed);
448
+ case 'move': return cmdMove(parsed);
449
+ case 'goto': return cmdGoto(parsed);
450
+ case 'done': return cmdDone(parsed);
451
+ case 'pop': return cmdPop(parsed);
452
+ case 'edit': return cmdEdit(parsed);
453
+ case 'show': return cmdShow(parsed);
454
+ case 'note': return cmdNote(parsed);
455
+ case 'delete': return cmdDelete(parsed);
456
+ case 'where': return cmdWhere(parsed);
457
+ case 'help':
458
+ case '--help':
459
+ case '-h': cmdHelp(); return;
460
+ default: fail(`unknown command: ${verb} (try 'help')`);
461
+ }
462
+ }
463
+
464
+ function main() {
465
+ const argv = process.argv.slice(2);
466
+ if (argv.length === 0) { cmdHelp(); process.exit(0); }
467
+ const { verb, args } = splitCommand(argv);
468
+ const parsed = parseArgs(args);
469
+ exitContext.verb = verb || null;
470
+ exitContext.json = Boolean(parsed.flags.json);
471
+ if (parsed.flags.help) { cmdHelp(); process.exit(0); }
472
+ try {
473
+ dispatch(verb, parsed);
474
+ } catch (e) {
475
+ if (e instanceof NogginError) {
476
+ fail(e.message, e.exitCode, e.code);
477
+ }
478
+ throw e;
479
+ }
480
+ }
481
+
482
+ main();
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "noggin-cli",
3
+ "version": "0.1.2",
4
+ "description": "A working-memory tree CLI for in-flight work.",
5
+ "type": "module",
6
+ "bin": {
7
+ "noggin": "./noggin.mjs",
8
+ "noggin-mcp": "./noggin-mcp.mjs"
9
+ },
10
+ "main": "./noggin.mjs",
11
+ "files": [
12
+ "noggin.mjs",
13
+ "noggin-mcp.mjs",
14
+ "noggin-api.mjs",
15
+ "noggin-api.d.mts",
16
+ "SKILL.md",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.0.0",
25
+ "js-yaml": "^4.1.0"
26
+ },
27
+ "scripts": {
28
+ "test": "node --check noggin.mjs && node --test"
29
+ },
30
+ "keywords": [
31
+ "noggin",
32
+ "todo",
33
+ "working-memory",
34
+ "agent-skill",
35
+ "copilot"
36
+ ],
37
+ "author": "dornstein",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/dornstein/noggin.git",
42
+ "directory": "cli"
43
+ },
44
+ "homepage": "https://github.com/dornstein/noggin#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/dornstein/noggin/issues"
47
+ }
48
+ }