Skip to content

Commit 9ee7b29

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 0f2f667 commit 9ee7b29

2 files changed

Lines changed: 129 additions & 56 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
});
@@ -293,10 +308,16 @@ describe('init command', () => {
293308
expect(existsSync(cliSkillDir)).toBe(false);
294309
expect(existsSync(mcpSkillDir)).toBe(false);
295310

296-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
297-
expect(output).toContain('Uninstalled skill directories');
298-
expect(output).toContain('Removed (xcodebuildmcp-cli):');
299-
expect(output).toContain('Removed (xcodebuildmcp):');
311+
const output = parseJsonOutput(stdoutSpy);
312+
expect(output.action).toBe('uninstall');
313+
expect(output.message).toBe('Uninstalled skill directories');
314+
expect(output.removed).toHaveLength(2);
315+
expect(output.removed).toEqual(
316+
expect.arrayContaining([
317+
{ client: 'Custom', variant: 'xcodebuildmcp-cli', path: cliSkillDir },
318+
{ client: 'Custom', variant: 'xcodebuildmcp', path: mcpSkillDir },
319+
]),
320+
);
300321

301322
stdoutSpy.mockRestore();
302323
});
@@ -314,8 +335,10 @@ describe('init command', () => {
314335
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
315336
await app.parseAsync();
316337

317-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
318-
expect(output).toContain('No installed skill directories found');
338+
const output = parseJsonOutput(stdoutSpy);
339+
expect(output.action).toBe('uninstall');
340+
expect(output.message).toBe('No installed skill directories found to remove.');
341+
expect(output.removed).toEqual([]);
319342

320343
stdoutSpy.mockRestore();
321344
});
@@ -334,8 +357,10 @@ describe('init command', () => {
334357
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
335358
await app.parseAsync();
336359

337-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
338-
expect(output).toContain('No installed skill directories found');
360+
const output = parseJsonOutput(stdoutSpy);
361+
expect(output.action).toBe('uninstall');
362+
expect(output.message).toBe('No installed skill directories found to remove.');
363+
expect(output.removed).toEqual([]);
339364

340365
stdoutSpy.mockRestore();
341366
});
@@ -415,8 +440,9 @@ describe('init command', () => {
415440
expect(existsSync(agentsPath)).toBe(true);
416441
expect(readFileSync(agentsPath, 'utf8')).toContain(agentsGuidanceLine);
417442

418-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
419-
expect(output).toContain('Created AGENTS.md with XcodeBuildMCP guidance');
443+
const output = parseJsonOutput(stdoutSpy);
444+
expect(output.action).toBe('install');
445+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
420446

421447
stdoutSpy.mockRestore();
422448
});
@@ -442,11 +468,9 @@ describe('init command', () => {
442468
'AGENTS.md exists and requires confirmation to update',
443469
);
444470

445-
const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
446-
expect(output).toContain('Proposed update for');
447-
expect(output).toContain('--- AGENTS.md');
448-
expect(output).toContain('+++ AGENTS.md');
449-
expect(output).toContain(agentsGuidanceLine);
471+
const output = parseJsonOutput(stdoutSpy);
472+
expect(output.action).toBe('install');
473+
expect(output.message).toBe('Installed XcodeBuildMCP CLI skill');
450474

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

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

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

src/cli/commands/init.ts

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ interface InstallPolicyResult {
116116
skippedClients: Array<{ client: string; reason: string }>;
117117
}
118118

119+
interface InitReport {
120+
action: 'install' | 'uninstall';
121+
skillType?: SkillType;
122+
installed?: InstallResult[];
123+
removed?: Array<{ client: string; variant: string; path: string }>;
124+
skipped?: Array<{ client: string; reason: string }>;
125+
message: string;
126+
}
127+
119128
async function installSkill(
120129
skillsDir: string,
121130
clientName: string,
@@ -231,18 +240,23 @@ function renderAgentsAppendDiff(fileName: string): string {
231240
async function ensureAgentsGuidance(
232241
projectRoot: string,
233242
force: boolean,
243+
emitOutput: boolean,
234244
): Promise<'created' | 'updated' | 'no_change' | 'skipped'> {
235245
const agentsPath = path.join(projectRoot, AGENTS_FILE_NAME);
236246
if (!fs.existsSync(agentsPath)) {
237247
const newContent = `# ${AGENTS_FILE_NAME}\n\n${AGENTS_GUIDANCE_LINE}\n`;
238248
fs.writeFileSync(agentsPath, newContent, 'utf8');
239-
writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`);
249+
if (emitOutput) {
250+
writeLine(`Created ${AGENTS_FILE_NAME} with XcodeBuildMCP guidance at ${agentsPath}`);
251+
}
240252
return 'created';
241253
}
242254

243255
const currentContent = fs.readFileSync(agentsPath, 'utf8');
244256
if (currentContent.includes(AGENTS_GUIDANCE_LINE)) {
245-
writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`);
257+
if (emitOutput) {
258+
writeLine(`${AGENTS_FILE_NAME} already includes XcodeBuildMCP guidance.`);
259+
}
246260
return 'no_change';
247261
}
248262

@@ -252,13 +266,17 @@ async function ensureAgentsGuidance(
252266
AGENTS_GUIDANCE_LINE,
253267
);
254268
fs.writeFileSync(agentsPath, updatedFromLegacy, 'utf8');
255-
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
269+
if (emitOutput) {
270+
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
271+
}
256272
return 'updated';
257273
}
258274

259275
const diff = renderAgentsAppendDiff(AGENTS_FILE_NAME);
260-
writeLine(`Proposed update for ${agentsPath}:`);
261-
writeLine(diff);
276+
if (emitOutput) {
277+
writeLine(`Proposed update for ${agentsPath}:`);
278+
writeLine(diff);
279+
}
262280

263281
if (!force) {
264282
if (!process.stdin.isTTY) {
@@ -269,7 +287,9 @@ async function ensureAgentsGuidance(
269287

270288
const confirmed = await promptConfirm(`Update ${AGENTS_FILE_NAME} with the guidance above?`);
271289
if (!confirmed) {
272-
writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`);
290+
if (emitOutput) {
291+
writeLine(`Skipped updating ${AGENTS_FILE_NAME}.`);
292+
}
273293
return 'skipped';
274294
}
275295
}
@@ -279,7 +299,9 @@ async function ensureAgentsGuidance(
279299
: `${currentContent}\n${AGENTS_GUIDANCE_LINE}\n`;
280300

281301
fs.writeFileSync(agentsPath, updatedContent, 'utf8');
282-
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
302+
if (emitOutput) {
303+
writeLine(`Updated ${AGENTS_FILE_NAME} at ${agentsPath}`);
304+
}
283305
return 'updated';
284306
}
285307

@@ -479,29 +501,48 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }):
479501
}
480502

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

484506
for (const target of targets) {
485507
const result = uninstallSkill(target.skillsDir, target.name);
486-
if (result) {
487-
if (!anyRemoved) {
488-
clack.log.step('Uninstalled skill directories');
489-
}
490-
const removedLines = result.removed
491-
.map((r) => ` Removed (${r.variant}): ${r.path}`)
492-
.join('\n');
493-
clack.log.message(` Client: ${result.client}\n${removedLines}`);
494-
anyRemoved = true;
508+
if (!result) {
509+
continue;
495510
}
496-
}
497511

498-
if (!anyRemoved) {
499-
clack.log.info('No installed skill directories found to remove.');
512+
for (const removed of result.removed) {
513+
removedEntries.push({
514+
client: result.client,
515+
variant: removed.variant,
516+
path: removed.path,
517+
});
518+
}
500519
}
501520

521+
const report: InitReport = {
522+
action: 'uninstall',
523+
removed: removedEntries,
524+
message:
525+
removedEntries.length > 0
526+
? 'Uninstalled skill directories'
527+
: 'No installed skill directories found to remove.',
528+
};
529+
502530
if (isTTY) {
503-
clack.outro(anyRemoved ? 'Done.' : undefined);
531+
if (removedEntries.length > 0) {
532+
clack.log.step(report.message);
533+
for (const removed of removedEntries) {
534+
clack.log.message(
535+
` Client: ${removed.client}\n Removed (${removed.variant}): ${removed.path}`,
536+
);
537+
}
538+
} else {
539+
clack.log.info(report.message);
540+
}
541+
clack.outro('Done.');
542+
} else {
543+
process.stdout.write(`${JSON.stringify(report)}\n`);
504544
}
545+
505546
return;
506547
}
507548

@@ -543,22 +584,30 @@ export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }):
543584
results.push(result);
544585
}
545586

546-
for (const skipped of policy.skippedClients) {
547-
writeLine(`Skipped ${skipped.client}: ${skipped.reason}`);
548-
}
549-
550-
clack.log.success(`Installed ${skillDisplayName(selection.skillType)} skill`);
551-
for (const result of results) {
552-
clack.log.message(` Client: ${result.client}\n Location: ${result.location}`);
553-
}
587+
const report: InitReport = {
588+
action: 'install',
589+
skillType: selection.skillType,
590+
installed: results,
591+
skipped: policy.skippedClients,
592+
message: `Installed ${skillDisplayName(selection.skillType)} skill`,
593+
};
554594

555595
if (isTTY) {
596+
for (const skipped of report.skipped ?? []) {
597+
clack.log.info(`Skipped ${skipped.client}: ${skipped.reason}`);
598+
}
599+
clack.log.success(report.message);
600+
for (const result of results) {
601+
clack.log.message(` Client: ${result.client}\n Location: ${result.location}`);
602+
}
556603
clack.outro('Done.');
604+
} else {
605+
process.stdout.write(`${JSON.stringify(report)}\n`);
557606
}
558607

559608
if (ctx?.workspaceRoot) {
560609
const projectRoot = path.resolve(ctx.workspaceRoot);
561-
await ensureAgentsGuidance(projectRoot, argv.force as boolean);
610+
await ensureAgentsGuidance(projectRoot, argv.force as boolean, isTTY);
562611
}
563612
},
564613
);

0 commit comments

Comments
 (0)