planmode 0.1.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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1293 -0
- package/package.json +46 -0
- package/src/commands/info.ts +61 -0
- package/src/commands/init.ts +85 -0
- package/src/commands/install.ts +29 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/login.ts +56 -0
- package/src/commands/publish.ts +204 -0
- package/src/commands/run.ts +87 -0
- package/src/commands/search.ts +45 -0
- package/src/commands/uninstall.ts +17 -0
- package/src/commands/update.ts +49 -0
- package/src/index.ts +31 -0
- package/src/lib/claude-md.ts +74 -0
- package/src/lib/config.ts +64 -0
- package/src/lib/git.ts +121 -0
- package/src/lib/installer.ts +204 -0
- package/src/lib/lockfile.ts +63 -0
- package/src/lib/logger.ts +53 -0
- package/src/lib/manifest.ts +119 -0
- package/src/lib/registry.ts +135 -0
- package/src/lib/resolver.ts +120 -0
- package/src/lib/template.ts +110 -0
- package/src/types/index.ts +144 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { updatePackage } from "../lib/installer.js";
|
|
3
|
+
import { readLockfile } from "../lib/lockfile.js";
|
|
4
|
+
import { logger } from "../lib/logger.js";
|
|
5
|
+
|
|
6
|
+
export const updateCommand = new Command("update")
|
|
7
|
+
.description("Update installed packages to latest compatible versions")
|
|
8
|
+
.argument("[package]", "Package name (omit to update all)")
|
|
9
|
+
.action(async (packageName?: string) => {
|
|
10
|
+
try {
|
|
11
|
+
logger.blank();
|
|
12
|
+
|
|
13
|
+
if (packageName) {
|
|
14
|
+
const updated = await updatePackage(packageName);
|
|
15
|
+
if (!updated) {
|
|
16
|
+
logger.info("Already up to date.");
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
const lockfile = readLockfile();
|
|
20
|
+
const names = Object.keys(lockfile.packages);
|
|
21
|
+
|
|
22
|
+
if (names.length === 0) {
|
|
23
|
+
logger.info("No packages installed.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
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}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (updatedCount === 0) {
|
|
38
|
+
logger.info("All packages are up to date.");
|
|
39
|
+
} else {
|
|
40
|
+
logger.success(`Updated ${updatedCount} package${updatedCount > 1 ? "s" : ""}.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
logger.blank();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger.error((err as Error).message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { installCommand } from "./commands/install.js";
|
|
3
|
+
import { uninstallCommand } from "./commands/uninstall.js";
|
|
4
|
+
import { searchCommand } from "./commands/search.js";
|
|
5
|
+
import { runCommand } from "./commands/run.js";
|
|
6
|
+
import { publishCommand } from "./commands/publish.js";
|
|
7
|
+
import { updateCommand } from "./commands/update.js";
|
|
8
|
+
import { listCommand } from "./commands/list.js";
|
|
9
|
+
import { infoCommand } from "./commands/info.js";
|
|
10
|
+
import { initCommand } from "./commands/init.js";
|
|
11
|
+
import { loginCommand } from "./commands/login.js";
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name("planmode")
|
|
17
|
+
.description("The open source package manager for AI plans, rules, and prompts.")
|
|
18
|
+
.version("0.1.0");
|
|
19
|
+
|
|
20
|
+
program.addCommand(installCommand);
|
|
21
|
+
program.addCommand(uninstallCommand);
|
|
22
|
+
program.addCommand(searchCommand);
|
|
23
|
+
program.addCommand(runCommand);
|
|
24
|
+
program.addCommand(publishCommand);
|
|
25
|
+
program.addCommand(updateCommand);
|
|
26
|
+
program.addCommand(listCommand);
|
|
27
|
+
program.addCommand(infoCommand);
|
|
28
|
+
program.addCommand(initCommand);
|
|
29
|
+
program.addCommand(loginCommand);
|
|
30
|
+
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const CLAUDE_MD = "CLAUDE.md";
|
|
5
|
+
const PLANMODE_SECTION = "# Planmode";
|
|
6
|
+
|
|
7
|
+
export function getClaudeMdPath(projectDir: string = process.cwd()): string {
|
|
8
|
+
return path.join(projectDir, CLAUDE_MD);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function addImport(
|
|
12
|
+
planName: string,
|
|
13
|
+
projectDir: string = process.cwd(),
|
|
14
|
+
): void {
|
|
15
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
16
|
+
const importLine = `- @plans/${planName}.md`;
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(claudeMdPath)) {
|
|
19
|
+
// Create CLAUDE.md with the import
|
|
20
|
+
const content = `${PLANMODE_SECTION}\n${importLine}\n`;
|
|
21
|
+
fs.writeFileSync(claudeMdPath, content, "utf-8");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const content = fs.readFileSync(claudeMdPath, "utf-8");
|
|
26
|
+
|
|
27
|
+
// Check if import already exists
|
|
28
|
+
if (content.includes(importLine)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Find or create Planmode section
|
|
33
|
+
if (content.includes(PLANMODE_SECTION)) {
|
|
34
|
+
// Append under existing section
|
|
35
|
+
const updated = content.replace(PLANMODE_SECTION, `${PLANMODE_SECTION}\n${importLine}`);
|
|
36
|
+
fs.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
37
|
+
} else {
|
|
38
|
+
// Append new section at the end
|
|
39
|
+
const separator = content.endsWith("\n") ? "\n" : "\n\n";
|
|
40
|
+
const updated = content + separator + `${PLANMODE_SECTION}\n${importLine}\n`;
|
|
41
|
+
fs.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function removeImport(
|
|
46
|
+
planName: string,
|
|
47
|
+
projectDir: string = process.cwd(),
|
|
48
|
+
): void {
|
|
49
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
50
|
+
if (!fs.existsSync(claudeMdPath)) return;
|
|
51
|
+
|
|
52
|
+
const content = fs.readFileSync(claudeMdPath, "utf-8");
|
|
53
|
+
const importLine = `- @plans/${planName}.md`;
|
|
54
|
+
const updated = content
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter((line) => line.trim() !== importLine)
|
|
57
|
+
.join("\n");
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(claudeMdPath, updated, "utf-8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listImports(projectDir: string = process.cwd()): string[] {
|
|
63
|
+
const claudeMdPath = getClaudeMdPath(projectDir);
|
|
64
|
+
if (!fs.existsSync(claudeMdPath)) return [];
|
|
65
|
+
|
|
66
|
+
const content = fs.readFileSync(claudeMdPath, "utf-8");
|
|
67
|
+
const importRegex = /^-\s*@plans\/(.+)\.md$/gm;
|
|
68
|
+
const imports: string[] = [];
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
71
|
+
imports.push(match[1]!);
|
|
72
|
+
}
|
|
73
|
+
return imports;
|
|
74
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
import type { PlanmodeConfig } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), ".planmode");
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config");
|
|
9
|
+
const CACHE_DIR = path.join(CONFIG_DIR, "cache");
|
|
10
|
+
|
|
11
|
+
export function getConfigDir(): string {
|
|
12
|
+
return CONFIG_DIR;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getCacheDir(): string {
|
|
16
|
+
const config = readConfig();
|
|
17
|
+
return config.cache?.dir ?? CACHE_DIR;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getCacheTTL(): number {
|
|
21
|
+
const config = readConfig();
|
|
22
|
+
return config.cache?.ttl ?? 3600;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readConfig(): PlanmodeConfig {
|
|
26
|
+
try {
|
|
27
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
28
|
+
return (parse(raw) as PlanmodeConfig) ?? {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeConfig(config: PlanmodeConfig): void {
|
|
35
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
36
|
+
fs.writeFileSync(CONFIG_PATH, stringify(config), "utf-8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getGitHubToken(): string | undefined {
|
|
40
|
+
const envToken = process.env["PLANMODE_GITHUB_TOKEN"];
|
|
41
|
+
if (envToken) return envToken;
|
|
42
|
+
const config = readConfig();
|
|
43
|
+
return config.auth?.github_token;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function setGitHubToken(token: string): void {
|
|
47
|
+
const config = readConfig();
|
|
48
|
+
config.auth = { ...config.auth, github_token: token };
|
|
49
|
+
writeConfig(config);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getRegistries(): Record<string, string> {
|
|
53
|
+
const config = readConfig();
|
|
54
|
+
return {
|
|
55
|
+
default: "github.com/planmode/registry",
|
|
56
|
+
...config.registries,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function addRegistry(name: string, url: string): void {
|
|
61
|
+
const config = readConfig();
|
|
62
|
+
config.registries = { ...config.registries, [name]: url };
|
|
63
|
+
writeConfig(config);
|
|
64
|
+
}
|
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { simpleGit } from "simple-git";
|
|
5
|
+
import { getGitHubToken } from "./config.js";
|
|
6
|
+
|
|
7
|
+
function repoCloneUrl(repoUrl: string): string {
|
|
8
|
+
const token = getGitHubToken();
|
|
9
|
+
// Convert github.com/org/repo to https clone URL
|
|
10
|
+
const match = repoUrl.match(/^github\.com\/(.+)$/);
|
|
11
|
+
if (!match) return `https://${repoUrl}.git`;
|
|
12
|
+
if (token) {
|
|
13
|
+
return `https://${token}@github.com/${match[1]}.git`;
|
|
14
|
+
}
|
|
15
|
+
return `https://github.com/${match[1]}.git`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function cloneAtTag(
|
|
19
|
+
repoUrl: string,
|
|
20
|
+
tag: string,
|
|
21
|
+
targetDir: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const cloneUrl = repoCloneUrl(repoUrl);
|
|
24
|
+
const git = simpleGit();
|
|
25
|
+
await git.clone(cloneUrl, targetDir, [
|
|
26
|
+
"--depth",
|
|
27
|
+
"1",
|
|
28
|
+
"--branch",
|
|
29
|
+
tag,
|
|
30
|
+
"--single-branch",
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchFileAtTag(
|
|
35
|
+
repoUrl: string,
|
|
36
|
+
tag: string,
|
|
37
|
+
filePath: string,
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
// Use GitHub raw content API for efficiency
|
|
40
|
+
const match = repoUrl.match(/^github\.com\/([^/]+)\/([^/]+)$/);
|
|
41
|
+
if (match) {
|
|
42
|
+
const token = getGitHubToken();
|
|
43
|
+
const rawUrl = `https://raw.githubusercontent.com/${match[1]}/${match[2]}/${tag}/${filePath}`;
|
|
44
|
+
const headers: Record<string, string> = { "User-Agent": "planmode-cli" };
|
|
45
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
46
|
+
|
|
47
|
+
const response = await fetch(rawUrl, { headers });
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Failed to fetch ${filePath} from ${repoUrl}@${tag}: ${response.status}`);
|
|
50
|
+
}
|
|
51
|
+
return response.text();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback: clone and read
|
|
55
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "planmode-"));
|
|
56
|
+
try {
|
|
57
|
+
await cloneAtTag(repoUrl, tag, tmpDir);
|
|
58
|
+
const fullPath = path.join(tmpDir, filePath);
|
|
59
|
+
if (!fs.existsSync(fullPath)) {
|
|
60
|
+
throw new Error(`File not found: ${filePath} in ${repoUrl}@${tag}`);
|
|
61
|
+
}
|
|
62
|
+
return fs.readFileSync(fullPath, "utf-8");
|
|
63
|
+
} finally {
|
|
64
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchPackageFiles(
|
|
69
|
+
repoUrl: string,
|
|
70
|
+
tag: string,
|
|
71
|
+
files: string[],
|
|
72
|
+
): Promise<Record<string, string>> {
|
|
73
|
+
const result: Record<string, string> = {};
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
result[file] = await fetchFileAtTag(repoUrl, tag, file);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getLatestTag(repoUrl: string): Promise<string | null> {
|
|
81
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "planmode-tags-"));
|
|
82
|
+
try {
|
|
83
|
+
const cloneUrl = repoCloneUrl(repoUrl);
|
|
84
|
+
const git = simpleGit();
|
|
85
|
+
const result = await git.listRemote(["--tags", "--sort=-v:refname", cloneUrl]);
|
|
86
|
+
const lines = result.trim().split("\n");
|
|
87
|
+
if (lines.length === 0 || !lines[0]) return null;
|
|
88
|
+
|
|
89
|
+
const match = lines[0].match(/refs\/tags\/(.+)$/);
|
|
90
|
+
return match ? match[1]! : null;
|
|
91
|
+
} finally {
|
|
92
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createTag(dir: string, tag: string): Promise<void> {
|
|
97
|
+
const git = simpleGit(dir);
|
|
98
|
+
await git.addTag(tag);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function pushTag(dir: string, tag: string): Promise<void> {
|
|
102
|
+
const git = simpleGit(dir);
|
|
103
|
+
await git.push("origin", tag);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function getRemoteUrl(dir: string): Promise<string | null> {
|
|
107
|
+
try {
|
|
108
|
+
const git = simpleGit(dir);
|
|
109
|
+
const remotes = await git.getRemotes(true);
|
|
110
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
111
|
+
return origin?.refs?.fetch ?? null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getHeadSha(dir: string): Promise<string> {
|
|
118
|
+
const git = simpleGit(dir);
|
|
119
|
+
const log = await git.log({ n: 1 });
|
|
120
|
+
return log.latest?.hash ?? "";
|
|
121
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import type { PackageManifest, PackageType, LockfileEntry, ResolvedPackage } from "../types/index.js";
|
|
5
|
+
import { resolveVersion, parseDepString } from "./resolver.js";
|
|
6
|
+
import { fetchVersionMetadata, fetchPackageMetadata } from "./registry.js";
|
|
7
|
+
import { fetchFileAtTag } from "./git.js";
|
|
8
|
+
import { addToLockfile, removeFromLockfile, getLockedVersion } from "./lockfile.js";
|
|
9
|
+
import { addImport, removeImport } from "./claude-md.js";
|
|
10
|
+
import { parseManifest, readPackageContent } from "./manifest.js";
|
|
11
|
+
import { renderTemplate, collectVariableValues } from "./template.js";
|
|
12
|
+
import { logger } from "./logger.js";
|
|
13
|
+
|
|
14
|
+
function getInstallDir(type: PackageType): string {
|
|
15
|
+
switch (type) {
|
|
16
|
+
case "plan":
|
|
17
|
+
return "plans";
|
|
18
|
+
case "rule":
|
|
19
|
+
return path.join(".claude", "rules");
|
|
20
|
+
case "prompt":
|
|
21
|
+
return "prompts";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getInstallPath(name: string, type: PackageType): string {
|
|
26
|
+
return path.join(getInstallDir(type), `${name}.md`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function contentHash(content: string): string {
|
|
30
|
+
return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InstallOptions {
|
|
34
|
+
version?: string;
|
|
35
|
+
forceRule?: boolean;
|
|
36
|
+
noInput?: boolean;
|
|
37
|
+
variables?: Record<string, string>;
|
|
38
|
+
projectDir?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function installPackage(
|
|
42
|
+
packageName: string,
|
|
43
|
+
options: InstallOptions = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
46
|
+
|
|
47
|
+
// Check lockfile first
|
|
48
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
49
|
+
if (locked && !options.version) {
|
|
50
|
+
logger.dim(`${packageName}@${locked.version} already installed`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Resolve version
|
|
55
|
+
logger.info(`Resolving ${packageName}...`);
|
|
56
|
+
const { version, metadata } = await resolveVersion(packageName, options.version);
|
|
57
|
+
|
|
58
|
+
// Fetch version metadata
|
|
59
|
+
const versionMeta = await fetchVersionMetadata(packageName, version);
|
|
60
|
+
|
|
61
|
+
// Fetch manifest
|
|
62
|
+
logger.info(`Fetching ${packageName}@${version}...`);
|
|
63
|
+
const manifestRaw = await fetchFileAtTag(
|
|
64
|
+
versionMeta.source.repository,
|
|
65
|
+
versionMeta.source.tag,
|
|
66
|
+
"planmode.yaml",
|
|
67
|
+
);
|
|
68
|
+
const manifest = parseManifest(manifestRaw);
|
|
69
|
+
|
|
70
|
+
// Fetch content
|
|
71
|
+
let content: string;
|
|
72
|
+
if (manifest.content) {
|
|
73
|
+
content = manifest.content;
|
|
74
|
+
} else if (manifest.content_file) {
|
|
75
|
+
content = await fetchFileAtTag(
|
|
76
|
+
versionMeta.source.repository,
|
|
77
|
+
versionMeta.source.tag,
|
|
78
|
+
manifest.content_file,
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error("Package has no content or content_file");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Process variables if templated
|
|
85
|
+
if (manifest.variables && Object.keys(manifest.variables).length > 0) {
|
|
86
|
+
const provided = options.variables ?? {};
|
|
87
|
+
if (options.noInput) {
|
|
88
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
89
|
+
content = renderTemplate(content, values);
|
|
90
|
+
} else {
|
|
91
|
+
// Use defaults for non-provided values
|
|
92
|
+
const values = collectVariableValues(manifest.variables, provided);
|
|
93
|
+
content = renderTemplate(content, values);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Determine type (--rule overrides)
|
|
98
|
+
const type = options.forceRule ? "rule" : manifest.type;
|
|
99
|
+
const installPath = getInstallPath(packageName, type);
|
|
100
|
+
const fullPath = path.join(projectDir, installPath);
|
|
101
|
+
|
|
102
|
+
// Check for conflicts
|
|
103
|
+
if (fs.existsSync(fullPath)) {
|
|
104
|
+
const existingContent = fs.readFileSync(fullPath, "utf-8");
|
|
105
|
+
const existingHash = contentHash(existingContent);
|
|
106
|
+
const newHash = contentHash(content);
|
|
107
|
+
if (existingHash === newHash) {
|
|
108
|
+
logger.dim(`${packageName} already installed (identical content)`);
|
|
109
|
+
} else {
|
|
110
|
+
logger.warn(`Overwriting ${installPath} with new content`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Create directory and write file
|
|
115
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
116
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
117
|
+
logger.success(`Installed ${packageName}@${version} → ${installPath}`);
|
|
118
|
+
|
|
119
|
+
// Update CLAUDE.md for plans
|
|
120
|
+
if (type === "plan") {
|
|
121
|
+
addImport(packageName, projectDir);
|
|
122
|
+
logger.dim(`Added @import to CLAUDE.md`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Update lockfile
|
|
126
|
+
const hash = contentHash(content);
|
|
127
|
+
const entry: LockfileEntry = {
|
|
128
|
+
version,
|
|
129
|
+
type,
|
|
130
|
+
source: versionMeta.source.repository,
|
|
131
|
+
tag: versionMeta.source.tag,
|
|
132
|
+
sha: versionMeta.source.sha,
|
|
133
|
+
content_hash: hash,
|
|
134
|
+
installed_to: installPath,
|
|
135
|
+
};
|
|
136
|
+
addToLockfile(packageName, entry, projectDir);
|
|
137
|
+
|
|
138
|
+
// Install dependencies
|
|
139
|
+
if (manifest.dependencies) {
|
|
140
|
+
const deps = [
|
|
141
|
+
...(manifest.dependencies.rules ?? []).map((d) => ({ dep: d, type: "rule" as const })),
|
|
142
|
+
...(manifest.dependencies.plans ?? []).map((d) => ({ dep: d, type: "plan" as const })),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
for (const { dep } of deps) {
|
|
146
|
+
const { name, range } = parseDepString(dep);
|
|
147
|
+
await installPackage(name, {
|
|
148
|
+
version: range === "*" ? undefined : range,
|
|
149
|
+
projectDir,
|
|
150
|
+
noInput: options.noInput,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function uninstallPackage(
|
|
157
|
+
packageName: string,
|
|
158
|
+
projectDir: string = process.cwd(),
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
161
|
+
if (!locked) {
|
|
162
|
+
throw new Error(`Package '${packageName}' is not installed.`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Remove file
|
|
166
|
+
const fullPath = path.join(projectDir, locked.installed_to);
|
|
167
|
+
if (fs.existsSync(fullPath)) {
|
|
168
|
+
fs.unlinkSync(fullPath);
|
|
169
|
+
logger.success(`Removed ${locked.installed_to}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Remove import from CLAUDE.md
|
|
173
|
+
if (locked.type === "plan") {
|
|
174
|
+
removeImport(packageName, projectDir);
|
|
175
|
+
logger.dim(`Removed @import from CLAUDE.md`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Update lockfile
|
|
179
|
+
removeFromLockfile(packageName, projectDir);
|
|
180
|
+
logger.success(`Uninstalled ${packageName}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function updatePackage(
|
|
184
|
+
packageName: string,
|
|
185
|
+
projectDir: string = process.cwd(),
|
|
186
|
+
): Promise<boolean> {
|
|
187
|
+
const locked = getLockedVersion(packageName, projectDir);
|
|
188
|
+
if (!locked) {
|
|
189
|
+
throw new Error(`Package '${packageName}' is not installed.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { version, metadata } = await resolveVersion(packageName);
|
|
193
|
+
if (version === locked.version) {
|
|
194
|
+
logger.dim(`${packageName}@${version} is already up to date`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info(`Updating ${packageName}: ${locked.version} → ${version}`);
|
|
199
|
+
|
|
200
|
+
// Uninstall old, install new
|
|
201
|
+
await uninstallPackage(packageName, projectDir);
|
|
202
|
+
await installPackage(packageName, { version, projectDir });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import type { Lockfile, LockfileEntry } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
const LOCKFILE_NAME = "planmode.lock";
|
|
7
|
+
|
|
8
|
+
export function getLockfilePath(projectDir: string = process.cwd()): string {
|
|
9
|
+
return path.join(projectDir, LOCKFILE_NAME);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readLockfile(projectDir: string = process.cwd()): Lockfile {
|
|
13
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(lockfilePath, "utf-8");
|
|
16
|
+
const data = parse(raw) as Lockfile;
|
|
17
|
+
return data ?? { lockfile_version: 1, packages: {} };
|
|
18
|
+
} catch {
|
|
19
|
+
return { lockfile_version: 1, packages: {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeLockfile(lockfile: Lockfile, projectDir: string = process.cwd()): void {
|
|
24
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
25
|
+
fs.writeFileSync(lockfilePath, stringify(lockfile), "utf-8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addToLockfile(
|
|
29
|
+
packageName: string,
|
|
30
|
+
entry: LockfileEntry,
|
|
31
|
+
projectDir: string = process.cwd(),
|
|
32
|
+
): void {
|
|
33
|
+
const lockfile = readLockfile(projectDir);
|
|
34
|
+
lockfile.packages[packageName] = entry;
|
|
35
|
+
writeLockfile(lockfile, projectDir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function removeFromLockfile(
|
|
39
|
+
packageName: string,
|
|
40
|
+
projectDir: string = process.cwd(),
|
|
41
|
+
): void {
|
|
42
|
+
const lockfile = readLockfile(projectDir);
|
|
43
|
+
delete lockfile.packages[packageName];
|
|
44
|
+
writeLockfile(lockfile, projectDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getLockedVersion(
|
|
48
|
+
packageName: string,
|
|
49
|
+
projectDir: string = process.cwd(),
|
|
50
|
+
): LockfileEntry | undefined {
|
|
51
|
+
const lockfile = readLockfile(projectDir);
|
|
52
|
+
return lockfile.packages[packageName];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getDependents(
|
|
56
|
+
packageName: string,
|
|
57
|
+
projectDir: string = process.cwd(),
|
|
58
|
+
): string[] {
|
|
59
|
+
// Check which installed packages depend on the given package
|
|
60
|
+
// This is a simplified check — in a full implementation we'd read manifests
|
|
61
|
+
const lockfile = readLockfile(projectDir);
|
|
62
|
+
return Object.keys(lockfile.packages).filter((name) => name !== packageName);
|
|
63
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const RESET = "\x1b[0m";
|
|
2
|
+
const RED = "\x1b[31m";
|
|
3
|
+
const GREEN = "\x1b[32m";
|
|
4
|
+
const YELLOW = "\x1b[33m";
|
|
5
|
+
const CYAN = "\x1b[36m";
|
|
6
|
+
const DIM = "\x1b[2m";
|
|
7
|
+
const BOLD = "\x1b[1m";
|
|
8
|
+
|
|
9
|
+
export const logger = {
|
|
10
|
+
info(msg: string) {
|
|
11
|
+
console.log(`${CYAN}info${RESET} ${msg}`);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
success(msg: string) {
|
|
15
|
+
console.log(`${GREEN}✓${RESET} ${msg}`);
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
warn(msg: string) {
|
|
19
|
+
console.log(`${YELLOW}warn${RESET} ${msg}`);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
error(msg: string) {
|
|
23
|
+
console.error(`${RED}error${RESET} ${msg}`);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
dim(msg: string) {
|
|
27
|
+
console.log(`${DIM}${msg}${RESET}`);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
bold(msg: string) {
|
|
31
|
+
console.log(`${BOLD}${msg}${RESET}`);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
table(headers: string[], rows: string[][]) {
|
|
35
|
+
const colWidths = headers.map((h, i) =>
|
|
36
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const header = headers
|
|
40
|
+
.map((h, i) => h.toUpperCase().padEnd(colWidths[i]!))
|
|
41
|
+
.join(" ");
|
|
42
|
+
console.log(` ${DIM}${header}${RESET}`);
|
|
43
|
+
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
const line = row.map((cell, i) => cell.padEnd(colWidths[i]!)).join(" ");
|
|
46
|
+
console.log(` ${line}`);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
blank() {
|
|
51
|
+
console.log();
|
|
52
|
+
},
|
|
53
|
+
};
|