planmode 0.1.5 → 0.2.1
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 +43 -0
- package/dist/index.js +1183 -193
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +2435 -0
- package/package.json +6 -3
- package/src/commands/doctor.ts +43 -0
- package/src/commands/init.ts +17 -36
- package/src/commands/mcp.ts +39 -0
- package/src/commands/publish.ts +3 -191
- package/src/commands/record.ts +76 -0
- package/src/commands/snapshot.ts +46 -0
- package/src/commands/test.ts +45 -0
- package/src/index.ts +11 -1
- package/src/lib/doctor.ts +123 -0
- package/src/lib/init.ts +71 -0
- package/src/lib/installer.ts +20 -1
- package/src/lib/logger.ts +74 -11
- package/src/lib/publisher.ts +203 -0
- package/src/lib/recorder.ts +195 -0
- package/src/lib/snapshot.ts +348 -0
- package/src/lib/templates.ts +60 -0
- package/src/lib/tester.ts +162 -0
- package/src/mcp.ts +853 -0
- package/src/types/index.ts +2 -0
- package/tsup.config.ts +1 -1
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import { logger } from "./lib/logger.js";
|
|
7
|
+
import { searchPackages, fetchPackageMetadata, fetchVersionMetadata } from "./lib/registry.js";
|
|
8
|
+
import { installPackage, uninstallPackage, updatePackage } from "./lib/installer.js";
|
|
9
|
+
import { readLockfile } from "./lib/lockfile.js";
|
|
10
|
+
import { readManifest, validateManifest, parseManifest, readPackageContent } from "./lib/manifest.js";
|
|
11
|
+
import { renderTemplate, collectVariableValues } from "./lib/template.js";
|
|
12
|
+
import { createPackage } from "./lib/init.js";
|
|
13
|
+
import { publishPackage } from "./lib/publisher.js";
|
|
14
|
+
import { resolveVersion } from "./lib/resolver.js";
|
|
15
|
+
import { fetchFileAtTag } from "./lib/git.js";
|
|
16
|
+
import { runDoctor } from "./lib/doctor.js";
|
|
17
|
+
import { testPackage } from "./lib/tester.js";
|
|
18
|
+
import { startRecordingAsync, stopRecording, isRecording } from "./lib/recorder.js";
|
|
19
|
+
import { takeSnapshot } from "./lib/snapshot.js";
|
|
20
|
+
import type { Category } from "./types/index.js";
|
|
21
|
+
|
|
22
|
+
// ── Helpers ──
|
|
23
|
+
|
|
24
|
+
function withCapture<T>(fn: () => T): { result: T; messages: string[] } {
|
|
25
|
+
logger.capture();
|
|
26
|
+
try {
|
|
27
|
+
const result = fn();
|
|
28
|
+
const messages = logger.flush();
|
|
29
|
+
return { result, messages };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const messages = logger.flush();
|
|
32
|
+
throw Object.assign(err as Error, { capturedMessages: messages });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function withCaptureAsync<T>(fn: () => Promise<T>): Promise<{ result: T; messages: string[] }> {
|
|
37
|
+
logger.capture();
|
|
38
|
+
try {
|
|
39
|
+
const result = await fn();
|
|
40
|
+
const messages = logger.flush();
|
|
41
|
+
return { result, messages };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const messages = logger.flush();
|
|
44
|
+
throw Object.assign(err as Error, { capturedMessages: messages });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function textResult(text: string, isError = false) {
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text" as const, text }],
|
|
51
|
+
isError,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatMessages(messages: string[], extra?: string): string {
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
if (messages.length > 0) {
|
|
58
|
+
parts.push(messages.filter((m) => m !== "").join("\n"));
|
|
59
|
+
}
|
|
60
|
+
if (extra) {
|
|
61
|
+
parts.push(extra);
|
|
62
|
+
}
|
|
63
|
+
return parts.join("\n\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Rewrite CLI-oriented error messages for MCP context */
|
|
67
|
+
function adaptError(message: string): string {
|
|
68
|
+
return message
|
|
69
|
+
.replace(/Run `planmode login` first\./, "Authentication required. Configure a GitHub token via `planmode login` in your terminal, or set the PLANMODE_GITHUB_TOKEN environment variable.")
|
|
70
|
+
.replace(/Run `planmode search <query>` to find packages\./, "Try using the planmode_search tool to find packages.")
|
|
71
|
+
.replace(/Run `planmode install (.+?)` to get started\./, "Use the planmode_install tool to install packages.")
|
|
72
|
+
.replace(/Install it first: planmode install (.+)/, "Install it first using the planmode_install tool.")
|
|
73
|
+
.replace(/run `planmode publish` when ready\./, "use the planmode_publish tool when ready.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function errorResult(prefix: string, err: Error): ReturnType<typeof textResult> {
|
|
77
|
+
const capturedMessages = (err as Error & { capturedMessages?: string[] }).capturedMessages;
|
|
78
|
+
const msgPrefix = capturedMessages?.length ? capturedMessages.join("\n") + "\n\n" : "";
|
|
79
|
+
return textResult(adaptError(`${msgPrefix}${prefix}: ${err.message}`), true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Server ──
|
|
83
|
+
|
|
84
|
+
const server = new McpServer({
|
|
85
|
+
name: "planmode",
|
|
86
|
+
version: "0.2.1",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── Tools ──
|
|
90
|
+
|
|
91
|
+
// -- planmode_search --
|
|
92
|
+
server.registerTool(
|
|
93
|
+
"planmode_search",
|
|
94
|
+
{
|
|
95
|
+
description: "Search the planmode registry for packages (plans, rules, and prompts for AI-assisted development)",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
query: z.string().describe("Search query to find packages"),
|
|
98
|
+
type: z.enum(["prompt", "rule", "plan"]).optional().describe("Filter by package type"),
|
|
99
|
+
category: z.string().optional().describe("Filter by category (frontend, backend, devops, database, testing, mobile, ai-ml, design, security, other)"),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
async ({ query, type, category }) => {
|
|
103
|
+
try {
|
|
104
|
+
const { result: results, messages } = await withCaptureAsync(() =>
|
|
105
|
+
searchPackages(query, { type, category }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (results.length === 0) {
|
|
109
|
+
return textResult(formatMessages(messages, "No packages found matching your query."));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const table = results
|
|
113
|
+
.map(
|
|
114
|
+
(pkg) =>
|
|
115
|
+
`- **${pkg.name}** (${pkg.type} v${pkg.version}) — ${pkg.description}`,
|
|
116
|
+
)
|
|
117
|
+
.join("\n");
|
|
118
|
+
|
|
119
|
+
return textResult(formatMessages(messages, `Found ${results.length} package(s):\n\n${table}`));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return errorResult("Error searching registry", err as Error);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// -- planmode_info --
|
|
127
|
+
server.registerTool(
|
|
128
|
+
"planmode_info",
|
|
129
|
+
{
|
|
130
|
+
description: "Get detailed information about a planmode package including versions, dependencies, and variables. Use this before installing to understand what a package provides and what variables it needs.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
package: z.string().describe("Package name (e.g., nextjs-tailwind-starter)"),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
async ({ package: packageName }) => {
|
|
136
|
+
try {
|
|
137
|
+
const { result: meta, messages } = await withCaptureAsync(() =>
|
|
138
|
+
fetchPackageMetadata(packageName),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const lines = [
|
|
142
|
+
`# ${meta.name}@${meta.latest_version}`,
|
|
143
|
+
"",
|
|
144
|
+
`**Description:** ${meta.description}`,
|
|
145
|
+
`**Type:** ${meta.type}`,
|
|
146
|
+
`**Author:** ${meta.author}`,
|
|
147
|
+
`**License:** ${meta.license}`,
|
|
148
|
+
`**Category:** ${meta.category}`,
|
|
149
|
+
`**Downloads:** ${meta.downloads.toLocaleString()}`,
|
|
150
|
+
`**Repository:** ${meta.repository}`,
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
if (meta.models && meta.models.length > 0) {
|
|
154
|
+
lines.push(`**Models:** ${meta.models.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
if (meta.tags && meta.tags.length > 0) {
|
|
157
|
+
lines.push(`**Tags:** ${meta.tags.join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
lines.push(`**Versions:** ${meta.versions.join(", ")}`);
|
|
160
|
+
|
|
161
|
+
if (meta.dependencies) {
|
|
162
|
+
if (meta.dependencies.rules?.length) {
|
|
163
|
+
lines.push(`**Dependencies (rules):** ${meta.dependencies.rules.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
if (meta.dependencies.plans?.length) {
|
|
166
|
+
lines.push(`**Dependencies (plans):** ${meta.dependencies.plans.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (meta.variables) {
|
|
171
|
+
lines.push("", "**Variables:**");
|
|
172
|
+
for (const [name, def] of Object.entries(meta.variables)) {
|
|
173
|
+
const required = def.required ? " (required)" : "";
|
|
174
|
+
const defaultVal = def.default !== undefined ? ` [default: ${def.default}]` : "";
|
|
175
|
+
lines.push(`- \`${name}\`: ${def.type}${required}${defaultVal} — ${def.description}`);
|
|
176
|
+
if (def.options) {
|
|
177
|
+
lines.push(` Options: ${def.options.join(", ")}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return textResult(formatMessages(messages, lines.join("\n")));
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return errorResult("Error fetching package info", err as Error);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// -- planmode_preview --
|
|
190
|
+
server.registerTool(
|
|
191
|
+
"planmode_preview",
|
|
192
|
+
{
|
|
193
|
+
description: "Preview the full content of a planmode package from the registry without installing it. Returns the raw markdown content so you can review what the package does before installing.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
package: z.string().describe("Package name to preview"),
|
|
196
|
+
version: z.string().optional().describe("Specific version to preview (default: latest)"),
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
async ({ package: packageName, version }) => {
|
|
200
|
+
try {
|
|
201
|
+
const { result: resolved, messages: resolveMessages } = await withCaptureAsync(() =>
|
|
202
|
+
resolveVersion(packageName, version),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const versionMeta = await fetchVersionMetadata(packageName, resolved.version);
|
|
206
|
+
|
|
207
|
+
const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
|
|
208
|
+
const manifestRaw = await fetchFileAtTag(
|
|
209
|
+
versionMeta.source.repository,
|
|
210
|
+
versionMeta.source.tag,
|
|
211
|
+
`${basePath}planmode.yaml`,
|
|
212
|
+
);
|
|
213
|
+
const manifest = parseManifest(manifestRaw);
|
|
214
|
+
|
|
215
|
+
let content: string;
|
|
216
|
+
if (manifest.content) {
|
|
217
|
+
content = manifest.content;
|
|
218
|
+
} else if (manifest.content_file) {
|
|
219
|
+
content = await fetchFileAtTag(
|
|
220
|
+
versionMeta.source.repository,
|
|
221
|
+
versionMeta.source.tag,
|
|
222
|
+
`${basePath}${manifest.content_file}`,
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
return textResult("Package has no content or content_file defined.", true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const header = [
|
|
229
|
+
`# Preview: ${packageName}@${resolved.version}`,
|
|
230
|
+
`**Type:** ${manifest.type} | **Author:** ${manifest.author ?? "unknown"}`,
|
|
231
|
+
manifest.description ? `**Description:** ${manifest.description}` : "",
|
|
232
|
+
]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join("\n");
|
|
235
|
+
|
|
236
|
+
const variableNote =
|
|
237
|
+
manifest.variables && Object.keys(manifest.variables).length > 0
|
|
238
|
+
? `\n**Note:** This package uses template variables (${Object.keys(manifest.variables).join(", ")}). Content below shows raw templates.\n`
|
|
239
|
+
: "";
|
|
240
|
+
|
|
241
|
+
return textResult(`${header}${variableNote}\n---\n\n${content}`);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return errorResult("Error previewing package", err as Error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// -- planmode_install --
|
|
249
|
+
server.registerTool(
|
|
250
|
+
"planmode_install",
|
|
251
|
+
{
|
|
252
|
+
description: "Install a planmode package into the current project. Places plans in plans/, rules in .claude/rules/, prompts in prompts/. Updates CLAUDE.md with @import for plans. Use planmode_info first to check for required variables.",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
package: z.string().describe("Package name to install"),
|
|
255
|
+
version: z.string().optional().describe("Specific version to install (default: latest)"),
|
|
256
|
+
asRule: z.boolean().optional().describe("Force install as a rule to .claude/rules/"),
|
|
257
|
+
variables: z.record(z.string(), z.string()).optional().describe("Template variable values as key-value pairs"),
|
|
258
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
async ({ package: packageName, version, asRule, variables, projectDir }) => {
|
|
262
|
+
try {
|
|
263
|
+
const { messages } = await withCaptureAsync(() =>
|
|
264
|
+
installPackage(packageName, {
|
|
265
|
+
version,
|
|
266
|
+
forceRule: asRule,
|
|
267
|
+
noInput: true,
|
|
268
|
+
variables: variables as Record<string, string> | undefined,
|
|
269
|
+
projectDir,
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return textResult(adaptError(formatMessages(messages) || `Installed ${packageName} successfully.`));
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return errorResult("Error installing package", err as Error);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// -- planmode_uninstall --
|
|
281
|
+
server.registerTool(
|
|
282
|
+
"planmode_uninstall",
|
|
283
|
+
{
|
|
284
|
+
description: "Remove an installed planmode package from the current project",
|
|
285
|
+
inputSchema: {
|
|
286
|
+
package: z.string().describe("Package name to uninstall"),
|
|
287
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
async ({ package: packageName, projectDir }) => {
|
|
291
|
+
try {
|
|
292
|
+
const { messages } = await withCaptureAsync(() =>
|
|
293
|
+
uninstallPackage(packageName, projectDir),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return textResult(formatMessages(messages) || `Uninstalled ${packageName} successfully.`);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return errorResult("Error uninstalling package", err as Error);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// -- planmode_list --
|
|
304
|
+
server.registerTool(
|
|
305
|
+
"planmode_list",
|
|
306
|
+
{
|
|
307
|
+
description: "List all planmode packages installed in the current project",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
async ({ projectDir }) => {
|
|
313
|
+
try {
|
|
314
|
+
const lockfile = readLockfile(projectDir);
|
|
315
|
+
const entries = Object.entries(lockfile.packages);
|
|
316
|
+
|
|
317
|
+
if (entries.length === 0) {
|
|
318
|
+
return textResult("No packages installed. Use the planmode_install tool to install a package.");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const table = entries
|
|
322
|
+
.map(
|
|
323
|
+
([name, entry]) =>
|
|
324
|
+
`- **${name}** (${entry.type} v${entry.version}) → ${entry.installed_to}`,
|
|
325
|
+
)
|
|
326
|
+
.join("\n");
|
|
327
|
+
|
|
328
|
+
return textResult(`${entries.length} package(s) installed:\n\n${table}`);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return errorResult("Error listing packages", err as Error);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// -- planmode_read --
|
|
336
|
+
server.registerTool(
|
|
337
|
+
"planmode_read",
|
|
338
|
+
{
|
|
339
|
+
description: "Read the content of an installed planmode package from disk. Use this to view what a plan, rule, or prompt contains after installation.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
package: z.string().describe("Package name to read"),
|
|
342
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
async ({ package: packageName, projectDir }) => {
|
|
346
|
+
try {
|
|
347
|
+
const dir = projectDir ?? process.cwd();
|
|
348
|
+
const lockfile = readLockfile(dir);
|
|
349
|
+
const entry = lockfile.packages[packageName];
|
|
350
|
+
|
|
351
|
+
if (!entry) {
|
|
352
|
+
// Try to find it by looking in common locations
|
|
353
|
+
const candidates = [
|
|
354
|
+
path.join(dir, "plans", `${packageName}.md`),
|
|
355
|
+
path.join(dir, ".claude", "rules", `${packageName}.md`),
|
|
356
|
+
path.join(dir, "prompts", `${packageName}.md`),
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
for (const candidate of candidates) {
|
|
360
|
+
if (fs.existsSync(candidate)) {
|
|
361
|
+
const content = fs.readFileSync(candidate, "utf-8");
|
|
362
|
+
const relativePath = path.relative(dir, candidate);
|
|
363
|
+
return textResult(`# ${packageName}\n**Location:** ${relativePath}\n\n---\n\n${content}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return textResult(
|
|
368
|
+
`Package '${packageName}' is not installed. Use planmode_list to see installed packages, or planmode_preview to view a package from the registry.`,
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const fullPath = path.join(dir, entry.installed_to);
|
|
374
|
+
if (!fs.existsSync(fullPath)) {
|
|
375
|
+
return textResult(
|
|
376
|
+
`Package '${packageName}' is in the lockfile but the file is missing at ${entry.installed_to}. Try reinstalling with planmode_install.`,
|
|
377
|
+
true,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
382
|
+
return textResult(
|
|
383
|
+
`# ${packageName} (${entry.type} v${entry.version})\n**Location:** ${entry.installed_to}\n\n---\n\n${content}`,
|
|
384
|
+
);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return errorResult("Error reading package", err as Error);
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// -- planmode_update --
|
|
392
|
+
server.registerTool(
|
|
393
|
+
"planmode_update",
|
|
394
|
+
{
|
|
395
|
+
description: "Update installed planmode packages to their latest compatible versions",
|
|
396
|
+
inputSchema: {
|
|
397
|
+
package: z.string().optional().describe("Package name to update (omit to update all)"),
|
|
398
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
async ({ package: packageName, projectDir }) => {
|
|
402
|
+
try {
|
|
403
|
+
if (packageName) {
|
|
404
|
+
const { result: updated, messages } = await withCaptureAsync(() =>
|
|
405
|
+
updatePackage(packageName, projectDir),
|
|
406
|
+
);
|
|
407
|
+
if (!updated) {
|
|
408
|
+
return textResult(formatMessages(messages, `${packageName} is already up to date.`));
|
|
409
|
+
}
|
|
410
|
+
return textResult(formatMessages(messages) || `Updated ${packageName} successfully.`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Update all
|
|
414
|
+
const lockfile = readLockfile(projectDir);
|
|
415
|
+
const names = Object.keys(lockfile.packages);
|
|
416
|
+
|
|
417
|
+
if (names.length === 0) {
|
|
418
|
+
return textResult("No packages installed.");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const results: string[] = [];
|
|
422
|
+
let updatedCount = 0;
|
|
423
|
+
|
|
424
|
+
for (const name of names) {
|
|
425
|
+
try {
|
|
426
|
+
const { result: updated, messages } = await withCaptureAsync(() =>
|
|
427
|
+
updatePackage(name, projectDir),
|
|
428
|
+
);
|
|
429
|
+
if (updated) {
|
|
430
|
+
updatedCount++;
|
|
431
|
+
results.push(`Updated ${name}`);
|
|
432
|
+
}
|
|
433
|
+
} catch (err) {
|
|
434
|
+
results.push(`Failed to update ${name}: ${(err as Error).message}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (updatedCount === 0) {
|
|
439
|
+
return textResult("All packages are up to date.");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return textResult(`Updated ${updatedCount} package(s):\n\n${results.join("\n")}`);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
return errorResult("Error updating packages", err as Error);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// -- planmode_init --
|
|
450
|
+
server.registerTool(
|
|
451
|
+
"planmode_init",
|
|
452
|
+
{
|
|
453
|
+
description: "Initialize a new planmode package by creating planmode.yaml and a content stub file",
|
|
454
|
+
inputSchema: {
|
|
455
|
+
name: z.string().describe("Package name (lowercase, hyphens only)"),
|
|
456
|
+
type: z.enum(["plan", "rule", "prompt"]).describe("Package type"),
|
|
457
|
+
description: z.string().describe("Package description (max 200 chars)"),
|
|
458
|
+
author: z.string().describe("Author GitHub username"),
|
|
459
|
+
license: z.string().optional().describe("License identifier (default: MIT)"),
|
|
460
|
+
tags: z.array(z.string()).optional().describe("Tags for discovery (max 10)"),
|
|
461
|
+
category: z.enum([
|
|
462
|
+
"frontend", "backend", "devops", "database", "testing",
|
|
463
|
+
"mobile", "ai-ml", "design", "security", "other",
|
|
464
|
+
]).optional().describe("Package category (default: other)"),
|
|
465
|
+
projectDir: z.string().optional().describe("Directory to create the package in (default: current working directory)"),
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
async ({ name, type, description, author, license, tags, category, projectDir }) => {
|
|
469
|
+
try {
|
|
470
|
+
const { result, messages } = withCapture(() =>
|
|
471
|
+
createPackage({
|
|
472
|
+
name,
|
|
473
|
+
type,
|
|
474
|
+
description,
|
|
475
|
+
author,
|
|
476
|
+
license,
|
|
477
|
+
tags,
|
|
478
|
+
category: category as Category | undefined,
|
|
479
|
+
projectDir,
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
return textResult(
|
|
484
|
+
formatMessages(
|
|
485
|
+
messages,
|
|
486
|
+
`Created package "${name}":\n- ${result.files.join("\n- ")}\n\nEdit the content file, then use the planmode_publish tool when ready.`,
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return errorResult("Error creating package", err as Error);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// -- planmode_publish --
|
|
496
|
+
server.registerTool(
|
|
497
|
+
"planmode_publish",
|
|
498
|
+
{
|
|
499
|
+
description: "Publish a planmode package to the registry. Creates a git tag, forks the registry, and opens a PR. Requires GitHub authentication (configure via `planmode login` in terminal or PLANMODE_GITHUB_TOKEN env var).",
|
|
500
|
+
inputSchema: {
|
|
501
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)"),
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
async ({ projectDir }) => {
|
|
505
|
+
try {
|
|
506
|
+
const { result, messages } = await withCaptureAsync(() =>
|
|
507
|
+
publishPackage({ projectDir }),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
return textResult(
|
|
511
|
+
formatMessages(
|
|
512
|
+
messages,
|
|
513
|
+
`Published ${result.packageName}@${result.version}\nPR: ${result.prUrl}`,
|
|
514
|
+
),
|
|
515
|
+
);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
return errorResult("Error publishing package", err as Error);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// -- planmode_validate --
|
|
523
|
+
server.registerTool(
|
|
524
|
+
"planmode_validate",
|
|
525
|
+
{
|
|
526
|
+
description: "Validate a planmode.yaml manifest file for correctness",
|
|
527
|
+
inputSchema: {
|
|
528
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)"),
|
|
529
|
+
requirePublishFields: z.boolean().optional().describe("Require fields needed for publishing (description, author, license)"),
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
async ({ projectDir, requirePublishFields }) => {
|
|
533
|
+
try {
|
|
534
|
+
const dir = projectDir ?? process.cwd();
|
|
535
|
+
const manifest = readManifest(dir);
|
|
536
|
+
const errors = validateManifest(manifest, requirePublishFields ?? false);
|
|
537
|
+
|
|
538
|
+
if (errors.length === 0) {
|
|
539
|
+
return textResult(
|
|
540
|
+
`Manifest is valid: ${manifest.name}@${manifest.version} (${manifest.type})`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return textResult(
|
|
545
|
+
`Manifest validation failed:\n\n${errors.map((e) => `- ${e}`).join("\n")}`,
|
|
546
|
+
true,
|
|
547
|
+
);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
return errorResult("Error reading manifest", err as Error);
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// -- planmode_run --
|
|
555
|
+
server.registerTool(
|
|
556
|
+
"planmode_run",
|
|
557
|
+
{
|
|
558
|
+
description: "Render a templated planmode prompt with variables and return the result",
|
|
559
|
+
inputSchema: {
|
|
560
|
+
prompt: z.string().describe("Prompt package name (looks in prompts/ directory)"),
|
|
561
|
+
variables: z.record(z.string(), z.string()).optional().describe("Template variable values as key-value pairs"),
|
|
562
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
async ({ prompt: promptName, variables, projectDir }) => {
|
|
566
|
+
try {
|
|
567
|
+
const dir = projectDir ?? process.cwd();
|
|
568
|
+
const localPath = path.join(dir, "prompts", `${promptName}.md`);
|
|
569
|
+
const localManifestPath = path.join(dir, "prompts", promptName, "planmode.yaml");
|
|
570
|
+
|
|
571
|
+
let content: string;
|
|
572
|
+
let manifest: ReturnType<typeof parseManifest> | undefined;
|
|
573
|
+
|
|
574
|
+
if (fs.existsSync(localManifestPath)) {
|
|
575
|
+
const raw = fs.readFileSync(localManifestPath, "utf-8");
|
|
576
|
+
manifest = parseManifest(raw);
|
|
577
|
+
const promptDir = path.join(dir, "prompts", promptName);
|
|
578
|
+
content = readPackageContent(promptDir, manifest);
|
|
579
|
+
} else if (fs.existsSync(localPath)) {
|
|
580
|
+
content = fs.readFileSync(localPath, "utf-8");
|
|
581
|
+
} else {
|
|
582
|
+
return textResult(
|
|
583
|
+
`Prompt '${promptName}' not found locally. Install it first using the planmode_install tool.`,
|
|
584
|
+
true,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (manifest?.variables && Object.keys(manifest.variables).length > 0) {
|
|
589
|
+
const provided = (variables ?? {}) as Record<string, string>;
|
|
590
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
591
|
+
content = renderTemplate(content, values);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return textResult(content);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
return errorResult("Error running prompt", err as Error);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
// -- planmode_doctor --
|
|
602
|
+
server.registerTool(
|
|
603
|
+
"planmode_doctor",
|
|
604
|
+
{
|
|
605
|
+
description: "Check project health: verify installed packages have matching files on disk, CLAUDE.md imports are correct, and content hashes haven't drifted. Use this to diagnose issues with planmode packages.",
|
|
606
|
+
inputSchema: {
|
|
607
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
608
|
+
},
|
|
609
|
+
},
|
|
610
|
+
async ({ projectDir }) => {
|
|
611
|
+
try {
|
|
612
|
+
const result = runDoctor(projectDir);
|
|
613
|
+
|
|
614
|
+
if (result.issues.length === 0) {
|
|
615
|
+
return textResult(`Checked ${result.packagesChecked} package(s) — all healthy. No issues found.`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const lines = [`Checked ${result.packagesChecked} package(s):\n`];
|
|
619
|
+
|
|
620
|
+
for (const issue of result.issues) {
|
|
621
|
+
const icon = issue.severity === "error" ? "ERROR" : "WARN";
|
|
622
|
+
lines.push(`**${icon}:** ${issue.message}`);
|
|
623
|
+
if (issue.fix) {
|
|
624
|
+
lines.push(` Fix: ${adaptError(issue.fix)}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const errors = result.issues.filter((i) => i.severity === "error").length;
|
|
629
|
+
const warnings = result.issues.filter((i) => i.severity === "warning").length;
|
|
630
|
+
lines.push("", `${errors} error(s), ${warnings} warning(s)`);
|
|
631
|
+
|
|
632
|
+
return textResult(lines.join("\n"), errors > 0);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
return errorResult("Error running health check", err as Error);
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// -- planmode_test --
|
|
640
|
+
server.registerTool(
|
|
641
|
+
"planmode_test",
|
|
642
|
+
{
|
|
643
|
+
description: "Test a planmode package before publishing. Validates the manifest, checks that templates render with default values, verifies dependencies exist in the registry, and checks content size.",
|
|
644
|
+
inputSchema: {
|
|
645
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)"),
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
async ({ projectDir }) => {
|
|
649
|
+
try {
|
|
650
|
+
const { result, messages } = await withCaptureAsync(() =>
|
|
651
|
+
testPackage(projectDir),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const lines: string[] = [];
|
|
655
|
+
|
|
656
|
+
for (const check of result.checks) {
|
|
657
|
+
if (check.passed) {
|
|
658
|
+
lines.push(`PASS: ${check.name}`);
|
|
659
|
+
} else {
|
|
660
|
+
const issue = result.issues.find((i) => i.check === check.name);
|
|
661
|
+
const severity = issue?.severity === "error" ? "FAIL" : "WARN";
|
|
662
|
+
lines.push(`${severity}: ${check.name}${issue ? ` — ${issue.message}` : ""}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
lines.push("");
|
|
667
|
+
if (result.passed) {
|
|
668
|
+
lines.push("All checks passed. Ready to publish.");
|
|
669
|
+
} else {
|
|
670
|
+
const errors = result.issues.filter((i) => i.severity === "error").length;
|
|
671
|
+
const warnings = result.issues.filter((i) => i.severity === "warning").length;
|
|
672
|
+
lines.push(`${errors} error(s), ${warnings} warning(s). Fix errors before publishing.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return textResult(formatMessages(messages, lines.join("\n")), !result.passed);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
return errorResult("Error testing package", err as Error);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// -- planmode_record_start --
|
|
683
|
+
server.registerTool(
|
|
684
|
+
"planmode_record_start",
|
|
685
|
+
{
|
|
686
|
+
description: "Start recording git activity. Saves the current HEAD commit as the starting point. Work normally (make commits), then use planmode_record_stop to generate a plan from the commits.",
|
|
687
|
+
inputSchema: {
|
|
688
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
async ({ projectDir }) => {
|
|
692
|
+
try {
|
|
693
|
+
const dir = projectDir ?? process.cwd();
|
|
694
|
+
if (isRecording(dir)) {
|
|
695
|
+
return textResult("A recording is already in progress. Use planmode_record_stop to finish it first.", true);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const sha = await startRecordingAsync(dir);
|
|
699
|
+
return textResult(`Recording started at commit ${sha.slice(0, 7)}. Make commits as normal, then use planmode_record_stop to generate a plan.`);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
return errorResult("Error starting recording", err as Error);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
// -- planmode_record_stop --
|
|
707
|
+
server.registerTool(
|
|
708
|
+
"planmode_record_stop",
|
|
709
|
+
{
|
|
710
|
+
description: "Stop recording and generate a planmode package from the git commits made since recording started. Creates planmode.yaml and plan.md with each commit as a step.",
|
|
711
|
+
inputSchema: {
|
|
712
|
+
name: z.string().optional().describe("Package name (auto-inferred from commits if not provided)"),
|
|
713
|
+
author: z.string().optional().describe("Author GitHub username"),
|
|
714
|
+
outputDir: z.string().optional().describe("Directory to write planmode.yaml and plan.md (default: current working directory)"),
|
|
715
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)"),
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
async ({ name, author, outputDir, projectDir }) => {
|
|
719
|
+
try {
|
|
720
|
+
const dir = projectDir ?? process.cwd();
|
|
721
|
+
|
|
722
|
+
const result = await stopRecording(dir, { name, author });
|
|
723
|
+
|
|
724
|
+
// Write files
|
|
725
|
+
const outDir = outputDir ?? dir;
|
|
726
|
+
const fsModule = await import("node:fs");
|
|
727
|
+
const pathModule = await import("node:path");
|
|
728
|
+
fsModule.mkdirSync(outDir, { recursive: true });
|
|
729
|
+
fsModule.writeFileSync(pathModule.join(outDir, "planmode.yaml"), result.manifestContent, "utf-8");
|
|
730
|
+
fsModule.writeFileSync(pathModule.join(outDir, "plan.md"), result.planContent, "utf-8");
|
|
731
|
+
|
|
732
|
+
const stepList = result.steps
|
|
733
|
+
.map((s, i) => `${i + 1}. ${s.title} (${s.filesChanged.length} files)`)
|
|
734
|
+
.join("\n");
|
|
735
|
+
|
|
736
|
+
return textResult(
|
|
737
|
+
`Generated plan from ${result.totalCommits} commit(s) (${result.totalFilesChanged} files changed):\n\n${stepList}\n\nCreated planmode.yaml and plan.md. Edit the plan content, then use planmode_test to validate and planmode_publish to publish.`,
|
|
738
|
+
);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return errorResult("Error stopping recording", err as Error);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// -- planmode_snapshot --
|
|
746
|
+
server.registerTool(
|
|
747
|
+
"planmode_snapshot",
|
|
748
|
+
{
|
|
749
|
+
description: "Analyze the current project and generate a planmode package that recreates this setup. Reads package.json, detects config files and tools, captures the directory structure, and creates a step-by-step plan.",
|
|
750
|
+
inputSchema: {
|
|
751
|
+
name: z.string().optional().describe("Package name (auto-inferred from project name)"),
|
|
752
|
+
author: z.string().optional().describe("Author GitHub username"),
|
|
753
|
+
outputDir: z.string().optional().describe("Directory to write planmode.yaml and plan.md (default: current working directory)"),
|
|
754
|
+
projectDir: z.string().optional().describe("Project to analyze (default: current working directory)"),
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
async ({ name, author, outputDir, projectDir }) => {
|
|
758
|
+
try {
|
|
759
|
+
const dir = projectDir ?? process.cwd();
|
|
760
|
+
const result = takeSnapshot(dir, { name, author });
|
|
761
|
+
|
|
762
|
+
// Write files
|
|
763
|
+
const outDir = outputDir ?? dir;
|
|
764
|
+
const fsModule = await import("node:fs");
|
|
765
|
+
const pathModule = await import("node:path");
|
|
766
|
+
fsModule.mkdirSync(outDir, { recursive: true });
|
|
767
|
+
fsModule.writeFileSync(pathModule.join(outDir, "planmode.yaml"), result.manifestContent, "utf-8");
|
|
768
|
+
fsModule.writeFileSync(pathModule.join(outDir, "plan.md"), result.planContent, "utf-8");
|
|
769
|
+
|
|
770
|
+
const toolList = result.data.detectedTools.map((t) => t.name).join(", ") || "none";
|
|
771
|
+
const depCount = Object.keys(result.data.dependencies).length;
|
|
772
|
+
const devDepCount = Object.keys(result.data.devDependencies).length;
|
|
773
|
+
|
|
774
|
+
let summary = `Snapshot: **${result.data.name}**\n`;
|
|
775
|
+
if (result.data.framework) summary += `Framework: ${result.data.framework}\n`;
|
|
776
|
+
summary += `Dependencies: ${depCount} | Dev dependencies: ${devDepCount}\n`;
|
|
777
|
+
summary += `Tools detected: ${toolList}\n\n`;
|
|
778
|
+
summary += `Created planmode.yaml and plan.md. Edit the plan content to add details, then use planmode_test to validate and planmode_publish to publish.`;
|
|
779
|
+
|
|
780
|
+
return textResult(summary);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
return errorResult("Error creating snapshot", err as Error);
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
// ── Resources ──
|
|
788
|
+
|
|
789
|
+
// Expose installed packages as browsable resources
|
|
790
|
+
server.registerResource(
|
|
791
|
+
"installed-packages",
|
|
792
|
+
new ResourceTemplate("planmode://packages/{name}", {
|
|
793
|
+
list: async () => {
|
|
794
|
+
const lockfile = readLockfile();
|
|
795
|
+
return {
|
|
796
|
+
resources: Object.entries(lockfile.packages).map(([name, entry]) => ({
|
|
797
|
+
uri: `planmode://packages/${name}`,
|
|
798
|
+
name: `${name} (${entry.type} v${entry.version})`,
|
|
799
|
+
description: `Installed at ${entry.installed_to}`,
|
|
800
|
+
mimeType: "text/markdown",
|
|
801
|
+
})),
|
|
802
|
+
};
|
|
803
|
+
},
|
|
804
|
+
}),
|
|
805
|
+
{
|
|
806
|
+
description: "Installed planmode packages in the current project. Each resource contains the full content of an installed plan, rule, or prompt.",
|
|
807
|
+
mimeType: "text/markdown",
|
|
808
|
+
},
|
|
809
|
+
async (uri, variables) => {
|
|
810
|
+
const name = variables["name"] as string;
|
|
811
|
+
const lockfile = readLockfile();
|
|
812
|
+
const entry = lockfile.packages[name];
|
|
813
|
+
|
|
814
|
+
if (!entry) {
|
|
815
|
+
return {
|
|
816
|
+
contents: [{
|
|
817
|
+
uri: uri.href,
|
|
818
|
+
mimeType: "text/plain",
|
|
819
|
+
text: `Package '${name}' is not installed.`,
|
|
820
|
+
}],
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const fullPath = path.join(process.cwd(), entry.installed_to);
|
|
825
|
+
let content: string;
|
|
826
|
+
try {
|
|
827
|
+
content = fs.readFileSync(fullPath, "utf-8");
|
|
828
|
+
} catch {
|
|
829
|
+
content = `File not found at ${entry.installed_to}. The package may need to be reinstalled.`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
contents: [{
|
|
834
|
+
uri: uri.href,
|
|
835
|
+
mimeType: "text/markdown",
|
|
836
|
+
text: content,
|
|
837
|
+
}],
|
|
838
|
+
};
|
|
839
|
+
},
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
// ── Start server ──
|
|
843
|
+
|
|
844
|
+
async function main() {
|
|
845
|
+
const transport = new StdioServerTransport();
|
|
846
|
+
await server.connect(transport);
|
|
847
|
+
console.error("planmode MCP server running on stdio");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
main().catch((error) => {
|
|
851
|
+
console.error("Server error:", error);
|
|
852
|
+
process.exit(1);
|
|
853
|
+
});
|