diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index b3ec659..01508d1 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,9 +82,13 @@ 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', () => { +describe('init command', () => { beforeEach(() => { jest.clearAllMocks(); process.exitCode = undefined; @@ -109,13 +114,15 @@ describe('init command template mode', () => { mockSkillManager.addSkill.mockResolvedValue(undefined); mockLoadInitTemplate.mockResolvedValue({}); + mockIsInteractiveTerminal.mockReturnValue(true); }); afterEach(() => { 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'], @@ -186,13 +193,156 @@ 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' }); + + 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)', () => { + 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); + }); + }); + + 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 }); - await initCommand({ template: '/tmp/init.yaml' }); + 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); - expect(mockUi.error).toHaveBeenCalledWith('Invalid template at /tmp/init.yaml: bad field'); - expect(process.exitCode).toBe(1); - expect(mockConfigManager.setEnvironments).not.toHaveBeenCalled(); + 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 1684167..f53bc47 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 ec85db4..d162ce4 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,6 +69,35 @@ function normalizeEnvironmentOption( .filter((value): value is EnvironmentCode => value.length > 0); } +const BUILTIN_SKILLS: InitTemplateSkill[] = BUILTIN_SKILL_NAMES.map((skill: string) => ({ + registry: BUILTIN_SKILL_REGISTRY, + skill +})); + +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; skill: string; @@ -298,6 +330,27 @@ export async function initCommand(options: InitOptions) { ui.warning(`${result.registry}/${result.skill}: ${result.reason || 'Unknown error'}`); }); } + } else if (!hasTemplate) { + const shouldInstall = await shouldInstallBuiltinSkills(options); + + 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; + 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) { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 0000000..b945b2a --- /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 78fd85c..f5d8e4e 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 95e2ff9..a59b4fa 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); }