@bantay/cli 0.1.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * CI workflow generator
3
+ *
4
+ * Generates CI configuration for GitHub Actions, GitLab CI, or generic shell commands.
5
+ */
6
+
7
+ import { readFile, writeFile, access, mkdir } from "fs/promises";
8
+ import { join, dirname } from "path";
9
+
10
+ export interface CiOptions {
11
+ provider: "github-actions" | "gitlab" | "generic";
12
+ force?: boolean;
13
+ }
14
+
15
+ export interface CiResult {
16
+ provider: string;
17
+ outputPath?: string;
18
+ content: string;
19
+ alreadyExists?: boolean;
20
+ }
21
+
22
+ async function fileExists(path: string): Promise<boolean> {
23
+ try {
24
+ await access(path);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Generate GitHub Actions workflow YAML
33
+ */
34
+ function generateGitHubWorkflow(): string {
35
+ return `name: Bantay Invariant Check
36
+
37
+ on:
38
+ pull_request:
39
+ branches: [main, master]
40
+ push:
41
+ branches: [main, master]
42
+
43
+ jobs:
44
+ bantay-check:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+
49
+ - name: Setup Bun
50
+ uses: oven-sh/setup-bun@v1
51
+ with:
52
+ bun-version: latest
53
+
54
+ - name: Install dependencies
55
+ run: bun install
56
+
57
+ - name: Run Bantay check
58
+ run: bunx @bantay/cli check --json > bantay-results.json 2>&1 || true
59
+
60
+ - name: Upload results
61
+ uses: actions/upload-artifact@v4
62
+ with:
63
+ name: bantay-results
64
+ path: bantay-results.json
65
+
66
+ - name: Check for failures
67
+ run: |
68
+ if grep -q '"status":"fail"' bantay-results.json; then
69
+ echo "Invariant violations found!"
70
+ cat bantay-results.json
71
+ exit 1
72
+ fi
73
+ echo "All invariants pass!"
74
+ `;
75
+ }
76
+
77
+ /**
78
+ * Generate GitLab CI configuration
79
+ */
80
+ function generateGitLabConfig(): string {
81
+ return `# Bantay invariant check stage
82
+ # Add this to your .gitlab-ci.yml
83
+
84
+ bantay:
85
+ stage: test
86
+ image: oven/bun:latest
87
+ script:
88
+ - bun install
89
+ - bunx @bantay/cli check --json > bantay-results.json
90
+ artifacts:
91
+ paths:
92
+ - bantay-results.json
93
+ reports:
94
+ dotenv: bantay-results.json
95
+ rules:
96
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
97
+ - if: '$CI_COMMIT_BRANCH == "main"'
98
+ - if: '$CI_COMMIT_BRANCH == "master"'
99
+ `;
100
+ }
101
+
102
+ /**
103
+ * Generate generic CI instructions
104
+ */
105
+ function generateGenericInstructions(): string {
106
+ return `# Bantay CI Integration
107
+
108
+ Run these commands in your CI pipeline:
109
+
110
+ ## Install Bun (if not available)
111
+ curl -fsSL https://bun.sh/install | bash
112
+
113
+ ## Install dependencies
114
+ bun install
115
+
116
+ ## Run invariant check
117
+ bunx @bantay/cli check
118
+
119
+ ## For JSON output (recommended for CI parsing)
120
+ bunx @bantay/cli check --json > bantay-results.json
121
+
122
+ ## Exit code
123
+ # - 0: All invariants pass
124
+ # - 1: One or more invariants failed
125
+ # - 2: Configuration error
126
+
127
+ ## Example for common CI systems:
128
+
129
+ ### CircleCI
130
+ # jobs:
131
+ # bantay:
132
+ # docker:
133
+ # - image: oven/bun:latest
134
+ # steps:
135
+ # - checkout
136
+ # - run: bun install
137
+ # - run: bunx @bantay/cli check
138
+
139
+ ### Jenkins (Jenkinsfile)
140
+ # stage('Bantay Check') {
141
+ # steps {
142
+ # sh 'curl -fsSL https://bun.sh/install | bash'
143
+ # sh 'bun install'
144
+ # sh 'bunx @bantay/cli check'
145
+ # }
146
+ # }
147
+
148
+ ### Azure Pipelines
149
+ # - script: |
150
+ # curl -fsSL https://bun.sh/install | bash
151
+ # export PATH="$HOME/.bun/bin:$PATH"
152
+ # bun install
153
+ # bunx @bantay/cli check
154
+ # displayName: 'Run Bantay check'
155
+ `;
156
+ }
157
+
158
+ /**
159
+ * Run CI generator
160
+ */
161
+ export async function runCi(
162
+ projectPath: string,
163
+ options: CiOptions
164
+ ): Promise<CiResult> {
165
+ const { provider, force } = options;
166
+
167
+ if (provider === "github-actions") {
168
+ const workflowDir = join(projectPath, ".github", "workflows");
169
+ const outputPath = join(workflowDir, "bantay.yml");
170
+
171
+ // Check if file already exists
172
+ if (await fileExists(outputPath)) {
173
+ if (!force) {
174
+ return {
175
+ provider: "github-actions",
176
+ outputPath,
177
+ content: "",
178
+ alreadyExists: true,
179
+ };
180
+ }
181
+ }
182
+
183
+ // Create directory if needed
184
+ await mkdir(workflowDir, { recursive: true });
185
+
186
+ // Generate and write workflow
187
+ const content = generateGitHubWorkflow();
188
+ await writeFile(outputPath, content, "utf-8");
189
+
190
+ return {
191
+ provider: "github-actions",
192
+ outputPath,
193
+ content,
194
+ };
195
+ }
196
+
197
+ if (provider === "gitlab") {
198
+ const outputPath = join(projectPath, ".gitlab-ci.bantay.yml");
199
+
200
+ // Check if file already exists
201
+ if (await fileExists(outputPath)) {
202
+ if (!force) {
203
+ return {
204
+ provider: "gitlab",
205
+ outputPath,
206
+ content: "",
207
+ alreadyExists: true,
208
+ };
209
+ }
210
+ }
211
+
212
+ // Generate and write config
213
+ const content = generateGitLabConfig();
214
+ await writeFile(outputPath, content, "utf-8");
215
+
216
+ return {
217
+ provider: "gitlab",
218
+ outputPath,
219
+ content,
220
+ };
221
+ }
222
+
223
+ // Generic - just return instructions, don't write file
224
+ return {
225
+ provider: "generic",
226
+ content: generateGenericInstructions(),
227
+ };
228
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * diff.ts — bantay diff command
3
+ *
4
+ * Wraps bantay aide diff with entity type classification based on parent chain.
5
+ * bantay aide diff stays unchanged (raw structural output).
6
+ * bantay diff adds classification for human-readable output.
7
+ *
8
+ * @scenario sc_diff_classified
9
+ */
10
+
11
+ import { existsSync } from "fs";
12
+ import { readFile, readdir } from "fs/promises";
13
+ import { read, type AideTree } from "../aide";
14
+
15
+ /**
16
+ * Container parents that define entity types
17
+ */
18
+ const PARENT_TYPE_MAP: Record<string, string> = {
19
+ cujs: "scenario",
20
+ invariants: "invariant",
21
+ constraints: "constraint",
22
+ foundations: "foundation",
23
+ wisdom: "wisdom",
24
+ };
25
+
26
+ const CUJ_PREFIX = "cuj_";
27
+
28
+ /**
29
+ * Classified change entry
30
+ */
31
+ export interface ClassifiedChange {
32
+ action: "ADDED" | "MODIFIED" | "REMOVED";
33
+ type: string;
34
+ entity_id: string;
35
+ parent?: string;
36
+ from?: string;
37
+ to?: string;
38
+ relationship_type?: string;
39
+ }
40
+
41
+ /**
42
+ * Result from diff command
43
+ */
44
+ export interface DiffResult {
45
+ hasChanges: boolean;
46
+ changes: ClassifiedChange[];
47
+ }
48
+
49
+ /**
50
+ * Discover aide file in directory
51
+ */
52
+ async function discoverAideFile(cwd: string): Promise<string | null> {
53
+ try {
54
+ const files = await readdir(cwd);
55
+ const aideFiles = files.filter((f) => f.endsWith(".aide"));
56
+ if (aideFiles.length === 1) {
57
+ return `${cwd}/${aideFiles[0]}`;
58
+ }
59
+ return null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get entity type by walking parent chain
67
+ */
68
+ function getEntityTypeByParent(id: string, parent: string | undefined, tree: AideTree): string {
69
+ if (!parent) {
70
+ return "entity";
71
+ }
72
+
73
+ // Direct mapping
74
+ if (parent in PARENT_TYPE_MAP) {
75
+ return PARENT_TYPE_MAP[parent];
76
+ }
77
+
78
+ // If parent starts with cuj_, child is a scenario
79
+ if (parent.startsWith(CUJ_PREFIX)) {
80
+ return "scenario";
81
+ }
82
+
83
+ // Walk up the parent chain
84
+ const parentEntity = tree.entities[parent];
85
+ if (parentEntity && parentEntity.parent) {
86
+ return getEntityTypeByParent(id, parentEntity.parent, tree);
87
+ }
88
+
89
+ return "entity";
90
+ }
91
+
92
+ /**
93
+ * Get entity type from parent ID (for removed entities)
94
+ */
95
+ function getEntityTypeFromParentId(parentId: string): string {
96
+ if (parentId in PARENT_TYPE_MAP) {
97
+ return PARENT_TYPE_MAP[parentId];
98
+ }
99
+ if (parentId.startsWith(CUJ_PREFIX)) {
100
+ return "scenario";
101
+ }
102
+ return "entity";
103
+ }
104
+
105
+ /**
106
+ * Fallback: get entity type from ID prefix
107
+ */
108
+ function getEntityTypeByIdPrefix(id: string): string {
109
+ const prefixes: Record<string, string> = {
110
+ cuj_: "cuj",
111
+ sc_: "scenario",
112
+ inv_: "invariant",
113
+ con_: "constraint",
114
+ found_: "foundation",
115
+ wis_: "wisdom",
116
+ };
117
+ for (const [prefix, type] of Object.entries(prefixes)) {
118
+ if (id.startsWith(prefix)) {
119
+ return type;
120
+ }
121
+ }
122
+ return "entity";
123
+ }
124
+
125
+ /**
126
+ * Lock file entity with parent info
127
+ */
128
+ interface LockFileEntity {
129
+ hash: string;
130
+ parent?: string;
131
+ }
132
+
133
+ interface LockFile {
134
+ entities: Record<string, LockFileEntity>;
135
+ relationships: string[];
136
+ }
137
+
138
+ /**
139
+ * Parse lock file content
140
+ */
141
+ function parseLockFile(content: string): LockFile {
142
+ const result: LockFile = {
143
+ entities: {},
144
+ relationships: [],
145
+ };
146
+
147
+ let section = "";
148
+ const lines = content.split("\n");
149
+
150
+ for (const line of lines) {
151
+ const trimmed = line.trim();
152
+
153
+ if (trimmed.startsWith("#") || trimmed === "") {
154
+ continue;
155
+ }
156
+
157
+ if (trimmed === "entities:") {
158
+ section = "entities";
159
+ continue;
160
+ }
161
+ if (trimmed === "relationships:") {
162
+ section = "relationships";
163
+ continue;
164
+ }
165
+
166
+ if (section === "entities") {
167
+ const matchWithParent = trimmed.match(/^(\w+):\s*(\w+)\s+parent:(\w+)$/);
168
+ if (matchWithParent) {
169
+ result.entities[matchWithParent[1]] = {
170
+ hash: matchWithParent[2],
171
+ parent: matchWithParent[3],
172
+ };
173
+ } else {
174
+ const match = trimmed.match(/^(\w+):\s*(\w+)$/);
175
+ if (match) {
176
+ result.entities[match[1]] = {
177
+ hash: match[2],
178
+ };
179
+ }
180
+ }
181
+ } else if (section === "relationships") {
182
+ const match = trimmed.match(/^-\s*(\w+):(\w+):(\w+):\s*\w+$/);
183
+ if (match) {
184
+ result.relationships.push(`${match[1]}:${match[2]}:${match[3]}`);
185
+ }
186
+ }
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Compute entity hash (same as aide.ts)
194
+ */
195
+ function computeEntityHash(
196
+ id: string,
197
+ entity: { display?: string; parent?: string; props?: Record<string, unknown> }
198
+ ): string {
199
+ const str = JSON.stringify({ id, ...entity });
200
+ let hash = 0;
201
+ for (let i = 0; i < str.length; i++) {
202
+ const char = str.charCodeAt(i);
203
+ hash = ((hash << 5) - hash) + char;
204
+ hash = hash & hash;
205
+ }
206
+ return Math.abs(hash).toString(16).padStart(8, "0");
207
+ }
208
+
209
+ /**
210
+ * Run diff and classify changes
211
+ */
212
+ export async function runDiff(projectPath: string): Promise<DiffResult> {
213
+ const aidePath = await discoverAideFile(projectPath);
214
+
215
+ if (!aidePath) {
216
+ throw new Error("No .aide file found. Run 'bantay aide init' to create one.");
217
+ }
218
+
219
+ const lockPath = `${aidePath}.lock`;
220
+
221
+ if (!existsSync(lockPath)) {
222
+ throw new Error(`Lock file not found: ${lockPath}. Run 'bantay aide lock' first.`);
223
+ }
224
+
225
+ // Read current aide tree
226
+ const tree = await read(aidePath);
227
+
228
+ // Read and parse lock file
229
+ const lockContent = await readFile(lockPath, "utf-8");
230
+ const lock = parseLockFile(lockContent);
231
+
232
+ // Compute current hashes
233
+ const currentHashes: Record<string, string> = {};
234
+ for (const [id, entity] of Object.entries(tree.entities)) {
235
+ currentHashes[id] = computeEntityHash(id, entity);
236
+ }
237
+
238
+ // Compute current relationships
239
+ const currentRelationships = new Set<string>();
240
+ for (const rel of tree.relationships) {
241
+ currentRelationships.add(`${rel.from}:${rel.to}:${rel.type}`);
242
+ }
243
+
244
+ const lockRelationships = new Set<string>(lock.relationships);
245
+
246
+ // Build classified changes
247
+ const changes: ClassifiedChange[] = [];
248
+
249
+ // Added entities
250
+ for (const id of Object.keys(currentHashes)) {
251
+ if (!(id in lock.entities)) {
252
+ const entity = tree.entities[id];
253
+ const parent = entity.parent;
254
+ const entityType = getEntityTypeByParent(id, parent, tree);
255
+ changes.push({
256
+ action: "ADDED",
257
+ type: entityType,
258
+ entity_id: id,
259
+ parent,
260
+ });
261
+ } else if (lock.entities[id].hash !== currentHashes[id]) {
262
+ const entity = tree.entities[id];
263
+ const parent = entity.parent;
264
+ const entityType = getEntityTypeByParent(id, parent, tree);
265
+ changes.push({
266
+ action: "MODIFIED",
267
+ type: entityType,
268
+ entity_id: id,
269
+ parent,
270
+ });
271
+ }
272
+ }
273
+
274
+ // Removed entities
275
+ for (const id of Object.keys(lock.entities)) {
276
+ if (!(id in currentHashes)) {
277
+ const lockEntity = lock.entities[id];
278
+ let entityType: string;
279
+ if (lockEntity.parent) {
280
+ entityType = getEntityTypeFromParentId(lockEntity.parent);
281
+ } else {
282
+ entityType = getEntityTypeByIdPrefix(id);
283
+ }
284
+ changes.push({
285
+ action: "REMOVED",
286
+ type: entityType,
287
+ entity_id: id,
288
+ parent: lockEntity.parent,
289
+ });
290
+ }
291
+ }
292
+
293
+ // Added relationships
294
+ for (const rel of currentRelationships) {
295
+ if (!lockRelationships.has(rel)) {
296
+ const [from, to, relType] = rel.split(":");
297
+ changes.push({
298
+ action: "ADDED",
299
+ type: "relationship",
300
+ entity_id: `${from}:${to}`,
301
+ from,
302
+ to,
303
+ relationship_type: relType,
304
+ });
305
+ }
306
+ }
307
+
308
+ // Removed relationships
309
+ for (const rel of lockRelationships) {
310
+ if (!currentRelationships.has(rel)) {
311
+ const [from, to, relType] = rel.split(":");
312
+ changes.push({
313
+ action: "REMOVED",
314
+ type: "relationship",
315
+ entity_id: `${from}:${to}`,
316
+ from,
317
+ to,
318
+ relationship_type: relType,
319
+ });
320
+ }
321
+ }
322
+
323
+ return {
324
+ hasChanges: changes.length > 0,
325
+ changes,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Format diff result as human-readable output
331
+ */
332
+ export function formatDiff(result: DiffResult): string {
333
+ if (!result.hasChanges) {
334
+ return "No changes since last lock.";
335
+ }
336
+
337
+ const lines: string[] = [];
338
+
339
+ // Sort by action then by entity_id
340
+ const sorted = [...result.changes].sort((a, b) => {
341
+ const actionOrder = { ADDED: 0, MODIFIED: 1, REMOVED: 2 };
342
+ const actionDiff = actionOrder[a.action] - actionOrder[b.action];
343
+ if (actionDiff !== 0) return actionDiff;
344
+ return a.entity_id.localeCompare(b.entity_id);
345
+ });
346
+
347
+ for (const change of sorted) {
348
+ if (change.type === "relationship") {
349
+ lines.push(`${change.action} relationship: ${change.from} → ${change.to}`);
350
+ } else {
351
+ const parentStr = change.parent ? ` (parent: ${change.parent})` : "";
352
+ lines.push(`${change.action} ${change.type}: ${change.entity_id}${parentStr}`);
353
+ }
354
+ }
355
+
356
+ return lines.join("\n");
357
+ }
358
+
359
+ /**
360
+ * Format diff result as JSON
361
+ */
362
+ export function formatDiffJson(result: DiffResult): string {
363
+ return JSON.stringify(result, null, 2);
364
+ }
365
+
366
+ /**
367
+ * Handle bantay diff command
368
+ */
369
+ export async function handleDiff(args: string[]): Promise<void> {
370
+ const projectPath = process.cwd();
371
+ const jsonOutput = args.includes("--json");
372
+
373
+ try {
374
+ const result = await runDiff(projectPath);
375
+
376
+ if (jsonOutput) {
377
+ console.log(formatDiffJson(result));
378
+ } else {
379
+ console.log(formatDiff(result));
380
+ }
381
+
382
+ process.exit(result.hasChanges ? 1 : 0);
383
+ } catch (error) {
384
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
385
+ process.exit(1);
386
+ }
387
+ }
@@ -1,11 +1,18 @@
1
- import { writeFile, access } from "fs/promises";
1
+ import { writeFile, access, mkdir } from "fs/promises";
2
2
  import { join } from "path";
3
3
  import { detectStack, type StackDetectionResult } from "../detectors";
4
4
  import { generateInvariants } from "../generators/invariants";
5
5
  import { generateConfig, configToYaml } from "../generators/config";
6
+ import {
7
+ generateInterviewCommand,
8
+ generateStatusCommand,
9
+ generateCheckCommand,
10
+ generateOrchestrateCommand,
11
+ } from "../generators/claude-commands";
6
12
 
7
13
  export interface InitOptions {
8
14
  regenerateConfig?: boolean;
15
+ force?: boolean;
9
16
  }
10
17
 
11
18
  export interface InitResult {
@@ -66,6 +73,36 @@ export async function runInit(
66
73
  filesCreated.push("bantay.config.yml");
67
74
  }
68
75
 
76
+ // Generate Claude Code slash commands
77
+ const claudeCommandsDir = join(projectPath, ".claude", "commands");
78
+ await mkdir(claudeCommandsDir, { recursive: true });
79
+
80
+ const interviewPath = join(claudeCommandsDir, "bantay-interview.md");
81
+ const statusPath = join(claudeCommandsDir, "bantay-status.md");
82
+ const checkPath = join(claudeCommandsDir, "bantay-check.md");
83
+ const orchestratePath = join(claudeCommandsDir, "bantay-orchestrate.md");
84
+
85
+ // Only create if they don't exist (don't overwrite user customizations) unless --force
86
+ if (options?.force || !(await fileExists(interviewPath))) {
87
+ await writeFile(interviewPath, generateInterviewCommand());
88
+ filesCreated.push(".claude/commands/bantay-interview.md");
89
+ }
90
+
91
+ if (options?.force || !(await fileExists(statusPath))) {
92
+ await writeFile(statusPath, generateStatusCommand());
93
+ filesCreated.push(".claude/commands/bantay-status.md");
94
+ }
95
+
96
+ if (options?.force || !(await fileExists(checkPath))) {
97
+ await writeFile(checkPath, generateCheckCommand());
98
+ filesCreated.push(".claude/commands/bantay-check.md");
99
+ }
100
+
101
+ if (options?.force || !(await fileExists(orchestratePath))) {
102
+ await writeFile(orchestratePath, generateOrchestrateCommand());
103
+ filesCreated.push(".claude/commands/bantay-orchestrate.md");
104
+ }
105
+
69
106
  return {
70
107
  success: true,
71
108
  filesCreated,