@bantay/cli 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,80 @@
1
+ import { readFile, access } from "fs/promises";
2
+ import { join } from "path";
3
+ import type { OrmDetection } from "./types";
4
+
5
+ async function fileExists(path: string): Promise<boolean> {
6
+ try {
7
+ await access(path);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ async function readPackageJson(projectPath: string): Promise<Record<string, unknown> | null> {
15
+ try {
16
+ const content = await readFile(join(projectPath, "package.json"), "utf-8");
17
+ return JSON.parse(content);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export async function detect(projectPath: string): Promise<OrmDetection | null> {
24
+ const pkg = await readPackageJson(projectPath);
25
+
26
+ let version: string | undefined;
27
+ let confidence: "high" | "medium" | "low" = "low";
28
+ let detected = false;
29
+
30
+ // Check package.json for prisma dependencies
31
+ if (pkg) {
32
+ const deps = (pkg.dependencies ?? {}) as Record<string, string>;
33
+ const devDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
34
+
35
+ if (deps["@prisma/client"]) {
36
+ version = deps["@prisma/client"];
37
+ confidence = "high";
38
+ detected = true;
39
+ }
40
+
41
+ if (devDeps.prisma) {
42
+ version = version ?? devDeps.prisma;
43
+ confidence = "high";
44
+ detected = true;
45
+ }
46
+ }
47
+
48
+ if (!detected) {
49
+ return null;
50
+ }
51
+
52
+ // Find schema path
53
+ let schemaPath: string | undefined;
54
+
55
+ // Check for custom path in package.json
56
+ if (pkg?.prisma && typeof pkg.prisma === "object") {
57
+ const prismaConfig = pkg.prisma as Record<string, unknown>;
58
+ if (typeof prismaConfig.schema === "string") {
59
+ const customPath = prismaConfig.schema;
60
+ if (await fileExists(join(projectPath, customPath))) {
61
+ schemaPath = customPath;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Check default location if no custom path
67
+ if (!schemaPath) {
68
+ const defaultPath = "prisma/schema.prisma";
69
+ if (await fileExists(join(projectPath, defaultPath))) {
70
+ schemaPath = defaultPath;
71
+ }
72
+ }
73
+
74
+ return {
75
+ name: "prisma",
76
+ version,
77
+ confidence,
78
+ schemaPath,
79
+ };
80
+ }
@@ -0,0 +1,29 @@
1
+ export interface FrameworkDetection {
2
+ name: string;
3
+ version?: string;
4
+ confidence: "high" | "medium" | "low";
5
+ router?: "app" | "pages";
6
+ }
7
+
8
+ export interface OrmDetection {
9
+ name: string;
10
+ version?: string;
11
+ confidence: "high" | "medium" | "low";
12
+ schemaPath?: string;
13
+ }
14
+
15
+ export interface AuthDetection {
16
+ name: string;
17
+ version?: string;
18
+ confidence: "high" | "medium" | "low";
19
+ }
20
+
21
+ export interface StackDetectionResult {
22
+ framework: FrameworkDetection | null;
23
+ orm: OrmDetection | null;
24
+ auth: AuthDetection | null;
25
+ }
26
+
27
+ export interface Detector<T> {
28
+ detect(projectPath: string): Promise<T | null>;
29
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { join } from "path";
2
+
3
+ export interface DiffResult {
4
+ changedFiles: string[];
5
+ categories: Set<string>;
6
+ }
7
+
8
+ // Maps file patterns to invariant categories they affect
9
+ const FILE_CATEGORY_MAP: Array<{ pattern: RegExp; category: string }> = [
10
+ // Route files affect auth invariants
11
+ { pattern: /\/(api|routes)\/.*\.(ts|tsx|js|jsx)$/, category: "auth" },
12
+ { pattern: /route\.(ts|tsx|js|jsx)$/, category: "auth" },
13
+ { pattern: /page\.(ts|tsx|js|jsx)$/, category: "auth" },
14
+
15
+ // Schema/migration files affect schema invariants
16
+ { pattern: /schema\.prisma$/, category: "schema" },
17
+ { pattern: /migrations?\//, category: "schema" },
18
+ { pattern: /\.sql$/, category: "schema" },
19
+
20
+ // Log-related files affect logging invariants
21
+ { pattern: /log(ger)?\./, category: "logging" },
22
+ { pattern: /console\./, category: "logging" },
23
+ ];
24
+
25
+ export async function getGitDiff(
26
+ projectPath: string,
27
+ ref: string = "HEAD"
28
+ ): Promise<DiffResult> {
29
+ const changedFiles: string[] = [];
30
+ const categories = new Set<string>();
31
+
32
+ try {
33
+ // Get list of changed files
34
+ const proc = Bun.spawn(["git", "diff", "--name-only", ref], {
35
+ cwd: projectPath,
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+
40
+ const output = await new Response(proc.stdout).text();
41
+ const exitCode = await proc.exited;
42
+
43
+ if (exitCode !== 0) {
44
+ // Also try with staged files for uncommitted changes
45
+ const stagedProc = Bun.spawn(["git", "diff", "--name-only", "--cached"], {
46
+ cwd: projectPath,
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+ const stagedOutput = await new Response(stagedProc.stdout).text();
51
+ await stagedProc.exited;
52
+
53
+ const unstagedProc = Bun.spawn(["git", "diff", "--name-only"], {
54
+ cwd: projectPath,
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ const unstagedOutput = await new Response(unstagedProc.stdout).text();
59
+ await unstagedProc.exited;
60
+
61
+ const files = new Set([
62
+ ...stagedOutput.trim().split("\n").filter(Boolean),
63
+ ...unstagedOutput.trim().split("\n").filter(Boolean),
64
+ ]);
65
+
66
+ changedFiles.push(...files);
67
+ } else {
68
+ changedFiles.push(...output.trim().split("\n").filter(Boolean));
69
+ }
70
+
71
+ // Also include untracked files that are new
72
+ const untrackedProc = Bun.spawn(
73
+ ["git", "ls-files", "--others", "--exclude-standard"],
74
+ {
75
+ cwd: projectPath,
76
+ stdout: "pipe",
77
+ stderr: "pipe",
78
+ }
79
+ );
80
+ const untrackedOutput = await new Response(untrackedProc.stdout).text();
81
+ await untrackedProc.exited;
82
+
83
+ const untrackedFiles = untrackedOutput.trim().split("\n").filter(Boolean);
84
+ for (const file of untrackedFiles) {
85
+ if (!changedFiles.includes(file)) {
86
+ changedFiles.push(file);
87
+ }
88
+ }
89
+
90
+ // Determine which categories are affected
91
+ for (const file of changedFiles) {
92
+ for (const { pattern, category } of FILE_CATEGORY_MAP) {
93
+ if (pattern.test(file)) {
94
+ categories.add(category);
95
+ }
96
+ }
97
+ }
98
+
99
+ // If no specific categories matched but there are changes, include all
100
+ // This ensures we don't miss invariants for file types we don't recognize
101
+ if (changedFiles.length > 0 && categories.size === 0) {
102
+ // Default: check all categories when we can't determine specifics
103
+ categories.add("*");
104
+ }
105
+
106
+ return { changedFiles, categories };
107
+ } catch {
108
+ // Not a git repo or git not available - check everything
109
+ return { changedFiles: [], categories: new Set(["*"]) };
110
+ }
111
+ }
112
+
113
+ export function shouldCheckInvariant(
114
+ invariantCategory: string,
115
+ affectedCategories: Set<string>
116
+ ): boolean {
117
+ // If "*" is in affected categories, check everything
118
+ if (affectedCategories.has("*")) {
119
+ return true;
120
+ }
121
+
122
+ // Check if this invariant's category is affected
123
+ return affectedCategories.has(invariantCategory);
124
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Helpers to extract entities from the aide tree for export
3
+ */
4
+
5
+ import type { AideTree, Entity } from "../aide";
6
+ import type {
7
+ ExtractedInvariant,
8
+ ExtractedConstraint,
9
+ ExtractedFoundation,
10
+ ExtractedWisdom,
11
+ } from "./types";
12
+
13
+ /**
14
+ * Extract all invariants from the aide tree
15
+ * Invariants are entities with parent "invariants"
16
+ */
17
+ export function extractInvariants(tree: AideTree): ExtractedInvariant[] {
18
+ const invariants: ExtractedInvariant[] = [];
19
+
20
+ for (const [id, entity] of Object.entries(tree.entities)) {
21
+ if (entity.parent === "invariants" && entity.props) {
22
+ const props = entity.props;
23
+ invariants.push({
24
+ id,
25
+ statement: String(props.statement || ""),
26
+ category: String(props.category || "uncategorized"),
27
+ threatSignal: props.threat_signal ? String(props.threat_signal) : undefined,
28
+ done: props.done === true,
29
+ });
30
+ }
31
+ }
32
+
33
+ return invariants;
34
+ }
35
+
36
+ /**
37
+ * Extract all constraints from the aide tree
38
+ * Constraints are entities with parent "constraints"
39
+ */
40
+ export function extractConstraints(tree: AideTree): ExtractedConstraint[] {
41
+ const constraints: ExtractedConstraint[] = [];
42
+
43
+ for (const [id, entity] of Object.entries(tree.entities)) {
44
+ if (entity.parent === "constraints" && entity.props) {
45
+ const props = entity.props;
46
+ constraints.push({
47
+ id,
48
+ text: String(props.text || ""),
49
+ domain: String(props.domain || "general"),
50
+ rationale: props.rationale ? String(props.rationale) : undefined,
51
+ });
52
+ }
53
+ }
54
+
55
+ return constraints;
56
+ }
57
+
58
+ /**
59
+ * Extract all foundations from the aide tree
60
+ * Foundations are entities with parent "foundations"
61
+ */
62
+ export function extractFoundations(tree: AideTree): ExtractedFoundation[] {
63
+ const foundations: ExtractedFoundation[] = [];
64
+
65
+ for (const [id, entity] of Object.entries(tree.entities)) {
66
+ if (entity.parent === "foundations" && entity.props) {
67
+ const props = entity.props;
68
+ foundations.push({
69
+ id,
70
+ text: String(props.text || ""),
71
+ });
72
+ }
73
+ }
74
+
75
+ return foundations;
76
+ }
77
+
78
+ /**
79
+ * Extract all wisdom entries from the aide tree
80
+ * Wisdom entries are entities with parent "wisdom"
81
+ */
82
+ export function extractWisdom(tree: AideTree): ExtractedWisdom[] {
83
+ const wisdom: ExtractedWisdom[] = [];
84
+
85
+ for (const [id, entity] of Object.entries(tree.entities)) {
86
+ if (entity.parent === "wisdom" && entity.props) {
87
+ const props = entity.props;
88
+ wisdom.push({
89
+ id,
90
+ text: String(props.text || ""),
91
+ });
92
+ }
93
+ }
94
+
95
+ return wisdom;
96
+ }
97
+
98
+ /**
99
+ * Group items by a key
100
+ */
101
+ export function groupBy<T>(items: T[], keyFn: (item: T) => string): Map<string, T[]> {
102
+ const groups = new Map<string, T[]>();
103
+
104
+ for (const item of items) {
105
+ const key = keyFn(item);
106
+ const existing = groups.get(key) || [];
107
+ existing.push(item);
108
+ groups.set(key, existing);
109
+ }
110
+
111
+ return groups;
112
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Export all targets at once
3
+ */
4
+
5
+ import { exportInvariants } from "./invariants";
6
+ import { exportClaude } from "./claude";
7
+ import { exportCursor } from "./cursor";
8
+ import type { ExportOptions, ExportResult } from "./types";
9
+
10
+ /**
11
+ * Export all targets: invariants.md, CLAUDE.md, .cursorrules
12
+ */
13
+ export async function exportAll(
14
+ projectPath: string,
15
+ options: ExportOptions = {}
16
+ ): Promise<ExportResult[]> {
17
+ const results: ExportResult[] = [];
18
+
19
+ // Export invariants.md
20
+ results.push(await exportInvariants(projectPath, options));
21
+
22
+ // Export CLAUDE.md
23
+ results.push(await exportClaude(projectPath, options));
24
+
25
+ // Export .cursorrules
26
+ results.push(await exportCursor(projectPath, options));
27
+
28
+ return results;
29
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Export to CLAUDE.md with section markers
3
+ */
4
+
5
+ import { readFile, writeFile, access } from "fs/promises";
6
+ import { join } from "path";
7
+ import { read as readAide } from "../aide";
8
+ import {
9
+ extractConstraints,
10
+ extractFoundations,
11
+ extractInvariants,
12
+ groupBy,
13
+ } from "./aide-reader";
14
+ import {
15
+ type ExportOptions,
16
+ type ExportResult,
17
+ type ExtractedConstraint,
18
+ type ExtractedFoundation,
19
+ type ExtractedInvariant,
20
+ SECTION_START,
21
+ SECTION_END,
22
+ } from "./types";
23
+
24
+ /**
25
+ * Generate the Bantay section content for CLAUDE.md
26
+ */
27
+ export function generateClaudeSection(
28
+ constraints: ExtractedConstraint[],
29
+ foundations: ExtractedFoundation[],
30
+ invariants: ExtractedInvariant[]
31
+ ): string {
32
+ const lines: string[] = [];
33
+
34
+ lines.push(SECTION_START);
35
+ lines.push("");
36
+ lines.push("## Bantay Project Rules");
37
+ lines.push("");
38
+ lines.push("*Auto-generated from bantay.aide. Do not edit manually.*");
39
+ lines.push("");
40
+
41
+ // Foundations as principles
42
+ if (foundations.length > 0) {
43
+ lines.push("### Design Principles");
44
+ lines.push("");
45
+ for (const f of foundations) {
46
+ lines.push(`- ${f.text}`);
47
+ }
48
+ lines.push("");
49
+ }
50
+
51
+ // Constraints grouped by domain
52
+ if (constraints.length > 0) {
53
+ lines.push("### Architectural Constraints");
54
+ lines.push("");
55
+
56
+ const byDomain = groupBy(constraints, (c) => c.domain);
57
+ const sortedDomains = Array.from(byDomain.keys()).sort();
58
+
59
+ for (const domain of sortedDomains) {
60
+ const domainConstraints = byDomain.get(domain) || [];
61
+
62
+ lines.push(`#### ${formatDomainTitle(domain)}`);
63
+ lines.push("");
64
+
65
+ for (const c of domainConstraints) {
66
+ lines.push(`- **${c.id}**: ${c.text}`);
67
+ if (c.rationale) {
68
+ lines.push(` - *Rationale*: ${c.rationale}`);
69
+ }
70
+ }
71
+ lines.push("");
72
+ }
73
+ }
74
+
75
+ // Invariants as rules
76
+ if (invariants.length > 0) {
77
+ lines.push("### Invariants (Rules You Must Follow)");
78
+ lines.push("");
79
+
80
+ const byCategory = groupBy(invariants, (inv) => inv.category);
81
+ const sortedCategories = Array.from(byCategory.keys()).sort();
82
+
83
+ for (const category of sortedCategories) {
84
+ const categoryInvariants = byCategory.get(category) || [];
85
+
86
+ lines.push(`#### ${formatCategoryTitle(category)}`);
87
+ lines.push("");
88
+
89
+ for (const inv of categoryInvariants) {
90
+ lines.push(`- **${inv.id}**: ${inv.statement}`);
91
+ }
92
+ lines.push("");
93
+ }
94
+ }
95
+
96
+ lines.push(SECTION_END);
97
+
98
+ return lines.join("\n");
99
+ }
100
+
101
+ /**
102
+ * Format a domain ID into a readable title
103
+ */
104
+ function formatDomainTitle(domain: string): string {
105
+ return domain
106
+ .split("_")
107
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
108
+ .join(" ");
109
+ }
110
+
111
+ /**
112
+ * Format a category ID into a readable title
113
+ */
114
+ function formatCategoryTitle(category: string): string {
115
+ return category
116
+ .split("_")
117
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
118
+ .join(" ");
119
+ }
120
+
121
+ /**
122
+ * Find the position of a marker at the start of a line (not inline)
123
+ * Returns -1 if not found
124
+ */
125
+ function findMarkerPosition(content: string, marker: string): number {
126
+ // Match marker at start of line (with optional leading whitespace)
127
+ const regex = new RegExp(`^[ \\t]*${escapeRegex(marker)}`, "m");
128
+ const match = content.match(regex);
129
+ if (match && match.index !== undefined) {
130
+ // Return the position of the marker itself, not the leading whitespace
131
+ return content.indexOf(marker, match.index);
132
+ }
133
+ return -1;
134
+ }
135
+
136
+ /**
137
+ * Escape special regex characters
138
+ */
139
+ function escapeRegex(str: string): string {
140
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
141
+ }
142
+
143
+ /**
144
+ * Insert or replace the Bantay section in a file
145
+ * Only matches markers at the start of a line, not inline markers
146
+ */
147
+ export function insertSection(existingContent: string, newSection: string): string {
148
+ const startIdx = findMarkerPosition(existingContent, SECTION_START);
149
+ const endIdx = findMarkerPosition(existingContent, SECTION_END);
150
+
151
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
152
+ // Replace existing section
153
+ const before = existingContent.slice(0, startIdx);
154
+ const after = existingContent.slice(endIdx + SECTION_END.length);
155
+ return before + newSection + after;
156
+ }
157
+
158
+ // Append section at end
159
+ if (existingContent.length > 0 && !existingContent.endsWith("\n")) {
160
+ return existingContent + "\n\n" + newSection + "\n";
161
+ }
162
+ if (existingContent.length > 0) {
163
+ return existingContent + "\n" + newSection + "\n";
164
+ }
165
+ return newSection + "\n";
166
+ }
167
+
168
+ /**
169
+ * Check if a file exists
170
+ */
171
+ async function fileExists(path: string): Promise<boolean> {
172
+ try {
173
+ await access(path);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Export to CLAUDE.md
182
+ */
183
+ export async function exportClaude(
184
+ projectPath: string,
185
+ options: ExportOptions = {}
186
+ ): Promise<ExportResult> {
187
+ const aidePath = options.aidePath || join(projectPath, "bantay.aide");
188
+ const outputPath = options.outputPath || join(projectPath, "CLAUDE.md");
189
+
190
+ // Read the aide tree
191
+ const tree = await readAide(aidePath);
192
+
193
+ // Extract entities
194
+ const constraints = extractConstraints(tree);
195
+ const foundations = extractFoundations(tree);
196
+ const invariants = extractInvariants(tree);
197
+
198
+ // Generate section content
199
+ const section = generateClaudeSection(constraints, foundations, invariants);
200
+
201
+ // Read existing file if it exists
202
+ let existingContent = "";
203
+ if (await fileExists(outputPath)) {
204
+ existingContent = await readFile(outputPath, "utf-8");
205
+ }
206
+
207
+ // Insert or replace section
208
+ const content = insertSection(existingContent, section);
209
+
210
+ // Write unless dry run
211
+ if (!options.dryRun) {
212
+ await writeFile(outputPath, content, "utf-8");
213
+ }
214
+
215
+ return {
216
+ target: "claude",
217
+ outputPath,
218
+ content,
219
+ bytesWritten: Buffer.byteLength(content, "utf-8"),
220
+ };
221
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Export to .cursorrules with section markers
3
+ */
4
+
5
+ import { readFile, writeFile, access } from "fs/promises";
6
+ import { join } from "path";
7
+ import { read as readAide } from "../aide";
8
+ import {
9
+ extractConstraints,
10
+ extractFoundations,
11
+ extractInvariants,
12
+ } from "./aide-reader";
13
+ import { generateClaudeSection, insertSection } from "./claude";
14
+ import type { ExportOptions, ExportResult } from "./types";
15
+
16
+ /**
17
+ * Check if a file exists
18
+ */
19
+ async function fileExists(path: string): Promise<boolean> {
20
+ try {
21
+ await access(path);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Export to .cursorrules
30
+ */
31
+ export async function exportCursor(
32
+ projectPath: string,
33
+ options: ExportOptions = {}
34
+ ): Promise<ExportResult> {
35
+ const aidePath = options.aidePath || join(projectPath, "bantay.aide");
36
+ const outputPath = options.outputPath || join(projectPath, ".cursorrules");
37
+
38
+ // Read the aide tree
39
+ const tree = await readAide(aidePath);
40
+
41
+ // Extract entities
42
+ const constraints = extractConstraints(tree);
43
+ const foundations = extractFoundations(tree);
44
+ const invariants = extractInvariants(tree);
45
+
46
+ // Generate section content (same format as claude)
47
+ const section = generateClaudeSection(constraints, foundations, invariants);
48
+
49
+ // Read existing file if it exists
50
+ let existingContent = "";
51
+ if (await fileExists(outputPath)) {
52
+ existingContent = await readFile(outputPath, "utf-8");
53
+ }
54
+
55
+ // Insert or replace section
56
+ const content = insertSection(existingContent, section);
57
+
58
+ // Write unless dry run
59
+ if (!options.dryRun) {
60
+ await writeFile(outputPath, content, "utf-8");
61
+ }
62
+
63
+ return {
64
+ target: "cursor",
65
+ outputPath,
66
+ content,
67
+ bytesWritten: Buffer.byteLength(content, "utf-8"),
68
+ };
69
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Export module - generates output files from bantay.aide
3
+ */
4
+
5
+ export { exportInvariants, generateInvariantsMd } from "./invariants";
6
+ export { exportClaude, generateClaudeSection, insertSection } from "./claude";
7
+ export { exportCursor } from "./cursor";
8
+ export { exportAll } from "./all";
9
+
10
+ export type {
11
+ ExportOptions,
12
+ ExportResult,
13
+ ExportTarget,
14
+ ExtractedInvariant,
15
+ ExtractedConstraint,
16
+ ExtractedFoundation,
17
+ ExtractedWisdom,
18
+ } from "./types";
19
+
20
+ export { SECTION_START, SECTION_END } from "./types";
21
+
22
+ export {
23
+ extractInvariants,
24
+ extractConstraints,
25
+ extractFoundations,
26
+ extractWisdom,
27
+ groupBy,
28
+ } from "./aide-reader";