planmode 0.1.5 → 0.2.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.
@@ -0,0 +1,123 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { readLockfile } from "./lockfile.js";
5
+ import { listImports } from "./claude-md.js";
6
+
7
+ export interface DiagnosticIssue {
8
+ severity: "error" | "warning";
9
+ message: string;
10
+ fix?: string;
11
+ }
12
+
13
+ export interface DoctorResult {
14
+ issues: DiagnosticIssue[];
15
+ packagesChecked: number;
16
+ healthy: boolean;
17
+ }
18
+
19
+ function computeHash(content: string): string {
20
+ return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
21
+ }
22
+
23
+ export function runDoctor(projectDir: string = process.cwd()): DoctorResult {
24
+ const issues: DiagnosticIssue[] = [];
25
+ const lockfile = readLockfile(projectDir);
26
+ const entries = Object.entries(lockfile.packages);
27
+
28
+ // Check each lockfile entry
29
+ for (const [name, entry] of entries) {
30
+ const fullPath = path.join(projectDir, entry.installed_to);
31
+
32
+ // File exists?
33
+ if (!fs.existsSync(fullPath)) {
34
+ issues.push({
35
+ severity: "error",
36
+ message: `Missing file for "${name}": ${entry.installed_to}`,
37
+ fix: `Run \`planmode install ${name}\` to reinstall`,
38
+ });
39
+ continue;
40
+ }
41
+
42
+ // Content hash matches?
43
+ const content = fs.readFileSync(fullPath, "utf-8");
44
+ const actualHash = computeHash(content);
45
+ if (actualHash !== entry.content_hash) {
46
+ issues.push({
47
+ severity: "warning",
48
+ message: `Content hash mismatch for "${name}" at ${entry.installed_to}`,
49
+ fix: "File was modified locally. Run `planmode update " + name + "` to restore, or ignore if intentional",
50
+ });
51
+ }
52
+ }
53
+
54
+ // Check CLAUDE.md imports match installed plans
55
+ const claudeMdPath = path.join(projectDir, "CLAUDE.md");
56
+ const imports = listImports(projectDir);
57
+ const installedPlans = entries
58
+ .filter(([, entry]) => entry.type === "plan")
59
+ .map(([name]) => name);
60
+
61
+ // Plans in lockfile but missing from CLAUDE.md
62
+ for (const planName of installedPlans) {
63
+ if (!imports.includes(planName)) {
64
+ issues.push({
65
+ severity: "error",
66
+ message: `Plan "${planName}" is installed but missing from CLAUDE.md imports`,
67
+ fix: `Add \`- @plans/${planName}.md\` to the # Planmode section of CLAUDE.md`,
68
+ });
69
+ }
70
+ }
71
+
72
+ // Imports in CLAUDE.md that aren't in the lockfile
73
+ for (const importName of imports) {
74
+ if (!installedPlans.includes(importName)) {
75
+ // Check if the file at least exists
76
+ const filePath = path.join(projectDir, "plans", `${importName}.md`);
77
+ if (!fs.existsSync(filePath)) {
78
+ issues.push({
79
+ severity: "error",
80
+ message: `CLAUDE.md imports "${importName}" but the file doesn't exist at plans/${importName}.md`,
81
+ fix: `Run \`planmode install ${importName}\` or remove the import from CLAUDE.md`,
82
+ });
83
+ } else {
84
+ issues.push({
85
+ severity: "warning",
86
+ message: `CLAUDE.md imports "${importName}" but it's not tracked in planmode.lock`,
87
+ fix: "This plan was added manually. No action needed unless you want lockfile tracking.",
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ // Check CLAUDE.md exists if there are plans
94
+ if (installedPlans.length > 0 && !fs.existsSync(claudeMdPath)) {
95
+ issues.push({
96
+ severity: "error",
97
+ message: "CLAUDE.md is missing but plans are installed",
98
+ fix: "Run `planmode install <any-plan>` to recreate it, or create it manually with a # Planmode section",
99
+ });
100
+ }
101
+
102
+ // Check for orphaned files in plans/ that aren't tracked
103
+ const plansDir = path.join(projectDir, "plans");
104
+ if (fs.existsSync(plansDir)) {
105
+ const planFiles = fs.readdirSync(plansDir).filter((f) => f.endsWith(".md"));
106
+ for (const file of planFiles) {
107
+ const name = file.replace(/\.md$/, "");
108
+ if (!lockfile.packages[name]) {
109
+ issues.push({
110
+ severity: "warning",
111
+ message: `Untracked plan file: plans/${file}`,
112
+ fix: "This file isn't managed by planmode. Ignore if intentional.",
113
+ });
114
+ }
115
+ }
116
+ }
117
+
118
+ return {
119
+ issues,
120
+ packagesChecked: entries.length,
121
+ healthy: issues.filter((i) => i.severity === "error").length === 0,
122
+ };
123
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { stringify } from "yaml";
4
+ import type { PackageType, Category } from "../types/index.js";
5
+ import { getPlanTemplate, getRuleTemplate, getPromptTemplate } from "./templates.js";
6
+
7
+ export interface InitOptions {
8
+ name: string;
9
+ type: PackageType;
10
+ description: string;
11
+ author: string;
12
+ license?: string;
13
+ tags?: string[];
14
+ category?: Category;
15
+ projectDir?: string;
16
+ }
17
+
18
+ export interface InitResult {
19
+ files: string[];
20
+ manifestPath: string;
21
+ contentPath: string;
22
+ }
23
+
24
+ export function createPackage(options: InitOptions): InitResult {
25
+ const {
26
+ name,
27
+ type,
28
+ description,
29
+ author,
30
+ license = "MIT",
31
+ tags = [],
32
+ category = "other",
33
+ projectDir = process.cwd(),
34
+ } = options;
35
+
36
+ const manifest: Record<string, unknown> = {
37
+ name,
38
+ version: "1.0.0",
39
+ type,
40
+ description,
41
+ author,
42
+ license,
43
+ };
44
+
45
+ if (tags.length > 0) manifest["tags"] = tags;
46
+ manifest["category"] = category;
47
+
48
+ const contentFile = `${type}.md`;
49
+ manifest["content_file"] = contentFile;
50
+
51
+ // Write planmode.yaml
52
+ const yamlContent = stringify(manifest);
53
+ const manifestPath = path.join(projectDir, "planmode.yaml");
54
+ fs.writeFileSync(manifestPath, yamlContent, "utf-8");
55
+
56
+ // Write stub content file
57
+ const stubs: Record<string, string> = {
58
+ plan: getPlanTemplate(name),
59
+ rule: getRuleTemplate(name),
60
+ prompt: getPromptTemplate(name),
61
+ };
62
+
63
+ const contentPath = path.join(projectDir, contentFile);
64
+ fs.writeFileSync(contentPath, stubs[type] ?? stubs["plan"]!, "utf-8");
65
+
66
+ return {
67
+ files: ["planmode.yaml", contentFile],
68
+ manifestPath,
69
+ contentPath,
70
+ };
71
+ }
@@ -113,6 +113,16 @@ export async function installPackage(
113
113
  }
114
114
  }
115
115
 
116
+ // Verify content hash against registry
117
+ const computedHash = contentHash(content);
118
+ if (versionMeta.content_hash && computedHash !== versionMeta.content_hash) {
119
+ logger.warn(
120
+ `Content hash mismatch for ${packageName}@${version}. ` +
121
+ `Expected ${versionMeta.content_hash.slice(0, 20)}..., got ${computedHash.slice(0, 20)}... ` +
122
+ `The package content may have been modified after review.`,
123
+ );
124
+ }
125
+
116
126
  // Create directory and write file
117
127
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
118
128
  fs.writeFileSync(fullPath, content, "utf-8");
@@ -122,7 +132,16 @@ export async function installPackage(
122
132
  // Update CLAUDE.md for plans
123
133
  if (type === "plan") {
124
134
  addImport(packageName, projectDir);
125
- logger.dim(`Added @import to CLAUDE.md`);
135
+ logger.dim(`Added @plans/${packageName}.md to CLAUDE.md`);
136
+ logger.dim(`Claude Code will automatically see this plan in your next conversation.`);
137
+ }
138
+
139
+ if (type === "rule") {
140
+ logger.dim(`Rule is active — Claude Code auto-loads all files in .claude/rules/.`);
141
+ }
142
+
143
+ if (type === "prompt") {
144
+ logger.dim(`Run it with: planmode run ${packageName}`);
126
145
  }
127
146
 
128
147
  // Update lockfile
package/src/lib/logger.ts CHANGED
@@ -6,29 +6,80 @@ const CYAN = "\x1b[36m";
6
6
  const DIM = "\x1b[2m";
7
7
  const BOLD = "\x1b[1m";
8
8
 
9
+ function stripAnsi(str: string): string {
10
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
11
+ }
12
+
13
+ let capturing = false;
14
+ let captured: string[] = [];
15
+
9
16
  export const logger = {
17
+ capture() {
18
+ capturing = true;
19
+ captured = [];
20
+ },
21
+
22
+ flush(): string[] {
23
+ const messages = captured;
24
+ captured = [];
25
+ capturing = false;
26
+ return messages;
27
+ },
28
+
29
+ isCapturing(): boolean {
30
+ return capturing;
31
+ },
32
+
10
33
  info(msg: string) {
11
- console.log(`${CYAN}info${RESET} ${msg}`);
34
+ const text = `info ${msg}`;
35
+ if (capturing) {
36
+ captured.push(stripAnsi(text));
37
+ } else {
38
+ console.log(`${CYAN}info${RESET} ${msg}`);
39
+ }
12
40
  },
13
41
 
14
42
  success(msg: string) {
15
- console.log(`${GREEN}✓${RESET} ${msg}`);
43
+ const text = `✓ ${msg}`;
44
+ if (capturing) {
45
+ captured.push(stripAnsi(text));
46
+ } else {
47
+ console.log(`${GREEN}✓${RESET} ${msg}`);
48
+ }
16
49
  },
17
50
 
18
51
  warn(msg: string) {
19
- console.log(`${YELLOW}warn${RESET} ${msg}`);
52
+ const text = `warn ${msg}`;
53
+ if (capturing) {
54
+ captured.push(stripAnsi(text));
55
+ } else {
56
+ console.log(`${YELLOW}warn${RESET} ${msg}`);
57
+ }
20
58
  },
21
59
 
22
60
  error(msg: string) {
23
- console.error(`${RED}error${RESET} ${msg}`);
61
+ const text = `error ${msg}`;
62
+ if (capturing) {
63
+ captured.push(stripAnsi(text));
64
+ } else {
65
+ console.error(`${RED}error${RESET} ${msg}`);
66
+ }
24
67
  },
25
68
 
26
69
  dim(msg: string) {
27
- console.log(`${DIM}${msg}${RESET}`);
70
+ if (capturing) {
71
+ captured.push(msg);
72
+ } else {
73
+ console.log(`${DIM}${msg}${RESET}`);
74
+ }
28
75
  },
29
76
 
30
77
  bold(msg: string) {
31
- console.log(`${BOLD}${msg}${RESET}`);
78
+ if (capturing) {
79
+ captured.push(msg);
80
+ } else {
81
+ console.log(`${BOLD}${msg}${RESET}`);
82
+ }
32
83
  },
33
84
 
34
85
  table(headers: string[], rows: string[][]) {
@@ -39,15 +90,27 @@ export const logger = {
39
90
  const header = headers
40
91
  .map((h, i) => h.toUpperCase().padEnd(colWidths[i]!))
41
92
  .join(" ");
42
- console.log(` ${DIM}${header}${RESET}`);
43
93
 
44
- for (const row of rows) {
45
- const line = row.map((cell, i) => cell.padEnd(colWidths[i]!)).join(" ");
46
- console.log(` ${line}`);
94
+ if (capturing) {
95
+ captured.push(` ${header}`);
96
+ for (const row of rows) {
97
+ const line = row.map((cell, i) => cell.padEnd(colWidths[i]!)).join(" ");
98
+ captured.push(` ${line}`);
99
+ }
100
+ } else {
101
+ console.log(` ${DIM}${header}${RESET}`);
102
+ for (const row of rows) {
103
+ const line = row.map((cell, i) => cell.padEnd(colWidths[i]!)).join(" ");
104
+ console.log(` ${line}`);
105
+ }
47
106
  }
48
107
  },
49
108
 
50
109
  blank() {
51
- console.log();
110
+ if (capturing) {
111
+ captured.push("");
112
+ } else {
113
+ console.log();
114
+ }
52
115
  },
53
116
  };
@@ -0,0 +1,203 @@
1
+ import { readManifest, validateManifest } from "./manifest.js";
2
+ import { getGitHubToken } from "./config.js";
3
+ import { getRemoteUrl, getHeadSha, createTag, pushTag } from "./git.js";
4
+ import { logger } from "./logger.js";
5
+
6
+ export interface PublishOptions {
7
+ projectDir?: string;
8
+ token?: string;
9
+ }
10
+
11
+ export interface PublishResult {
12
+ prUrl: string;
13
+ packageName: string;
14
+ version: string;
15
+ }
16
+
17
+ export async function publishPackage(options: PublishOptions = {}): Promise<PublishResult> {
18
+ const cwd = options.projectDir ?? process.cwd();
19
+
20
+ // Check auth
21
+ const token = options.token ?? getGitHubToken();
22
+ if (!token) {
23
+ throw new Error("Not authenticated. Run `planmode login` first.");
24
+ }
25
+
26
+ // 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
+ }
33
+
34
+ // Check git remote
35
+ const remoteUrl = await getRemoteUrl(cwd);
36
+ if (!remoteUrl) {
37
+ throw new Error("No git remote found. Push your code to GitHub first.");
38
+ }
39
+
40
+ const sha = await getHeadSha(cwd);
41
+ const tag = `v${manifest.version}`;
42
+
43
+ // 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
+ }
50
+
51
+ try {
52
+ await pushTag(cwd, tag);
53
+ logger.success(`Pushed tag ${tag}`);
54
+ } catch {
55
+ logger.dim(`Tag ${tag} already pushed`);
56
+ }
57
+
58
+ // Fork registry and create PR via GitHub API
59
+ logger.info("Submitting to registry...");
60
+
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
+ };
67
+
68
+ // Fork the registry repo (idempotent)
69
+ await fetch("https://api.github.com/repos/kaihannonen/planmode.org/forks", {
70
+ method: "POST",
71
+ headers,
72
+ });
73
+
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
+ }
135
+
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",
154
+ headers,
155
+ body: JSON.stringify({
156
+ message: `Add ${manifest.name}@${manifest.version}`,
157
+ content: Buffer.from(metadataContent).toString("base64"),
158
+ branch: branchName,
159
+ }),
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",
168
+ headers,
169
+ body: JSON.stringify({
170
+ message: `Add ${manifest.name}@${manifest.version} version metadata`,
171
+ content: Buffer.from(versionContent).toString("base64"),
172
+ branch: branchName,
173
+ }),
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
+ }
193
+
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}`);
197
+
198
+ return {
199
+ prUrl: pr.html_url,
200
+ packageName: manifest.name,
201
+ version: manifest.version,
202
+ };
203
+ }