Skip to content

Commit cc1ead7

Browse files
committed
fix(init): Emit JSON output in non-interactive mode
Return structured JSON from init install/uninstall flows when no TTY is available. Keep clack-based output for interactive sessions. This gives agent and CI callers a stable machine-readable contract without introducing additional flags or output modes.
1 parent 98002a8 commit cc1ead7

2 files changed

Lines changed: 129 additions & 55 deletions

File tree

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

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ function loadInitModule() {
2929
return import('../init.ts');
3030
}
3131

32+
function parseJsonOutput(stdoutSpy: { mock: { calls: unknown[][] } }): Record<string, unknown> {
33+
const output = stdoutSpy.mock.calls.map((call) => String(call[0] ?? '')).join('');
34+
return JSON.parse(output.trim()) as Record<string, unknown>;
35+
}
36+
3237
describe('init command', () => {
3338
let tempDir: string;
3439
let fakeResourceRoot: string;
@@ -81,10 +86,11 @@ describe('init command', () => {
8186
expect(existsSync(installed)).toBe(true);
8287
expect(readFileSync(installed, 'utf8')).toBe('# CLI Skill Content');
8388

84-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
85-
expect(output).toContain('Installed XcodeBuildMCP CLI skill');
86-
expect(output).toContain('Custom');
87-
expect(output).toContain(installed);
89+
const output = parseJsonOutput(stdoutSpy);
90+
expect(output.action).toBe('install');
91+
expect(output.skillType).toBe('cli');
92+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
93+
expect(output.installed).toEqual([{ client: 'Custom', location: installed }]);
8894

8995
stdoutSpy.mockRestore();
9096
});
@@ -106,8 +112,10 @@ describe('init command', () => {
106112
expect(existsSync(installed)).toBe(true);
107113
expect(readFileSync(installed, 'utf8')).toBe('# MCP Skill Content');
108114

109-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
110-
expect(output).toContain('Installed XcodeBuildMCP (MCP server) skill');
115+
const output = parseJsonOutput(stdoutSpy);
116+
expect(output.action).toBe('install');
117+
expect(output.skillType).toBe('mcp');
118+
expect(output.message).toBe('Installed XcodeBuildMCP (MCP server) skill');
111119

112120
stdoutSpy.mockRestore();
113121
});
@@ -173,8 +181,15 @@ describe('init command', () => {
173181
true,
174182
);
175183

176-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
177-
expect(output).toContain('Skipped Claude Code');
184+
const output = parseJsonOutput(stdoutSpy);
185+
expect(output.action).toBe('install');
186+
expect(output.skillType).toBe('mcp');
187+
expect(output.skipped).toEqual([
188+
{
189+
client: 'Claude Code',
190+
reason: 'MCP skill is unnecessary because Claude Code already uses server instructions.',
191+
},
192+
]);
178193

179194
stdoutSpy.mockRestore();
180195
});
@@ -317,10 +332,16 @@ describe('init command', () => {
317332
expect(existsSync(cliSkillDir)).toBe(false);
318333
expect(existsSync(mcpSkillDir)).toBe(false);
319334

320-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
321-
expect(output).toContain('Uninstalled skill directories');
322-
expect(output).toContain('Removed (xcodebuildmcp-cli):');
323-
expect(output).toContain('Removed (xcodebuildmcp):');
335+
const output = parseJsonOutput(stdoutSpy);
336+
expect(output.action).toBe('uninstall');
337+
expect(output.message).toBe('Uninstalled skill directories');
338+
expect(output.removed).toHaveLength(2);
339+
expect(output.removed).toEqual(
340+
expect.arrayContaining([
341+
{ client: 'Custom', variant: 'xcodebuildmcp-cli', path: cliSkillDir },
342+
{ client: 'Custom', variant: 'xcodebuildmcp', path: mcpSkillDir },
343+
]),
344+
);
324345

325346
stdoutSpy.mockRestore();
326347
});
@@ -338,8 +359,10 @@ describe('init command', () => {
338359
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
339360
await app.parseAsync();
340361

341-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
342-
expect(output).toContain('No installed skill directories found');
362+
const output = parseJsonOutput(stdoutSpy);
363+
expect(output.action).toBe('uninstall');
364+
expect(output.message).toBe('No installed skill directories found to remove.');
365+
expect(output.removed).toEqual([]);
343366

344367
stdoutSpy.mockRestore();
345368
});
@@ -358,8 +381,10 @@ describe('init command', () => {
358381
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
359382
await app.parseAsync();
360383

361-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
362-
expect(output).toContain('No installed skill directories found');
384+
const output = parseJsonOutput(stdoutSpy);
385+
expect(output.action).toBe('uninstall');
386+
expect(output.message).toBe('No installed skill directories found to remove.');
387+
expect(output.removed).toEqual([]);
363388

364389
stdoutSpy.mockRestore();
365390
});
@@ -439,8 +464,9 @@ describe('init command', () => {
439464
expect(existsSync(agentsPath)).toBe(true);
440465
expect(readFileSync(agentsPath, 'utf8')).toContain(agentsGuidanceLine);
441466

442-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
443-
expect(output).toContain('Created AGENTS.md with XcodeBuildMCP guidance');
467+
const output = parseJsonOutput(stdoutSpy);
468+
expect(output.action).toBe('install');
469+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
444470

445471
stdoutSpy.mockRestore();
446472
});
@@ -466,11 +492,9 @@ describe('init command', () => {
466492
'AGENTS.md exists and requires confirmation to update',
467493
);
468494

469-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
470-
expect(output).toContain('Proposed update for');
471-
expect(output).toContain('--- AGENTS.md');
472-
expect(output).toContain('+++ AGENTS.md');
473-
expect(output).toContain(agentsGuidanceLine);
495+
const output = parseJsonOutput(stdoutSpy);
496+
expect(output.action).toBe('install');
497+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
474498

475499
stdoutSpy.mockRestore();
476500
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
@@ -499,9 +523,9 @@ describe('init command', () => {
499523
expect(agentsContent).toContain('# Existing');
500524
expect(agentsContent).toContain(agentsGuidanceLine);
501525

502-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
503-
expect(output).toContain('Proposed update for');
504-
expect(output).toContain('Updated AGENTS.md at');
526+
const output = parseJsonOutput(stdoutSpy);
527+
expect(output.action).toBe('install');
528+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
505529

506530
stdoutSpy.mockRestore();
507531
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });

src/cli/commands/init.ts

Lines changed: 79 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ function formatSkippedClients(skippedClients: Array<{ client: string; reason: st
124124
return skippedClients.map((skipped) => `${skipped.client}: ${skipped.reason}`).join('; ');
125125
}
126126

127+
interface InitReport {
128+
action: 'install' | 'uninstall';
129+
skillType?: SkillType;
130+
installed?: InstallResult[];
131+
removed?: Array<{ client: string; variant: string; path: string }>;
132+
skipped?: Array<{ client: string; reason: string }>;
133+
message: string;
134+
}
135+
127136
async function installSkill(
128137
skillsDir: string,
129138
clientName: string,
@@ -239,18 +248,23 @@ function renderAgentsAppendDiff(fileName: string): string {
239248
async function ensureAgentsGuidance(
240249
projectRoot: string,
241250
force: boolean,
251+
emitOutput: boolean,
242252
): Promise<'created' | 'updated' | 'no_change' | 'skipped'> {
243253
const agentsPath = path.join(projectRoot, AGENTS_FILE_NAME);
244254
if (!fs.existsSync(agentsPath)) {
245255
const newContent = `# ${AGENTS_FILE_NAME}\n\n${AGENTS_GUIDANCE_LINE}\n`;
246256
fs.writeFileSync(agentsPath, newContent, 'utf8');
247-
writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`);
257+
if (emitOutput) {
258+
writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`);
259+
}
248260
return 'created';
249261
}
250262

251263
const currentContent = fs.readFileSync(agentsPath, 'utf8');
252264
if (currentContent.includes(AGENTS_GUIDANCE_LINE)) {
253-
writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`);
265+
if (emitOutput) {
266+
writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`);
267+
}
254268
return 'no_change';
255269
}
256270

@@ -260,13 +274,17 @@ async function ensureAgentsGuidance(
260274
AGENTS_GUIDANCE_LINE,
261275
);
262276
fs.writeFileSync(agentsPath, updatedFromLegacy, 'utf8');
263-
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
277+
if (emitOutput) {
278+
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
279+
}
264280
return 'updated';
265281
}
266282

267283
const diff = renderAgentsAppendDiff(AGENTS_FILE_NAME);
268-
writeLine(`Proposed update for ${agentsPath}:`);
269-
writeLine(diff);
284+
if (emitOutput) {
285+
writeLine(`Proposed update for ${agentsPath}:`);
286+
writeLine(diff);
287+
}
270288

271289
if (!force) {
272290
if (!process.stdin.isTTY) {
@@ -277,7 +295,9 @@ async function ensureAgentsGuidance(
277295

278296
const confirmed = await promptConfirm(`Update ${AGENTS_FILE_NAME} with the guidance above?`);
279297
if (!confirmed) {
280-
writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`);
298+
if (emitOutput) {
299+
writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`);
300+
}
281301
return 'skipped';
282302
}
283303
}
@@ -287,7 +307,9 @@ async function ensureAgentsGuidance(
287307
: `${currentContent}\n${AGENTS_GUIDANCE_LINE}\n`;
288308

289309
fs.writeFileSync(agentsPath, updatedContent, 'utf8');
290-
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
310+
if (emitOutput) {
311+
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
312+
}
291313
return 'updated';
292314
}
293315

@@ -487,29 +509,48 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }):
487509
}
488510

489511
const targets = resolveTargets(clientFlag ?? 'auto', destFlag, 'uninstall');
490-
let anyRemoved = false;
512+
const removedEntries: Array<{ client: string; variant: string; path: string }> = [];
491513

492514
for (const target of targets) {
493515
const result = uninstallSkill(target.skillsDir, target.name);
494-
if (result) {
495-
if (!anyRemoved) {
496-
clack.log.step('Uninstalled skill directories');
497-
}
498-
const removedLines = result.removed
499-
.map((r) => ` Removed (${r.variant}): ${r.path}`)
500-
.join('\n');
501-
clack.log.message(` Client: ${result.client}\n${removedLines}`);
502-
anyRemoved = true;
516+
if (!result) {
517+
continue;
503518
}
504-
}
505519

506-
if (!anyRemoved) {
507-
clack.log.info('No installed skill directories found to remove.');
520+
for (const removed of result.removed) {
521+
removedEntries.push({
522+
client: result.client,
523+
variant: removed.variant,
524+
path: removed.path,
525+
});
526+
}
508527
}
509528

529+
const report: InitReport = {
530+
action: 'uninstall',
531+
removed: removedEntries,
532+
message:
533+
removedEntries.length > 0
534+
? 'Uninstalled skill directories'
535+
: 'No installed skill directories found to remove.',
536+
};
537+
510538
if (isTTY) {
511-
clack.outro(anyRemoved ? 'Done.' : undefined);
539+
if (removedEntries.length > 0) {
540+
clack.log.step(report.message);
541+
for (const removed of removedEntries) {
542+
clack.log.message(
543+
` Client: ${removed.client}\n Removed (${removed.variant}): ${removed.path}`,
544+
);
545+
}
546+
} else {
547+
clack.log.info(report.message);
548+
}
549+
clack.outro('Done.');
550+
} else {
551+
process.stdout.write(`${JSON.stringify(report)}\n`);
512552
}
553+
513554
return;
514555
}
515556

@@ -538,9 +579,6 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }):
538579
clientFlag,
539580
destFlag,
540581
);
541-
for (const skipped of policy.skippedClients) {
542-
writeLine(`Skipped ${skipped.client}: ${skipped.reason}`);
543-
}
544582

545583
if (policy.allowedTargets.length === 0) {
546584
const skippedSummary = formatSkippedClients(policy.skippedClients);
@@ -557,18 +595,30 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }):
557595
results.push(result);
558596
}
559597

560-
clack.log.success(`Installed ${skillDisplayName(selection.skillType)} skill`);
561-
for (const result of results) {
562-
clack.log.message(` Client: ${result.client}\n Location: ${result.location}`);
563-
}
598+
const report: InitReport = {
599+
action: 'install',
600+
skillType: selection.skillType,
601+
installed: results,
602+
skipped: policy.skippedClients,
603+
message: `Installed ${skillDisplayName(selection.skillType)} skill`,
604+
};
564605

565606
if (isTTY) {
607+
for (const skipped of report.skipped ?? []) {
608+
clack.log.info(`Skipped ${skipped.client}: ${skipped.reason}`);
609+
}
610+
clack.log.success(report.message);
611+
for (const result of results) {
612+
clack.log.message(` Client: ${result.client}\n Location: ${result.location}`);
613+
}
566614
clack.outro('Done.');
615+
} else {
616+
process.stdout.write(`${JSON.stringify(report)}\n`);
567617
}
568618

569619
if (ctx?.workspaceRoot) {
570620
const projectRoot = path.resolve(ctx.workspaceRoot);
571-
await ensureAgentsGuidance(projectRoot, argv.force as boolean);
621+
await ensureAgentsGuidance(projectRoot, argv.force as boolean, isTTY);
572622
}
573623
},
574624
);

0 commit comments

Comments
 (0)