Skip to content

Commit 0e8893f

Browse files
MaxKlessnx-cloud[bot]FrozenPandaz
authored
feat(core): improve configure-ai-agents to copy nx skills/subagents/plugins (#34176)
## Current Behavior The `configure-ai-agents` command sets up rules files (CLAUDE.md, AGENTS.md, GEMINI.md) and MCP configurations for AI coding agents, but doesn't provide extensibility artifacts like commands, skills, or subagents. ## Expected Behavior The command now: - **Adds OpenCode** as a new supported agent with project-level MCP config - **Configures Claude plugin** via marketplace settings (`.claude/settings.json` with `extraKnownMarketplaces`) - **Copies extensibility artifacts** (commands, skills, subagents) from `nrwl/nx-ai-agents-config` repo for non-Claude agents - **Caches the config repo** in `/tmp/nx-ai-agents-config/<commit-hash>/` with automatic cleanup of old versions ### Agent Distribution Matrix | Agent | Rules | MCP Config | Commands | Skills | Subagents | Plugin | |-------|-------|------------|----------|--------|-----------|--------| | Claude | CLAUDE.md | .mcp.json | - | - | - | ✓ (marketplace) | | OpenCode | AGENTS.md | opencode.json | ✓ | ✓ | ✓ | - | | Copilot | AGENTS.md | Nx Console | ✓ | ✓ | ✓ | - | | Cursor | AGENTS.md | Nx Console | ✓ | ✓ | - | - | | Gemini | GEMINI.md | .gemini/settings.json | ✓ | ✓ | - | - | --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: MaxKless <MaxKless@users.noreply.github.com> Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>
1 parent f9ab939 commit 0e8893f

31 files changed

Lines changed: 998 additions & 95 deletions

e2e/nx/src/configure-ai-agents.test.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
cleanupProject,
3+
listFiles,
34
newProject,
45
readFile,
56
removeFile,
@@ -15,7 +16,9 @@ describe('configure-ai-agents', () => {
1516
runCLI(`configure-ai-agents --agents claude --no-interactive`);
1617

1718
expect(readFile('CLAUDE.md')).toContain('# General Guidelines');
18-
expect(readFile('.mcp.json')).toContain('nx-mcp');
19+
// Claude uses plugin from marketplace (MCP is included in plugin)
20+
expect(readFile('.claude/settings.json')).toContain('nx-claude-plugins');
21+
expect(readFile('.claude/settings.json')).toContain('nx@nx-claude-plugins');
1922
});
2023

2124
it('should do nothing if agent is already configured', () => {
@@ -28,7 +31,7 @@ describe('configure-ai-agents', () => {
2831

2932
it('should throw with --check if agent rules are out of date', () => {
3033
updateFile('CLAUDE.md', (content: string) =>
31-
content.replace('nx_workspace', 'nx_workspace_outdated')
34+
content.replace('nx_docs', 'nx_docs_outdated')
3235
);
3336

3437
let didThrow = false;
@@ -43,14 +46,14 @@ describe('configure-ai-agents', () => {
4346
it('should update agent rules if out of date', () => {
4447
runCLI(`configure-ai-agents --agents claude --no-interactive`);
4548

46-
expect(readFile('CLAUDE.md')).not.toContain('nx_workspace_outdated');
49+
expect(readFile('CLAUDE.md')).not.toContain('nx_docs_outdated');
4750
});
4851

4952
describe('--check (backward compatible, defaults to outdated mode)', () => {
5053
beforeAll(() => {
5154
// Clean up previous tests
5255
removeFile('CLAUDE.md');
53-
removeFile('.mcp.json');
56+
removeFile('.claude/settings.json');
5457
});
5558

5659
it('should exit 0 with no configured agents', () => {
@@ -69,7 +72,7 @@ describe('configure-ai-agents', () => {
6972
describe('--check=outdated (explicit outdated mode)', () => {
7073
it('should exit 0 with no configured agents', () => {
7174
removeFile('CLAUDE.md');
72-
removeFile('.mcp.json');
75+
removeFile('.claude/settings.json');
7376

7477
const output = runCLI(
7578
`configure-ai-agents --agents claude --check=outdated`
@@ -79,7 +82,7 @@ describe('configure-ai-agents', () => {
7982

8083
it('should exit 0 with partially configured agents (ignores partial configs)', () => {
8184
runCLI(`configure-ai-agents --agents claude --no-interactive`);
82-
removeFile('.mcp.json');
85+
removeFile('.claude/settings.json');
8386

8487
const output = runCLI(
8588
`configure-ai-agents --agents claude --check=outdated`
@@ -95,7 +98,7 @@ describe('configure-ai-agents', () => {
9598

9699
// Make it outdated
97100
updateFile('CLAUDE.md', (content: string) =>
98-
content.replace('nx_workspace', 'nx_workspace_outdated')
101+
content.replace('nx_docs', 'nx_docs_outdated')
99102
);
100103

101104
let didThrow = false;
@@ -114,7 +117,7 @@ describe('configure-ai-agents', () => {
114117
describe('--check=all (comprehensive check)', () => {
115118
it('should exit 1 on clean workspace with no configured agents', () => {
116119
removeFile('CLAUDE.md');
117-
removeFile('.mcp.json');
120+
removeFile('.claude/settings.json');
118121

119122
let didThrow = false;
120123
try {
@@ -127,7 +130,7 @@ describe('configure-ai-agents', () => {
127130

128131
it('should exit 1 with partially configured agents', () => {
129132
runCLI(`configure-ai-agents --agents claude --no-interactive`);
130-
removeFile('.mcp.json');
133+
removeFile('.claude/settings.json');
131134

132135
let didThrow = false;
133136
try {
@@ -143,7 +146,7 @@ describe('configure-ai-agents', () => {
143146

144147
it('should exit 1 with outdated agents', () => {
145148
updateFile('CLAUDE.md', (content: string) =>
146-
content.replace('nx_workspace', 'nx_workspace_outdated')
149+
content.replace('nx_docs', 'nx_docs_outdated')
147150
);
148151

149152
let didThrow = false;
@@ -168,4 +171,17 @@ describe('configure-ai-agents', () => {
168171
);
169172
});
170173
});
174+
175+
describe('opencode agent', () => {
176+
it('should create opencode.json and .opencode/skills directory', () => {
177+
runCLI(`configure-ai-agents --agents opencode --no-interactive`);
178+
179+
// Verify opencode.json exists and contains nx-mcp config
180+
expect(readFile('opencode.json')).toContain('nx-mcp');
181+
182+
// Verify .opencode/skills is a directory with content
183+
const skillsContents = listFiles('.opencode/skills');
184+
expect(skillsContents.length).toBeGreaterThan(0);
185+
});
186+
});
171187
});

packages/create-nx-workspace/src/create-workspace-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const supportedAgents = [
5858
'copilot',
5959
'cursor',
6060
'gemini',
61+
'opencode',
6162
] as const;
6263
export type Agent = (typeof supportedAgents)[number];
6364
export const agentDisplayMap: Record<Agent, string> = {
@@ -66,4 +67,5 @@ export const agentDisplayMap: Record<Agent, string> = {
6667
codex: 'OpenAI Codex',
6768
copilot: 'GitHub Copilot for VSCode',
6869
cursor: 'Cursor',
70+
opencode: 'OpenCode',
6971
};

packages/devkit/src/generators/generate-files.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface GenerateFilesOptions {
2323
overwriteStrategy?: OverwriteStrategy;
2424
}
2525

26+
// TODO(v24): use the version from nx/src/generators/utils
2627
/**
2728
* Generates a folder of files based on provided templates.
2829
*

packages/devkit/src/utils/binary-extensions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ const binaryExtensions = new Set([
273273
'.zipx',
274274
]);
275275

276+
// TODO(v24): use the version from nx/src/utils
276277
export function isBinaryPath(path: string): boolean {
277278
return binaryExtensions.has(extname(path).toLowerCase());
278279
}

packages/nx/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"cliui": "^8.0.1",
4949
"dotenv": "~16.4.5",
5050
"dotenv-expand": "~11.0.6",
51+
"ejs": "^3.1.7",
5152
"enquirer": "catalog:",
5253
"figures": "3.2.0",
5354
"flat": "^5.0.2",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { execSync } from 'child_process';
2+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
6+
const REPO_URL = 'https://github.com/nrwl/nx-ai-agents-config';
7+
const CACHE_DIR = join(tmpdir(), 'nx-ai-agents-config');
8+
9+
/**
10+
* Get the latest commit hash from the remote repository.
11+
* Uses `git ls-remote` to fetch the HEAD commit hash without cloning.
12+
*/
13+
function getLatestCommitHash(): string {
14+
try {
15+
const output = execSync(`git ls-remote ${REPO_URL} HEAD`, {
16+
encoding: 'utf-8',
17+
stdio: ['pipe', 'pipe', 'pipe'],
18+
timeout: 30000, // 30 second timeout
19+
});
20+
const hash = output.split('\t')[0];
21+
if (!hash || hash.length < 10) {
22+
throw new Error('Invalid commit hash received');
23+
}
24+
// Return first 10 characters of the commit hash
25+
return hash.substring(0, 10);
26+
} catch (error) {
27+
throw new Error(
28+
`Failed to fetch latest commit hash from ${REPO_URL}. Please check your network connection.`
29+
);
30+
}
31+
}
32+
33+
/**
34+
* Clone the repository to the specified path using shallow clone.
35+
*/
36+
function cloneRepo(targetPath: string): void {
37+
try {
38+
// Ensure parent directory exists
39+
mkdirSync(CACHE_DIR, { recursive: true });
40+
41+
// Use a temporary path first to avoid race conditions
42+
const tempPath = `${targetPath}.tmp.${process.pid}`;
43+
44+
// Clean up any leftover temp directory
45+
if (existsSync(tempPath)) {
46+
rmSync(tempPath, { recursive: true, force: true });
47+
}
48+
49+
execSync(`git clone --depth 1 ${REPO_URL} "${tempPath}"`, {
50+
encoding: 'utf-8',
51+
stdio: ['pipe', 'pipe', 'pipe'],
52+
timeout: 120000, // 2 minute timeout for clone
53+
});
54+
55+
// Remove .git directory after clone
56+
const gitDir = join(tempPath, '.git');
57+
if (existsSync(gitDir)) {
58+
rmSync(gitDir, { recursive: true, force: true });
59+
}
60+
61+
// Atomically move temp directory to final location
62+
// If targetPath already exists (race condition), just clean up temp
63+
if (existsSync(targetPath)) {
64+
rmSync(tempPath, { recursive: true, force: true });
65+
} else {
66+
// Rename is atomic on the same filesystem
67+
try {
68+
renameSync(tempPath, targetPath);
69+
} catch {
70+
// Rename failed - check if another process won the race
71+
if (existsSync(targetPath)) {
72+
// Another process created it, clean up our temp
73+
rmSync(tempPath, { recursive: true, force: true });
74+
} else {
75+
// targetPath still doesn't exist - retry once
76+
try {
77+
renameSync(tempPath, targetPath);
78+
} catch (retryError) {
79+
// Clean up and fail
80+
rmSync(tempPath, { recursive: true, force: true });
81+
throw new Error(
82+
`Failed to move cloned repository to cache location: ${(retryError as Error).message}`
83+
);
84+
}
85+
}
86+
}
87+
}
88+
} catch (error) {
89+
// Re-throw if it's already our error (from rename failure)
90+
if (error instanceof Error && error.message.startsWith('Failed to move')) {
91+
throw error;
92+
}
93+
throw new Error(
94+
`Failed to clone ${REPO_URL}. Please check your network connection.`
95+
);
96+
}
97+
}
98+
99+
/**
100+
* Clean up old cached versions, keeping only the current one.
101+
*/
102+
function cleanupOldCaches(currentCommitHash: string): void {
103+
if (!existsSync(CACHE_DIR)) {
104+
return;
105+
}
106+
107+
try {
108+
const entries = readdirSync(CACHE_DIR, { withFileTypes: true });
109+
for (const entry of entries) {
110+
if (entry.isDirectory() && entry.name !== currentCommitHash) {
111+
const oldCachePath = join(CACHE_DIR, entry.name);
112+
rmSync(oldCachePath, { recursive: true, force: true });
113+
}
114+
}
115+
} catch {
116+
// Ignore cleanup errors - not critical
117+
}
118+
}
119+
120+
/**
121+
* Get the path to the cached nx-ai-agents-config repository.
122+
* Uses a commit-hash based caching strategy:
123+
* 1. Fetches the latest commit hash from the remote repository
124+
* 2. Checks if a cached version exists for that hash
125+
* 3. If not, clones the repository and cleans up old caches
126+
*
127+
* @returns The path to the cached repository
128+
* @throws Error if unable to fetch or clone the repository
129+
*/
130+
export function getAiConfigRepoPath(): string {
131+
// 1. Get latest commit hash (first 10 chars)
132+
const commitHash = getLatestCommitHash();
133+
134+
// 2. Check if cached version exists
135+
const cachedPath = join(CACHE_DIR, commitHash);
136+
if (existsSync(cachedPath)) {
137+
return cachedPath;
138+
}
139+
140+
// 3. Clone fresh
141+
cloneRepo(cachedPath);
142+
143+
// 4. Clean up old cached versions
144+
cleanupOldCaches(commitHash);
145+
146+
return cachedPath;
147+
}

packages/nx/src/ai/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ export function claudeMdPath(root: string): string {
2929
return join(root, 'CLAUDE.md');
3030
}
3131

32-
export function claudeMcpPath(root: string): string {
33-
return join(root, '.mcp.json');
32+
export function opencodeMcpPath(root: string): string {
33+
return join(root, 'opencode.json');
3434
}
3535

3636
export const codexConfigTomlPath = join(homedir(), '.codex', 'config.toml');

packages/nx/src/ai/set-up-ai-agents/get-agent-rules.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ export function getAgentRules(nxCloud: boolean) {
44
55
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through \`nx\` (i.e. \`nx run\`, \`nx run-many\`, \`nx affected\`) instead of using the underlying tooling directly
66
- You have access to the Nx MCP server and its tools, use them to help the user
7-
- When answering questions about the repository, use the \`nx_workspace\` tool first to gain an understanding of the workspace architecture where applicable.
8-
- When working in individual projects, use the \`nx_project_details\` mcp tool to analyze and understand the specific project structure and dependencies
9-
- For questions around nx configuration, best practices or if you're unsure, use the \`nx_docs\` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
10-
- If the user needs help with an Nx configuration or project graph error, use the \`nx_workspace\` tool to get any errors
7+
- For understanding the workspace structure, projects, or available tasks, use the \`/nx-workspace\` skill which provides guidance on exploring Nx workspaces
8+
- For questions around nx configuration, best practices or if you're unsure, use the \`nx_docs\` MCP tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
119
- For Nx plugin best practices, check \`node_modules/@nx/<plugin>/PLUGIN.md\`. Not all plugins have this file - proceed without it if unavailable.
1210
`;
1311
}

packages/nx/src/ai/set-up-ai-agents/schema.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Agent } from '../utils';
2+
13
export type SetupAiAgentsGeneratorSchema = {
24
directory: string;
35
writeNxCloudRules?: boolean;

packages/nx/src/ai/set-up-ai-agents/schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
"description": "The agents to setup Nx configuration for.",
2626
"items": {
2727
"type": "string",
28-
"enum": ["claude", "gemini", "codex", "cursor", "copilot"]
28+
"enum": ["claude", "gemini", "codex", "cursor", "copilot", "opencode"]
2929
},
30-
"default": ["claude", "gemini", "codex", "cursor", "copilot"]
30+
"default": ["claude", "gemini", "codex", "cursor", "copilot", "opencode"]
3131
}
3232
},
3333
"required": ["directory"]

0 commit comments

Comments
 (0)