@clubmatto/ai-kit 0.0.5 → 0.0.7
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 +30 -8
- package/dist/src/cmd/sync.js +42 -2
- package/dist/src/detection/detect.js +43 -0
- package/dist/src/detection/language-detectors.js +25 -0
- package/dist/src/detection/scanner.js +53 -0
- package/dist/src/index.js +13 -1
- package/dist/src/reader.js +10 -3
- package/package.json +2 -2
- package/src/agents/monorepo.md +1 -1
- package/src/agents/single-repo.md +15 -0
- package/src/cmd/sync.ts +0 -158
- package/src/gradient-string.d.ts +0 -1
- package/src/index.ts +0 -24
- package/src/logger.ts +0 -10
- package/src/manifest.ts +0 -29
- package/src/output.ts +0 -66
- package/src/reader.ts +0 -114
- package/src/rules/spring-boot.md +0 -549
- package/src/template.ts +0 -14
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,6 +82,9 @@ ai-kit sync
|
|
|
63
82
|
## Release
|
|
64
83
|
|
|
65
84
|
```bash
|
|
85
|
+
# Bump version in package.json first
|
|
86
|
+
git add package.json && git commit -m "release: bump version to <version>"
|
|
87
|
+
|
|
66
88
|
# Create git tag with prefix (triggers automated release)
|
|
67
89
|
git tag ai-kit/v<version>
|
|
68
90
|
git push origin ai-kit/v<version>
|
package/dist/src/cmd/sync.js
CHANGED
|
@@ -7,6 +7,8 @@ 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");
|
|
10
12
|
const rootDir = (0, path_1.join)(__dirname, "..", "..", "..");
|
|
11
13
|
const defaultSourceDirs = {
|
|
12
14
|
rules: (0, path_1.join)(rootDir, "src", "rules"),
|
|
@@ -44,8 +46,46 @@ async function doSync(cwd, version, options, logger, sourceDirs) {
|
|
|
44
46
|
}
|
|
45
47
|
const contentFiles = (0, reader_1.readContent)(sourceDirs.rules, sourceDirs.skills);
|
|
46
48
|
const rootFiles = (0, reader_1.readConfigs)(sourceDirs.agents);
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
+
// Detect languages and determine project type
|
|
50
|
+
const detectionResult = (0, detect_1.detectLanguages)(cwd);
|
|
51
|
+
let languages = detectionResult.languages;
|
|
52
|
+
let isMonorepo = detectionResult.isMonorepo;
|
|
53
|
+
const primaryLanguage = detectionResult.primaryLanguage;
|
|
54
|
+
// Apply overrides from options
|
|
55
|
+
if (options.allRules) {
|
|
56
|
+
languages = language_detectors_1.detectors.map((d) => d.name);
|
|
57
|
+
isMonorepo = true;
|
|
58
|
+
}
|
|
59
|
+
else if (options.monorepo) {
|
|
60
|
+
isMonorepo = true;
|
|
61
|
+
}
|
|
62
|
+
else if (options.singleRepo) {
|
|
63
|
+
isMonorepo = false;
|
|
64
|
+
}
|
|
65
|
+
if (options.languages && options.languages.length > 0) {
|
|
66
|
+
languages = options.languages;
|
|
67
|
+
isMonorepo = languages.length > 1;
|
|
68
|
+
}
|
|
69
|
+
// If no languages detected and no overrides, fall back to all rules (monorepo)
|
|
70
|
+
if (languages.length === 0) {
|
|
71
|
+
languages = language_detectors_1.detectors.map((d) => d.name);
|
|
72
|
+
isMonorepo = true;
|
|
73
|
+
}
|
|
74
|
+
const agentsFile = (0, reader_1.readAgents)(sourceDirs.agents, isMonorepo, primaryLanguage);
|
|
75
|
+
// Filter rules based on detected languages
|
|
76
|
+
const ruleFilesToInclude = options.allRules
|
|
77
|
+
? (0, detect_1.getAllRuleFiles)()
|
|
78
|
+
: (0, detect_1.getRuleFilesForLanguages)(languages);
|
|
79
|
+
const rules = contentFiles.filter((f) => {
|
|
80
|
+
if (f.type !== "rules")
|
|
81
|
+
return false;
|
|
82
|
+
// Always include non-language-specific rules (plan-mode.md, unsure.md, etc.)
|
|
83
|
+
if (!(0, detect_1.isLanguageSpecificRule)(f.name)) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
// For language-specific rules, check if they're in the include list
|
|
87
|
+
return ruleFilesToInclude.includes(f.name);
|
|
88
|
+
});
|
|
49
89
|
const stats = { rules: 0, skills: 0, commands: 0 };
|
|
50
90
|
const installedRootFiles = [];
|
|
51
91
|
if (!options.skipOpencode) {
|
|
@@ -0,0 +1,43 @@
|
|
|
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 detectLanguages(cwd) {
|
|
10
|
+
const detected = new Set();
|
|
11
|
+
for (const detector of language_detectors_1.detectors) {
|
|
12
|
+
if ((0, scanner_1.hasAnyConfigFile)(cwd, detector.configFiles)) {
|
|
13
|
+
detected.add(detector.name);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (detected.size === 0) {
|
|
17
|
+
for (const detector of language_detectors_1.detectors) {
|
|
18
|
+
if ((0, scanner_1.hasAnySourceFile)(cwd, detector.extensions, 2)) {
|
|
19
|
+
detected.add(detector.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const languages = Array.from(detected);
|
|
24
|
+
const isMonorepo = languages.length > 1;
|
|
25
|
+
const primaryLanguage = languages.length > 0 ? languages[0] : undefined;
|
|
26
|
+
return { languages, isMonorepo, primaryLanguage };
|
|
27
|
+
}
|
|
28
|
+
function getRuleFilesForLanguages(languages) {
|
|
29
|
+
const ruleFiles = new Set();
|
|
30
|
+
for (const language of languages) {
|
|
31
|
+
const detector = language_detectors_1.detectors.find((d) => d.name === language);
|
|
32
|
+
if (detector) {
|
|
33
|
+
ruleFiles.add(detector.ruleFile);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Array.from(ruleFiles);
|
|
37
|
+
}
|
|
38
|
+
function getAllRuleFiles() {
|
|
39
|
+
return language_detectors_1.detectors.map((detector) => detector.ruleFile);
|
|
40
|
+
}
|
|
41
|
+
function isLanguageSpecificRule(ruleFile) {
|
|
42
|
+
return language_detectors_1.detectors.some((detector) => detector.ruleFile === ruleFile);
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectors = void 0;
|
|
4
|
+
//TODO this is a good first implementation
|
|
5
|
+
//but we clearly want each detector to come with a detect function
|
|
6
|
+
exports.detectors = [
|
|
7
|
+
{
|
|
8
|
+
name: "typescript",
|
|
9
|
+
ruleFile: "typescript.md",
|
|
10
|
+
configFiles: ["package.json", "tsconfig.json"],
|
|
11
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "go",
|
|
15
|
+
ruleFile: "go.md",
|
|
16
|
+
configFiles: ["go.mod"],
|
|
17
|
+
extensions: [".go"],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "kotlin",
|
|
21
|
+
ruleFile: "kotlin.md",
|
|
22
|
+
configFiles: ["build.gradle", "build.gradle.kts", "pom.xml"],
|
|
23
|
+
extensions: [".kt", ".kts", ".java"],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasAnyConfigFile = hasAnyConfigFile;
|
|
4
|
+
exports.hasAnySourceFile = hasAnySourceFile;
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const IGNORE_DIRS = [
|
|
8
|
+
"node_modules",
|
|
9
|
+
".git",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
"target",
|
|
13
|
+
".next",
|
|
14
|
+
".nuxt",
|
|
15
|
+
];
|
|
16
|
+
function hasAnyConfigFile(cwd, configFiles) {
|
|
17
|
+
for (const configFile of configFiles) {
|
|
18
|
+
if ((0, fs_1.existsSync)((0, path_1.join)(cwd, configFile))) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function hasAnySourceFile(cwd, extensions, maxDepth = 2) {
|
|
25
|
+
return scanForExtensions(cwd, extensions, maxDepth, 0);
|
|
26
|
+
}
|
|
27
|
+
function scanForExtensions(dir, extensions, maxDepth, currentDepth) {
|
|
28
|
+
if (currentDepth > maxDepth) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const fullPath = (0, path_1.join)(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (!IGNORE_DIRS.includes(entry.name) && !entry.name.startsWith(".")) {
|
|
37
|
+
if (scanForExtensions(fullPath, extensions, maxDepth, currentDepth + 1)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (entry.isFile()) {
|
|
43
|
+
if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// If we can't read the directory, skip it
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubmatto/ai-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "The AI configuration CLI from Club Matto",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
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",
|
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/cmd/sync.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
|
-
import { dirname, join } from "path";
|
|
3
|
-
import {
|
|
4
|
-
readAgents,
|
|
5
|
-
getCommandConfig,
|
|
6
|
-
readContent,
|
|
7
|
-
readConfigs,
|
|
8
|
-
SyncItem,
|
|
9
|
-
} from "../reader";
|
|
10
|
-
import { readManifest, writeManifest } from "../manifest";
|
|
11
|
-
import { processTemplate } from "../template";
|
|
12
|
-
import { log, SyncStats } from "../output";
|
|
13
|
-
import { Logger } from "../logger";
|
|
14
|
-
|
|
15
|
-
const rootDir = join(__dirname, "..", "..", "..");
|
|
16
|
-
|
|
17
|
-
export interface SourceDirs {
|
|
18
|
-
rules: string;
|
|
19
|
-
skills: string;
|
|
20
|
-
agents: string;
|
|
21
|
-
commands: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const defaultSourceDirs: SourceDirs = {
|
|
25
|
-
rules: join(rootDir, "src", "rules"),
|
|
26
|
-
skills: join(rootDir, "src", "skills"),
|
|
27
|
-
agents: join(rootDir, "src", "agents"),
|
|
28
|
-
commands: join(rootDir, "src", "commands"),
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
interface SyncOptions {
|
|
32
|
-
skipOpencode?: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function sync(
|
|
36
|
-
cwd: string,
|
|
37
|
-
version: string,
|
|
38
|
-
options: SyncOptions,
|
|
39
|
-
logger: Logger = log,
|
|
40
|
-
sourceDirs: SourceDirs = defaultSourceDirs,
|
|
41
|
-
): Promise<void> {
|
|
42
|
-
const manifest = readManifest(cwd);
|
|
43
|
-
logger.logo(version);
|
|
44
|
-
|
|
45
|
-
if (manifest && manifest.version === version) {
|
|
46
|
-
logger.success(`Already at latest version (${version})`);
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
logger.welcome();
|
|
51
|
-
const counts = await doSync(cwd, version, options, logger, sourceDirs);
|
|
52
|
-
logger.summary(counts);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function writeItem(aiDir: string, file: SyncItem): void {
|
|
56
|
-
const targetDir = join(aiDir, file.type);
|
|
57
|
-
if (!existsSync(targetDir)) {
|
|
58
|
-
mkdirSync(targetDir, { recursive: true });
|
|
59
|
-
}
|
|
60
|
-
const targetPath = join(targetDir, file.name);
|
|
61
|
-
const parentDir = dirname(targetPath);
|
|
62
|
-
if (!existsSync(parentDir)) {
|
|
63
|
-
mkdirSync(parentDir, { recursive: true });
|
|
64
|
-
}
|
|
65
|
-
writeFileSync(targetPath, processTemplate(file.content));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function doSync(
|
|
69
|
-
cwd: string,
|
|
70
|
-
version: string,
|
|
71
|
-
options: SyncOptions,
|
|
72
|
-
logger: Logger,
|
|
73
|
-
sourceDirs: SourceDirs,
|
|
74
|
-
): Promise<SyncStats> {
|
|
75
|
-
const aiDir = join(cwd, ".agents");
|
|
76
|
-
|
|
77
|
-
if (!existsSync(aiDir)) {
|
|
78
|
-
mkdirSync(aiDir, { recursive: true });
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const contentFiles = readContent(sourceDirs.rules, sourceDirs.skills);
|
|
82
|
-
const rootFiles = readConfigs(sourceDirs.agents);
|
|
83
|
-
const agentsFile = readAgents(sourceDirs.agents);
|
|
84
|
-
|
|
85
|
-
const rules = contentFiles.filter((f) => f.type === "rules");
|
|
86
|
-
|
|
87
|
-
const stats: SyncStats = { rules: 0, skills: 0, commands: 0 };
|
|
88
|
-
|
|
89
|
-
const installedRootFiles: string[] = [];
|
|
90
|
-
if (!options.skipOpencode) {
|
|
91
|
-
const commandConfig = getCommandConfig(sourceDirs.commands);
|
|
92
|
-
stats.commands = Object.keys(commandConfig).length;
|
|
93
|
-
|
|
94
|
-
if (Object.keys(commandConfig).length > 0) {
|
|
95
|
-
logger.section("commands");
|
|
96
|
-
for (const name of Object.keys(commandConfig)) {
|
|
97
|
-
logger.success(`${name}.md`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (rootFiles.length > 0) {
|
|
102
|
-
logger.section("configs");
|
|
103
|
-
for (const file of rootFiles) {
|
|
104
|
-
let content = file.content;
|
|
105
|
-
if (
|
|
106
|
-
file.name === "opencode.json" &&
|
|
107
|
-
Object.keys(commandConfig).length > 0
|
|
108
|
-
) {
|
|
109
|
-
const config = JSON.parse(content);
|
|
110
|
-
config.command = commandConfig;
|
|
111
|
-
content = JSON.stringify(config, null, 2) + "\n";
|
|
112
|
-
}
|
|
113
|
-
const targetPath = join(cwd, file.name);
|
|
114
|
-
writeFileSync(targetPath, content);
|
|
115
|
-
logger.success(`${file.name}`);
|
|
116
|
-
installedRootFiles.push(file.name);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (agentsFile) {
|
|
122
|
-
const targetPath = join(cwd, agentsFile.name);
|
|
123
|
-
writeFileSync(targetPath, processTemplate(agentsFile.content));
|
|
124
|
-
logger.success(`${agentsFile.name}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (rules.length > 0) {
|
|
128
|
-
logger.section("rules");
|
|
129
|
-
for (const file of rules) {
|
|
130
|
-
writeItem(aiDir, file);
|
|
131
|
-
logger.success(`${file.name}`);
|
|
132
|
-
stats.rules++;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const skills = contentFiles.filter((f) => f.type === "skills");
|
|
137
|
-
|
|
138
|
-
if (skills.length > 0) {
|
|
139
|
-
logger.section("skills");
|
|
140
|
-
const skillDirs = [...new Set(skills.map((f) => f.name.split("/")[0]))];
|
|
141
|
-
stats.skills = skillDirs.length;
|
|
142
|
-
for (const dir of skillDirs) {
|
|
143
|
-
const dirFiles = skills.filter((f) => f.name.startsWith(dir + "/"));
|
|
144
|
-
logger.success(`${dir} (${dirFiles.length} files)`);
|
|
145
|
-
for (const file of dirFiles) {
|
|
146
|
-
writeItem(aiDir, file);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
writeManifest(cwd, {
|
|
152
|
-
version,
|
|
153
|
-
installedAt: new Date().toISOString(),
|
|
154
|
-
rootFiles: installedRootFiles,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
return stats;
|
|
158
|
-
}
|
package/src/gradient-string.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
declare module 'gradient-string';
|
package/src/index.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { Command } from "commander";
|
|
3
|
-
import { sync } from "./cmd/sync";
|
|
4
|
-
import { readFileSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
|
-
|
|
7
|
-
const version = JSON.parse(
|
|
8
|
-
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
|
|
9
|
-
).version;
|
|
10
|
-
|
|
11
|
-
const program = new Command();
|
|
12
|
-
|
|
13
|
-
program
|
|
14
|
-
.name("@clubmatto/ai-kit")
|
|
15
|
-
.description("The AI configuration CLI from Club Matto")
|
|
16
|
-
.version(version)
|
|
17
|
-
.option("--skip-opencode", "Skip installing opencode.json to project root");
|
|
18
|
-
|
|
19
|
-
program
|
|
20
|
-
.command("sync")
|
|
21
|
-
.description("Initialize or update AI configuration")
|
|
22
|
-
.action(() => sync(process.cwd(), version, program.opts()));
|
|
23
|
-
|
|
24
|
-
program.parse();
|
package/src/logger.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { SyncStats } from "./output";
|
|
2
|
-
|
|
3
|
-
export interface Logger {
|
|
4
|
-
logo: (version: string) => void;
|
|
5
|
-
welcome: () => void;
|
|
6
|
-
section: (msg: string) => void;
|
|
7
|
-
success: (msg: string) => void;
|
|
8
|
-
final: (msg: string) => void;
|
|
9
|
-
summary: (counts: SyncStats) => void;
|
|
10
|
-
}
|
package/src/manifest.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
|
|
4
|
-
interface Manifest {
|
|
5
|
-
version: string;
|
|
6
|
-
installedAt: string;
|
|
7
|
-
rootFiles?: string[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const AI_DIR = ".agents";
|
|
11
|
-
const MANIFEST_FILE = ".ai-kit";
|
|
12
|
-
|
|
13
|
-
function getManifestPath(cwd: string): string {
|
|
14
|
-
return join(cwd, AI_DIR, MANIFEST_FILE);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function readManifest(cwd: string): Manifest | null {
|
|
18
|
-
const path = getManifestPath(cwd);
|
|
19
|
-
if (!existsSync(path)) return null;
|
|
20
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function writeManifest(cwd: string, manifest: Manifest): void {
|
|
24
|
-
const dir = join(cwd, AI_DIR);
|
|
25
|
-
if (!existsSync(dir)) {
|
|
26
|
-
mkdirSync(dir, { recursive: true });
|
|
27
|
-
}
|
|
28
|
-
writeFileSync(getManifestPath(cwd), JSON.stringify(manifest, null, 2));
|
|
29
|
-
}
|
package/src/output.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import gradient from "gradient-string";
|
|
2
|
-
|
|
3
|
-
const brand = gradient(["#ff006e", "#fb5607", "#ffbe0b", "#8338ec", "#3a86ff"]);
|
|
4
|
-
|
|
5
|
-
type Color = "green" | "cyan" | "yellow" | "red" | "dim" | "white" | "reset";
|
|
6
|
-
|
|
7
|
-
const colors: Record<Color, string> = {
|
|
8
|
-
green: "\x1b[32m",
|
|
9
|
-
cyan: "\x1b[36m",
|
|
10
|
-
yellow: "\x1b[33m",
|
|
11
|
-
red: "\x1b[31m",
|
|
12
|
-
dim: "\x1b[90m",
|
|
13
|
-
white: "\x1b[37m",
|
|
14
|
-
reset: "\x1b[0m",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function colorize(text: string, color: Color): string {
|
|
18
|
-
return `${colors[color]}${text}${colors.reset}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface SyncStats {
|
|
22
|
-
rules: number;
|
|
23
|
-
skills: number;
|
|
24
|
-
commands: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const log = {
|
|
28
|
-
logo: (version: string) => {
|
|
29
|
-
console.log(
|
|
30
|
-
brand(`ai-kit v${version}`) +
|
|
31
|
-
" " +
|
|
32
|
-
colorize("The AI configuration CLI", "dim"),
|
|
33
|
-
);
|
|
34
|
-
console.log(colorize("from Club Matto\n", "dim"));
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
welcome: () => {
|
|
38
|
-
console.log(
|
|
39
|
-
colorize(
|
|
40
|
-
" Syncing AI rules, skills, and commands to your project...\n",
|
|
41
|
-
"dim",
|
|
42
|
-
),
|
|
43
|
-
);
|
|
44
|
-
},
|
|
45
|
-
|
|
46
|
-
section: (msg: string) => console.log(colorize(` → ${msg}`, "cyan")),
|
|
47
|
-
|
|
48
|
-
success: (msg: string) => console.log(colorize(` ✓ ${msg}`, "green")),
|
|
49
|
-
|
|
50
|
-
final: (msg: string) => console.log(colorize(` ✓ ${msg}`, "green")),
|
|
51
|
-
|
|
52
|
-
summary: (counts: SyncStats) => {
|
|
53
|
-
console.log(colorize("\n ✓ Done!", "green"));
|
|
54
|
-
console.log(
|
|
55
|
-
colorize(` → `, "dim") +
|
|
56
|
-
colorize(counts.commands.toString(), "white") +
|
|
57
|
-
colorize(` commands`, "dim") +
|
|
58
|
-
colorize(`, `, "dim") +
|
|
59
|
-
colorize(counts.rules.toString(), "white") +
|
|
60
|
-
colorize(` rules`, "dim") +
|
|
61
|
-
colorize(`, `, "dim") +
|
|
62
|
-
colorize(counts.skills.toString(), "white") +
|
|
63
|
-
colorize(` skills`, "dim"),
|
|
64
|
-
);
|
|
65
|
-
},
|
|
66
|
-
};
|
package/src/reader.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
|
|
4
|
-
type SyncType = "commands" | "rules" | "skills" | "config";
|
|
5
|
-
|
|
6
|
-
export interface SyncItem {
|
|
7
|
-
type: SyncType;
|
|
8
|
-
name: string;
|
|
9
|
-
content: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface CommandConfig {
|
|
13
|
-
template: string;
|
|
14
|
-
description: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function parseFrontmatter(content: string): Record<string, string> {
|
|
18
|
-
const result: Record<string, string> = {};
|
|
19
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
20
|
-
if (!match) return result;
|
|
21
|
-
|
|
22
|
-
const frontmatter = match[1];
|
|
23
|
-
for (const line of frontmatter.split("\n")) {
|
|
24
|
-
const colonIndex = line.indexOf(":");
|
|
25
|
-
if (colonIndex === -1) continue;
|
|
26
|
-
const key = line.slice(0, colonIndex).trim();
|
|
27
|
-
result[key] = line.slice(colonIndex + 1).trim();
|
|
28
|
-
}
|
|
29
|
-
return result;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function getCommandConfig(
|
|
33
|
-
commandsDir: string,
|
|
34
|
-
): Record<string, CommandConfig> {
|
|
35
|
-
const config: Record<string, CommandConfig> = {};
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const files = readdirSync(commandsDir);
|
|
39
|
-
for (const file of files) {
|
|
40
|
-
if (!file.endsWith(".md")) continue;
|
|
41
|
-
const filePath = join(commandsDir, file);
|
|
42
|
-
const content = readFileSync(filePath, "utf-8");
|
|
43
|
-
const frontmatter = parseFrontmatter(content);
|
|
44
|
-
const name = file.replace(/\.md$/, "");
|
|
45
|
-
const body = content.replace(/^---[\s\S]*?---\n/, "").trim();
|
|
46
|
-
config[name] = {
|
|
47
|
-
description: frontmatter.description || "",
|
|
48
|
-
template: body,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
return config;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return config;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function readFiles(dir: string, type: SyncType, baseDir?: string): SyncItem[] {
|
|
59
|
-
if (!readdirSync(dir, { withFileTypes: true }).length) {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const base = baseDir || dir;
|
|
64
|
-
|
|
65
|
-
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
66
|
-
const path = join(dir, entry.name);
|
|
67
|
-
const relativePath = path.slice(base.length + 1);
|
|
68
|
-
if (entry.isDirectory()) {
|
|
69
|
-
return readFiles(path, type, base);
|
|
70
|
-
}
|
|
71
|
-
if (entry.name.endsWith(".md")) {
|
|
72
|
-
return [
|
|
73
|
-
{
|
|
74
|
-
type,
|
|
75
|
-
name: relativePath,
|
|
76
|
-
content: readFileSync(path, "utf-8"),
|
|
77
|
-
},
|
|
78
|
-
];
|
|
79
|
-
}
|
|
80
|
-
return [];
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function readContent(rulesDir: string, skillsDir: string): SyncItem[] {
|
|
85
|
-
return [...readFiles(rulesDir, "rules"), ...readFiles(skillsDir, "skills")];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function readConfigs(agentsDir: string): SyncItem[] {
|
|
89
|
-
try {
|
|
90
|
-
return readdirSync(agentsDir, { withFileTypes: true })
|
|
91
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
92
|
-
.map((entry) => ({
|
|
93
|
-
type: "config",
|
|
94
|
-
name: entry.name,
|
|
95
|
-
content: readFileSync(join(agentsDir, entry.name), "utf-8"),
|
|
96
|
-
}));
|
|
97
|
-
} catch {
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function readAgents(agentsDir: string): SyncItem | null {
|
|
103
|
-
const sourcePath = join(agentsDir, "monorepo.md");
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
return {
|
|
107
|
-
type: "config",
|
|
108
|
-
name: "AGENTS.md",
|
|
109
|
-
content: readFileSync(sourcePath, "utf-8"),
|
|
110
|
-
};
|
|
111
|
-
} catch {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
}
|
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}}
|
package/src/template.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export function processTemplate(content: string): string {
|
|
2
|
-
const now = new Date();
|
|
3
|
-
const isoDate = now.toISOString().split("T")[0];
|
|
4
|
-
|
|
5
|
-
return content
|
|
6
|
-
.replace(
|
|
7
|
-
/\{\{FOOTER}}/g,
|
|
8
|
-
`Last updated: ${isoDate}. This file extends the global rules in @AGENTS.md. Always check both files.`,
|
|
9
|
-
)
|
|
10
|
-
.replace(
|
|
11
|
-
/\{\{AGENTS_FOOTER}}/g,
|
|
12
|
-
`This file was last updated: ${isoDate}. Always check the \`.agents/rules/\` directory for the most current language-specific guidelines.`,
|
|
13
|
-
);
|
|
14
|
-
}
|