noggin-cli 0.1.2 → 0.4.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 CHANGED
@@ -1,45 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // Noggin CLI — thin wrapper over noggin-api.mjs.
2
+ // Noggin CLI — thin wrapper over the engine.
3
3
  //
4
4
  // Responsibilities:
5
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.
6
+ // 2. Resolve a noggin location (--noggin / $NOGGIN / default).
7
+ // 3. Translate flags into verb option objects.
8
+ // 4. Dispatch to `verbs.X(noggin, opts)` from the engine.
9
+ // 5. Format the result for a terminal.
9
10
  //
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.
11
+ // All noggin logic lives in the engine. This file owns nothing about
12
+ // the data model — only argv interpretation and terminal rendering.
13
+ //
14
+ // Embedding: I/O and noggin construction are injected. `runCommand(argv,
15
+ // { io, openNoggin, defaultLocationLabel })` returns a Promise<exitCode>
16
+ // and never touches `process` directly, so the same dispatcher powers
17
+ // the shebang entry below and the in-browser playground on the docs site.
18
+ //
19
+ // The Node file backend is imported lazily so the browser bundle can
20
+ // avoid pulling in `node:fs`/`node:os`/`node:path`.
13
21
 
14
22
  import {
15
- apiPush, apiAdd, apiMove, apiGoto, apiDone, apiPop, apiEdit,
16
- apiShow, apiNote, apiDelete, apiWhere,
17
- resolveFile, DEFAULT_FILE, NogginError,
23
+ NogginError,
24
+ factories,
18
25
  formatSuccess, formatError,
26
+ verbs,
19
27
  } from './noggin-api.mjs';
20
28
 
21
29
  // ── Argument parsing ─────────────────────────────────────────────────────────
22
30
 
23
- const VALUE_FLAGS = new Set(['file', 'title', 'before', 'after', 'into']);
31
+ const VALUE_FLAGS = new Set(['noggin', 'title', 'before', 'after', 'into']);
24
32
  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 };
33
+ const BOOL_FLAGS = new Set([
34
+ 'json', 'with-json', 'help', 'no-children', 'with-notes', 'done', 'open',
35
+ 'recursive', 'with-siblings', 'with-descendants', 'with-all', 'force',
36
+ 'close-all',
37
+ ]);
38
+
39
+ // Internal sentinel: fail() throws this so runCommand() can convert it
40
+ // into an exit code without unwinding into the embedder's stack.
41
+ class ExitSignal extends Error {
42
+ constructor(code) { super(`exit:${code}`); this.code = code; this.name = 'ExitSignal'; }
43
+ }
30
44
 
31
- function fail(msg, code = 2, errCode = 'noggin-error') {
32
- if (exitContext.json) {
45
+ function fail(ctx, msg, code = 2, errCode = 'noggin-error') {
46
+ if (ctx.json) {
33
47
  const envelope = formatError({
34
- verb: exitContext.verb,
35
- file: exitContext.file,
48
+ verb: ctx.verb,
36
49
  error: new NogginError(msg, { code: errCode, exitCode: code }),
37
50
  });
38
- process.stderr.write(JSON.stringify(envelope, null, 2) + '\n');
51
+ ctx.io.stderr(JSON.stringify(envelope, null, 2) + '\n');
39
52
  } else {
40
- process.stderr.write(`noggin: ${msg}\n`);
53
+ ctx.io.stderr(`noggin: ${msg}\n`);
41
54
  }
42
- process.exit(code);
55
+ throw new ExitSignal(code);
43
56
  }
44
57
 
45
58
  function looksLikePath(value) {
@@ -47,9 +60,6 @@ function looksLikePath(value) {
47
60
  if (text === '.' || text === '..' || text === '-' || text === '+') return true;
48
61
  if (text.startsWith('./') || text.startsWith('../')) return true;
49
62
  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
63
  if (/^\/?\d+(?:\/\d+)*$/.test(text)) return true;
54
64
  return false;
55
65
  }
@@ -60,7 +70,7 @@ function parseFlagToken(token) {
60
70
  return { key: token.slice(2, eq), value: token.slice(eq + 1), hasInlineValue: true };
61
71
  }
62
72
 
63
- function parseArgs(argv) {
73
+ function parseArgs(ctx, argv) {
64
74
  const positional = [];
65
75
  const flags = {};
66
76
  for (let i = 0; i < argv.length; i++) {
@@ -83,13 +93,13 @@ function parseArgs(argv) {
83
93
  if (VALUE_FLAGS.has(key)) {
84
94
  const val = hasInlineValue ? value : argv[i + 1];
85
95
  if (val === undefined || val.startsWith('--')) {
86
- fail(`flag --${key} requires a value`);
96
+ fail(ctx, `flag --${key} requires a value`);
87
97
  }
88
98
  flags[key] = val;
89
99
  if (!hasInlineValue) i++;
90
100
  continue;
91
101
  }
92
- fail(`unknown flag: --${key}`);
102
+ fail(ctx, `unknown flag: --${key}`);
93
103
  } else {
94
104
  positional.push(a);
95
105
  }
@@ -97,7 +107,7 @@ function parseArgs(argv) {
97
107
  return { positional, flags };
98
108
  }
99
109
 
100
- function splitCommand(argv) {
110
+ function splitCommand(ctx, argv) {
101
111
  const leading = [];
102
112
  let i = 0;
103
113
  while (i < argv.length && (argv[i].startsWith('--') || argv[i] === '-h')) {
@@ -109,7 +119,7 @@ function splitCommand(argv) {
109
119
  if (parsedFlag?.hasInlineValue) {
110
120
  i++;
111
121
  } else if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
112
- fail(`flag --${key} requires a value`);
122
+ fail(ctx, `flag --${key} requires a value`);
113
123
  } else {
114
124
  leading.push(argv[i + 1]);
115
125
  i += 2;
@@ -130,19 +140,6 @@ function splitCommand(argv) {
130
140
 
131
141
  // ── Output formatting (terminal-only helpers) ────────────────────────────────
132
142
 
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
143
  function formatItemLine(item, activeKey, indent) {
147
144
  const leading = [];
148
145
  if (item.key === activeKey) leading.push('📍');
@@ -152,20 +149,12 @@ function formatItemLine(item, activeKey, indent) {
152
149
  return `${indent}${item.path ?? '?'} ${prefix}${item.title}${trailing}`;
153
150
  }
154
151
 
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 = {}) {
152
+ function printView(ctx, view, opts = {}) {
163
153
  if (!view || !Array.isArray(view.items) || view.items.length === 0) {
164
- process.stdout.write('(no item)\n');
154
+ ctx.io.stdout('(no item)\n');
165
155
  return;
166
156
  }
167
157
  const lines = [];
168
-
169
158
  function walk(node, depth) {
170
159
  const indent = ' '.repeat(depth);
171
160
  lines.push(formatItemLine(node, view.activeKey, indent));
@@ -181,122 +170,116 @@ function printView(view, opts = {}) {
181
170
  }
182
171
  }
183
172
  }
184
-
185
173
  for (const root of view.items) walk(root, 0);
186
- process.stdout.write(lines.join('\n') + '\n');
174
+ ctx.io.stdout(lines.join('\n') + '\n');
187
175
  }
188
176
 
189
- function printJson(envelope) {
190
- process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
177
+ function printJson(ctx, envelope) {
178
+ ctx.io.stdout(JSON.stringify(envelope, null, 2) + '\n');
191
179
  }
192
180
 
193
- function emitOutput(flags, human, data) {
181
+ function emitOutput(ctx, flags, human, data) {
194
182
  if (flags.json) {
195
- printJson(formatSuccess({ verb: exitContext.verb, file: exitContext.file, data }));
183
+ printJson(ctx, formatSuccess({ verb: ctx.verb, data }));
196
184
  return;
197
185
  }
198
186
  human();
199
187
  if (flags['with-json']) {
200
- process.stdout.write('\n');
201
- printJson(formatSuccess({ verb: exitContext.verb, file: exitContext.file, data }));
188
+ ctx.io.stdout('\n');
189
+ printJson(ctx, formatSuccess({ verb: ctx.verb, data }));
202
190
  }
203
191
  }
204
192
 
205
- /** Render a verb's CurrentTreeView in both human and JSON modes. */
206
- function emitView(view, flags, opts = {}) {
193
+ function emitView(ctx, view, flags, opts = {}) {
207
194
  if (view === null || view === undefined) {
208
- emitOutput(flags, () => process.stdout.write('(no item)\n'), view ?? null);
195
+ emitOutput(ctx, flags, () => ctx.io.stdout('(no item)\n'), view ?? null);
209
196
  return;
210
197
  }
211
198
  emitOutput(
199
+ ctx,
212
200
  flags,
213
- () => printView(view, { includeNotes: Boolean(opts.includeNotes) }),
201
+ () => printView(ctx, view, { includeNotes: Boolean(opts.includeNotes) }),
214
202
  view,
215
203
  );
216
204
  }
217
205
 
218
- // ── Flag → API options translators ───────────────────────────────────────────
206
+ // ── Flag → verb opts translators ─────────────────────────────────────────────
219
207
 
220
- function getFile(flags) {
221
- const file = resolveFile({ file: flags.file }).file;
222
- exitContext.file = file;
223
- return file;
224
- }
225
208
  function hasGoto(flags) { return Object.prototype.hasOwnProperty.call(flags, 'goto'); }
226
209
  function gotoOpt(flags) { return hasGoto(flags) ? flags.goto : undefined; }
227
210
 
228
- function parsePlacement(flags, commandName) {
211
+ function parsePlacement(ctx, flags, commandName) {
229
212
  const present = ['before', 'after', 'into'].filter((k) => flags[k] !== undefined);
230
213
  if (present.length === 0) return undefined;
231
- if (present.length > 1) fail(`${commandName}: --before, --after, and --into are mutually exclusive`);
214
+ if (present.length > 1) fail(ctx, `${commandName}: --before, --after, and --into are mutually exclusive`);
232
215
  const kind = present[0];
233
216
  return { kind, anchor: flags[kind] };
234
217
  }
235
218
 
219
+ function closeFlags(flags) {
220
+ return {
221
+ force: flags.force === true,
222
+ closeAll: flags['close-all'] === true,
223
+ };
224
+ }
225
+
236
226
  // ── Command dispatch ─────────────────────────────────────────────────────────
237
227
 
238
- function cmdPush({ positional, flags }) {
228
+ async function cmdPush(ctx, { positional, flags }) {
239
229
  const title = flags.title || positional.join(' ').trim();
240
- const file = getFile(flags);
241
- emitView(apiPush(file, { title }), flags);
230
+ const noggin = await ctx.openNoggin(flags);
231
+ emitView(ctx, await verbs.push(noggin, { title }), flags);
242
232
  }
243
233
 
244
- function cmdAdd({ positional, flags }) {
234
+ async function cmdAdd(ctx, { positional, flags }) {
245
235
  const title = flags.title || positional.join(' ').trim();
246
- const file = getFile(flags);
247
- emitView(apiAdd(file, {
236
+ const noggin = await ctx.openNoggin(flags);
237
+ emitView(ctx, await verbs.add(noggin, {
248
238
  title,
249
- placement: parsePlacement(flags, 'add'),
239
+ placement: parsePlacement(ctx, flags, 'add'),
250
240
  goto: gotoOpt(flags),
251
241
  }), flags);
252
242
  }
253
243
 
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, {
244
+ async function cmdMove(ctx, { positional, flags }) {
245
+ if (positional.length > 1) fail(ctx, 'move: accepts at most one path');
246
+ const noggin = await ctx.openNoggin(flags);
247
+ emitView(ctx, await verbs.move(noggin, {
258
248
  path: positional[0],
259
- placement: parsePlacement(flags, 'move'),
249
+ placement: parsePlacement(ctx, flags, 'move'),
260
250
  goto: gotoOpt(flags),
261
251
  }), flags);
262
252
  }
263
253
 
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
- };
254
+ async function cmdGoto(ctx, { positional, flags }) {
255
+ if (!positional[0]) fail(ctx, 'goto: path required');
256
+ const noggin = await ctx.openNoggin(flags);
257
+ emitView(ctx, await verbs.goto(noggin, { path: positional[0] }), flags);
275
258
  }
276
259
 
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, {
260
+ async function cmdDone(ctx, { positional, flags }) {
261
+ if (positional.length > 1) fail(ctx, 'done: accepts at most one path');
262
+ const noggin = await ctx.openNoggin(flags);
263
+ emitView(ctx, await verbs.done(noggin, {
281
264
  path: positional[0],
282
265
  ...closeFlags(flags),
283
266
  ...(hasGoto(flags) ? { goto: flags.goto } : {}),
284
267
  }), flags);
285
268
  }
286
269
 
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);
270
+ async function cmdPop(ctx, { positional, flags }) {
271
+ if (positional.length > 0) fail(ctx, 'pop: takes no path; pop always operates on the active item');
272
+ if (hasGoto(flags)) fail(ctx, "pop: --goto is not supported; pop always moves to the active item's parent");
273
+ const noggin = await ctx.openNoggin(flags);
274
+ emitView(ctx, await verbs.pop(noggin, closeFlags(flags)), flags);
292
275
  }
293
276
 
294
- function cmdEdit({ positional, flags }) {
277
+ async function cmdEdit(ctx, { positional, flags }) {
295
278
  if (flags.done === true && flags.open === true) {
296
- fail('edit: --done and --open are mutually exclusive');
279
+ fail(ctx, 'edit: --done and --open are mutually exclusive');
297
280
  }
298
- if (positional.length > 1) fail('edit: accepts at most one path');
299
- const file = getFile(flags);
281
+ if (positional.length > 1) fail(ctx, 'edit: accepts at most one path');
282
+ const noggin = await ctx.openNoggin(flags);
300
283
  const opts = {
301
284
  path: positional[0],
302
285
  goto: gotoOpt(flags),
@@ -305,18 +288,18 @@ function cmdEdit({ positional, flags }) {
305
288
  if (flags.done === true) opts.done = true;
306
289
  else if (flags.open === true) opts.done = false;
307
290
  if (flags.title !== undefined) opts.title = flags.title;
308
- emitView(apiEdit(file, opts), flags);
291
+ emitView(ctx, await verbs.edit(noggin, opts), flags);
309
292
  }
310
293
 
311
- function cmdShow({ positional, flags }) {
312
- const file = getFile(flags);
294
+ async function cmdShow(ctx, { positional, flags }) {
295
+ const noggin = await ctx.openNoggin(flags);
313
296
  const withSiblings = flags['with-siblings'] === true || flags['with-all'] === true;
314
297
  const withDescendants = flags['with-descendants'] === true || flags['with-all'] === true;
315
298
  const noChildren = flags['no-children'] === true;
316
299
  if (withDescendants && noChildren) {
317
- fail('show: --with-descendants and --no-children are mutually exclusive');
300
+ fail(ctx, 'show: --with-descendants and --no-children are mutually exclusive');
318
301
  }
319
- const view = apiShow(file, {
302
+ const view = await verbs.show(noggin, {
320
303
  path: positional[0],
321
304
  includeChildren: !noChildren,
322
305
  withSiblings,
@@ -324,64 +307,101 @@ function cmdShow({ positional, flags }) {
324
307
  goto: gotoOpt(flags),
325
308
  });
326
309
  if (view === null) {
327
- emitOutput(flags, () => process.stdout.write('(no active item; pass a path)\n'), null);
310
+ emitOutput(ctx, flags, () => ctx.io.stdout('(no active item; pass a path)\n'), null);
328
311
  return;
329
312
  }
330
- emitView(view, flags, { includeNotes: flags['with-notes'] === true });
313
+ emitView(ctx, view, flags, { includeNotes: flags['with-notes'] === true });
331
314
  }
332
315
 
333
- function cmdNote({ positional, flags }) {
334
- const file = getFile(flags);
316
+ async function cmdNote(ctx, { positional, flags }) {
317
+ const noggin = await ctx.openNoggin(flags);
335
318
  let pathArg;
336
319
  let textParts = positional;
337
320
  if (positional.length > 0 && looksLikePath(positional[0])) {
338
321
  pathArg = positional[0];
339
322
  textParts = positional.slice(1);
340
323
  }
341
- emitView(apiNote(file, {
324
+ emitView(ctx, await verbs.note(noggin, {
342
325
  path: pathArg,
343
326
  text: textParts.join(' ').trim(),
344
327
  goto: gotoOpt(flags),
345
328
  }), flags);
346
329
  }
347
330
 
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, {
331
+ async function cmdDelete(ctx, { positional, flags }) {
332
+ if (hasGoto(flags)) fail(ctx, 'delete: --goto is not supported');
333
+ if (positional.length === 0) fail(ctx, 'delete: path required');
334
+ if (positional.length > 1) fail(ctx, 'delete: accepts at most one path');
335
+ const noggin = await ctx.openNoggin(flags);
336
+ const result = await verbs.delete(noggin, {
354
337
  path: positional[0],
355
338
  recursive: flags.recursive === true,
356
339
  });
357
340
  emitOutput(
341
+ ctx,
358
342
  flags,
359
343
  () => {
360
344
  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');
345
+ ctx.io.stdout(`deleted ${result.deleted.path}${tail}\n`);
346
+ if (result.view) printView(ctx, result.view);
347
+ else ctx.io.stdout('(tree is now empty)\n');
348
+ },
349
+ result,
350
+ );
351
+ }
352
+
353
+ async function cmdWhere(ctx, { flags }) {
354
+ const noggin = await ctx.openNoggin(flags);
355
+ const location = noggin.describe();
356
+ emitOutput(
357
+ ctx,
358
+ flags,
359
+ () => { ctx.io.stdout(`${location}\n`); },
360
+ location,
361
+ );
362
+ }
363
+
364
+ async function cmdCopy(ctx, { positional, flags }) {
365
+ // `noggin copy <from> <to>` — open both via the engine (separate from
366
+ // ctx.openNoggin which is tied to --noggin/$NOGGIN/default) and call
367
+ // verbs.copy. v1 is a whole-noggin append-only copy; see SKILL.md.
368
+ if (positional.length < 2) {
369
+ fail(ctx, 'copy: usage: noggin copy <from> <to>', 2, 'usage');
370
+ }
371
+ const [fromLoc, toLoc] = positional;
372
+ const source = await ctx.openNogginAt(fromLoc);
373
+ const dest = await ctx.openNogginAt(toLoc);
374
+ const result = await verbs.copy(source, dest, {});
375
+ emitOutput(
376
+ ctx,
377
+ flags,
378
+ () => {
379
+ ctx.io.stdout(`copied ${result.copied} item(s) from ${source.describe()} to ${dest.describe()}\n`);
364
380
  },
365
381
  result,
366
382
  );
367
383
  }
368
384
 
369
- function cmdWhere({ flags }) {
370
- const info = apiWhere({ file: flags.file });
371
- exitContext.file = info.file;
385
+ async function cmdFactories(ctx, { flags }) {
386
+ const list = factories.list();
372
387
  emitOutput(
388
+ ctx,
373
389
  flags,
374
390
  () => {
375
- process.stdout.write(`${info.file}\n`);
376
- process.stdout.write(` source: ${info.source}\n`);
377
- process.stdout.write(` exists: ${info.exists}\n`);
391
+ if (list.length === 0) { ctx.io.stdout('(no factories registered)\n'); return; }
392
+ const w = Math.max(...list.map((f) => f.scheme.length), 6);
393
+ ctx.io.stdout(`${'scheme'.padEnd(w)} default\n`);
394
+ ctx.io.stdout(`${'-'.repeat(w)} -------\n`);
395
+ for (const f of list) {
396
+ ctx.io.stdout(`${f.scheme.padEnd(w)} ${f.default ? 'yes' : ''}\n`);
397
+ }
378
398
  },
379
- info,
399
+ list,
380
400
  );
381
401
  }
382
402
 
383
- function cmdHelp() {
384
- process.stdout.write([
403
+ async function cmdHelp(ctx) {
404
+ ctx.io.stdout([
385
405
  'noggin — working-memory tree CLI',
386
406
  '',
387
407
  'An item has: title, done flag, timestamps, and append-only notes.',
@@ -419,64 +439,179 @@ function cmdHelp() {
419
439
  ' note [<path>] <text…> [--goto [path]]',
420
440
  ' append a timestamped note',
421
441
  ' delete <path> [--recursive] remove an item; --recursive also removes its subtree',
422
- ' where print which noggin file would be used and why',
442
+ ' where print which noggin would be used and why',
443
+ ' copy <from> <to> append every item from <from> into <to> (whole-noggin, append-only, fresh keys; notes and timestamps preserved)',
444
+ ' factories list registered backend factories',
423
445
  ' help',
424
446
  '',
425
447
  'Item creation flags (push/add):',
426
448
  ' --title T title (alternative to positional)',
427
449
  '',
428
450
  'Common:',
429
- ' --file <path> override the file resolution (highest priority)',
451
+ ' --noggin <location> override the noggin location (highest priority)',
430
452
  ' --goto [path] move after command; relative paths resolve from target',
431
453
  ' --json structured output',
432
454
  ' --with-json human output followed by structured output',
433
455
  '',
434
- 'File resolution (highest first):',
435
- ' 1. --file <path>',
436
- ` 2. $NOGGIN_FILE env var`,
437
- ` 3. ${DEFAULT_FILE}`,
456
+ 'Noggin location (highest first):',
457
+ ' 1. --noggin <location>',
458
+ ' 2. $NOGGIN env var',
459
+ ` 3. ${ctx.defaultLocationLabel}`,
460
+ '',
461
+ 'Locations may be a bare path (defaults to the file backend) or a',
462
+ 'URI like `file:///abs/path.yaml`. Run `noggin factories` to see all',
463
+ 'registered backends.',
438
464
  '',
439
465
  ].join('\n'));
440
466
  }
441
467
 
442
- // ── Main ─────────────────────────────────────────────────────────────────────
468
+ // ── Embedding entry point ───────────────────────────────────────────────────
469
+ //
470
+ // `runCommand(argv, opts)` is the engine of the CLI. It accepts an array
471
+ // of argv tokens (no program name; just what would come after `noggin`)
472
+ // and a bundle of injected dependencies, and returns the exit code.
473
+ //
474
+ // opts.io.stdout(str) — write a chunk of stdout text
475
+ // opts.io.stderr(str) — write a chunk of stderr text
476
+ // opts.io.exit(code) — optional; called with the final exit code
477
+ // opts.openNoggin(flags) — async (flags) => Noggin; resolves the backend
478
+ // opts.openNogginAt(location)— optional; async (location) => Noggin; opens an arbitrary
479
+ // location (used by `copy` which needs two noggins at once)
480
+ // opts.defaultLocationLabel — optional; string shown in help text as
481
+ // the default noggin location
482
+ //
483
+ // When `openNoggin`/`openNogginAt`/`defaultLocationLabel` are omitted
484
+ // the node file backend is loaded lazily (so a browser bundle that
485
+ // supplies its own openNoggin never pulls in `node:fs` et al.).
486
+
487
+ export async function runCommand(argv, opts = {}) {
488
+ const io = opts.io || defaultNodeIo();
489
+ const openNogginFn = opts.openNoggin || await defaultNodeOpenNoggin();
490
+ const openNogginAtFn = opts.openNogginAt || await defaultNodeOpenNogginAt();
491
+ const defaultLocationLabel = opts.defaultLocationLabel
492
+ || (opts.openNoggin ? '(injected)' : await defaultNodeLocationLabel());
493
+ const ctx = {
494
+ verb: null,
495
+ json: false,
496
+ io,
497
+ openNoggin: openNogginFn,
498
+ openNogginAt: openNogginAtFn,
499
+ defaultLocationLabel,
500
+ };
501
+
502
+ let exitCode = 0;
503
+ try {
504
+ if (!argv || argv.length === 0) { await cmdHelp(ctx); return finish(io, 0); }
505
+ const { verb, args } = splitCommand(ctx, argv);
506
+ const parsed = parseArgs(ctx, args);
507
+ ctx.verb = verb || null;
508
+ ctx.json = Boolean(parsed.flags.json);
509
+ if (parsed.flags.help) { await cmdHelp(ctx); return finish(io, 0); }
510
+ try {
511
+ await dispatch(ctx, verb, parsed);
512
+ } catch (e) {
513
+ if (e instanceof NogginError) {
514
+ fail(ctx, e.message, e.exitCode, e.code);
515
+ }
516
+ throw e;
517
+ }
518
+ } catch (e) {
519
+ if (e instanceof ExitSignal) {
520
+ exitCode = e.code;
521
+ } else {
522
+ io.stderr(`noggin: ${e && e.message ? e.message : e}\n`);
523
+ exitCode = 1;
524
+ }
525
+ }
526
+ return finish(io, exitCode);
527
+ }
528
+
529
+ function finish(io, code) {
530
+ if (typeof io.exit === 'function') io.exit(code);
531
+ return code;
532
+ }
443
533
 
444
- function dispatch(verb, parsed) {
534
+ async function dispatch(ctx, verb, parsed) {
445
535
  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);
536
+ case 'push': return await cmdPush(ctx, parsed);
537
+ case 'add': return await cmdAdd(ctx, parsed);
538
+ case 'move': return await cmdMove(ctx, parsed);
539
+ case 'goto': return await cmdGoto(ctx, parsed);
540
+ case 'done': return await cmdDone(ctx, parsed);
541
+ case 'pop': return await cmdPop(ctx, parsed);
542
+ case 'edit': return await cmdEdit(ctx, parsed);
543
+ case 'show': return await cmdShow(ctx, parsed);
544
+ case 'note': return await cmdNote(ctx, parsed);
545
+ case 'delete': return await cmdDelete(ctx, parsed);
546
+ case 'where': return await cmdWhere(ctx, parsed);
547
+ case 'copy': return await cmdCopy(ctx, parsed);
548
+ case 'factories': return await cmdFactories(ctx, parsed);
457
549
  case 'help':
458
550
  case '--help':
459
- case '-h': cmdHelp(); return;
460
- default: fail(`unknown command: ${verb} (try 'help')`);
551
+ case '-h': await cmdHelp(ctx); return;
552
+ default: fail(ctx, `unknown command: ${verb} (try 'help')`);
461
553
  }
462
554
  }
463
555
 
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
- }
556
+ // ── Lazy node defaults ──────────────────────────────────────────────────────
557
+ //
558
+ // These helpers are only invoked when the caller leaves the matching
559
+ // opt unset. The dynamic import of `./backends/file.mjs` means a browser
560
+ // bundle that always passes its own io/openNoggin never pulls in
561
+ // `node:fs`/`node:os`/`node:path`.
562
+
563
+ function defaultNodeIo() {
564
+ return {
565
+ stdout: (s) => process.stdout.write(s),
566
+ stderr: (s) => process.stderr.write(s),
567
+ exit: (code) => process.exit(code),
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Default openNoggin(flags): resolves the location by the standard
573
+ * priority and opens via the engine's factory registry. Imports the
574
+ * file backend for side-effect (registers the file:// factory).
575
+ */
576
+ async function defaultNodeOpenNoggin() {
577
+ await import('./backends/file.mjs');
578
+ const { openNoggin } = await import('./noggin-api.mjs');
579
+ const defaultLoc = await defaultNodeLocationLabel();
580
+ return (flags) => openNoggin(resolveLocation(flags, defaultLoc));
480
581
  }
481
582
 
482
- main();
583
+ /**
584
+ * Default openNogginAt(location): opens an explicit location string
585
+ * via the engine. Used by `copy` and any other verb that needs to
586
+ * open a noggin from a path argument rather than the resolved
587
+ * --noggin/$NOGGIN/default. Imports the file backend side-effect
588
+ * the same way as defaultNodeOpenNoggin.
589
+ */
590
+ async function defaultNodeOpenNogginAt() {
591
+ await import('./backends/file.mjs');
592
+ const { openNoggin } = await import('./noggin-api.mjs');
593
+ return (location) => openNoggin(location);
594
+ }
595
+
596
+ async function defaultNodeLocationLabel() {
597
+ // The default noggin location is `~/.noggin.yaml` in canonical form.
598
+ // The file backend's expandHome() turns this into the actual home dir
599
+ // at open time, but the *location string* stays symbolic so `where`
600
+ // shows the human-readable form.
601
+ return '~/.noggin.yaml';
602
+ }
603
+
604
+ function resolveLocation(flags, defaultLocation) {
605
+ if (flags && flags.noggin) return flags.noggin;
606
+ if (typeof process !== 'undefined' && process.env && process.env.NOGGIN) return process.env.NOGGIN;
607
+ return defaultLocation;
608
+ }
609
+
610
+ // ── Shebang main ────────────────────────────────────────────────────────────
611
+
612
+ if (typeof process !== 'undefined' && Array.isArray(process.argv)) {
613
+ runCommand(process.argv.slice(2)).catch((e) => {
614
+ process.stderr.write(`noggin: ${e && e.message ? e.message : e}\n`);
615
+ process.exit(1);
616
+ });
617
+ }