@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.
- package/package.json +3 -3
- package/src/aide/discovery.ts +88 -0
- package/src/aide/index.ts +9 -0
- package/src/cli.ts +133 -11
- package/src/commands/aide.ts +432 -48
- package/src/commands/check.ts +9 -4
- package/src/commands/ci.ts +228 -0
- package/src/commands/diff.ts +387 -0
- package/src/commands/init.ts +38 -1
- package/src/commands/status.ts +311 -0
- package/src/commands/tasks.ts +220 -0
- package/src/export/all.ts +5 -1
- package/src/export/claude.ts +8 -5
- package/src/export/codex.ts +72 -0
- package/src/export/cursor.ts +5 -3
- package/src/export/index.ts +1 -0
- package/src/export/invariants.ts +7 -5
- package/src/generators/claude-commands.ts +61 -0
- package/src/templates/commands/bantay-check.md +58 -0
- package/src/templates/commands/bantay-interview.md +207 -0
- package/src/templates/commands/bantay-orchestrate.md +164 -0
- package/src/templates/commands/bantay-status.md +39 -0
|
@@ -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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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,
|