planmode 0.2.2 → 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,7 +1,9 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { execSync } from "node:child_process";
3
4
  import { setGitHubToken, getGitHubToken } from "../lib/config.js";
4
5
  import { logger } from "../lib/logger.js";
6
+ import { isInteractive, handleCancel, withSpinner } from "../lib/prompts.js";
5
7
 
6
8
  export const loginCommand = new Command("login")
7
9
  .description("Configure GitHub authentication")
@@ -19,16 +21,18 @@ export const loginCommand = new Command("login")
19
21
  logger.error("Failed to read token from GitHub CLI. Make sure `gh` is installed and authenticated.");
20
22
  process.exit(1);
21
23
  }
22
- } else {
23
- // Interactive prompt via stdin
24
- const { createInterface } = await import("node:readline");
25
- const rl = createInterface({ input: process.stdin, output: process.stdout });
26
- token = await new Promise<string>((resolve) => {
27
- rl.question("GitHub personal access token: ", (answer) => {
28
- rl.close();
29
- resolve(answer.trim());
30
- });
24
+ } else if (isInteractive()) {
25
+ p.intro("planmode login");
26
+ const value = await p.password({
27
+ message: "GitHub personal access token:",
28
+ validate(input) {
29
+ if (!input) return "Token is required";
30
+ },
31
31
  });
32
+ token = handleCancel(value);
33
+ } else {
34
+ logger.error("No token provided. Use --token <token> or --gh.");
35
+ process.exit(1);
32
36
  }
33
37
 
34
38
  if (!token) {
@@ -37,20 +41,43 @@ export const loginCommand = new Command("login")
37
41
  }
38
42
 
39
43
  // Validate token
40
- logger.info("Validating token...");
41
- const response = await fetch("https://api.github.com/user", {
42
- headers: {
43
- Authorization: `Bearer ${token}`,
44
- "User-Agent": "planmode-cli",
45
- },
46
- });
47
-
48
- if (!response.ok) {
49
- logger.error("Invalid token. GitHub API returned: " + response.status);
44
+ const validateToken = async () => {
45
+ const response = await fetch("https://api.github.com/user", {
46
+ headers: {
47
+ Authorization: `Bearer ${token}`,
48
+ "User-Agent": "planmode-cli",
49
+ },
50
+ });
51
+
52
+ if (!response.ok) {
53
+ throw new Error("Invalid token. GitHub API returned: " + response.status);
54
+ }
55
+
56
+ return (await response.json()) as { login: string };
57
+ };
58
+
59
+ try {
60
+ const user = await withSpinner(
61
+ "Validating token...",
62
+ validateToken,
63
+ "Token validated",
64
+ );
65
+
66
+ setGitHubToken(token);
67
+
68
+ if (isInteractive()) {
69
+ p.log.success(`Authenticated as ${user.login}`);
70
+ p.outro("You're all set!");
71
+ } else {
72
+ logger.success(`Authenticated as ${user.login}`);
73
+ }
74
+ } catch (err) {
75
+ if (isInteractive()) {
76
+ p.log.error((err as Error).message);
77
+ p.outro("Authentication failed.");
78
+ } else {
79
+ logger.error((err as Error).message);
80
+ }
50
81
  process.exit(1);
51
82
  }
52
-
53
- const user = (await response.json()) as { login: string };
54
- setGitHubToken(token);
55
- logger.success(`Authenticated as ${user.login}`);
56
83
  });
@@ -1,14 +1,26 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { publishPackage } from "../lib/publisher.js";
3
4
  import { logger } from "../lib/logger.js";
5
+ import { isInteractive } from "../lib/prompts.js";
4
6
 
5
7
  export const publishCommand = new Command("publish")
6
8
  .description("Publish the current directory as a package to the registry")
7
9
  .action(async () => {
8
10
  try {
9
- logger.blank();
10
- const result = await publishPackage();
11
- logger.blank();
11
+ if (isInteractive()) {
12
+ p.intro("Publishing package");
13
+ } else {
14
+ logger.blank();
15
+ }
16
+
17
+ const result = await publishPackage({ interactive: isInteractive() });
18
+
19
+ if (isInteractive()) {
20
+ p.outro(`Published ${result.packageName}@${result.version} — PR: ${result.prUrl}`);
21
+ } else {
22
+ logger.blank();
23
+ }
12
24
  } catch (err) {
13
25
  logger.error((err as Error).message);
14
26
  process.exit(1);
@@ -1,8 +1,10 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { startRecordingAsync, stopRecording, isRecording } from "../lib/recorder.js";
5
6
  import { logger } from "../lib/logger.js";
7
+ import { isInteractive, withSpinner } from "../lib/prompts.js";
6
8
 
7
9
  export const recordCommand = new Command("record")
8
10
  .description("Record git activity and generate a plan from commits");
@@ -31,13 +33,30 @@ recordCommand
31
33
  .option("--dir <dir>", "Output directory for the generated package (default: current directory)")
32
34
  .action(async (options: { name?: string; author?: string; dir?: string }) => {
33
35
  try {
34
- logger.blank();
35
- logger.info("Analyzing commits...");
36
+ const interactive = isInteractive();
37
+
38
+ if (interactive) {
39
+ p.intro("Generating plan from recording");
40
+ } else {
41
+ logger.blank();
42
+ }
36
43
 
37
- const result = await stopRecording(process.cwd(), {
38
- name: options.name,
39
- author: options.author,
40
- });
44
+ const result = interactive
45
+ ? await withSpinner(
46
+ "Analyzing commits...",
47
+ () => stopRecording(process.cwd(), {
48
+ name: options.name,
49
+ author: options.author,
50
+ }),
51
+ "Analysis complete",
52
+ )
53
+ : await (async () => {
54
+ logger.info("Analyzing commits...");
55
+ return stopRecording(process.cwd(), {
56
+ name: options.name,
57
+ author: options.author,
58
+ });
59
+ })();
41
60
 
42
61
  // Write to output directory
43
62
  const outDir = options.dir ?? process.cwd();
@@ -56,8 +75,13 @@ recordCommand
56
75
 
57
76
  logger.blank();
58
77
  logger.success("Created planmode.yaml and plan.md");
59
- logger.dim("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
60
- logger.blank();
78
+
79
+ if (interactive) {
80
+ p.outro("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
81
+ } else {
82
+ logger.dim("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
83
+ logger.blank();
84
+ }
61
85
  } catch (err) {
62
86
  logger.error((err as Error).message);
63
87
  process.exit(1);
@@ -2,8 +2,9 @@ import { Command } from "commander";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parseManifest, readPackageContent } from "../lib/manifest.js";
5
- import { renderTemplate, collectVariableValues, resolveVariable } from "../lib/template.js";
5
+ import { renderTemplate, resolveVariable } from "../lib/template.js";
6
6
  import { logger } from "../lib/logger.js";
7
+ import { isInteractive, promptForVariables } from "../lib/prompts.js";
7
8
 
8
9
  export const runCommand = new Command("run")
9
10
  .description("Run a templated prompt and output to stdout")
@@ -49,22 +50,12 @@ export const runCommand = new Command("run")
49
50
 
50
51
  // Resolve variables
51
52
  if (manifest?.variables && Object.keys(manifest.variables).length > 0) {
52
- const values: Record<string, string | number | boolean> = {};
53
+ const noInput = options.input === false;
53
54
 
54
- // First pass: resolve non-resolved variables
55
- for (const [name, def] of Object.entries(manifest.variables)) {
56
- if (def.type === "resolved") continue;
57
- if (vars[name] !== undefined) {
58
- values[name] = vars[name]!;
59
- } else if (def.default !== undefined) {
60
- values[name] = def.default;
61
- } else if (def.required && options.input === false) {
62
- logger.error(`Missing required variable: --${name}`);
63
- process.exit(1);
64
- }
65
- }
55
+ // Collect non-resolved variables (interactive or from flags/defaults)
56
+ const values = await promptForVariables(manifest.variables, vars, noInput);
66
57
 
67
- // Second pass: resolve dynamic variables
58
+ // Resolve dynamic variables
68
59
  for (const [name, def] of Object.entries(manifest.variables)) {
69
60
  if (def.type !== "resolved") continue;
70
61
  values[name] = await resolveVariable(def, values);
@@ -1,6 +1,9 @@
1
1
  import { Command } from "commander";
2
- import { searchPackages } from "../lib/registry.js";
2
+ import * as p from "@clack/prompts";
3
+ import { searchPackages, fetchPackageMetadata } from "../lib/registry.js";
4
+ import { installPackage } from "../lib/installer.js";
3
5
  import { logger } from "../lib/logger.js";
6
+ import { isInteractive, handleCancel, withSpinner } from "../lib/prompts.js";
4
7
 
5
8
  export const searchCommand = new Command("search")
6
9
  .description("Search the registry for packages")
@@ -10,13 +13,20 @@ export const searchCommand = new Command("search")
10
13
  .option("--json", "Output as JSON")
11
14
  .action(async (query: string, options: { type?: string; category?: string; json?: boolean }) => {
12
15
  try {
13
- const results = await searchPackages(query, {
14
- type: options.type,
15
- category: options.category,
16
- });
16
+ const results = await withSpinner(
17
+ "Searching registry...",
18
+ () => searchPackages(query, {
19
+ type: options.type,
20
+ category: options.category,
21
+ }),
22
+ );
17
23
 
18
24
  if (results.length === 0) {
19
- logger.info("No packages found matching your query.");
25
+ if (isInteractive() && !options.json) {
26
+ p.log.warn("No packages found matching your query.");
27
+ } else {
28
+ logger.info("No packages found matching your query.");
29
+ }
20
30
  return;
21
31
  }
22
32
 
@@ -25,19 +35,80 @@ export const searchCommand = new Command("search")
25
35
  return;
26
36
  }
27
37
 
28
- logger.blank();
29
- logger.table(
30
- ["name", "type", "version", "description"],
31
- results.map((pkg) => [
32
- pkg.name,
33
- pkg.type,
34
- pkg.version,
35
- pkg.description.length > 50
36
- ? pkg.description.slice(0, 50) + "..."
37
- : pkg.description,
38
- ]),
38
+ // Non-interactive: just show the table
39
+ if (!isInteractive()) {
40
+ logger.blank();
41
+ logger.table(
42
+ ["name", "type", "version", "description"],
43
+ results.map((pkg) => [
44
+ pkg.name,
45
+ pkg.type,
46
+ pkg.version,
47
+ pkg.description.length > 50
48
+ ? pkg.description.slice(0, 50) + "..."
49
+ : pkg.description,
50
+ ]),
51
+ );
52
+ logger.blank();
53
+ return;
54
+ }
55
+
56
+ // Interactive: let user select a package
57
+ const selected = handleCancel(
58
+ await p.select({
59
+ message: `Found ${results.length} package(s). Select one:`,
60
+ options: [
61
+ ...results.map((pkg) => ({
62
+ value: pkg.name,
63
+ label: `${pkg.name} (${pkg.type} v${pkg.version})`,
64
+ hint: pkg.description.length > 60
65
+ ? pkg.description.slice(0, 60) + "..."
66
+ : pkg.description,
67
+ })),
68
+ { value: "__none__", label: "Cancel" },
69
+ ],
70
+ }),
39
71
  );
40
- logger.blank();
72
+
73
+ if (selected === "__none__") return;
74
+
75
+ const action = handleCancel(
76
+ await p.select({
77
+ message: `${selected}:`,
78
+ options: [
79
+ { value: "install", label: "Install" },
80
+ { value: "details", label: "View details" },
81
+ { value: "back", label: "Cancel" },
82
+ ],
83
+ }),
84
+ );
85
+
86
+ if (action === "install") {
87
+ try {
88
+ await installPackage(selected, { interactive: true });
89
+ p.log.success(`Installed ${selected}`);
90
+ } catch (err) {
91
+ p.log.error((err as Error).message);
92
+ }
93
+ } else if (action === "details") {
94
+ const meta = await withSpinner(
95
+ "Fetching package details...",
96
+ () => fetchPackageMetadata(selected),
97
+ );
98
+ const lines = [
99
+ `Type: ${meta.type}`,
100
+ `Author: ${meta.author}`,
101
+ `License: ${meta.license}`,
102
+ `Category: ${meta.category}`,
103
+ `Downloads: ${meta.downloads.toLocaleString()}`,
104
+ `Versions: ${meta.versions.join(", ")}`,
105
+ `Repository: ${meta.repository}`,
106
+ ];
107
+ if (meta.tags?.length) {
108
+ lines.push(`Tags: ${meta.tags.join(", ")}`);
109
+ }
110
+ p.note(lines.join("\n"), `${meta.name}@${meta.latest_version}`);
111
+ }
41
112
  } catch (err) {
42
113
  logger.error((err as Error).message);
43
114
  process.exit(1);
@@ -1,23 +1,42 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { takeSnapshot } from "../lib/snapshot.js";
5
6
  import { logger } from "../lib/logger.js";
7
+ import { isInteractive, withSpinner } from "../lib/prompts.js";
6
8
 
7
9
  export const snapshotCommand = new Command("snapshot")
8
10
  .description("Analyze the current project and generate a plan that recreates this setup")
9
11
  .option("--name <name>", "Package name (auto-inferred from project)")
10
12
  .option("--author <author>", "Author GitHub username")
11
13
  .option("--dir <dir>", "Output directory for the generated package (default: current directory)")
12
- .action((options: { name?: string; author?: string; dir?: string }) => {
14
+ .action(async (options: { name?: string; author?: string; dir?: string }) => {
13
15
  try {
14
- logger.blank();
15
- logger.info("Analyzing project...");
16
+ const interactive = isInteractive();
17
+
18
+ if (interactive) {
19
+ p.intro("Taking project snapshot");
20
+ } else {
21
+ logger.blank();
22
+ }
23
+
24
+ const doSnapshot = () => Promise.resolve(
25
+ takeSnapshot(process.cwd(), {
26
+ name: options.name,
27
+ author: options.author,
28
+ }),
29
+ );
16
30
 
17
- const result = takeSnapshot(process.cwd(), {
18
- name: options.name,
19
- author: options.author,
20
- });
31
+ const result = interactive
32
+ ? await withSpinner("Analyzing project...", doSnapshot, "Analysis complete")
33
+ : (() => {
34
+ logger.info("Analyzing project...");
35
+ return takeSnapshot(process.cwd(), {
36
+ name: options.name,
37
+ author: options.author,
38
+ });
39
+ })();
21
40
 
22
41
  // Write to output directory
23
42
  const outDir = options.dir ?? process.cwd();
@@ -37,8 +56,13 @@ export const snapshotCommand = new Command("snapshot")
37
56
 
38
57
  logger.blank();
39
58
  logger.success("Created planmode.yaml and plan.md");
40
- logger.dim("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
41
- logger.blank();
59
+
60
+ if (interactive) {
61
+ p.outro("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
62
+ } else {
63
+ logger.dim("Edit the generated plan, then run `planmode test` to validate and `planmode publish` when ready.");
64
+ logger.blank();
65
+ }
42
66
  } catch (err) {
43
67
  logger.error((err as Error).message);
44
68
  process.exit(1);
@@ -1,39 +1,69 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { testPackage } from "../lib/tester.js";
3
4
  import { logger } from "../lib/logger.js";
5
+ import { isInteractive } from "../lib/prompts.js";
4
6
 
5
7
  export const testCommand = new Command("test")
6
8
  .description("Test the current package before publishing: validate manifest, render templates, check dependencies")
7
9
  .action(async () => {
8
10
  try {
9
- logger.blank();
10
- logger.bold("Testing package...");
11
- logger.blank();
11
+ const interactive = isInteractive();
12
+
13
+ if (interactive) {
14
+ p.intro("Testing package");
15
+ } else {
16
+ logger.blank();
17
+ logger.bold("Testing package...");
18
+ logger.blank();
19
+ }
12
20
 
13
21
  const result = await testPackage();
14
22
 
15
23
  for (const check of result.checks) {
16
24
  if (check.passed) {
17
- logger.success(check.name);
25
+ if (interactive) {
26
+ p.log.success(check.name);
27
+ } else {
28
+ logger.success(check.name);
29
+ }
18
30
  } else {
19
31
  const issue = result.issues.find((i) => i.check === check.name);
20
32
  if (issue?.severity === "error") {
21
- logger.error(`${check.name}: ${issue.message}`);
33
+ if (interactive) {
34
+ p.log.error(`${check.name}: ${issue.message}`);
35
+ } else {
36
+ logger.error(`${check.name}: ${issue.message}`);
37
+ }
22
38
  } else if (issue) {
23
- logger.warn(`${check.name}: ${issue.message}`);
39
+ if (interactive) {
40
+ p.log.warn(`${check.name}: ${issue.message}`);
41
+ } else {
42
+ logger.warn(`${check.name}: ${issue.message}`);
43
+ }
24
44
  }
25
45
  }
26
46
  }
27
47
 
28
- logger.blank();
29
- if (result.passed) {
30
- logger.success(`All checks passed. Ready to publish.`);
48
+ if (interactive) {
49
+ if (result.passed) {
50
+ p.outro("All checks passed. Ready to publish.");
51
+ } else {
52
+ const errors = result.issues.filter((i) => i.severity === "error");
53
+ const warnings = result.issues.filter((i) => i.severity === "warning");
54
+ p.outro(`${errors.length} error(s), ${warnings.length} warning(s). Fix errors before publishing.`);
55
+ }
31
56
  } else {
32
- const errors = result.issues.filter((i) => i.severity === "error");
33
- const warnings = result.issues.filter((i) => i.severity === "warning");
34
- logger.error(`${errors.length} error(s), ${warnings.length} warning(s). Fix errors before publishing.`);
57
+ logger.blank();
58
+ if (result.passed) {
59
+ logger.success(`All checks passed. Ready to publish.`);
60
+ } else {
61
+ const errors = result.issues.filter((i) => i.severity === "error");
62
+ const warnings = result.issues.filter((i) => i.severity === "warning");
63
+ logger.error(`${errors.length} error(s), ${warnings.length} warning(s). Fix errors before publishing.`);
64
+ }
65
+ logger.blank();
35
66
  }
36
- logger.blank();
37
67
 
38
68
  if (!result.passed) {
39
69
  process.exit(1);
@@ -1,47 +1,89 @@
1
1
  import { Command } from "commander";
2
+ import * as p from "@clack/prompts";
2
3
  import { updatePackage } from "../lib/installer.js";
3
4
  import { readLockfile } from "../lib/lockfile.js";
4
5
  import { logger } from "../lib/logger.js";
6
+ import { isInteractive, withSpinner } from "../lib/prompts.js";
5
7
 
6
8
  export const updateCommand = new Command("update")
7
9
  .description("Update installed packages to latest compatible versions")
8
10
  .argument("[package]", "Package name (omit to update all)")
9
11
  .action(async (packageName?: string) => {
10
12
  try {
11
- logger.blank();
13
+ const interactive = isInteractive();
14
+
15
+ if (interactive) {
16
+ p.intro("Updating packages");
17
+ } else {
18
+ logger.blank();
19
+ }
12
20
 
13
21
  if (packageName) {
14
- const updated = await updatePackage(packageName);
22
+ const updated = interactive
23
+ ? await withSpinner(
24
+ `Checking ${packageName} for updates...`,
25
+ () => updatePackage(packageName),
26
+ )
27
+ : await updatePackage(packageName);
28
+
15
29
  if (!updated) {
16
- logger.info("Already up to date.");
30
+ if (interactive) {
31
+ p.log.info("Already up to date.");
32
+ } else {
33
+ logger.info("Already up to date.");
34
+ }
17
35
  }
18
36
  } else {
19
37
  const lockfile = readLockfile();
20
38
  const names = Object.keys(lockfile.packages);
21
39
 
22
40
  if (names.length === 0) {
23
- logger.info("No packages installed.");
41
+ if (interactive) {
42
+ p.log.info("No packages installed.");
43
+ } else {
44
+ logger.info("No packages installed.");
45
+ }
46
+ if (interactive) p.outro("Nothing to update.");
24
47
  return;
25
48
  }
26
49
 
27
- let updatedCount = 0;
28
- for (const name of names) {
29
- try {
30
- const updated = await updatePackage(name);
31
- if (updated) updatedCount++;
32
- } catch (err) {
33
- logger.warn(`Failed to update ${name}: ${(err as Error).message}`);
50
+ const doUpdate = async () => {
51
+ let updatedCount = 0;
52
+ for (const name of names) {
53
+ try {
54
+ const updated = await updatePackage(name);
55
+ if (updated) updatedCount++;
56
+ } catch (err) {
57
+ logger.warn(`Failed to update ${name}: ${(err as Error).message}`);
58
+ }
34
59
  }
35
- }
60
+ return updatedCount;
61
+ };
62
+
63
+ const updatedCount = interactive
64
+ ? await withSpinner("Checking for updates...", doUpdate)
65
+ : await doUpdate();
36
66
 
37
67
  if (updatedCount === 0) {
38
- logger.info("All packages are up to date.");
68
+ if (interactive) {
69
+ p.log.info("All packages are up to date.");
70
+ } else {
71
+ logger.info("All packages are up to date.");
72
+ }
39
73
  } else {
40
- logger.success(`Updated ${updatedCount} package${updatedCount > 1 ? "s" : ""}.`);
74
+ if (interactive) {
75
+ p.log.success(`Updated ${updatedCount} package${updatedCount > 1 ? "s" : ""}.`);
76
+ } else {
77
+ logger.success(`Updated ${updatedCount} package${updatedCount > 1 ? "s" : ""}.`);
78
+ }
41
79
  }
42
80
  }
43
81
 
44
- logger.blank();
82
+ if (interactive) {
83
+ p.outro("Done!");
84
+ } else {
85
+ logger.blank();
86
+ }
45
87
  } catch (err) {
46
88
  logger.error((err as Error).message);
47
89
  process.exit(1);
package/src/index.ts CHANGED
@@ -14,13 +14,14 @@ import { doctorCommand } from "./commands/doctor.js";
14
14
  import { testCommand } from "./commands/test.js";
15
15
  import { recordCommand } from "./commands/record.js";
16
16
  import { snapshotCommand } from "./commands/snapshot.js";
17
+ import { isInteractive } from "./lib/prompts.js";
17
18
 
18
19
  const program = new Command();
19
20
 
20
21
  program
21
22
  .name("planmode")
22
23
  .description("The open source package manager for AI plans, rules, and prompts.")
23
- .version("0.2.2");
24
+ .version("0.3.0");
24
25
 
25
26
  program.addCommand(installCommand);
26
27
  program.addCommand(uninstallCommand);
@@ -38,4 +39,10 @@ program.addCommand(testCommand);
38
39
  program.addCommand(recordCommand);
39
40
  program.addCommand(snapshotCommand);
40
41
 
41
- program.parse();
42
+ // If no args and interactive TTY, show the interactive menu
43
+ if (process.argv.length <= 2 && isInteractive()) {
44
+ const { runInteractiveMenu } = await import("./commands/interactive.js");
45
+ runInteractiveMenu();
46
+ } else {
47
+ program.parse();
48
+ }