From 0d231c7be1cf89313fe208ba5e66af6f57012c91 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 22 Sep 2026 16:31:46 +0200 Subject: [PATCH 1/2] fix(cli): standardize flow success output --- .../cli/src/__tests__/commands/docs.test.ts | 106 +++++++++-- .../cli/src/__tests__/commands/init.test.ts | 168 ++++++++++++++---- .../cli/src/__tests__/commands/phase.test.ts | 82 +++++++++ .../cli/src/__tests__/commands/setup.test.ts | 89 +++++++--- packages/cli/src/commands/docs.ts | 28 ++- packages/cli/src/commands/init.ts | 91 +++++++--- packages/cli/src/commands/phase.ts | 13 +- packages/cli/src/commands/setup.ts | 57 ++++-- 8 files changed, 523 insertions(+), 111 deletions(-) create mode 100644 packages/cli/src/__tests__/commands/phase.test.ts diff --git a/packages/cli/src/__tests__/commands/docs.test.ts b/packages/cli/src/__tests__/commands/docs.test.ts index ae10c9e6..54d43778 100644 --- a/packages/cli/src/__tests__/commands/docs.test.ts +++ b/packages/cli/src/__tests__/commands/docs.test.ts @@ -4,7 +4,8 @@ import { ui } from "../../util/terminal-ui.js"; const mockGetDocsDir = vi.fn<() => Promise>(); const mockGetPhases = vi.fn<() => Promise>(); -const mockCopyFeatureDocTemplates = vi.fn<(...args: unknown[]) => Promise>(); +const mockCopyFeatureDocTemplates = + vi.fn<(...args: unknown[]) => Promise>(); const mockTemplateManagerConstructor = vi.fn(); vi.mock("../../lib/Config.js", () => ({ @@ -20,7 +21,8 @@ vi.mock("../../lib/TemplateManager.js", () => ({ TemplateManager: vi.fn(function (...args: unknown[]) { mockTemplateManagerConstructor(...args); return { - copyFeatureDocTemplates: (...copyArgs: unknown[]) => mockCopyFeatureDocTemplates(...copyArgs), + copyFeatureDocTemplates: (...copyArgs: unknown[]) => + mockCopyFeatureDocTemplates(...copyArgs), }; }), })); @@ -33,6 +35,12 @@ vi.mock("../../util/terminal-ui.js", () => ({ }, })); +vi.mock("chalk", () => ({ + default: { + dim: (text: string) => `[dim]${text}[/dim]`, + }, +})); + describe("docs command", () => { const mockedUi = vi.mocked(ui); @@ -61,14 +69,61 @@ describe("docs command", () => { const program = new Command(); registerDocsCommand(program); - await program.parseAsync(["node", "test", "docs", "init-feature", "sample"]); + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "sample", + ]); expect(mockCopyFeatureDocTemplates).toHaveBeenCalledWith("sample", { date: "2026-05-25", phases: ["requirements", "design"], }); - expect(mockedUi.success).toHaveBeenCalledWith("Created 1 feature doc(s) for sample."); - expect(mockedUi.text).toHaveBeenCalledWith("docs/ai/requirements/2026-05-25-feature-sample.md"); + expect(mockedUi.success).toHaveBeenCalledWith( + "Created 1 feature doc for sample.", + ); + expect(mockedUi.text).toHaveBeenCalledWith( + "[dim] - docs/ai/requirements/2026-05-25-feature-sample.md[/dim]", + ); + }); + + it("pluralizes the success headline for multiple generated feature docs", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 4, 25, 10, 30)); + mockCopyFeatureDocTemplates.mockResolvedValue([ + { + phase: "requirements", + path: "/repo/docs/ai/requirements/2026-05-25-feature-sample.md", + relativePath: "docs/ai/requirements/2026-05-25-feature-sample.md", + }, + { + phase: "design", + path: "/repo/docs/ai/design/2026-05-25-feature-sample.md", + relativePath: "docs/ai/design/2026-05-25-feature-sample.md", + }, + ]); + const program = new Command(); + registerDocsCommand(program); + + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "sample", + ]); + + expect(mockedUi.success).toHaveBeenCalledWith( + "Created 2 feature docs for sample.", + ); + expect(mockedUi.text).toHaveBeenCalledWith( + "[dim] - docs/ai/requirements/2026-05-25-feature-sample.md[/dim]", + ); + expect(mockedUi.text).toHaveBeenCalledWith( + "[dim] - docs/ai/design/2026-05-25-feature-sample.md[/dim]", + ); }); it("uses the current local date", async () => { @@ -77,7 +132,13 @@ describe("docs command", () => { const program = new Command(); registerDocsCommand(program); - await program.parseAsync(["node", "test", "docs", "init-feature", "sample"]); + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "sample", + ]); expect(mockCopyFeatureDocTemplates).toHaveBeenCalledWith("sample", { date: "2026-05-25", @@ -91,7 +152,14 @@ describe("docs command", () => { const program = new Command(); registerDocsCommand(program); - await program.parseAsync(["node", "test", "docs", "init-feature", "feature-sample", "--json"]); + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "feature-sample", + "--json", + ]); expect(mockedUi.text).toHaveBeenCalledWith( JSON.stringify( @@ -117,21 +185,37 @@ describe("docs command", () => { const program = new Command(); registerDocsCommand(program); - await program.parseAsync(["node", "test", "docs", "init-feature", "bad name"]); + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "bad name", + ]); expect(process.exitCode).toBe(1); expect(mockCopyFeatureDocTemplates).not.toHaveBeenCalled(); - expect(mockedUi.error).toHaveBeenCalledWith("Invalid feature name: bad name"); + expect(mockedUi.error).toHaveBeenCalledWith( + "Invalid feature name: bad name", + ); }); it("surfaces copy errors and sets a non-zero exit code", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 4, 25, 10, 30)); - mockCopyFeatureDocTemplates.mockRejectedValue(new Error("Feature docs already exist")); + mockCopyFeatureDocTemplates.mockRejectedValue( + new Error("Feature docs already exist"), + ); const program = new Command(); registerDocsCommand(program); - await program.parseAsync(["node", "test", "docs", "init-feature", "sample"]); + await program.parseAsync([ + "node", + "test", + "docs", + "init-feature", + "sample", + ]); expect(process.exitCode).toBe(1); expect(mockedUi.error).toHaveBeenCalledWith("Feature docs already exist"); diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index d0290bc2..908d1b92 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -43,6 +43,7 @@ const { success: vi.fn(), info: vi.fn(), text: vi.fn(), + breakline: vi.fn(), summary: vi.fn(), } as any, mockConfirm: vi.fn() as any, @@ -69,6 +70,13 @@ vi.mock("@inquirer/prompts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), })); +vi.mock("chalk", () => ({ + default: { + bold: (text: string) => `[bold]${text}[/bold]`, + dim: (text: string) => `[dim]${text}[/dim]`, + }, +})); + vi.mock("../../lib/Config.js", () => ({ ConfigManager: vi.fn(function () { return mockConfigManager; @@ -101,7 +109,8 @@ vi.mock("../../services/skill/skill.service.js", () => ({ vi.mock("../../services/skill/skill-builtins.js", () => ({ BUILTIN_SKILL_REGISTRY: "codeaholicguy/ai-devkit", - getBuiltinSkillNames: (...args: unknown[]) => mockGetBuiltinSkillNames(...args), + getBuiltinSkillNames: (...args: unknown[]) => + mockGetBuiltinSkillNames(...args), })); vi.mock("../../lib/InitTemplate.js", () => ({ @@ -113,14 +122,17 @@ vi.mock("../../util/terminal-ui.js", () => ({ })); vi.mock("../../util/terminal.js", () => ({ - isInteractiveTerminal: (...args: unknown[]) => mockIsInteractiveTerminal(...args), + isInteractiveTerminal: (...args: unknown[]) => + mockIsInteractiveTerminal(...args), })); import { initCommand } from "../../commands/init.js"; import { BUILTIN_SKILL_REGISTRY } from "../../services/skill/skill-builtins.js"; function confirmCallsMatching(pattern: RegExp): any[] { - return mockConfirm.mock.calls.filter(([config]: any[]) => pattern.test(config?.message ?? "")); + return mockConfirm.mock.calls.filter(([config]: any[]) => + pattern.test(config?.message ?? ""), + ); } function appliedConfig(): any { @@ -137,15 +149,22 @@ describe("init command", () => { mockConfigManager.exists.mockResolvedValue(false); mockConfigManager.read.mockResolvedValue(null); - mockConfigManager.create.mockResolvedValue({ environments: [], phases: [] }); + mockConfigManager.create.mockResolvedValue({ + environments: [], + phases: [], + }); mockConfigManager.setEnvironments.mockResolvedValue(undefined); mockConfigManager.addPhase.mockResolvedValue(undefined); mockConfigManager.update.mockResolvedValue({}); mockTemplateManager.checkEnvironmentExists.mockResolvedValue(false); - mockTemplateManager.setupMultipleEnvironments.mockResolvedValue(["AGENTS.md"]); + mockTemplateManager.setupMultipleEnvironments.mockResolvedValue([ + "AGENTS.md", + ]); mockTemplateManager.fileExists.mockResolvedValue(false); - mockTemplateManager.copyPhaseTemplate.mockResolvedValue("docs/ai/requirements/README.md"); + mockTemplateManager.copyPhaseTemplate.mockResolvedValue( + "docs/ai/requirements/README.md", + ); mockEnvironmentSelector.selectEnvironments.mockResolvedValue(["codex"]); mockEnvironmentSelector.confirmOverride.mockResolvedValue(true); @@ -178,7 +197,11 @@ describe("init command", () => { environments: ["codex"], phases: ["requirements"], mcpServers: { - memory: { transport: "stdio", command: "npx", args: ["-y", "@ai-devkit/memory"] }, + memory: { + transport: "stdio", + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }, }, }); @@ -190,7 +213,10 @@ describe("init command", () => { phases: ["requirements"], mcpServers: expect.objectContaining({ memory: expect.any(Object) }), }), - expect.objectContaining({ overwrite: undefined, nonInteractive: false }), + expect.objectContaining({ + overwrite: undefined, + nonInteractive: false, + }), ); expect(mockUi.info).not.toHaveBeenCalledWith( expect.stringContaining("Run `ai-devkit install`"), @@ -272,7 +298,9 @@ describe("init command", () => { await initCommand({ template: "./init.yaml" }); - expect(mockEnvironmentSelector.selectEnvironments).toHaveBeenCalledTimes(1); + expect(mockEnvironmentSelector.selectEnvironments).toHaveBeenCalledTimes( + 1, + ); expect(mockPhaseSelector.selectPhases).toHaveBeenCalledTimes(1); expect(appliedConfig().skills).toContainEqual({ registry: "codeaholicguy/ai-devkit", @@ -298,7 +326,9 @@ describe("init command", () => { await initCommand({ template: "/tmp/init.yaml" }); - expect(mockUi.error).toHaveBeenCalledWith("Invalid template at /tmp/init.yaml: bad field"); + expect(mockUi.error).toHaveBeenCalledWith( + "Invalid template at /tmp/init.yaml: bad field", + ); expect(process.exitCode).toBe(1); expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); }); @@ -312,7 +342,9 @@ describe("init command", () => { await initCommand({ template: "./init.yaml", builtIn: true }); - expect(appliedConfig().skills).toHaveLength(BUILTIN_SKILL_FIXTURE.length + 1); + expect(appliedConfig().skills).toHaveLength( + BUILTIN_SKILL_FIXTURE.length + 1, + ); expect(appliedConfig().skills).toContainEqual({ registry: BUILTIN_SKILL_REGISTRY, name: "debug", @@ -323,7 +355,9 @@ describe("init command", () => { name: skill, }); } - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); }); @@ -342,12 +376,42 @@ describe("init command", () => { name: skill, }); } - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); }); }); describe("built-in skills prompt (interactive init without template)", () => { + it("prints a success headline and dim next-step list without embedded trailing newlines", async () => { + await initCommand({}); + + expect(mockUi.success).toHaveBeenCalledWith( + "AI DevKit project initialized successfully!", + ); + expect(mockUi.text).toHaveBeenCalledWith("[bold]Next steps:[/bold]"); + expect(mockUi.text).toHaveBeenCalledWith( + "[dim] - Review and customize templates in docs/ai/[/dim]", + ); + expect(mockUi.text).toHaveBeenCalledWith( + "[dim] - Your selected AI environments are ready in this project[/dim]", + ); + expect(mockUi.text).toHaveBeenCalledWith( + "[dim] - Run `ai-devkit phase ` to add more phases later[/dim]", + ); + expect(mockUi.text).toHaveBeenCalledWith( + "[dim] - Run `ai-devkit init` again to add more environments[/dim]", + ); + expect(mockUi.breakline).toHaveBeenCalled(); + for (const call of [ + ...mockUi.text.mock.calls, + ...mockUi.success.mock.calls, + ]) { + expect(call[0]).not.toMatch(/\n$/); + } + }); + it("installs built-in AI DevKit skills when user confirms the prompt", async () => { mockConfirm.mockResolvedValueOnce(true); @@ -367,7 +431,9 @@ describe("init command", () => { await initCommand({}); - const builtinPromptCalls = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPromptCalls = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPromptCalls.length).toBe(1); expect(mockSkillService.addSkill).not.toHaveBeenCalled(); expect(mockGetBuiltinSkillNames).not.toHaveBeenCalled(); @@ -381,7 +447,9 @@ describe("init command", () => { await initCommand({ template: "./init.yaml" }); - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); }); @@ -400,7 +468,9 @@ describe("init command", () => { await expect(initCommand({})).resolves.toBeUndefined(); expect(process.exitCode).toBe(1); - expect(mockUi.warning).toHaveBeenCalledWith(expect.stringContaining("setup is incomplete")); + expect(mockUi.warning).toHaveBeenCalledWith( + expect.stringContaining("setup is incomplete"), + ); }); }); @@ -410,11 +480,15 @@ describe("init command", () => { await initCommand({}); - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); expect(mockSkillService.addSkill).not.toHaveBeenCalled(); expect(mockGetBuiltinSkillNames).not.toHaveBeenCalled(); - expect(mockUi.info).toHaveBeenCalledWith(expect.stringMatching(/non-interactive|--built-in/)); + expect(mockUi.info).toHaveBeenCalledWith( + expect.stringMatching(/non-interactive|--built-in/), + ); }); it("installs built-in skills without prompting when --built-in is passed in a non-interactive environment", async () => { @@ -422,7 +496,9 @@ describe("init command", () => { await initCommand({ builtIn: true }); - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); expect(appliedConfig().skills.length).toBeGreaterThan(0); }); @@ -432,7 +508,9 @@ describe("init command", () => { await initCommand({ builtIn: true }); - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); expect(appliedConfig().skills.length).toBeGreaterThan(0); }); @@ -444,7 +522,10 @@ describe("init command", () => { expect(mockConfirm).not.toHaveBeenCalled(); expect(mockEnvironmentSelector.selectEnvironments).not.toHaveBeenCalled(); - expect(mockPhaseSelector.selectPhases).toHaveBeenCalledWith(true, undefined); + expect(mockPhaseSelector.selectPhases).toHaveBeenCalledWith( + true, + undefined, + ); expect(mockReconcileAndInstall).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ nonInteractive: true }), @@ -466,7 +547,9 @@ describe("init command", () => { expect(process.exitCode).toBe(1); expect(mockUi.error).toHaveBeenCalledWith( - expect.stringMatching(/Non-interactive mode requires --all or --phases/), + expect.stringMatching( + /Non-interactive mode requires --all or --phases/, + ), ); expect(mockPhaseSelector.selectPhases).not.toHaveBeenCalled(); expect(mockConfigManager.create).not.toHaveBeenCalled(); @@ -477,7 +560,9 @@ describe("init command", () => { await initCommand({ yes: true, all: true, environment: "claude" }); - const reconfigurePrompts = confirmCallsMatching(/already initialized.*reconfigure/); + const reconfigurePrompts = confirmCallsMatching( + /already initialized.*reconfigure/, + ); expect(reconfigurePrompts).toHaveLength(0); expect(process.exitCode).not.toBe(1); }); @@ -488,7 +573,9 @@ describe("init command", () => { await initCommand({ yes: true, all: true, environment: "claude" }); expect(mockEnvironmentSelector.confirmOverride).not.toHaveBeenCalled(); - expect(mockTemplateManager.setupMultipleEnvironments).not.toHaveBeenCalled(); + expect( + mockTemplateManager.setupMultipleEnvironments, + ).not.toHaveBeenCalled(); expect(mockUi.warning).toHaveBeenCalledWith( expect.stringMatching(/Skipping overwrite of existing environments/), ); @@ -497,7 +584,12 @@ describe("init command", () => { it("overwrites existing environments under --yes when --overwrite is passed", async () => { mockTemplateManager.checkEnvironmentExists.mockResolvedValue(true); - await initCommand({ yes: true, overwrite: true, all: true, environment: "claude" }); + await initCommand({ + yes: true, + overwrite: true, + all: true, + environment: "claude", + }); expect(mockEnvironmentSelector.confirmOverride).not.toHaveBeenCalled(); expect(mockReconcileAndInstall).toHaveBeenCalled(); @@ -511,7 +603,9 @@ describe("init command", () => { await initCommand({ yes: true, all: true, environment: "claude" }); - const overwritePrompts = confirmCallsMatching(/already exists\. Overwrite\?/); + const overwritePrompts = confirmCallsMatching( + /already exists\. Overwrite\?/, + ); expect(overwritePrompts).toHaveLength(0); expect(mockTemplateManager.copyPhaseTemplate).not.toHaveBeenCalled(); expect(mockReconcileAndInstall).toHaveBeenCalledWith( @@ -526,9 +620,16 @@ describe("init command", () => { it("overwrites existing phase files under --yes --overwrite (no prompt)", async () => { mockTemplateManager.fileExists.mockResolvedValue(true); - await initCommand({ yes: true, overwrite: true, all: true, environment: "claude" }); + await initCommand({ + yes: true, + overwrite: true, + all: true, + environment: "claude", + }); - const overwritePrompts = confirmCallsMatching(/already exists\. Overwrite\?/); + const overwritePrompts = confirmCallsMatching( + /already exists\. Overwrite\?/, + ); expect(overwritePrompts).toHaveLength(0); expect(mockReconcileAndInstall).toHaveBeenCalledWith( expect.any(Object), @@ -544,7 +645,9 @@ describe("init command", () => { await initCommand({ yes: true, all: true, environment: "claude" }); - const builtinPrompts = confirmCallsMatching(/Install AI DevKit built-in skills/); + const builtinPrompts = confirmCallsMatching( + /Install AI DevKit built-in skills/, + ); expect(builtinPrompts).toHaveLength(0); expect(mockSkillService.addSkill).not.toHaveBeenCalled(); }); @@ -552,7 +655,12 @@ describe("init command", () => { it("installs built-in skills under --yes when --built-in is also passed", async () => { mockIsInteractiveTerminal.mockReturnValue(true); - await initCommand({ yes: true, builtIn: true, all: true, environment: "claude" }); + await initCommand({ + yes: true, + builtIn: true, + all: true, + environment: "claude", + }); expect(appliedConfig().skills.length).toBeGreaterThan(0); }); diff --git a/packages/cli/src/__tests__/commands/phase.test.ts b/packages/cli/src/__tests__/commands/phase.test.ts new file mode 100644 index 00000000..71c0fc6e --- /dev/null +++ b/packages/cli/src/__tests__/commands/phase.test.ts @@ -0,0 +1,82 @@ +const { mockConfigManager, mockTemplateManager, mockConfirm, mockUi } = + vi.hoisted(() => ({ + mockConfigManager: { + exists: vi.fn(), + getDocsDir: vi.fn(), + read: vi.fn(), + addPhase: vi.fn(), + }, + mockTemplateManager: { + fileExists: vi.fn(), + copyPhaseTemplate: vi.fn(), + }, + mockConfirm: vi.fn(), + mockUi: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + info: vi.fn(), + text: vi.fn(), + breakline: vi.fn(), + }, + })); + +vi.mock("chalk", () => ({ + default: { + dim: (text: string) => `[dim]${text}[/dim]`, + }, +})); + +vi.mock("@inquirer/prompts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), + select: vi.fn(), +})); + +vi.mock("../../lib/Config.js", () => ({ + ConfigManager: vi.fn(function () { + return mockConfigManager; + }), +})); + +vi.mock("../../lib/TemplateManager.js", () => ({ + TemplateManager: vi.fn(function () { + return mockTemplateManager; + }), +})); + +vi.mock("../../util/terminal-ui.js", () => ({ + ui: mockUi, +})); + +import { phaseCommand } from "../../commands/phase.js"; + +describe("phase command", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigManager.exists.mockResolvedValue(true); + mockConfigManager.getDocsDir.mockResolvedValue("docs/ai"); + mockConfigManager.addPhase.mockResolvedValue(undefined); + mockTemplateManager.fileExists.mockResolvedValue(false); + mockTemplateManager.copyPhaseTemplate.mockResolvedValue( + "docs/ai/requirements.md", + ); + }); + + it("prints a success headline and dim file location without embedded trailing newlines", async () => { + await phaseCommand("requirements"); + + expect(mockUi.success).toHaveBeenCalledWith( + "Requirements & Problem Understanding created successfully.", + ); + expect(mockUi.text).toHaveBeenCalledWith( + "[dim] - docs/ai/requirements.md[/dim]", + ); + expect(mockUi.info).not.toHaveBeenCalled(); + for (const call of [ + ...mockUi.text.mock.calls, + ...mockUi.success.mock.calls, + ]) { + expect(call[0]).not.toMatch(/\n$/); + } + }); +}); diff --git a/packages/cli/src/__tests__/commands/setup.test.ts b/packages/cli/src/__tests__/commands/setup.test.ts index 9ec4a709..1c4ba619 100644 --- a/packages/cli/src/__tests__/commands/setup.test.ts +++ b/packages/cli/src/__tests__/commands/setup.test.ts @@ -1,28 +1,40 @@ import { Command } from "commander"; -const { mockSetupService, mockInspectTmux, mockResolveTmuxInstallInstructions, mockUi } = - vi.hoisted(() => ({ - mockSetupService: { - run: vi.fn(), - }, - mockInspectTmux: vi.fn(), - mockResolveTmuxInstallInstructions: vi.fn(), - mockUi: { - error: vi.fn(), - success: vi.fn(), - warning: vi.fn(), - text: vi.fn(), - summary: vi.fn(), - table: vi.fn(), - }, - })); +const { + mockSetupService, + mockInspectTmux, + mockResolveTmuxInstallInstructions, + mockUi, +} = vi.hoisted(() => ({ + mockSetupService: { + run: vi.fn(), + }, + mockInspectTmux: vi.fn(), + mockResolveTmuxInstallInstructions: vi.fn(), + mockUi: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + text: vi.fn(), + summary: vi.fn(), + table: vi.fn(), + }, +})); + +vi.mock("chalk", () => ({ + default: { + bold: (text: string) => `[bold]${text}[/bold]`, + }, +})); vi.mock("../../util/tmux.js", () => ({ inspectTmux: mockInspectTmux, resolveTmuxInstallInstructions: mockResolveTmuxInstallInstructions, })); -vi.mock("../../util/tmux-deps.js", () => ({ createTmuxInspectionDeps: () => ({}) })); +vi.mock("../../util/tmux-deps.js", () => ({ + createTmuxInspectionDeps: () => ({}), +})); vi.mock("../../services/setup/setup.service.js", () => ({ createSetupService: () => mockSetupService, @@ -77,20 +89,30 @@ describe("setup command", () => { await program.parseAsync(["node", "test", "setup"]); expect(mockSetupService.run).toHaveBeenCalledWith({ agents: undefined }); - expect(mockUi.text).toHaveBeenCalledWith("Host Prerequisites"); + expect(mockUi.success).toHaveBeenCalledWith( + "Setup completed successfully.", + ); + expect(mockUi.text).toHaveBeenCalledWith( + "[bold]Host Prerequisites:[/bold]", + ); expect(mockUi.success).toHaveBeenCalledWith("tmux 3.4 available"); expect(mockUi.summary).toHaveBeenCalledWith({ title: "Setup Summary", items: [ - { type: "success", count: 1, label: "step(s) installed" }, - { type: "warning", count: 1, label: "step(s) skipped" }, - { type: "error", count: 0, label: "step(s) failed" }, + { type: "success", count: 1, label: "step installed" }, + { type: "warning", count: 1, label: "step skipped" }, + { type: "error", count: 0, label: "steps failed" }, ], }); expect(mockUi.table).toHaveBeenCalledWith({ headers: ["agent", "step", "status", "message"], rows: [ - ["codex", "codex-session-hook", "installed", "Installed Codex SessionStart hook."], + [ + "codex", + "codex-session-hook", + "installed", + "Installed Codex SessionStart hook.", + ], ["pi", "pi-session-tracker", "skipped", "~/.pi does not exist."], ], }); @@ -98,17 +120,25 @@ describe("setup command", () => { }); it("warns with platform-aware instructions and continues setup when tmux is missing", async () => { - mockInspectTmux.mockResolvedValue({ state: "missing", version: null, rawVersion: null }); + mockInspectTmux.mockResolvedValue({ + state: "missing", + version: null, + rawVersion: null, + }); mockResolveTmuxInstallInstructions.mockResolvedValue({ command: "sudo apt-get update && sudo apt-get install tmux", - message: "Install it with: sudo apt-get update && sudo apt-get install tmux.", + message: + "Install it with: sudo apt-get update && sudo apt-get install tmux.", }); const program = new Command(); registerSetupCommand(program); await program.parseAsync(["node", "test", "setup"]); - expect(mockUi.text).toHaveBeenCalledWith("Next steps"); + expect(mockUi.success).toHaveBeenCalledWith( + "Setup completed successfully.", + ); + expect(mockUi.text).toHaveBeenCalledWith("[bold]Next steps:[/bold]"); expect(mockUi.warning).toHaveBeenCalledWith( "Next step: install tmux (sudo apt-get update && sudo apt-get install tmux), then run ai-devkit setup again to start managed agents.", ); @@ -136,7 +166,10 @@ describe("setup command", () => { await program.parseAsync(["node", "test", "setup"]); - expect(mockUi.text).toHaveBeenCalledWith("Next steps"); + expect(mockUi.success).toHaveBeenCalledWith( + "Setup completed successfully.", + ); + expect(mockUi.text).toHaveBeenCalledWith("[bold]Next steps:[/bold]"); expect(mockUi.warning).toHaveBeenCalledWith( "tmux check could not run (permission denied) — verify tmux works before starting agents.", ); @@ -156,7 +189,9 @@ describe("setup command", () => { await program.parseAsync(["node", "test", "setup", "--agent", "codex,pi"]); - expect(mockSetupService.run).toHaveBeenCalledWith({ agents: ["codex", "pi"] }); + expect(mockSetupService.run).toHaveBeenCalledWith({ + agents: ["codex", "pi"], + }); }); it("fails for unsupported agents before running setup", async () => { diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts index 3a2567ac..f381837d 100644 --- a/packages/cli/src/commands/docs.ts +++ b/packages/cli/src/commands/docs.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import chalk from "chalk"; import { ConfigManager } from "../lib/Config.js"; import { FeatureDoc, TemplateManager } from "../lib/TemplateManager.js"; import { formatLocalDate } from "../util/time.js"; @@ -13,16 +14,23 @@ interface InitFeatureOptions { } export function registerDocsCommand(program: Command): void { - const docs = program.command("docs").description("Manage AI DevKit documentation"); + const docs = program + .command("docs") + .description("Manage AI DevKit documentation"); docs .command("init-feature ") - .description("Initialize date-prefixed feature documentation from phase templates") + .description( + "Initialize date-prefixed feature documentation from phase templates", + ) .option("--json", "Output generated paths as JSON") .action(initFeatureDocsCommand); } -async function initFeatureDocsCommand(name: string, options: InitFeatureOptions): Promise { +async function initFeatureDocsCommand( + name: string, + options: InitFeatureOptions, +): Promise { const validation = validateFeatureNameRule(name); if (validation.check) { ui.error(`Invalid feature name: ${name}`); @@ -38,7 +46,10 @@ async function initFeatureDocsCommand(name: string, options: InitFeatureOptions) const templateManager = new TemplateManager({ docsDir }); try { - const files = await templateManager.copyFeatureDocTemplates(featureName, { date, phases }); + const files = await templateManager.copyFeatureDocTemplates(featureName, { + date, + phases, + }); renderInitFeatureResult( { feature: featureName, @@ -82,6 +93,11 @@ function renderInitFeatureResult( return; } - ui.success(`Created ${result.files.length} feature doc(s) for ${result.feature}.`); - result.files.forEach((file) => ui.text(file.relativePath)); + const docLabel = result.files.length === 1 ? "feature doc" : "feature docs"; + ui.success( + `Created ${result.files.length} ${docLabel} for ${result.feature}.`, + ); + result.files.forEach((file) => + ui.text(chalk.dim(` - ${file.relativePath}`)), + ); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9fdd30fc..2ae372fc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,12 +1,24 @@ import { execFileSync } from "child_process"; -import { BUILTIN_SKILL_REGISTRY, getBuiltinSkillNames } from "../services/skill/skill-builtins.js"; +import chalk from "chalk"; +import { + BUILTIN_SKILL_REGISTRY, + getBuiltinSkillNames, +} from "../services/skill/skill-builtins.js"; import { ConfigManager } from "../lib/Config.js"; import { TemplateManager } from "../lib/TemplateManager.js"; import { EnvironmentSelector } from "../lib/EnvironmentSelector.js"; import { PhaseSelector } from "../lib/PhaseSelector.js"; import { loadInitTemplate, InitTemplateSkill } from "../lib/InitTemplate.js"; -import { ConfigSkill, EnvironmentCode, Phase, DEFAULT_DOCS_DIR } from "../types.js"; -import { getInstallExitCode, reconcileAndInstall } from "../services/install/install.service.js"; +import { + ConfigSkill, + EnvironmentCode, + Phase, + DEFAULT_DOCS_DIR, +} from "../types.js"; +import { + getInstallExitCode, + reconcileAndInstall, +} from "../services/install/install.service.js"; import { renderApplicationReport } from "../services/install/install-report.js"; import { isValidEnvironmentCode } from "../util/env.js"; import { isInteractiveTerminal } from "../util/terminal.js"; @@ -31,7 +43,9 @@ function ensureGitRepository(): void { } try { - execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "ignore" }); + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + stdio: "ignore", + }); } catch { try { execFileSync("git", ["init"], { stdio: "ignore" }); @@ -72,7 +86,9 @@ function normalizeEnvironmentOption( .filter((value): value is EnvironmentCode => value.length > 0); } -async function shouldInstallBuiltinSkills(options: InitOptions): Promise { +async function shouldInstallBuiltinSkills( + options: InitOptions, +): Promise { if (options.builtIn) { return true; } @@ -137,7 +153,8 @@ export async function initCommand(options: InitOptions) { ui.warning("AI DevKit is already initialized. Reconfiguring (--yes)."); } else { const shouldContinue = await confirm({ - message: "AI DevKit is already initialized. Do you want to reconfigure?", + message: + "AI DevKit is already initialized. Do you want to reconfigure?", default: false, }); @@ -147,11 +164,18 @@ export async function initCommand(options: InitOptions) { } } } else if ((await configManager.exists()) && hasTemplate) { - ui.warning("AI DevKit is already initialized. Reconfiguring from template."); + ui.warning( + "AI DevKit is already initialized. Reconfiguring from template.", + ); } - let selectedEnvironments: EnvironmentCode[] = normalizeEnvironmentOption(options.environment); - if (selectedEnvironments.length === 0 && templateConfig?.environments?.length) { + let selectedEnvironments: EnvironmentCode[] = normalizeEnvironmentOption( + options.environment, + ); + if ( + selectedEnvironments.length === 0 && + templateConfig?.environments?.length + ) { selectedEnvironments = templateConfig.environments; } if (selectedEnvironments.length === 0) { @@ -186,18 +210,25 @@ export async function initCommand(options: InitOptions) { let shouldProceedWithSetup = true; if (existingEnvironments.length > 0) { - ui.warning(`The following environments are already set up: ${existingEnvironments.join(", ")}`); + ui.warning( + `The following environments are already set up: ${existingEnvironments.join(", ")}`, + ); if (hasTemplate) { - ui.warning("Template mode enabled: proceeding with overwrite of selected environments."); + ui.warning( + "Template mode enabled: proceeding with overwrite of selected environments.", + ); } else if (nonInteractive) { if (options.overwrite) { ui.warning("Overwriting existing environments (--yes --overwrite)."); } else { - ui.warning("Skipping overwrite of existing environments (--yes without --overwrite)."); + ui.warning( + "Skipping overwrite of existing environments (--yes without --overwrite).", + ); shouldProceedWithSetup = false; } } else { - shouldProceedWithSetup = await environmentSelector.confirmOverride(existingEnvironments); + shouldProceedWithSetup = + await environmentSelector.confirmOverride(existingEnvironments); } } @@ -208,7 +239,10 @@ export async function initCommand(options: InitOptions) { let selectedPhases: Phase[] = []; if (options.all || options.phases) { - selectedPhases = await phaseSelector.selectPhases(options.all, options.phases); + selectedPhases = await phaseSelector.selectPhases( + options.all, + options.phases, + ); } else if (templateConfig?.phases?.length) { selectedPhases = templateConfig.phases; } else if (nonInteractive) { @@ -245,7 +279,9 @@ export async function initCommand(options: InitOptions) { if (options.builtIn || !hasTemplate) { const shouldInstall = await shouldInstallBuiltinSkills(options); if (shouldInstall) { - const builtInSkills: InitTemplateSkill[] = (await getBuiltinSkillNames()).map((skill) => ({ + const builtInSkills: InitTemplateSkill[] = ( + await getBuiltinSkillNames() + ).map((skill) => ({ registry: BUILTIN_SKILL_REGISTRY, skill, })); @@ -259,7 +295,9 @@ export async function initCommand(options: InitOptions) { await configManager.update({ environments: selectedEnvironments, phases: selectedPhases, - ...(docsDir !== DEFAULT_DOCS_DIR ? { paths: { ...config.paths, docs: docsDir } } : {}), + ...(docsDir !== DEFAULT_DOCS_DIR + ? { paths: { ...config.paths, docs: docsDir } } + : {}), ...(Object.keys(registries).length > 0 ? { registries } : {}), ...(desiredSkills.length > 0 ? { skills: desiredSkills } : {}), ...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}), @@ -284,7 +322,9 @@ export async function initCommand(options: InitOptions) { ); renderApplicationReport(report, "Initialization Summary"); - process.exitCode = getInstallExitCode(report, { overwrite: options.overwrite }); + process.exitCode = getInstallExitCode(report, { + overwrite: options.overwrite, + }); if (process.exitCode !== 0) { ui.warning("Project configuration was saved, but setup is incomplete."); @@ -292,10 +332,15 @@ export async function initCommand(options: InitOptions) { return; } - ui.text("AI DevKit project initialized successfully!", { breakline: true }); - ui.info("Next steps:"); - ui.text(` • Review and customize templates in ${docsDir}/`); - ui.text(" • Your selected AI environments are ready in this project"); - ui.text(" • Run `ai-devkit phase ` to add more phases later"); - ui.text(" • Run `ai-devkit init` again to add more environments\n"); + ui.breakline(); + ui.success("AI DevKit project initialized successfully!"); + ui.text(chalk.bold("Next steps:")); + ui.text(chalk.dim(` - Review and customize templates in ${docsDir}/`)); + ui.text( + chalk.dim(" - Your selected AI environments are ready in this project"), + ); + ui.text( + chalk.dim(" - Run `ai-devkit phase ` to add more phases later"), + ); + ui.text(chalk.dim(" - Run `ai-devkit init` again to add more environments")); } diff --git a/packages/cli/src/commands/phase.ts b/packages/cli/src/commands/phase.ts index 7d0ceea9..8ebb3b93 100644 --- a/packages/cli/src/commands/phase.ts +++ b/packages/cli/src/commands/phase.ts @@ -1,4 +1,5 @@ import { ConfigManager } from "../lib/Config.js"; +import chalk from "chalk"; import { TemplateManager } from "../lib/TemplateManager.js"; import { Phase, AVAILABLE_PHASES, PHASE_DISPLAY_NAMES } from "../types.js"; import { ui } from "../util/terminal-ui.js"; @@ -19,11 +20,15 @@ export async function phaseCommand(phaseName?: string) { if (phaseName && AVAILABLE_PHASES.includes(phaseName as Phase)) { phase = phaseName as Phase; } else if (phaseName) { - ui.error(`Unknown phase "${phaseName}". Available phases: ${AVAILABLE_PHASES.join(", ")}`); + ui.error( + `Unknown phase "${phaseName}". Available phases: ${AVAILABLE_PHASES.join(", ")}`, + ); return; } else { const config = await configManager.read(); - const availableToAdd = AVAILABLE_PHASES.filter((p) => !config?.phases.includes(p)); + const availableToAdd = AVAILABLE_PHASES.filter( + (p) => !config?.phases.includes(p), + ); if (availableToAdd.length === 0) { ui.warning("All phases are already initialized."); @@ -66,6 +71,6 @@ export async function phaseCommand(phaseName?: string) { const file = await templateManager.copyPhaseTemplate(phase); await configManager.addPhase(phase); - ui.success(`${PHASE_DISPLAY_NAMES[phase]} created successfully!`); - ui.info(` Location: ${file}\n`); + ui.success(`${PHASE_DISPLAY_NAMES[phase]} created successfully.`); + ui.text(chalk.dim(` - ${file}`)); } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index f873dba1..d66c6997 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import chalk from "chalk"; import { createSetupService, SUPPORTED_SETUP_AGENTS, @@ -21,7 +22,9 @@ export function registerSetupCommand(program: Command): void { .action(setupCommand); } -export async function setupCommand(options: SetupCommandOptions = {}): Promise { +export async function setupCommand( + options: SetupCommandOptions = {}, +): Promise { const agents = parseAgents(options.agent); if (agents === null) { @@ -38,9 +41,21 @@ export async function setupCommand(options: SetupCommandOptions = {}): Promise 0 ? 1 : 0; + if (process.exitCode === 0) { + ui.success("Setup completed successfully."); + } +} + +function renderSectionHeader(label: string): void { + ui.text(chalk.bold(`${label}:`)); +} + +function stepLabel( + count: number, + status: "installed" | "skipped" | "failed", +): string { + return `${count === 1 ? "step" : "steps"} ${status}`; } -function parseAgents(value: string | undefined): SetupAgent[] | undefined | null { +function parseAgents( + value: string | undefined, +): SetupAgent[] | undefined | null { if (!value?.trim()) { return undefined; } @@ -99,7 +134,9 @@ function isSetupAgent(agent: string): agent is SetupAgent { return SUPPORTED_SETUP_AGENTS.includes(agent as SetupAgent); } -function countStatuses(statuses: SetupStepStatus[]): Record { +function countStatuses( + statuses: SetupStepStatus[], +): Record { return statuses.reduce>( (counts, status) => { counts[status] += 1; From 1632192ea183d758b44331a22bca38622f2f13f9 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 23 Sep 2026 12:30:21 +0200 Subject: [PATCH 2/2] test(cli): update docs chalk mock --- packages/cli/src/__tests__/commands/docs.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/__tests__/commands/docs.test.ts b/packages/cli/src/__tests__/commands/docs.test.ts index 54d43778..6e9408cd 100644 --- a/packages/cli/src/__tests__/commands/docs.test.ts +++ b/packages/cli/src/__tests__/commands/docs.test.ts @@ -38,6 +38,9 @@ vi.mock("../../util/terminal-ui.js", () => ({ vi.mock("chalk", () => ({ default: { dim: (text: string) => `[dim]${text}[/dim]`, + green: (text: string) => text, + red: (text: string) => text, + yellow: (text: string) => text, }, }));