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.
@@ -11,6 +11,7 @@ import { parseManifest, readPackageContent } from "./manifest.js";
11
11
  import { renderTemplate, collectVariableValues } from "./template.js";
12
12
  import { logger } from "./logger.js";
13
13
  import { trackDownload } from "./analytics.js";
14
+ import { isInteractive, promptForVariables, withSpinner } from "./prompts.js";
14
15
 
15
16
  function getInstallDir(type: PackageType): string {
16
17
  switch (type) {
@@ -37,6 +38,7 @@ export interface InstallOptions {
37
38
  noInput?: boolean;
38
39
  variables?: Record<string, string>;
39
40
  projectDir?: string;
41
+ interactive?: boolean;
40
42
  }
41
43
 
42
44
  export async function installPackage(
@@ -44,6 +46,7 @@ export async function installPackage(
44
46
  options: InstallOptions = {},
45
47
  ): Promise<void> {
46
48
  const projectDir = options.projectDir ?? process.cwd();
49
+ const interactive = options.interactive ?? (isInteractive() && !options.noInput);
47
50
 
48
51
  // Check lockfile first
49
52
  const locked = getLockedVersion(packageName, projectDir);
@@ -53,44 +56,68 @@ export async function installPackage(
53
56
  }
54
57
 
55
58
  // Resolve version
56
- logger.info(`Resolving ${packageName}...`);
57
- const { version, metadata } = await resolveVersion(packageName, options.version);
58
-
59
- // Fetch version metadata
60
- const versionMeta = await fetchVersionMetadata(packageName, version);
61
-
62
- // Fetch manifest
63
- logger.info(`Fetching ${packageName}@${version}...`);
64
- const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
65
- const manifestRaw = await fetchFileAtTag(
66
- versionMeta.source.repository,
67
- versionMeta.source.tag,
68
- `${basePath}planmode.yaml`,
69
- );
70
- const manifest = parseManifest(manifestRaw);
71
-
72
- // Fetch content
73
- let content: string;
74
- if (manifest.content) {
75
- content = manifest.content;
76
- } else if (manifest.content_file) {
77
- content = await fetchFileAtTag(
59
+ const resolveAndFetch = async () => {
60
+ const { version, metadata } = await resolveVersion(packageName, options.version);
61
+ const versionMeta = await fetchVersionMetadata(packageName, version);
62
+ return { version, metadata, versionMeta };
63
+ };
64
+
65
+ const { version, metadata, versionMeta } = interactive
66
+ ? await withSpinner(
67
+ `Resolving ${packageName}...`,
68
+ resolveAndFetch,
69
+ `Resolved ${packageName}`,
70
+ )
71
+ : await (async () => {
72
+ logger.info(`Resolving ${packageName}...`);
73
+ return resolveAndFetch();
74
+ })();
75
+
76
+ // Fetch manifest and content
77
+ const fetchContent = async () => {
78
+ const basePath = versionMeta.source.path ? `${versionMeta.source.path}/` : "";
79
+ const manifestRaw = await fetchFileAtTag(
78
80
  versionMeta.source.repository,
79
81
  versionMeta.source.tag,
80
- `${basePath}${manifest.content_file}`,
82
+ `${basePath}planmode.yaml`,
81
83
  );
82
- } else {
83
- throw new Error("Package has no content or content_file");
84
- }
84
+ const manifest = parseManifest(manifestRaw);
85
+
86
+ let content: string;
87
+ if (manifest.content) {
88
+ content = manifest.content;
89
+ } else if (manifest.content_file) {
90
+ content = await fetchFileAtTag(
91
+ versionMeta.source.repository,
92
+ versionMeta.source.tag,
93
+ `${basePath}${manifest.content_file}`,
94
+ );
95
+ } else {
96
+ throw new Error("Package has no content or content_file");
97
+ }
98
+
99
+ return { manifest, content };
100
+ };
101
+
102
+ const { manifest, content: rawContent } = interactive
103
+ ? await withSpinner(
104
+ `Fetching ${packageName}@${version}...`,
105
+ fetchContent,
106
+ `Fetched ${packageName}@${version}`,
107
+ )
108
+ : await (async () => {
109
+ logger.info(`Fetching ${packageName}@${version}...`);
110
+ return fetchContent();
111
+ })();
85
112
 
86
113
  // Process variables if templated
114
+ let content = rawContent;
87
115
  if (manifest.variables && Object.keys(manifest.variables).length > 0) {
88
116
  const provided = options.variables ?? {};
89
- if (options.noInput) {
90
- const values = collectVariableValues(manifest.variables, provided);
117
+ if (interactive) {
118
+ const values = await promptForVariables(manifest.variables, provided, false);
91
119
  content = renderTemplate(content, values);
92
120
  } else {
93
- // Use defaults for non-provided values
94
121
  const values = collectVariableValues(manifest.variables, provided);
95
122
  content = renderTemplate(content, values);
96
123
  }
@@ -170,6 +197,7 @@ export async function installPackage(
170
197
  version: range === "*" ? undefined : range,
171
198
  projectDir,
172
199
  noInput: options.noInput,
200
+ interactive: options.interactive,
173
201
  });
174
202
  }
175
203
  }
@@ -0,0 +1,159 @@
1
+ import * as p from "@clack/prompts";
2
+ import type { VariableDefinition } from "../types/index.js";
3
+
4
+ /**
5
+ * Returns true if the CLI is running in an interactive terminal.
6
+ * False when piped, in CI, or when --no-input is set.
7
+ */
8
+ export function isInteractive(): boolean {
9
+ return Boolean(process.stdin.isTTY) && !process.env.CI;
10
+ }
11
+
12
+ /**
13
+ * Wraps a clack prompt result — if the user cancels (Ctrl+C),
14
+ * prints a cancel message and exits cleanly.
15
+ */
16
+ export function handleCancel<T>(value: T | symbol): T {
17
+ if (p.isCancel(value)) {
18
+ p.cancel("Cancelled.");
19
+ process.exit(0);
20
+ }
21
+ return value as T;
22
+ }
23
+
24
+ /**
25
+ * Prompts for a single manifest variable using the appropriate clack widget.
26
+ */
27
+ async function promptForVariable(
28
+ name: string,
29
+ def: VariableDefinition,
30
+ ): Promise<string | number | boolean> {
31
+ switch (def.type) {
32
+ case "enum": {
33
+ const value = await p.select({
34
+ message: def.description || name,
35
+ options: (def.options ?? []).map((opt) => ({
36
+ value: opt,
37
+ label: opt,
38
+ })),
39
+ initialValue: def.default !== undefined ? String(def.default) : undefined,
40
+ });
41
+ return handleCancel(value);
42
+ }
43
+
44
+ case "boolean": {
45
+ const value = await p.confirm({
46
+ message: def.description || name,
47
+ initialValue: def.default !== undefined ? Boolean(def.default) : false,
48
+ });
49
+ return handleCancel(value);
50
+ }
51
+
52
+ case "number": {
53
+ const value = await p.text({
54
+ message: def.description || name,
55
+ placeholder: def.default !== undefined ? String(def.default) : undefined,
56
+ defaultValue: def.default !== undefined ? String(def.default) : undefined,
57
+ validate(input) {
58
+ if (isNaN(Number(input))) return "Must be a number";
59
+ },
60
+ });
61
+ return Number(handleCancel(value));
62
+ }
63
+
64
+ case "string":
65
+ default: {
66
+ const value = await p.text({
67
+ message: def.description || name,
68
+ placeholder: def.default !== undefined ? String(def.default) : undefined,
69
+ defaultValue: def.default !== undefined ? String(def.default) : undefined,
70
+ validate(input) {
71
+ if (def.required && !input) return `${name} is required`;
72
+ },
73
+ });
74
+ return handleCancel(value);
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Collects all missing variable values interactively.
81
+ * Merges with already-provided values.
82
+ * Falls back to defaults or throws if not interactive.
83
+ */
84
+ export async function promptForVariables(
85
+ variableDefs: Record<string, VariableDefinition>,
86
+ provided: Record<string, string>,
87
+ noInput: boolean = false,
88
+ ): Promise<Record<string, string | number | boolean>> {
89
+ const values: Record<string, string | number | boolean> = {};
90
+
91
+ for (const [name, def] of Object.entries(variableDefs)) {
92
+ if (def.type === "resolved") continue;
93
+
94
+ if (provided[name] !== undefined) {
95
+ values[name] = coerceValue(provided[name]!, def);
96
+ } else if (def.default !== undefined) {
97
+ if (isInteractive() && !noInput) {
98
+ // In interactive mode, let user confirm/change defaults
99
+ values[name] = await promptForVariable(name, def);
100
+ } else {
101
+ values[name] = def.default;
102
+ }
103
+ } else if (def.required) {
104
+ if (isInteractive() && !noInput) {
105
+ values[name] = await promptForVariable(name, def);
106
+ } else {
107
+ throw new Error(`Missing required variable: ${name} -- ${def.description}`);
108
+ }
109
+ }
110
+ }
111
+
112
+ return values;
113
+ }
114
+
115
+ function coerceValue(
116
+ raw: string,
117
+ def: VariableDefinition,
118
+ ): string | number | boolean {
119
+ switch (def.type) {
120
+ case "number":
121
+ return Number(raw);
122
+ case "boolean":
123
+ return raw === "true" || raw === "1" || raw === "yes";
124
+ case "enum":
125
+ if (def.options && !def.options.includes(raw)) {
126
+ throw new Error(
127
+ `Invalid value "${raw}" for enum variable. Options: ${def.options.join(", ")}`,
128
+ );
129
+ }
130
+ return raw;
131
+ default:
132
+ return raw;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Wraps an async operation with a clack spinner.
138
+ * Only shows spinner when interactive.
139
+ */
140
+ export async function withSpinner<T>(
141
+ message: string,
142
+ fn: () => Promise<T>,
143
+ successMessage?: string,
144
+ ): Promise<T> {
145
+ if (!isInteractive()) {
146
+ return fn();
147
+ }
148
+
149
+ const s = p.spinner();
150
+ s.start(message);
151
+ try {
152
+ const result = await fn();
153
+ s.stop(successMessage ?? message);
154
+ return result;
155
+ } catch (err) {
156
+ s.stop(`Failed: ${message}`);
157
+ throw err;
158
+ }
159
+ }
@@ -2,10 +2,12 @@ import { readManifest, validateManifest } from "./manifest.js";
2
2
  import { getGitHubToken } from "./config.js";
3
3
  import { getRemoteUrl, getHeadSha, createTag, pushTag } from "./git.js";
4
4
  import { logger } from "./logger.js";
5
+ import { withSpinner } from "./prompts.js";
5
6
 
6
7
  export interface PublishOptions {
7
8
  projectDir?: string;
8
9
  token?: string;
10
+ interactive?: boolean;
9
11
  }
10
12
 
11
13
  export interface PublishResult {
@@ -16,6 +18,7 @@ export interface PublishResult {
16
18
 
17
19
  export async function publishPackage(options: PublishOptions = {}): Promise<PublishResult> {
18
20
  const cwd = options.projectDir ?? process.cwd();
21
+ const interactive = options.interactive ?? false;
19
22
 
20
23
  // Check auth
21
24
  const token = options.token ?? getGitHubToken();
@@ -24,12 +27,21 @@ export async function publishPackage(options: PublishOptions = {}): Promise<Publ
24
27
  }
25
28
 
26
29
  // Read and validate manifest
27
- logger.info("Reading planmode.yaml...");
28
- const manifest = readManifest(cwd);
29
- const errors = validateManifest(manifest, true);
30
- if (errors.length > 0) {
31
- throw new Error(`Invalid manifest:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
32
- }
30
+ const doValidate = async () => {
31
+ const manifest = readManifest(cwd);
32
+ const errors = validateManifest(manifest, true);
33
+ if (errors.length > 0) {
34
+ throw new Error(`Invalid manifest:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
35
+ }
36
+ return manifest;
37
+ };
38
+
39
+ const manifest = interactive
40
+ ? await withSpinner("Validating manifest...", doValidate, "Manifest valid")
41
+ : await (async () => {
42
+ logger.info("Reading planmode.yaml...");
43
+ return doValidate();
44
+ })();
33
45
 
34
46
  // Check git remote
35
47
  const remoteUrl = await getRemoteUrl(cwd);
@@ -41,162 +53,182 @@ export async function publishPackage(options: PublishOptions = {}): Promise<Publ
41
53
  const tag = `v${manifest.version}`;
42
54
 
43
55
  // Create and push tag
44
- logger.info(`Creating tag ${tag}...`);
45
- try {
46
- await createTag(cwd, tag);
47
- } catch {
48
- logger.dim(`Tag ${tag} already exists, using existing`);
49
- }
56
+ const doTag = async () => {
57
+ try {
58
+ await createTag(cwd, tag);
59
+ } catch {
60
+ // Tag already exists
61
+ }
62
+ try {
63
+ await pushTag(cwd, tag);
64
+ } catch {
65
+ // Tag already pushed
66
+ }
67
+ };
50
68
 
51
- try {
52
- await pushTag(cwd, tag);
69
+ if (interactive) {
70
+ await withSpinner(`Creating tag ${tag}...`, doTag, `Tag ${tag} ready`);
71
+ } else {
72
+ logger.info(`Creating tag ${tag}...`);
73
+ await doTag();
53
74
  logger.success(`Pushed tag ${tag}`);
54
- } catch {
55
- logger.dim(`Tag ${tag} already pushed`);
56
75
  }
57
76
 
58
77
  // Fork registry and create PR via GitHub API
59
- logger.info("Submitting to registry...");
78
+ const doSubmit = async () => {
79
+ const headers = {
80
+ Authorization: `Bearer ${token}`,
81
+ Accept: "application/vnd.github.v3+json",
82
+ "User-Agent": "planmode-cli",
83
+ "Content-Type": "application/json",
84
+ };
85
+
86
+ // Fork the registry repo (idempotent)
87
+ await fetch("https://api.github.com/repos/kaihannonen/planmode.org/forks", {
88
+ method: "POST",
89
+ headers,
90
+ });
91
+
92
+ // Get authenticated user
93
+ const userRes = await fetch("https://api.github.com/user", { headers });
94
+ if (!userRes.ok) {
95
+ throw new Error("Failed to authenticate with GitHub. Check your token.");
96
+ }
97
+ const user = (await userRes.json()) as { login: string };
98
+
99
+ // Create metadata files content
100
+ const repoPath = remoteUrl.replace(/^https?:\/\//, "").replace(/\.git$/, "");
101
+
102
+ const metadataContent = JSON.stringify(
103
+ {
104
+ name: manifest.name,
105
+ description: manifest.description,
106
+ author: manifest.author,
107
+ license: manifest.license,
108
+ repository: repoPath,
109
+ category: manifest.category ?? "other",
110
+ tags: manifest.tags ?? [],
111
+ type: manifest.type,
112
+ models: manifest.models ?? [],
113
+ latest_version: manifest.version,
114
+ versions: [manifest.version],
115
+ downloads: 0,
116
+ created_at: new Date().toISOString(),
117
+ updated_at: new Date().toISOString(),
118
+ dependencies: manifest.dependencies,
119
+ variables: manifest.variables,
120
+ },
121
+ null,
122
+ 2,
123
+ );
124
+
125
+ const versionContent = JSON.stringify(
126
+ {
127
+ version: manifest.version,
128
+ published_at: new Date().toISOString(),
129
+ source: {
130
+ repository: repoPath,
131
+ tag,
132
+ sha,
133
+ },
134
+ files: ["planmode.yaml", manifest.content_file ?? "inline"],
135
+ content_hash: `sha256:${sha.slice(0, 16)}`,
136
+ },
137
+ null,
138
+ 2,
139
+ );
60
140
 
61
- const headers = {
62
- Authorization: `Bearer ${token}`,
63
- Accept: "application/vnd.github.v3+json",
64
- "User-Agent": "planmode-cli",
65
- "Content-Type": "application/json",
66
- };
141
+ // Create branch on fork
142
+ const branchName = `add-${manifest.name}-${manifest.version}`;
67
143
 
68
- // Fork the registry repo (idempotent)
69
- await fetch("https://api.github.com/repos/kaihannonen/planmode.org/forks", {
70
- method: "POST",
71
- headers,
72
- });
144
+ // Get main branch ref
145
+ const refRes = await fetch(
146
+ `https://api.github.com/repos/${user.login}/planmode.org/git/ref/heads/main`,
147
+ { headers },
148
+ );
73
149
 
74
- // Get authenticated user
75
- const userRes = await fetch("https://api.github.com/user", { headers });
76
- if (!userRes.ok) {
77
- throw new Error("Failed to authenticate with GitHub. Check your token.");
78
- }
79
- const user = (await userRes.json()) as { login: string };
80
-
81
- // Create metadata files content
82
- const repoPath = remoteUrl.replace(/^https?:\/\//, "").replace(/\.git$/, "");
83
-
84
- const metadataContent = JSON.stringify(
85
- {
86
- name: manifest.name,
87
- description: manifest.description,
88
- author: manifest.author,
89
- license: manifest.license,
90
- repository: repoPath,
91
- category: manifest.category ?? "other",
92
- tags: manifest.tags ?? [],
93
- type: manifest.type,
94
- models: manifest.models ?? [],
95
- latest_version: manifest.version,
96
- versions: [manifest.version],
97
- downloads: 0,
98
- created_at: new Date().toISOString(),
99
- updated_at: new Date().toISOString(),
100
- dependencies: manifest.dependencies,
101
- variables: manifest.variables,
102
- },
103
- null,
104
- 2,
105
- );
106
-
107
- const versionContent = JSON.stringify(
108
- {
109
- version: manifest.version,
110
- published_at: new Date().toISOString(),
111
- source: {
112
- repository: repoPath,
113
- tag,
114
- sha,
115
- },
116
- files: ["planmode.yaml", manifest.content_file ?? "inline"],
117
- content_hash: `sha256:${sha.slice(0, 16)}`,
118
- },
119
- null,
120
- 2,
121
- );
122
-
123
- // Create branch on fork
124
- const branchName = `add-${manifest.name}-${manifest.version}`;
125
-
126
- // Get main branch ref
127
- const refRes = await fetch(
128
- `https://api.github.com/repos/${user.login}/planmode.org/git/ref/heads/main`,
129
- { headers },
130
- );
131
-
132
- if (!refRes.ok) {
133
- throw new Error("Failed to access registry fork. Make sure the fork exists.");
134
- }
150
+ if (!refRes.ok) {
151
+ throw new Error("Failed to access registry fork. Make sure the fork exists.");
152
+ }
153
+
154
+ const refData = (await refRes.json()) as { object: { sha: string } };
155
+ const baseSha = refData.object.sha;
135
156
 
136
- const refData = (await refRes.json()) as { object: { sha: string } };
137
- const baseSha = refData.object.sha;
138
-
139
- // Create branch
140
- await fetch(`https://api.github.com/repos/${user.login}/planmode.org/git/refs`, {
141
- method: "POST",
142
- headers,
143
- body: JSON.stringify({
144
- ref: `refs/heads/${branchName}`,
145
- sha: baseSha,
146
- }),
147
- });
148
-
149
- // Create metadata.json
150
- await fetch(
151
- `https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/metadata.json`,
152
- {
153
- method: "PUT",
157
+ // Create branch
158
+ await fetch(`https://api.github.com/repos/${user.login}/planmode.org/git/refs`, {
159
+ method: "POST",
154
160
  headers,
155
161
  body: JSON.stringify({
156
- message: `Add ${manifest.name}@${manifest.version}`,
157
- content: Buffer.from(metadataContent).toString("base64"),
158
- branch: branchName,
162
+ ref: `refs/heads/${branchName}`,
163
+ sha: baseSha,
159
164
  }),
160
- },
161
- );
162
-
163
- // Create version file
164
- await fetch(
165
- `https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/versions/${manifest.version}.json`,
166
- {
167
- method: "PUT",
165
+ });
166
+
167
+ // Create metadata.json
168
+ await fetch(
169
+ `https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/metadata.json`,
170
+ {
171
+ method: "PUT",
172
+ headers,
173
+ body: JSON.stringify({
174
+ message: `Add ${manifest.name}@${manifest.version}`,
175
+ content: Buffer.from(metadataContent).toString("base64"),
176
+ branch: branchName,
177
+ }),
178
+ },
179
+ );
180
+
181
+ // Create version file
182
+ await fetch(
183
+ `https://api.github.com/repos/${user.login}/planmode.org/contents/registry/packages/${manifest.name}/versions/${manifest.version}.json`,
184
+ {
185
+ method: "PUT",
186
+ headers,
187
+ body: JSON.stringify({
188
+ message: `Add ${manifest.name}@${manifest.version} version metadata`,
189
+ content: Buffer.from(versionContent).toString("base64"),
190
+ branch: branchName,
191
+ }),
192
+ },
193
+ );
194
+
195
+ // Create PR
196
+ const prRes = await fetch("https://api.github.com/repos/kaihannonen/planmode.org/pulls", {
197
+ method: "POST",
168
198
  headers,
169
199
  body: JSON.stringify({
170
- message: `Add ${manifest.name}@${manifest.version} version metadata`,
171
- content: Buffer.from(versionContent).toString("base64"),
172
- branch: branchName,
200
+ title: `Add ${manifest.name}@${manifest.version}`,
201
+ head: `${user.login}:${branchName}`,
202
+ base: "main",
203
+ body: `## New package: ${manifest.name}\n\n- **Type:** ${manifest.type}\n- **Version:** ${manifest.version}\n- **Description:** ${manifest.description}\n- **Author:** ${manifest.author}\n\nSubmitted via \`planmode publish\`.`,
173
204
  }),
174
- },
175
- );
176
-
177
- // Create PR
178
- const prRes = await fetch("https://api.github.com/repos/kaihannonen/planmode.org/pulls", {
179
- method: "POST",
180
- headers,
181
- body: JSON.stringify({
182
- title: `Add ${manifest.name}@${manifest.version}`,
183
- head: `${user.login}:${branchName}`,
184
- base: "main",
185
- body: `## New package: ${manifest.name}\n\n- **Type:** ${manifest.type}\n- **Version:** ${manifest.version}\n- **Description:** ${manifest.description}\n- **Author:** ${manifest.author}\n\nSubmitted via \`planmode publish\`.`,
186
- }),
187
- });
188
-
189
- if (!prRes.ok) {
190
- const err = await prRes.text();
191
- throw new Error(`Failed to create PR: ${err}`);
192
- }
205
+ });
206
+
207
+ if (!prRes.ok) {
208
+ const err = await prRes.text();
209
+ throw new Error(`Failed to create PR: ${err}`);
210
+ }
193
211
 
194
- const pr = (await prRes.json()) as { html_url: string };
195
- logger.success(`Published ${manifest.name}@${manifest.version}`);
196
- logger.info(`PR: ${pr.html_url}`);
212
+ const pr = (await prRes.json()) as { html_url: string };
213
+ return pr.html_url;
214
+ };
215
+
216
+ let prUrl: string;
217
+ if (interactive) {
218
+ prUrl = await withSpinner(
219
+ "Submitting to registry...",
220
+ doSubmit,
221
+ "Submitted to registry",
222
+ );
223
+ } else {
224
+ logger.info("Submitting to registry...");
225
+ prUrl = await doSubmit();
226
+ logger.success(`Published ${manifest.name}@${manifest.version}`);
227
+ logger.info(`PR: ${prUrl}`);
228
+ }
197
229
 
198
230
  return {
199
- prUrl: pr.html_url,
231
+ prUrl,
200
232
  packageName: manifest.name,
201
233
  version: manifest.version,
202
234
  };
package/src/mcp.ts CHANGED
@@ -83,7 +83,7 @@ function errorResult(prefix: string, err: Error): ReturnType<typeof textResult>
83
83
 
84
84
  const server = new McpServer({
85
85
  name: "planmode",
86
- version: "0.2.1",
86
+ version: "0.2.2",
87
87
  });
88
88
 
89
89
  // ── Tools ──