Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 158 additions & 8 deletions packages/cli/src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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'],
Expand Down Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ program
.option('-p, --phases <phases>', 'Comma-separated list of phases to initialize')
.option('-t, --template <path>', 'Initialize from template file (.yaml, .yml, .json)')
.option('-d, --docs-dir <path>', '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
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -47,6 +49,7 @@ interface InitOptions {
phases?: string;
template?: string;
docsDir?: string;
builtIn?: boolean;
}

function normalizeEnvironmentOption(
Expand All @@ -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<boolean> {
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);
}

Comment thread
codeaholicguy marked this conversation as resolved.
interface TemplateSkillInstallResult {
registry: string;
skill: string;
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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];
3 changes: 1 addition & 2 deletions packages/cli/src/lib/SkillManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -610,7 +610,6 @@ export class SkillManager {
}
}


/**
* Display update summary with colored output
* @param summary - UpdateSummary to display
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/util/terminal.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading