noggin-cli 0.1.3 → 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/README.md +97 -17
- package/SKILL.md +10 -2
- package/noggin-api.d.mts +262 -159
- package/noggin-api.mjs +654 -534
- package/noggin-mcp.mjs +163 -85
- package/noggin.mjs +311 -176
- package/package.json +4 -1
package/noggin.mjs
CHANGED
|
@@ -1,45 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Noggin CLI — thin wrapper over
|
|
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
|
|
7
|
-
// 3. Translate flags into
|
|
8
|
-
// 4.
|
|
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
|
|
11
|
-
// the data model — only
|
|
12
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
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(['
|
|
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([
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 (
|
|
45
|
+
function fail(ctx, msg, code = 2, errCode = 'noggin-error') {
|
|
46
|
+
if (ctx.json) {
|
|
33
47
|
const envelope = formatError({
|
|
34
|
-
verb:
|
|
35
|
-
file: exitContext.file,
|
|
48
|
+
verb: ctx.verb,
|
|
36
49
|
error: new NogginError(msg, { code: errCode, exitCode: code }),
|
|
37
50
|
});
|
|
38
|
-
|
|
51
|
+
ctx.io.stderr(JSON.stringify(envelope, null, 2) + '\n');
|
|
39
52
|
} else {
|
|
40
|
-
|
|
53
|
+
ctx.io.stderr(`noggin: ${msg}\n`);
|
|
41
54
|
}
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
ctx.io.stdout(lines.join('\n') + '\n');
|
|
187
175
|
}
|
|
188
176
|
|
|
189
|
-
function printJson(envelope) {
|
|
190
|
-
|
|
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:
|
|
183
|
+
printJson(ctx, formatSuccess({ verb: ctx.verb, data }));
|
|
196
184
|
return;
|
|
197
185
|
}
|
|
198
186
|
human();
|
|
199
187
|
if (flags['with-json']) {
|
|
200
|
-
|
|
201
|
-
printJson(formatSuccess({ verb:
|
|
188
|
+
ctx.io.stdout('\n');
|
|
189
|
+
printJson(ctx, formatSuccess({ verb: ctx.verb, data }));
|
|
202
190
|
}
|
|
203
191
|
}
|
|
204
192
|
|
|
205
|
-
|
|
206
|
-
function emitView(view, flags, opts = {}) {
|
|
193
|
+
function emitView(ctx, view, flags, opts = {}) {
|
|
207
194
|
if (view === null || view === undefined) {
|
|
208
|
-
emitOutput(flags, () =>
|
|
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 →
|
|
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
|
|
241
|
-
emitView(
|
|
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
|
|
247
|
-
emitView(
|
|
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
|
|
257
|
-
emitView(
|
|
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
|
|
267
|
-
emitView(
|
|
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
|
|
280
|
-
emitView(
|
|
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(
|
|
290
|
-
const
|
|
291
|
-
emitView(
|
|
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
|
|
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(
|
|
291
|
+
emitView(ctx, await verbs.edit(noggin, opts), flags);
|
|
309
292
|
}
|
|
310
293
|
|
|
311
|
-
function cmdShow({ positional, flags }) {
|
|
312
|
-
const
|
|
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 =
|
|
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, () =>
|
|
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
|
|
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(
|
|
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
|
|
353
|
-
const result =
|
|
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
|
-
|
|
362
|
-
if (result.view) printView(result.view);
|
|
363
|
-
else
|
|
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
|
|
370
|
-
const
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
399
|
+
list,
|
|
380
400
|
);
|
|
381
401
|
}
|
|
382
402
|
|
|
383
|
-
function cmdHelp() {
|
|
384
|
-
|
|
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
|
|
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
|
-
' --
|
|
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
|
-
'
|
|
435
|
-
' 1. --
|
|
436
|
-
|
|
437
|
-
` 3. ${
|
|
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
|
-
// ──
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
+
}
|