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.
@@ -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
+ };