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-mcp.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// noggin MCP server — exposes the noggin verbs over the Model Context Protocol
|
|
3
|
-
// via stdio. Hosts that can't see the VS Code language-model tools (
|
|
4
|
-
// Claude Code, Codex
|
|
3
|
+
// via stdio. Hosts that can't see the VS Code language-model tools (GitHub
|
|
4
|
+
// Copilot CLI, Claude Code, Codex) can spawn this server to get the same
|
|
5
|
+
// toolset.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Multi-noggin: every tool call requires a `noggin` parameter, a canonical
|
|
8
|
+
// location string (e.g. `~/.noggin.yaml`, `./.noggin.yaml`, `file:///abs/path`).
|
|
9
|
+
// The server opens that noggin per call, caches the result for the lifetime
|
|
10
|
+
// of the process, and routes the verb to it. There is no server-wide default
|
|
11
|
+
// and no env-var fallback — every call carries the noggin it operates on so
|
|
12
|
+
// agents can work with multiple noggins in one session.
|
|
9
13
|
//
|
|
10
14
|
// Wire-up (varies by host):
|
|
11
15
|
// - Codex CLI: declared in plugin/.codex-plugin/plugin.json
|
|
12
|
-
// - Claude Code / Copilot CLI: user adds an mcpServers entry pointing here
|
|
16
|
+
// - Claude Code / GitHub Copilot CLI: user adds an mcpServers entry pointing here
|
|
13
17
|
// - VS Code (outside the extension): user adds the same to .vscode/mcp.json
|
|
14
18
|
//
|
|
15
19
|
// The protocol layer (request parsing, schema validation, stdio framing) is
|
|
@@ -21,15 +25,34 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
21
25
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
26
|
|
|
23
27
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
resolveFile, formatSuccess, formatError,
|
|
28
|
+
formatSuccess, formatError,
|
|
29
|
+
factories, openNoggin as engineOpenNoggin, verbs,
|
|
27
30
|
} from './noggin-api.mjs';
|
|
31
|
+
import './backends/file.mjs'; // side-effect: registers the file:// factory
|
|
32
|
+
import url from 'node:url';
|
|
33
|
+
import pkg from './package.json' with { type: 'json' };
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
// Bundled clients (Codex plugin) and direct runs (npx noggin-mcp) both pick
|
|
36
|
+
// up the version from package.json — esbuild inlines the JSON, Node imports
|
|
37
|
+
// it at runtime.
|
|
38
|
+
const PKG = { name: 'noggin-mcp', version: pkg.version };
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
// Per-process cache of opened noggins by canonical location string. Each
|
|
41
|
+
// noggin's mutator queue serializes its own writes, so the only sharing
|
|
42
|
+
// hazard is multiple cache entries for the same physical file under
|
|
43
|
+
// different location strings — that's fine because each FileNoggin holds
|
|
44
|
+
// its own cross-process lock at write time.
|
|
45
|
+
const _noggins = new Map();
|
|
46
|
+
async function openNogginByLocation(location) {
|
|
47
|
+
let p = _noggins.get(location);
|
|
48
|
+
if (!p) {
|
|
49
|
+
p = engineOpenNoggin(location);
|
|
50
|
+
_noggins.set(location, p);
|
|
51
|
+
// If open fails, drop the rejected promise so a retry can try again
|
|
52
|
+
// (e.g. user fixes the path).
|
|
53
|
+
p.catch(() => _noggins.delete(location));
|
|
54
|
+
}
|
|
55
|
+
return p;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
function placementFrom(input, { required }) {
|
|
@@ -44,6 +67,10 @@ function placementFrom(input, { required }) {
|
|
|
44
67
|
return { kind, anchor: String(input[kind]) };
|
|
45
68
|
}
|
|
46
69
|
|
|
70
|
+
const NOGGIN_PROP = {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'canonical location of the noggin to operate on — e.g. `~/.noggin.yaml`, `./.noggin.yaml`, `/abs/path.yaml`, or `file:///abs/path.yaml`. Required on every tool call.',
|
|
73
|
+
};
|
|
47
74
|
const PATH_PROP = { type: 'string', description: 'noggin path (absolute /1/2 or relative — see SKILL.md)' };
|
|
48
75
|
const TITLE_PROP = { type: 'string', description: 'item title (one line)' };
|
|
49
76
|
const GOTO_PROP = { type: ['string', 'boolean'], description: 'true = goto the target; string = goto this path after the verb' };
|
|
@@ -57,14 +84,29 @@ const CLOSE_FLAGS = {
|
|
|
57
84
|
closeAll: { type: 'boolean', description: 'cascade-close all open descendants first' },
|
|
58
85
|
};
|
|
59
86
|
|
|
87
|
+
// Helper: build an inputSchema with `noggin` required first, plus any
|
|
88
|
+
// extra required keys. Avoids repeating the required/properties wiring
|
|
89
|
+
// in every tool definition.
|
|
90
|
+
function schemaWithNoggin({ properties = {}, required = [] } = {}) {
|
|
91
|
+
return {
|
|
92
|
+
type: 'object',
|
|
93
|
+
required: ['noggin', ...required],
|
|
94
|
+
properties: { noggin: NOGGIN_PROP, ...properties },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
// Each tool: name, JSON-Schema inputSchema, and a handler that returns a
|
|
61
99
|
// value to embed in the envelope's `data` field. Throwing surfaces an error.
|
|
62
|
-
|
|
100
|
+
//
|
|
101
|
+
// Exported so the docs site can render a generated tool reference without
|
|
102
|
+
// spawning the server. Anything importing this module gets the metadata
|
|
103
|
+
// for free; the stdio transport is only attached when this file is the
|
|
104
|
+
// entry point (see the main-guard at the bottom).
|
|
105
|
+
export const TOOLS = [
|
|
63
106
|
{
|
|
64
107
|
name: 'noggin_show',
|
|
65
108
|
description: 'Show the current-position view (spine + peers + first-level children). Default target is active.',
|
|
66
|
-
inputSchema: {
|
|
67
|
-
type: 'object',
|
|
109
|
+
inputSchema: schemaWithNoggin({
|
|
68
110
|
properties: {
|
|
69
111
|
path: PATH_PROP,
|
|
70
112
|
noChildren: { type: 'boolean', description: 'omit first-level children of the target' },
|
|
@@ -73,8 +115,8 @@ const TOOLS = [
|
|
|
73
115
|
withAll: { type: 'boolean', description: 'shorthand for withSiblings + withDescendants' },
|
|
74
116
|
withNotes: { type: 'boolean', description: 'include note bodies after the tree (human-readable)' },
|
|
75
117
|
},
|
|
76
|
-
},
|
|
77
|
-
handler: (input,
|
|
118
|
+
}),
|
|
119
|
+
handler: (input, noggin) => verbs.show(noggin, {
|
|
78
120
|
path: input.path,
|
|
79
121
|
includeChildren: input.noChildren === true ? false : undefined,
|
|
80
122
|
withSiblings: input.withSiblings === true || input.withAll === true,
|
|
@@ -85,33 +127,31 @@ const TOOLS = [
|
|
|
85
127
|
{
|
|
86
128
|
name: 'noggin_push',
|
|
87
129
|
description: 'Create a child of active and immediately become it (going on a side-quest).',
|
|
88
|
-
inputSchema: {
|
|
89
|
-
type: 'object',
|
|
130
|
+
inputSchema: schemaWithNoggin({
|
|
90
131
|
required: ['title'],
|
|
91
132
|
properties: { title: TITLE_PROP },
|
|
92
|
-
},
|
|
93
|
-
handler: (input,
|
|
133
|
+
}),
|
|
134
|
+
handler: (input, noggin) => {
|
|
94
135
|
const title = String(input.title ?? '').trim();
|
|
95
136
|
if (!title) throw new Error('title is required');
|
|
96
|
-
return
|
|
137
|
+
return verbs.push(noggin, { title });
|
|
97
138
|
},
|
|
98
139
|
},
|
|
99
140
|
{
|
|
100
141
|
name: 'noggin_add',
|
|
101
142
|
description: 'Add a child without making it active (capture a deferred todo).',
|
|
102
|
-
inputSchema: {
|
|
103
|
-
type: 'object',
|
|
143
|
+
inputSchema: schemaWithNoggin({
|
|
104
144
|
required: ['title'],
|
|
105
145
|
properties: {
|
|
106
146
|
title: TITLE_PROP,
|
|
107
147
|
...PLACEMENT_PROPS,
|
|
108
148
|
goto: GOTO_PROP,
|
|
109
149
|
},
|
|
110
|
-
},
|
|
111
|
-
handler: (input,
|
|
150
|
+
}),
|
|
151
|
+
handler: (input, noggin) => {
|
|
112
152
|
const title = String(input.title ?? '').trim();
|
|
113
153
|
if (!title) throw new Error('title is required');
|
|
114
|
-
return
|
|
154
|
+
return verbs.add(noggin, {
|
|
115
155
|
title,
|
|
116
156
|
placement: placementFrom(input, { required: false }),
|
|
117
157
|
goto: input.goto,
|
|
@@ -121,25 +161,23 @@ const TOOLS = [
|
|
|
121
161
|
{
|
|
122
162
|
name: 'noggin_goto',
|
|
123
163
|
description: 'Make the item at the given path active.',
|
|
124
|
-
inputSchema: {
|
|
125
|
-
type: 'object',
|
|
164
|
+
inputSchema: schemaWithNoggin({
|
|
126
165
|
required: ['path'],
|
|
127
166
|
properties: { path: PATH_PROP },
|
|
128
|
-
},
|
|
129
|
-
handler: (input,
|
|
167
|
+
}),
|
|
168
|
+
handler: (input, noggin) => {
|
|
130
169
|
const p = String(input.path ?? '').trim();
|
|
131
170
|
if (!p) throw new Error('path is required');
|
|
132
|
-
return
|
|
171
|
+
return verbs.goto(noggin, { path: p });
|
|
133
172
|
},
|
|
134
173
|
},
|
|
135
174
|
{
|
|
136
175
|
name: 'noggin_done',
|
|
137
176
|
description: 'Mark target done and surface to its parent. Idempotent.',
|
|
138
|
-
inputSchema: {
|
|
139
|
-
type: 'object',
|
|
177
|
+
inputSchema: schemaWithNoggin({
|
|
140
178
|
properties: { path: PATH_PROP, ...CLOSE_FLAGS },
|
|
141
|
-
},
|
|
142
|
-
handler: (input,
|
|
179
|
+
}),
|
|
180
|
+
handler: (input, noggin) => verbs.done(noggin, {
|
|
143
181
|
path: input.path,
|
|
144
182
|
force: input.force === true,
|
|
145
183
|
closeAll: input.closeAll === true,
|
|
@@ -148,11 +186,10 @@ const TOOLS = [
|
|
|
148
186
|
{
|
|
149
187
|
name: 'noggin_pop',
|
|
150
188
|
description: 'Shorthand for done on the active item.',
|
|
151
|
-
inputSchema: {
|
|
152
|
-
type: 'object',
|
|
189
|
+
inputSchema: schemaWithNoggin({
|
|
153
190
|
properties: CLOSE_FLAGS,
|
|
154
|
-
},
|
|
155
|
-
handler: (input,
|
|
191
|
+
}),
|
|
192
|
+
handler: (input, noggin) => verbs.pop(noggin, {
|
|
156
193
|
force: input.force === true,
|
|
157
194
|
closeAll: input.closeAll === true,
|
|
158
195
|
}),
|
|
@@ -160,8 +197,7 @@ const TOOLS = [
|
|
|
160
197
|
{
|
|
161
198
|
name: 'noggin_edit',
|
|
162
199
|
description: 'Idempotent mutation of an item\'s state and/or title. Pass at least one of state or title.',
|
|
163
|
-
inputSchema: {
|
|
164
|
-
type: 'object',
|
|
200
|
+
inputSchema: schemaWithNoggin({
|
|
165
201
|
properties: {
|
|
166
202
|
path: PATH_PROP,
|
|
167
203
|
state: { type: 'string', enum: ['done', 'open'], description: 'set done/open state' },
|
|
@@ -169,15 +205,15 @@ const TOOLS = [
|
|
|
169
205
|
...CLOSE_FLAGS,
|
|
170
206
|
goto: GOTO_PROP,
|
|
171
207
|
},
|
|
172
|
-
},
|
|
173
|
-
handler: (input,
|
|
208
|
+
}),
|
|
209
|
+
handler: (input, noggin) => {
|
|
174
210
|
const state = input.state;
|
|
175
211
|
const hasState = state === 'done' || state === 'open';
|
|
176
212
|
const rawTitle = typeof input.title === 'string' ? input.title : undefined;
|
|
177
213
|
const hasTitle = typeof rawTitle === 'string' && rawTitle.trim() !== '';
|
|
178
214
|
if (!hasState && !hasTitle) throw new Error('pass at least one of state ("done"/"open") or title');
|
|
179
215
|
if (state !== undefined && !hasState) throw new Error('state must be "done" or "open"');
|
|
180
|
-
return
|
|
216
|
+
return verbs.edit(noggin, {
|
|
181
217
|
path: input.path,
|
|
182
218
|
done: hasState ? state === 'done' : undefined,
|
|
183
219
|
title: hasTitle ? rawTitle : undefined,
|
|
@@ -190,28 +226,26 @@ const TOOLS = [
|
|
|
190
226
|
{
|
|
191
227
|
name: 'noggin_note',
|
|
192
228
|
description: 'Append a timestamped note to an item (default: active).',
|
|
193
|
-
inputSchema: {
|
|
194
|
-
type: 'object',
|
|
229
|
+
inputSchema: schemaWithNoggin({
|
|
195
230
|
required: ['text'],
|
|
196
231
|
properties: {
|
|
197
232
|
path: PATH_PROP,
|
|
198
233
|
text: { type: 'string', description: 'note body (free-form)' },
|
|
199
234
|
},
|
|
200
|
-
},
|
|
201
|
-
handler: (input,
|
|
235
|
+
}),
|
|
236
|
+
handler: (input, noggin) => {
|
|
202
237
|
const text = String(input.text ?? '');
|
|
203
238
|
if (!text.trim()) throw new Error('text is required');
|
|
204
|
-
return
|
|
239
|
+
return verbs.note(noggin, { path: input.path, text });
|
|
205
240
|
},
|
|
206
241
|
},
|
|
207
242
|
{
|
|
208
243
|
name: 'noggin_move',
|
|
209
244
|
description: 'Relocate an item. Exactly one of before/after/into is required.',
|
|
210
|
-
inputSchema: {
|
|
211
|
-
type: 'object',
|
|
245
|
+
inputSchema: schemaWithNoggin({
|
|
212
246
|
properties: { path: PATH_PROP, ...PLACEMENT_PROPS },
|
|
213
|
-
},
|
|
214
|
-
handler: (input,
|
|
247
|
+
}),
|
|
248
|
+
handler: (input, noggin) => verbs.move(noggin, {
|
|
215
249
|
path: input.path,
|
|
216
250
|
placement: placementFrom(input, { required: true }),
|
|
217
251
|
}),
|
|
@@ -219,52 +253,96 @@ const TOOLS = [
|
|
|
219
253
|
{
|
|
220
254
|
name: 'noggin_delete',
|
|
221
255
|
description: 'Remove an item. Pass recursive=true if it has descendants.',
|
|
222
|
-
inputSchema: {
|
|
223
|
-
type: 'object',
|
|
256
|
+
inputSchema: schemaWithNoggin({
|
|
224
257
|
required: ['path'],
|
|
225
258
|
properties: {
|
|
226
259
|
path: PATH_PROP,
|
|
227
260
|
recursive: { type: 'boolean', description: 'also delete descendants' },
|
|
228
261
|
},
|
|
229
|
-
},
|
|
230
|
-
handler: (input,
|
|
262
|
+
}),
|
|
263
|
+
handler: (input, noggin) => {
|
|
231
264
|
const p = String(input.path ?? '').trim();
|
|
232
265
|
if (!p) throw new Error('path is required');
|
|
233
|
-
return
|
|
266
|
+
return verbs.delete(noggin, { path: p, recursive: input.recursive === true });
|
|
234
267
|
},
|
|
235
268
|
},
|
|
236
269
|
{
|
|
237
270
|
name: 'noggin_where',
|
|
238
|
-
description: '
|
|
271
|
+
description: 'Return the canonical location string of the given noggin (echoes back the `noggin` parameter, useful for confirming the value the server interpreted).',
|
|
272
|
+
inputSchema: schemaWithNoggin(),
|
|
273
|
+
handler: (_input, noggin) => noggin.describe(),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'noggin_copy',
|
|
277
|
+
description: 'Append every item from `from` into `to` (whole-noggin, append-only). New keys are generated; notes, done state, and createdAt timestamps are preserved verbatim. Use to migrate a noggin between locations or duplicate a tree under one root.',
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
required: ['from', 'to'],
|
|
281
|
+
properties: {
|
|
282
|
+
from: { type: 'string', description: 'canonical location of the SOURCE noggin (read-only)' },
|
|
283
|
+
to: { type: 'string', description: 'canonical location of the DESTINATION noggin (mutated)' },
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
// Two noggins, neither of them the standard `noggin` arg, so we
|
|
287
|
+
// bypass the single-noggin dispatch path and open both ourselves.
|
|
288
|
+
skipNoggin: true,
|
|
289
|
+
handler: async (input) => {
|
|
290
|
+
const fromLoc = typeof input.from === 'string' ? input.from.trim() : '';
|
|
291
|
+
const toLoc = typeof input.to === 'string' ? input.to.trim() : '';
|
|
292
|
+
if (!fromLoc) throw new Error('`from` is required: the source noggin location');
|
|
293
|
+
if (!toLoc) throw new Error('`to` is required: the destination noggin location');
|
|
294
|
+
const source = await openNogginByLocation(fromLoc);
|
|
295
|
+
const dest = await openNogginByLocation(toLoc);
|
|
296
|
+
return verbs.copy(source, dest, {});
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'noggin_factories',
|
|
301
|
+
description: 'List backend factories registered in this MCP server (e.g. file://). Useful for discovering what location forms the server accepts.',
|
|
302
|
+
// No `noggin` param: this verb introspects the server itself, not a noggin.
|
|
239
303
|
inputSchema: { type: 'object', properties: {} },
|
|
240
|
-
handler: () =>
|
|
304
|
+
handler: () => factories.list(),
|
|
305
|
+
skipNoggin: true,
|
|
241
306
|
},
|
|
242
307
|
];
|
|
243
308
|
|
|
244
|
-
const server = new Server(PKG, { capabilities: { tools: {} } });
|
|
245
309
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
310
|
+
// Only attach the stdio transport when this file is the entry point. Importing
|
|
311
|
+
// the module (e.g. from the docs generator) must not start a server.
|
|
312
|
+
if (typeof process !== 'undefined' && Array.isArray(process.argv) && process.argv[1] &&
|
|
313
|
+
import.meta.url === url.pathToFileURL(process.argv[1]).href) {
|
|
314
|
+
const server = new Server(PKG, { capabilities: { tools: {} } });
|
|
249
315
|
|
|
250
|
-
server.setRequestHandler(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
316
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
317
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
321
|
+
const { name, arguments: args = {} } = request.params;
|
|
322
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
323
|
+
const verb = name.replace(/^noggin_/, '').replace(/_/g, '-');
|
|
324
|
+
if (!tool) {
|
|
325
|
+
const envelope = formatError({ verb, error: new Error(`unknown tool: ${name}`) });
|
|
326
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
let data;
|
|
330
|
+
if (tool.skipNoggin) {
|
|
331
|
+
data = await tool.handler(args);
|
|
332
|
+
} else {
|
|
333
|
+
const location = typeof args.noggin === 'string' ? args.noggin.trim() : '';
|
|
334
|
+
if (!location) throw new Error('`noggin` parameter is required: pass the canonical location of the noggin to operate on (e.g. "~/.noggin.yaml")');
|
|
335
|
+
const noggin = await openNogginByLocation(location);
|
|
336
|
+
data = await tool.handler(args, noggin);
|
|
337
|
+
}
|
|
338
|
+
const envelope = formatSuccess({ verb, data });
|
|
339
|
+
return { content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const envelope = formatError({ verb, error: err });
|
|
342
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
|
|
343
|
+
}
|
|
344
|
+
});
|
|
268
345
|
|
|
269
|
-
const transport = new StdioServerTransport();
|
|
270
|
-
await server.connect(transport);
|
|
346
|
+
const transport = new StdioServerTransport();
|
|
347
|
+
await server.connect(transport);
|
|
348
|
+
}
|