Skip to content

Commit afb847a

Browse files
committed
fix: addSkill() crashes when skills is an object with registries field
1 parent 65ad123 commit afb847a

File tree

3 files changed

+189
-23
lines changed

3 files changed

+189
-23
lines changed

packages/cli/src/__tests__/lib/Config.test.ts

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ describe('ConfigManager', () => {
425425
});
426426

427427
describe('addSkill', () => {
428-
it('adds a new skill entry to config', async () => {
428+
it('adds a new skill entry to config when skills is an array', async () => {
429429
const config: DevKitConfig = {
430430
version: '1.0.0',
431431
environments: ['cursor'],
@@ -444,10 +444,46 @@ describe('ConfigManager', () => {
444444
name: 'memory'
445445
});
446446

447-
expect(result.skills).toEqual([
448-
{ registry: 'codeaholicguy/ai-devkit', name: 'debug' },
449-
{ registry: 'codeaholicguy/ai-devkit', name: 'memory' }
450-
]);
447+
expect(result.skills).toEqual({
448+
installed: [
449+
{ registry: 'codeaholicguy/ai-devkit', name: 'debug' },
450+
{ registry: 'codeaholicguy/ai-devkit', name: 'memory' }
451+
]
452+
});
453+
expect(mockFs.writeJson).toHaveBeenCalled();
454+
});
455+
456+
it('adds a new skill entry when skills is an object with registries', async () => {
457+
const config = {
458+
version: '1.0.0',
459+
environments: ['cursor'],
460+
phases: [],
461+
skills: {
462+
registries: {
463+
'custom/repo': 'https://github.com/custom/repo.git'
464+
}
465+
},
466+
createdAt: '2024-01-01T00:00:00.000Z',
467+
updatedAt: '2024-01-01T00:00:00.000Z'
468+
};
469+
470+
(mockFs.pathExists as any).mockResolvedValue(true);
471+
(mockFs.readJson as any).mockResolvedValue(config);
472+
(mockFs.writeJson as any).mockResolvedValue(undefined);
473+
474+
const result = await configManager.addSkill({
475+
registry: 'custom/repo',
476+
name: 'my-skill'
477+
});
478+
479+
expect(result.skills).toEqual({
480+
registries: {
481+
'custom/repo': 'https://github.com/custom/repo.git'
482+
},
483+
installed: [
484+
{ registry: 'custom/repo', name: 'my-skill' }
485+
]
486+
});
451487
expect(mockFs.writeJson).toHaveBeenCalled();
452488
});
453489

@@ -472,6 +508,114 @@ describe('ConfigManager', () => {
472508
expect(result.skills).toEqual([{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }]);
473509
expect(mockFs.writeJson).not.toHaveBeenCalled();
474510
});
511+
512+
it('adds skill when skills is undefined', async () => {
513+
const config: DevKitConfig = {
514+
version: '1.0.0',
515+
environments: ['cursor'],
516+
phases: [],
517+
createdAt: '2024-01-01T00:00:00.000Z',
518+
updatedAt: '2024-01-01T00:00:00.000Z'
519+
};
520+
521+
(mockFs.pathExists as any).mockResolvedValue(true);
522+
(mockFs.readJson as any).mockResolvedValue(config);
523+
(mockFs.writeJson as any).mockResolvedValue(undefined);
524+
525+
const result = await configManager.addSkill({
526+
registry: 'codeaholicguy/ai-devkit',
527+
name: 'memory'
528+
});
529+
530+
expect(result.skills).toEqual({
531+
installed: [
532+
{ registry: 'codeaholicguy/ai-devkit', name: 'memory' }
533+
]
534+
});
535+
expect(mockFs.writeJson).toHaveBeenCalled();
536+
});
537+
});
538+
539+
describe('normalizeSkillsConfig', () => {
540+
it('normalizes an array to SkillsConfig', () => {
541+
const result = configManager.normalizeSkillsConfig([
542+
{ registry: 'r', name: 's' }
543+
]);
544+
expect(result).toEqual({
545+
installed: [{ registry: 'r', name: 's' }]
546+
});
547+
});
548+
549+
it('normalizes an object with registries and installed', () => {
550+
const result = configManager.normalizeSkillsConfig({
551+
registries: { 'r': 'https://example.com' },
552+
installed: [{ registry: 'r', name: 's' }]
553+
});
554+
expect(result).toEqual({
555+
registries: { 'r': 'https://example.com' },
556+
installed: [{ registry: 'r', name: 's' }]
557+
});
558+
});
559+
560+
it('normalizes an object with only registries', () => {
561+
const result = configManager.normalizeSkillsConfig({
562+
registries: { 'r': 'https://example.com' }
563+
});
564+
expect(result).toEqual({
565+
registries: { 'r': 'https://example.com' },
566+
installed: []
567+
});
568+
});
569+
570+
it('normalizes undefined to empty SkillsConfig', () => {
571+
const result = configManager.normalizeSkillsConfig(undefined);
572+
expect(result).toEqual({ installed: [] });
573+
});
574+
575+
it('normalizes null to empty SkillsConfig', () => {
576+
const result = configManager.normalizeSkillsConfig(null);
577+
expect(result).toEqual({ installed: [] });
578+
});
579+
});
580+
581+
describe('getInstalledSkills', () => {
582+
it('returns skills from array format', () => {
583+
const config: DevKitConfig = {
584+
version: '1.0.0',
585+
environments: [],
586+
phases: [],
587+
skills: [{ registry: 'r', name: 's' }],
588+
createdAt: '',
589+
updatedAt: ''
590+
};
591+
expect(configManager.getInstalledSkills(config)).toEqual([{ registry: 'r', name: 's' }]);
592+
});
593+
594+
it('returns skills from object format', () => {
595+
const config = {
596+
version: '1.0.0',
597+
environments: [],
598+
phases: [],
599+
skills: {
600+
registries: { 'r': 'https://example.com' },
601+
installed: [{ registry: 'r', name: 's' }]
602+
},
603+
createdAt: '',
604+
updatedAt: ''
605+
} as DevKitConfig;
606+
expect(configManager.getInstalledSkills(config)).toEqual([{ registry: 'r', name: 's' }]);
607+
});
608+
609+
it('returns empty array when skills is undefined', () => {
610+
const config: DevKitConfig = {
611+
version: '1.0.0',
612+
environments: [],
613+
phases: [],
614+
createdAt: '',
615+
updatedAt: ''
616+
};
617+
expect(configManager.getInstalledSkills(config)).toEqual([]);
618+
});
475619
});
476620

477621
describe('getSkillRegistries', () => {

packages/cli/src/lib/Config.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fs from 'fs-extra';
22
import * as path from 'path';
3-
import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill, DEFAULT_DOCS_DIR } from '../types';
3+
import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill, SkillsConfig, DEFAULT_DOCS_DIR } from '../types';
44
import packageJson from '../../package.json';
55

66
const CONFIG_FILE_NAME = '.ai-devkit.json';
@@ -127,34 +127,50 @@ export class ConfigManager {
127127
return environments.includes(envId);
128128
}
129129

130+
normalizeSkillsConfig(raw: unknown): SkillsConfig {
131+
if (Array.isArray(raw)) {
132+
return { installed: raw };
133+
}
134+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
135+
const obj = raw as Record<string, unknown>;
136+
return {
137+
registries: obj.registries as Record<string, string> | undefined,
138+
installed: Array.isArray(obj.installed) ? obj.installed as ConfigSkill[] : []
139+
};
140+
}
141+
return { installed: [] };
142+
}
143+
130144
async addSkill(skill: ConfigSkill): Promise<DevKitConfig> {
131145
const config = await this.read();
132146
if (!config) {
133147
throw new Error('Config file not found. Run ai-devkit init first.');
134148
}
135149

136-
const skills = config.skills || [];
137-
const exists = skills.some(
150+
const normalized = this.normalizeSkillsConfig(config.skills);
151+
const installed = normalized.installed || [];
152+
153+
const exists = installed.some(
138154
entry => entry.registry === skill.registry && entry.name === skill.name
139155
);
140156

141157
if (exists) {
142158
return config;
143159
}
144160

145-
skills.push(skill);
146-
return this.update({ skills });
161+
installed.push(skill);
162+
normalized.installed = installed;
163+
return this.update({ skills: normalized });
147164
}
148165

149166
async getSkillRegistries(): Promise<Record<string, string>> {
150-
const config = await this.read() as any;
151-
const rootRegistries = config?.registries;
152-
const nestedRegistries =
153-
config?.skills && !Array.isArray(config.skills)
154-
? config.skills.registries
155-
: undefined;
167+
const config = await this.read();
168+
if (!config) {
169+
return {};
170+
}
156171

157-
const registries = rootRegistries ?? nestedRegistries;
172+
const normalized = this.normalizeSkillsConfig(config.skills);
173+
const registries = config.registries ?? normalized.registries;
158174

159175
if (!registries || typeof registries !== 'object' || Array.isArray(registries)) {
160176
return {};
@@ -164,4 +180,8 @@ export class ConfigManager {
164180
Object.entries(registries).filter(([, value]) => typeof value === 'string')
165181
) as Record<string, string>;
166182
}
183+
184+
getInstalledSkills(config: DevKitConfig): ConfigSkill[] {
185+
return this.normalizeSkillsConfig(config.skills).installed || [];
186+
}
167187
}

packages/cli/src/types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export type EnvironmentCode = 'cursor' | 'claude' | 'github' | 'gemini' | 'codex
2424

2525
export const DEFAULT_DOCS_DIR = 'docs/ai';
2626

27+
export interface SkillsConfig {
28+
registries?: Record<string, string>;
29+
installed?: ConfigSkill[];
30+
}
31+
2732
export interface DevKitConfig {
2833
version: string;
2934
paths?: {
@@ -34,7 +39,8 @@ export interface DevKitConfig {
3439
};
3540
environments: EnvironmentCode[];
3641
phases: Phase[];
37-
skills?: ConfigSkill[];
42+
skills?: ConfigSkill[] | SkillsConfig;
43+
registries?: Record<string, string>;
3844
createdAt: string;
3945
updatedAt: string;
4046
}
@@ -44,12 +50,8 @@ export interface ConfigSkill {
4450
name: string;
4551
}
4652

47-
export interface SkillRegistriesConfig {
48-
registries?: Record<string, string>;
49-
}
50-
5153
export interface GlobalDevKitConfig {
52-
skills?: SkillRegistriesConfig;
54+
skills?: SkillsConfig;
5355
}
5456

5557
export interface PhaseMetadata {

0 commit comments

Comments
 (0)