mspec 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,14 +13,17 @@ const { prompt } = require('enquirer');
13
13
  async function initCommand() {
14
14
  const mspecDir = path_1.default.join(process.cwd(), '.mspec');
15
15
  const configPath = path_1.default.join(mspecDir, 'mspec.json');
16
- let existingAgent = '';
16
+ let existingAgents = [];
17
+ let config = {};
17
18
  if (fs_1.default.existsSync(mspecDir)) {
18
- console.log(chalk_1.default.blue('Existing .mspec directory found. Updating integration files...'));
19
19
  if (fs_1.default.existsSync(configPath)) {
20
20
  try {
21
- const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
22
- if (config && config.agent) {
23
- existingAgent = config.agent;
21
+ config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
22
+ if (config && config.agents && Array.isArray(config.agents)) {
23
+ existingAgents = config.agents;
24
+ }
25
+ else if (config && config.agent) {
26
+ existingAgents = [config.agent];
24
27
  }
25
28
  }
26
29
  catch (e) {
@@ -28,22 +31,27 @@ async function initCommand() {
28
31
  }
29
32
  }
30
33
  }
31
- let agent = existingAgent;
32
- if (!agent) {
33
- let response;
34
- try {
35
- response = await prompt({
36
- type: 'select',
37
- name: 'agent',
38
- message: 'Which AI agent are you using?',
39
- choices: ['claude', 'gemini', 'cursor', 'opencode', 'zed', 'generic'],
40
- });
41
- }
42
- catch (error) {
43
- console.log(chalk_1.default.yellow('\nInitialization cancelled.'));
44
- return;
45
- }
46
- agent = response.agent;
34
+ const choices = ['claude', 'gemini', 'cursor', 'opencode', 'zed', 'generic'];
35
+ let selectedAgents = [];
36
+ try {
37
+ const response = await prompt({
38
+ type: 'multiselect',
39
+ name: 'agents',
40
+ message: 'Which AI agents would you like to configure? (Space to toggle, Enter to confirm)',
41
+ choices: choices.map(c => ({
42
+ name: c,
43
+ enabled: existingAgents.includes(c)
44
+ }))
45
+ });
46
+ selectedAgents = response.agents;
47
+ }
48
+ catch (error) {
49
+ console.log(chalk_1.default.yellow('\nInitialization cancelled.'));
50
+ return;
51
+ }
52
+ if (!selectedAgents || selectedAgents.length === 0) {
53
+ console.log(chalk_1.default.yellow('No agents selected. Setup incomplete.'));
54
+ return;
47
55
  }
48
56
  // Create directories if they don't exist
49
57
  if (!fs_1.default.existsSync(mspecDir)) {
@@ -52,25 +60,30 @@ async function initCommand() {
52
60
  }
53
61
  // Write mspec.json config
54
62
  const mspecConfig = {
55
- agent,
56
- paths: {
63
+ ...config,
64
+ agents: selectedAgents,
65
+ paths: config.paths || {
57
66
  specs: '.mspec/specs',
58
67
  tasks: '.mspec/tasks'
59
68
  }
60
69
  };
70
+ // Remove legacy field
71
+ delete mspecConfig.agent;
61
72
  fs_1.default.writeFileSync(configPath, JSON.stringify(mspecConfig, null, 2));
62
- // Write agent integration files
63
- const agentTemplates = (0, templates_1.getTemplates)(agent);
64
- if (agentTemplates.length > 0) {
65
- for (const template of agentTemplates) {
66
- const targetDir = path_1.default.join(process.cwd(), template.dir);
67
- fs_1.default.mkdirSync(targetDir, { recursive: true });
68
- fs_1.default.writeFileSync(path_1.default.join(targetDir, template.file), template.content);
69
- console.log(chalk_1.default.green(`Updated integration file for ${agent} at ${path_1.default.join(template.dir, template.file)}`));
73
+ // Write agent integration files for each selected agent
74
+ for (const agent of selectedAgents) {
75
+ const agentTemplates = (0, templates_1.getTemplates)(agent);
76
+ if (agentTemplates.length > 0) {
77
+ for (const template of agentTemplates) {
78
+ const targetDir = path_1.default.join(process.cwd(), template.dir);
79
+ fs_1.default.mkdirSync(targetDir, { recursive: true });
80
+ fs_1.default.writeFileSync(path_1.default.join(targetDir, template.file), template.content);
81
+ console.log(chalk_1.default.green(`Updated integration file for ${agent} at ${path_1.default.join(template.dir, template.file)}`));
82
+ }
83
+ }
84
+ else {
85
+ console.log(chalk_1.default.yellow(`No specific integration template found for ${agent}. Setup completed with generic settings.`));
70
86
  }
71
- }
72
- else {
73
- console.log(chalk_1.default.yellow(`No specific integration template found for ${agent}. Setup completed with generic settings.`));
74
87
  }
75
88
  console.log(chalk_1.default.green('mspec initialized/updated successfully!'));
76
89
  }
@@ -37,7 +37,7 @@ describe('initCommand', () => {
37
37
  ];
38
38
  providers.forEach(({ agent, expectedFiles }) => {
39
39
  it(`should initialize the .mspec environment for ${agent}`, async () => {
40
- mockPrompt.mockResolvedValueOnce({ agent });
40
+ mockPrompt.mockResolvedValueOnce({ agents: [agent] });
41
41
  const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
42
42
  await (0, init_1.initCommand)();
43
43
  expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec'))).toBe(true);
@@ -46,33 +46,77 @@ describe('initCommand', () => {
46
46
  const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
47
47
  expect(fs_1.default.existsSync(configPath)).toBe(true);
48
48
  const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
49
- expect(config.agent).toBe(agent);
49
+ expect(config.agents).toContain(agent);
50
50
  for (const expectedFile of expectedFiles) {
51
51
  const integrationPath = path_1.default.join(tmpDir, expectedFile);
52
52
  expect(fs_1.default.existsSync(integrationPath)).toBe(true);
53
53
  }
54
54
  });
55
55
  });
56
- it('should update integration files if .mspec directory already exists', async () => {
56
+ it('should initialize multiple agents at once', async () => {
57
+ mockPrompt.mockResolvedValueOnce({ agents: ['claude', 'gemini'] });
58
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
59
+ await (0, init_1.initCommand)();
60
+ const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
61
+ const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
62
+ expect(config.agents).toEqual(['claude', 'gemini']);
63
+ expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.claude/commands/mspec.spec.md'))).toBe(true);
64
+ expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.gemini/commands/mspec.spec.toml'))).toBe(true);
65
+ });
66
+ it('should update and prompt even if .mspec directory already exists', async () => {
57
67
  const mspecDir = path_1.default.join(tmpDir, '.mspec');
58
68
  fs_1.default.mkdirSync(mspecDir);
59
69
  fs_1.default.mkdirSync(path_1.default.join(mspecDir, 'specs'));
60
70
  fs_1.default.mkdirSync(path_1.default.join(mspecDir, 'tasks'));
61
- fs_1.default.writeFileSync(path_1.default.join(mspecDir, 'mspec.json'), JSON.stringify({ agent: 'cursor' }));
71
+ fs_1.default.writeFileSync(path_1.default.join(mspecDir, 'mspec.json'), JSON.stringify({ agents: ['cursor'] }));
62
72
  const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
63
- mockPrompt.mockRejectedValueOnce(new Error('Should not prompt'));
73
+ mockPrompt.mockResolvedValueOnce({ agents: ['cursor', 'gemini'] });
64
74
  await (0, init_1.initCommand)();
65
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Existing .mspec directory found. Updating integration files...'));
75
+ const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
76
+ const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
77
+ expect(config.agents).toEqual(['cursor', 'gemini']);
66
78
  expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.cursor/rules/mspec.spec.mdc'))).toBe(true);
79
+ expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.gemini/commands/mspec.spec.toml'))).toBe(true);
67
80
  });
68
- it('should prompt if .mspec directory exists but mspec.json is missing or invalid', async () => {
81
+ it('should handle legacy "agent" field and migrate to "agents"', async () => {
69
82
  const mspecDir = path_1.default.join(tmpDir, '.mspec');
70
83
  fs_1.default.mkdirSync(mspecDir);
71
- mockPrompt.mockResolvedValueOnce({ agent: 'gemini' });
84
+ fs_1.default.writeFileSync(path_1.default.join(mspecDir, 'mspec.json'), JSON.stringify({ agent: 'claude' }));
85
+ mockPrompt.mockResolvedValueOnce({ agents: ['claude', 'gemini'] });
86
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
87
+ await (0, init_1.initCommand)();
88
+ const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
89
+ const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
90
+ expect(config.agents).toEqual(['claude', 'gemini']);
91
+ expect(config.agent).toBeUndefined();
92
+ });
93
+ it('should support running init multiple times to add/remove agents', async () => {
72
94
  const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
95
+ // 1st init: Select 'claude'
96
+ mockPrompt.mockResolvedValueOnce({ agents: ['claude'] });
73
97
  await (0, init_1.initCommand)();
74
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Existing .mspec directory found. Updating integration files...'));
98
+ let config = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpDir, '.mspec/mspec.json'), 'utf-8'));
99
+ expect(config.agents).toEqual(['claude']);
100
+ expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.claude/commands/mspec.spec.md'))).toBe(true);
101
+ // 2nd init: Add 'gemini', 'claude' should be pre-selected (enabled: true)
102
+ mockPrompt.mockResolvedValueOnce({ agents: ['claude', 'gemini'] });
103
+ await (0, init_1.initCommand)();
104
+ // Verify prompt was called with 'claude' enabled
105
+ expect(mockPrompt).toHaveBeenLastCalledWith(expect.objectContaining({
106
+ choices: expect.arrayContaining([
107
+ expect.objectContaining({ name: 'claude', enabled: true }),
108
+ expect.objectContaining({ name: 'gemini', enabled: false })
109
+ ])
110
+ }));
111
+ config = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpDir, '.mspec/mspec.json'), 'utf-8'));
112
+ expect(config.agents).toEqual(['claude', 'gemini']);
75
113
  expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.gemini/commands/mspec.spec.toml'))).toBe(true);
114
+ // 3rd init: Remove 'claude', keep 'gemini'
115
+ mockPrompt.mockResolvedValueOnce({ agents: ['gemini'] });
116
+ await (0, init_1.initCommand)();
117
+ config = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpDir, '.mspec/mspec.json'), 'utf-8'));
118
+ expect(config.agents).toEqual(['gemini']);
119
+ // Note: mspec currently doesn't delete files of removed agents, which is fine for now
76
120
  });
77
121
  it('should handle prompt cancellation gracefully', async () => {
78
122
  mockPrompt.mockRejectedValueOnce(new Error('User cancelled'));
@@ -82,13 +126,13 @@ describe('initCommand', () => {
82
126
  expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec'))).toBe(false);
83
127
  });
84
128
  it('should handle missing template gracefully', async () => {
85
- mockPrompt.mockResolvedValueOnce({ agent: 'unknown-agent' });
129
+ mockPrompt.mockResolvedValueOnce({ agents: ['unknown-agent'] });
86
130
  const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
87
131
  await (0, init_1.initCommand)();
88
132
  const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
89
133
  expect(fs_1.default.existsSync(configPath)).toBe(true);
90
134
  const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
91
- expect(config.agent).toBe('unknown-agent');
135
+ expect(config.agents).toContain('unknown-agent');
92
136
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No specific integration template found'));
93
137
  });
94
138
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mspec",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "A minimalist Spec-Driven Development (SDD) toolkit for solo developers and AI agents",
5
5
  "main": "index.js",
6
6
  "scripts": {