aicm 0.14.5 → 0.15.1
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/README.md +21 -2
- package/dist/commands/install.js +62 -88
- package/dist/utils/config.d.ts +4 -2
- package/dist/utils/config.js +8 -1
- package/dist/utils/rules-file-writer.d.ts +5 -0
- package/dist/utils/rules-file-writer.js +46 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ Modern AI-powered IDEs like Cursor and Agents like Codex enable developers to wr
|
|
|
17
17
|
aicm accepts Cursor's `.mdc` format as it provides the most comprehensive feature set. For other AI tools and IDEs, aicm automatically generates compatible formats:
|
|
18
18
|
|
|
19
19
|
- **Cursor**: Native `.mdc` files with full feature support
|
|
20
|
-
- **Windsurf
|
|
20
|
+
- **Windsurf**: Generates `.windsurfrules` file
|
|
21
|
+
- **Codex**: Generates `AGENTS.md` file
|
|
22
|
+
- **Claude**: Generates `CLAUDE.md` file
|
|
21
23
|
|
|
22
24
|
This approach ensures you write your rules once in the richest format available, while maintaining compatibility across different AI development environments.
|
|
23
25
|
|
|
@@ -160,6 +162,7 @@ aicm automatically detects workspaces if your `package.json` contains a `workspa
|
|
|
160
162
|
|
|
161
163
|
1. **Discover packages**: Automatically find all directories containing `aicm.json` files in your repository
|
|
162
164
|
2. **Install per package**: Install rules and MCPs for each package individually in their respective directories
|
|
165
|
+
3. **Merge MCP servers**: Write a merged `.cursor/mcp.json` at the repository root containing all MCP servers from every package
|
|
163
166
|
|
|
164
167
|
### How It Works
|
|
165
168
|
|
|
@@ -185,6 +188,20 @@ Running `npx aicm install` will install rules for each package in their respecti
|
|
|
185
188
|
- `packages/backend/.cursor/rules/aicm/`
|
|
186
189
|
- `services/api/.cursor/rules/aicm/`
|
|
187
190
|
|
|
191
|
+
### Preset Packages in Workspaces
|
|
192
|
+
|
|
193
|
+
When you have a preset package within your workspace (a package that provides rules to be consumed by others), you can prevent aicm from installing rules into it by setting `skipInstall: true`:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"skipInstall": true,
|
|
198
|
+
"rulesDir": "./rules",
|
|
199
|
+
"targets": ["cursor"]
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This is useful when your workspace contains both consumer packages (that need rules installed) and provider packages (that only export rules).
|
|
204
|
+
|
|
188
205
|
## Configuration
|
|
189
206
|
|
|
190
207
|
Create an `aicm.json` file in your project root, or an `aicm` key in your project's `package.json`.
|
|
@@ -195,7 +212,8 @@ Create an `aicm.json` file in your project root, or an `aicm` key in your projec
|
|
|
195
212
|
"targets": ["cursor"],
|
|
196
213
|
"presets": [],
|
|
197
214
|
"overrides": {},
|
|
198
|
-
"mcpServers": {}
|
|
215
|
+
"mcpServers": {},
|
|
216
|
+
"skipInstall": false
|
|
199
217
|
}
|
|
200
218
|
```
|
|
201
219
|
|
|
@@ -205,6 +223,7 @@ Create an `aicm.json` file in your project root, or an `aicm` key in your projec
|
|
|
205
223
|
- **overrides**: Map of rule names to `false` (disable) or a replacement file path.
|
|
206
224
|
- **mcpServers**: MCP server configurations.
|
|
207
225
|
- **workspaces**: Set to `true` to enable workspace mode. If not specified, aicm will automatically detect workspaces from your `package.json`.
|
|
226
|
+
- **skipInstall**: Set to `true` to skip rule installation for this package. Useful for preset packages that provide rules but shouldn't have rules installed into them.
|
|
208
227
|
|
|
209
228
|
### MCP Server Installation
|
|
210
229
|
|
package/dist/commands/install.js
CHANGED
|
@@ -13,12 +13,14 @@ const child_process_1 = require("child_process");
|
|
|
13
13
|
const config_1 = require("../utils/config");
|
|
14
14
|
const working_directory_1 = require("../utils/working-directory");
|
|
15
15
|
const is_ci_1 = require("../utils/is-ci");
|
|
16
|
+
const rules_file_writer_1 = require("../utils/rules-file-writer");
|
|
16
17
|
function getTargetPaths() {
|
|
17
18
|
const projectDir = process.cwd();
|
|
18
19
|
return {
|
|
19
20
|
cursor: node_path_1.default.join(projectDir, ".cursor", "rules", "aicm"),
|
|
20
21
|
windsurf: node_path_1.default.join(projectDir, ".aicm"),
|
|
21
22
|
codex: node_path_1.default.join(projectDir, ".aicm"),
|
|
23
|
+
claude: node_path_1.default.join(projectDir, ".aicm"),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
function writeCursorRules(rules, cursorRulesDir) {
|
|
@@ -50,80 +52,6 @@ function extractNamespaceFromPresetPath(presetPath) {
|
|
|
50
52
|
const parts = presetPath.split(node_path_1.default.sep);
|
|
51
53
|
return parts.filter((part) => part.length > 0); // Filter out empty segments
|
|
52
54
|
}
|
|
53
|
-
function generateRulesFileContent(ruleFiles) {
|
|
54
|
-
const alwaysApplyRules = [];
|
|
55
|
-
const optInRules = [];
|
|
56
|
-
for (const rule of ruleFiles) {
|
|
57
|
-
// Parse metadata to determine rule type
|
|
58
|
-
const metadata = rule.content.match(/^---\n([\s\S]*?)\n---/);
|
|
59
|
-
let alwaysApply = false;
|
|
60
|
-
if (metadata) {
|
|
61
|
-
try {
|
|
62
|
-
const frontmatter = metadata[1];
|
|
63
|
-
// Simple YAML parsing for alwaysApply field
|
|
64
|
-
const alwaysApplyMatch = frontmatter.match(/alwaysApply:\s*(true|false)/);
|
|
65
|
-
if (alwaysApplyMatch) {
|
|
66
|
-
alwaysApply = alwaysApplyMatch[1] === "true";
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
catch (_a) {
|
|
70
|
-
// If parsing fails, default to false
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
if (alwaysApply) {
|
|
74
|
-
alwaysApplyRules.push(`- ${rule.path}`);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
optInRules.push(`- ${rule.path}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
let content = "<!-- AICM:BEGIN -->\n";
|
|
81
|
-
if (alwaysApplyRules.length > 0) {
|
|
82
|
-
content +=
|
|
83
|
-
"The following rules always apply to all files in the project:\n";
|
|
84
|
-
content += alwaysApplyRules.join("\n") + "\n\n";
|
|
85
|
-
}
|
|
86
|
-
if (optInRules.length > 0) {
|
|
87
|
-
content +=
|
|
88
|
-
"The following rules are available for the AI to include when needed:\n";
|
|
89
|
-
content += optInRules.join("\n") + "\n\n";
|
|
90
|
-
}
|
|
91
|
-
content += "<!-- AICM:END -->";
|
|
92
|
-
return content;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Write rules file content to a file, preserving existing content outside markers
|
|
96
|
-
*/
|
|
97
|
-
function writeRulesFile(rulesContent, rulesFilePath) {
|
|
98
|
-
const RULES_BEGIN = "<!-- AICM:BEGIN -->";
|
|
99
|
-
const RULES_END = "<!-- AICM:END -->";
|
|
100
|
-
let fileContent;
|
|
101
|
-
if (fs_extra_1.default.existsSync(rulesFilePath)) {
|
|
102
|
-
const existingContent = fs_extra_1.default.readFileSync(rulesFilePath, "utf8");
|
|
103
|
-
if (existingContent.includes(RULES_BEGIN) &&
|
|
104
|
-
existingContent.includes(RULES_END)) {
|
|
105
|
-
const beforeMarker = existingContent.split(RULES_BEGIN)[0];
|
|
106
|
-
const afterMarker = existingContent.split(RULES_END)[1];
|
|
107
|
-
fileContent = beforeMarker + rulesContent + afterMarker;
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// Preserve the existing content and append markers
|
|
111
|
-
let separator = "";
|
|
112
|
-
if (!existingContent.endsWith("\n")) {
|
|
113
|
-
separator += "\n";
|
|
114
|
-
}
|
|
115
|
-
if (!existingContent.endsWith("\n\n")) {
|
|
116
|
-
separator += "\n";
|
|
117
|
-
}
|
|
118
|
-
fileContent = existingContent + separator + rulesContent;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
// Create new file with markers and content
|
|
123
|
-
fileContent = rulesContent + "\n";
|
|
124
|
-
}
|
|
125
|
-
fs_extra_1.default.writeFileSync(rulesFilePath, fileContent);
|
|
126
|
-
}
|
|
127
55
|
/**
|
|
128
56
|
* Write rules to a shared directory and update the given rules file
|
|
129
57
|
*/
|
|
@@ -161,11 +89,11 @@ function writeRulesForFile(rules, ruleDir, rulesFile) {
|
|
|
161
89
|
return {
|
|
162
90
|
name: rule.name,
|
|
163
91
|
path: windsurfPathPosix,
|
|
164
|
-
|
|
92
|
+
metadata: (0, rules_file_writer_1.parseRuleFrontmatter)(rule.content),
|
|
165
93
|
};
|
|
166
94
|
});
|
|
167
|
-
const rulesContent = generateRulesFileContent(ruleFiles);
|
|
168
|
-
writeRulesFile(rulesContent, node_path_1.default.join(process.cwd(), rulesFile));
|
|
95
|
+
const rulesContent = (0, rules_file_writer_1.generateRulesFileContent)(ruleFiles);
|
|
96
|
+
(0, rules_file_writer_1.writeRulesFile)(rulesContent, node_path_1.default.join(process.cwd(), rulesFile));
|
|
169
97
|
}
|
|
170
98
|
/**
|
|
171
99
|
* Write all collected rules to their respective IDE targets
|
|
@@ -189,6 +117,11 @@ function writeRulesToTargets(rules, targets) {
|
|
|
189
117
|
writeRulesForFile(rules, targetPaths.codex, "AGENTS.md");
|
|
190
118
|
}
|
|
191
119
|
break;
|
|
120
|
+
case "claude":
|
|
121
|
+
if (rules.length > 0) {
|
|
122
|
+
writeRulesForFile(rules, targetPaths.claude, "CLAUDE.md");
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
192
125
|
}
|
|
193
126
|
}
|
|
194
127
|
}
|
|
@@ -245,6 +178,37 @@ function writeMcpServersToFile(mcpServers, mcpPath) {
|
|
|
245
178
|
};
|
|
246
179
|
fs_extra_1.default.writeJsonSync(mcpPath, mergedConfig, { spaces: 2 });
|
|
247
180
|
}
|
|
181
|
+
function mergeWorkspaceMcpServers(packages) {
|
|
182
|
+
const merged = {};
|
|
183
|
+
const info = {};
|
|
184
|
+
for (const pkg of packages) {
|
|
185
|
+
for (const [key, value] of Object.entries(pkg.config.mcpServers)) {
|
|
186
|
+
if (value === false)
|
|
187
|
+
continue;
|
|
188
|
+
const json = JSON.stringify(value);
|
|
189
|
+
if (!info[key]) {
|
|
190
|
+
info[key] = {
|
|
191
|
+
configs: new Set([json]),
|
|
192
|
+
packages: [pkg.relativePath],
|
|
193
|
+
chosen: pkg.relativePath,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
info[key].packages.push(pkg.relativePath);
|
|
198
|
+
info[key].configs.add(json);
|
|
199
|
+
info[key].chosen = pkg.relativePath;
|
|
200
|
+
}
|
|
201
|
+
merged[key] = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const conflicts = [];
|
|
205
|
+
for (const [key, data] of Object.entries(info)) {
|
|
206
|
+
if (data.configs.size > 1) {
|
|
207
|
+
conflicts.push({ key, packages: data.packages, chosen: data.chosen });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { merged, conflicts };
|
|
211
|
+
}
|
|
248
212
|
/**
|
|
249
213
|
* Discover all packages with aicm configurations using git ls-files
|
|
250
214
|
*/
|
|
@@ -310,17 +274,12 @@ async function installPackage(options = {}) {
|
|
|
310
274
|
};
|
|
311
275
|
}
|
|
312
276
|
const { config, rules, mcpServers } = resolvedConfig;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
error: new Error("No rules defined in configuration"),
|
|
320
|
-
installedRuleCount: 0,
|
|
321
|
-
packagesCount: 0,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
277
|
+
if (config.skipInstall === true) {
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
installedRuleCount: 0,
|
|
281
|
+
packagesCount: 0,
|
|
282
|
+
};
|
|
324
283
|
}
|
|
325
284
|
try {
|
|
326
285
|
if (!options.dryRun) {
|
|
@@ -396,6 +355,9 @@ async function installWorkspaces(cwd, installOnCI, verbose = false, dryRun = fal
|
|
|
396
355
|
}
|
|
397
356
|
const allPackages = await discoverPackagesWithAicm(cwd);
|
|
398
357
|
const packages = allPackages.filter((pkg) => {
|
|
358
|
+
if (pkg.config.config.skipInstall === true) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
399
361
|
const isRoot = pkg.relativePath === ".";
|
|
400
362
|
if (!isRoot)
|
|
401
363
|
return true;
|
|
@@ -424,6 +386,15 @@ async function installWorkspaces(cwd, installOnCI, verbose = false, dryRun = fal
|
|
|
424
386
|
verbose,
|
|
425
387
|
dryRun,
|
|
426
388
|
});
|
|
389
|
+
const { merged: rootMcp, conflicts } = mergeWorkspaceMcpServers(packages);
|
|
390
|
+
const hasCursorTarget = packages.some((p) => p.config.config.targets.includes("cursor"));
|
|
391
|
+
if (!dryRun && hasCursorTarget && Object.keys(rootMcp).length > 0) {
|
|
392
|
+
const mcpPath = node_path_1.default.join(cwd, ".cursor", "mcp.json");
|
|
393
|
+
writeMcpServersToFile(rootMcp, mcpPath);
|
|
394
|
+
}
|
|
395
|
+
for (const conflict of conflicts) {
|
|
396
|
+
console.warn(`Warning: MCP configuration conflict detected\n Key: "${conflict.key}"\n Packages: ${conflict.packages.join(", ")}\n Using configuration from: ${conflict.chosen}`);
|
|
397
|
+
}
|
|
427
398
|
if (verbose) {
|
|
428
399
|
result.packages.forEach((pkg) => {
|
|
429
400
|
if (pkg.success) {
|
|
@@ -508,6 +479,9 @@ async function installCommand(installOnCI, verbose, dryRun) {
|
|
|
508
479
|
console.log(`Dry run: validated ${rulesInstalledMessage}`);
|
|
509
480
|
}
|
|
510
481
|
}
|
|
482
|
+
else if (result.installedRuleCount === 0) {
|
|
483
|
+
console.log("No rules installed");
|
|
484
|
+
}
|
|
511
485
|
else if (result.packagesCount > 1) {
|
|
512
486
|
console.log(`Successfully installed ${rulesInstalledMessage} across ${result.packagesCount} packages`);
|
|
513
487
|
}
|
package/dist/utils/config.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface RawConfig {
|
|
|
6
6
|
overrides?: Record<string, string | false>;
|
|
7
7
|
mcpServers?: MCPServers;
|
|
8
8
|
workspaces?: boolean;
|
|
9
|
+
skipInstall?: boolean;
|
|
9
10
|
}
|
|
10
11
|
export interface Config {
|
|
11
12
|
rulesDir?: string;
|
|
@@ -14,6 +15,7 @@ export interface Config {
|
|
|
14
15
|
overrides?: Record<string, string | false>;
|
|
15
16
|
mcpServers?: MCPServers;
|
|
16
17
|
workspaces?: boolean;
|
|
18
|
+
skipInstall?: boolean;
|
|
17
19
|
}
|
|
18
20
|
export type MCPServer = {
|
|
19
21
|
command: string;
|
|
@@ -44,8 +46,8 @@ export interface ResolvedConfig {
|
|
|
44
46
|
rules: RuleFile[];
|
|
45
47
|
mcpServers: MCPServers;
|
|
46
48
|
}
|
|
47
|
-
export declare const ALLOWED_CONFIG_KEYS: readonly ["rulesDir", "targets", "presets", "overrides", "mcpServers", "workspaces"];
|
|
48
|
-
export declare const SUPPORTED_TARGETS: readonly ["cursor", "windsurf", "codex"];
|
|
49
|
+
export declare const ALLOWED_CONFIG_KEYS: readonly ["rulesDir", "targets", "presets", "overrides", "mcpServers", "workspaces", "skipInstall"];
|
|
50
|
+
export declare const SUPPORTED_TARGETS: readonly ["cursor", "windsurf", "codex", "claude"];
|
|
49
51
|
export type SupportedTarget = (typeof SUPPORTED_TARGETS)[number];
|
|
50
52
|
export declare function detectWorkspacesFromPackageJson(cwd: string): boolean;
|
|
51
53
|
export declare function resolveWorkspaces(config: unknown, configFilePath: string, cwd: string): boolean;
|
package/dist/utils/config.js
CHANGED
|
@@ -27,8 +27,14 @@ exports.ALLOWED_CONFIG_KEYS = [
|
|
|
27
27
|
"overrides",
|
|
28
28
|
"mcpServers",
|
|
29
29
|
"workspaces",
|
|
30
|
+
"skipInstall",
|
|
31
|
+
];
|
|
32
|
+
exports.SUPPORTED_TARGETS = [
|
|
33
|
+
"cursor",
|
|
34
|
+
"windsurf",
|
|
35
|
+
"codex",
|
|
36
|
+
"claude",
|
|
30
37
|
];
|
|
31
|
-
exports.SUPPORTED_TARGETS = ["cursor", "windsurf", "codex"];
|
|
32
38
|
function detectWorkspacesFromPackageJson(cwd) {
|
|
33
39
|
try {
|
|
34
40
|
const packageJsonPath = node_path_1.default.join(cwd, "package.json");
|
|
@@ -60,6 +66,7 @@ function applyDefaults(config, workspaces) {
|
|
|
60
66
|
overrides: config.overrides || {},
|
|
61
67
|
mcpServers: config.mcpServers || {},
|
|
62
68
|
workspaces,
|
|
69
|
+
skipInstall: config.skipInstall || false,
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
function validateConfig(config, configFilePath, cwd, isWorkspaceMode = false) {
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export type RuleMetadata = Record<string, string | boolean | string[]>;
|
|
2
|
+
/**
|
|
3
|
+
* Parse YAML frontmatter blocks from a rule file and return a flat metadata object
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseRuleFrontmatter(content: string): RuleMetadata;
|
|
1
6
|
/**
|
|
2
7
|
* Write rules to the .windsurfrules file
|
|
3
8
|
* This will update the content between the RULES_BEGIN and RULES_END markers
|
|
@@ -3,10 +3,55 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRuleFrontmatter = parseRuleFrontmatter;
|
|
6
7
|
exports.writeRulesFile = writeRulesFile;
|
|
7
8
|
exports.generateRulesFileContent = generateRulesFileContent;
|
|
8
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
11
|
+
/**
|
|
12
|
+
* Parse YAML frontmatter blocks from a rule file and return a flat metadata object
|
|
13
|
+
*/
|
|
14
|
+
function parseRuleFrontmatter(content) {
|
|
15
|
+
const metadata = {};
|
|
16
|
+
// Support both LF and CRLF line endings
|
|
17
|
+
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/gm;
|
|
18
|
+
let match;
|
|
19
|
+
while ((match = frontmatterRegex.exec(content)) !== null) {
|
|
20
|
+
const lines = match[1].split("\n");
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
continue;
|
|
25
|
+
const [key, ...rest] = trimmed.split(":");
|
|
26
|
+
if (!key)
|
|
27
|
+
continue;
|
|
28
|
+
const raw = rest.join(":").trim();
|
|
29
|
+
if (raw === "") {
|
|
30
|
+
metadata[key] = "";
|
|
31
|
+
}
|
|
32
|
+
else if (raw === "true" || raw === "false") {
|
|
33
|
+
metadata[key] = raw === "true";
|
|
34
|
+
}
|
|
35
|
+
else if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw.replace(/'/g, '"'));
|
|
38
|
+
metadata[key] = parsed;
|
|
39
|
+
}
|
|
40
|
+
catch (_a) {
|
|
41
|
+
metadata[key] = raw;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if ((raw.startsWith('"') && raw.endsWith('"')) ||
|
|
45
|
+
(raw.startsWith("'") && raw.endsWith("'"))) {
|
|
46
|
+
metadata[key] = raw.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
metadata[key] = raw;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return metadata;
|
|
54
|
+
}
|
|
10
55
|
const RULES_BEGIN = "<!-- AICM:BEGIN -->";
|
|
11
56
|
const RULES_END = "<!-- AICM:END -->";
|
|
12
57
|
const WARNING = "<!-- WARNING: Everything between these markers will be overwritten during installation -->";
|
|
@@ -118,7 +163,7 @@ function generateRulesFileContent(ruleFiles) {
|
|
|
118
163
|
// Agent Requested rules
|
|
119
164
|
if (agentRequestedRules.length > 0) {
|
|
120
165
|
content +=
|
|
121
|
-
"The following rules
|
|
166
|
+
"The following rules can be loaded when relevant. Check each file's description:\n";
|
|
122
167
|
agentRequestedRules.forEach((rule) => {
|
|
123
168
|
content += `- ${rule}\n`;
|
|
124
169
|
});
|