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/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 (Copilot CLI,
4
- // Claude Code, Codex CLI) can spawn this server to get the same toolset.
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
- // Usage:
7
- // noggin-mcp # uses NOGGIN_FILE env or default ~/.noggin.yaml
8
- // NOGGIN_FILE=/path npx noggin-mcp
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
- apiPush, apiAdd, apiMove, apiGoto, apiDone, apiPop,
25
- apiEdit, apiShow, apiNote, apiDelete, apiWhere,
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
- const PKG = { name: 'noggin-mcp', version: '0.1.0' };
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
- function getFile() {
32
- return resolveFile({ env: process.env }).file;
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
- const TOOLS = [
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, file) => apiShow(file, {
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, file) => {
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 apiPush(file, { title });
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, file) => {
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 apiAdd(file, {
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, file) => {
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 apiGoto(file, { path: p });
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, file) => apiDone(file, {
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, file) => apiPop(file, {
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, file) => {
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 apiEdit(file, {
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, file) => {
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 apiNote(file, { path: input.path, text });
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, file) => apiMove(file, {
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, file) => {
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 apiDelete(file, { path: p, recursive: input.recursive === true });
266
+ return verbs.delete(noggin, { path: p, recursive: input.recursive === true });
234
267
  },
235
268
  },
236
269
  {
237
270
  name: 'noggin_where',
238
- description: 'Report which noggin file would be used and why (flag/env/default).',
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: () => apiWhere({ env: process.env }),
304
+ handler: () => factories.list(),
305
+ skipNoggin: true,
241
306
  },
242
307
  ];
243
308
 
244
- const server = new Server(PKG, { capabilities: { tools: {} } });
245
309
 
246
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
247
- tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
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(CallToolRequestSchema, async (request) => {
251
- const { name, arguments: args = {} } = request.params;
252
- const tool = TOOLS.find((t) => t.name === name);
253
- const verb = name.replace(/^noggin_/, '').replace(/_/g, '-');
254
- const file = getFile();
255
- if (!tool) {
256
- const envelope = formatError({ verb, file, error: new Error(`unknown tool: ${name}`) });
257
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
258
- }
259
- try {
260
- const data = tool.handler(args, file);
261
- const envelope = formatSuccess({ verb, file, data });
262
- return { content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
263
- } catch (err) {
264
- const envelope = formatError({ verb, file, error: err });
265
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(envelope, null, 2) }] };
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
+ }