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/dist/mcp.js
ADDED
|
@@ -0,0 +1,2435 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp.ts
|
|
4
|
+
import fs12 from "fs";
|
|
5
|
+
import path12 from "path";
|
|
6
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { z } from "zod/v4";
|
|
9
|
+
|
|
10
|
+
// src/lib/logger.ts
|
|
11
|
+
var RESET = "\x1B[0m";
|
|
12
|
+
var RED = "\x1B[31m";
|
|
13
|
+
var GREEN = "\x1B[32m";
|
|
14
|
+
var YELLOW = "\x1B[33m";
|
|
15
|
+
var CYAN = "\x1B[36m";
|
|
16
|
+
var DIM = "\x1B[2m";
|
|
17
|
+
var BOLD = "\x1B[1m";
|
|
18
|
+
function stripAnsi(str) {
|
|
19
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
20
|
+
}
|
|
21
|
+
var capturing = false;
|
|
22
|
+
var captured = [];
|
|
23
|
+
var logger = {
|
|
24
|
+
capture() {
|
|
25
|
+
capturing = true;
|
|
26
|
+
captured = [];
|
|
27
|
+
},
|
|
28
|
+
flush() {
|
|
29
|
+
const messages = captured;
|
|
30
|
+
captured = [];
|
|
31
|
+
capturing = false;
|
|
32
|
+
return messages;
|
|
33
|
+
},
|
|
34
|
+
isCapturing() {
|
|
35
|
+
return capturing;
|
|
36
|
+
},
|
|
37
|
+
info(msg) {
|
|
38
|
+
const text = `info ${msg}`;
|
|
39
|
+
if (capturing) {
|
|
40
|
+
captured.push(stripAnsi(text));
|
|
41
|
+
} else {
|
|
42
|
+
console.log(`${CYAN}info${RESET} ${msg}`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
success(msg) {
|
|
46
|
+
const text = `\u2713 ${msg}`;
|
|
47
|
+
if (capturing) {
|
|
48
|
+
captured.push(stripAnsi(text));
|
|
49
|
+
} else {
|
|
50
|
+
console.log(`${GREEN}\u2713${RESET} ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
warn(msg) {
|
|
54
|
+
const text = `warn ${msg}`;
|
|
55
|
+
if (capturing) {
|
|
56
|
+
captured.push(stripAnsi(text));
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`${YELLOW}warn${RESET} ${msg}`);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
error(msg) {
|
|
62
|
+
const text = `error ${msg}`;
|
|
63
|
+
if (capturing) {
|
|
64
|
+
captured.push(stripAnsi(text));
|
|
65
|
+
} else {
|
|
66
|
+
console.error(`${RED}error${RESET} ${msg}`);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
dim(msg) {
|
|
70
|
+
if (capturing) {
|
|
71
|
+
captured.push(msg);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(`${DIM}${msg}${RESET}`);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
bold(msg) {
|
|
77
|
+
if (capturing) {
|
|
78
|
+
captured.push(msg);
|
|
79
|
+
} else {
|
|
80
|
+
console.log(`${BOLD}${msg}${RESET}`);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
table(headers, rows) {
|
|
84
|
+
const colWidths = headers.map(
|
|
85
|
+
(h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
|
|
86
|
+
);
|
|
87
|
+
const header = headers.map((h, i) => h.toUpperCase().padEnd(colWidths[i])).join(" ");
|
|
88
|
+
if (capturing) {
|
|
89
|
+
captured.push(` ${header}`);
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
const line = row.map((cell, i) => cell.padEnd(colWidths[i])).join(" ");
|
|
92
|
+
captured.push(` ${line}`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
console.log(` ${DIM}${header}${RESET}`);
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
const line = row.map((cell, i) => cell.padEnd(colWidths[i])).join(" ");
|
|
98
|
+
console.log(` ${line}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
blank() {
|
|
103
|
+
if (capturing) {
|
|
104
|
+
captured.push("");
|
|
105
|
+
} else {
|
|
106
|
+
console.log();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// src/lib/registry.ts
|
|
112
|
+
import fs2 from "fs";
|
|
113
|
+
import path2 from "path";
|
|
114
|
+
|
|
115
|
+
// src/lib/config.ts
|
|
116
|
+
import fs from "fs";
|
|
117
|
+
import path from "path";
|
|
118
|
+
import os from "os";
|
|
119
|
+
import { parse, stringify } from "yaml";
|
|
120
|
+
var CONFIG_DIR = path.join(os.homedir(), ".planmode");
|
|
121
|
+
var CONFIG_PATH = path.join(CONFIG_DIR, "config");
|
|
122
|
+
var CACHE_DIR = path.join(CONFIG_DIR, "cache");
|
|
123
|
+
function getCacheDir() {
|
|
124
|
+
const config = readConfig();
|
|
125
|
+
return config.cache?.dir ?? CACHE_DIR;
|
|
126
|
+
}
|
|
127
|
+
function getCacheTTL() {
|
|
128
|
+
const config = readConfig();
|
|
129
|
+
return config.cache?.ttl ?? 3600;
|
|
130
|
+
}
|
|
131
|
+
function readConfig() {
|
|
132
|
+
try {
|
|
133
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
134
|
+
return parse(raw) ?? {};
|
|
135
|
+
} catch {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function getGitHubToken() {
|
|
140
|
+
const envToken = process.env["PLANMODE_GITHUB_TOKEN"];
|
|
141
|
+
if (envToken) return envToken;
|
|
142
|
+
const config = readConfig();
|
|
143
|
+
return config.auth?.github_token;
|
|
144
|
+
}
|
|
145
|
+
function getRegistries() {
|
|
146
|
+
const config = readConfig();
|
|
147
|
+
return {
|
|
148
|
+
default: "github.com/kaihannonen/planmode.org/registry",
|
|
149
|
+
...config.registries
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/lib/registry.ts
|
|
154
|
+
var INDEX_CACHE_FILE = "index.json";
|
|
155
|
+
function getHeaders() {
|
|
156
|
+
const headers = {
|
|
157
|
+
"Accept": "application/vnd.github.v3.raw+json",
|
|
158
|
+
"User-Agent": "planmode-cli"
|
|
159
|
+
};
|
|
160
|
+
const token = getGitHubToken();
|
|
161
|
+
if (token) {
|
|
162
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
163
|
+
}
|
|
164
|
+
return headers;
|
|
165
|
+
}
|
|
166
|
+
function registryRawUrl(registryUrl, filePath) {
|
|
167
|
+
const match = registryUrl.match(/^github\.com\/([^/]+)\/([^/]+)(?:\/(.+))?$/);
|
|
168
|
+
if (!match) {
|
|
169
|
+
throw new Error(`Invalid registry URL: ${registryUrl}`);
|
|
170
|
+
}
|
|
171
|
+
const subpath = match[3] ? `${match[3]}/` : "";
|
|
172
|
+
return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/${subpath}${filePath}`;
|
|
173
|
+
}
|
|
174
|
+
function resolveRegistry(packageName) {
|
|
175
|
+
const registries = getRegistries();
|
|
176
|
+
if (packageName.startsWith("@")) {
|
|
177
|
+
const scope = packageName.split("/")[0].slice(1);
|
|
178
|
+
const registryUrl = registries[scope];
|
|
179
|
+
if (!registryUrl) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`No registry configured for scope "@${scope}". Run: planmode registry add ${scope} <url>`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return registryUrl;
|
|
185
|
+
}
|
|
186
|
+
return registries["default"];
|
|
187
|
+
}
|
|
188
|
+
async function fetchIndex(registryUrl) {
|
|
189
|
+
const url = registryUrl ?? getRegistries()["default"];
|
|
190
|
+
const cacheDir = getCacheDir();
|
|
191
|
+
const cachePath = path2.join(cacheDir, INDEX_CACHE_FILE);
|
|
192
|
+
const ttl = getCacheTTL();
|
|
193
|
+
try {
|
|
194
|
+
const stat = fs2.statSync(cachePath);
|
|
195
|
+
const ageSeconds = (Date.now() - stat.mtimeMs) / 1e3;
|
|
196
|
+
if (ageSeconds < ttl) {
|
|
197
|
+
const cached = JSON.parse(fs2.readFileSync(cachePath, "utf-8"));
|
|
198
|
+
return cached;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
const rawUrl = registryRawUrl(url, "index.json");
|
|
203
|
+
const response = await fetch(rawUrl, { headers: getHeaders() });
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new Error(`Failed to fetch registry index: ${response.status} ${response.statusText}`);
|
|
206
|
+
}
|
|
207
|
+
const data = await response.json();
|
|
208
|
+
fs2.mkdirSync(cacheDir, { recursive: true });
|
|
209
|
+
fs2.writeFileSync(cachePath, JSON.stringify(data, null, 2), "utf-8");
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
212
|
+
async function searchPackages(query, options) {
|
|
213
|
+
const index = await fetchIndex();
|
|
214
|
+
const q = query.toLowerCase();
|
|
215
|
+
let results = index.packages.filter((pkg) => {
|
|
216
|
+
const searchable = [pkg.name, pkg.description, pkg.author, ...pkg.tags].join(" ").toLowerCase();
|
|
217
|
+
return searchable.includes(q);
|
|
218
|
+
});
|
|
219
|
+
if (options?.type) {
|
|
220
|
+
results = results.filter((pkg) => pkg.type === options.type);
|
|
221
|
+
}
|
|
222
|
+
if (options?.category) {
|
|
223
|
+
results = results.filter((pkg) => pkg.category === options.category);
|
|
224
|
+
}
|
|
225
|
+
return results.sort((a, b) => b.downloads - a.downloads);
|
|
226
|
+
}
|
|
227
|
+
async function fetchPackageMetadata(packageName) {
|
|
228
|
+
const registryUrl = resolveRegistry(packageName);
|
|
229
|
+
const name = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
|
230
|
+
const rawUrl = registryRawUrl(registryUrl, `packages/${name}/metadata.json`);
|
|
231
|
+
const response = await fetch(rawUrl, { headers: getHeaders() });
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
if (response.status === 404) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Package '${packageName}' not found in registry. Run \`planmode search <query>\` to find packages.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`Failed to fetch package metadata: ${response.status}`);
|
|
239
|
+
}
|
|
240
|
+
return await response.json();
|
|
241
|
+
}
|
|
242
|
+
async function fetchVersionMetadata(packageName, version) {
|
|
243
|
+
const registryUrl = resolveRegistry(packageName);
|
|
244
|
+
const name = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
|
245
|
+
const rawUrl = registryRawUrl(registryUrl, `packages/${name}/versions/${version}.json`);
|
|
246
|
+
const response = await fetch(rawUrl, { headers: getHeaders() });
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
if (response.status === 404) {
|
|
249
|
+
throw new Error(`Version '${version}' not found for '${packageName}'.`);
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Failed to fetch version metadata: ${response.status}`);
|
|
252
|
+
}
|
|
253
|
+
return await response.json();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/lib/installer.ts
|
|
257
|
+
import fs7 from "fs";
|
|
258
|
+
import path7 from "path";
|
|
259
|
+
import crypto from "crypto";
|
|
260
|
+
|
|
261
|
+
// src/lib/resolver.ts
|
|
262
|
+
function parseSemver(version) {
|
|
263
|
+
const parts = version.split(".").map(Number);
|
|
264
|
+
return { major: parts[0], minor: parts[1], patch: parts[2] };
|
|
265
|
+
}
|
|
266
|
+
function parseVersionRange(range) {
|
|
267
|
+
if (range === "*") {
|
|
268
|
+
return { operator: "any", major: 0, minor: 0, patch: 0 };
|
|
269
|
+
}
|
|
270
|
+
if (range.startsWith("^")) {
|
|
271
|
+
const { major: major2, minor: minor2, patch: patch2 } = parseSemver(range.slice(1));
|
|
272
|
+
return { operator: "caret", major: major2, minor: minor2, patch: patch2 };
|
|
273
|
+
}
|
|
274
|
+
if (range.startsWith("~")) {
|
|
275
|
+
const { major: major2, minor: minor2, patch: patch2 } = parseSemver(range.slice(1));
|
|
276
|
+
return { operator: "tilde", major: major2, minor: minor2, patch: patch2 };
|
|
277
|
+
}
|
|
278
|
+
if (range.startsWith(">=")) {
|
|
279
|
+
const { major: major2, minor: minor2, patch: patch2 } = parseSemver(range.slice(2));
|
|
280
|
+
return { operator: "gte", major: major2, minor: minor2, patch: patch2 };
|
|
281
|
+
}
|
|
282
|
+
const { major, minor, patch } = parseSemver(range);
|
|
283
|
+
return { operator: "exact", major, minor, patch };
|
|
284
|
+
}
|
|
285
|
+
function satisfies(version, range) {
|
|
286
|
+
const v = parseSemver(version);
|
|
287
|
+
switch (range.operator) {
|
|
288
|
+
case "any":
|
|
289
|
+
return true;
|
|
290
|
+
case "exact":
|
|
291
|
+
return v.major === range.major && v.minor === range.minor && v.patch === range.patch;
|
|
292
|
+
case "caret":
|
|
293
|
+
if (v.major !== range.major) return false;
|
|
294
|
+
if (v.minor > range.minor) return true;
|
|
295
|
+
if (v.minor === range.minor) return v.patch >= range.patch;
|
|
296
|
+
return false;
|
|
297
|
+
case "tilde":
|
|
298
|
+
if (v.major !== range.major) return false;
|
|
299
|
+
if (v.minor !== range.minor) return false;
|
|
300
|
+
return v.patch >= range.patch;
|
|
301
|
+
case "gte":
|
|
302
|
+
if (v.major > range.major) return true;
|
|
303
|
+
if (v.major < range.major) return false;
|
|
304
|
+
if (v.minor > range.minor) return true;
|
|
305
|
+
if (v.minor < range.minor) return false;
|
|
306
|
+
return v.patch >= range.patch;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function compareVersions(a, b) {
|
|
310
|
+
const va = parseSemver(a);
|
|
311
|
+
const vb = parseSemver(b);
|
|
312
|
+
if (va.major !== vb.major) return va.major - vb.major;
|
|
313
|
+
if (va.minor !== vb.minor) return va.minor - vb.minor;
|
|
314
|
+
return va.patch - vb.patch;
|
|
315
|
+
}
|
|
316
|
+
function parseDepString(dep) {
|
|
317
|
+
const atIndex = dep.lastIndexOf("@");
|
|
318
|
+
if (atIndex > 0) {
|
|
319
|
+
return {
|
|
320
|
+
name: dep.slice(0, atIndex),
|
|
321
|
+
range: dep.slice(atIndex + 1)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { name: dep, range: "*" };
|
|
325
|
+
}
|
|
326
|
+
async function resolveVersion(packageName, versionRange) {
|
|
327
|
+
const metadata = await fetchPackageMetadata(packageName);
|
|
328
|
+
if (!versionRange || versionRange === "latest") {
|
|
329
|
+
return { version: metadata.latest_version, metadata };
|
|
330
|
+
}
|
|
331
|
+
const range = parseVersionRange(versionRange);
|
|
332
|
+
const matching = metadata.versions.filter((v) => satisfies(v, range)).sort(compareVersions);
|
|
333
|
+
if (matching.length === 0) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Version '${versionRange}' not found for '${packageName}'. Available: ${metadata.versions.join(", ")}`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const version = matching[matching.length - 1];
|
|
339
|
+
return { version, metadata };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/lib/git.ts
|
|
343
|
+
import fs3 from "fs";
|
|
344
|
+
import path3 from "path";
|
|
345
|
+
import os2 from "os";
|
|
346
|
+
import { simpleGit } from "simple-git";
|
|
347
|
+
function repoCloneUrl(repoUrl) {
|
|
348
|
+
const token = getGitHubToken();
|
|
349
|
+
const match = repoUrl.match(/^github\.com\/(.+)$/);
|
|
350
|
+
if (!match) return `https://${repoUrl}.git`;
|
|
351
|
+
if (token) {
|
|
352
|
+
return `https://${token}@github.com/${match[1]}.git`;
|
|
353
|
+
}
|
|
354
|
+
return `https://github.com/${match[1]}.git`;
|
|
355
|
+
}
|
|
356
|
+
async function cloneAtTag(repoUrl, tag, targetDir) {
|
|
357
|
+
const cloneUrl = repoCloneUrl(repoUrl);
|
|
358
|
+
const git = simpleGit();
|
|
359
|
+
await git.clone(cloneUrl, targetDir, [
|
|
360
|
+
"--depth",
|
|
361
|
+
"1",
|
|
362
|
+
"--branch",
|
|
363
|
+
tag,
|
|
364
|
+
"--single-branch"
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
async function fetchFileAtTag(repoUrl, tag, filePath) {
|
|
368
|
+
const match = repoUrl.match(/^github\.com\/([^/]+)\/([^/]+)$/);
|
|
369
|
+
if (match) {
|
|
370
|
+
const token = getGitHubToken();
|
|
371
|
+
const rawUrl = `https://raw.githubusercontent.com/${match[1]}/${match[2]}/${tag}/${filePath}`;
|
|
372
|
+
const headers = { "User-Agent": "planmode-cli" };
|
|
373
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
374
|
+
const response = await fetch(rawUrl, { headers });
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
throw new Error(`Failed to fetch ${filePath} from ${repoUrl}@${tag}: ${response.status}`);
|
|
377
|
+
}
|
|
378
|
+
return response.text();
|
|
379
|
+
}
|
|
380
|
+
const tmpDir = fs3.mkdtempSync(path3.join(os2.tmpdir(), "planmode-"));
|
|
381
|
+
try {
|
|
382
|
+
await cloneAtTag(repoUrl, tag, tmpDir);
|
|
383
|
+
const fullPath = path3.join(tmpDir, filePath);
|
|
384
|
+
if (!fs3.existsSync(fullPath)) {
|
|
385
|
+
throw new Error(`File not found: ${filePath} in ${repoUrl}@${tag}`);
|
|
386
|
+
}
|
|
387
|
+
return fs3.readFileSync(fullPath, "utf-8");
|
|
388
|
+
} finally {
|
|
389
|
+
fs3.rmSync(tmpDir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function createTag(dir, tag) {
|
|
393
|
+
const git = simpleGit(dir);
|
|
394
|
+
await git.addTag(tag);
|
|
395
|
+
}
|
|
396
|
+
async function pushTag(dir, tag) {
|
|
397
|
+
const git = simpleGit(dir);
|
|
398
|
+
await git.push("origin", tag);
|
|
399
|
+
}
|
|
400
|
+
async function getRemoteUrl(dir) {
|
|
401
|
+
try {
|
|
402
|
+
const git = simpleGit(dir);
|
|
403
|
+
const remotes = await git.getRemotes(true);
|
|
404
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
405
|
+
return origin?.refs?.fetch ?? null;
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function getHeadSha(dir) {
|
|
411
|
+
const git = simpleGit(dir);
|
|
412
|
+
const log = await git.log({ n: 1 });
|
|
413
|
+
return log.latest?.hash ?? "";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/lib/lockfile.ts
|
|
417
|
+
import fs4 from "fs";
|
|
418
|
+
import path4 from "path";
|
|
419
|
+
import { parse as parse2, stringify as stringify2 } from "yaml";
|
|
420
|
+
var LOCKFILE_NAME = "planmode.lock";
|
|
421
|
+
function getLockfilePath(projectDir = process.cwd()) {
|
|
422
|
+
return path4.join(projectDir, LOCKFILE_NAME);
|
|
423
|
+
}
|
|
424
|
+
function readLockfile(projectDir = process.cwd()) {
|
|
425
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
426
|
+
try {
|
|
427
|
+
const raw = fs4.readFileSync(lockfilePath, "utf-8");
|
|
428
|
+
const data = parse2(raw);
|
|
429
|
+
return data ?? { lockfile_version: 1, packages: {} };
|
|
430
|
+
} catch {
|
|
431
|
+
return { lockfile_version: 1, packages: {} };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function writeLockfile(lockfile, projectDir = process.cwd()) {
|
|
435
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
436
|
+
fs4.writeFileSync(lockfilePath, stringify2(lockfile), "utf-8");
|
|
437
|
+
}
|
|
438
|
+
function addToLockfile(packageName, entry, projectDir = process.cwd()) {
|
|
439
|
+
const lockfile = readLockfile(projectDir);
|
|
440
|
+
lockfile.packages[packageName] = entry;
|
|
441
|
+
writeLockfile(lockfile, projectDir);
|
|
442
|
+
}
|
|
443
|
+
function removeFromLockfile(packageName, projectDir = process.cwd()) {
|
|
444
|
+
const lockfile = readLockfile(projectDir);
|
|
445
|
+
delete lockfile.packages[packageName];
|
|
446
|
+
writeLockfile(lockfile, projectDir);
|
|
447
|
+
}
|
|
448
|
+
function getLockedVersion(packageName, projectDir = process.cwd()) {
|
|
449
|
+
const lockfile = readLockfile(projectDir);
|
|
450
|
+
return lockfile.packages[packageName];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/lib/claude-md.ts
|
|
454
|
+
import fs5 from "fs";
|
|
455
|
+
import path5 from "path";
|
|
456
|
+
var CLAUDE_MD = "CLAUDE.md";
|
|
457
|
+
var PLANMODE_SECTION = "# Planmode";
|
|
458
|
+
function getClaudeMdPath(projectDir = process.cwd()) {
|
|
459
|
+
return path5.join(projectDir, CLAUDE_MD);
|
|
460
|
+
}
|
|
461
|
+
function addImport(planName, projectDir = process.cwd()) {
|
|
462
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
463
|
+
const importLine = `- @plans/${planName}.md`;
|
|
464
|
+
if (!fs5.existsSync(claudeMdPath)) {
|
|
465
|
+
const content2 = `${PLANMODE_SECTION}
|
|
466
|
+
${importLine}
|
|
467
|
+
`;
|
|
468
|
+
fs5.writeFileSync(claudeMdPath, content2, "utf-8");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const content = fs5.readFileSync(claudeMdPath, "utf-8");
|
|
472
|
+
if (content.includes(importLine)) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (content.includes(PLANMODE_SECTION)) {
|
|
476
|
+
const updated = content.replace(PLANMODE_SECTION, `${PLANMODE_SECTION}
|
|
477
|
+
${importLine}`);
|
|
478
|
+
fs5.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
479
|
+
} else {
|
|
480
|
+
const separator = content.endsWith("\n") ? "\n" : "\n\n";
|
|
481
|
+
const updated = content + separator + `${PLANMODE_SECTION}
|
|
482
|
+
${importLine}
|
|
483
|
+
`;
|
|
484
|
+
fs5.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function removeImport(planName, projectDir = process.cwd()) {
|
|
488
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
489
|
+
if (!fs5.existsSync(claudeMdPath)) return;
|
|
490
|
+
const content = fs5.readFileSync(claudeMdPath, "utf-8");
|
|
491
|
+
const importLine = `- @plans/${planName}.md`;
|
|
492
|
+
const updated = content.split("\n").filter((line) => line.trim() !== importLine).join("\n");
|
|
493
|
+
fs5.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
494
|
+
}
|
|
495
|
+
function listImports(projectDir = process.cwd()) {
|
|
496
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
497
|
+
if (!fs5.existsSync(claudeMdPath)) return [];
|
|
498
|
+
const content = fs5.readFileSync(claudeMdPath, "utf-8");
|
|
499
|
+
const importRegex = /^-\s*@plans\/(.+)\.md$/gm;
|
|
500
|
+
const imports = [];
|
|
501
|
+
let match;
|
|
502
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
503
|
+
imports.push(match[1]);
|
|
504
|
+
}
|
|
505
|
+
return imports;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/lib/manifest.ts
|
|
509
|
+
import fs6 from "fs";
|
|
510
|
+
import path6 from "path";
|
|
511
|
+
import { parse as parse3 } from "yaml";
|
|
512
|
+
var NAME_REGEX = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
|
|
513
|
+
var SEMVER_REGEX = /^\d+\.\d+\.\d+$/;
|
|
514
|
+
var VALID_TYPES = ["prompt", "rule", "plan"];
|
|
515
|
+
var VALID_VAR_TYPES = ["string", "number", "boolean", "enum", "resolved"];
|
|
516
|
+
var VALID_CATEGORIES = [
|
|
517
|
+
"frontend",
|
|
518
|
+
"backend",
|
|
519
|
+
"devops",
|
|
520
|
+
"database",
|
|
521
|
+
"testing",
|
|
522
|
+
"mobile",
|
|
523
|
+
"ai-ml",
|
|
524
|
+
"design",
|
|
525
|
+
"security",
|
|
526
|
+
"other"
|
|
527
|
+
];
|
|
528
|
+
function parseManifest(raw) {
|
|
529
|
+
const data = parse3(raw);
|
|
530
|
+
if (!data || typeof data !== "object") {
|
|
531
|
+
throw new Error("Invalid YAML: manifest must be an object");
|
|
532
|
+
}
|
|
533
|
+
return data;
|
|
534
|
+
}
|
|
535
|
+
function readManifest(dir) {
|
|
536
|
+
const manifestPath = path6.join(dir, "planmode.yaml");
|
|
537
|
+
if (!fs6.existsSync(manifestPath)) {
|
|
538
|
+
throw new Error(`No planmode.yaml found in ${dir}`);
|
|
539
|
+
}
|
|
540
|
+
const raw = fs6.readFileSync(manifestPath, "utf-8");
|
|
541
|
+
return parseManifest(raw);
|
|
542
|
+
}
|
|
543
|
+
function validateManifest(manifest, requirePublishFields = false) {
|
|
544
|
+
const errors = [];
|
|
545
|
+
if (!manifest.name) {
|
|
546
|
+
errors.push("Missing required field: name");
|
|
547
|
+
} else if (!NAME_REGEX.test(manifest.name)) {
|
|
548
|
+
errors.push(`Invalid name "${manifest.name}": must match ${NAME_REGEX}`);
|
|
549
|
+
} else if (manifest.name.length > 100) {
|
|
550
|
+
errors.push("Name must be 100 characters or fewer");
|
|
551
|
+
}
|
|
552
|
+
if (!manifest.version) {
|
|
553
|
+
errors.push("Missing required field: version");
|
|
554
|
+
} else if (!SEMVER_REGEX.test(manifest.version)) {
|
|
555
|
+
errors.push(`Invalid version "${manifest.version}": must be valid semver (X.Y.Z)`);
|
|
556
|
+
}
|
|
557
|
+
if (!manifest.type) {
|
|
558
|
+
errors.push("Missing required field: type");
|
|
559
|
+
} else if (!VALID_TYPES.includes(manifest.type)) {
|
|
560
|
+
errors.push(`Invalid type "${manifest.type}": must be one of ${VALID_TYPES.join(", ")}`);
|
|
561
|
+
}
|
|
562
|
+
if (requirePublishFields) {
|
|
563
|
+
if (!manifest.description) errors.push("Missing required field: description");
|
|
564
|
+
if (manifest.description && manifest.description.length > 200) {
|
|
565
|
+
errors.push("Description must be 200 characters or fewer");
|
|
566
|
+
}
|
|
567
|
+
if (!manifest.author) errors.push("Missing required field: author");
|
|
568
|
+
if (!manifest.license) errors.push("Missing required field: license");
|
|
569
|
+
}
|
|
570
|
+
if (manifest.tags) {
|
|
571
|
+
if (manifest.tags.length > 10) {
|
|
572
|
+
errors.push("Maximum 10 tags allowed");
|
|
573
|
+
}
|
|
574
|
+
for (const tag of manifest.tags) {
|
|
575
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(tag)) {
|
|
576
|
+
errors.push(`Invalid tag "${tag}": must be lowercase alphanumeric with hyphens`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (manifest.category && !VALID_CATEGORIES.includes(manifest.category)) {
|
|
581
|
+
errors.push(`Invalid category "${manifest.category}": must be one of ${VALID_CATEGORIES.join(", ")}`);
|
|
582
|
+
}
|
|
583
|
+
if (manifest.dependencies && manifest.type === "prompt") {
|
|
584
|
+
errors.push("Dependencies are not allowed for prompt type packages");
|
|
585
|
+
}
|
|
586
|
+
if (manifest.variables) {
|
|
587
|
+
for (const [varName, varDef] of Object.entries(manifest.variables)) {
|
|
588
|
+
if (!varDef.type || !VALID_VAR_TYPES.includes(varDef.type)) {
|
|
589
|
+
errors.push(`Variable "${varName}" has invalid type: must be one of ${VALID_VAR_TYPES.join(", ")}`);
|
|
590
|
+
}
|
|
591
|
+
if (varDef.type === "enum" && (!varDef.options || varDef.options.length === 0)) {
|
|
592
|
+
errors.push(`Variable "${varName}" of type enum must have options`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (manifest.content && manifest.content_file) {
|
|
597
|
+
errors.push("Cannot specify both content and content_file");
|
|
598
|
+
}
|
|
599
|
+
return errors;
|
|
600
|
+
}
|
|
601
|
+
function readPackageContent(dir, manifest) {
|
|
602
|
+
if (manifest.content) {
|
|
603
|
+
return manifest.content;
|
|
604
|
+
}
|
|
605
|
+
if (manifest.content_file) {
|
|
606
|
+
const contentPath = path6.join(dir, manifest.content_file);
|
|
607
|
+
if (!fs6.existsSync(contentPath)) {
|
|
608
|
+
throw new Error(`Content file not found: ${manifest.content_file}`);
|
|
609
|
+
}
|
|
610
|
+
return fs6.readFileSync(contentPath, "utf-8");
|
|
611
|
+
}
|
|
612
|
+
throw new Error("Package must specify either content or content_file");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/lib/template.ts
|
|
616
|
+
import Handlebars from "handlebars";
|
|
617
|
+
Handlebars.registerHelper("eq", (a, b) => a === b);
|
|
618
|
+
function renderTemplate(content, variables) {
|
|
619
|
+
const template = Handlebars.compile(content);
|
|
620
|
+
return template(variables);
|
|
621
|
+
}
|
|
622
|
+
function collectVariableValues(variableDefs, provided) {
|
|
623
|
+
const values = {};
|
|
624
|
+
for (const [name, def] of Object.entries(variableDefs)) {
|
|
625
|
+
const rawValue = provided[name];
|
|
626
|
+
if (rawValue !== void 0) {
|
|
627
|
+
values[name] = coerceValue(rawValue, def);
|
|
628
|
+
} else if (def.default !== void 0) {
|
|
629
|
+
values[name] = def.default;
|
|
630
|
+
} else if (def.required) {
|
|
631
|
+
throw new Error(`Missing required variable: ${name} \u2014 ${def.description}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return values;
|
|
635
|
+
}
|
|
636
|
+
function coerceValue(raw, def) {
|
|
637
|
+
switch (def.type) {
|
|
638
|
+
case "number":
|
|
639
|
+
return Number(raw);
|
|
640
|
+
case "boolean":
|
|
641
|
+
return raw === "true" || raw === "1" || raw === "yes";
|
|
642
|
+
case "enum":
|
|
643
|
+
if (def.options && !def.options.includes(raw)) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Invalid value "${raw}" for enum variable. Options: ${def.options.join(", ")}`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return raw;
|
|
649
|
+
default:
|
|
650
|
+
return raw;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/lib/analytics.ts
|
|
655
|
+
var API_BASE = "https://api.planmode.org";
|
|
656
|
+
function trackDownload(packageName) {
|
|
657
|
+
fetch(`${API_BASE}/downloads/${encodeURIComponent(packageName)}`, {
|
|
658
|
+
method: "POST"
|
|
659
|
+
}).catch(() => {
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/lib/installer.ts
|
|
664
|
+
function getInstallDir(type) {
|
|
665
|
+
switch (type) {
|
|
666
|
+
case "plan":
|
|
667
|
+
return "plans";
|
|
668
|
+
case "rule":
|
|
669
|
+
return path7.join(".claude", "rules");
|
|
670
|
+
case "prompt":
|
|
671
|
+
return "prompts";
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function getInstallPath(name, type) {
|
|
675
|
+
return path7.join(getInstallDir(type), `${name}.md`);
|
|
676
|
+
}
|
|
677
|
+
function contentHash(content) {
|
|
678
|
+
return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
|
|
679
|
+
}
|
|
680
|
+
async function installPackage(packageName, options = {}) {
|
|
681
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
682
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
683
|
+
if (locked && !options.version) {
|
|
684
|
+
logger.dim(`${packageName}@${locked.version} already installed`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
logger.info(`Resolving ${packageName}...`);
|
|
688
|
+
const { version, metadata } = await resolveVersion(packageName, options.version);
|
|
689
|
+
const versionMeta = await fetchVersionMetadata(packageName, version);
|
|
690
|
+
logger.info(`Fetching ${packageName}@${version}...`);
|
|
691
|
+
const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
|
|
692
|
+
const manifestRaw = await fetchFileAtTag(
|
|
693
|
+
versionMeta.source.repository,
|
|
694
|
+
versionMeta.source.tag,
|
|
695
|
+
`${basePath}planmode.yaml`
|
|
696
|
+
);
|
|
697
|
+
const manifest = parseManifest(manifestRaw);
|
|
698
|
+
let content;
|
|
699
|
+
if (manifest.content) {
|
|
700
|
+
content = manifest.content;
|
|
701
|
+
} else if (manifest.content_file) {
|
|
702
|
+
content = await fetchFileAtTag(
|
|
703
|
+
versionMeta.source.repository,
|
|
704
|
+
versionMeta.source.tag,
|
|
705
|
+
`${basePath}${manifest.content_file}`
|
|
706
|
+
);
|
|
707
|
+
} else {
|
|
708
|
+
throw new Error("Package has no content or content_file");
|
|
709
|
+
}
|
|
710
|
+
if (manifest.variables && Object.keys(manifest.variables).length > 0) {
|
|
711
|
+
const provided = options.variables ?? {};
|
|
712
|
+
if (options.noInput) {
|
|
713
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
714
|
+
content = renderTemplate(content, values);
|
|
715
|
+
} else {
|
|
716
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
717
|
+
content = renderTemplate(content, values);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const type = options.forceRule ? "rule" : manifest.type;
|
|
721
|
+
const installPath = getInstallPath(packageName, type);
|
|
722
|
+
const fullPath = path7.join(projectDir, installPath);
|
|
723
|
+
if (fs7.existsSync(fullPath)) {
|
|
724
|
+
const existingContent = fs7.readFileSync(fullPath, "utf-8");
|
|
725
|
+
const existingHash = contentHash(existingContent);
|
|
726
|
+
const newHash = contentHash(content);
|
|
727
|
+
if (existingHash === newHash) {
|
|
728
|
+
logger.dim(`${packageName} already installed (identical content)`);
|
|
729
|
+
} else {
|
|
730
|
+
logger.warn(`Overwriting ${installPath} with new content`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const computedHash = contentHash(content);
|
|
734
|
+
if (versionMeta.content_hash && computedHash !== versionMeta.content_hash) {
|
|
735
|
+
logger.warn(
|
|
736
|
+
`Content hash mismatch for ${packageName}@${version}. Expected ${versionMeta.content_hash.slice(0, 20)}..., got ${computedHash.slice(0, 20)}... The package content may have been modified after review.`
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
fs7.mkdirSync(path7.dirname(fullPath), { recursive: true });
|
|
740
|
+
fs7.writeFileSync(fullPath, content, "utf-8");
|
|
741
|
+
logger.success(`Installed ${packageName}@${version} \u2192 ${installPath}`);
|
|
742
|
+
trackDownload(packageName);
|
|
743
|
+
if (type === "plan") {
|
|
744
|
+
addImport(packageName, projectDir);
|
|
745
|
+
logger.dim(`Added @plans/${packageName}.md to CLAUDE.md`);
|
|
746
|
+
logger.dim(`Claude Code will automatically see this plan in your next conversation.`);
|
|
747
|
+
}
|
|
748
|
+
if (type === "rule") {
|
|
749
|
+
logger.dim(`Rule is active \u2014 Claude Code auto-loads all files in .claude/rules/.`);
|
|
750
|
+
}
|
|
751
|
+
if (type === "prompt") {
|
|
752
|
+
logger.dim(`Run it with: planmode run ${packageName}`);
|
|
753
|
+
}
|
|
754
|
+
const hash = contentHash(content);
|
|
755
|
+
const entry = {
|
|
756
|
+
version,
|
|
757
|
+
type,
|
|
758
|
+
source: versionMeta.source.repository,
|
|
759
|
+
tag: versionMeta.source.tag,
|
|
760
|
+
sha: versionMeta.source.sha,
|
|
761
|
+
content_hash: hash,
|
|
762
|
+
installed_to: installPath
|
|
763
|
+
};
|
|
764
|
+
addToLockfile(packageName, entry, projectDir);
|
|
765
|
+
if (manifest.dependencies) {
|
|
766
|
+
const deps = [
|
|
767
|
+
...(manifest.dependencies.rules ?? []).map((d) => ({ dep: d, type: "rule" })),
|
|
768
|
+
...(manifest.dependencies.plans ?? []).map((d) => ({ dep: d, type: "plan" }))
|
|
769
|
+
];
|
|
770
|
+
for (const { dep } of deps) {
|
|
771
|
+
const { name, range } = parseDepString(dep);
|
|
772
|
+
await installPackage(name, {
|
|
773
|
+
version: range === "*" ? void 0 : range,
|
|
774
|
+
projectDir,
|
|
775
|
+
noInput: options.noInput
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
async function uninstallPackage(packageName, projectDir = process.cwd()) {
|
|
781
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
782
|
+
if (!locked) {
|
|
783
|
+
throw new Error(`Package '${packageName}' is not installed.`);
|
|
784
|
+
}
|
|
785
|
+
const fullPath = path7.join(projectDir, locked.installed_to);
|
|
786
|
+
if (fs7.existsSync(fullPath)) {
|
|
787
|
+
fs7.unlinkSync(fullPath);
|
|
788
|
+
logger.success(`Removed ${locked.installed_to}`);
|
|
789
|
+
}
|
|
790
|
+
if (locked.type === "plan") {
|
|
791
|
+
removeImport(packageName, projectDir);
|
|
792
|
+
logger.dim(`Removed @import from CLAUDE.md`);
|
|
793
|
+
}
|
|
794
|
+
removeFromLockfile(packageName, projectDir);
|
|
795
|
+
logger.success(`Uninstalled ${packageName}`);
|
|
796
|
+
}
|
|
797
|
+
async function updatePackage(packageName, projectDir = process.cwd()) {
|
|
798
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
799
|
+
if (!locked) {
|
|
800
|
+
throw new Error(`Package '${packageName}' is not installed.`);
|
|
801
|
+
}
|
|
802
|
+
const { version, metadata } = await resolveVersion(packageName);
|
|
803
|
+
if (version === locked.version) {
|
|
804
|
+
logger.dim(`${packageName}@${version} is already up to date`);
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
logger.info(`Updating ${packageName}: ${locked.version} \u2192 ${version}`);
|
|
808
|
+
await uninstallPackage(packageName, projectDir);
|
|
809
|
+
await installPackage(packageName, { version, projectDir });
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/lib/init.ts
|
|
814
|
+
import fs8 from "fs";
|
|
815
|
+
import path8 from "path";
|
|
816
|
+
import { stringify as stringify3 } from "yaml";
|
|
817
|
+
|
|
818
|
+
// src/lib/templates.ts
|
|
819
|
+
function getPlanTemplate(name) {
|
|
820
|
+
return `# ${name}
|
|
821
|
+
|
|
822
|
+
## Prerequisites
|
|
823
|
+
|
|
824
|
+
- List any tools, dependencies, or setup required before starting
|
|
825
|
+
|
|
826
|
+
## Steps
|
|
827
|
+
|
|
828
|
+
1. **Step one** \u2014 Description of what to do first
|
|
829
|
+
2. **Step two** \u2014 Description of what to do next
|
|
830
|
+
3. **Step three** \u2014 Description of the final step
|
|
831
|
+
|
|
832
|
+
## Verification
|
|
833
|
+
|
|
834
|
+
- [ ] Verify step one completed successfully
|
|
835
|
+
- [ ] Verify step two completed successfully
|
|
836
|
+
- [ ] Verify the final result works as expected
|
|
837
|
+
`;
|
|
838
|
+
}
|
|
839
|
+
function getRuleTemplate(name) {
|
|
840
|
+
return `# ${name}
|
|
841
|
+
|
|
842
|
+
## Code Style
|
|
843
|
+
|
|
844
|
+
- Follow consistent naming conventions
|
|
845
|
+
- Keep functions small and focused
|
|
846
|
+
|
|
847
|
+
## Best Practices
|
|
848
|
+
|
|
849
|
+
- Prefer composition over inheritance
|
|
850
|
+
- Write self-documenting code
|
|
851
|
+
|
|
852
|
+
## Avoid
|
|
853
|
+
|
|
854
|
+
- Do not use deprecated APIs
|
|
855
|
+
- Do not ignore error handling
|
|
856
|
+
`;
|
|
857
|
+
}
|
|
858
|
+
function getPromptTemplate(name) {
|
|
859
|
+
return `# ${name}
|
|
860
|
+
|
|
861
|
+
{{description}}
|
|
862
|
+
|
|
863
|
+
## Context
|
|
864
|
+
|
|
865
|
+
Provide any relevant context here.
|
|
866
|
+
|
|
867
|
+
## Requirements
|
|
868
|
+
|
|
869
|
+
- Requirement one
|
|
870
|
+
- Requirement two
|
|
871
|
+
|
|
872
|
+
## Output Format
|
|
873
|
+
|
|
874
|
+
Describe the expected output format.
|
|
875
|
+
`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/lib/init.ts
|
|
879
|
+
function createPackage(options) {
|
|
880
|
+
const {
|
|
881
|
+
name,
|
|
882
|
+
type,
|
|
883
|
+
description,
|
|
884
|
+
author,
|
|
885
|
+
license = "MIT",
|
|
886
|
+
tags = [],
|
|
887
|
+
category = "other",
|
|
888
|
+
projectDir = process.cwd()
|
|
889
|
+
} = options;
|
|
890
|
+
const manifest = {
|
|
891
|
+
name,
|
|
892
|
+
version: "1.0.0",
|
|
893
|
+
type,
|
|
894
|
+
description,
|
|
895
|
+
author,
|
|
896
|
+
license
|
|
897
|
+
};
|
|
898
|
+
if (tags.length > 0) manifest["tags"] = tags;
|
|
899
|
+
manifest["category"] = category;
|
|
900
|
+
const contentFile = `${type}.md`;
|
|
901
|
+
manifest["content_file"] = contentFile;
|
|
902
|
+
const yamlContent = stringify3(manifest);
|
|
903
|
+
const manifestPath = path8.join(projectDir, "planmode.yaml");
|
|
904
|
+
fs8.writeFileSync(manifestPath, yamlContent, "utf-8");
|
|
905
|
+
const stubs = {
|
|
906
|
+
plan: getPlanTemplate(name),
|
|
907
|
+
rule: getRuleTemplate(name),
|
|
908
|
+
prompt: getPromptTemplate(name)
|
|
909
|
+
};
|
|
910
|
+
const contentPath = path8.join(projectDir, contentFile);
|
|
911
|
+
fs8.writeFileSync(contentPath, stubs[type] ?? stubs["plan"], "utf-8");
|
|
912
|
+
return {
|
|
913
|
+
files: ["planmode.yaml", contentFile],
|
|
914
|
+
manifestPath,
|
|
915
|
+
contentPath
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/lib/publisher.ts
|
|
920
|
+
async function publishPackage(options = {}) {
|
|
921
|
+
const cwd = options.projectDir ?? process.cwd();
|
|
922
|
+
const token = options.token ?? getGitHubToken();
|
|
923
|
+
if (!token) {
|
|
924
|
+
throw new Error("Not authenticated. Run `planmode login` first.");
|
|
925
|
+
}
|
|
926
|
+
logger.info("Reading planmode.yaml...");
|
|
927
|
+
const manifest = readManifest(cwd);
|
|
928
|
+
const errors = validateManifest(manifest, true);
|
|
929
|
+
if (errors.length > 0) {
|
|
930
|
+
throw new Error(`Invalid manifest:
|
|
931
|
+
${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
932
|
+
}
|
|
933
|
+
const remoteUrl = await getRemoteUrl(cwd);
|
|
934
|
+
if (!remoteUrl) {
|
|
935
|
+
throw new Error("No git remote found. Push your code to GitHub first.");
|
|
936
|
+
}
|
|
937
|
+
const sha = await getHeadSha(cwd);
|
|
938
|
+
const tag = `v${manifest.version}`;
|
|
939
|
+
logger.info(`Creating tag ${tag}...`);
|
|
940
|
+
try {
|
|
941
|
+
await createTag(cwd, tag);
|
|
942
|
+
} catch {
|
|
943
|
+
logger.dim(`Tag ${tag} already exists, using existing`);
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
await pushTag(cwd, tag);
|
|
947
|
+
logger.success(`Pushed tag ${tag}`);
|
|
948
|
+
} catch {
|
|
949
|
+
logger.dim(`Tag ${tag} already pushed`);
|
|
950
|
+
}
|
|
951
|
+
logger.info("Submitting to registry...");
|
|
952
|
+
const headers = {
|
|
953
|
+
Authorization: `Bearer ${token}`,
|
|
954
|
+
Accept: "application/vnd.github.v3+json",
|
|
955
|
+
"User-Agent": "planmode-cli",
|
|
956
|
+
"Content-Type": "application/json"
|
|
957
|
+
};
|
|
958
|
+
await fetch("https://api.github.com/repos/kaihannonen/planmode.org/forks", {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers
|
|
961
|
+
});
|
|
962
|
+
const userRes = await fetch("https://api.github.com/user", { headers });
|
|
963
|
+
if (!userRes.ok) {
|
|
964
|
+
throw new Error("Failed to authenticate with GitHub. Check your token.");
|
|
965
|
+
}
|
|
966
|
+
const user = await userRes.json();
|
|
967
|
+
const repoPath = remoteUrl.replace(/^https?:\/\//, "").replace(/\.git$/, "");
|
|
968
|
+
const metadataContent = JSON.stringify(
|
|
969
|
+
{
|
|
970
|
+
name: manifest.name,
|
|
971
|
+
description: manifest.description,
|
|
972
|
+
author: manifest.author,
|
|
973
|
+
license: manifest.license,
|
|
974
|
+
repository: repoPath,
|
|
975
|
+
category: manifest.category ?? "other",
|
|
976
|
+
tags: manifest.tags ?? [],
|
|
977
|
+
type: manifest.type,
|
|
978
|
+
models: manifest.models ?? [],
|
|
979
|
+
latest_version: manifest.version,
|
|
980
|
+
versions: [manifest.version],
|
|
981
|
+
downloads: 0,
|
|
982
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
983
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
984
|
+
dependencies: manifest.dependencies,
|
|
985
|
+
variables: manifest.variables
|
|
986
|
+
},
|
|
987
|
+
null,
|
|
988
|
+
2
|
|
989
|
+
);
|
|
990
|
+
const versionContent = JSON.stringify(
|
|
991
|
+
{
|
|
992
|
+
version: manifest.version,
|
|
993
|
+
published_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
994
|
+
source: {
|
|
995
|
+
repository: repoPath,
|
|
996
|
+
tag,
|
|
997
|
+
sha
|
|
998
|
+
},
|
|
999
|
+
files: ["planmode.yaml", manifest.content_file ?? "inline"],
|
|
1000
|
+
content_hash: `sha256:${sha.slice(0, 16)}`
|
|
1001
|
+
},
|
|
1002
|
+
null,
|
|
1003
|
+
2
|
|
1004
|
+
);
|
|
1005
|
+
const branchName = `add-${manifest.name}-${manifest.version}`;
|
|
1006
|
+
const refRes = await fetch(
|
|
1007
|
+
`https://api.github.com/repos/${user.login}/planmode.org/git/ref/heads/main`,
|
|
1008
|
+
{ headers }
|
|
1009
|
+
);
|
|
1010
|
+
if (!refRes.ok) {
|
|
1011
|
+
throw new Error("Failed to access registry fork. Make sure the fork exists.");
|
|
1012
|
+
}
|
|
1013
|
+
const refData = await refRes.json();
|
|
1014
|
+
const baseSha = refData.object.sha;
|
|
1015
|
+
await fetch(`https://api.github.com/repos/${user.login}/planmode.org/git/refs`, {
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
headers,
|
|
1018
|
+
body: JSON.stringify({
|
|
1019
|
+
ref: `refs/heads/${branchName}`,
|
|
1020
|
+
sha: baseSha
|
|
1021
|
+
})
|
|
1022
|
+
});
|
|
1023
|
+
await fetch(
|
|
1024
|
+
`https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/metadata.json`,
|
|
1025
|
+
{
|
|
1026
|
+
method: "PUT",
|
|
1027
|
+
headers,
|
|
1028
|
+
body: JSON.stringify({
|
|
1029
|
+
message: `Add ${manifest.name}@${manifest.version}`,
|
|
1030
|
+
content: Buffer.from(metadataContent).toString("base64"),
|
|
1031
|
+
branch: branchName
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
);
|
|
1035
|
+
await fetch(
|
|
1036
|
+
`https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/versions/${manifest.version}.json`,
|
|
1037
|
+
{
|
|
1038
|
+
method: "PUT",
|
|
1039
|
+
headers,
|
|
1040
|
+
body: JSON.stringify({
|
|
1041
|
+
message: `Add ${manifest.name}@${manifest.version} version metadata`,
|
|
1042
|
+
content: Buffer.from(versionContent).toString("base64"),
|
|
1043
|
+
branch: branchName
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
);
|
|
1047
|
+
const prRes = await fetch("https://api.github.com/repos/kaihannonen/planmode.org/pulls", {
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
headers,
|
|
1050
|
+
body: JSON.stringify({
|
|
1051
|
+
title: `Add ${manifest.name}@${manifest.version}`,
|
|
1052
|
+
head: `${user.login}:${branchName}`,
|
|
1053
|
+
base: "main",
|
|
1054
|
+
body: `## New package: ${manifest.name}
|
|
1055
|
+
|
|
1056
|
+
- **Type:** ${manifest.type}
|
|
1057
|
+
- **Version:** ${manifest.version}
|
|
1058
|
+
- **Description:** ${manifest.description}
|
|
1059
|
+
- **Author:** ${manifest.author}
|
|
1060
|
+
|
|
1061
|
+
Submitted via \`planmode publish\`.`
|
|
1062
|
+
})
|
|
1063
|
+
});
|
|
1064
|
+
if (!prRes.ok) {
|
|
1065
|
+
const err = await prRes.text();
|
|
1066
|
+
throw new Error(`Failed to create PR: ${err}`);
|
|
1067
|
+
}
|
|
1068
|
+
const pr = await prRes.json();
|
|
1069
|
+
logger.success(`Published ${manifest.name}@${manifest.version}`);
|
|
1070
|
+
logger.info(`PR: ${pr.html_url}`);
|
|
1071
|
+
return {
|
|
1072
|
+
prUrl: pr.html_url,
|
|
1073
|
+
packageName: manifest.name,
|
|
1074
|
+
version: manifest.version
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/lib/doctor.ts
|
|
1079
|
+
import fs9 from "fs";
|
|
1080
|
+
import path9 from "path";
|
|
1081
|
+
import crypto2 from "crypto";
|
|
1082
|
+
function computeHash(content) {
|
|
1083
|
+
return `sha256:${crypto2.createHash("sha256").update(content).digest("hex")}`;
|
|
1084
|
+
}
|
|
1085
|
+
function runDoctor(projectDir = process.cwd()) {
|
|
1086
|
+
const issues = [];
|
|
1087
|
+
const lockfile = readLockfile(projectDir);
|
|
1088
|
+
const entries = Object.entries(lockfile.packages);
|
|
1089
|
+
for (const [name, entry] of entries) {
|
|
1090
|
+
const fullPath = path9.join(projectDir, entry.installed_to);
|
|
1091
|
+
if (!fs9.existsSync(fullPath)) {
|
|
1092
|
+
issues.push({
|
|
1093
|
+
severity: "error",
|
|
1094
|
+
message: `Missing file for "${name}": ${entry.installed_to}`,
|
|
1095
|
+
fix: `Run \`planmode install ${name}\` to reinstall`
|
|
1096
|
+
});
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
const content = fs9.readFileSync(fullPath, "utf-8");
|
|
1100
|
+
const actualHash = computeHash(content);
|
|
1101
|
+
if (actualHash !== entry.content_hash) {
|
|
1102
|
+
issues.push({
|
|
1103
|
+
severity: "warning",
|
|
1104
|
+
message: `Content hash mismatch for "${name}" at ${entry.installed_to}`,
|
|
1105
|
+
fix: "File was modified locally. Run `planmode update " + name + "` to restore, or ignore if intentional"
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const claudeMdPath = path9.join(projectDir, "CLAUDE.md");
|
|
1110
|
+
const imports = listImports(projectDir);
|
|
1111
|
+
const installedPlans = entries.filter(([, entry]) => entry.type === "plan").map(([name]) => name);
|
|
1112
|
+
for (const planName of installedPlans) {
|
|
1113
|
+
if (!imports.includes(planName)) {
|
|
1114
|
+
issues.push({
|
|
1115
|
+
severity: "error",
|
|
1116
|
+
message: `Plan "${planName}" is installed but missing from CLAUDE.md imports`,
|
|
1117
|
+
fix: `Add \`- @plans/${planName}.md\` to the # Planmode section of CLAUDE.md`
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
for (const importName of imports) {
|
|
1122
|
+
if (!installedPlans.includes(importName)) {
|
|
1123
|
+
const filePath = path9.join(projectDir, "plans", `${importName}.md`);
|
|
1124
|
+
if (!fs9.existsSync(filePath)) {
|
|
1125
|
+
issues.push({
|
|
1126
|
+
severity: "error",
|
|
1127
|
+
message: `CLAUDE.md imports "${importName}" but the file doesn't exist at plans/${importName}.md`,
|
|
1128
|
+
fix: `Run \`planmode install ${importName}\` or remove the import from CLAUDE.md`
|
|
1129
|
+
});
|
|
1130
|
+
} else {
|
|
1131
|
+
issues.push({
|
|
1132
|
+
severity: "warning",
|
|
1133
|
+
message: `CLAUDE.md imports "${importName}" but it's not tracked in planmode.lock`,
|
|
1134
|
+
fix: "This plan was added manually. No action needed unless you want lockfile tracking."
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (installedPlans.length > 0 && !fs9.existsSync(claudeMdPath)) {
|
|
1140
|
+
issues.push({
|
|
1141
|
+
severity: "error",
|
|
1142
|
+
message: "CLAUDE.md is missing but plans are installed",
|
|
1143
|
+
fix: "Run `planmode install <any-plan>` to recreate it, or create it manually with a # Planmode section"
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
const plansDir = path9.join(projectDir, "plans");
|
|
1147
|
+
if (fs9.existsSync(plansDir)) {
|
|
1148
|
+
const planFiles = fs9.readdirSync(plansDir).filter((f) => f.endsWith(".md"));
|
|
1149
|
+
for (const file of planFiles) {
|
|
1150
|
+
const name = file.replace(/\.md$/, "");
|
|
1151
|
+
if (!lockfile.packages[name]) {
|
|
1152
|
+
issues.push({
|
|
1153
|
+
severity: "warning",
|
|
1154
|
+
message: `Untracked plan file: plans/${file}`,
|
|
1155
|
+
fix: "This file isn't managed by planmode. Ignore if intentional."
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return {
|
|
1161
|
+
issues,
|
|
1162
|
+
packagesChecked: entries.length,
|
|
1163
|
+
healthy: issues.filter((i) => i.severity === "error").length === 0
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/lib/tester.ts
|
|
1168
|
+
async function testPackage(projectDir = process.cwd()) {
|
|
1169
|
+
const issues = [];
|
|
1170
|
+
const checks = [];
|
|
1171
|
+
let manifest;
|
|
1172
|
+
try {
|
|
1173
|
+
manifest = readManifest(projectDir);
|
|
1174
|
+
checks.push({ name: "Manifest parses", passed: true });
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
issues.push({
|
|
1177
|
+
severity: "error",
|
|
1178
|
+
check: "Manifest parses",
|
|
1179
|
+
message: err.message
|
|
1180
|
+
});
|
|
1181
|
+
checks.push({ name: "Manifest parses", passed: false });
|
|
1182
|
+
return { issues, passed: false, checks };
|
|
1183
|
+
}
|
|
1184
|
+
const errors = validateManifest(manifest, true);
|
|
1185
|
+
if (errors.length === 0) {
|
|
1186
|
+
checks.push({ name: "Manifest valid for publishing", passed: true });
|
|
1187
|
+
} else {
|
|
1188
|
+
for (const err of errors) {
|
|
1189
|
+
issues.push({
|
|
1190
|
+
severity: "error",
|
|
1191
|
+
check: "Manifest valid for publishing",
|
|
1192
|
+
message: err
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
checks.push({ name: "Manifest valid for publishing", passed: false });
|
|
1196
|
+
}
|
|
1197
|
+
let content;
|
|
1198
|
+
try {
|
|
1199
|
+
content = readPackageContent(projectDir, manifest);
|
|
1200
|
+
if (content.trim().length === 0) {
|
|
1201
|
+
issues.push({
|
|
1202
|
+
severity: "warning",
|
|
1203
|
+
check: "Content is non-empty",
|
|
1204
|
+
message: "Content file is empty"
|
|
1205
|
+
});
|
|
1206
|
+
checks.push({ name: "Content is non-empty", passed: false });
|
|
1207
|
+
} else {
|
|
1208
|
+
checks.push({ name: "Content is non-empty", passed: true });
|
|
1209
|
+
}
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
issues.push({
|
|
1212
|
+
severity: "error",
|
|
1213
|
+
check: "Content readable",
|
|
1214
|
+
message: err.message
|
|
1215
|
+
});
|
|
1216
|
+
checks.push({ name: "Content readable", passed: false });
|
|
1217
|
+
}
|
|
1218
|
+
if (content && manifest.variables && Object.keys(manifest.variables).length > 0) {
|
|
1219
|
+
try {
|
|
1220
|
+
const missingDefaults = [];
|
|
1221
|
+
for (const [name, def] of Object.entries(manifest.variables)) {
|
|
1222
|
+
if (def.required && def.default === void 0) {
|
|
1223
|
+
missingDefaults.push(name);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (missingDefaults.length > 0) {
|
|
1227
|
+
issues.push({
|
|
1228
|
+
severity: "warning",
|
|
1229
|
+
check: "Required variables have defaults",
|
|
1230
|
+
message: `Required variables without defaults: ${missingDefaults.join(", ")}. Users must provide these at install time.`
|
|
1231
|
+
});
|
|
1232
|
+
checks.push({ name: "Required variables have defaults", passed: false });
|
|
1233
|
+
} else {
|
|
1234
|
+
checks.push({ name: "Required variables have defaults", passed: true });
|
|
1235
|
+
}
|
|
1236
|
+
const values = collectVariableValues(manifest.variables, {});
|
|
1237
|
+
renderTemplate(content, values);
|
|
1238
|
+
checks.push({ name: "Template renders with defaults", passed: true });
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
issues.push({
|
|
1241
|
+
severity: "error",
|
|
1242
|
+
check: "Template renders with defaults",
|
|
1243
|
+
message: err.message
|
|
1244
|
+
});
|
|
1245
|
+
checks.push({ name: "Template renders with defaults", passed: false });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (manifest.dependencies) {
|
|
1249
|
+
const allDeps = [
|
|
1250
|
+
...manifest.dependencies.rules ?? [],
|
|
1251
|
+
...manifest.dependencies.plans ?? []
|
|
1252
|
+
];
|
|
1253
|
+
for (const dep of allDeps) {
|
|
1254
|
+
const depName = dep.includes("@") ? dep.split("@")[0] : dep;
|
|
1255
|
+
try {
|
|
1256
|
+
const results = await searchPackages(depName);
|
|
1257
|
+
const found = results.some((r) => r.name === depName);
|
|
1258
|
+
if (found) {
|
|
1259
|
+
checks.push({ name: `Dependency "${depName}" exists`, passed: true });
|
|
1260
|
+
} else {
|
|
1261
|
+
issues.push({
|
|
1262
|
+
severity: "warning",
|
|
1263
|
+
check: `Dependency "${depName}" exists`,
|
|
1264
|
+
message: `Dependency "${depName}" not found in registry. It may not be published yet.`
|
|
1265
|
+
});
|
|
1266
|
+
checks.push({ name: `Dependency "${depName}" exists`, passed: false });
|
|
1267
|
+
}
|
|
1268
|
+
} catch {
|
|
1269
|
+
issues.push({
|
|
1270
|
+
severity: "warning",
|
|
1271
|
+
check: `Dependency "${depName}" exists`,
|
|
1272
|
+
message: `Could not check registry for "${depName}" (network error)`
|
|
1273
|
+
});
|
|
1274
|
+
checks.push({ name: `Dependency "${depName}" exists`, passed: false });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (content) {
|
|
1279
|
+
const sizeKb = Buffer.byteLength(content, "utf-8") / 1024;
|
|
1280
|
+
if (sizeKb > 100) {
|
|
1281
|
+
issues.push({
|
|
1282
|
+
severity: "warning",
|
|
1283
|
+
check: "Content size reasonable",
|
|
1284
|
+
message: `Content is ${sizeKb.toFixed(1)}KB. Large packages may hit token limits in AI models.`
|
|
1285
|
+
});
|
|
1286
|
+
checks.push({ name: "Content size reasonable", passed: false });
|
|
1287
|
+
} else {
|
|
1288
|
+
checks.push({ name: "Content size reasonable", passed: true });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const hasErrors = issues.some((i) => i.severity === "error");
|
|
1292
|
+
return { issues, passed: !hasErrors, checks };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// src/lib/recorder.ts
|
|
1296
|
+
import fs10 from "fs";
|
|
1297
|
+
import path10 from "path";
|
|
1298
|
+
import { simpleGit as simpleGit2 } from "simple-git";
|
|
1299
|
+
import { stringify as stringify4 } from "yaml";
|
|
1300
|
+
var RECORDING_FILE = ".planmode-recording";
|
|
1301
|
+
function startRecording(projectDir = process.cwd()) {
|
|
1302
|
+
const git = simpleGit2(projectDir);
|
|
1303
|
+
const recordingPath = path10.join(projectDir, RECORDING_FILE);
|
|
1304
|
+
if (fs10.existsSync(recordingPath)) {
|
|
1305
|
+
const existing = fs10.readFileSync(recordingPath, "utf-8").trim();
|
|
1306
|
+
throw new Error(
|
|
1307
|
+
`Recording already in progress (started at ${existing}). Run \`planmode record stop\` first.`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
return recordingPath;
|
|
1311
|
+
}
|
|
1312
|
+
async function startRecordingAsync(projectDir = process.cwd()) {
|
|
1313
|
+
const recordingPath = startRecording(projectDir);
|
|
1314
|
+
const git = simpleGit2(projectDir);
|
|
1315
|
+
const log = await git.log({ n: 1 });
|
|
1316
|
+
const sha = log.latest?.hash;
|
|
1317
|
+
if (!sha) {
|
|
1318
|
+
throw new Error("No commits found in this repository.");
|
|
1319
|
+
}
|
|
1320
|
+
fs10.writeFileSync(recordingPath, sha, "utf-8");
|
|
1321
|
+
return sha;
|
|
1322
|
+
}
|
|
1323
|
+
function isRecording(projectDir = process.cwd()) {
|
|
1324
|
+
return fs10.existsSync(path10.join(projectDir, RECORDING_FILE));
|
|
1325
|
+
}
|
|
1326
|
+
async function stopRecording(projectDir = process.cwd(), options = {}) {
|
|
1327
|
+
const recordingPath = path10.join(projectDir, RECORDING_FILE);
|
|
1328
|
+
if (!fs10.existsSync(recordingPath)) {
|
|
1329
|
+
throw new Error("No recording in progress. Run `planmode record start` first.");
|
|
1330
|
+
}
|
|
1331
|
+
const startSha = fs10.readFileSync(recordingPath, "utf-8").trim();
|
|
1332
|
+
const git = simpleGit2(projectDir);
|
|
1333
|
+
const log = await git.log({ from: startSha, to: "HEAD" });
|
|
1334
|
+
if (log.total === 0) {
|
|
1335
|
+
fs10.unlinkSync(recordingPath);
|
|
1336
|
+
throw new Error("No commits since recording started. Nothing to capture.");
|
|
1337
|
+
}
|
|
1338
|
+
const commits = [...log.all].reverse();
|
|
1339
|
+
const steps = [];
|
|
1340
|
+
const allFilesChanged = /* @__PURE__ */ new Set();
|
|
1341
|
+
for (const commit of commits) {
|
|
1342
|
+
const diff = await git.diffSummary([`${commit.hash}~1`, commit.hash]).catch(
|
|
1343
|
+
() => (
|
|
1344
|
+
// First commit in range might not have a parent in range
|
|
1345
|
+
git.diffSummary([startSha, commit.hash])
|
|
1346
|
+
)
|
|
1347
|
+
);
|
|
1348
|
+
const filesChanged = diff.files.map((f) => f.file);
|
|
1349
|
+
filesChanged.forEach((f) => allFilesChanged.add(f));
|
|
1350
|
+
const firstLine = commit.message.split("\n")[0].trim();
|
|
1351
|
+
const body = commit.message.split("\n").slice(1).join("\n").trim();
|
|
1352
|
+
steps.push({
|
|
1353
|
+
title: firstLine,
|
|
1354
|
+
message: body || firstLine,
|
|
1355
|
+
filesChanged,
|
|
1356
|
+
sha: commit.hash.slice(0, 7)
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
const planName = options.name || inferPlanName(steps);
|
|
1360
|
+
const planContent = generatePlanContent(planName, steps);
|
|
1361
|
+
const manifestContent = generateManifest(planName, options.author || "");
|
|
1362
|
+
fs10.unlinkSync(recordingPath);
|
|
1363
|
+
return {
|
|
1364
|
+
steps,
|
|
1365
|
+
planContent,
|
|
1366
|
+
manifestContent,
|
|
1367
|
+
totalCommits: commits.length,
|
|
1368
|
+
totalFilesChanged: allFilesChanged.size
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
function inferPlanName(steps) {
|
|
1372
|
+
const words = steps.map((s) => s.title.toLowerCase()).join(" ").replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !["the", "and", "for", "add", "fix", "update", "set"].includes(w));
|
|
1373
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1374
|
+
for (const word of words) {
|
|
1375
|
+
counts.set(word, (counts.get(word) || 0) + 1);
|
|
1376
|
+
}
|
|
1377
|
+
const topWords = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([w]) => w);
|
|
1378
|
+
return topWords.length > 0 ? topWords.join("-") + "-setup" : "recorded-plan";
|
|
1379
|
+
}
|
|
1380
|
+
function generatePlanContent(name, steps) {
|
|
1381
|
+
const lines = [];
|
|
1382
|
+
lines.push(`# ${name}`);
|
|
1383
|
+
lines.push("");
|
|
1384
|
+
lines.push("## Steps");
|
|
1385
|
+
lines.push("");
|
|
1386
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1387
|
+
const step = steps[i];
|
|
1388
|
+
lines.push(`### ${i + 1}. ${step.title}`);
|
|
1389
|
+
lines.push("");
|
|
1390
|
+
if (step.message !== step.title) {
|
|
1391
|
+
lines.push(step.message);
|
|
1392
|
+
lines.push("");
|
|
1393
|
+
}
|
|
1394
|
+
if (step.filesChanged.length > 0) {
|
|
1395
|
+
lines.push("**Files changed:**");
|
|
1396
|
+
for (const file of step.filesChanged) {
|
|
1397
|
+
lines.push(`- \`${file}\``);
|
|
1398
|
+
}
|
|
1399
|
+
lines.push("");
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
lines.push("## Verification");
|
|
1403
|
+
lines.push("");
|
|
1404
|
+
lines.push("- [ ] All steps completed successfully");
|
|
1405
|
+
lines.push("- [ ] Application builds without errors");
|
|
1406
|
+
lines.push("- [ ] Tests pass");
|
|
1407
|
+
lines.push("");
|
|
1408
|
+
return lines.join("\n");
|
|
1409
|
+
}
|
|
1410
|
+
function generateManifest(name, author) {
|
|
1411
|
+
const manifest = {
|
|
1412
|
+
name,
|
|
1413
|
+
version: "1.0.0",
|
|
1414
|
+
type: "plan",
|
|
1415
|
+
description: `Plan recorded from git history`,
|
|
1416
|
+
author: author || "unknown",
|
|
1417
|
+
license: "MIT",
|
|
1418
|
+
category: "other",
|
|
1419
|
+
content_file: "plan.md"
|
|
1420
|
+
};
|
|
1421
|
+
return stringify4(manifest);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// src/lib/snapshot.ts
|
|
1425
|
+
import fs11 from "fs";
|
|
1426
|
+
import path11 from "path";
|
|
1427
|
+
import { stringify as stringify5 } from "yaml";
|
|
1428
|
+
var CONFIG_FILES = {
|
|
1429
|
+
"tsconfig.json": "TypeScript",
|
|
1430
|
+
"tsconfig.base.json": "TypeScript (base)",
|
|
1431
|
+
".eslintrc": "ESLint",
|
|
1432
|
+
".eslintrc.js": "ESLint",
|
|
1433
|
+
".eslintrc.json": "ESLint",
|
|
1434
|
+
"eslint.config.js": "ESLint (flat config)",
|
|
1435
|
+
"eslint.config.mjs": "ESLint (flat config)",
|
|
1436
|
+
".prettierrc": "Prettier",
|
|
1437
|
+
".prettierrc.json": "Prettier",
|
|
1438
|
+
"prettier.config.js": "Prettier",
|
|
1439
|
+
"tailwind.config.js": "Tailwind CSS",
|
|
1440
|
+
"tailwind.config.ts": "Tailwind CSS",
|
|
1441
|
+
"tailwind.config.mjs": "Tailwind CSS",
|
|
1442
|
+
"postcss.config.js": "PostCSS",
|
|
1443
|
+
"postcss.config.mjs": "PostCSS",
|
|
1444
|
+
"next.config.js": "Next.js",
|
|
1445
|
+
"next.config.mjs": "Next.js",
|
|
1446
|
+
"next.config.ts": "Next.js",
|
|
1447
|
+
"vite.config.ts": "Vite",
|
|
1448
|
+
"vite.config.js": "Vite",
|
|
1449
|
+
"astro.config.mjs": "Astro",
|
|
1450
|
+
"astro.config.ts": "Astro",
|
|
1451
|
+
"svelte.config.js": "SvelteKit",
|
|
1452
|
+
"nuxt.config.ts": "Nuxt",
|
|
1453
|
+
"remix.config.js": "Remix",
|
|
1454
|
+
"webpack.config.js": "Webpack",
|
|
1455
|
+
"rollup.config.js": "Rollup",
|
|
1456
|
+
"vitest.config.ts": "Vitest",
|
|
1457
|
+
"jest.config.js": "Jest",
|
|
1458
|
+
"jest.config.ts": "Jest",
|
|
1459
|
+
"docker-compose.yml": "Docker Compose",
|
|
1460
|
+
"docker-compose.yaml": "Docker Compose",
|
|
1461
|
+
"Dockerfile": "Docker",
|
|
1462
|
+
".dockerignore": "Docker",
|
|
1463
|
+
"prisma/schema.prisma": "Prisma",
|
|
1464
|
+
"drizzle.config.ts": "Drizzle ORM",
|
|
1465
|
+
".env.example": "Environment variables",
|
|
1466
|
+
".github/workflows": "GitHub Actions",
|
|
1467
|
+
"vercel.json": "Vercel",
|
|
1468
|
+
"netlify.toml": "Netlify",
|
|
1469
|
+
"wrangler.toml": "Cloudflare Workers",
|
|
1470
|
+
"fly.toml": "Fly.io"
|
|
1471
|
+
};
|
|
1472
|
+
function takeSnapshot(projectDir = process.cwd(), options = {}) {
|
|
1473
|
+
const data = analyzeProject(projectDir);
|
|
1474
|
+
if (options.name) {
|
|
1475
|
+
data.name = options.name;
|
|
1476
|
+
}
|
|
1477
|
+
const planContent = generatePlanFromSnapshot(data);
|
|
1478
|
+
const manifestContent = generateManifestFromSnapshot(data, options.author || "");
|
|
1479
|
+
return { planContent, manifestContent, data };
|
|
1480
|
+
}
|
|
1481
|
+
function analyzeProject(projectDir) {
|
|
1482
|
+
let name = path11.basename(projectDir) + "-setup";
|
|
1483
|
+
const dependencies = {};
|
|
1484
|
+
const devDependencies = {};
|
|
1485
|
+
const scripts = {};
|
|
1486
|
+
const pkgPath = path11.join(projectDir, "package.json");
|
|
1487
|
+
if (fs11.existsSync(pkgPath)) {
|
|
1488
|
+
try {
|
|
1489
|
+
const pkg = JSON.parse(fs11.readFileSync(pkgPath, "utf-8"));
|
|
1490
|
+
if (pkg.name) name = pkg.name + "-setup";
|
|
1491
|
+
if (pkg.dependencies) Object.assign(dependencies, pkg.dependencies);
|
|
1492
|
+
if (pkg.devDependencies) Object.assign(devDependencies, pkg.devDependencies);
|
|
1493
|
+
if (pkg.scripts) Object.assign(scripts, pkg.scripts);
|
|
1494
|
+
} catch {
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
const detectedTools = [];
|
|
1498
|
+
for (const [file, toolName] of Object.entries(CONFIG_FILES)) {
|
|
1499
|
+
const fullPath = path11.join(projectDir, file);
|
|
1500
|
+
if (fs11.existsSync(fullPath)) {
|
|
1501
|
+
detectedTools.push({ name: toolName, file });
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const structure = getDirectoryStructure(projectDir, 2);
|
|
1505
|
+
const framework = detectFramework(dependencies, devDependencies);
|
|
1506
|
+
name = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1507
|
+
return { name, dependencies, devDependencies, detectedTools, structure, scripts, framework };
|
|
1508
|
+
}
|
|
1509
|
+
function detectFramework(deps, devDeps) {
|
|
1510
|
+
const all = { ...deps, ...devDeps };
|
|
1511
|
+
if (all["next"]) return "Next.js";
|
|
1512
|
+
if (all["astro"]) return "Astro";
|
|
1513
|
+
if (all["@sveltejs/kit"]) return "SvelteKit";
|
|
1514
|
+
if (all["nuxt"]) return "Nuxt";
|
|
1515
|
+
if (all["@remix-run/react"]) return "Remix";
|
|
1516
|
+
if (all["vue"]) return "Vue";
|
|
1517
|
+
if (all["react"]) return "React";
|
|
1518
|
+
if (all["express"]) return "Express";
|
|
1519
|
+
if (all["fastify"]) return "Fastify";
|
|
1520
|
+
if (all["hono"]) return "Hono";
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
function getDirectoryStructure(dir, maxDepth, depth = 0) {
|
|
1524
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
1525
|
+
"node_modules",
|
|
1526
|
+
"dist",
|
|
1527
|
+
".git",
|
|
1528
|
+
".next",
|
|
1529
|
+
".nuxt",
|
|
1530
|
+
".svelte-kit",
|
|
1531
|
+
".astro",
|
|
1532
|
+
".vercel",
|
|
1533
|
+
".netlify",
|
|
1534
|
+
"build",
|
|
1535
|
+
"coverage",
|
|
1536
|
+
"__pycache__",
|
|
1537
|
+
".turbo",
|
|
1538
|
+
".cache"
|
|
1539
|
+
]);
|
|
1540
|
+
const results = [];
|
|
1541
|
+
try {
|
|
1542
|
+
const entries = fs11.readdirSync(dir, { withFileTypes: true });
|
|
1543
|
+
for (const entry of entries) {
|
|
1544
|
+
if (entry.name.startsWith(".") && entry.name !== ".github") continue;
|
|
1545
|
+
if (SKIP.has(entry.name)) continue;
|
|
1546
|
+
const indent = " ".repeat(depth);
|
|
1547
|
+
if (entry.isDirectory()) {
|
|
1548
|
+
results.push(`${indent}${entry.name}/`);
|
|
1549
|
+
if (depth < maxDepth) {
|
|
1550
|
+
results.push(...getDirectoryStructure(path11.join(dir, entry.name), maxDepth, depth + 1));
|
|
1551
|
+
}
|
|
1552
|
+
} else {
|
|
1553
|
+
results.push(`${indent}${entry.name}`);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
} catch {
|
|
1557
|
+
}
|
|
1558
|
+
return results;
|
|
1559
|
+
}
|
|
1560
|
+
function generatePlanFromSnapshot(data) {
|
|
1561
|
+
const lines = [];
|
|
1562
|
+
lines.push(`# ${data.name}`);
|
|
1563
|
+
lines.push("");
|
|
1564
|
+
if (data.framework) {
|
|
1565
|
+
lines.push(`Set up a ${data.framework} project with the following tools and configuration.`);
|
|
1566
|
+
} else {
|
|
1567
|
+
lines.push("Set up a project with the following tools and configuration.");
|
|
1568
|
+
}
|
|
1569
|
+
lines.push("");
|
|
1570
|
+
lines.push("## Prerequisites");
|
|
1571
|
+
lines.push("");
|
|
1572
|
+
lines.push("- Node.js 20+");
|
|
1573
|
+
if (Object.keys(data.dependencies).length > 0 || Object.keys(data.devDependencies).length > 0) {
|
|
1574
|
+
lines.push("- npm or your preferred package manager");
|
|
1575
|
+
}
|
|
1576
|
+
const toolNames = [...new Set(data.detectedTools.map((t) => t.name))];
|
|
1577
|
+
if (toolNames.includes("Docker") || toolNames.includes("Docker Compose")) {
|
|
1578
|
+
lines.push("- Docker");
|
|
1579
|
+
}
|
|
1580
|
+
if (toolNames.includes("Prisma")) {
|
|
1581
|
+
lines.push("- A PostgreSQL database (or update the Prisma schema for your database)");
|
|
1582
|
+
}
|
|
1583
|
+
lines.push("");
|
|
1584
|
+
lines.push("## Steps");
|
|
1585
|
+
lines.push("");
|
|
1586
|
+
let stepNum = 1;
|
|
1587
|
+
if (data.framework) {
|
|
1588
|
+
lines.push(`### ${stepNum}. Create ${data.framework} project`);
|
|
1589
|
+
lines.push("");
|
|
1590
|
+
lines.push(`Initialize a new ${data.framework} project.`);
|
|
1591
|
+
lines.push("");
|
|
1592
|
+
stepNum++;
|
|
1593
|
+
}
|
|
1594
|
+
const depNames = Object.keys(data.dependencies);
|
|
1595
|
+
const devDepNames = Object.keys(data.devDependencies);
|
|
1596
|
+
if (depNames.length > 0) {
|
|
1597
|
+
lines.push(`### ${stepNum}. Install dependencies`);
|
|
1598
|
+
lines.push("");
|
|
1599
|
+
lines.push("```bash");
|
|
1600
|
+
lines.push(`npm install ${depNames.join(" ")}`);
|
|
1601
|
+
lines.push("```");
|
|
1602
|
+
lines.push("");
|
|
1603
|
+
stepNum++;
|
|
1604
|
+
}
|
|
1605
|
+
if (devDepNames.length > 0) {
|
|
1606
|
+
lines.push(`### ${stepNum}. Install dev dependencies`);
|
|
1607
|
+
lines.push("");
|
|
1608
|
+
lines.push("```bash");
|
|
1609
|
+
lines.push(`npm install -D ${devDepNames.join(" ")}`);
|
|
1610
|
+
lines.push("```");
|
|
1611
|
+
lines.push("");
|
|
1612
|
+
stepNum++;
|
|
1613
|
+
}
|
|
1614
|
+
for (const tool of data.detectedTools) {
|
|
1615
|
+
if (tool.name === data.framework) continue;
|
|
1616
|
+
if (tool.name === "Environment variables") continue;
|
|
1617
|
+
lines.push(`### ${stepNum}. Configure ${tool.name}`);
|
|
1618
|
+
lines.push("");
|
|
1619
|
+
lines.push(`Create or update \`${tool.file}\` with the appropriate configuration.`);
|
|
1620
|
+
lines.push("");
|
|
1621
|
+
stepNum++;
|
|
1622
|
+
}
|
|
1623
|
+
if (data.detectedTools.some((t) => t.name === "Environment variables")) {
|
|
1624
|
+
lines.push(`### ${stepNum}. Set up environment variables`);
|
|
1625
|
+
lines.push("");
|
|
1626
|
+
lines.push("Copy `.env.example` to `.env` and fill in the values:");
|
|
1627
|
+
lines.push("");
|
|
1628
|
+
lines.push("```bash");
|
|
1629
|
+
lines.push("cp .env.example .env");
|
|
1630
|
+
lines.push("```");
|
|
1631
|
+
lines.push("");
|
|
1632
|
+
stepNum++;
|
|
1633
|
+
}
|
|
1634
|
+
if (Object.keys(data.scripts).length > 0) {
|
|
1635
|
+
lines.push(`### ${stepNum}. Available scripts`);
|
|
1636
|
+
lines.push("");
|
|
1637
|
+
for (const [name, cmd] of Object.entries(data.scripts)) {
|
|
1638
|
+
lines.push(`- \`npm run ${name}\` \u2014 \`${cmd}\``);
|
|
1639
|
+
}
|
|
1640
|
+
lines.push("");
|
|
1641
|
+
stepNum++;
|
|
1642
|
+
}
|
|
1643
|
+
if (data.structure.length > 0) {
|
|
1644
|
+
lines.push("## Project Structure");
|
|
1645
|
+
lines.push("");
|
|
1646
|
+
lines.push("```");
|
|
1647
|
+
for (const line of data.structure.slice(0, 40)) {
|
|
1648
|
+
lines.push(line);
|
|
1649
|
+
}
|
|
1650
|
+
if (data.structure.length > 40) {
|
|
1651
|
+
lines.push(" ...");
|
|
1652
|
+
}
|
|
1653
|
+
lines.push("```");
|
|
1654
|
+
lines.push("");
|
|
1655
|
+
}
|
|
1656
|
+
lines.push("## Verification");
|
|
1657
|
+
lines.push("");
|
|
1658
|
+
lines.push("- [ ] All dependencies installed without errors");
|
|
1659
|
+
lines.push("- [ ] Configuration files are in place");
|
|
1660
|
+
if (data.scripts["build"]) lines.push("- [ ] `npm run build` succeeds");
|
|
1661
|
+
if (data.scripts["test"]) lines.push("- [ ] `npm run test` passes");
|
|
1662
|
+
if (data.scripts["dev"]) lines.push("- [ ] `npm run dev` starts without errors");
|
|
1663
|
+
lines.push("");
|
|
1664
|
+
return lines.join("\n");
|
|
1665
|
+
}
|
|
1666
|
+
function generateManifestFromSnapshot(data, author) {
|
|
1667
|
+
const tags = [];
|
|
1668
|
+
if (data.framework) tags.push(data.framework.toLowerCase().replace(/[^a-z0-9]/g, ""));
|
|
1669
|
+
const toolTags = data.detectedTools.map((t) => t.name.toLowerCase().replace(/[^a-z0-9]/g, "-")).filter((t) => t.length > 1);
|
|
1670
|
+
tags.push(...[...new Set(toolTags)].slice(0, 8));
|
|
1671
|
+
const category = detectCategory(data);
|
|
1672
|
+
const manifest = {
|
|
1673
|
+
name: data.name,
|
|
1674
|
+
version: "1.0.0",
|
|
1675
|
+
type: "plan",
|
|
1676
|
+
description: data.framework ? `Set up a ${data.framework} project with ${data.detectedTools.map((t) => t.name).slice(0, 3).join(", ")}` : `Project setup with ${data.detectedTools.map((t) => t.name).slice(0, 3).join(", ")}`,
|
|
1677
|
+
author: author || "unknown",
|
|
1678
|
+
license: "MIT",
|
|
1679
|
+
tags: tags.slice(0, 10),
|
|
1680
|
+
category,
|
|
1681
|
+
content_file: "plan.md"
|
|
1682
|
+
};
|
|
1683
|
+
return stringify5(manifest);
|
|
1684
|
+
}
|
|
1685
|
+
function detectCategory(data) {
|
|
1686
|
+
const all = { ...data.dependencies, ...data.devDependencies };
|
|
1687
|
+
if (all["react"] || all["vue"] || all["svelte"] || all["next"] || all["astro"]) return "frontend";
|
|
1688
|
+
if (all["express"] || all["fastify"] || all["hono"] || all["koa"]) return "backend";
|
|
1689
|
+
if (data.detectedTools.some((t) => t.name === "Docker" || t.name === "Docker Compose")) return "devops";
|
|
1690
|
+
if (all["prisma"] || all["drizzle-orm"] || all["typeorm"]) return "database";
|
|
1691
|
+
return "other";
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/mcp.ts
|
|
1695
|
+
function withCapture(fn) {
|
|
1696
|
+
logger.capture();
|
|
1697
|
+
try {
|
|
1698
|
+
const result = fn();
|
|
1699
|
+
const messages = logger.flush();
|
|
1700
|
+
return { result, messages };
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
const messages = logger.flush();
|
|
1703
|
+
throw Object.assign(err, { capturedMessages: messages });
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
async function withCaptureAsync(fn) {
|
|
1707
|
+
logger.capture();
|
|
1708
|
+
try {
|
|
1709
|
+
const result = await fn();
|
|
1710
|
+
const messages = logger.flush();
|
|
1711
|
+
return { result, messages };
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
const messages = logger.flush();
|
|
1714
|
+
throw Object.assign(err, { capturedMessages: messages });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function textResult(text, isError = false) {
|
|
1718
|
+
return {
|
|
1719
|
+
content: [{ type: "text", text }],
|
|
1720
|
+
isError
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
function formatMessages(messages, extra) {
|
|
1724
|
+
const parts = [];
|
|
1725
|
+
if (messages.length > 0) {
|
|
1726
|
+
parts.push(messages.filter((m) => m !== "").join("\n"));
|
|
1727
|
+
}
|
|
1728
|
+
if (extra) {
|
|
1729
|
+
parts.push(extra);
|
|
1730
|
+
}
|
|
1731
|
+
return parts.join("\n\n");
|
|
1732
|
+
}
|
|
1733
|
+
function adaptError(message) {
|
|
1734
|
+
return message.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.").replace(/Run `planmode search <query>` to find packages\./, "Try using the planmode_search tool to find packages.").replace(/Run `planmode install (.+?)` to get started\./, "Use the planmode_install tool to install packages.").replace(/Install it first: planmode install (.+)/, "Install it first using the planmode_install tool.").replace(/run `planmode publish` when ready\./, "use the planmode_publish tool when ready.");
|
|
1735
|
+
}
|
|
1736
|
+
function errorResult(prefix, err) {
|
|
1737
|
+
const capturedMessages = err.capturedMessages;
|
|
1738
|
+
const msgPrefix = capturedMessages?.length ? capturedMessages.join("\n") + "\n\n" : "";
|
|
1739
|
+
return textResult(adaptError(`${msgPrefix}${prefix}: ${err.message}`), true);
|
|
1740
|
+
}
|
|
1741
|
+
var server = new McpServer({
|
|
1742
|
+
name: "planmode",
|
|
1743
|
+
version: "0.2.1"
|
|
1744
|
+
});
|
|
1745
|
+
server.registerTool(
|
|
1746
|
+
"planmode_search",
|
|
1747
|
+
{
|
|
1748
|
+
description: "Search the planmode registry for packages (plans, rules, and prompts for AI-assisted development)",
|
|
1749
|
+
inputSchema: {
|
|
1750
|
+
query: z.string().describe("Search query to find packages"),
|
|
1751
|
+
type: z.enum(["prompt", "rule", "plan"]).optional().describe("Filter by package type"),
|
|
1752
|
+
category: z.string().optional().describe("Filter by category (frontend, backend, devops, database, testing, mobile, ai-ml, design, security, other)")
|
|
1753
|
+
}
|
|
1754
|
+
},
|
|
1755
|
+
async ({ query, type, category }) => {
|
|
1756
|
+
try {
|
|
1757
|
+
const { result: results, messages } = await withCaptureAsync(
|
|
1758
|
+
() => searchPackages(query, { type, category })
|
|
1759
|
+
);
|
|
1760
|
+
if (results.length === 0) {
|
|
1761
|
+
return textResult(formatMessages(messages, "No packages found matching your query."));
|
|
1762
|
+
}
|
|
1763
|
+
const table = results.map(
|
|
1764
|
+
(pkg) => `- **${pkg.name}** (${pkg.type} v${pkg.version}) \u2014 ${pkg.description}`
|
|
1765
|
+
).join("\n");
|
|
1766
|
+
return textResult(formatMessages(messages, `Found ${results.length} package(s):
|
|
1767
|
+
|
|
1768
|
+
${table}`));
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
return errorResult("Error searching registry", err);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
);
|
|
1774
|
+
server.registerTool(
|
|
1775
|
+
"planmode_info",
|
|
1776
|
+
{
|
|
1777
|
+
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.",
|
|
1778
|
+
inputSchema: {
|
|
1779
|
+
package: z.string().describe("Package name (e.g., nextjs-tailwind-starter)")
|
|
1780
|
+
}
|
|
1781
|
+
},
|
|
1782
|
+
async ({ package: packageName }) => {
|
|
1783
|
+
try {
|
|
1784
|
+
const { result: meta, messages } = await withCaptureAsync(
|
|
1785
|
+
() => fetchPackageMetadata(packageName)
|
|
1786
|
+
);
|
|
1787
|
+
const lines = [
|
|
1788
|
+
`# ${meta.name}@${meta.latest_version}`,
|
|
1789
|
+
"",
|
|
1790
|
+
`**Description:** ${meta.description}`,
|
|
1791
|
+
`**Type:** ${meta.type}`,
|
|
1792
|
+
`**Author:** ${meta.author}`,
|
|
1793
|
+
`**License:** ${meta.license}`,
|
|
1794
|
+
`**Category:** ${meta.category}`,
|
|
1795
|
+
`**Downloads:** ${meta.downloads.toLocaleString()}`,
|
|
1796
|
+
`**Repository:** ${meta.repository}`
|
|
1797
|
+
];
|
|
1798
|
+
if (meta.models && meta.models.length > 0) {
|
|
1799
|
+
lines.push(`**Models:** ${meta.models.join(", ")}`);
|
|
1800
|
+
}
|
|
1801
|
+
if (meta.tags && meta.tags.length > 0) {
|
|
1802
|
+
lines.push(`**Tags:** ${meta.tags.join(", ")}`);
|
|
1803
|
+
}
|
|
1804
|
+
lines.push(`**Versions:** ${meta.versions.join(", ")}`);
|
|
1805
|
+
if (meta.dependencies) {
|
|
1806
|
+
if (meta.dependencies.rules?.length) {
|
|
1807
|
+
lines.push(`**Dependencies (rules):** ${meta.dependencies.rules.join(", ")}`);
|
|
1808
|
+
}
|
|
1809
|
+
if (meta.dependencies.plans?.length) {
|
|
1810
|
+
lines.push(`**Dependencies (plans):** ${meta.dependencies.plans.join(", ")}`);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
if (meta.variables) {
|
|
1814
|
+
lines.push("", "**Variables:**");
|
|
1815
|
+
for (const [name, def] of Object.entries(meta.variables)) {
|
|
1816
|
+
const required = def.required ? " (required)" : "";
|
|
1817
|
+
const defaultVal = def.default !== void 0 ? ` [default: ${def.default}]` : "";
|
|
1818
|
+
lines.push(`- \`${name}\`: ${def.type}${required}${defaultVal} \u2014 ${def.description}`);
|
|
1819
|
+
if (def.options) {
|
|
1820
|
+
lines.push(` Options: ${def.options.join(", ")}`);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return textResult(formatMessages(messages, lines.join("\n")));
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
return errorResult("Error fetching package info", err);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
);
|
|
1830
|
+
server.registerTool(
|
|
1831
|
+
"planmode_preview",
|
|
1832
|
+
{
|
|
1833
|
+
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.",
|
|
1834
|
+
inputSchema: {
|
|
1835
|
+
package: z.string().describe("Package name to preview"),
|
|
1836
|
+
version: z.string().optional().describe("Specific version to preview (default: latest)")
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
async ({ package: packageName, version }) => {
|
|
1840
|
+
try {
|
|
1841
|
+
const { result: resolved, messages: resolveMessages } = await withCaptureAsync(
|
|
1842
|
+
() => resolveVersion(packageName, version)
|
|
1843
|
+
);
|
|
1844
|
+
const versionMeta = await fetchVersionMetadata(packageName, resolved.version);
|
|
1845
|
+
const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
|
|
1846
|
+
const manifestRaw = await fetchFileAtTag(
|
|
1847
|
+
versionMeta.source.repository,
|
|
1848
|
+
versionMeta.source.tag,
|
|
1849
|
+
`${basePath}planmode.yaml`
|
|
1850
|
+
);
|
|
1851
|
+
const manifest = parseManifest(manifestRaw);
|
|
1852
|
+
let content;
|
|
1853
|
+
if (manifest.content) {
|
|
1854
|
+
content = manifest.content;
|
|
1855
|
+
} else if (manifest.content_file) {
|
|
1856
|
+
content = await fetchFileAtTag(
|
|
1857
|
+
versionMeta.source.repository,
|
|
1858
|
+
versionMeta.source.tag,
|
|
1859
|
+
`${basePath}${manifest.content_file}`
|
|
1860
|
+
);
|
|
1861
|
+
} else {
|
|
1862
|
+
return textResult("Package has no content or content_file defined.", true);
|
|
1863
|
+
}
|
|
1864
|
+
const header = [
|
|
1865
|
+
`# Preview: ${packageName}@${resolved.version}`,
|
|
1866
|
+
`**Type:** ${manifest.type} | **Author:** ${manifest.author ?? "unknown"}`,
|
|
1867
|
+
manifest.description ? `**Description:** ${manifest.description}` : ""
|
|
1868
|
+
].filter(Boolean).join("\n");
|
|
1869
|
+
const variableNote = manifest.variables && Object.keys(manifest.variables).length > 0 ? `
|
|
1870
|
+
**Note:** This package uses template variables (${Object.keys(manifest.variables).join(", ")}). Content below shows raw templates.
|
|
1871
|
+
` : "";
|
|
1872
|
+
return textResult(`${header}${variableNote}
|
|
1873
|
+
---
|
|
1874
|
+
|
|
1875
|
+
${content}`);
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
return errorResult("Error previewing package", err);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
);
|
|
1881
|
+
server.registerTool(
|
|
1882
|
+
"planmode_install",
|
|
1883
|
+
{
|
|
1884
|
+
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.",
|
|
1885
|
+
inputSchema: {
|
|
1886
|
+
package: z.string().describe("Package name to install"),
|
|
1887
|
+
version: z.string().optional().describe("Specific version to install (default: latest)"),
|
|
1888
|
+
asRule: z.boolean().optional().describe("Force install as a rule to .claude/rules/"),
|
|
1889
|
+
variables: z.record(z.string(), z.string()).optional().describe("Template variable values as key-value pairs"),
|
|
1890
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
1891
|
+
}
|
|
1892
|
+
},
|
|
1893
|
+
async ({ package: packageName, version, asRule, variables, projectDir }) => {
|
|
1894
|
+
try {
|
|
1895
|
+
const { messages } = await withCaptureAsync(
|
|
1896
|
+
() => installPackage(packageName, {
|
|
1897
|
+
version,
|
|
1898
|
+
forceRule: asRule,
|
|
1899
|
+
noInput: true,
|
|
1900
|
+
variables,
|
|
1901
|
+
projectDir
|
|
1902
|
+
})
|
|
1903
|
+
);
|
|
1904
|
+
return textResult(adaptError(formatMessages(messages) || `Installed ${packageName} successfully.`));
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
return errorResult("Error installing package", err);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
);
|
|
1910
|
+
server.registerTool(
|
|
1911
|
+
"planmode_uninstall",
|
|
1912
|
+
{
|
|
1913
|
+
description: "Remove an installed planmode package from the current project",
|
|
1914
|
+
inputSchema: {
|
|
1915
|
+
package: z.string().describe("Package name to uninstall"),
|
|
1916
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
1917
|
+
}
|
|
1918
|
+
},
|
|
1919
|
+
async ({ package: packageName, projectDir }) => {
|
|
1920
|
+
try {
|
|
1921
|
+
const { messages } = await withCaptureAsync(
|
|
1922
|
+
() => uninstallPackage(packageName, projectDir)
|
|
1923
|
+
);
|
|
1924
|
+
return textResult(formatMessages(messages) || `Uninstalled ${packageName} successfully.`);
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
return errorResult("Error uninstalling package", err);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
);
|
|
1930
|
+
server.registerTool(
|
|
1931
|
+
"planmode_list",
|
|
1932
|
+
{
|
|
1933
|
+
description: "List all planmode packages installed in the current project",
|
|
1934
|
+
inputSchema: {
|
|
1935
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
async ({ projectDir }) => {
|
|
1939
|
+
try {
|
|
1940
|
+
const lockfile = readLockfile(projectDir);
|
|
1941
|
+
const entries = Object.entries(lockfile.packages);
|
|
1942
|
+
if (entries.length === 0) {
|
|
1943
|
+
return textResult("No packages installed. Use the planmode_install tool to install a package.");
|
|
1944
|
+
}
|
|
1945
|
+
const table = entries.map(
|
|
1946
|
+
([name, entry]) => `- **${name}** (${entry.type} v${entry.version}) \u2192 ${entry.installed_to}`
|
|
1947
|
+
).join("\n");
|
|
1948
|
+
return textResult(`${entries.length} package(s) installed:
|
|
1949
|
+
|
|
1950
|
+
${table}`);
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
return errorResult("Error listing packages", err);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
);
|
|
1956
|
+
server.registerTool(
|
|
1957
|
+
"planmode_read",
|
|
1958
|
+
{
|
|
1959
|
+
description: "Read the content of an installed planmode package from disk. Use this to view what a plan, rule, or prompt contains after installation.",
|
|
1960
|
+
inputSchema: {
|
|
1961
|
+
package: z.string().describe("Package name to read"),
|
|
1962
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
1963
|
+
}
|
|
1964
|
+
},
|
|
1965
|
+
async ({ package: packageName, projectDir }) => {
|
|
1966
|
+
try {
|
|
1967
|
+
const dir = projectDir ?? process.cwd();
|
|
1968
|
+
const lockfile = readLockfile(dir);
|
|
1969
|
+
const entry = lockfile.packages[packageName];
|
|
1970
|
+
if (!entry) {
|
|
1971
|
+
const candidates = [
|
|
1972
|
+
path12.join(dir, "plans", `${packageName}.md`),
|
|
1973
|
+
path12.join(dir, ".claude", "rules", `${packageName}.md`),
|
|
1974
|
+
path12.join(dir, "prompts", `${packageName}.md`)
|
|
1975
|
+
];
|
|
1976
|
+
for (const candidate of candidates) {
|
|
1977
|
+
if (fs12.existsSync(candidate)) {
|
|
1978
|
+
const content2 = fs12.readFileSync(candidate, "utf-8");
|
|
1979
|
+
const relativePath = path12.relative(dir, candidate);
|
|
1980
|
+
return textResult(`# ${packageName}
|
|
1981
|
+
**Location:** ${relativePath}
|
|
1982
|
+
|
|
1983
|
+
---
|
|
1984
|
+
|
|
1985
|
+
${content2}`);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return textResult(
|
|
1989
|
+
`Package '${packageName}' is not installed. Use planmode_list to see installed packages, or planmode_preview to view a package from the registry.`,
|
|
1990
|
+
true
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
const fullPath = path12.join(dir, entry.installed_to);
|
|
1994
|
+
if (!fs12.existsSync(fullPath)) {
|
|
1995
|
+
return textResult(
|
|
1996
|
+
`Package '${packageName}' is in the lockfile but the file is missing at ${entry.installed_to}. Try reinstalling with planmode_install.`,
|
|
1997
|
+
true
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
const content = fs12.readFileSync(fullPath, "utf-8");
|
|
2001
|
+
return textResult(
|
|
2002
|
+
`# ${packageName} (${entry.type} v${entry.version})
|
|
2003
|
+
**Location:** ${entry.installed_to}
|
|
2004
|
+
|
|
2005
|
+
---
|
|
2006
|
+
|
|
2007
|
+
${content}`
|
|
2008
|
+
);
|
|
2009
|
+
} catch (err) {
|
|
2010
|
+
return errorResult("Error reading package", err);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
);
|
|
2014
|
+
server.registerTool(
|
|
2015
|
+
"planmode_update",
|
|
2016
|
+
{
|
|
2017
|
+
description: "Update installed planmode packages to their latest compatible versions",
|
|
2018
|
+
inputSchema: {
|
|
2019
|
+
package: z.string().optional().describe("Package name to update (omit to update all)"),
|
|
2020
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
2021
|
+
}
|
|
2022
|
+
},
|
|
2023
|
+
async ({ package: packageName, projectDir }) => {
|
|
2024
|
+
try {
|
|
2025
|
+
if (packageName) {
|
|
2026
|
+
const { result: updated, messages } = await withCaptureAsync(
|
|
2027
|
+
() => updatePackage(packageName, projectDir)
|
|
2028
|
+
);
|
|
2029
|
+
if (!updated) {
|
|
2030
|
+
return textResult(formatMessages(messages, `${packageName} is already up to date.`));
|
|
2031
|
+
}
|
|
2032
|
+
return textResult(formatMessages(messages) || `Updated ${packageName} successfully.`);
|
|
2033
|
+
}
|
|
2034
|
+
const lockfile = readLockfile(projectDir);
|
|
2035
|
+
const names = Object.keys(lockfile.packages);
|
|
2036
|
+
if (names.length === 0) {
|
|
2037
|
+
return textResult("No packages installed.");
|
|
2038
|
+
}
|
|
2039
|
+
const results = [];
|
|
2040
|
+
let updatedCount = 0;
|
|
2041
|
+
for (const name of names) {
|
|
2042
|
+
try {
|
|
2043
|
+
const { result: updated, messages } = await withCaptureAsync(
|
|
2044
|
+
() => updatePackage(name, projectDir)
|
|
2045
|
+
);
|
|
2046
|
+
if (updated) {
|
|
2047
|
+
updatedCount++;
|
|
2048
|
+
results.push(`Updated ${name}`);
|
|
2049
|
+
}
|
|
2050
|
+
} catch (err) {
|
|
2051
|
+
results.push(`Failed to update ${name}: ${err.message}`);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
if (updatedCount === 0) {
|
|
2055
|
+
return textResult("All packages are up to date.");
|
|
2056
|
+
}
|
|
2057
|
+
return textResult(`Updated ${updatedCount} package(s):
|
|
2058
|
+
|
|
2059
|
+
${results.join("\n")}`);
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
return errorResult("Error updating packages", err);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
);
|
|
2065
|
+
server.registerTool(
|
|
2066
|
+
"planmode_init",
|
|
2067
|
+
{
|
|
2068
|
+
description: "Initialize a new planmode package by creating planmode.yaml and a content stub file",
|
|
2069
|
+
inputSchema: {
|
|
2070
|
+
name: z.string().describe("Package name (lowercase, hyphens only)"),
|
|
2071
|
+
type: z.enum(["plan", "rule", "prompt"]).describe("Package type"),
|
|
2072
|
+
description: z.string().describe("Package description (max 200 chars)"),
|
|
2073
|
+
author: z.string().describe("Author GitHub username"),
|
|
2074
|
+
license: z.string().optional().describe("License identifier (default: MIT)"),
|
|
2075
|
+
tags: z.array(z.string()).optional().describe("Tags for discovery (max 10)"),
|
|
2076
|
+
category: z.enum([
|
|
2077
|
+
"frontend",
|
|
2078
|
+
"backend",
|
|
2079
|
+
"devops",
|
|
2080
|
+
"database",
|
|
2081
|
+
"testing",
|
|
2082
|
+
"mobile",
|
|
2083
|
+
"ai-ml",
|
|
2084
|
+
"design",
|
|
2085
|
+
"security",
|
|
2086
|
+
"other"
|
|
2087
|
+
]).optional().describe("Package category (default: other)"),
|
|
2088
|
+
projectDir: z.string().optional().describe("Directory to create the package in (default: current working directory)")
|
|
2089
|
+
}
|
|
2090
|
+
},
|
|
2091
|
+
async ({ name, type, description, author, license, tags, category, projectDir }) => {
|
|
2092
|
+
try {
|
|
2093
|
+
const { result, messages } = withCapture(
|
|
2094
|
+
() => createPackage({
|
|
2095
|
+
name,
|
|
2096
|
+
type,
|
|
2097
|
+
description,
|
|
2098
|
+
author,
|
|
2099
|
+
license,
|
|
2100
|
+
tags,
|
|
2101
|
+
category,
|
|
2102
|
+
projectDir
|
|
2103
|
+
})
|
|
2104
|
+
);
|
|
2105
|
+
return textResult(
|
|
2106
|
+
formatMessages(
|
|
2107
|
+
messages,
|
|
2108
|
+
`Created package "${name}":
|
|
2109
|
+
- ${result.files.join("\n- ")}
|
|
2110
|
+
|
|
2111
|
+
Edit the content file, then use the planmode_publish tool when ready.`
|
|
2112
|
+
)
|
|
2113
|
+
);
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
return errorResult("Error creating package", err);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
);
|
|
2119
|
+
server.registerTool(
|
|
2120
|
+
"planmode_publish",
|
|
2121
|
+
{
|
|
2122
|
+
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).",
|
|
2123
|
+
inputSchema: {
|
|
2124
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)")
|
|
2125
|
+
}
|
|
2126
|
+
},
|
|
2127
|
+
async ({ projectDir }) => {
|
|
2128
|
+
try {
|
|
2129
|
+
const { result, messages } = await withCaptureAsync(
|
|
2130
|
+
() => publishPackage({ projectDir })
|
|
2131
|
+
);
|
|
2132
|
+
return textResult(
|
|
2133
|
+
formatMessages(
|
|
2134
|
+
messages,
|
|
2135
|
+
`Published ${result.packageName}@${result.version}
|
|
2136
|
+
PR: ${result.prUrl}`
|
|
2137
|
+
)
|
|
2138
|
+
);
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
return errorResult("Error publishing package", err);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
);
|
|
2144
|
+
server.registerTool(
|
|
2145
|
+
"planmode_validate",
|
|
2146
|
+
{
|
|
2147
|
+
description: "Validate a planmode.yaml manifest file for correctness",
|
|
2148
|
+
inputSchema: {
|
|
2149
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)"),
|
|
2150
|
+
requirePublishFields: z.boolean().optional().describe("Require fields needed for publishing (description, author, license)")
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
async ({ projectDir, requirePublishFields }) => {
|
|
2154
|
+
try {
|
|
2155
|
+
const dir = projectDir ?? process.cwd();
|
|
2156
|
+
const manifest = readManifest(dir);
|
|
2157
|
+
const errors = validateManifest(manifest, requirePublishFields ?? false);
|
|
2158
|
+
if (errors.length === 0) {
|
|
2159
|
+
return textResult(
|
|
2160
|
+
`Manifest is valid: ${manifest.name}@${manifest.version} (${manifest.type})`
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
return textResult(
|
|
2164
|
+
`Manifest validation failed:
|
|
2165
|
+
|
|
2166
|
+
${errors.map((e) => `- ${e}`).join("\n")}`,
|
|
2167
|
+
true
|
|
2168
|
+
);
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
return errorResult("Error reading manifest", err);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
);
|
|
2174
|
+
server.registerTool(
|
|
2175
|
+
"planmode_run",
|
|
2176
|
+
{
|
|
2177
|
+
description: "Render a templated planmode prompt with variables and return the result",
|
|
2178
|
+
inputSchema: {
|
|
2179
|
+
prompt: z.string().describe("Prompt package name (looks in prompts/ directory)"),
|
|
2180
|
+
variables: z.record(z.string(), z.string()).optional().describe("Template variable values as key-value pairs"),
|
|
2181
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
2182
|
+
}
|
|
2183
|
+
},
|
|
2184
|
+
async ({ prompt: promptName, variables, projectDir }) => {
|
|
2185
|
+
try {
|
|
2186
|
+
const dir = projectDir ?? process.cwd();
|
|
2187
|
+
const localPath = path12.join(dir, "prompts", `${promptName}.md`);
|
|
2188
|
+
const localManifestPath = path12.join(dir, "prompts", promptName, "planmode.yaml");
|
|
2189
|
+
let content;
|
|
2190
|
+
let manifest;
|
|
2191
|
+
if (fs12.existsSync(localManifestPath)) {
|
|
2192
|
+
const raw = fs12.readFileSync(localManifestPath, "utf-8");
|
|
2193
|
+
manifest = parseManifest(raw);
|
|
2194
|
+
const promptDir = path12.join(dir, "prompts", promptName);
|
|
2195
|
+
content = readPackageContent(promptDir, manifest);
|
|
2196
|
+
} else if (fs12.existsSync(localPath)) {
|
|
2197
|
+
content = fs12.readFileSync(localPath, "utf-8");
|
|
2198
|
+
} else {
|
|
2199
|
+
return textResult(
|
|
2200
|
+
`Prompt '${promptName}' not found locally. Install it first using the planmode_install tool.`,
|
|
2201
|
+
true
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
if (manifest?.variables && Object.keys(manifest.variables).length > 0) {
|
|
2205
|
+
const provided = variables ?? {};
|
|
2206
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
2207
|
+
content = renderTemplate(content, values);
|
|
2208
|
+
}
|
|
2209
|
+
return textResult(content);
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
return errorResult("Error running prompt", err);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
);
|
|
2215
|
+
server.registerTool(
|
|
2216
|
+
"planmode_doctor",
|
|
2217
|
+
{
|
|
2218
|
+
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.",
|
|
2219
|
+
inputSchema: {
|
|
2220
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
2221
|
+
}
|
|
2222
|
+
},
|
|
2223
|
+
async ({ projectDir }) => {
|
|
2224
|
+
try {
|
|
2225
|
+
const result = runDoctor(projectDir);
|
|
2226
|
+
if (result.issues.length === 0) {
|
|
2227
|
+
return textResult(`Checked ${result.packagesChecked} package(s) \u2014 all healthy. No issues found.`);
|
|
2228
|
+
}
|
|
2229
|
+
const lines = [`Checked ${result.packagesChecked} package(s):
|
|
2230
|
+
`];
|
|
2231
|
+
for (const issue of result.issues) {
|
|
2232
|
+
const icon = issue.severity === "error" ? "ERROR" : "WARN";
|
|
2233
|
+
lines.push(`**${icon}:** ${issue.message}`);
|
|
2234
|
+
if (issue.fix) {
|
|
2235
|
+
lines.push(` Fix: ${adaptError(issue.fix)}`);
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
const errors = result.issues.filter((i) => i.severity === "error").length;
|
|
2239
|
+
const warnings = result.issues.filter((i) => i.severity === "warning").length;
|
|
2240
|
+
lines.push("", `${errors} error(s), ${warnings} warning(s)`);
|
|
2241
|
+
return textResult(lines.join("\n"), errors > 0);
|
|
2242
|
+
} catch (err) {
|
|
2243
|
+
return errorResult("Error running health check", err);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
);
|
|
2247
|
+
server.registerTool(
|
|
2248
|
+
"planmode_test",
|
|
2249
|
+
{
|
|
2250
|
+
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.",
|
|
2251
|
+
inputSchema: {
|
|
2252
|
+
projectDir: z.string().optional().describe("Directory containing planmode.yaml (default: current working directory)")
|
|
2253
|
+
}
|
|
2254
|
+
},
|
|
2255
|
+
async ({ projectDir }) => {
|
|
2256
|
+
try {
|
|
2257
|
+
const { result, messages } = await withCaptureAsync(
|
|
2258
|
+
() => testPackage(projectDir)
|
|
2259
|
+
);
|
|
2260
|
+
const lines = [];
|
|
2261
|
+
for (const check of result.checks) {
|
|
2262
|
+
if (check.passed) {
|
|
2263
|
+
lines.push(`PASS: ${check.name}`);
|
|
2264
|
+
} else {
|
|
2265
|
+
const issue = result.issues.find((i) => i.check === check.name);
|
|
2266
|
+
const severity = issue?.severity === "error" ? "FAIL" : "WARN";
|
|
2267
|
+
lines.push(`${severity}: ${check.name}${issue ? ` \u2014 ${issue.message}` : ""}`);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
lines.push("");
|
|
2271
|
+
if (result.passed) {
|
|
2272
|
+
lines.push("All checks passed. Ready to publish.");
|
|
2273
|
+
} else {
|
|
2274
|
+
const errors = result.issues.filter((i) => i.severity === "error").length;
|
|
2275
|
+
const warnings = result.issues.filter((i) => i.severity === "warning").length;
|
|
2276
|
+
lines.push(`${errors} error(s), ${warnings} warning(s). Fix errors before publishing.`);
|
|
2277
|
+
}
|
|
2278
|
+
return textResult(formatMessages(messages, lines.join("\n")), !result.passed);
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
return errorResult("Error testing package", err);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
);
|
|
2284
|
+
server.registerTool(
|
|
2285
|
+
"planmode_record_start",
|
|
2286
|
+
{
|
|
2287
|
+
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.",
|
|
2288
|
+
inputSchema: {
|
|
2289
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
2290
|
+
}
|
|
2291
|
+
},
|
|
2292
|
+
async ({ projectDir }) => {
|
|
2293
|
+
try {
|
|
2294
|
+
const dir = projectDir ?? process.cwd();
|
|
2295
|
+
if (isRecording(dir)) {
|
|
2296
|
+
return textResult("A recording is already in progress. Use planmode_record_stop to finish it first.", true);
|
|
2297
|
+
}
|
|
2298
|
+
const sha = await startRecordingAsync(dir);
|
|
2299
|
+
return textResult(`Recording started at commit ${sha.slice(0, 7)}. Make commits as normal, then use planmode_record_stop to generate a plan.`);
|
|
2300
|
+
} catch (err) {
|
|
2301
|
+
return errorResult("Error starting recording", err);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
);
|
|
2305
|
+
server.registerTool(
|
|
2306
|
+
"planmode_record_stop",
|
|
2307
|
+
{
|
|
2308
|
+
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.",
|
|
2309
|
+
inputSchema: {
|
|
2310
|
+
name: z.string().optional().describe("Package name (auto-inferred from commits if not provided)"),
|
|
2311
|
+
author: z.string().optional().describe("Author GitHub username"),
|
|
2312
|
+
outputDir: z.string().optional().describe("Directory to write planmode.yaml and plan.md (default: current working directory)"),
|
|
2313
|
+
projectDir: z.string().optional().describe("Project directory (default: current working directory)")
|
|
2314
|
+
}
|
|
2315
|
+
},
|
|
2316
|
+
async ({ name, author, outputDir, projectDir }) => {
|
|
2317
|
+
try {
|
|
2318
|
+
const dir = projectDir ?? process.cwd();
|
|
2319
|
+
const result = await stopRecording(dir, { name, author });
|
|
2320
|
+
const outDir = outputDir ?? dir;
|
|
2321
|
+
const fsModule = await import("fs");
|
|
2322
|
+
const pathModule = await import("path");
|
|
2323
|
+
fsModule.mkdirSync(outDir, { recursive: true });
|
|
2324
|
+
fsModule.writeFileSync(pathModule.join(outDir, "planmode.yaml"), result.manifestContent, "utf-8");
|
|
2325
|
+
fsModule.writeFileSync(pathModule.join(outDir, "plan.md"), result.planContent, "utf-8");
|
|
2326
|
+
const stepList = result.steps.map((s, i) => `${i + 1}. ${s.title} (${s.filesChanged.length} files)`).join("\n");
|
|
2327
|
+
return textResult(
|
|
2328
|
+
`Generated plan from ${result.totalCommits} commit(s) (${result.totalFilesChanged} files changed):
|
|
2329
|
+
|
|
2330
|
+
${stepList}
|
|
2331
|
+
|
|
2332
|
+
Created planmode.yaml and plan.md. Edit the plan content, then use planmode_test to validate and planmode_publish to publish.`
|
|
2333
|
+
);
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
return errorResult("Error stopping recording", err);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
);
|
|
2339
|
+
server.registerTool(
|
|
2340
|
+
"planmode_snapshot",
|
|
2341
|
+
{
|
|
2342
|
+
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.",
|
|
2343
|
+
inputSchema: {
|
|
2344
|
+
name: z.string().optional().describe("Package name (auto-inferred from project name)"),
|
|
2345
|
+
author: z.string().optional().describe("Author GitHub username"),
|
|
2346
|
+
outputDir: z.string().optional().describe("Directory to write planmode.yaml and plan.md (default: current working directory)"),
|
|
2347
|
+
projectDir: z.string().optional().describe("Project to analyze (default: current working directory)")
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
async ({ name, author, outputDir, projectDir }) => {
|
|
2351
|
+
try {
|
|
2352
|
+
const dir = projectDir ?? process.cwd();
|
|
2353
|
+
const result = takeSnapshot(dir, { name, author });
|
|
2354
|
+
const outDir = outputDir ?? dir;
|
|
2355
|
+
const fsModule = await import("fs");
|
|
2356
|
+
const pathModule = await import("path");
|
|
2357
|
+
fsModule.mkdirSync(outDir, { recursive: true });
|
|
2358
|
+
fsModule.writeFileSync(pathModule.join(outDir, "planmode.yaml"), result.manifestContent, "utf-8");
|
|
2359
|
+
fsModule.writeFileSync(pathModule.join(outDir, "plan.md"), result.planContent, "utf-8");
|
|
2360
|
+
const toolList = result.data.detectedTools.map((t) => t.name).join(", ") || "none";
|
|
2361
|
+
const depCount = Object.keys(result.data.dependencies).length;
|
|
2362
|
+
const devDepCount = Object.keys(result.data.devDependencies).length;
|
|
2363
|
+
let summary = `Snapshot: **${result.data.name}**
|
|
2364
|
+
`;
|
|
2365
|
+
if (result.data.framework) summary += `Framework: ${result.data.framework}
|
|
2366
|
+
`;
|
|
2367
|
+
summary += `Dependencies: ${depCount} | Dev dependencies: ${devDepCount}
|
|
2368
|
+
`;
|
|
2369
|
+
summary += `Tools detected: ${toolList}
|
|
2370
|
+
|
|
2371
|
+
`;
|
|
2372
|
+
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.`;
|
|
2373
|
+
return textResult(summary);
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
return errorResult("Error creating snapshot", err);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
server.registerResource(
|
|
2380
|
+
"installed-packages",
|
|
2381
|
+
new ResourceTemplate("planmode://packages/{name}", {
|
|
2382
|
+
list: async () => {
|
|
2383
|
+
const lockfile = readLockfile();
|
|
2384
|
+
return {
|
|
2385
|
+
resources: Object.entries(lockfile.packages).map(([name, entry]) => ({
|
|
2386
|
+
uri: `planmode://packages/${name}`,
|
|
2387
|
+
name: `${name} (${entry.type} v${entry.version})`,
|
|
2388
|
+
description: `Installed at ${entry.installed_to}`,
|
|
2389
|
+
mimeType: "text/markdown"
|
|
2390
|
+
}))
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
}),
|
|
2394
|
+
{
|
|
2395
|
+
description: "Installed planmode packages in the current project. Each resource contains the full content of an installed plan, rule, or prompt.",
|
|
2396
|
+
mimeType: "text/markdown"
|
|
2397
|
+
},
|
|
2398
|
+
async (uri, variables) => {
|
|
2399
|
+
const name = variables["name"];
|
|
2400
|
+
const lockfile = readLockfile();
|
|
2401
|
+
const entry = lockfile.packages[name];
|
|
2402
|
+
if (!entry) {
|
|
2403
|
+
return {
|
|
2404
|
+
contents: [{
|
|
2405
|
+
uri: uri.href,
|
|
2406
|
+
mimeType: "text/plain",
|
|
2407
|
+
text: `Package '${name}' is not installed.`
|
|
2408
|
+
}]
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
const fullPath = path12.join(process.cwd(), entry.installed_to);
|
|
2412
|
+
let content;
|
|
2413
|
+
try {
|
|
2414
|
+
content = fs12.readFileSync(fullPath, "utf-8");
|
|
2415
|
+
} catch {
|
|
2416
|
+
content = `File not found at ${entry.installed_to}. The package may need to be reinstalled.`;
|
|
2417
|
+
}
|
|
2418
|
+
return {
|
|
2419
|
+
contents: [{
|
|
2420
|
+
uri: uri.href,
|
|
2421
|
+
mimeType: "text/markdown",
|
|
2422
|
+
text: content
|
|
2423
|
+
}]
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
);
|
|
2427
|
+
async function main() {
|
|
2428
|
+
const transport = new StdioServerTransport();
|
|
2429
|
+
await server.connect(transport);
|
|
2430
|
+
console.error("planmode MCP server running on stdio");
|
|
2431
|
+
}
|
|
2432
|
+
main().catch((error) => {
|
|
2433
|
+
console.error("Server error:", error);
|
|
2434
|
+
process.exit(1);
|
|
2435
|
+
});
|