noggin-cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +389 -0
- package/SKILL.md +153 -0
- package/noggin-api.d.mts +317 -0
- package/noggin-api.mjs +1236 -0
- package/noggin-mcp.mjs +270 -0
- package/noggin.mjs +482 -0
- package/package.json +48 -0
package/noggin-mcp.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// noggin-mcp # uses NOGGIN_FILE env or default ~/.noggin.yaml
|
|
8
|
+
// NOGGIN_FILE=/path npx noggin-mcp
|
|
9
|
+
//
|
|
10
|
+
// Wire-up (varies by host):
|
|
11
|
+
// - Codex CLI: declared in plugin/.codex-plugin/plugin.json
|
|
12
|
+
// - Claude Code / Copilot CLI: user adds an mcpServers entry pointing here
|
|
13
|
+
// - VS Code (outside the extension): user adds the same to .vscode/mcp.json
|
|
14
|
+
//
|
|
15
|
+
// The protocol layer (request parsing, schema validation, stdio framing) is
|
|
16
|
+
// provided by @modelcontextprotocol/sdk. Tool bodies just call the existing
|
|
17
|
+
// in-process API and wrap results in the same JSON envelope the CLI emits.
|
|
18
|
+
|
|
19
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
20
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
apiPush, apiAdd, apiMove, apiGoto, apiDone, apiPop,
|
|
25
|
+
apiEdit, apiShow, apiNote, apiDelete, apiWhere,
|
|
26
|
+
resolveFile, formatSuccess, formatError,
|
|
27
|
+
} from './noggin-api.mjs';
|
|
28
|
+
|
|
29
|
+
const PKG = { name: 'noggin-mcp', version: '0.1.0' };
|
|
30
|
+
|
|
31
|
+
function getFile() {
|
|
32
|
+
return resolveFile({ env: process.env }).file;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function placementFrom(input, { required }) {
|
|
36
|
+
const kinds = ['before', 'after', 'into'];
|
|
37
|
+
const present = kinds.filter((k) => input?.[k]);
|
|
38
|
+
if (present.length === 0) {
|
|
39
|
+
if (required) throw new Error('exactly one of before/after/into is required');
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (present.length > 1) throw new Error('choose at most one of before/after/into');
|
|
43
|
+
const kind = present[0];
|
|
44
|
+
return { kind, anchor: String(input[kind]) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PATH_PROP = { type: 'string', description: 'noggin path (absolute /1/2 or relative — see SKILL.md)' };
|
|
48
|
+
const TITLE_PROP = { type: 'string', description: 'item title (one line)' };
|
|
49
|
+
const GOTO_PROP = { type: ['string', 'boolean'], description: 'true = goto the target; string = goto this path after the verb' };
|
|
50
|
+
const PLACEMENT_PROPS = {
|
|
51
|
+
before: { type: 'string', description: 'place as sibling before this anchor path' },
|
|
52
|
+
after: { type: 'string', description: 'place as sibling after this anchor path' },
|
|
53
|
+
into: { type: 'string', description: 'place as last child of this anchor path' },
|
|
54
|
+
};
|
|
55
|
+
const CLOSE_FLAGS = {
|
|
56
|
+
force: { type: 'boolean', description: 'close even if open descendants exist (leaves them open)' },
|
|
57
|
+
closeAll: { type: 'boolean', description: 'cascade-close all open descendants first' },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Each tool: name, JSON-Schema inputSchema, and a handler that returns a
|
|
61
|
+
// value to embed in the envelope's `data` field. Throwing surfaces an error.
|
|
62
|
+
const TOOLS = [
|
|
63
|
+
{
|
|
64
|
+
name: 'noggin_show',
|
|
65
|
+
description: 'Show the current-position view (spine + peers + first-level children). Default target is active.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
path: PATH_PROP,
|
|
70
|
+
noChildren: { type: 'boolean', description: 'omit first-level children of the target' },
|
|
71
|
+
withSiblings: { type: 'boolean', description: 'also include ancestor sibling rows at every depth' },
|
|
72
|
+
withDescendants: { type: 'boolean', description: 'expand the target subtree recursively' },
|
|
73
|
+
withAll: { type: 'boolean', description: 'shorthand for withSiblings + withDescendants' },
|
|
74
|
+
withNotes: { type: 'boolean', description: 'include note bodies after the tree (human-readable)' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
handler: (input, file) => apiShow(file, {
|
|
78
|
+
path: input.path,
|
|
79
|
+
includeChildren: input.noChildren === true ? false : undefined,
|
|
80
|
+
withSiblings: input.withSiblings === true || input.withAll === true,
|
|
81
|
+
withDescendants: input.withDescendants === true || input.withAll === true,
|
|
82
|
+
withNotes: input.withNotes === true,
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'noggin_push',
|
|
87
|
+
description: 'Create a child of active and immediately become it (going on a side-quest).',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
required: ['title'],
|
|
91
|
+
properties: { title: TITLE_PROP },
|
|
92
|
+
},
|
|
93
|
+
handler: (input, file) => {
|
|
94
|
+
const title = String(input.title ?? '').trim();
|
|
95
|
+
if (!title) throw new Error('title is required');
|
|
96
|
+
return apiPush(file, { title });
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'noggin_add',
|
|
101
|
+
description: 'Add a child without making it active (capture a deferred todo).',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
required: ['title'],
|
|
105
|
+
properties: {
|
|
106
|
+
title: TITLE_PROP,
|
|
107
|
+
...PLACEMENT_PROPS,
|
|
108
|
+
goto: GOTO_PROP,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
handler: (input, file) => {
|
|
112
|
+
const title = String(input.title ?? '').trim();
|
|
113
|
+
if (!title) throw new Error('title is required');
|
|
114
|
+
return apiAdd(file, {
|
|
115
|
+
title,
|
|
116
|
+
placement: placementFrom(input, { required: false }),
|
|
117
|
+
goto: input.goto,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'noggin_goto',
|
|
123
|
+
description: 'Make the item at the given path active.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
required: ['path'],
|
|
127
|
+
properties: { path: PATH_PROP },
|
|
128
|
+
},
|
|
129
|
+
handler: (input, file) => {
|
|
130
|
+
const p = String(input.path ?? '').trim();
|
|
131
|
+
if (!p) throw new Error('path is required');
|
|
132
|
+
return apiGoto(file, { path: p });
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'noggin_done',
|
|
137
|
+
description: 'Mark target done and surface to its parent. Idempotent.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: { path: PATH_PROP, ...CLOSE_FLAGS },
|
|
141
|
+
},
|
|
142
|
+
handler: (input, file) => apiDone(file, {
|
|
143
|
+
path: input.path,
|
|
144
|
+
force: input.force === true,
|
|
145
|
+
closeAll: input.closeAll === true,
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'noggin_pop',
|
|
150
|
+
description: 'Shorthand for done on the active item.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: CLOSE_FLAGS,
|
|
154
|
+
},
|
|
155
|
+
handler: (input, file) => apiPop(file, {
|
|
156
|
+
force: input.force === true,
|
|
157
|
+
closeAll: input.closeAll === true,
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'noggin_edit',
|
|
162
|
+
description: 'Idempotent mutation of an item\'s state and/or title. Pass at least one of state or title.',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
path: PATH_PROP,
|
|
167
|
+
state: { type: 'string', enum: ['done', 'open'], description: 'set done/open state' },
|
|
168
|
+
title: { type: 'string', description: 'new title (rename)' },
|
|
169
|
+
...CLOSE_FLAGS,
|
|
170
|
+
goto: GOTO_PROP,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
handler: (input, file) => {
|
|
174
|
+
const state = input.state;
|
|
175
|
+
const hasState = state === 'done' || state === 'open';
|
|
176
|
+
const rawTitle = typeof input.title === 'string' ? input.title : undefined;
|
|
177
|
+
const hasTitle = typeof rawTitle === 'string' && rawTitle.trim() !== '';
|
|
178
|
+
if (!hasState && !hasTitle) throw new Error('pass at least one of state ("done"/"open") or title');
|
|
179
|
+
if (state !== undefined && !hasState) throw new Error('state must be "done" or "open"');
|
|
180
|
+
return apiEdit(file, {
|
|
181
|
+
path: input.path,
|
|
182
|
+
done: hasState ? state === 'done' : undefined,
|
|
183
|
+
title: hasTitle ? rawTitle : undefined,
|
|
184
|
+
force: input.force === true,
|
|
185
|
+
closeAll: input.closeAll === true,
|
|
186
|
+
goto: input.goto,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'noggin_note',
|
|
192
|
+
description: 'Append a timestamped note to an item (default: active).',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
required: ['text'],
|
|
196
|
+
properties: {
|
|
197
|
+
path: PATH_PROP,
|
|
198
|
+
text: { type: 'string', description: 'note body (free-form)' },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
handler: (input, file) => {
|
|
202
|
+
const text = String(input.text ?? '');
|
|
203
|
+
if (!text.trim()) throw new Error('text is required');
|
|
204
|
+
return apiNote(file, { path: input.path, text });
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'noggin_move',
|
|
209
|
+
description: 'Relocate an item. Exactly one of before/after/into is required.',
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: { path: PATH_PROP, ...PLACEMENT_PROPS },
|
|
213
|
+
},
|
|
214
|
+
handler: (input, file) => apiMove(file, {
|
|
215
|
+
path: input.path,
|
|
216
|
+
placement: placementFrom(input, { required: true }),
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'noggin_delete',
|
|
221
|
+
description: 'Remove an item. Pass recursive=true if it has descendants.',
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: 'object',
|
|
224
|
+
required: ['path'],
|
|
225
|
+
properties: {
|
|
226
|
+
path: PATH_PROP,
|
|
227
|
+
recursive: { type: 'boolean', description: 'also delete descendants' },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
handler: (input, file) => {
|
|
231
|
+
const p = String(input.path ?? '').trim();
|
|
232
|
+
if (!p) throw new Error('path is required');
|
|
233
|
+
return apiDelete(file, { path: p, recursive: input.recursive === true });
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'noggin_where',
|
|
238
|
+
description: 'Report which noggin file would be used and why (flag/env/default).',
|
|
239
|
+
inputSchema: { type: 'object', properties: {} },
|
|
240
|
+
handler: () => apiWhere({ env: process.env }),
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const server = new Server(PKG, { capabilities: { tools: {} } });
|
|
245
|
+
|
|
246
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
247
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
248
|
+
}));
|
|
249
|
+
|
|
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
|
+
});
|
|
268
|
+
|
|
269
|
+
const transport = new StdioServerTransport();
|
|
270
|
+
await server.connect(transport);
|