@fission-ai/openspec 1.1.1 → 1.3.0

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.
Files changed (85) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/index.js +2 -0
  3. package/dist/commands/config.d.ts +28 -0
  4. package/dist/commands/config.js +359 -5
  5. package/dist/commands/workflow/shared.d.ts +5 -0
  6. package/dist/commands/workflow/shared.js +21 -16
  7. package/dist/commands/workflow/status.js +18 -1
  8. package/dist/core/available-tools.d.ts +17 -0
  9. package/dist/core/available-tools.js +43 -0
  10. package/dist/core/command-generation/adapters/bob.d.ts +14 -0
  11. package/dist/core/command-generation/adapters/bob.js +45 -0
  12. package/dist/core/command-generation/adapters/index.d.ts +5 -0
  13. package/dist/core/command-generation/adapters/index.js +5 -0
  14. package/dist/core/command-generation/adapters/junie.d.ts +13 -0
  15. package/dist/core/command-generation/adapters/junie.js +26 -0
  16. package/dist/core/command-generation/adapters/kiro.d.ts +13 -0
  17. package/dist/core/command-generation/adapters/kiro.js +26 -0
  18. package/dist/core/command-generation/adapters/lingma.d.ts +13 -0
  19. package/dist/core/command-generation/adapters/lingma.js +30 -0
  20. package/dist/core/command-generation/adapters/opencode.d.ts +1 -1
  21. package/dist/core/command-generation/adapters/opencode.js +2 -2
  22. package/dist/core/command-generation/adapters/pi.d.ts +18 -0
  23. package/dist/core/command-generation/adapters/pi.js +55 -0
  24. package/dist/core/command-generation/registry.js +10 -0
  25. package/dist/core/completions/command-registry.js +5 -0
  26. package/dist/core/completions/installers/powershell-installer.d.ts +14 -0
  27. package/dist/core/completions/installers/powershell-installer.js +69 -9
  28. package/dist/core/config-schema.d.ts +10 -0
  29. package/dist/core/config-schema.js +14 -1
  30. package/dist/core/config.d.ts +1 -0
  31. package/dist/core/config.js +7 -1
  32. package/dist/core/global-config.d.ts +5 -0
  33. package/dist/core/global-config.js +12 -2
  34. package/dist/core/init.d.ts +5 -0
  35. package/dist/core/init.js +209 -52
  36. package/dist/core/legacy-cleanup.d.ts +1 -1
  37. package/dist/core/legacy-cleanup.js +23 -10
  38. package/dist/core/migration.d.ts +23 -0
  39. package/dist/core/migration.js +108 -0
  40. package/dist/core/profile-sync-drift.d.ts +38 -0
  41. package/dist/core/profile-sync-drift.js +200 -0
  42. package/dist/core/profiles.d.ts +26 -0
  43. package/dist/core/profiles.js +40 -0
  44. package/dist/core/shared/index.d.ts +1 -1
  45. package/dist/core/shared/index.js +1 -1
  46. package/dist/core/shared/skill-generation.d.ts +14 -7
  47. package/dist/core/shared/skill-generation.js +36 -20
  48. package/dist/core/shared/tool-detection.d.ts +8 -3
  49. package/dist/core/shared/tool-detection.js +18 -0
  50. package/dist/core/templates/index.d.ts +1 -1
  51. package/dist/core/templates/index.js +2 -2
  52. package/dist/core/templates/skill-templates.d.ts +15 -118
  53. package/dist/core/templates/skill-templates.js +14 -3424
  54. package/dist/core/templates/types.d.ts +19 -0
  55. package/dist/core/templates/types.js +5 -0
  56. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  57. package/dist/core/templates/workflows/apply-change.js +308 -0
  58. package/dist/core/templates/workflows/archive-change.d.ts +10 -0
  59. package/dist/core/templates/workflows/archive-change.js +271 -0
  60. package/dist/core/templates/workflows/bulk-archive-change.d.ts +10 -0
  61. package/dist/core/templates/workflows/bulk-archive-change.js +488 -0
  62. package/dist/core/templates/workflows/continue-change.d.ts +10 -0
  63. package/dist/core/templates/workflows/continue-change.js +232 -0
  64. package/dist/core/templates/workflows/explore.d.ts +10 -0
  65. package/dist/core/templates/workflows/explore.js +461 -0
  66. package/dist/core/templates/workflows/feedback.d.ts +9 -0
  67. package/dist/core/templates/workflows/feedback.js +108 -0
  68. package/dist/core/templates/workflows/ff-change.d.ts +10 -0
  69. package/dist/core/templates/workflows/ff-change.js +198 -0
  70. package/dist/core/templates/workflows/new-change.d.ts +10 -0
  71. package/dist/core/templates/workflows/new-change.js +143 -0
  72. package/dist/core/templates/workflows/onboard.d.ts +10 -0
  73. package/dist/core/templates/workflows/onboard.js +565 -0
  74. package/dist/core/templates/workflows/propose.d.ts +10 -0
  75. package/dist/core/templates/workflows/propose.js +216 -0
  76. package/dist/core/templates/workflows/sync-specs.d.ts +10 -0
  77. package/dist/core/templates/workflows/sync-specs.js +272 -0
  78. package/dist/core/templates/workflows/verify-change.d.ts +10 -0
  79. package/dist/core/templates/workflows/verify-change.js +332 -0
  80. package/dist/core/update.d.ts +36 -1
  81. package/dist/core/update.js +291 -65
  82. package/dist/prompts/searchable-multi-select.d.ts +3 -2
  83. package/dist/prompts/searchable-multi-select.js +22 -12
  84. package/package.json +1 -1
  85. package/scripts/postinstall.js +8 -72
@@ -8,15 +8,18 @@ type InitCommandOptions = {
8
8
  tools?: string;
9
9
  force?: boolean;
10
10
  interactive?: boolean;
11
+ profile?: string;
11
12
  };
12
13
  export declare class InitCommand {
13
14
  private readonly toolsArg?;
14
15
  private readonly force;
15
16
  private readonly interactiveOption?;
17
+ private readonly profileOverride?;
16
18
  constructor(options?: InitCommandOptions);
17
19
  execute(targetPath: string): Promise<void>;
18
20
  private validate;
19
21
  private canPromptInteractively;
22
+ private resolveProfileOverride;
20
23
  private handleLegacyCleanup;
21
24
  private performLegacyCleanup;
22
25
  private getSelectedTools;
@@ -27,6 +30,8 @@ export declare class InitCommand {
27
30
  private createConfig;
28
31
  private displaySuccessMessage;
29
32
  private startSpinner;
33
+ private removeSkillDirs;
34
+ private removeCommandFiles;
30
35
  }
31
36
  export {};
32
37
  //# sourceMappingURL=init.d.ts.map
package/dist/core/init.js CHANGED
@@ -18,6 +18,10 @@ import { serializeConfig } from './config-prompts.js';
18
18
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
19
19
  import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, } from './legacy-cleanup.js';
20
20
  import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, } from './shared/index.js';
21
+ import { getGlobalConfig } from './global-config.js';
22
+ import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
23
+ import { getAvailableTools } from './available-tools.js';
24
+ import { migrateIfNeeded } from './migration.js';
21
25
  const require = createRequire(import.meta.url);
22
26
  const { version: OPENSPEC_VERSION } = require('../../package.json');
23
27
  // -----------------------------------------------------------------------------
@@ -28,6 +32,19 @@ const PROGRESS_SPINNER = {
28
32
  interval: 80,
29
33
  frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
30
34
  };
35
+ const WORKFLOW_TO_SKILL_DIR = {
36
+ 'explore': 'openspec-explore',
37
+ 'new': 'openspec-new-change',
38
+ 'continue': 'openspec-continue-change',
39
+ 'apply': 'openspec-apply-change',
40
+ 'ff': 'openspec-ff-change',
41
+ 'sync': 'openspec-sync-specs',
42
+ 'archive': 'openspec-archive-change',
43
+ 'bulk-archive': 'openspec-bulk-archive-change',
44
+ 'verify': 'openspec-verify-change',
45
+ 'onboard': 'openspec-onboard',
46
+ 'propose': 'openspec-propose',
47
+ };
31
48
  // -----------------------------------------------------------------------------
32
49
  // Init Command Class
33
50
  // -----------------------------------------------------------------------------
@@ -35,10 +52,12 @@ export class InitCommand {
35
52
  toolsArg;
36
53
  force;
37
54
  interactiveOption;
55
+ profileOverride;
38
56
  constructor(options = {}) {
39
57
  this.toolsArg = options.tools;
40
58
  this.force = options.force ?? false;
41
59
  this.interactiveOption = options.interactive;
60
+ this.profileOverride = options.profile;
42
61
  }
43
62
  async execute(targetPath) {
44
63
  const projectPath = path.resolve(targetPath);
@@ -48,16 +67,25 @@ export class InitCommand {
48
67
  const extendMode = await this.validate(projectPath, openspecPath);
49
68
  // Check for legacy artifacts and handle cleanup
50
69
  await this.handleLegacyCleanup(projectPath, extendMode);
70
+ // Detect available tools in the project (task 7.1)
71
+ const detectedTools = getAvailableTools(projectPath);
72
+ // Migration check: migrate existing projects to profile system (task 7.3)
73
+ if (extendMode) {
74
+ migrateIfNeeded(projectPath, detectedTools);
75
+ }
51
76
  // Show animated welcome screen (interactive mode only)
52
77
  const canPrompt = this.canPromptInteractively();
53
78
  if (canPrompt) {
54
79
  const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
55
80
  await showWelcomeScreen();
56
81
  }
82
+ // Validate profile override early so invalid values fail before tool setup.
83
+ // The resolved value is consumed later when generation reads effective config.
84
+ this.resolveProfileOverride();
57
85
  // Get tool states before processing
58
86
  const toolStates = getToolStates(projectPath);
59
- // Get tool selection
60
- const selectedToolIds = await this.getSelectedTools(toolStates, extendMode);
87
+ // Get tool selection (pass detected tools for pre-selection)
88
+ const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);
61
89
  // Validate selected tools
62
90
  const validatedTools = this.validateTools(selectedToolIds, toolStates);
63
91
  // Create directory structure and config
@@ -87,6 +115,15 @@ export class InitCommand {
87
115
  return false;
88
116
  return isInteractive({ interactive: this.interactiveOption });
89
117
  }
118
+ resolveProfileOverride() {
119
+ if (this.profileOverride === undefined) {
120
+ return undefined;
121
+ }
122
+ if (this.profileOverride === 'core' || this.profileOverride === 'custom') {
123
+ return this.profileOverride;
124
+ }
125
+ throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`);
126
+ }
90
127
  // ═══════════════════════════════════════════════════════════
91
128
  // LEGACY CLEANUP
92
129
  // ═══════════════════════════════════════════════════════════
@@ -101,17 +138,13 @@ export class InitCommand {
101
138
  console.log(formatDetectionSummary(detection));
102
139
  console.log();
103
140
  const canPrompt = this.canPromptInteractively();
104
- if (this.force) {
105
- // --force flag: proceed with cleanup automatically
141
+ if (this.force || !canPrompt) {
142
+ // --force flag or non-interactive mode: proceed with cleanup automatically.
143
+ // Legacy slash commands are 100% OpenSpec-managed, and config file cleanup
144
+ // only removes markers (never deletes files), so auto-cleanup is safe.
106
145
  await this.performLegacyCleanup(projectPath, detection);
107
146
  return;
108
147
  }
109
- if (!canPrompt) {
110
- // Non-interactive mode without --force: abort
111
- console.log(chalk.red('Legacy files detected in non-interactive mode.'));
112
- console.log(chalk.dim('Run interactively to upgrade, or use --force to auto-cleanup.'));
113
- process.exit(1);
114
- }
115
148
  // Interactive mode: prompt for confirmation
116
149
  const { confirm } = await import('@inquirer/prompts');
117
150
  const shouldCleanup = await confirm({
@@ -139,40 +172,73 @@ export class InitCommand {
139
172
  // ═══════════════════════════════════════════════════════════
140
173
  // TOOL SELECTION
141
174
  // ═══════════════════════════════════════════════════════════
142
- async getSelectedTools(toolStates, extendMode) {
175
+ async getSelectedTools(toolStates, extendMode, detectedTools, projectPath) {
143
176
  // Check for --tools flag first
144
177
  const nonInteractiveSelection = this.resolveToolsArg();
145
178
  if (nonInteractiveSelection !== null) {
146
179
  return nonInteractiveSelection;
147
180
  }
148
181
  const validTools = getToolsWithSkillsDir();
182
+ const detectedToolIds = new Set(detectedTools.map((t) => t.value));
183
+ const configuredToolIds = new Set([...toolStates.entries()]
184
+ .filter(([, status]) => status.configured)
185
+ .map(([toolId]) => toolId));
186
+ const shouldPreselectDetected = !extendMode && configuredToolIds.size === 0;
149
187
  const canPrompt = this.canPromptInteractively();
150
- if (!canPrompt || validTools.length === 0) {
151
- throw new Error(`Missing required option --tools. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`);
188
+ // Non-interactive mode: use detected tools as fallback (task 7.8)
189
+ if (!canPrompt) {
190
+ if (detectedToolIds.size > 0) {
191
+ return [...detectedToolIds];
192
+ }
193
+ throw new Error(`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`);
194
+ }
195
+ if (validTools.length === 0) {
196
+ throw new Error(`No tools available for skill generation.`);
152
197
  }
153
198
  // Interactive mode: show searchable multi-select
154
199
  const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');
155
- // Build choices with configured status and sort configured tools first
200
+ // Build choices: pre-select configured tools; keep detected tools visible but unselected.
156
201
  const sortedChoices = validTools
157
202
  .map((toolId) => {
158
203
  const tool = AI_TOOLS.find((t) => t.value === toolId);
159
204
  const status = toolStates.get(toolId);
160
205
  const configured = status?.configured ?? false;
206
+ const detected = detectedToolIds.has(toolId);
161
207
  return {
162
208
  name: tool?.name || toolId,
163
209
  value: toolId,
164
210
  configured,
165
- preSelected: configured, // Pre-select configured tools for easy refresh
211
+ detected: detected && !configured,
212
+ preSelected: configured || (shouldPreselectDetected && detected && !configured),
166
213
  };
167
214
  })
168
215
  .sort((a, b) => {
169
- // Configured tools first
216
+ // Configured tools first, then detected (not configured), then everything else.
170
217
  if (a.configured && !b.configured)
171
218
  return -1;
172
219
  if (!a.configured && b.configured)
173
220
  return 1;
221
+ if (a.detected && !b.detected)
222
+ return -1;
223
+ if (!a.detected && b.detected)
224
+ return 1;
174
225
  return 0;
175
226
  });
227
+ const configuredNames = validTools
228
+ .filter((toolId) => configuredToolIds.has(toolId))
229
+ .map((toolId) => AI_TOOLS.find((t) => t.value === toolId)?.name || toolId);
230
+ if (configuredNames.length > 0) {
231
+ console.log(`OpenSpec configured: ${configuredNames.join(', ')} (pre-selected)`);
232
+ }
233
+ const detectedOnlyNames = detectedTools
234
+ .filter((tool) => !configuredToolIds.has(tool.value))
235
+ .map((tool) => tool.name);
236
+ if (detectedOnlyNames.length > 0) {
237
+ const detectionLabel = shouldPreselectDetected
238
+ ? 'pre-selected for first-time setup'
239
+ : 'not pre-selected';
240
+ console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
241
+ }
176
242
  const selectedTools = await searchableMultiSelect({
177
243
  message: `Select tools to set up (${validTools.length} available)`,
178
244
  pageSize: 15,
@@ -288,37 +354,58 @@ export class InitCommand {
288
354
  const refreshedTools = [];
289
355
  const failedTools = [];
290
356
  const commandsSkipped = [];
291
- // Get skill and command templates once (shared across all tools)
292
- const skillTemplates = getSkillTemplates();
293
- const commandContents = getCommandContents();
357
+ let removedCommandCount = 0;
358
+ let removedSkillCount = 0;
359
+ // Read global config for profile and delivery settings (use --profile override if set)
360
+ const globalConfig = getGlobalConfig();
361
+ const profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core';
362
+ const delivery = globalConfig.delivery ?? 'both';
363
+ const workflows = getProfileWorkflows(profile, globalConfig.workflows);
364
+ // Get skill and command templates filtered by profile workflows
365
+ const shouldGenerateSkills = delivery !== 'commands';
366
+ const shouldGenerateCommands = delivery !== 'skills';
367
+ const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : [];
368
+ const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : [];
294
369
  // Process each tool
295
370
  for (const tool of tools) {
296
371
  const spinner = ora(`Setting up ${tool.name}...`).start();
297
372
  try {
298
- // Use tool-specific skillsDir
299
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
300
- // Create skill directories and SKILL.md files
301
- for (const { template, dirName } of skillTemplates) {
302
- const skillDir = path.join(skillsDir, dirName);
303
- const skillFile = path.join(skillDir, 'SKILL.md');
304
- // Generate SKILL.md content with YAML frontmatter including generatedBy
305
- // Use hyphen-based command references for OpenCode
306
- const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
307
- const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
308
- // Write the skill file
309
- await FileSystemUtils.writeFile(skillFile, skillContent);
373
+ // Generate skill files if delivery includes skills
374
+ if (shouldGenerateSkills) {
375
+ // Use tool-specific skillsDir
376
+ const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
377
+ // Create skill directories and SKILL.md files
378
+ for (const { template, dirName } of skillTemplates) {
379
+ const skillDir = path.join(skillsDir, dirName);
380
+ const skillFile = path.join(skillDir, 'SKILL.md');
381
+ // Generate SKILL.md content with YAML frontmatter including generatedBy
382
+ // Use hyphen-based command references for tools where filename = command name
383
+ const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
384
+ const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
385
+ // Write the skill file
386
+ await FileSystemUtils.writeFile(skillFile, skillContent);
387
+ }
310
388
  }
311
- // Generate commands using the adapter system
312
- const adapter = CommandAdapterRegistry.get(tool.value);
313
- if (adapter) {
314
- const generatedCommands = generateCommands(commandContents, adapter);
315
- for (const cmd of generatedCommands) {
316
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
317
- await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
389
+ if (!shouldGenerateSkills) {
390
+ const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
391
+ removedSkillCount += await this.removeSkillDirs(skillsDir);
392
+ }
393
+ // Generate commands if delivery includes commands
394
+ if (shouldGenerateCommands) {
395
+ const adapter = CommandAdapterRegistry.get(tool.value);
396
+ if (adapter) {
397
+ const generatedCommands = generateCommands(commandContents, adapter);
398
+ for (const cmd of generatedCommands) {
399
+ const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
400
+ await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
401
+ }
402
+ }
403
+ else {
404
+ commandsSkipped.push(tool.value);
318
405
  }
319
406
  }
320
- else {
321
- commandsSkipped.push(tool.value);
407
+ if (!shouldGenerateCommands) {
408
+ removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
322
409
  }
323
410
  spinner.succeed(`Setup complete for ${tool.name}`);
324
411
  if (tool.wasConfigured) {
@@ -333,7 +420,14 @@ export class InitCommand {
333
420
  failedTools.push({ name: tool.name, error: error });
334
421
  }
335
422
  }
336
- return { createdTools, refreshedTools, failedTools, commandsSkipped };
423
+ return {
424
+ createdTools,
425
+ refreshedTools,
426
+ failedTools,
427
+ commandsSkipped,
428
+ removedCommandCount,
429
+ removedSkillCount,
430
+ };
337
431
  }
338
432
  // ═══════════════════════════════════════════════════════════
339
433
  // CONFIG FILE
@@ -373,16 +467,24 @@ export class InitCommand {
373
467
  if (results.refreshedTools.length > 0) {
374
468
  console.log(`Refreshed: ${results.refreshedTools.map((t) => t.name).join(', ')}`);
375
469
  }
376
- // Show counts
470
+ // Show counts (respecting profile filter)
377
471
  const successfulTools = [...results.createdTools, ...results.refreshedTools];
378
472
  if (successfulTools.length > 0) {
473
+ const globalConfig = getGlobalConfig();
474
+ const profile = this.profileOverride ?? globalConfig.profile ?? 'core';
475
+ const delivery = globalConfig.delivery ?? 'both';
476
+ const workflows = getProfileWorkflows(profile, globalConfig.workflows);
379
477
  const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
380
- const hasCommands = results.commandsSkipped.length < successfulTools.length;
381
- if (hasCommands) {
382
- console.log(`${getSkillTemplates().length} skills and ${getCommandContents().length} commands in ${toolDirs}/`);
478
+ const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0;
479
+ const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0;
480
+ if (skillCount > 0 && commandCount > 0) {
481
+ console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
482
+ }
483
+ else if (skillCount > 0) {
484
+ console.log(`${skillCount} skills in ${toolDirs}/`);
383
485
  }
384
- else {
385
- console.log(`${getSkillTemplates().length} skills in ${toolDirs}/`);
486
+ else if (commandCount > 0) {
487
+ console.log(`${commandCount} commands in ${toolDirs}/`);
386
488
  }
387
489
  }
388
490
  // Show failures
@@ -393,6 +495,12 @@ export class InitCommand {
393
495
  if (results.commandsSkipped.length > 0) {
394
496
  console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`));
395
497
  }
498
+ if (results.removedCommandCount > 0) {
499
+ console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
500
+ }
501
+ if (results.removedSkillCount > 0) {
502
+ console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
503
+ }
396
504
  // Config status
397
505
  if (configStatus === 'created') {
398
506
  console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);
@@ -407,12 +515,22 @@ export class InitCommand {
407
515
  else {
408
516
  console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
409
517
  }
410
- // Getting started
518
+ // Getting started (task 7.6: show propose if in profile)
519
+ const globalCfg = getGlobalConfig();
520
+ const activeProfile = this.profileOverride ?? globalCfg.profile ?? 'core';
521
+ const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
411
522
  console.log();
412
- console.log(chalk.bold('Getting started:'));
413
- console.log(' /opsx:new Start a new change');
414
- console.log(' /opsx:continue Create the next artifact');
415
- console.log(' /opsx:apply Implement tasks');
523
+ if (activeWorkflows.includes('propose')) {
524
+ console.log(chalk.bold('Getting started:'));
525
+ console.log(' Start your first change: /opsx:propose "your idea"');
526
+ }
527
+ else if (activeWorkflows.includes('new')) {
528
+ console.log(chalk.bold('Getting started:'));
529
+ console.log(' Start your first change: /opsx:new "your idea"');
530
+ }
531
+ else {
532
+ console.log("Done. Run 'openspec config profile' to configure your workflows.");
533
+ }
416
534
  // Links
417
535
  console.log();
418
536
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
@@ -432,5 +550,44 @@ export class InitCommand {
432
550
  spinner: PROGRESS_SPINNER,
433
551
  }).start();
434
552
  }
553
+ async removeSkillDirs(skillsDir) {
554
+ let removed = 0;
555
+ for (const workflow of ALL_WORKFLOWS) {
556
+ const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
557
+ if (!dirName)
558
+ continue;
559
+ const skillDir = path.join(skillsDir, dirName);
560
+ try {
561
+ if (fs.existsSync(skillDir)) {
562
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
563
+ removed++;
564
+ }
565
+ }
566
+ catch {
567
+ // Ignore errors
568
+ }
569
+ }
570
+ return removed;
571
+ }
572
+ async removeCommandFiles(projectPath, toolId) {
573
+ let removed = 0;
574
+ const adapter = CommandAdapterRegistry.get(toolId);
575
+ if (!adapter)
576
+ return 0;
577
+ for (const workflow of ALL_WORKFLOWS) {
578
+ const cmdPath = adapter.getFilePath(workflow);
579
+ const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
580
+ try {
581
+ if (fs.existsSync(fullPath)) {
582
+ await fs.promises.unlink(fullPath);
583
+ removed++;
584
+ }
585
+ }
586
+ catch {
587
+ // Ignore errors
588
+ }
589
+ }
590
+ return removed;
591
+ }
435
592
  }
436
593
  //# sourceMappingURL=init.js.map
@@ -19,7 +19,7 @@ export declare const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashComma
19
19
  export interface LegacySlashCommandPattern {
20
20
  type: 'directory' | 'files';
21
21
  path?: string;
22
- pattern?: string;
22
+ pattern?: string | string[];
23
23
  }
24
24
  /**
25
25
  * Result of legacy artifact detection
@@ -30,6 +30,7 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
30
30
  'claude': { type: 'directory', path: '.claude/commands/openspec' },
31
31
  'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
32
32
  'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
33
+ 'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
33
34
  'crush': { type: 'directory', path: '.crush/commands/openspec' },
34
35
  'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
35
36
  'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
@@ -37,16 +38,18 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
37
38
  'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
38
39
  'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
39
40
  'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
41
+ 'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
40
42
  'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
41
43
  'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
42
44
  'cline': { type: 'files', pattern: '.clinerules/workflows/openspec-*.md' },
43
45
  'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
44
46
  'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
45
47
  'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
46
- 'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
48
+ 'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
47
49
  'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
48
50
  'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
49
51
  'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
52
+ 'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
50
53
  'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
51
54
  'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
52
55
  };
@@ -131,8 +134,11 @@ export async function detectLegacySlashCommands(projectPath) {
131
134
  }
132
135
  else if (pattern.type === 'files' && pattern.pattern) {
133
136
  // For file-based patterns, check for individual files
134
- const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
135
- files.push(...foundFiles);
137
+ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
138
+ for (const p of patterns) {
139
+ const foundFiles = await findLegacySlashCommandFiles(projectPath, p);
140
+ files.push(...foundFiles);
141
+ }
136
142
  }
137
143
  }
138
144
  return { directories, files };
@@ -465,14 +471,21 @@ export function getToolsFromLegacyArtifacts(detection) {
465
471
  if (pattern.type === 'files' && pattern.pattern) {
466
472
  // Convert glob pattern to regex for matching
467
473
  // e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
468
- const regexPattern = pattern.pattern
469
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
470
- .replace(/\*/g, '.*'); // Replace * with .*
471
- const regex = new RegExp(`^${regexPattern}$`);
472
- if (regex.test(normalizedFile)) {
473
- tools.add(toolId);
474
- break;
474
+ const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
475
+ let matched = false;
476
+ for (const p of patterns) {
477
+ const regexPattern = p
478
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
479
+ .replace(/\*/g, '.*'); // Replace * with .*
480
+ const regex = new RegExp(`^${regexPattern}$`);
481
+ if (regex.test(normalizedFile)) {
482
+ tools.add(toolId);
483
+ matched = true;
484
+ break;
485
+ }
475
486
  }
487
+ if (matched)
488
+ break;
476
489
  }
477
490
  }
478
491
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Migration Utilities
3
+ *
4
+ * One-time migration logic for existing projects when profile system is introduced.
5
+ * Called by both init and update commands before profile resolution.
6
+ */
7
+ import type { AIToolOption } from './config.js';
8
+ /**
9
+ * Scans installed workflow files across all detected tools and returns
10
+ * the union of installed workflow IDs.
11
+ */
12
+ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
13
+ /**
14
+ * Performs one-time migration if the global config does not yet have a profile field.
15
+ * Called by both init and update before profile resolution.
16
+ *
17
+ * - If no profile field exists and workflows are installed: sets profile to 'custom'
18
+ * with the detected workflows, preserving the user's existing setup.
19
+ * - If no profile field exists and no workflows are installed: no-op (defaults apply).
20
+ * - If profile field already exists: no-op.
21
+ */
22
+ export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
23
+ //# sourceMappingURL=migration.d.ts.map
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Migration Utilities
3
+ *
4
+ * One-time migration logic for existing projects when profile system is introduced.
5
+ * Called by both init and update commands before profile resolution.
6
+ */
7
+ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js';
8
+ import { CommandAdapterRegistry } from './command-generation/index.js';
9
+ import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
10
+ import { ALL_WORKFLOWS } from './profiles.js';
11
+ import path from 'path';
12
+ import * as fs from 'fs';
13
+ function scanInstalledWorkflowArtifacts(projectPath, tools) {
14
+ const installed = new Set();
15
+ let hasSkills = false;
16
+ let hasCommands = false;
17
+ for (const tool of tools) {
18
+ if (!tool.skillsDir)
19
+ continue;
20
+ const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
21
+ for (const workflowId of ALL_WORKFLOWS) {
22
+ const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId];
23
+ const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md');
24
+ if (fs.existsSync(skillFile)) {
25
+ installed.add(workflowId);
26
+ hasSkills = true;
27
+ }
28
+ }
29
+ const adapter = CommandAdapterRegistry.get(tool.value);
30
+ if (!adapter)
31
+ continue;
32
+ for (const workflowId of ALL_WORKFLOWS) {
33
+ const commandPath = adapter.getFilePath(workflowId);
34
+ const fullPath = path.isAbsolute(commandPath)
35
+ ? commandPath
36
+ : path.join(projectPath, commandPath);
37
+ if (fs.existsSync(fullPath)) {
38
+ installed.add(workflowId);
39
+ hasCommands = true;
40
+ }
41
+ }
42
+ }
43
+ return {
44
+ workflows: ALL_WORKFLOWS.filter((workflowId) => installed.has(workflowId)),
45
+ hasSkills,
46
+ hasCommands,
47
+ };
48
+ }
49
+ /**
50
+ * Scans installed workflow files across all detected tools and returns
51
+ * the union of installed workflow IDs.
52
+ */
53
+ export function scanInstalledWorkflows(projectPath, tools) {
54
+ return scanInstalledWorkflowArtifacts(projectPath, tools).workflows;
55
+ }
56
+ function inferDelivery(artifacts) {
57
+ if (artifacts.hasSkills && artifacts.hasCommands) {
58
+ return 'both';
59
+ }
60
+ if (artifacts.hasCommands) {
61
+ return 'commands';
62
+ }
63
+ return 'skills';
64
+ }
65
+ /**
66
+ * Performs one-time migration if the global config does not yet have a profile field.
67
+ * Called by both init and update before profile resolution.
68
+ *
69
+ * - If no profile field exists and workflows are installed: sets profile to 'custom'
70
+ * with the detected workflows, preserving the user's existing setup.
71
+ * - If no profile field exists and no workflows are installed: no-op (defaults apply).
72
+ * - If profile field already exists: no-op.
73
+ */
74
+ export function migrateIfNeeded(projectPath, tools) {
75
+ const config = getGlobalConfig();
76
+ // Check raw config file for profile field presence
77
+ const configPath = getGlobalConfigPath();
78
+ let rawConfig = {};
79
+ try {
80
+ if (fs.existsSync(configPath)) {
81
+ rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
82
+ }
83
+ }
84
+ catch {
85
+ return; // Can't read config, skip migration
86
+ }
87
+ // If profile is already explicitly set, no migration needed
88
+ if (rawConfig.profile !== undefined) {
89
+ return;
90
+ }
91
+ // Scan for installed workflows
92
+ const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools);
93
+ const installedWorkflows = artifacts.workflows;
94
+ if (installedWorkflows.length === 0) {
95
+ // No workflows installed, new user — defaults will apply
96
+ return;
97
+ }
98
+ // Migrate: set profile to custom with detected workflows
99
+ config.profile = 'custom';
100
+ config.workflows = installedWorkflows;
101
+ if (rawConfig.delivery === undefined) {
102
+ config.delivery = inferDelivery(artifacts);
103
+ }
104
+ saveGlobalConfig(config);
105
+ console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`);
106
+ console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience.");
107
+ }
108
+ //# sourceMappingURL=migration.js.map