shelvd-mcp 0.0.0-stage → 0.1.0
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/dist/cli.js +364 -0
- package/package.json +16 -4
- package/README.md +0 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
|
|
9
|
+
// ../shared/src/index.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
var UPGRADE_URL = "https://shelvd.dev/pro";
|
|
12
|
+
var TOOLS = {
|
|
13
|
+
park: "park_idea",
|
|
14
|
+
list: "list_ideas",
|
|
15
|
+
get: "get_idea",
|
|
16
|
+
complete: "complete_idea",
|
|
17
|
+
snooze: "snooze_idea",
|
|
18
|
+
delete: "delete_idea",
|
|
19
|
+
clear: "clear_ideas"
|
|
20
|
+
};
|
|
21
|
+
var parkIdeaInput = {
|
|
22
|
+
title: z.string().min(1).max(200),
|
|
23
|
+
details: z.string().max(5e3).optional(),
|
|
24
|
+
project: z.string().max(80).optional(),
|
|
25
|
+
due: z.string().optional().describe("ISO date or natural text like 'friday 3pm'"),
|
|
26
|
+
nagEveryMinutes: z.number().int().positive().optional(),
|
|
27
|
+
context: z.object({
|
|
28
|
+
source: z.string().optional(),
|
|
29
|
+
repo: z.string().optional(),
|
|
30
|
+
branch: z.string().optional(),
|
|
31
|
+
files: z.array(z.string()).max(20).optional(),
|
|
32
|
+
summary: z.string().max(1e3).optional()
|
|
33
|
+
}).optional()
|
|
34
|
+
};
|
|
35
|
+
var listIdeasInput = {
|
|
36
|
+
project: z.string().optional(),
|
|
37
|
+
status: z.enum(["parked", "due", "snoozed", "done", "all"]).optional(),
|
|
38
|
+
from: z.string().optional(),
|
|
39
|
+
to: z.string().optional(),
|
|
40
|
+
limit: z.number().int().positive().max(200).optional()
|
|
41
|
+
};
|
|
42
|
+
var idInput = { id: z.string().min(1) };
|
|
43
|
+
var snoozeIdeaInput = {
|
|
44
|
+
id: z.string().min(1),
|
|
45
|
+
until: z.string().optional(),
|
|
46
|
+
minutes: z.number().int().positive().optional()
|
|
47
|
+
};
|
|
48
|
+
var clearIdeasInput = {
|
|
49
|
+
scope: z.string().describe("'done', 'all', or 'project:<name>'"),
|
|
50
|
+
confirm: z.boolean().optional()
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/dates.ts
|
|
54
|
+
import * as chrono from "chrono-node";
|
|
55
|
+
function parseDue(text, now = /* @__PURE__ */ new Date()) {
|
|
56
|
+
const [hit] = chrono.parse(text, now, { forwardDate: true });
|
|
57
|
+
if (!hit) return null;
|
|
58
|
+
const d = hit.start.date();
|
|
59
|
+
if (!hit.start.isCertain("hour")) d.setHours(10, 0, 0, 0);
|
|
60
|
+
return d;
|
|
61
|
+
}
|
|
62
|
+
function startOfDay(d) {
|
|
63
|
+
const s = new Date(d);
|
|
64
|
+
s.setHours(0, 0, 0, 0);
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
function fmt(d) {
|
|
68
|
+
const x = new Date(d);
|
|
69
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
70
|
+
return `${x.getFullYear()}-${p(x.getMonth() + 1)}-${p(x.getDate())} ${p(x.getHours())}:${p(x.getMinutes())}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/store.ts
|
|
74
|
+
import { randomBytes } from "crypto";
|
|
75
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
76
|
+
import { homedir } from "os";
|
|
77
|
+
import { join } from "path";
|
|
78
|
+
var CorruptStoreError = class extends Error {
|
|
79
|
+
constructor(file) {
|
|
80
|
+
super(`${file} is unreadable. Left untouched; fix or move it, then retry.`);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var Store = class {
|
|
84
|
+
file;
|
|
85
|
+
constructor(dir = process.env.SHELVD_DIR ?? join(homedir(), ".shelvd")) {
|
|
86
|
+
this.dir = dir;
|
|
87
|
+
this.file = join(dir, "ideas.json");
|
|
88
|
+
}
|
|
89
|
+
dir;
|
|
90
|
+
read() {
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = readFileSync(this.file, "utf8");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e.code === "ENOENT") return [];
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.ideas)) throw new Error();
|
|
101
|
+
return parsed.ideas;
|
|
102
|
+
} catch {
|
|
103
|
+
throw new CorruptStoreError(this.file);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// shortcut: re-reads before each write but takes no lock, so two clients writing in the same
|
|
107
|
+
// millisecond can drop one write. Add a lockfile if that ever happens in practice.
|
|
108
|
+
update(fn) {
|
|
109
|
+
const ideas = this.read();
|
|
110
|
+
const result = fn(ideas);
|
|
111
|
+
mkdirSync(this.dir, { recursive: true });
|
|
112
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
113
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, ideas }, null, 2));
|
|
114
|
+
renameSync(tmp, this.file);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
newId(ideas) {
|
|
118
|
+
for (; ; ) {
|
|
119
|
+
const id = randomBytes(4).toString("hex").slice(0, 6);
|
|
120
|
+
if (!ideas.some((i) => i.id === id)) return id;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/tools.ts
|
|
126
|
+
var err = (text) => ({ text, isError: true });
|
|
127
|
+
var pipe = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
128
|
+
function label(i, now) {
|
|
129
|
+
if (i.status === "done") return "done";
|
|
130
|
+
if (i.dueAt && new Date(i.dueAt) <= now) {
|
|
131
|
+
return new Date(i.dueAt) < startOfDay(now) ? "overdue" : "due";
|
|
132
|
+
}
|
|
133
|
+
if (i.status === "snoozed") return "snoozed";
|
|
134
|
+
return i.dueAt ? "upcoming" : "parked";
|
|
135
|
+
}
|
|
136
|
+
var RANK = { overdue: 0, due: 1, snoozed: 2, upcoming: 2, parked: 3, done: 4 };
|
|
137
|
+
function sortIdeas(ideas, now) {
|
|
138
|
+
return [...ideas].sort((a, b) => {
|
|
139
|
+
const r = RANK[label(a, now)] - RANK[label(b, now)];
|
|
140
|
+
if (r) return r;
|
|
141
|
+
return (a.dueAt ?? "").localeCompare(b.dueAt ?? "");
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function table(ideas, now) {
|
|
145
|
+
const rows = ideas.map(
|
|
146
|
+
(i) => `| ${pipe(i.title)} | ${pipe(i.project)} | ${label(i, now)} | ${i.dueAt ? fmt(i.dueAt) : "-"} | ${i.id} |`
|
|
147
|
+
);
|
|
148
|
+
return ["| Title | Project | Status | Due | ID |", "| --- | --- | --- | --- | --- |", ...rows].join("\n");
|
|
149
|
+
}
|
|
150
|
+
function dueSummary(ideas, now) {
|
|
151
|
+
const due = sortIdeas(ideas, now).filter((i) => ["overdue", "due"].includes(label(i, now)));
|
|
152
|
+
if (!due.length) return "";
|
|
153
|
+
const lines = due.map((i) => `- ${i.title} [${i.project}] ${label(i, now)} ${fmt(i.dueAt)} (id ${i.id})`);
|
|
154
|
+
return `${due.length} idea${due.length > 1 ? "s" : ""} due today or overdue:
|
|
155
|
+
${lines.join("\n")}`;
|
|
156
|
+
}
|
|
157
|
+
var find = (ideas, id) => ideas.find((i) => i.id === id);
|
|
158
|
+
var touch = (i, now, event) => {
|
|
159
|
+
i.updatedAt = now.toISOString();
|
|
160
|
+
i.history.push({ at: i.updatedAt, event });
|
|
161
|
+
};
|
|
162
|
+
function parkIdea(store2, input, now = /* @__PURE__ */ new Date()) {
|
|
163
|
+
const due = input.due ? parseDue(input.due, now) : null;
|
|
164
|
+
const project = input.project?.trim() || input.context?.repo?.trim() || "Inbox";
|
|
165
|
+
const idea = store2.update((ideas) => {
|
|
166
|
+
const at = now.toISOString();
|
|
167
|
+
const i = {
|
|
168
|
+
id: store2.newId(ideas),
|
|
169
|
+
title: input.title,
|
|
170
|
+
details: input.details,
|
|
171
|
+
project,
|
|
172
|
+
status: "parked",
|
|
173
|
+
dueAt: due?.toISOString() ?? null,
|
|
174
|
+
nagEveryMinutes: input.nagEveryMinutes,
|
|
175
|
+
context: input.context,
|
|
176
|
+
notes: [],
|
|
177
|
+
history: [{ at, event: "parked" }],
|
|
178
|
+
completedAt: null,
|
|
179
|
+
createdAt: at,
|
|
180
|
+
updatedAt: at
|
|
181
|
+
};
|
|
182
|
+
ideas.push(i);
|
|
183
|
+
return i;
|
|
184
|
+
});
|
|
185
|
+
const parts = [`Parked "${idea.title}" in ${project} (id ${idea.id}).`];
|
|
186
|
+
if (due) parts.push(`Due ${fmt(due)}.`);
|
|
187
|
+
else if (input.due) parts.push("Could not read the date, parked without one.");
|
|
188
|
+
else parts.push("No due date.");
|
|
189
|
+
if (input.nagEveryMinutes) parts.push(`Phone reminders need the hosted Pro version: ${UPGRADE_URL}`);
|
|
190
|
+
return { text: parts.join(" "), data: idea };
|
|
191
|
+
}
|
|
192
|
+
function listIdeas(store2, input, now = /* @__PURE__ */ new Date()) {
|
|
193
|
+
const all = store2.read();
|
|
194
|
+
let ideas = all;
|
|
195
|
+
if (input.project) {
|
|
196
|
+
const p = input.project.toLowerCase();
|
|
197
|
+
ideas = ideas.filter((i) => i.project.toLowerCase() === p);
|
|
198
|
+
if (!ideas.length && !all.some((i) => i.project.toLowerCase() === p)) {
|
|
199
|
+
const known = [...new Set(all.map((i) => i.project))];
|
|
200
|
+
return err(`Unknown project "${input.project}". Known projects: ${known.join(", ") || "none"}.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const status = input.status ?? "parked,snoozed";
|
|
204
|
+
ideas = ideas.filter((i) => {
|
|
205
|
+
if (status === "all") return true;
|
|
206
|
+
if (status === "due") return ["overdue", "due"].includes(label(i, now));
|
|
207
|
+
if (status === "parked,snoozed") return i.status !== "done";
|
|
208
|
+
return i.status === status;
|
|
209
|
+
});
|
|
210
|
+
const from = input.from ? parseDue(input.from, now) : null;
|
|
211
|
+
const to = input.to ? parseDue(input.to, now) : null;
|
|
212
|
+
if (from || to) {
|
|
213
|
+
ideas = ideas.filter((i) => i.dueAt && (!from || new Date(i.dueAt) >= from) && (!to || new Date(i.dueAt) <= to));
|
|
214
|
+
}
|
|
215
|
+
ideas = sortIdeas(ideas, now);
|
|
216
|
+
const limit = input.limit ?? 20;
|
|
217
|
+
const shown = ideas.slice(0, limit);
|
|
218
|
+
if (!shown.length) return { text: "No ideas found.", data: [] };
|
|
219
|
+
const more = ideas.length > shown.length ? `
|
|
220
|
+
|
|
221
|
+
${ideas.length - shown.length} more not shown.` : "";
|
|
222
|
+
return { text: table(shown, now) + more, data: shown };
|
|
223
|
+
}
|
|
224
|
+
function getIdea(store2, id) {
|
|
225
|
+
const i = find(store2.read(), id);
|
|
226
|
+
return i ? { text: `${i.title} [${i.project}]`, data: i } : err(`No idea with id ${id}.`);
|
|
227
|
+
}
|
|
228
|
+
function completeIdea(store2, id, now = /* @__PURE__ */ new Date()) {
|
|
229
|
+
const i = store2.update((ideas) => {
|
|
230
|
+
const x = find(ideas, id);
|
|
231
|
+
if (x) {
|
|
232
|
+
x.status = "done";
|
|
233
|
+
x.completedAt = now.toISOString();
|
|
234
|
+
touch(x, now, "done");
|
|
235
|
+
}
|
|
236
|
+
return x;
|
|
237
|
+
});
|
|
238
|
+
return i ? { text: `Marked done: "${i.title}".`, data: i } : err(`No idea with id ${id}.`);
|
|
239
|
+
}
|
|
240
|
+
function snoozeIdea(store2, input, now = /* @__PURE__ */ new Date()) {
|
|
241
|
+
let until = null;
|
|
242
|
+
if (input.minutes) until = new Date(now.getTime() + input.minutes * 6e4);
|
|
243
|
+
else if (input.until) until = parseDue(input.until, now);
|
|
244
|
+
if (!until) return err("Give a readable `until` date or `minutes`.");
|
|
245
|
+
const i = store2.update((ideas) => {
|
|
246
|
+
const x = find(ideas, input.id);
|
|
247
|
+
if (x) {
|
|
248
|
+
x.status = "snoozed";
|
|
249
|
+
x.dueAt = until.toISOString();
|
|
250
|
+
touch(x, now, `snoozed until ${x.dueAt}`);
|
|
251
|
+
}
|
|
252
|
+
return x;
|
|
253
|
+
});
|
|
254
|
+
return i ? { text: `Snoozed "${i.title}" until ${fmt(until)}.`, data: i } : err(`No idea with id ${input.id}.`);
|
|
255
|
+
}
|
|
256
|
+
function deleteIdea(store2, id) {
|
|
257
|
+
const gone = store2.update((ideas) => {
|
|
258
|
+
const n = ideas.findIndex((i) => i.id === id);
|
|
259
|
+
return n < 0 ? void 0 : ideas.splice(n, 1)[0];
|
|
260
|
+
});
|
|
261
|
+
return gone ? { text: `Deleted "${gone.title}".`, data: gone } : err(`No idea with id ${id}.`);
|
|
262
|
+
}
|
|
263
|
+
function clearIdeas(store2, input) {
|
|
264
|
+
const scope = input.scope.trim();
|
|
265
|
+
let match;
|
|
266
|
+
if (scope === "done") match = (i) => i.status === "done";
|
|
267
|
+
else if (scope === "all") match = () => true;
|
|
268
|
+
else if (scope.startsWith("project:")) {
|
|
269
|
+
const name = scope.slice("project:".length).trim().toLowerCase();
|
|
270
|
+
match = (i) => i.project.toLowerCase() === name;
|
|
271
|
+
} else return err("scope must be 'done', 'all', or 'project:<name>'.");
|
|
272
|
+
if (!input.confirm) {
|
|
273
|
+
const hits = store2.read().filter(match);
|
|
274
|
+
const list = hits.map((i) => `- ${i.title} (${i.id})`).join("\n");
|
|
275
|
+
return {
|
|
276
|
+
text: `Would delete ${hits.length} ideas. Nothing deleted. Ask the user, then call again with confirm: true.${list ? "\n" + list : ""}`,
|
|
277
|
+
data: hits
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const removed = store2.update((ideas) => {
|
|
281
|
+
const keep = ideas.filter((i) => !match(i));
|
|
282
|
+
const out = ideas.length - keep.length;
|
|
283
|
+
ideas.splice(0, ideas.length, ...keep);
|
|
284
|
+
return out;
|
|
285
|
+
});
|
|
286
|
+
return { text: `Deleted ${removed} ideas.`, data: { deleted: removed } };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/server.ts
|
|
290
|
+
function wrap(run) {
|
|
291
|
+
let r;
|
|
292
|
+
try {
|
|
293
|
+
r = run();
|
|
294
|
+
} catch (e) {
|
|
295
|
+
r = { text: e instanceof Error ? e.message : String(e), isError: true };
|
|
296
|
+
}
|
|
297
|
+
const content = [{ type: "text", text: r.text }];
|
|
298
|
+
if (r.data !== void 0) content.push({ type: "text", text: JSON.stringify(r.data) });
|
|
299
|
+
return { content, isError: r.isError };
|
|
300
|
+
}
|
|
301
|
+
function buildServer(store2) {
|
|
302
|
+
let instructions = "Shelvd parks ideas for later. Use park_idea when the user wants to save an idea for later.";
|
|
303
|
+
try {
|
|
304
|
+
const due = dueSummary(store2.read(), /* @__PURE__ */ new Date());
|
|
305
|
+
if (due) instructions += `
|
|
306
|
+
|
|
307
|
+
Mention this to the user in your first reply:
|
|
308
|
+
${due}`;
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
const s = new McpServer({ name: "shelvd", version: "0.1.0" }, { instructions });
|
|
312
|
+
s.registerTool(TOOLS.park, {
|
|
313
|
+
description: "Park an idea for later, with an optional due date (natural language ok), project and context.",
|
|
314
|
+
inputSchema: parkIdeaInput
|
|
315
|
+
}, (a) => wrap(() => parkIdea(store2, a)));
|
|
316
|
+
s.registerTool(TOOLS.list, {
|
|
317
|
+
description: "List parked ideas as a markdown table. Show the table to the user exactly as returned; do not reformat or drop dates and ids.",
|
|
318
|
+
inputSchema: listIdeasInput
|
|
319
|
+
}, (a) => wrap(() => listIdeas(store2, a)));
|
|
320
|
+
s.registerTool(TOOLS.get, {
|
|
321
|
+
description: "Get one idea with full context, notes and history.",
|
|
322
|
+
inputSchema: idInput
|
|
323
|
+
}, (a) => wrap(() => getIdea(store2, a.id)));
|
|
324
|
+
s.registerTool(TOOLS.complete, {
|
|
325
|
+
description: "Mark an idea done.",
|
|
326
|
+
inputSchema: idInput
|
|
327
|
+
}, (a) => wrap(() => completeIdea(store2, a.id)));
|
|
328
|
+
s.registerTool(TOOLS.snooze, {
|
|
329
|
+
description: "Move an idea's due date later. Give `until` (date text) or `minutes`.",
|
|
330
|
+
inputSchema: snoozeIdeaInput
|
|
331
|
+
}, (a) => wrap(() => snoozeIdea(store2, a)));
|
|
332
|
+
s.registerTool(TOOLS.delete, {
|
|
333
|
+
description: "Permanently delete ONE idea by exact id. Confirm with the user first. Never guess an id from a vague title; list ideas to find it.",
|
|
334
|
+
inputSchema: idInput
|
|
335
|
+
}, (a) => wrap(() => deleteIdea(store2, a.id)));
|
|
336
|
+
s.registerTool(TOOLS.clear, {
|
|
337
|
+
description: "Delete many ideas: scope 'done', 'all' or 'project:<name>'. Without confirm=true it only previews. Show the preview to the user and call again with confirm=true only after they approve.",
|
|
338
|
+
inputSchema: clearIdeasInput
|
|
339
|
+
}, (a) => wrap(() => clearIdeas(store2, a)));
|
|
340
|
+
return s;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/cli.ts
|
|
344
|
+
var HOOK = JSON.stringify(
|
|
345
|
+
{ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "npx -y shelvd-mcp due" }] }] } },
|
|
346
|
+
null,
|
|
347
|
+
2
|
|
348
|
+
);
|
|
349
|
+
var cmd = process.argv[2];
|
|
350
|
+
var store = new Store();
|
|
351
|
+
if (cmd === "due") {
|
|
352
|
+
try {
|
|
353
|
+
const out = dueSummary(store.read(), /* @__PURE__ */ new Date());
|
|
354
|
+
if (out) console.log(out);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
console.log(`shelvd: ${e instanceof Error ? e.message : e}`);
|
|
357
|
+
}
|
|
358
|
+
} else if (cmd === "hook") {
|
|
359
|
+
console.log(`Add this to ~/.claude/settings.json (Shelvd never edits it for you):
|
|
360
|
+
|
|
361
|
+
${HOOK}`);
|
|
362
|
+
} else {
|
|
363
|
+
await buildServer(store).connect(new StdioServerTransport());
|
|
364
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shelvd-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shelvd: park ideas from your AI tool. Local, private MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "shelvd-mcp": "dist/cli.js" },
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
14
|
+
"chrono-node": "^2.7.0",
|
|
15
|
+
"zod": "^3.23.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": { "@shelvd/shared": "*" }
|
|
18
|
+
}
|
package/README.md
DELETED