planmode 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,64 +1,112 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { logger } from "../lib/logger.js";
3
4
  import { createPackage } from "../lib/init.js";
5
+ import { isInteractive, handleCancel } from "../lib/prompts.js";
4
6
  import type { PackageType, Category } from "../types/index.js";
5
7
 
6
- async function prompt(question: string): Promise<string> {
7
- const { createInterface } = await import("node:readline");
8
- const rl = createInterface({ input: process.stdin, output: process.stdout });
9
- return new Promise((resolve) => {
10
- rl.question(question, (answer) => {
11
- rl.close();
12
- resolve(answer.trim());
13
- });
8
+ const CATEGORIES: Category[] = [
9
+ "frontend",
10
+ "backend",
11
+ "devops",
12
+ "database",
13
+ "testing",
14
+ "mobile",
15
+ "ai-ml",
16
+ "design",
17
+ "security",
18
+ "other",
19
+ ];
20
+
21
+ export async function initInteractive(): Promise<void> {
22
+ p.intro("Create a new planmode package");
23
+
24
+ const result = await p.group(
25
+ {
26
+ name: () =>
27
+ p.text({
28
+ message: "Package name",
29
+ placeholder: "my-awesome-plan",
30
+ validate(input) {
31
+ if (!input) return "Package name is required";
32
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(input))
33
+ return "Lowercase letters, numbers, and hyphens only";
34
+ },
35
+ }),
36
+ type: () =>
37
+ p.select<PackageType>({
38
+ message: "Package type",
39
+ options: [
40
+ { value: "plan" as PackageType, label: "Plan", hint: "multi-step implementation guide" },
41
+ { value: "rule" as PackageType, label: "Rule", hint: "always-on coding constraint" },
42
+ { value: "prompt" as PackageType, label: "Prompt", hint: "single-use templated prompt" },
43
+ ],
44
+ }),
45
+ description: () =>
46
+ p.text({
47
+ message: "Description",
48
+ placeholder: "A short description of what this package does",
49
+ }),
50
+ author: () =>
51
+ p.text({
52
+ message: "Author (GitHub username)",
53
+ placeholder: "username",
54
+ }),
55
+ license: () =>
56
+ p.text({
57
+ message: "License",
58
+ defaultValue: "MIT",
59
+ placeholder: "MIT",
60
+ }),
61
+ category: () =>
62
+ p.select<Category>({
63
+ message: "Category",
64
+ options: CATEGORIES.map((cat) => ({ value: cat as Category, label: cat })),
65
+ initialValue: "other" as Category,
66
+ }),
67
+ tags: () =>
68
+ p.text({
69
+ message: "Tags (comma-separated)",
70
+ placeholder: "nextjs, tailwind, starter",
71
+ }),
72
+ },
73
+ {
74
+ onCancel() {
75
+ p.cancel("Cancelled.");
76
+ process.exit(0);
77
+ },
78
+ },
79
+ );
80
+
81
+ const tags = result.tags
82
+ ? result.tags.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean)
83
+ : [];
84
+
85
+ const output = createPackage({
86
+ name: result.name,
87
+ type: result.type,
88
+ description: result.description ?? "",
89
+ author: result.author ?? "",
90
+ license: result.license || "MIT",
91
+ tags,
92
+ category: result.category,
14
93
  });
94
+
95
+ p.log.success(`Created ${output.files.join(", ")}`);
96
+ p.outro(`Edit ${output.files[1]}, then run \`planmode publish\` when ready.`);
15
97
  }
16
98
 
17
99
  export const initCommand = new Command("init")
18
100
  .description("Initialize a new package in the current directory")
19
101
  .action(async () => {
20
102
  try {
21
- logger.blank();
22
- logger.bold("Initialize a new Planmode package");
23
- logger.blank();
24
-
25
- const name = await prompt("Package name: ");
26
- if (!name) {
27
- logger.error("Package name is required.");
103
+ if (isInteractive()) {
104
+ await initInteractive();
105
+ } else {
106
+ // Non-interactive fallback: require all fields via env or fail
107
+ logger.error("Interactive terminal required for `planmode init`. Use a TTY.");
28
108
  process.exit(1);
29
109
  }
30
-
31
- const typeInput = await prompt("Type (plan/rule/prompt) [plan]: ");
32
- const type = (typeInput || "plan") as PackageType;
33
-
34
- const description = await prompt("Description: ");
35
- const author = await prompt("Author (GitHub username): ");
36
- const license = (await prompt("License [MIT]: ")) || "MIT";
37
- const tagsInput = await prompt("Tags (comma-separated): ");
38
- const tags = tagsInput
39
- ? tagsInput.split(",").map((t) => t.trim().toLowerCase())
40
- : [];
41
- const category =
42
- ((await prompt(
43
- "Category (frontend/backend/devops/database/testing/mobile/ai-ml/security/other) [other]: ",
44
- )) || "other") as Category;
45
-
46
- const result = createPackage({
47
- name,
48
- type,
49
- description,
50
- author,
51
- license,
52
- tags,
53
- category,
54
- });
55
-
56
- logger.success(`Created ${result.files.join(", ")}`);
57
- logger.blank();
58
- logger.info(
59
- `Edit ${result.files[1]}, then run \`planmode publish\` when ready.`,
60
- );
61
- logger.blank();
62
110
  } catch (err) {
63
111
  logger.error((err as Error).message);
64
112
  process.exit(1);
@@ -1,6 +1,8 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { installPackage } from "../lib/installer.js";
3
4
  import { logger } from "../lib/logger.js";
5
+ import { isInteractive } from "../lib/prompts.js";
4
6
 
5
7
  function parseVariables(pairs: string[]): Record<string, string> {
6
8
  const vars: Record<string, string> = {};
@@ -27,15 +29,28 @@ export const installCommand = new Command("install")
27
29
  options: { version?: string; rule?: boolean; input?: boolean; set?: string[] },
28
30
  ) => {
29
31
  try {
30
- logger.blank();
32
+ const interactive = isInteractive() && options.input !== false;
31
33
  const variables = options.set ? parseVariables(options.set) : undefined;
34
+
35
+ if (interactive) {
36
+ p.intro(`Installing ${packageName}`);
37
+ } else {
38
+ logger.blank();
39
+ }
40
+
32
41
  await installPackage(packageName, {
33
42
  version: options.version,
34
43
  forceRule: options.rule,
35
44
  noInput: options.input === false,
36
45
  variables,
46
+ interactive,
37
47
  });
38
- logger.blank();
48
+
49
+ if (interactive) {
50
+ p.outro("Done!");
51
+ } else {
52
+ logger.blank();
53
+ }
39
54
  } catch (err) {
40
55
  logger.error((err as Error).message);
41
56
  process.exit(1);
@@ -0,0 +1,449 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import * as p from "@clack/prompts";
5
+ import { handleCancel, withSpinner } from "../lib/prompts.js";
6
+ import { searchPackages, fetchPackageMetadata, fetchIndex } from "../lib/registry.js";
7
+ import { installPackage } from "../lib/installer.js";
8
+ import { readLockfile } from "../lib/lockfile.js";
9
+ import { runDoctor } from "../lib/doctor.js";
10
+ import type { PackageSummary } from "../types/index.js";
11
+
12
+ type Action =
13
+ | "search"
14
+ | "browse"
15
+ | "install"
16
+ | "create"
17
+ | "list"
18
+ | "doctor"
19
+ | "exit";
20
+
21
+ type FirstRunAction =
22
+ | "browse"
23
+ | "search"
24
+ | "create";
25
+
26
+ const CATEGORIES = [
27
+ "frontend",
28
+ "backend",
29
+ "devops",
30
+ "database",
31
+ "testing",
32
+ "mobile",
33
+ "ai-ml",
34
+ "design",
35
+ "security",
36
+ "other",
37
+ ] as const;
38
+
39
+ function isFirstRun(): boolean {
40
+ const configPath = path.join(os.homedir(), ".planmode", "config");
41
+ const hasConfig = fs.existsSync(configPath);
42
+ const lockfile = readLockfile();
43
+ const hasPackages = Object.keys(lockfile.packages).length > 0;
44
+ return !hasConfig && !hasPackages;
45
+ }
46
+
47
+ export async function runInteractiveMenu(): Promise<void> {
48
+ if (isFirstRun()) {
49
+ await firstRunFlow();
50
+ } else {
51
+ await mainMenu();
52
+ }
53
+ }
54
+
55
+ // ── First-run experience ──
56
+
57
+ async function firstRunFlow(): Promise<void> {
58
+ p.intro("planmode");
59
+
60
+ p.note(
61
+ [
62
+ "planmode installs AI plans, rules, and prompts into your project.",
63
+ "Plans work with Claude Code automatically via CLAUDE.md imports.",
64
+ "",
65
+ " Plans - step-by-step guides Claude follows to build things",
66
+ " Rules - always-on constraints that shape every AI interaction",
67
+ " Prompts - reusable templates you run once to get output",
68
+ ].join("\n"),
69
+ "Welcome",
70
+ );
71
+
72
+ const action = handleCancel(
73
+ await p.select<FirstRunAction>({
74
+ message: "Let's get you started. What would you like to do?",
75
+ options: [
76
+ { value: "browse" as FirstRunAction, label: "Browse popular packages", hint: "see what's available" },
77
+ { value: "search" as FirstRunAction, label: "Search for something specific" },
78
+ { value: "create" as FirstRunAction, label: "Create your own package", hint: "start from scratch" },
79
+ ],
80
+ }),
81
+ );
82
+
83
+ switch (action) {
84
+ case "browse":
85
+ await featuredFlow();
86
+ break;
87
+ case "search":
88
+ await searchFlow();
89
+ break;
90
+ case "create": {
91
+ const { initInteractive } = await import("./init.js");
92
+ await initInteractive();
93
+ break;
94
+ }
95
+ }
96
+
97
+ // After first action, drop into the regular menu
98
+ const cont = handleCancel(
99
+ await p.confirm({
100
+ message: "Continue exploring?",
101
+ initialValue: true,
102
+ }),
103
+ );
104
+
105
+ if (cont) {
106
+ await mainMenu();
107
+ } else {
108
+ p.outro("Run `planmode` anytime to come back.");
109
+ }
110
+ }
111
+
112
+ async function featuredFlow(): Promise<void> {
113
+ const index = await withSpinner(
114
+ "Loading packages...",
115
+ () => fetchIndex(),
116
+ );
117
+
118
+ // Curate: show a mix of types, pick the most useful-looking ones
119
+ const plans = index.packages.filter((pkg) => pkg.type === "plan");
120
+ const rules = index.packages.filter((pkg) => pkg.type === "rule");
121
+ const prompts = index.packages.filter((pkg) => pkg.type === "prompt");
122
+
123
+ // Build a featured list: up to 5 plans, 3 rules, 3 prompts
124
+ const featured: PackageSummary[] = [
125
+ ...plans.slice(0, 5),
126
+ ...rules.slice(0, 3),
127
+ ...prompts.slice(0, 3),
128
+ ];
129
+
130
+ if (featured.length === 0) {
131
+ p.log.warn("No packages in the registry yet.");
132
+ return;
133
+ }
134
+
135
+ // Group display
136
+ const planOptions = plans.slice(0, 5).map((pkg) => ({
137
+ value: pkg.name,
138
+ label: pkg.name,
139
+ hint: pkg.description.length > 55 ? pkg.description.slice(0, 55) + "..." : pkg.description,
140
+ }));
141
+ const ruleOptions = rules.slice(0, 3).map((pkg) => ({
142
+ value: pkg.name,
143
+ label: pkg.name,
144
+ hint: pkg.description.length > 55 ? pkg.description.slice(0, 55) + "..." : pkg.description,
145
+ }));
146
+ const promptOptions = prompts.slice(0, 3).map((pkg) => ({
147
+ value: pkg.name,
148
+ label: pkg.name,
149
+ hint: pkg.description.length > 55 ? pkg.description.slice(0, 55) + "..." : pkg.description,
150
+ }));
151
+
152
+ // Show all in one select with separator-style labels
153
+ const allOptions: { value: string; label: string; hint?: string }[] = [];
154
+
155
+ if (planOptions.length > 0) {
156
+ allOptions.push(...planOptions);
157
+ }
158
+ if (ruleOptions.length > 0) {
159
+ allOptions.push(...ruleOptions);
160
+ }
161
+ if (promptOptions.length > 0) {
162
+ allOptions.push(...promptOptions);
163
+ }
164
+
165
+ const selected = handleCancel(
166
+ await p.select({
167
+ message: `${index.packages.length} packages available. Pick one to install:`,
168
+ options: [
169
+ ...allOptions,
170
+ { value: "__more__", label: "Browse by category..." },
171
+ ],
172
+ }),
173
+ );
174
+
175
+ if (selected === "__more__") {
176
+ await browseFlow();
177
+ return;
178
+ }
179
+
180
+ await installOrDetailFlow(selected);
181
+ }
182
+
183
+ // ── Main menu (returning users) ──
184
+
185
+ async function mainMenu(): Promise<void> {
186
+ p.intro("planmode");
187
+
188
+ while (true) {
189
+ const action = handleCancel(
190
+ await p.select<Action>({
191
+ message: "What would you like to do?",
192
+ options: [
193
+ { value: "search" as Action, label: "Search packages", hint: "find packages by keyword" },
194
+ { value: "browse" as Action, label: "Browse by category" },
195
+ { value: "install" as Action, label: "Install a package", hint: "install by name" },
196
+ { value: "create" as Action, label: "Create a new package" },
197
+ { value: "list" as Action, label: "My installed packages" },
198
+ { value: "doctor" as Action, label: "Health check" },
199
+ { value: "exit" as Action, label: "Exit" },
200
+ ],
201
+ }),
202
+ );
203
+
204
+ switch (action) {
205
+ case "search":
206
+ await searchFlow();
207
+ break;
208
+ case "browse":
209
+ await browseFlow();
210
+ break;
211
+ case "install":
212
+ await installFlow();
213
+ break;
214
+ case "create": {
215
+ const { initInteractive } = await import("./init.js");
216
+ await initInteractive();
217
+ break;
218
+ }
219
+ case "list":
220
+ listFlow();
221
+ break;
222
+ case "doctor":
223
+ doctorFlow();
224
+ break;
225
+ case "exit":
226
+ p.outro("Goodbye!");
227
+ return;
228
+ }
229
+ }
230
+ }
231
+
232
+ // ── Shared flows ──
233
+
234
+ async function searchFlow(): Promise<void> {
235
+ const query = handleCancel(
236
+ await p.text({
237
+ message: "Search for packages:",
238
+ placeholder: "e.g. nextjs, tailwind, auth",
239
+ validate(input) {
240
+ if (!input) return "Please enter a search query";
241
+ },
242
+ }),
243
+ );
244
+
245
+ const results = await withSpinner(
246
+ "Searching registry...",
247
+ () => searchPackages(query),
248
+ );
249
+
250
+ if (results.length === 0) {
251
+ p.log.warn("No packages found matching your query.");
252
+ return;
253
+ }
254
+
255
+ p.log.info(`Found ${results.length} package(s)`);
256
+
257
+ await packageSelectionFlow(results.map((r) => ({
258
+ name: r.name,
259
+ type: r.type,
260
+ version: r.version,
261
+ description: r.description,
262
+ })));
263
+ }
264
+
265
+ async function browseFlow(): Promise<void> {
266
+ const category = handleCancel(
267
+ await p.select({
268
+ message: "Select a category:",
269
+ options: CATEGORIES.map((cat) => ({
270
+ value: cat,
271
+ label: cat,
272
+ })),
273
+ }),
274
+ );
275
+
276
+ const results = await withSpinner(
277
+ `Loading ${category} packages...`,
278
+ () => searchPackages("", { category }),
279
+ );
280
+
281
+ if (results.length === 0) {
282
+ p.log.warn(`No packages found in category "${category}".`);
283
+ return;
284
+ }
285
+
286
+ p.log.info(`Found ${results.length} package(s) in "${category}"`);
287
+
288
+ await packageSelectionFlow(results.map((r) => ({
289
+ name: r.name,
290
+ type: r.type,
291
+ version: r.version,
292
+ description: r.description,
293
+ })));
294
+ }
295
+
296
+ interface PackageOption {
297
+ name: string;
298
+ type: string;
299
+ version: string;
300
+ description: string;
301
+ }
302
+
303
+ async function packageSelectionFlow(packages: PackageOption[]): Promise<void> {
304
+ const selected = handleCancel(
305
+ await p.select({
306
+ message: "Select a package:",
307
+ options: [
308
+ ...packages.map((pkg) => ({
309
+ value: pkg.name,
310
+ label: `${pkg.name} (${pkg.type} v${pkg.version})`,
311
+ hint: pkg.description.length > 60
312
+ ? pkg.description.slice(0, 60) + "..."
313
+ : pkg.description,
314
+ })),
315
+ { value: "__back__", label: "Back" },
316
+ ],
317
+ }),
318
+ );
319
+
320
+ if (selected === "__back__") return;
321
+
322
+ await installOrDetailFlow(selected);
323
+ }
324
+
325
+ async function installOrDetailFlow(packageName: string): Promise<void> {
326
+ const action = handleCancel(
327
+ await p.select({
328
+ message: `${packageName}:`,
329
+ options: [
330
+ { value: "install", label: "Install" },
331
+ { value: "details", label: "View details" },
332
+ { value: "back", label: "Back" },
333
+ ],
334
+ }),
335
+ );
336
+
337
+ if (action === "install") {
338
+ try {
339
+ await installPackage(packageName, { interactive: true });
340
+ p.log.success(`Installed ${packageName}`);
341
+ } catch (err) {
342
+ p.log.error((err as Error).message);
343
+ }
344
+ } else if (action === "details") {
345
+ try {
346
+ const meta = await withSpinner(
347
+ "Fetching package details...",
348
+ () => fetchPackageMetadata(packageName),
349
+ );
350
+ const lines = [
351
+ `Type: ${meta.type}`,
352
+ `Author: ${meta.author}`,
353
+ `License: ${meta.license}`,
354
+ `Category: ${meta.category}`,
355
+ `Downloads: ${meta.downloads.toLocaleString()}`,
356
+ `Versions: ${meta.versions.join(", ")}`,
357
+ `Repository: ${meta.repository}`,
358
+ ];
359
+ if (meta.tags?.length) {
360
+ lines.push(`Tags: ${meta.tags.join(", ")}`);
361
+ }
362
+ if (meta.dependencies?.rules?.length) {
363
+ lines.push(`Dep (rules): ${meta.dependencies.rules.join(", ")}`);
364
+ }
365
+ if (meta.dependencies?.plans?.length) {
366
+ lines.push(`Dep (plans): ${meta.dependencies.plans.join(", ")}`);
367
+ }
368
+ p.note(lines.join("\n"), `${meta.name}@${meta.latest_version}`);
369
+
370
+ const nextAction = handleCancel(
371
+ await p.confirm({
372
+ message: "Install this package?",
373
+ initialValue: true,
374
+ }),
375
+ );
376
+
377
+ if (nextAction) {
378
+ try {
379
+ await installPackage(packageName, { interactive: true });
380
+ p.log.success(`Installed ${packageName}`);
381
+ } catch (err) {
382
+ p.log.error((err as Error).message);
383
+ }
384
+ }
385
+ } catch (err) {
386
+ p.log.error((err as Error).message);
387
+ }
388
+ }
389
+ }
390
+
391
+ async function installFlow(): Promise<void> {
392
+ const packageName = handleCancel(
393
+ await p.text({
394
+ message: "Package name to install:",
395
+ placeholder: "e.g. nextjs-tailwind-starter",
396
+ validate(input) {
397
+ if (!input) return "Please enter a package name";
398
+ },
399
+ }),
400
+ );
401
+
402
+ try {
403
+ await installPackage(packageName, { interactive: true });
404
+ p.log.success(`Installed ${packageName}`);
405
+ } catch (err) {
406
+ p.log.error((err as Error).message);
407
+ }
408
+ }
409
+
410
+ function listFlow(): void {
411
+ const lockfile = readLockfile();
412
+ const entries = Object.entries(lockfile.packages);
413
+
414
+ if (entries.length === 0) {
415
+ p.log.info("No packages installed. Select \"Install a package\" to get started.");
416
+ return;
417
+ }
418
+
419
+ const lines = entries.map(
420
+ ([name, entry]) =>
421
+ `${name} (${entry.type} v${entry.version}) -> ${entry.installed_to}`,
422
+ );
423
+ p.note(lines.join("\n"), "Installed packages");
424
+ }
425
+
426
+ function doctorFlow(): void {
427
+ const result = runDoctor();
428
+
429
+ if (result.issues.length === 0) {
430
+ p.log.success(`Checked ${result.packagesChecked} package(s) — no issues found.`);
431
+ return;
432
+ }
433
+
434
+ const errors = result.issues.filter((i) => i.severity === "error");
435
+ const warnings = result.issues.filter((i) => i.severity === "warning");
436
+
437
+ for (const issue of errors) {
438
+ p.log.error(issue.message);
439
+ }
440
+ for (const issue of warnings) {
441
+ p.log.warn(issue.message);
442
+ }
443
+
444
+ if (errors.length > 0) {
445
+ p.log.error(`${errors.length} error(s), ${warnings.length} warning(s)`);
446
+ } else {
447
+ p.log.warn(`${warnings.length} warning(s)`);
448
+ }
449
+ }