@clubmatto/ai-kit 0.0.6 → 0.0.8
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/.agents/disabled/spring-boot.md +5 -0
- package/CHANGELOG.md +1 -1
- package/README.md +33 -10
- package/dist/src/cmd/sync.js +145 -71
- package/dist/src/detection/detect.js +46 -0
- package/dist/src/detection/language-detectors.js +23 -0
- package/dist/src/detection/scanner.js +23 -0
- package/dist/src/index.js +13 -1
- package/dist/src/manifest.js +16 -2
- package/dist/src/output.js +21 -10
- package/dist/src/plan.js +91 -0
- package/dist/src/reader.js +10 -3
- package/dist/src/template.js +6 -0
- package/package.json +3 -2
- package/src/agents/monorepo.md +1 -1
- package/src/agents/single-repo.md +15 -0
- package/src/rules/spring-boot.md +0 -549
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@clubmatto/ai-kit)
|
|
5
5
|
[](/LICENSE)
|
|
6
6
|
|
|
7
|
-
The AI configuration CLI from Club Matto. Sync rules, skills, and commands to
|
|
7
|
+
The AI configuration CLI from Club Matto. Sync rules, skills, and commands to
|
|
8
|
+
power up your AI coding workflow.
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
11
12
|
- **Language Rules** — TypeScript, Go, Kotlin, and more
|
|
12
13
|
- **Skills** — Reusable AI capabilities like Playwright automation
|
|
13
|
-
- **Commands** — Pre-built prompts for common tasks (commit messages, PR
|
|
14
|
+
- **Commands** — Pre-built prompts for common tasks (commit messages, PR
|
|
15
|
+
reviews)
|
|
14
16
|
|
|
15
17
|
## Quick Start
|
|
16
18
|
|
|
@@ -30,16 +32,33 @@ ai-kit sync
|
|
|
30
32
|
|
|
31
33
|
# Skip installing opencode.json to project root
|
|
32
34
|
ai-kit sync --skip-opencode
|
|
35
|
+
|
|
36
|
+
# Language detection & filtering
|
|
37
|
+
ai-kit sync --all-rules # Install all language rules
|
|
38
|
+
ai-kit sync --languages=go,kotlin # Install specific language rules
|
|
39
|
+
ai-kit sync --monorepo # Force monorepo AGENTS.md template
|
|
40
|
+
ai-kit sync --single-repo # Force single-repo AGENTS.md template
|
|
33
41
|
```
|
|
34
42
|
|
|
43
|
+
The CLI automatically detects project languages and installs only relevant
|
|
44
|
+
rules:
|
|
45
|
+
|
|
46
|
+
- **TypeScript/JavaScript**: `package.json` or `.ts`/`.js` files
|
|
47
|
+
- **Go**: `go.mod` or `.go` files
|
|
48
|
+
- **Kotlin**: `build.gradle`, `build.gradle.kts`, `pom.xml` or `.kt` files
|
|
49
|
+
- **Spring Boot**: `application.properties`/`.yml` + Kotlin/Java files
|
|
50
|
+
|
|
51
|
+
Multiple languages → monorepo mode (all rules + monorepo AGENTS.md).
|
|
52
|
+
Single language → single-repo mode (language-specific AGENTS.md).
|
|
53
|
+
|
|
35
54
|
## What's Installed
|
|
36
55
|
|
|
37
|
-
| Location | Description
|
|
38
|
-
| ----------------- |
|
|
39
|
-
| `.agents/rules/` | Language/framework rules
|
|
40
|
-
| `.agents/skills/` | Reusable AI capabilities
|
|
41
|
-
| `opencode.json` | Opencode configuration (optional)
|
|
42
|
-
| `AGENTS.md` | Agent instructions
|
|
56
|
+
| Location | Description |
|
|
57
|
+
| ----------------- | ----------------------------------------- |
|
|
58
|
+
| `.agents/rules/` | Language/framework rules (auto-detected) |
|
|
59
|
+
| `.agents/skills/` | Reusable AI capabilities |
|
|
60
|
+
| `opencode.json` | Opencode configuration (optional) |
|
|
61
|
+
| `AGENTS.md` | Agent instructions (monorepo/single-repo) |
|
|
43
62
|
|
|
44
63
|
## Commands
|
|
45
64
|
|
|
@@ -63,9 +82,13 @@ ai-kit sync
|
|
|
63
82
|
## Release
|
|
64
83
|
|
|
65
84
|
```bash
|
|
66
|
-
#
|
|
85
|
+
# Bump version in package.json first
|
|
86
|
+
git add ai-kit/package.json
|
|
87
|
+
git commit -m "release: bump version to <version>"
|
|
88
|
+
|
|
89
|
+
# Create git tag and push both (tag triggers automated release)
|
|
67
90
|
git tag ai-kit/v<version>
|
|
68
|
-
git push origin
|
|
91
|
+
git push origin main --follow-tags
|
|
69
92
|
```
|
|
70
93
|
|
|
71
94
|
## License
|
package/dist/src/cmd/sync.js
CHANGED
|
@@ -7,6 +7,9 @@ const reader_1 = require("../reader");
|
|
|
7
7
|
const manifest_1 = require("../manifest");
|
|
8
8
|
const template_1 = require("../template");
|
|
9
9
|
const output_1 = require("../output");
|
|
10
|
+
const detect_1 = require("../detection/detect");
|
|
11
|
+
const language_detectors_1 = require("../detection/language-detectors");
|
|
12
|
+
const plan_1 = require("../plan");
|
|
10
13
|
const rootDir = (0, path_1.join)(__dirname, "..", "..", "..");
|
|
11
14
|
const defaultSourceDirs = {
|
|
12
15
|
rules: (0, path_1.join)(rootDir, "src", "rules"),
|
|
@@ -15,95 +18,166 @@ const defaultSourceDirs = {
|
|
|
15
18
|
commands: (0, path_1.join)(rootDir, "src", "commands"),
|
|
16
19
|
};
|
|
17
20
|
async function sync(cwd, version, options, logger = output_1.log, sourceDirs = defaultSourceDirs) {
|
|
18
|
-
const manifest = (0, manifest_1.readManifest)(cwd);
|
|
19
21
|
logger.logo(version);
|
|
20
|
-
if (manifest && manifest.version === version) {
|
|
21
|
-
logger.success(`Already at latest version (${version})`);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
22
|
logger.welcome();
|
|
25
|
-
const counts = await doSync(cwd, version, options, logger, sourceDirs);
|
|
26
|
-
logger.summary(counts);
|
|
27
|
-
}
|
|
28
|
-
function writeItem(aiDir, file) {
|
|
29
|
-
const targetDir = (0, path_1.join)(aiDir, file.type);
|
|
30
|
-
if (!(0, fs_1.existsSync)(targetDir)) {
|
|
31
|
-
(0, fs_1.mkdirSync)(targetDir, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
const targetPath = (0, path_1.join)(targetDir, file.name);
|
|
34
|
-
const parentDir = (0, path_1.dirname)(targetPath);
|
|
35
|
-
if (!(0, fs_1.existsSync)(parentDir)) {
|
|
36
|
-
(0, fs_1.mkdirSync)(parentDir, { recursive: true });
|
|
37
|
-
}
|
|
38
|
-
(0, fs_1.writeFileSync)(targetPath, (0, template_1.processTemplate)(file.content));
|
|
39
|
-
}
|
|
40
|
-
async function doSync(cwd, version, options, logger, sourceDirs) {
|
|
41
23
|
const aiDir = (0, path_1.join)(cwd, ".agents");
|
|
42
24
|
if (!(0, fs_1.existsSync)(aiDir)) {
|
|
43
25
|
(0, fs_1.mkdirSync)(aiDir, { recursive: true });
|
|
44
26
|
}
|
|
27
|
+
const manifest = (0, manifest_1.readManifest)(cwd);
|
|
28
|
+
const desired = buildDesiredFiles(sourceDirs, cwd, options);
|
|
29
|
+
const actions = (0, plan_1.diffDesired)(desired, manifest, cwd);
|
|
30
|
+
const changes = executeActions(actions, cwd, logger);
|
|
31
|
+
const newFiles = {};
|
|
32
|
+
for (const [relPath, df] of desired) {
|
|
33
|
+
newFiles[relPath] = { sourceHash: df.identity };
|
|
34
|
+
}
|
|
35
|
+
(0, manifest_1.writeManifest)(cwd, { version, files: newFiles });
|
|
36
|
+
logger.summary(changes);
|
|
37
|
+
}
|
|
38
|
+
function buildDesiredFiles(sourceDirs, cwd, options) {
|
|
39
|
+
const desired = new Map();
|
|
45
40
|
const contentFiles = (0, reader_1.readContent)(sourceDirs.rules, sourceDirs.skills);
|
|
46
41
|
const rootFiles = (0, reader_1.readConfigs)(sourceDirs.agents);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
42
|
+
const detectionResult = (0, detect_1.detectLanguages)(cwd);
|
|
43
|
+
let languages = detectionResult.languages;
|
|
44
|
+
let isMonorepo = detectionResult.isMonorepo;
|
|
45
|
+
const primaryLanguage = detectionResult.primaryLanguage;
|
|
46
|
+
if (options.allRules) {
|
|
47
|
+
languages = language_detectors_1.detectors.map((d) => d.name);
|
|
48
|
+
isMonorepo = true;
|
|
49
|
+
}
|
|
50
|
+
else if (options.monorepo) {
|
|
51
|
+
isMonorepo = true;
|
|
52
|
+
}
|
|
53
|
+
else if (options.singleRepo) {
|
|
54
|
+
isMonorepo = false;
|
|
55
|
+
}
|
|
56
|
+
if (options.languages && options.languages.length > 0) {
|
|
57
|
+
languages = options.languages;
|
|
58
|
+
isMonorepo = languages.length > 1;
|
|
59
|
+
}
|
|
60
|
+
if (languages.length === 0) {
|
|
61
|
+
isMonorepo = true;
|
|
62
|
+
}
|
|
63
|
+
const agentsFile = (0, reader_1.readAgents)(sourceDirs.agents, isMonorepo, primaryLanguage);
|
|
64
|
+
const ruleFilesToInclude = options.allRules
|
|
65
|
+
? (0, detect_1.getAllRuleFiles)()
|
|
66
|
+
: (0, detect_1.getRuleFilesForLanguages)(languages);
|
|
67
|
+
const rules = contentFiles.filter((f) => {
|
|
68
|
+
if (f.type !== "rules")
|
|
69
|
+
return false;
|
|
70
|
+
if (!(0, detect_1.isLanguageSpecificRule)(f.name))
|
|
71
|
+
return true;
|
|
72
|
+
return ruleFilesToInclude.includes(f.name);
|
|
73
|
+
});
|
|
51
74
|
if (!options.skipOpencode) {
|
|
52
75
|
const commandConfig = (0, reader_1.getCommandConfig)(sourceDirs.commands);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (rootFiles.length > 0) {
|
|
61
|
-
logger.section("configs");
|
|
62
|
-
for (const file of rootFiles) {
|
|
63
|
-
let content = file.content;
|
|
64
|
-
if (file.name === "opencode.json" &&
|
|
65
|
-
Object.keys(commandConfig).length > 0) {
|
|
66
|
-
const config = JSON.parse(content);
|
|
67
|
-
config.command = commandConfig;
|
|
68
|
-
content = JSON.stringify(config, null, 2) + "\n";
|
|
69
|
-
}
|
|
70
|
-
const targetPath = (0, path_1.join)(cwd, file.name);
|
|
71
|
-
(0, fs_1.writeFileSync)(targetPath, content);
|
|
72
|
-
logger.success(`${file.name}`);
|
|
73
|
-
installedRootFiles.push(file.name);
|
|
76
|
+
for (const file of rootFiles) {
|
|
77
|
+
let content = file.content;
|
|
78
|
+
if (file.name === "opencode.json" &&
|
|
79
|
+
Object.keys(commandConfig).length > 0) {
|
|
80
|
+
const config = JSON.parse(content);
|
|
81
|
+
config.command = commandConfig;
|
|
82
|
+
content = JSON.stringify(config, null, 2) + "\n";
|
|
74
83
|
}
|
|
84
|
+
desired.set(file.name, {
|
|
85
|
+
path: file.name,
|
|
86
|
+
identity: (0, manifest_1.hashContent)(content),
|
|
87
|
+
content,
|
|
88
|
+
category: file.name === "opencode.json"
|
|
89
|
+
? "opencode-json"
|
|
90
|
+
: "static",
|
|
91
|
+
});
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
94
|
if (agentsFile) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
95
|
+
desired.set(agentsFile.name, {
|
|
96
|
+
path: agentsFile.name,
|
|
97
|
+
identity: (0, manifest_1.hashContent)(agentsFile.content),
|
|
98
|
+
content: (0, template_1.processTemplate)(agentsFile.content),
|
|
99
|
+
category: "agents-md",
|
|
100
|
+
});
|
|
81
101
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
for (const file of rules) {
|
|
103
|
+
const relPath = (0, path_1.join)(".agents", file.type, file.name);
|
|
104
|
+
desired.set(relPath, {
|
|
105
|
+
path: relPath,
|
|
106
|
+
identity: (0, manifest_1.hashContent)(file.content),
|
|
107
|
+
content: (0, template_1.processTemplate)(file.content),
|
|
108
|
+
category: "static",
|
|
109
|
+
});
|
|
89
110
|
}
|
|
90
111
|
const skills = contentFiles.filter((f) => f.type === "skills");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
for (const file of skills) {
|
|
113
|
+
const relPath = (0, path_1.join)(".agents", file.type, file.name);
|
|
114
|
+
desired.set(relPath, {
|
|
115
|
+
path: relPath,
|
|
116
|
+
identity: (0, manifest_1.hashContent)(file.content),
|
|
117
|
+
content: (0, template_1.processTemplate)(file.content),
|
|
118
|
+
category: "static",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return desired;
|
|
122
|
+
}
|
|
123
|
+
function executeActions(actions, cwd, logger) {
|
|
124
|
+
const changes = (0, plan_1.emptySyncChanges)();
|
|
125
|
+
for (const action of actions) {
|
|
126
|
+
const targetPath = (0, path_1.join)(cwd, action.relPath);
|
|
127
|
+
switch (action.action) {
|
|
128
|
+
case "add": {
|
|
129
|
+
const dir = (0, path_1.dirname)(targetPath);
|
|
130
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
131
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
132
|
+
(0, fs_1.writeFileSync)(targetPath, action.content);
|
|
133
|
+
changes.added++;
|
|
134
|
+
logger.success(`+ ${action.relPath}`);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "update": {
|
|
138
|
+
const dir = (0, path_1.dirname)(targetPath);
|
|
139
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
140
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
141
|
+
(0, fs_1.writeFileSync)(targetPath, action.content);
|
|
142
|
+
changes.updated++;
|
|
143
|
+
logger.success(`~ ${action.relPath}`);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "skip":
|
|
147
|
+
changes.skipped++;
|
|
148
|
+
break;
|
|
149
|
+
case "merge": {
|
|
150
|
+
const currentContent = (0, fs_1.readFileSync)(targetPath, "utf-8");
|
|
151
|
+
const merged = (0, plan_1.mergeOpencodeJson)(action.content, currentContent);
|
|
152
|
+
(0, fs_1.writeFileSync)(targetPath, merged);
|
|
153
|
+
changes.merged++;
|
|
154
|
+
logger.success(`M ${action.relPath}`);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "backup": {
|
|
158
|
+
const currentContent = (0, fs_1.readFileSync)(targetPath, "utf-8");
|
|
159
|
+
const backupDir = (0, path_1.join)(cwd, ".agents");
|
|
160
|
+
if (!(0, fs_1.existsSync)(backupDir))
|
|
161
|
+
(0, fs_1.mkdirSync)(backupDir, { recursive: true });
|
|
162
|
+
const backupPath = (0, path_1.join)(backupDir, `${action.relPath}.bak.${Date.now()}`);
|
|
163
|
+
(0, fs_1.writeFileSync)(backupPath, currentContent);
|
|
164
|
+
(0, fs_1.writeFileSync)(targetPath, action.content);
|
|
165
|
+
changes.backedUp++;
|
|
166
|
+
logger.success(`! ${action.relPath}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "remove": {
|
|
170
|
+
(0, fs_1.rmSync)(targetPath, { force: true });
|
|
171
|
+
changes.removed++;
|
|
172
|
+
logger.success(`- ${action.relPath}`);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "warn": {
|
|
176
|
+
changes.warned++;
|
|
177
|
+
logger.warn(`${action.relPath} (modified — skipped)`);
|
|
178
|
+
break;
|
|
100
179
|
}
|
|
101
180
|
}
|
|
102
181
|
}
|
|
103
|
-
|
|
104
|
-
version,
|
|
105
|
-
installedAt: new Date().toISOString(),
|
|
106
|
-
rootFiles: installedRootFiles,
|
|
107
|
-
});
|
|
108
|
-
return stats;
|
|
182
|
+
return changes;
|
|
109
183
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectLanguages = detectLanguages;
|
|
4
|
+
exports.getRuleFilesForLanguages = getRuleFilesForLanguages;
|
|
5
|
+
exports.getAllRuleFiles = getAllRuleFiles;
|
|
6
|
+
exports.isLanguageSpecificRule = isLanguageSpecificRule;
|
|
7
|
+
const language_detectors_1 = require("./language-detectors");
|
|
8
|
+
const scanner_1 = require("./scanner");
|
|
9
|
+
function hasMatchingFile(files, names) {
|
|
10
|
+
return names.some((name) => files.some((f) => f === name || f.endsWith("/" + name)));
|
|
11
|
+
}
|
|
12
|
+
function hasMatchingExtension(files, extensions) {
|
|
13
|
+
return extensions.some((ext) => files.some((f) => f.endsWith(ext)));
|
|
14
|
+
}
|
|
15
|
+
function detectLanguages(cwd) {
|
|
16
|
+
const { allFiles, rootFiles } = (0, scanner_1.scanTree)(cwd);
|
|
17
|
+
const detected = new Set();
|
|
18
|
+
for (const detector of language_detectors_1.detectors) {
|
|
19
|
+
const hasConfigAtRoot = hasMatchingFile(rootFiles, detector.configFiles);
|
|
20
|
+
const hasConfigInTree = hasMatchingFile(allFiles, detector.configFiles);
|
|
21
|
+
const hasSource = hasMatchingExtension(allFiles, detector.extensions);
|
|
22
|
+
if (hasConfigAtRoot || hasConfigInTree || hasSource) {
|
|
23
|
+
detected.add(detector.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const languages = Array.from(detected);
|
|
27
|
+
const isMonorepo = languages.length > 1;
|
|
28
|
+
const primaryLanguage = languages.length > 0 ? languages[0] : undefined;
|
|
29
|
+
return { languages, isMonorepo, primaryLanguage };
|
|
30
|
+
}
|
|
31
|
+
function getRuleFilesForLanguages(languages) {
|
|
32
|
+
const ruleFiles = new Set();
|
|
33
|
+
for (const language of languages) {
|
|
34
|
+
const detector = language_detectors_1.detectors.find((d) => d.name === language);
|
|
35
|
+
if (detector) {
|
|
36
|
+
ruleFiles.add(detector.ruleFile);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Array.from(ruleFiles);
|
|
40
|
+
}
|
|
41
|
+
function getAllRuleFiles() {
|
|
42
|
+
return language_detectors_1.detectors.map((detector) => detector.ruleFile);
|
|
43
|
+
}
|
|
44
|
+
function isLanguageSpecificRule(ruleFile) {
|
|
45
|
+
return language_detectors_1.detectors.some((detector) => detector.ruleFile === ruleFile);
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectors = void 0;
|
|
4
|
+
exports.detectors = [
|
|
5
|
+
{
|
|
6
|
+
name: "typescript",
|
|
7
|
+
ruleFile: "typescript.md",
|
|
8
|
+
configFiles: ["package.json", "tsconfig.json"],
|
|
9
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "go",
|
|
13
|
+
ruleFile: "go.md",
|
|
14
|
+
configFiles: ["go.mod"],
|
|
15
|
+
extensions: [".go"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "kotlin",
|
|
19
|
+
ruleFile: "kotlin.md",
|
|
20
|
+
configFiles: ["build.gradle", "build.gradle.kts", "pom.xml"],
|
|
21
|
+
extensions: [".kt", ".kts", ".java"],
|
|
22
|
+
},
|
|
23
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanTree = scanTree;
|
|
4
|
+
const fdir_1 = require("fdir");
|
|
5
|
+
const IGNORE_DIRS = [
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
"dist",
|
|
9
|
+
"build",
|
|
10
|
+
"target",
|
|
11
|
+
".next",
|
|
12
|
+
".nuxt",
|
|
13
|
+
];
|
|
14
|
+
function scanTree(cwd, maxDepth = 4) {
|
|
15
|
+
const allFiles = new fdir_1.fdir()
|
|
16
|
+
.withRelativePaths()
|
|
17
|
+
.withMaxDepth(maxDepth)
|
|
18
|
+
.exclude((name) => IGNORE_DIRS.includes(name) || name.startsWith("."))
|
|
19
|
+
.crawl(cwd)
|
|
20
|
+
.sync();
|
|
21
|
+
const rootFiles = allFiles.filter((f) => !f.includes("/"));
|
|
22
|
+
return { allFiles, rootFiles };
|
|
23
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -15,5 +15,17 @@ program
|
|
|
15
15
|
program
|
|
16
16
|
.command("sync")
|
|
17
17
|
.description("Initialize or update AI configuration")
|
|
18
|
-
.
|
|
18
|
+
.option("--all-rules", "Install all language rules regardless of detection")
|
|
19
|
+
.option("--monorepo", "Force treat project as monorepo")
|
|
20
|
+
.option("--single-repo", "Force treat project as single repository")
|
|
21
|
+
.option("--languages <languages>", "Specify languages to install rules for (comma-separated)")
|
|
22
|
+
.action((cmdOptions) => {
|
|
23
|
+
const options = { ...program.opts(), ...cmdOptions };
|
|
24
|
+
if (options.languages && typeof options.languages === "string") {
|
|
25
|
+
options.languages = options.languages
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((lang) => lang.trim());
|
|
28
|
+
}
|
|
29
|
+
(0, sync_1.sync)(process.cwd(), version, options);
|
|
30
|
+
});
|
|
19
31
|
program.parse();
|
package/dist/src/manifest.js
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hashContent = hashContent;
|
|
3
4
|
exports.readManifest = readManifest;
|
|
4
5
|
exports.writeManifest = writeManifest;
|
|
5
6
|
const fs_1 = require("fs");
|
|
6
7
|
const path_1 = require("path");
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
7
9
|
const AI_DIR = ".agents";
|
|
8
10
|
const MANIFEST_FILE = ".ai-kit";
|
|
9
11
|
function getManifestPath(cwd) {
|
|
10
12
|
return (0, path_1.join)(cwd, AI_DIR, MANIFEST_FILE);
|
|
11
13
|
}
|
|
14
|
+
function hashContent(content) {
|
|
15
|
+
return (0, crypto_1.createHash)("sha256").update(content, "utf-8").digest("hex");
|
|
16
|
+
}
|
|
12
17
|
function readManifest(cwd) {
|
|
13
18
|
const path = getManifestPath(cwd);
|
|
14
19
|
if (!(0, fs_1.existsSync)(path))
|
|
15
20
|
return null;
|
|
16
|
-
|
|
21
|
+
try {
|
|
22
|
+
const data = JSON.parse((0, fs_1.readFileSync)(path, "utf-8"));
|
|
23
|
+
if (!data.files) {
|
|
24
|
+
return { version: data.version || "0.0.0", files: {} };
|
|
25
|
+
}
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
17
31
|
}
|
|
18
32
|
function writeManifest(cwd, manifest) {
|
|
19
33
|
const dir = (0, path_1.join)(cwd, AI_DIR);
|
|
20
34
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
21
35
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
22
36
|
}
|
|
23
|
-
(0, fs_1.writeFileSync)(getManifestPath(cwd), JSON.stringify(manifest, null, 2));
|
|
37
|
+
(0, fs_1.writeFileSync)(getManifestPath(cwd), JSON.stringify(manifest, null, 2) + "\n");
|
|
24
38
|
}
|
package/dist/src/output.js
CHANGED
|
@@ -30,17 +30,28 @@ exports.log = {
|
|
|
30
30
|
},
|
|
31
31
|
section: (msg) => console.log(colorize(` → ${msg}`, "cyan")),
|
|
32
32
|
success: (msg) => console.log(colorize(` ✓ ${msg}`, "green")),
|
|
33
|
+
warn: (msg) => console.log(colorize(` ! ${msg}`, "yellow")),
|
|
33
34
|
final: (msg) => console.log(colorize(` ✓ ${msg}`, "green")),
|
|
34
35
|
summary: (counts) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
colorize(counts.
|
|
38
|
-
|
|
39
|
-
colorize(
|
|
40
|
-
|
|
41
|
-
colorize(`
|
|
42
|
-
|
|
43
|
-
colorize(counts.
|
|
44
|
-
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (counts.added > 0)
|
|
38
|
+
parts.push(colorize(`+${counts.added} added`, "green"));
|
|
39
|
+
if (counts.updated > 0)
|
|
40
|
+
parts.push(colorize(`~${counts.updated} updated`, "white"));
|
|
41
|
+
if (counts.merged > 0)
|
|
42
|
+
parts.push(colorize(`M${counts.merged} merged`, "yellow"));
|
|
43
|
+
if (counts.backedUp > 0)
|
|
44
|
+
parts.push(colorize(`!${counts.backedUp} backed up`, "yellow"));
|
|
45
|
+
if (counts.removed > 0)
|
|
46
|
+
parts.push(colorize(`-${counts.removed} removed`, "red"));
|
|
47
|
+
if (counts.warned > 0)
|
|
48
|
+
parts.push(colorize(`!${counts.warned} modified (skipped)`, "yellow"));
|
|
49
|
+
if (parts.length === 0) {
|
|
50
|
+
console.log(colorize("\n ✓ Everything up to date!", "green"));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log(colorize("\n ✓ Done!", "green"));
|
|
54
|
+
console.log(" " + parts.join(colorize(", ", "dim")));
|
|
55
|
+
}
|
|
45
56
|
},
|
|
46
57
|
};
|
package/dist/src/plan.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.emptySyncChanges = emptySyncChanges;
|
|
4
|
+
exports.diffDesired = diffDesired;
|
|
5
|
+
exports.mergeOpencodeJson = mergeOpencodeJson;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const template_1 = require("./template");
|
|
9
|
+
function emptySyncChanges() {
|
|
10
|
+
return {
|
|
11
|
+
added: 0,
|
|
12
|
+
updated: 0,
|
|
13
|
+
merged: 0,
|
|
14
|
+
backedUp: 0,
|
|
15
|
+
removed: 0,
|
|
16
|
+
skipped: 0,
|
|
17
|
+
warned: 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function diffDesired(desired, manifest, cwd) {
|
|
21
|
+
const actions = [];
|
|
22
|
+
for (const [relPath, df] of desired) {
|
|
23
|
+
const lastEntry = manifest?.files[relPath];
|
|
24
|
+
const targetPath = (0, path_1.join)(cwd, relPath);
|
|
25
|
+
let onDiskContent = null;
|
|
26
|
+
try {
|
|
27
|
+
onDiskContent = (0, fs_1.readFileSync)(targetPath, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// file doesn't exist
|
|
31
|
+
}
|
|
32
|
+
if (onDiskContent === null) {
|
|
33
|
+
actions.push({ action: "add", relPath, content: df.content });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const normalizedDisk = (0, template_1.stripDates)(onDiskContent);
|
|
37
|
+
const normalizedDesired = (0, template_1.stripDates)(df.content);
|
|
38
|
+
if (normalizedDisk === normalizedDesired) {
|
|
39
|
+
actions.push({ action: "skip", relPath });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const sourceChanged = !lastEntry || lastEntry.sourceHash !== df.identity;
|
|
43
|
+
if (df.category === "opencode-json") {
|
|
44
|
+
actions.push({ action: "merge", relPath, content: df.content });
|
|
45
|
+
}
|
|
46
|
+
else if (sourceChanged && df.category === "agents-md") {
|
|
47
|
+
actions.push({ action: "backup", relPath, content: df.content });
|
|
48
|
+
}
|
|
49
|
+
else if (sourceChanged) {
|
|
50
|
+
actions.push({ action: "update", relPath, content: df.content });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
actions.push({ action: "warn", relPath });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (manifest) {
|
|
57
|
+
for (const relPath of Object.keys(manifest.files)) {
|
|
58
|
+
if (!desired.has(relPath)) {
|
|
59
|
+
actions.push({ action: "remove", relPath });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return actions;
|
|
64
|
+
}
|
|
65
|
+
function mergeOpencodeJson(desiredContent, currentContent) {
|
|
66
|
+
const desired = JSON.parse(desiredContent);
|
|
67
|
+
const current = JSON.parse(currentContent);
|
|
68
|
+
const result = { ...desired };
|
|
69
|
+
if (current.mcp && typeof current.mcp === "object") {
|
|
70
|
+
const resultMcp = result.mcp;
|
|
71
|
+
for (const [key, value] of Object.entries(current.mcp)) {
|
|
72
|
+
if (!(key in resultMcp)) {
|
|
73
|
+
resultMcp[key] = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (current.command && typeof current.command === "object") {
|
|
78
|
+
const resultCmd = result.command;
|
|
79
|
+
for (const [key, value] of Object.entries(current.command)) {
|
|
80
|
+
if (!(key in resultCmd)) {
|
|
81
|
+
resultCmd[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const key of Object.keys(current)) {
|
|
86
|
+
if (!(key in result)) {
|
|
87
|
+
result[key] = current[key];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(result, null, 2) + "\n";
|
|
91
|
+
}
|
package/dist/src/reader.js
CHANGED
|
@@ -84,13 +84,20 @@ function readConfigs(agentsDir) {
|
|
|
84
84
|
return [];
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
function readAgents(agentsDir) {
|
|
88
|
-
const
|
|
87
|
+
function readAgents(agentsDir, isMonorepo = true, primaryLanguage) {
|
|
88
|
+
const templateName = isMonorepo ? "monorepo.md" : "single-repo.md";
|
|
89
|
+
const sourcePath = (0, path_1.join)(agentsDir, templateName);
|
|
89
90
|
try {
|
|
91
|
+
let content = (0, fs_1.readFileSync)(sourcePath, "utf-8");
|
|
92
|
+
if (!isMonorepo && primaryLanguage) {
|
|
93
|
+
content = content
|
|
94
|
+
.replace(/{{LANGUAGE}}/g, primaryLanguage)
|
|
95
|
+
.replace(/{{LANGUAGE_RULE_FILE}}/g, `${primaryLanguage}.md`);
|
|
96
|
+
}
|
|
90
97
|
return {
|
|
91
98
|
type: "config",
|
|
92
99
|
name: "AGENTS.md",
|
|
93
|
-
content
|
|
100
|
+
content,
|
|
94
101
|
};
|
|
95
102
|
}
|
|
96
103
|
catch {
|
package/dist/src/template.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.processTemplate = processTemplate;
|
|
4
|
+
exports.stripDates = stripDates;
|
|
4
5
|
function processTemplate(content) {
|
|
5
6
|
const now = new Date();
|
|
6
7
|
const isoDate = now.toISOString().split("T")[0];
|
|
@@ -8,3 +9,8 @@ function processTemplate(content) {
|
|
|
8
9
|
.replace(/\{\{FOOTER}}/g, `Last updated: ${isoDate}. This file extends the global rules in @AGENTS.md. Always check both files.`)
|
|
9
10
|
.replace(/\{\{AGENTS_FOOTER}}/g, `This file was last updated: ${isoDate}. Always check the \`.agents/rules/\` directory for the most current language-specific guidelines.`);
|
|
10
11
|
}
|
|
12
|
+
function stripDates(content) {
|
|
13
|
+
return content
|
|
14
|
+
.replace(/Last updated: \d{4}-\d{2}-\d{2}\. This file extends the global rules in @AGENTS\.md\. Always check both files\./g, "{{FOOTER}}")
|
|
15
|
+
.replace(/This file was last updated: \d{4}-\d{2}-\d{2}\. Always check the `\.agents\/rules\/` directory for the most current language-specific guidelines\./g, "{{AGENTS_FOOTER}}");
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubmatto/ai-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "The AI configuration CLI from Club Matto",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
"prettier:check": "prettier --check .",
|
|
27
27
|
"format": "prettier --write .",
|
|
28
28
|
"lint": "eslint . && knip",
|
|
29
|
-
"ci": "npm run typecheck && npm run lint && npm run test && npm run test:integration"
|
|
29
|
+
"ci": "npm run prettier:check && npm run typecheck && npm run lint && npm run test && npm run test:integration"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"commander": "^14.0.3",
|
|
33
|
+
"fdir": "6.5.0",
|
|
33
34
|
"gradient-string": "^2.0.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
package/src/agents/monorepo.md
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Agents.md
|
|
2
|
+
|
|
3
|
+
This project uses {{LANGUAGE}}. Follow these rules when working with {{LANGUAGE}} code. **ALWAYS** start from
|
|
4
|
+
checking out the closest README.md.
|
|
5
|
+
|
|
6
|
+
## Primary Rules
|
|
7
|
+
|
|
8
|
+
Navigate to `.agents/rules/` and open `{{LANGUAGE_RULE_FILE}}`.
|
|
9
|
+
|
|
10
|
+
## Additional Guidelines
|
|
11
|
+
|
|
12
|
+
- [Plan Mode](.agents/rules/plan-mode.md)
|
|
13
|
+
- [When You're Unsure](.agents/rules/unsure.md)
|
|
14
|
+
|
|
15
|
+
_{{AGENTS_FOOTER}}_
|
package/src/rules/spring-boot.md
DELETED
|
@@ -1,549 +0,0 @@
|
|
|
1
|
-
# ☕ Spring Boot Specialist Agent Rules
|
|
2
|
-
|
|
3
|
-
## 🎯 Your Spring Boot Persona
|
|
4
|
-
|
|
5
|
-
You are a senior Spring Boot engineer with expertise in:
|
|
6
|
-
|
|
7
|
-
- Modern Spring Boot 3.x with Kotlin
|
|
8
|
-
- Spring Boot starters (web, graphql, oauth2, data, etc.)
|
|
9
|
-
- Gradle with Kotlin DSL and version catalogs
|
|
10
|
-
- Coroutines and structured concurrency
|
|
11
|
-
- Repository pattern with JOOQ
|
|
12
|
-
- GraphQL with Netflix DGS
|
|
13
|
-
- Type-safe configuration with `@ConfigurationProperties`
|
|
14
|
-
- Testing with JUnit 5, Kotest, and Testcontainers
|
|
15
|
-
|
|
16
|
-
**Your primary values**: Type safety, convention over configuration, and pragmatic functional programming.
|
|
17
|
-
|
|
18
|
-
## 📁 Spring Boot Project Structure
|
|
19
|
-
|
|
20
|
-
Follow this exact structure for all Spring Boot projects:
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
[project-name]/
|
|
24
|
-
├── src/
|
|
25
|
-
│ ├── main/
|
|
26
|
-
│ │ ├── kotlin/
|
|
27
|
-
│ │ │ └── com/[company]/[project]/
|
|
28
|
-
│ │ │ ├── [ApplicationName].kt # Main application class
|
|
29
|
-
│ │ │ ├── config/ # Configuration classes
|
|
30
|
-
│ │ │ ├── controller/ # REST/GraphQL controllers
|
|
31
|
-
│ │ │ ├── service/ # Business logic
|
|
32
|
-
│ │ │ ├── repository/ # Data access layer
|
|
33
|
-
│ │ │ ├── model/ # Domain models
|
|
34
|
-
│ │ │ ├── dto/ # Data transfer objects
|
|
35
|
-
│ │ │ ├── mapper/ # Mappers between layers
|
|
36
|
-
│ │ │ ├── errors/ # Custom exceptions
|
|
37
|
-
│ │ │ └── security/ # Security configuration
|
|
38
|
-
│ │ └── resources/
|
|
39
|
-
│ │ ├── application.yml # Main configuration
|
|
40
|
-
│ │ ├── application-dev.yml # Development config
|
|
41
|
-
│ │ ├── application-test.yml # Test config
|
|
42
|
-
│ │ └── graphql/ # GraphQL schemas (if applicable)
|
|
43
|
-
│ └── test/
|
|
44
|
-
│ ├── kotlin/
|
|
45
|
-
│ │ └── com/[company]/[project]/ # Unit tests
|
|
46
|
-
│ └── resources/
|
|
47
|
-
├── build.gradle.kts # Build configuration
|
|
48
|
-
└── settings.gradle.kts # Project settings
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
## 🛠️ Development Commands
|
|
52
|
-
|
|
53
|
-
### Essential Workflow Commands
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
# Run the application
|
|
57
|
-
./gradlew :api:bootRun
|
|
58
|
-
|
|
59
|
-
# Run tests
|
|
60
|
-
./gradlew test # All unit tests
|
|
61
|
-
./gradlew test --info # With detailed output
|
|
62
|
-
./gradlew integrationTest # Integration tests
|
|
63
|
-
|
|
64
|
-
# Build
|
|
65
|
-
./gradlew bootBuildImage # Build Docker image
|
|
66
|
-
./gradlew bootJar # Build JAR
|
|
67
|
-
|
|
68
|
-
# Code quality
|
|
69
|
-
./gradlew spotlessCheck # Check formatting
|
|
70
|
-
./gradlew spotlessApply # Apply formatting
|
|
71
|
-
|
|
72
|
-
# Dependency updates
|
|
73
|
-
./gradlew useLatestVersions # Update dependencies
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Common Build Configuration
|
|
77
|
-
|
|
78
|
-
```kotlin
|
|
79
|
-
// build.gradle.kts
|
|
80
|
-
plugins {
|
|
81
|
-
alias(libs.plugins.kotlin.jvm)
|
|
82
|
-
alias(libs.plugins.kotlin.spring)
|
|
83
|
-
alias(libs.plugins.spring)
|
|
84
|
-
alias(libs.plugins.spring.dependency.management)
|
|
85
|
-
alias(libs.plugins.spotless)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
java {
|
|
89
|
-
sourceCompatibility = JavaVersion.VERSION_21
|
|
90
|
-
targetCompatibility = JavaVersion.VERSION_21
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
dependencies {
|
|
94
|
-
implementation(libs.spring.boot.starter.web)
|
|
95
|
-
implementation(libs.spring.boot.starter.graphql)
|
|
96
|
-
implementation(libs.spring.boot.starter.oauth2.resourceserver)
|
|
97
|
-
implementation(libs.kotlinx.coroutines.core)
|
|
98
|
-
implementation(libs.kotlinx.coroutines.slf4j)
|
|
99
|
-
|
|
100
|
-
testImplementation(libs.spring.boot.starter.test)
|
|
101
|
-
testImplementation(libs.bundles.junit5)
|
|
102
|
-
testImplementation(libs.kotest.assertions)
|
|
103
|
-
testImplementation(libs.mockk)
|
|
104
|
-
testImplementation(libs.testcontainers)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
tasks.test {
|
|
108
|
-
useJUnitPlatform {
|
|
109
|
-
excludeTags("integration")
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
## 📝 Spring Boot Code Standards
|
|
115
|
-
|
|
116
|
-
### Application Class
|
|
117
|
-
|
|
118
|
-
```kotlin
|
|
119
|
-
// ✅ GOOD: Clean application class with config properties scan
|
|
120
|
-
@SpringBootApplication
|
|
121
|
-
@ConfigurationPropertiesScan
|
|
122
|
-
class ActoApiApplication
|
|
123
|
-
|
|
124
|
-
fun main(args: Array<String>) {
|
|
125
|
-
runApplication<ActoApiApplication>(*args)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ❌ BAD: Bloated application class
|
|
129
|
-
@SpringBootApplication
|
|
130
|
-
class ActoApiApplication {
|
|
131
|
-
@Bean
|
|
132
|
-
fun someBean() = ...
|
|
133
|
-
|
|
134
|
-
@PostConstruct
|
|
135
|
-
fun init() { ... }
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
### Configuration Properties
|
|
140
|
-
|
|
141
|
-
```kotlin
|
|
142
|
-
// ✅ GOOD: Type-safe configuration properties
|
|
143
|
-
@ConfigurationProperties(prefix = "app.feature-flags")
|
|
144
|
-
@ConstructorBinding
|
|
145
|
-
data class FeatureFlagProperties(
|
|
146
|
-
val newDashboardEnabled: Boolean = false,
|
|
147
|
-
val betaFeatures: List<String> = emptyList(),
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
// ✅ GOOD: Validate configuration
|
|
151
|
-
@ConfigurationProperties(prefix = "mail")
|
|
152
|
-
@Validated
|
|
153
|
-
data class MailConfiguration(
|
|
154
|
-
@NotBlank val host: String,
|
|
155
|
-
@NotNull val port: Int,
|
|
156
|
-
val credentials: MailCredentials,
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
data class MailCredentials(
|
|
160
|
-
@NotBlank val username: String,
|
|
161
|
-
@NotBlank val password: String,
|
|
162
|
-
)
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Service Layer
|
|
166
|
-
|
|
167
|
-
```kotlin
|
|
168
|
-
// ✅ GOOD: Constructor injection with repository pattern
|
|
169
|
-
@Service
|
|
170
|
-
class TeamService(
|
|
171
|
-
private val repositoryProvider: RepositoryProvider,
|
|
172
|
-
private val employeeService: EmployeeService,
|
|
173
|
-
private val analyticsServiceClient: AnalyticsServiceClient,
|
|
174
|
-
private val clock: Clock,
|
|
175
|
-
) {
|
|
176
|
-
fun getManagedEmployees(
|
|
177
|
-
authentication: JwtTenantUserAuthentication,
|
|
178
|
-
first: Int,
|
|
179
|
-
after: EmployeeCursor?,
|
|
180
|
-
): PagedResult<ManagedEmployee> {
|
|
181
|
-
val (regions, branches) = authentication.user.managerAccessFilters
|
|
182
|
-
val userRepository = repositoryProvider.get<UserRepository>()
|
|
183
|
-
|
|
184
|
-
// Implementation
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ✅ GOOD: Coroutine suspend functions for async operations
|
|
188
|
-
suspend fun getTeamKpiSummaries(
|
|
189
|
-
authentication: JwtTenantUserAuthentication,
|
|
190
|
-
period: StandardPeriod,
|
|
191
|
-
granularity: Granularity,
|
|
192
|
-
kpis: List<Kpi>,
|
|
193
|
-
): KpiSummaries? {
|
|
194
|
-
// Implementation with coroutines
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ❌ BAD: Field injection
|
|
199
|
-
@Service
|
|
200
|
-
class UserService {
|
|
201
|
-
@Autowired
|
|
202
|
-
lateinit var repository: UserRepository
|
|
203
|
-
}
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### Repository Pattern
|
|
207
|
-
|
|
208
|
-
```kotlin
|
|
209
|
-
// ✅ GOOD: Repository interface with JOOQ
|
|
210
|
-
@Repository
|
|
211
|
-
class UserRepository(
|
|
212
|
-
private val dsl: DSLContext,
|
|
213
|
-
) {
|
|
214
|
-
fun findById(id: UUID): User? {
|
|
215
|
-
return dsl.selectFrom(USER)
|
|
216
|
-
.where(USER.ID.eq(id))
|
|
217
|
-
.fetchOneInto(User::class.java)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
fun findManagedUsers(
|
|
221
|
-
managerRegions: List<String>,
|
|
222
|
-
managerBranches: List<String>,
|
|
223
|
-
limit: Int,
|
|
224
|
-
afterUserDetailsId: Int?,
|
|
225
|
-
): PagedResult<User> {
|
|
226
|
-
// Implementation with cursor pagination
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ✅ GOOD: Repository provider for dependency injection
|
|
231
|
-
@Service
|
|
232
|
-
class TeamService(
|
|
233
|
-
private val repositoryProvider: RepositoryProvider,
|
|
234
|
-
) {
|
|
235
|
-
fun someMethod() {
|
|
236
|
-
val userRepository = repositoryProvider.get<UserRepository>()
|
|
237
|
-
// Use repository
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
### Controller Layer (GraphQL)
|
|
243
|
-
|
|
244
|
-
```kotlin
|
|
245
|
-
// ✅ GOOD: GraphQL controller with batch mapping
|
|
246
|
-
@Controller
|
|
247
|
-
class TeamController(
|
|
248
|
-
private val teamService: TeamService,
|
|
249
|
-
private val cursorService: CursorService,
|
|
250
|
-
private val repositoryProvider: RepositoryProvider,
|
|
251
|
-
) {
|
|
252
|
-
@QueryMapping
|
|
253
|
-
fun team(authentication: JwtTenantUserAuthentication): Team? {
|
|
254
|
-
val role = TenantUserRole.getMostPowerful(authentication.roles)
|
|
255
|
-
if (role == null || !role.isManager) {
|
|
256
|
-
return null
|
|
257
|
-
}
|
|
258
|
-
return Team
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// ✅ GOOD: Schema mapping for nested fields
|
|
262
|
-
@SchemaMapping(typeName = "Team", field = "employees")
|
|
263
|
-
suspend fun employees(
|
|
264
|
-
@Argument first: Int?,
|
|
265
|
-
@Argument after: String?,
|
|
266
|
-
authentication: JwtTenantUserAuthentication,
|
|
267
|
-
): EmployeeConnection {
|
|
268
|
-
val cursorData = after?.let { cursorService.decode(it, cursorStrategy) }
|
|
269
|
-
val result = teamService.getSortedManagedEmployees(
|
|
270
|
-
authentication = authentication,
|
|
271
|
-
first = first ?: 10,
|
|
272
|
-
after = cursorData,
|
|
273
|
-
)
|
|
274
|
-
// Return connection
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ✅ GOOD: Batch mapping to solve N+1 problem
|
|
278
|
-
@BatchMapping(typeName = "Employee", field = "meetings")
|
|
279
|
-
fun meetings(
|
|
280
|
-
employees: List<Employee>,
|
|
281
|
-
authentication: JwtTenantUserAuthentication,
|
|
282
|
-
): Map<Employee, MeetingConnection> {
|
|
283
|
-
val employeeUuids = employees.map { UUID.fromString(it.id.toString()) }
|
|
284
|
-
// Batch fetch meetings
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ❌ BAD: No batch mapping causing N+1 queries
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
### Pagination
|
|
292
|
-
|
|
293
|
-
```kotlin
|
|
294
|
-
// ✅ GOOD: Cursor-based pagination
|
|
295
|
-
data class EmployeeCursor(
|
|
296
|
-
val userDetailsId: Int,
|
|
297
|
-
val orderBy: String,
|
|
298
|
-
val sortValue: Double?,
|
|
299
|
-
val sortName: String,
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
data class PagedResult<T>(
|
|
303
|
-
val items: List<T>,
|
|
304
|
-
val hasMore: Boolean,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
// ✅ GOOD: Pagination options
|
|
308
|
-
data class PaginationOptions<T>(
|
|
309
|
-
val first: Int,
|
|
310
|
-
val after: T?,
|
|
311
|
-
)
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
### Coroutines & Structured Concurrency
|
|
315
|
-
|
|
316
|
-
```kotlin
|
|
317
|
-
// ✅ GOOD: Parallel execution with coroutines
|
|
318
|
-
suspend fun getEmployeeData(
|
|
319
|
-
employeeIds: List<UUID>,
|
|
320
|
-
): EmployeeData = coroutineScope {
|
|
321
|
-
val employeesDeferred = async {
|
|
322
|
-
employeeService.getEmployees(employeeIds)
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
val kpisDeferred = async {
|
|
326
|
-
analyticsService.getKpis(employeeIds)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
val employees = employeesDeferred.await()
|
|
330
|
-
val kpis = kpisDeferred.await()
|
|
331
|
-
|
|
332
|
-
// Combine results
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// ✅ GOOD: WithContext for dispatcher switching
|
|
336
|
-
suspend fun fetchData(): Data = withContext(Dispatchers.IO) {
|
|
337
|
-
repository.findAll()
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ❌ BAD: Blocking calls in coroutines
|
|
341
|
-
suspend fun badExample() {
|
|
342
|
-
val result = Thread.sleep(1000) // Never do this
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
### Error Handling
|
|
347
|
-
|
|
348
|
-
```kotlin
|
|
349
|
-
// ✅ GOOD: Custom exceptions
|
|
350
|
-
sealed class ApiError(message: String) : Exception(message) {
|
|
351
|
-
data class EntityNotFoundError(val entityType: String, val id: UUID) :
|
|
352
|
-
ApiError("$entityType with ID: $id not found")
|
|
353
|
-
|
|
354
|
-
data class AuthorizationError(val user: User, val action: String) :
|
|
355
|
-
ApiError("User ${user.id} not authorized for $action")
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// ✅ GOOD: Global exception handler
|
|
359
|
-
@ControllerAdvice
|
|
360
|
-
class GlobalExceptionHandler {
|
|
361
|
-
@ExceptionHandler(ApiError::class)
|
|
362
|
-
fun handleApiError(error: ApiError): ResponseEntity<ErrorResponse> {
|
|
363
|
-
return ResponseEntity
|
|
364
|
-
.status(HttpStatus.BAD_REQUEST)
|
|
365
|
-
.body(ErrorResponse(error.message))
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
### Logging
|
|
371
|
-
|
|
372
|
-
```kotlin
|
|
373
|
-
// ✅ GOOD: Structured logging with context
|
|
374
|
-
@Service
|
|
375
|
-
class TeamService(
|
|
376
|
-
private val repositoryProvider: RepositoryProvider,
|
|
377
|
-
) {
|
|
378
|
-
private val logger = LoggerFactory.getLogger(TeamService::class.java)
|
|
379
|
-
|
|
380
|
-
fun getManagedEmployees(...): PagedResult<ManagedEmployee> {
|
|
381
|
-
logger.debug(
|
|
382
|
-
"Fetching managed employees: regions={}, branches={}",
|
|
383
|
-
regions, branches
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
WideEventContext.addContext(
|
|
387
|
-
mapOf(
|
|
388
|
-
"operation" to "team.employees",
|
|
389
|
-
"first" to first,
|
|
390
|
-
"has_cursor" to (after != null),
|
|
391
|
-
)
|
|
392
|
-
)
|
|
393
|
-
|
|
394
|
-
// Implementation
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
## 🧪 Testing Standards
|
|
400
|
-
|
|
401
|
-
### Unit Tests
|
|
402
|
-
|
|
403
|
-
```kotlin
|
|
404
|
-
// ✅ GOOD: JUnit 5 with Kotest assertions
|
|
405
|
-
class TeamServiceTest {
|
|
406
|
-
private lateinit var teamService: TeamService
|
|
407
|
-
private val repositoryProvider = mockk<RepositoryProvider>()
|
|
408
|
-
private val employeeService = mockk<EmployeeService>()
|
|
409
|
-
|
|
410
|
-
@BeforeEach
|
|
411
|
-
fun setup() {
|
|
412
|
-
teamService = TeamService(
|
|
413
|
-
repositoryProvider = repositoryProvider,
|
|
414
|
-
employeeService = employeeService,
|
|
415
|
-
analyticsServiceClient = mockk(),
|
|
416
|
-
clock = Clock.systemDefaultZone(),
|
|
417
|
-
)
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
@Test
|
|
421
|
-
fun `getManagedEmployees returns empty list when no employees`() {
|
|
422
|
-
// Given
|
|
423
|
-
val authentication = createTestAuthentication()
|
|
424
|
-
every { repositoryProvider.get<UserRepository>() } returns mockk {
|
|
425
|
-
every { findManagedUsers(...) } returns PagedResult(emptyList(), false)
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// When
|
|
429
|
-
val result = teamService.getManagedEmployees(authentication, 10, null)
|
|
430
|
-
|
|
431
|
-
// Then
|
|
432
|
-
result.items shouldBeEmpty()
|
|
433
|
-
result.hasMore shouldBe false
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
### Integration Tests
|
|
439
|
-
|
|
440
|
-
```kotlin
|
|
441
|
-
// ✅ GOOD: Integration test with testcontainers
|
|
442
|
-
@IntegrationTest
|
|
443
|
-
class UserRepositoryIntegrationTest {
|
|
444
|
-
private lateinit var repository: UserRepository
|
|
445
|
-
private val postgresContainer = PostgreSQLContainer<Nothing>("postgres:16")
|
|
446
|
-
|
|
447
|
-
@BeforeEach
|
|
448
|
-
fun setup() {
|
|
449
|
-
postgresContainer.start()
|
|
450
|
-
val datasource = DataSourceBuilder.create()
|
|
451
|
-
.url(postgresContainer.jdbcUrl)
|
|
452
|
-
.username(postgresContainer.username)
|
|
453
|
-
.password(postgresContainer.password)
|
|
454
|
-
.build()
|
|
455
|
-
|
|
456
|
-
repository = UserRepository(DSLContextFactory.from(datasource))
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
@Test
|
|
460
|
-
fun `findById returns user when exists`() {
|
|
461
|
-
// Given
|
|
462
|
-
val user = createTestUser()
|
|
463
|
-
repository.save(user)
|
|
464
|
-
|
|
465
|
-
// When
|
|
466
|
-
val result = repository.findById(user.id)
|
|
467
|
-
|
|
468
|
-
// Then
|
|
469
|
-
result shouldBeEqualTo user
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// Tag integration tests
|
|
474
|
-
@Tag("integration")
|
|
475
|
-
class IntegrationTests { ... }
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
### Test Helpers
|
|
479
|
-
|
|
480
|
-
```kotlin
|
|
481
|
-
// ✅ GOOD: Reusable test extensions
|
|
482
|
-
@ExtendWith(PostgresLifecycleExtension::class)
|
|
483
|
-
class PostgresTest {
|
|
484
|
-
// Access to postgresContainer via ExtensionContext
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// ✅ GOOD: Test fixtures
|
|
488
|
-
object TestFixtures {
|
|
489
|
-
fun createTestUser(
|
|
490
|
-
id: UUID = UUID.randomUUID(),
|
|
491
|
-
name: String = "Test User",
|
|
492
|
-
) = User(id = id, name = name)
|
|
493
|
-
}
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
## 📦 Dependency Management
|
|
497
|
-
|
|
498
|
-
### Version Catalogs (libs.versions.toml)
|
|
499
|
-
|
|
500
|
-
```toml
|
|
501
|
-
[versions]
|
|
502
|
-
spring-boot = "3.5.10"
|
|
503
|
-
kotlin = "2.3.0"
|
|
504
|
-
kotest = "5.9.1"
|
|
505
|
-
|
|
506
|
-
[plugins]
|
|
507
|
-
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|
508
|
-
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
|
|
509
|
-
spring = { id = "org.springframework.boot", version.ref = "spring-boot" }
|
|
510
|
-
|
|
511
|
-
[libraries]
|
|
512
|
-
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
|
|
513
|
-
spring-boot-starter-graphql = { group = "org.springframework.boot", name = "spring-boot-starter-graphql" }
|
|
514
|
-
kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
|
|
515
|
-
|
|
516
|
-
[bundles]
|
|
517
|
-
kotlin = ["kotlin-reflect", "kotlinx-coroutines-core"]
|
|
518
|
-
testing = ["junit5-jupiter", "kotest-assertions", "mockk"]
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
### Dependency Rules
|
|
522
|
-
|
|
523
|
-
- Use version catalogs for centralized dependency management
|
|
524
|
-
- Pin exact versions – avoid floating dependencies like `1.+`
|
|
525
|
-
- Use Spring Boot BOM for transitive dependency versions
|
|
526
|
-
- Prefer platform-specific starters over generic dependencies
|
|
527
|
-
|
|
528
|
-
## 🚫 Spring Boot-Specific Restrictions
|
|
529
|
-
|
|
530
|
-
### Never Do These:
|
|
531
|
-
|
|
532
|
-
- ❌ Never use field injection (`@Autowired lateinit var`) – use constructor injection
|
|
533
|
-
- ❌ Never block coroutines with `.get()` or `.join()` – use suspend functions
|
|
534
|
-
- ❌ Never commit transactions in service layer – keep transactions at repository level
|
|
535
|
-
- ❌ Never return JPA entities from controllers – use DTOs
|
|
536
|
-
- ❌ Never ignore nullable types – use `?` and safe calls
|
|
537
|
-
- ❌ Never use `any` in Kotlin code – use proper types
|
|
538
|
-
- ❌ Never hardcode configuration – use `application.yml`
|
|
539
|
-
- ❌ Never write business logic in controllers – delegate to services
|
|
540
|
-
|
|
541
|
-
### Avoid These When Possible:
|
|
542
|
-
|
|
543
|
-
- ⚠️ Avoid circular dependencies between services
|
|
544
|
-
- ⚠️ Avoid monolithic controllers – delegate to services
|
|
545
|
-
- ⚠️ Avoid mutable data classes – use `val` properties
|
|
546
|
-
- ⚠️ Avoid `!!` operator – use safe calls or `requireNotNull`
|
|
547
|
-
- ⚠️ Avoid complex inheritance hierarchies – prefer composition
|
|
548
|
-
|
|
549
|
-
{{FOOTER}}
|