From b00d478a43e997aec15848db176154545123f380 Mon Sep 17 00:00:00 2001 From: nnhhoang Date: Fri, 17 Apr 2026 03:55:48 +0700 Subject: [PATCH 1/3] feat(init): prompt to install AI DevKit built-in skills during interactive init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, `ai-devkit init` without a template only set up environments and phases — built-in skills were never installed, even though agents need them for the guided SDLC workflow. Users had to discover and run `ai-devkit skill add codeaholicguy/ai-devkit` separately, or init with a specific template. Now the interactive init prompts the user after phase setup asking whether to install the built-in AI DevKit skills (dev-lifecycle, debug, capture-knowledge, memory, simplify-implementation, technical-writer, verify, tdd). Default is Yes so fresh users get the expected experience out of the box, but users who want a lean setup can decline. Reuses the existing installTemplateSkills helper so failures are reported as warnings instead of aborting init — matches the behavior already established for template-driven installs. The prompt only appears in interactive mode. Template mode (`-t some.yaml`) is unchanged: templates that declare skills still drive the install, and templates without skills still skip the interactive prompt. Fixes #56 --- .../cli/src/__tests__/commands/init.test.ts | 58 +++++++++++++++++++ packages/cli/src/commands/init.ts | 41 +++++++++++++ 2 files changed, 99 insertions(+) diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index b3ec6599..52c8e2d5 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -195,4 +195,62 @@ describe('init command template mode', () => { expect(process.exitCode).toBe(1); expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); }); + + describe('built-in skills prompt (interactive init without template)', () => { + it('installs built-in AI DevKit skills when user confirms the prompt', async () => { + mockPrompt.mockResolvedValueOnce({ installBuiltinSkills: true }); + + await initCommand({}); + + const builtinCalls = mockSkillManager.addSkill.mock.calls.filter( + (call: unknown[]) => call[0] === 'codeaholicguy/ai-devkit' + ); + expect(builtinCalls.length).toBeGreaterThan(0); + expect(mockPrompt).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'confirm', + name: 'installBuiltinSkills', + default: true + }) + ]); + }); + + it('skips installing built-in skills when user declines the prompt', async () => { + mockPrompt.mockResolvedValueOnce({ installBuiltinSkills: false }); + + await initCommand({}); + + const builtinPromptCalls = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPromptCalls.length).toBe(1); + expect(mockSkillManager.addSkill).not.toHaveBeenCalled(); + }); + + it('does not prompt for built-in skills when running in template mode', async () => { + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'] + }); + + await initCommand({ template: './init.yaml' }); + + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + if (!Array.isArray(questions)) return false; + return questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + }); + + it('continues init when built-in skill install fails', async () => { + mockPrompt.mockResolvedValueOnce({ installBuiltinSkills: true }); + mockSkillManager.addSkill.mockRejectedValue(new Error('network down')); + + await expect(initCommand({})).resolves.toBeUndefined(); + expect(mockSkillManager.addSkill).toHaveBeenCalledWith('codeaholicguy/ai-devkit', expect.any(String)); + expect(process.exitCode).not.toBe(1); + }); + }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ec85db4a..66c8b4eb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -66,6 +66,19 @@ function normalizeEnvironmentOption( .filter((value): value is EnvironmentCode => value.length > 0); } +const BUILTIN_SKILL_REGISTRY = 'codeaholicguy/ai-devkit'; + +const BUILTIN_SKILLS: InitTemplateSkill[] = [ + { registry: BUILTIN_SKILL_REGISTRY, skill: 'dev-lifecycle' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'debug' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'capture-knowledge' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'memory' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'simplify-implementation' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'technical-writer' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'verify' }, + { registry: BUILTIN_SKILL_REGISTRY, skill: 'tdd' } +]; + interface TemplateSkillInstallResult { registry: string; skill: string; @@ -298,6 +311,34 @@ export async function initCommand(options: InitOptions) { ui.warning(`${result.registry}/${result.skill}: ${result.reason || 'Unknown error'}`); }); } + } else if (!hasTemplate) { + const { installBuiltinSkills } = await inquirer.prompt([ + { + type: 'confirm', + name: 'installBuiltinSkills', + message: `Install AI DevKit built-in skills from ${BUILTIN_SKILL_REGISTRY}?`, + default: true + } + ]); + + if (installBuiltinSkills) { + ui.text('Installing AI DevKit built-in skills...', { breakline: true }); + const skillResults = await installTemplateSkills(skillManager, BUILTIN_SKILLS); + const installedCount = skillResults.filter(result => result.status === 'installed').length; + const failedResults = skillResults.filter(result => result.status === 'failed'); + + if (installedCount > 0) { + ui.success(`Installed ${installedCount} built-in skill(s).`); + } + if (failedResults.length > 0) { + ui.warning( + `${failedResults.length} built-in skill install(s) failed. Continuing with warnings.` + ); + failedResults.forEach(result => { + ui.warning(`${result.registry}/${result.skill}: ${result.reason || 'Unknown error'}`); + }); + } + } } if (templateConfig?.mcpServers && Object.keys(templateConfig.mcpServers).length > 0) { From 0e053261a8a93f18bac88b1d1ebf9d46b808bf3b Mon Sep 17 00:00:00 2001 From: nnhhoang Date: Fri, 17 Apr 2026 23:36:33 +0700 Subject: [PATCH 2/3] refactor(init): add TTY detection and --built-in flag for the built-in skills flow Addresses PR review feedback: - Detect non-interactive environments via a new isInteractiveTerminal() utility shared with SkillManager, so inquirer.prompt is never called when stdin is not a TTY. Prevents the init command from hanging in CI pipelines or any piped execution context. - Introduce a --built-in CLI flag so users can opt in to installing the curated built-in skills without an interactive prompt. Behavior matrix: Interactive, no flag -> prompt Y/n (default Yes) Interactive, --built-in -> install without prompting Non-interactive, no flag -> skip, log a hint pointing at --built-in Non-interactive, --built-in -> install without prompting - Extract BUILTIN_SKILL_REGISTRY and BUILTIN_SKILL_NAMES into packages/cli/src/constants.ts so future commands (doctor, upgrade, ...) can import the same canonical list instead of duplicating names. BUILTIN_SKILLS in init.ts is now derived via .map() over the shared names array. - Extract the previously-private isInteractiveTerminal() logic from SkillManager into util/terminal.ts. SkillManager now delegates to the shared helper so both commands see identical TTY semantics. Adds three unit tests covering the new non-interactive paths and the --built-in flag in both interactive and non-interactive contexts. --- .../cli/src/__tests__/commands/init.test.ts | 56 +++++++++++++++++++ packages/cli/src/cli.ts | 1 + packages/cli/src/commands/init.ts | 52 ++++++++++------- packages/cli/src/constants.ts | 24 ++++++++ packages/cli/src/lib/SkillManager.ts | 3 +- packages/cli/src/util/terminal.ts | 6 ++ 6 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/constants.ts diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 52c8e2d5..fb0f1cba 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -41,6 +41,7 @@ const mockUi: any = { const mockPrompt: any = jest.fn(); const mockLoadInitTemplate: any = jest.fn(); const mockExecSync: any = jest.fn(); +const mockIsInteractiveTerminal: any = jest.fn(); jest.mock('child_process', () => ({ execSync: (...args: unknown[]) => mockExecSync(...args) @@ -81,6 +82,10 @@ jest.mock('../../util/terminal-ui', () => ({ ui: mockUi })); +jest.mock('../../util/terminal', () => ({ + isInteractiveTerminal: (...args: unknown[]) => mockIsInteractiveTerminal(...args) +})); + import { initCommand } from '../../commands/init'; describe('init command template mode', () => { @@ -109,6 +114,7 @@ describe('init command template mode', () => { mockSkillManager.addSkill.mockResolvedValue(undefined); mockLoadInitTemplate.mockResolvedValue({}); + mockIsInteractiveTerminal.mockReturnValue(true); }); afterEach(() => { @@ -253,4 +259,54 @@ describe('init command template mode', () => { expect(process.exitCode).not.toBe(1); }); }); + + describe('built-in skills in non-interactive environments (CI)', () => { + it('skips the built-in skills prompt and install when stdin is not a TTY', async () => { + mockIsInteractiveTerminal.mockReturnValue(false); + + await initCommand({}); + + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + expect(mockSkillManager.addSkill).not.toHaveBeenCalled(); + 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 () => { + mockIsInteractiveTerminal.mockReturnValue(false); + + await initCommand({ builtIn: true }); + + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + const builtinCalls = mockSkillManager.addSkill.mock.calls.filter( + (call: unknown[]) => call[0] === 'codeaholicguy/ai-devkit' + ); + expect(builtinCalls.length).toBeGreaterThan(0); + }); + + it('installs built-in skills without prompting when --built-in is passed in an interactive environment', async () => { + mockIsInteractiveTerminal.mockReturnValue(true); + + await initCommand({ builtIn: true }); + + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + const builtinCalls = mockSkillManager.addSkill.mock.calls.filter( + (call: unknown[]) => call[0] === 'codeaholicguy/ai-devkit' + ); + expect(builtinCalls.length).toBeGreaterThan(0); + }); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 16841678..f53bc471 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -28,6 +28,7 @@ program .option('-p, --phases ', 'Comma-separated list of phases to initialize') .option('-t, --template ', 'Initialize from template file (.yaml, .yml, .json)') .option('-d, --docs-dir ', 'Custom directory for AI documentation (default: docs/ai)') + .option('--built-in', 'Install AI DevKit built-in skills without prompting (useful for CI/non-interactive runs)') .action(initCommand); program diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 66c8b4eb..d162ce43 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,5 +1,6 @@ import { execSync } from 'child_process'; import inquirer from 'inquirer'; +import { BUILTIN_SKILL_NAMES, BUILTIN_SKILL_REGISTRY } from '../constants'; import { ConfigManager } from '../lib/Config'; import { TemplateManager } from '../lib/TemplateManager'; import { EnvironmentSelector } from '../lib/EnvironmentSelector'; @@ -8,6 +9,7 @@ import { SkillManager } from '../lib/SkillManager'; import { loadInitTemplate, InitTemplateSkill } from '../lib/InitTemplate'; import { EnvironmentCode, PHASE_DISPLAY_NAMES, Phase, DEFAULT_DOCS_DIR } from '../types'; import { isValidEnvironmentCode } from '../util/env'; +import { isInteractiveTerminal } from '../util/terminal'; import { ui } from '../util/terminal-ui'; function isGitAvailable(): boolean { @@ -47,6 +49,7 @@ interface InitOptions { phases?: string; template?: string; docsDir?: string; + builtIn?: boolean; } function normalizeEnvironmentOption( @@ -66,18 +69,34 @@ function normalizeEnvironmentOption( .filter((value): value is EnvironmentCode => value.length > 0); } -const BUILTIN_SKILL_REGISTRY = 'codeaholicguy/ai-devkit'; +const BUILTIN_SKILLS: InitTemplateSkill[] = BUILTIN_SKILL_NAMES.map((skill: string) => ({ + registry: BUILTIN_SKILL_REGISTRY, + skill +})); -const BUILTIN_SKILLS: InitTemplateSkill[] = [ - { registry: BUILTIN_SKILL_REGISTRY, skill: 'dev-lifecycle' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'debug' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'capture-knowledge' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'memory' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'simplify-implementation' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'technical-writer' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'verify' }, - { registry: BUILTIN_SKILL_REGISTRY, skill: 'tdd' } -]; +async function shouldInstallBuiltinSkills(options: InitOptions): Promise { + if (options.builtIn) { + return true; + } + + if (!isInteractiveTerminal()) { + ui.info( + `Skipping built-in skills (non-interactive environment). Pass --built-in to install them from ${BUILTIN_SKILL_REGISTRY}.` + ); + return false; + } + + const { installBuiltinSkills } = await inquirer.prompt([ + { + type: 'confirm', + name: 'installBuiltinSkills', + message: `Install AI DevKit built-in skills from ${BUILTIN_SKILL_REGISTRY}?`, + default: true + } + ]); + + return Boolean(installBuiltinSkills); +} interface TemplateSkillInstallResult { registry: string; @@ -312,16 +331,9 @@ export async function initCommand(options: InitOptions) { }); } } else if (!hasTemplate) { - const { installBuiltinSkills } = await inquirer.prompt([ - { - type: 'confirm', - name: 'installBuiltinSkills', - message: `Install AI DevKit built-in skills from ${BUILTIN_SKILL_REGISTRY}?`, - default: true - } - ]); + const shouldInstall = await shouldInstallBuiltinSkills(options); - if (installBuiltinSkills) { + if (shouldInstall) { ui.text('Installing AI DevKit built-in skills...', { breakline: true }); const skillResults = await installTemplateSkills(skillManager, BUILTIN_SKILLS); const installedCount = skillResults.filter(result => result.status === 'installed').length; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 00000000..b945b2aa --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1,24 @@ +/** + * Registry identifier for the AI DevKit built-in skills. + */ +export const BUILTIN_SKILL_REGISTRY = 'codeaholicguy/ai-devkit'; + +/** + * Canonical list of built-in skills that ship with AI DevKit. Keep in sync + * with the skills published under the {@link BUILTIN_SKILL_REGISTRY} + * registry. Commands that need to install or reference the curated set + * (e.g., `ai-devkit init`, future `doctor`/`upgrade` commands) should import + * from here rather than hard-coding names locally. + */ +export const BUILTIN_SKILL_NAMES = [ + 'dev-lifecycle', + 'debug', + 'capture-knowledge', + 'memory', + 'simplify-implementation', + 'technical-writer', + 'verify', + 'tdd' +] as const; + +export type BuiltinSkillName = typeof BUILTIN_SKILL_NAMES[number]; diff --git a/packages/cli/src/lib/SkillManager.ts b/packages/cli/src/lib/SkillManager.ts index 78fd85cb..f5d8e4ee 100644 --- a/packages/cli/src/lib/SkillManager.ts +++ b/packages/cli/src/lib/SkillManager.ts @@ -8,8 +8,8 @@ import { EnvironmentSelector } from './EnvironmentSelector'; import { getGlobalSkillPath, getSkillPath, validateEnvironmentCodes } from '../util/env'; import { ensureGitInstalled, cloneRepository, isGitRepository, pullRepository, fetchGitHead } from '../util/git'; import { validateRegistryId, validateSkillName, extractSkillDescription } from '../util/skill'; -import { isInteractiveTerminal } from '../util/terminal'; import { fetchGitHubSkillPaths, fetchRawGitHubFile } from '../util/github'; +import { isInteractiveTerminal } from '../util/terminal'; import { ui } from '../util/terminal-ui'; const REGISTRY_URL = 'https://raw.githubusercontent.com/codeaholicguy/ai-devkit/main/skills/registry.json'; @@ -610,7 +610,6 @@ export class SkillManager { } } - /** * Display update summary with colored output * @param summary - UpdateSummary to display diff --git a/packages/cli/src/util/terminal.ts b/packages/cli/src/util/terminal.ts index 95e2ff92..a59b4fa5 100644 --- a/packages/cli/src/util/terminal.ts +++ b/packages/cli/src/util/terminal.ts @@ -1,3 +1,9 @@ +/** + * Detect whether the current process is attached to an interactive terminal + * on both stdin and stdout. Used by commands that need to decide between + * prompting the user and falling back to a non-interactive default + * (e.g., when running under CI, `npx ... | cat`, or other piped contexts). + */ export function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } From 50559f20995c2e5747b6d8713ce8807ac96e0f0d Mon Sep 17 00:00:00 2001 From: nnhhoang Date: Sat, 18 Apr 2026 01:15:13 +0700 Subject: [PATCH 3/3] test(init): restructure built-in skills describes and cover --built-in + template Addresses review feedback on PR #60: - Move the 'built-in skills prompt (interactive init without template)' and 'built-in skills in non-interactive environments (CI)' describe blocks out of 'init command template mode' and make them siblings under a single outer 'init command' describe. The previous nesting implied these scenarios ran under template mode when they test the opposite, which is misleading when a single describe is selected by name. - Add two test cases in the 'template mode' describe that pin down the silent-ignore behavior of --built-in when a template is in play: once with skills declared (template skills install, no built-in path), once without (no install at all). This closes a previously untested invariant that future refactors could otherwise break. No production code changes. 14 init tests across 3 sibling describes. --- .../cli/src/__tests__/commands/init.test.ts | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index fb0f1cba..01508d1b 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -88,7 +88,7 @@ jest.mock('../../util/terminal', () => ({ import { initCommand } from '../../commands/init'; -describe('init command template mode', () => { +describe('init command', () => { beforeEach(() => { jest.clearAllMocks(); process.exitCode = undefined; @@ -121,7 +121,8 @@ describe('init command template mode', () => { process.exitCode = undefined; }); - it('uses template values and installs multiple skills from same registry without prompts', async () => { + describe('template mode', () => { + it('uses template values and installs multiple skills from same registry without prompts', async () => { mockLoadInitTemplate.mockResolvedValue({ environments: ['codex'], phases: ['requirements', 'design'], @@ -192,14 +193,49 @@ describe('init command template mode', () => { expect(mockUi.warning).toHaveBeenCalledWith('Initialization cancelled.'); }); - it('sets non-zero exit code when template loading fails', async () => { - mockLoadInitTemplate.mockRejectedValue(new Error('Invalid template at /tmp/init.yaml: bad field')); + it('sets non-zero exit code when template loading fails', async () => { + mockLoadInitTemplate.mockRejectedValue(new Error('Invalid template at /tmp/init.yaml: bad field')); - await initCommand({ template: '/tmp/init.yaml' }); + await initCommand({ template: '/tmp/init.yaml' }); - expect(mockUi.error).toHaveBeenCalledWith('Invalid template at /tmp/init.yaml: bad field'); - expect(process.exitCode).toBe(1); - expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); + expect(mockUi.error).toHaveBeenCalledWith('Invalid template at /tmp/init.yaml: bad field'); + expect(process.exitCode).toBe(1); + expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); + }); + + it('silently ignores --built-in when the template declares skills', async () => { + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'], + skills: [{ registry: 'codeaholicguy/ai-devkit', skill: 'debug' }] + }); + + await initCommand({ template: './init.yaml', builtIn: true }); + + expect(mockSkillManager.addSkill).toHaveBeenCalledTimes(1); + expect(mockSkillManager.addSkill).toHaveBeenCalledWith('codeaholicguy/ai-devkit', 'debug'); + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + }); + + it('silently ignores --built-in when the template has no skills declared', async () => { + mockLoadInitTemplate.mockResolvedValue({ + environments: ['codex'], + phases: ['requirements'] + }); + + await initCommand({ template: './init.yaml', builtIn: true }); + + expect(mockSkillManager.addSkill).not.toHaveBeenCalled(); + const builtinPrompts = mockPrompt.mock.calls.filter((call: any[]) => { + const questions = call[0]; + return Array.isArray(questions) && questions.some((q: any) => q?.name === 'installBuiltinSkills'); + }); + expect(builtinPrompts).toHaveLength(0); + }); }); describe('built-in skills prompt (interactive init without template)', () => {