Skip to content

Commit a815832

Browse files
committed
feat(cli): add AGENTS.md guidance and Claude MCP install policy to init
The init command now creates or updates a project-level AGENTS.md with XcodeBuildMCP skill usage guidance after installing a skill. Handles legacy guidance line replacement, shows a diff preview, and prompts for confirmation (or requires --force in non-interactive mode). Skip Claude Code when installing MCP skills in auto-detect mode since Claude already receives server instructions via the MCP protocol. An explicit --client claude flag overrides the policy.
1 parent 46906c3 commit a815832

5 files changed

Lines changed: 307 additions & 14 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# AGENTS.md
2+
3+
- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async function runInitCommand(): Promise<void> {
6363
setLogLevel(level);
6464
}
6565
});
66-
registerInitCommand(app);
66+
registerInitCommand(app, { workspaceRoot: process.cwd() });
6767
await app.parseAsync();
6868
}
6969

src/cli/commands/__tests__/init.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import { homedir } from 'node:os';
2020

2121
const mockedGetResourceRoot = vi.mocked(getResourceRoot);
2222
const mockedHomedir = vi.mocked(homedir);
23+
const agentsGuidanceLine =
24+
'- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.';
25+
const legacyAgentsGuidanceLine =
26+
'- If using XcodeBuildMCP, first find and read the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools.';
2327

2428
function loadInitModule() {
2529
return import('../init.ts');
@@ -126,6 +130,58 @@ describe('init command', () => {
126130

127131
stdoutSpy.mockRestore();
128132
});
133+
134+
it('skips Claude for MCP skill in auto-detect mode', async () => {
135+
const fakeHome = join(tempDir, 'home-auto-skip-claude');
136+
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
137+
mkdirSync(join(fakeHome, '.cursor'), { recursive: true });
138+
mockedHomedir.mockReturnValue(fakeHome);
139+
140+
const yargs = (await import('yargs')).default;
141+
const mod = await loadInitModule();
142+
143+
const app = yargs(['init', '--skill', 'mcp']).scriptName('');
144+
mod.registerInitCommand(app);
145+
146+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
147+
await app.parseAsync();
148+
149+
expect(existsSync(join(fakeHome, '.claude', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
150+
false,
151+
);
152+
expect(existsSync(join(fakeHome, '.cursor', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
153+
true,
154+
);
155+
156+
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
157+
expect(output).toContain('Skipped Claude Code');
158+
159+
stdoutSpy.mockRestore();
160+
});
161+
162+
it('allows explicit Claude MCP install with --client claude', async () => {
163+
const fakeHome = join(tempDir, 'home-explicit-claude');
164+
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
165+
mockedHomedir.mockReturnValue(fakeHome);
166+
167+
const yargs = (await import('yargs')).default;
168+
const mod = await loadInitModule();
169+
170+
const app = yargs(['init', '--client', 'claude', '--skill', 'mcp']).scriptName('');
171+
mod.registerInitCommand(app);
172+
173+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
174+
await app.parseAsync();
175+
176+
expect(existsSync(join(fakeHome, '.claude', 'skills', 'xcodebuildmcp', 'SKILL.md'))).toBe(
177+
true,
178+
);
179+
180+
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
181+
expect(output).not.toContain('Skipped Claude Code');
182+
183+
stdoutSpy.mockRestore();
184+
});
129185
});
130186

131187
describe('conflict handling', () => {
@@ -320,6 +376,127 @@ describe('init command', () => {
320376
});
321377
});
322378

379+
describe('AGENTS.md guidance on skill install', () => {
380+
it('creates project-level AGENTS.md when missing', async () => {
381+
const dest = join(tempDir, 'skills');
382+
const projectRoot = join(tempDir, 'project-create');
383+
mkdirSync(dest, { recursive: true });
384+
mkdirSync(projectRoot, { recursive: true });
385+
386+
const yargs = (await import('yargs')).default;
387+
const mod = await loadInitModule();
388+
389+
const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('');
390+
mod.registerInitCommand(app, { workspaceRoot: projectRoot });
391+
392+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
393+
await app.parseAsync();
394+
395+
const agentsPath = join(projectRoot, 'AGENTS.md');
396+
expect(existsSync(agentsPath)).toBe(true);
397+
expect(readFileSync(agentsPath, 'utf8')).toContain(agentsGuidanceLine);
398+
399+
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
400+
expect(output).toContain('Created AGENTS.md with XcodeBuildMCP guidance');
401+
402+
stdoutSpy.mockRestore();
403+
});
404+
405+
it('shows diff and errors in non-interactive mode when AGENTS.md exists and --force is not set', async () => {
406+
const dest = join(tempDir, 'skills');
407+
const projectRoot = join(tempDir, 'project-non-interactive');
408+
mkdirSync(dest, { recursive: true });
409+
mkdirSync(projectRoot, { recursive: true });
410+
writeFileSync(join(projectRoot, 'AGENTS.md'), '# Existing\n', 'utf8');
411+
412+
const originalIsTTY = process.stdin.isTTY;
413+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
414+
415+
const yargs = (await import('yargs')).default;
416+
const mod = await loadInitModule();
417+
418+
const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('').fail(false);
419+
mod.registerInitCommand(app, { workspaceRoot: projectRoot });
420+
421+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
422+
await expect(app.parseAsync()).rejects.toThrow(
423+
'AGENTS.md exists and requires confirmation to update',
424+
);
425+
426+
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
427+
expect(output).toContain('Proposed update for');
428+
expect(output).toContain('--- AGENTS.md');
429+
expect(output).toContain('+++ AGENTS.md');
430+
expect(output).toContain(agentsGuidanceLine);
431+
432+
stdoutSpy.mockRestore();
433+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
434+
});
435+
436+
it('updates existing AGENTS.md with --force without prompting', async () => {
437+
const dest = join(tempDir, 'skills');
438+
const projectRoot = join(tempDir, 'project-force');
439+
mkdirSync(dest, { recursive: true });
440+
mkdirSync(projectRoot, { recursive: true });
441+
writeFileSync(join(projectRoot, 'AGENTS.md'), '# Existing\n', 'utf8');
442+
443+
const originalIsTTY = process.stdin.isTTY;
444+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
445+
446+
const yargs = (await import('yargs')).default;
447+
const mod = await loadInitModule();
448+
449+
const app = yargs(['init', '--dest', dest, '--skill', 'cli', '--force']).scriptName('');
450+
mod.registerInitCommand(app, { workspaceRoot: projectRoot });
451+
452+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
453+
await app.parseAsync();
454+
455+
const agentsContent = readFileSync(join(projectRoot, 'AGENTS.md'), 'utf8');
456+
expect(agentsContent).toContain('# Existing');
457+
expect(agentsContent).toContain(agentsGuidanceLine);
458+
459+
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
460+
expect(output).toContain('Proposed update for');
461+
expect(output).toContain('Updated AGENTS.md at');
462+
463+
stdoutSpy.mockRestore();
464+
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
465+
});
466+
467+
it('replaces legacy XcodeBuildMCP guidance line without appending duplicate', async () => {
468+
const dest = join(tempDir, 'skills');
469+
const projectRoot = join(tempDir, 'project-legacy-guidance');
470+
mkdirSync(dest, { recursive: true });
471+
mkdirSync(projectRoot, { recursive: true });
472+
writeFileSync(
473+
join(projectRoot, 'AGENTS.md'),
474+
`# Existing\n\n${legacyAgentsGuidanceLine}\n`,
475+
'utf8',
476+
);
477+
478+
const yargs = (await import('yargs')).default;
479+
const mod = await loadInitModule();
480+
481+
const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('');
482+
mod.registerInitCommand(app, { workspaceRoot: projectRoot });
483+
484+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
485+
await app.parseAsync();
486+
487+
const agentsContent = readFileSync(join(projectRoot, 'AGENTS.md'), 'utf8');
488+
expect(agentsContent).toContain(agentsGuidanceLine);
489+
expect(agentsContent).not.toContain(legacyAgentsGuidanceLine);
490+
expect(
491+
agentsContent.match(
492+
new RegExp(agentsGuidanceLine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
493+
)?.length,
494+
).toBe(1);
495+
496+
stdoutSpy.mockRestore();
497+
});
498+
});
499+
323500
describe('error cases', () => {
324501
it('errors when --dest points to filesystem root', async () => {
325502
const rootDest = '/';

0 commit comments

Comments
 (0)