@fission-ai/openspec 1.6.0 → 1.8.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 (208) hide show
  1. package/README.md +31 -2
  2. package/dist/cli/index.js +79 -8
  3. package/dist/commands/change.d.ts +0 -1
  4. package/dist/commands/change.js +105 -51
  5. package/dist/commands/config.js +14 -8
  6. package/dist/commands/doctor.js +9 -4
  7. package/dist/commands/feedback.js +74 -23
  8. package/dist/commands/schema.js +110 -31
  9. package/dist/commands/show.js +5 -2
  10. package/dist/commands/spec.js +49 -29
  11. package/dist/commands/validate.js +20 -5
  12. package/dist/commands/workflow/index.d.ts +1 -1
  13. package/dist/commands/workflow/index.js +1 -1
  14. package/dist/commands/workflow/instructions.d.ts +7 -1
  15. package/dist/commands/workflow/instructions.js +121 -33
  16. package/dist/commands/workflow/shared.d.ts +13 -2
  17. package/dist/commands/workflow/shared.js +34 -4
  18. package/dist/commands/workflow/status.js +9 -4
  19. package/dist/commands/workflow/templates.js +15 -5
  20. package/dist/core/archive.d.ts +14 -1
  21. package/dist/core/archive.js +1332 -155
  22. package/dist/core/artifact-graph/graph.d.ts +16 -0
  23. package/dist/core/artifact-graph/graph.js +30 -6
  24. package/dist/core/artifact-graph/index.d.ts +1 -1
  25. package/dist/core/artifact-graph/index.js +1 -1
  26. package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
  27. package/dist/core/artifact-graph/instruction-loader.js +70 -12
  28. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  29. package/dist/core/artifact-graph/outputs.js +67 -5
  30. package/dist/core/artifact-graph/resolver.d.ts +14 -0
  31. package/dist/core/artifact-graph/resolver.js +69 -15
  32. package/dist/core/artifact-graph/types.js +21 -3
  33. package/dist/core/available-tools.d.ts +2 -2
  34. package/dist/core/available-tools.js +14 -3
  35. package/dist/core/change-metadata/schema.d.ts +2 -0
  36. package/dist/core/change-metadata/schema.js +13 -0
  37. package/dist/core/change-status-policy.d.ts +1 -1
  38. package/dist/core/change-status-policy.js +2 -2
  39. package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
  40. package/dist/core/command-generation/adapters/amazon-q.js +7 -1
  41. package/dist/core/command-generation/adapters/antigravity.js +2 -1
  42. package/dist/core/command-generation/adapters/auggie.js +2 -1
  43. package/dist/core/command-generation/adapters/bob.d.ts +5 -1
  44. package/dist/core/command-generation/adapters/bob.js +6 -5
  45. package/dist/core/command-generation/adapters/claude.js +1 -8
  46. package/dist/core/command-generation/adapters/codebuddy.js +3 -2
  47. package/dist/core/command-generation/adapters/continue.js +3 -2
  48. package/dist/core/command-generation/adapters/costrict.js +2 -1
  49. package/dist/core/command-generation/adapters/crush.js +5 -5
  50. package/dist/core/command-generation/adapters/cursor.js +2 -2
  51. package/dist/core/command-generation/adapters/devin.d.ts +19 -0
  52. package/dist/core/command-generation/adapters/devin.js +36 -0
  53. package/dist/core/command-generation/adapters/factory.js +2 -1
  54. package/dist/core/command-generation/adapters/gemini.js +37 -2
  55. package/dist/core/command-generation/adapters/github-copilot.js +2 -1
  56. package/dist/core/command-generation/adapters/iflow.js +5 -4
  57. package/dist/core/command-generation/adapters/index.d.ts +2 -2
  58. package/dist/core/command-generation/adapters/index.js +2 -2
  59. package/dist/core/command-generation/adapters/junie.js +2 -1
  60. package/dist/core/command-generation/adapters/kiro.js +2 -1
  61. package/dist/core/command-generation/adapters/lingma.js +5 -5
  62. package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
  63. package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
  64. package/dist/core/command-generation/adapters/opencode.js +3 -5
  65. package/dist/core/command-generation/adapters/pi.d.ts +2 -2
  66. package/dist/core/command-generation/adapters/pi.js +3 -6
  67. package/dist/core/command-generation/adapters/qoder.js +5 -5
  68. package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
  69. package/dist/core/command-generation/adapters/qwen.js +12 -7
  70. package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
  71. package/dist/core/command-generation/adapters/roocode.js +4 -4
  72. package/dist/core/command-generation/adapters/trae.js +1 -21
  73. package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
  74. package/dist/core/command-generation/adapters/zcode.js +33 -0
  75. package/dist/core/command-generation/generator.d.ts +8 -0
  76. package/dist/core/command-generation/generator.js +15 -1
  77. package/dist/core/command-generation/index.d.ts +1 -1
  78. package/dist/core/command-generation/index.js +1 -1
  79. package/dist/core/command-generation/invocation.d.ts +73 -0
  80. package/dist/core/command-generation/invocation.js +77 -0
  81. package/dist/core/command-generation/registry.js +4 -4
  82. package/dist/core/command-generation/types.d.ts +9 -2
  83. package/dist/core/command-generation/yaml.d.ts +20 -6
  84. package/dist/core/command-generation/yaml.js +31 -21
  85. package/dist/core/command-surface.d.ts +15 -0
  86. package/dist/core/command-surface.js +33 -0
  87. package/dist/core/completions/command-registry.js +16 -2
  88. package/dist/core/completions/generators/powershell-generator.js +15 -8
  89. package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
  90. package/dist/core/completions/installers/zsh-installer.js +20 -4
  91. package/dist/core/config-prompts.d.ts +1 -1
  92. package/dist/core/config-prompts.js +14 -1
  93. package/dist/core/config-schema.d.ts +10 -1
  94. package/dist/core/config-schema.js +67 -1
  95. package/dist/core/config.d.ts +15 -0
  96. package/dist/core/config.js +45 -5
  97. package/dist/core/file-state.js +56 -24
  98. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  99. package/dist/core/github-copilot/cloud-agent.js +497 -0
  100. package/dist/core/global-config.d.ts +16 -0
  101. package/dist/core/init.d.ts +58 -0
  102. package/dist/core/init.js +450 -69
  103. package/dist/core/legacy-cleanup.d.ts +59 -0
  104. package/dist/core/legacy-cleanup.js +253 -24
  105. package/dist/core/list.js +5 -7
  106. package/dist/core/migration.d.ts +73 -1
  107. package/dist/core/migration.js +360 -11
  108. package/dist/core/onboarding-commands.d.ts +30 -0
  109. package/dist/core/onboarding-commands.js +38 -0
  110. package/dist/core/parsers/change-parser.d.ts +17 -2
  111. package/dist/core/parsers/change-parser.js +33 -20
  112. package/dist/core/parsers/code-fence.d.ts +15 -0
  113. package/dist/core/parsers/code-fence.js +49 -0
  114. package/dist/core/parsers/markdown-parser.js +2 -1
  115. package/dist/core/parsers/requirement-blocks.d.ts +18 -0
  116. package/dist/core/parsers/requirement-blocks.js +120 -35
  117. package/dist/core/parsers/requirement-text.d.ts +1 -8
  118. package/dist/core/parsers/requirement-text.js +4 -42
  119. package/dist/core/parsers/spec-structure.d.ts +1 -1
  120. package/dist/core/parsers/spec-structure.js +20 -30
  121. package/dist/core/profile-sync-drift.d.ts +0 -8
  122. package/dist/core/profile-sync-drift.js +48 -49
  123. package/dist/core/project-config.d.ts +28 -6
  124. package/dist/core/project-config.js +108 -8
  125. package/dist/core/references.js +42 -4
  126. package/dist/core/relationship-health.d.ts +8 -0
  127. package/dist/core/relationship-health.js +13 -0
  128. package/dist/core/root-selection.d.ts +9 -6
  129. package/dist/core/root-selection.js +42 -5
  130. package/dist/core/shared/index.d.ts +1 -0
  131. package/dist/core/shared/index.js +1 -0
  132. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  133. package/dist/core/shared/skill-content-equivalence.js +41 -0
  134. package/dist/core/shared/skill-paths.d.ts +13 -0
  135. package/dist/core/shared/skill-paths.js +23 -0
  136. package/dist/core/shared/tool-detection.d.ts +25 -5
  137. package/dist/core/shared/tool-detection.js +166 -36
  138. package/dist/core/shared-skill-target.d.ts +16 -0
  139. package/dist/core/shared-skill-target.js +156 -0
  140. package/dist/core/specs-apply.d.ts +80 -33
  141. package/dist/core/specs-apply.js +659 -171
  142. package/dist/core/store/git.d.ts +13 -0
  143. package/dist/core/store/git.js +24 -0
  144. package/dist/core/templates/skill-templates.d.ts +1 -1
  145. package/dist/core/templates/skill-templates.js +1 -1
  146. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  147. package/dist/core/templates/workflows/apply-change.js +48 -158
  148. package/dist/core/templates/workflows/archive-change.js +170 -42
  149. package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
  150. package/dist/core/templates/workflows/continue-change.js +38 -46
  151. package/dist/core/templates/workflows/explore.js +54 -18
  152. package/dist/core/templates/workflows/ff-change.js +46 -30
  153. package/dist/core/templates/workflows/new-change.js +2 -2
  154. package/dist/core/templates/workflows/onboard.js +13 -7
  155. package/dist/core/templates/workflows/propose.js +116 -46
  156. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  157. package/dist/core/templates/workflows/store-selection.js +1 -1
  158. package/dist/core/templates/workflows/sync-specs.js +260 -32
  159. package/dist/core/templates/workflows/update-change.js +26 -16
  160. package/dist/core/templates/workflows/verify-change.js +16 -10
  161. package/dist/core/update.d.ts +40 -5
  162. package/dist/core/update.js +389 -93
  163. package/dist/core/validation/constants.d.ts +4 -1
  164. package/dist/core/validation/constants.js +4 -1
  165. package/dist/core/validation/validator.d.ts +22 -8
  166. package/dist/core/validation/validator.js +222 -50
  167. package/dist/core/version-check.d.ts +152 -0
  168. package/dist/core/version-check.js +731 -0
  169. package/dist/core/view.js +12 -17
  170. package/dist/prompts/searchable-multi-select.js +1 -1
  171. package/dist/telemetry/config.d.ts +3 -4
  172. package/dist/telemetry/index.d.ts +10 -5
  173. package/dist/telemetry/index.js +75 -41
  174. package/dist/ui/welcome-screen.d.ts +9 -1
  175. package/dist/ui/welcome-screen.js +91 -38
  176. package/dist/utils/change-metadata.d.ts +43 -0
  177. package/dist/utils/change-metadata.js +123 -7
  178. package/dist/utils/change-utils.d.ts +10 -4
  179. package/dist/utils/change-utils.js +20 -13
  180. package/dist/utils/ci.d.ts +12 -0
  181. package/dist/utils/ci.js +16 -0
  182. package/dist/utils/command-references.d.ts +92 -6
  183. package/dist/utils/command-references.js +180 -7
  184. package/dist/utils/date.d.ts +8 -0
  185. package/dist/utils/date.js +13 -0
  186. package/dist/utils/file-system.d.ts +10 -0
  187. package/dist/utils/file-system.js +70 -0
  188. package/dist/utils/index.d.ts +1 -1
  189. package/dist/utils/index.js +1 -1
  190. package/dist/utils/interactive.d.ts +23 -0
  191. package/dist/utils/interactive.js +32 -0
  192. package/dist/utils/item-discovery.d.ts +19 -0
  193. package/dist/utils/item-discovery.js +30 -48
  194. package/dist/utils/shell-detection.d.ts +1 -1
  195. package/dist/utils/shell-detection.js +64 -11
  196. package/dist/utils/spec-discovery.d.ts +40 -0
  197. package/dist/utils/spec-discovery.js +115 -0
  198. package/dist/utils/task-progress.d.ts +17 -0
  199. package/dist/utils/task-progress.js +41 -13
  200. package/package.json +14 -12
  201. package/schemas/spec-driven/schema.yaml +67 -10
  202. package/schemas/spec-driven/templates/design.md +2 -2
  203. package/schemas/spec-driven/templates/proposal.md +10 -4
  204. package/schemas/spec-driven/templates/spec.md +3 -0
  205. package/dist/core/command-generation/adapters/codex.d.ts +0 -16
  206. package/dist/core/command-generation/adapters/codex.js +0 -39
  207. package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
  208. package/dist/core/command-generation/adapters/windsurf.js +0 -38
package/dist/core/init.js CHANGED
@@ -12,18 +12,21 @@ import { createRequire } from 'module';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
13
  import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
14
14
  import { findRepoPlanningRootSync } from './planning-home.js';
15
- import { transformToHyphenCommands } from '../utils/command-references.js';
16
- import { AI_TOOLS, OPENSPEC_DIR_NAME, } from './config.js';
15
+ import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
16
+ import { AI_TOOLS, OPENSPEC_DIR_NAME, resolveToolIdAlias, } from './config.js';
17
17
  import { PALETTE } from './styles/palette.js';
18
18
  import { isInteractive } from '../utils/interactive.js';
19
19
  import { serializeConfig } from './config-prompts.js';
20
20
  import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
21
- import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, } from './legacy-cleanup.js';
22
- import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, } from './shared/index.js';
21
+ import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDeferredGlobalPromptSummary, formatDetectionSummary, getLegacyGlobalPromptMatches, omitGlobalLegacyPromptFiles, pickGlobalLegacyPromptFiles, } from './legacy-cleanup.js';
22
+ import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/index.js';
23
23
  import { getGlobalConfig } from './global-config.js';
24
24
  import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
25
25
  import { getAvailableTools } from './available-tools.js';
26
- import { migrateIfNeeded } from './migration.js';
26
+ import { writeSharedSkillTarget } from './shared-skill-target.js';
27
+ import { migrateIfNeeded, migrateLegacyToolDirs, describeLegacyMigration, keptInPlaceNotice, hasMovableContent, scanInstalledWorkflows as scanInstalledWorkflowsShared } from './migration.js';
28
+ import { resolveCommandSurfaceCapability, resolveCommandInvocation, shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
29
+ import { writeCopilotCloudFiles, readCopilotCloudOptIn, hasExistingManagedCloudFiles, persistCopilotCloudOptIn, removeCopilotCloudFiles, findUnmanagedCloudFiles, listManagedCloudFiles, } from './github-copilot/cloud-agent.js';
27
30
  const require = createRequire(import.meta.url);
28
31
  const { version: OPENSPEC_VERSION } = require('../../package.json');
29
32
  // -----------------------------------------------------------------------------
@@ -56,11 +59,15 @@ export class InitCommand {
56
59
  force;
57
60
  interactiveOption;
58
61
  profileOverride;
62
+ animation;
63
+ copilotCloudOption;
59
64
  constructor(options = {}) {
60
65
  this.toolsArg = options.tools;
61
66
  this.force = options.force ?? false;
62
67
  this.interactiveOption = options.interactive;
63
68
  this.profileOverride = options.profile;
69
+ this.animation = options.animation ?? true;
70
+ this.copilotCloudOption = options.copilotCloud;
64
71
  }
65
72
  async execute(targetPath) {
66
73
  const projectPath = path.resolve(targetPath);
@@ -91,36 +98,104 @@ export class InitCommand {
91
98
  }
92
99
  }
93
100
  // Check for legacy artifacts and handle cleanup
94
- await this.handleLegacyCleanup(projectPath, extendMode);
101
+ const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);
102
+ // Migrate OpenSpec-managed skills left in renamed tool directories
103
+ // (e.g. .kimi -> .kimi-code) before detection so they stay recognized.
104
+ migrateLegacyToolDirs(projectPath);
95
105
  // Detect available tools in the project (task 7.1)
96
106
  const detectedTools = getAvailableTools(projectPath);
97
107
  // Migration check: migrate existing projects to profile system (task 7.3)
98
108
  if (extendMode) {
99
109
  migrateIfNeeded(projectPath, detectedTools);
100
110
  }
111
+ // Validate profile override early so invalid values fail before tool setup.
112
+ // The resolved value is consumed later when generation reads effective config.
113
+ // This runs ahead of the welcome screen so an invalid --profile does not make
114
+ // the user press Enter before seeing the error.
115
+ this.resolveProfileOverride();
101
116
  // Show animated welcome screen (interactive mode only)
102
117
  const canPrompt = this.canPromptInteractively();
103
118
  if (canPrompt) {
104
119
  const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
105
- await showWelcomeScreen();
120
+ await showWelcomeScreen(this.getActiveWorkflows(), { animate: this.animation });
106
121
  }
107
- // Validate profile override early so invalid values fail before tool setup.
108
- // The resolved value is consumed later when generation reads effective config.
109
- this.resolveProfileOverride();
110
122
  // Get tool states before processing
111
123
  const toolStates = getToolStates(projectPath);
112
124
  // Get tool selection (pass detected tools for pre-selection)
113
125
  const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);
114
126
  // Validate selected tools
115
- const validatedTools = this.validateTools(selectedToolIds, toolStates);
127
+ const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath);
128
+ // Selecting a renamed tool is consent to leave its former directory:
129
+ // init is about to write the current one, and leaving OpenSpec content
130
+ // behind would give the user two installs of the same tool.
131
+ for (const migration of migrateLegacyToolDirs(projectPath, validatedTools.map((tool) => tool.value))) {
132
+ if (hasMovableContent(migration)) {
133
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
134
+ }
135
+ const kept = keptInPlaceNotice(migration);
136
+ if (kept)
137
+ console.log(chalk.dim(kept));
138
+ }
139
+ // Decide whether to generate GitHub Copilot cloud files. This is opt-in
140
+ // (see cloud-agent.ts): selecting the Copilot tool no longer silently
141
+ // writes a GitHub Actions workflow into the user's .github/. The decision
142
+ // is made before generation so the write can be gated, and persisted after
143
+ // config.yaml exists so future non-interactive updates honor it.
144
+ const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools);
116
145
  // Create directory structure and config
117
146
  await this.createDirectoryStructure(openspecPath, extendMode);
118
147
  // Generate skills and commands for each tool
119
- const results = await this.generateSkillsAndCommands(projectPath, validatedTools);
148
+ const results = await this.generateSkillsAndCommands(projectPath, validatedTools, copilotDecision.write);
149
+ // Legacy cleanup was deferred to avoid interfering with skill/command generation;
150
+ // now that outputs are written, finalize the cleanup (e.g. remove stale files).
151
+ if (deferredLegacyCleanup) {
152
+ await this.finalizeDeferredLegacyCleanup(projectPath, deferredLegacyCleanup);
153
+ }
120
154
  // Create config.yaml if needed
121
155
  const configStatus = await this.createConfig(openspecPath, extendMode);
156
+ // Persist an explicit Copilot cloud decision so `openspec update` (which
157
+ // never prompts) honors it. Best-effort: a config-write failure must not
158
+ // fail an otherwise-successful init.
159
+ if (copilotDecision.persist !== undefined) {
160
+ try {
161
+ await persistCopilotCloudOptIn(projectPath, copilotDecision.persist);
162
+ }
163
+ catch {
164
+ // Non-fatal: the files (if any) were still written correctly.
165
+ }
166
+ }
167
+ // An explicit opt-out means "no cloud files here": clean up any that a
168
+ // previous run (or an older OpenSpec) generated. Only OpenSpec-managed
169
+ // files are removed — a user-customized file is preserved.
170
+ let copilotRemoved = 0;
171
+ if (copilotDecision.optedOut) {
172
+ try {
173
+ copilotRemoved = await removeCopilotCloudFiles(projectPath);
174
+ }
175
+ catch {
176
+ // Non-fatal: removal targets files from a prior run; a failure here
177
+ // just leaves them for the next `openspec update` to clean up.
178
+ }
179
+ }
180
+ // Report the cloud outcome from what is actually on disk after the write,
181
+ // not from the decision alone: writing over a user-owned file is a no-op,
182
+ // and the alternate-agent path can remove a managed file — so list only
183
+ // managed files that exist, and separately flag any left-untouched ones.
184
+ const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some((tool) => tool.value === 'github-copilot');
185
+ const wroteCloud = copilotDecision.write && copilotSucceeded;
186
+ const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : [];
187
+ const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : [];
122
188
  // Display success message
123
- this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);
189
+ this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, {
190
+ write: copilotDecision.write,
191
+ skippedUndecided: copilotDecision.skippedUndecided,
192
+ present: copilotPresent,
193
+ collisions: copilotCollisions,
194
+ removed: copilotRemoved,
195
+ });
196
+ if (results.failedTools.length > 0) {
197
+ throw new Error(`OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}`);
198
+ }
124
199
  }
125
200
  // ═══════════════════════════════════════════════════════════
126
201
  // VALIDATION & SETUP
@@ -140,6 +215,59 @@ export class InitCommand {
140
215
  return false;
141
216
  return isInteractive({ interactive: this.interactiveOption });
142
217
  }
218
+ /**
219
+ * Decide whether to generate GitHub Copilot cloud files, and whether to
220
+ * persist that decision. Precedence:
221
+ * 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run)
222
+ * 2. persisted opt-in in config.yaml
223
+ * 3. managed files already present (migration for pre-opt-in projects)
224
+ * 4. interactive confirm (default No)
225
+ * 5. non-interactive with no signal: skip, and don't persist a default
226
+ *
227
+ * @returns `write` — generate the files this run; `persist` — value to write
228
+ * back to config (undefined = leave config untouched); `optedOut` — the user
229
+ * explicitly declined, so any already-generated managed files should be
230
+ * removed; `skippedUndecided` — selected but no signal and couldn't ask, so
231
+ * the caller can hint that the opt-in exists.
232
+ */
233
+ async resolveCopilotCloudDecision(projectPath, tools) {
234
+ const copilotSelected = tools.some((tool) => tool.value === 'github-copilot');
235
+ if (!copilotSelected) {
236
+ // A flag that can't apply is a likely mistake — say so rather than no-op.
237
+ if (this.copilotCloudOption !== undefined) {
238
+ console.log(chalk.yellow('--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.'));
239
+ }
240
+ return { write: false, optedOut: false, skippedUndecided: false };
241
+ }
242
+ if (this.copilotCloudOption !== undefined) {
243
+ return {
244
+ write: this.copilotCloudOption,
245
+ persist: this.copilotCloudOption,
246
+ optedOut: !this.copilotCloudOption,
247
+ skippedUndecided: false,
248
+ };
249
+ }
250
+ const persistedOptIn = readCopilotCloudOptIn(projectPath);
251
+ if (typeof persistedOptIn === 'boolean') {
252
+ return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false };
253
+ }
254
+ if (await hasExistingManagedCloudFiles(projectPath)) {
255
+ return { write: true, optedOut: false, skippedUndecided: false };
256
+ }
257
+ if (this.canPromptInteractively()) {
258
+ const { confirm } = await import('@inquirer/prompts');
259
+ const answer = await confirm({
260
+ message: 'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' +
261
+ 'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' +
262
+ '.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.',
263
+ default: false,
264
+ });
265
+ return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false };
266
+ }
267
+ // Non-interactive with no explicit signal: don't write, and leave the
268
+ // decision unpersisted so a later interactive run can still prompt.
269
+ return { write: false, optedOut: false, skippedUndecided: true };
270
+ }
143
271
  resolveProfileOverride() {
144
272
  if (this.profileOverride === undefined) {
145
273
  return undefined;
@@ -149,26 +277,50 @@ export class InitCommand {
149
277
  }
150
278
  throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`);
151
279
  }
280
+ /**
281
+ * Resolves the workflows the effective profile installs, so onboarding output
282
+ * only mentions commands that will actually exist.
283
+ */
284
+ getActiveWorkflows() {
285
+ const globalCfg = getGlobalConfig();
286
+ const activeProfile = this.resolveProfileOverride() ?? globalCfg.profile ?? 'core';
287
+ return [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
288
+ }
152
289
  // ═══════════════════════════════════════════════════════════
153
290
  // LEGACY CLEANUP
154
291
  // ═══════════════════════════════════════════════════════════
292
+ /**
293
+ * Cleans repo-local legacy artifacts immediately and defers global Codex prompt
294
+ * cleanup until replacement skills have been installed.
295
+ */
155
296
  async handleLegacyCleanup(projectPath, extendMode) {
156
297
  // Detect legacy artifacts
157
298
  const detection = await detectLegacyArtifacts(projectPath);
158
299
  if (!detection.hasLegacyArtifacts) {
159
- return; // No legacy artifacts found
300
+ return null; // No legacy artifacts found
160
301
  }
302
+ const immediateDetection = omitGlobalLegacyPromptFiles(detection);
161
303
  // Show what was detected
162
- console.log();
163
- console.log(formatDetectionSummary(detection));
164
- console.log();
304
+ const immediateSummary = formatDetectionSummary(immediateDetection);
305
+ if (immediateSummary) {
306
+ console.log();
307
+ console.log(immediateSummary);
308
+ console.log();
309
+ }
310
+ // Show which global prompts are deferred — they'll only be removed once
311
+ // the corresponding replacement skills are installed during generation.
312
+ const deferredSummary = formatDeferredGlobalPromptSummary(detection);
313
+ if (deferredSummary) {
314
+ console.log(deferredSummary);
315
+ console.log();
316
+ }
165
317
  const canPrompt = this.canPromptInteractively();
166
318
  if (this.force || !canPrompt) {
167
319
  // --force flag or non-interactive mode: proceed with cleanup automatically.
168
320
  // Legacy slash commands are 100% OpenSpec-managed, and config file cleanup
169
321
  // only removes markers (never deletes files), so auto-cleanup is safe.
170
- await this.performLegacyCleanup(projectPath, detection);
171
- return;
322
+ await this.performImmediateLegacyCleanup(projectPath, detection);
323
+ return detection.globalSlashCommandFiles.length > 0 ? { detection } : null;
172
324
  }
173
325
  // Interactive mode: prompt for confirmation
174
326
  const { confirm } = await import('@inquirer/prompts');
@@ -181,7 +333,51 @@ export class InitCommand {
181
333
  console.log(chalk.dim('Run with --force to skip this prompt, or manually remove legacy files.'));
182
334
  process.exit(0);
183
335
  }
184
- await this.performLegacyCleanup(projectPath, detection);
336
+ await this.performImmediateLegacyCleanup(projectPath, detection);
337
+ return detection.globalSlashCommandFiles.length > 0 ? { detection } : null;
338
+ }
339
+ /**
340
+ * Applies the safe subset of legacy cleanup that does not depend on newly
341
+ * generated Codex skills.
342
+ */
343
+ async performImmediateLegacyCleanup(projectPath, detection) {
344
+ const immediateDetection = omitGlobalLegacyPromptFiles(detection);
345
+ if (!immediateDetection.hasLegacyArtifacts) {
346
+ return;
347
+ }
348
+ await this.performLegacyCleanup(projectPath, immediateDetection);
349
+ }
350
+ /**
351
+ * Removes only the legacy global Codex prompts whose workflows now have
352
+ * replacement skills in the project.
353
+ */
354
+ async finalizeDeferredLegacyCleanup(projectPath, deferredCleanup) {
355
+ const availableCodexWorkflows = await this.getInstalledWorkflowsForTool(projectPath, 'codex');
356
+ const removableMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection)
357
+ .filter((prompt) => prompt.workflowIds.every((workflowId) => availableCodexWorkflows.has(workflowId)));
358
+ if (removableMatches.length > 0) {
359
+ await this.performLegacyCleanup(projectPath, pickGlobalLegacyPromptFiles(deferredCleanup.detection, removableMatches.map((prompt) => prompt.path)));
360
+ }
361
+ const blockedMatches = getLegacyGlobalPromptMatches(deferredCleanup.detection)
362
+ .filter((prompt) => !removableMatches.some((match) => match.path === prompt.path));
363
+ if (blockedMatches.length > 0) {
364
+ console.log(chalk.yellow('Preserved deferred global prompts without replacement skills:'));
365
+ for (const prompt of blockedMatches) {
366
+ console.log(chalk.dim(` - ${prompt.toolId}: ${prompt.path}`));
367
+ }
368
+ console.log();
369
+ }
370
+ }
371
+ /**
372
+ * Reads the currently installed workflow IDs for a single tool from the
373
+ * generated skill layout on disk.
374
+ */
375
+ async getInstalledWorkflowsForTool(projectPath, toolId) {
376
+ const tool = AI_TOOLS.find((candidate) => candidate.value === toolId);
377
+ if (!tool) {
378
+ return new Set();
379
+ }
380
+ return new Set(scanInstalledWorkflowsShared(projectPath, [tool]));
185
381
  }
186
382
  async performLegacyCleanup(projectPath, detection) {
187
383
  const spinner = ora('Cleaning up legacy files...').start();
@@ -300,7 +496,9 @@ export class InitCommand {
300
496
  if (tokens.length === 0) {
301
497
  throw new Error('The --tools option requires at least one tool ID when not using "all" or "none".');
302
498
  }
303
- const normalizedTokens = tokens.map((token) => token.toLowerCase());
499
+ // Retired ids resolve to their current tool, so a rebrand does not break
500
+ // an existing `--tools windsurf` in someone's setup script.
501
+ const normalizedTokens = tokens.map((token) => resolveToolIdAlias(token.toLowerCase()));
304
502
  if (normalizedTokens.some((token) => token === 'all' || token === 'none')) {
305
503
  throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.');
306
504
  }
@@ -317,23 +515,34 @@ export class InitCommand {
317
515
  }
318
516
  return deduped;
319
517
  }
320
- validateTools(toolIds, toolStates) {
518
+ validateTools(toolIds, toolStates, projectPath) {
321
519
  const validatedTools = [];
322
- for (const toolId of toolIds) {
520
+ const reconciledToolIds = toolIds.includes('codex') && toolIds.includes('agents')
521
+ ? toolIds.filter((toolId) => toolId !== 'agents')
522
+ : toolIds;
523
+ if (reconciledToolIds.length !== toolIds.length) {
524
+ console.log(chalk.dim('Codex and agents share .agents/skills; writing one tree with Codex and generic skill references.'));
525
+ }
526
+ for (const toolId of reconciledToolIds) {
323
527
  const tool = AI_TOOLS.find((t) => t.value === toolId);
324
528
  if (!tool) {
325
529
  const validToolIds = getToolsWithSkillsDir();
326
530
  throw new Error(`Unknown tool '${toolId}'. Valid tools:\n ${validToolIds.join('\n ')}`);
327
531
  }
328
- if (!tool.skillsDir) {
532
+ if (!toolSupportsSkills(tool)) {
329
533
  const validToolsWithSkills = getToolsWithSkillsDir();
330
534
  throw new Error(`Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`);
331
535
  }
332
536
  const preState = toolStates.get(tool.value);
537
+ const skillsPath = resolveToolSkillsDir(projectPath, tool);
538
+ const isGlobalSkillTarget = hasGlobalSkillTarget(tool);
333
539
  validatedTools.push({
334
540
  value: tool.value,
335
541
  name: tool.name,
336
542
  skillsDir: tool.skillsDir,
543
+ skillsPath,
544
+ skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath,
545
+ isGlobalSkillTarget,
337
546
  wasConfigured: preState?.configured ?? false,
338
547
  });
339
548
  }
@@ -352,6 +561,7 @@ export class InitCommand {
352
561
  path.join(openspecPath, 'changes', 'archive'),
353
562
  ];
354
563
  for (const dir of directories) {
564
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
355
565
  await FileSystemUtils.createDirectory(dir);
356
566
  }
357
567
  return;
@@ -364,6 +574,7 @@ export class InitCommand {
364
574
  path.join(openspecPath, 'changes', 'archive'),
365
575
  ];
366
576
  for (const dir of directories) {
577
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), dir);
367
578
  await FileSystemUtils.createDirectory(dir);
368
579
  }
369
580
  spinner.stopAndPersist({
@@ -374,11 +585,20 @@ export class InitCommand {
374
585
  // ═══════════════════════════════════════════════════════════
375
586
  // SKILL & COMMAND GENERATION
376
587
  // ═══════════════════════════════════════════════════════════
377
- async generateSkillsAndCommands(projectPath, tools) {
588
+ /**
589
+ * Generates skill files and slash commands for each selected tool,
590
+ * honoring the configured delivery mode (skills, commands, or both).
591
+ *
592
+ * @param projectPath - Absolute path to the project root
593
+ * @param tools - Selected tools with their skill directory metadata
594
+ * @returns Created, refreshed, and failed tools plus removed artifact counts
595
+ */
596
+ async generateSkillsAndCommands(projectPath, tools, writeCopilotCloud) {
378
597
  const createdTools = [];
379
598
  const refreshedTools = [];
380
599
  const failedTools = [];
381
600
  const commandsSkipped = [];
601
+ const skillsInvocableCommandSkips = [];
382
602
  let removedCommandCount = 0;
383
603
  let removedSkillCount = 0;
384
604
  // Read global config for profile and delivery settings (use --profile override if set)
@@ -387,33 +607,35 @@ export class InitCommand {
387
607
  const delivery = globalConfig.delivery ?? 'both';
388
608
  const workflows = getProfileWorkflows(profile, globalConfig.workflows);
389
609
  // Get skill and command templates filtered by profile workflows
390
- const shouldGenerateSkills = delivery !== 'commands';
391
- const shouldGenerateCommands = delivery !== 'skills';
392
- const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : [];
393
- const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : [];
610
+ const deliveryIncludesCommands = delivery !== 'skills';
611
+ const skillTemplates = getSkillTemplates(workflows);
612
+ const commandContents = getCommandContents(workflows);
394
613
  // Process each tool
395
614
  for (const tool of tools) {
396
615
  const spinner = ora(`Setting up ${tool.name}...`).start();
397
616
  try {
398
- // Generate skill files if delivery includes skills
617
+ const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
618
+ const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);
619
+ // Generate skill files if the selected delivery and tool capability allow skills
399
620
  if (shouldGenerateSkills) {
400
- // Use tool-specific skillsDir
401
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
402
621
  // Create skill directories and SKILL.md files
403
622
  for (const { template, dirName } of skillTemplates) {
404
- const skillDir = path.join(skillsDir, dirName);
623
+ const skillDir = path.join(tool.skillsPath, dirName);
405
624
  const skillFile = path.join(skillDir, 'SKILL.md');
406
625
  // Generate SKILL.md content with YAML frontmatter including generatedBy
407
- // Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
408
- const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
626
+ const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
409
627
  const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
410
628
  // Write the skill file
629
+ FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile);
411
630
  await FileSystemUtils.writeFile(skillFile, skillContent);
412
631
  }
632
+ writeSharedSkillTarget(projectPath, tool.value);
413
633
  }
414
- if (!shouldGenerateSkills) {
415
- const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
416
- removedSkillCount += await this.removeSkillDirs(skillsDir);
634
+ if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) {
635
+ removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath);
636
+ // Retain an explicit selection even when this delivery mode produces
637
+ // no skills, so a divergent legacy sibling cannot reclaim ownership.
638
+ writeSharedSkillTarget(projectPath, tool.value);
417
639
  }
418
640
  // Generate commands if delivery includes commands
419
641
  if (shouldGenerateCommands) {
@@ -421,17 +643,25 @@ export class InitCommand {
421
643
  if (adapter) {
422
644
  const generatedCommands = generateCommands(commandContents, adapter);
423
645
  for (const cmd of generatedCommands) {
424
- const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
646
+ const commandFile = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmd.path);
425
647
  await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
426
648
  }
427
649
  }
650
+ }
651
+ else if (deliveryIncludesCommands) {
652
+ if (resolveCommandSurfaceCapability(tool.value) === 'skills-invocable') {
653
+ skillsInvocableCommandSkips.push(tool.value);
654
+ }
428
655
  else {
429
656
  commandsSkipped.push(tool.value);
430
657
  }
431
658
  }
432
- if (!shouldGenerateCommands) {
659
+ if (shouldReconcileCommandFilesForTool(tool.value, delivery)) {
433
660
  removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
434
661
  }
662
+ if (tool.value === 'github-copilot' && writeCopilotCloud) {
663
+ await writeCopilotCloudFiles(projectPath);
664
+ }
435
665
  spinner.succeed(`Setup complete for ${tool.name}`);
436
666
  if (tool.wasConfigured) {
437
667
  refreshedTools.push(tool);
@@ -445,11 +675,22 @@ export class InitCommand {
445
675
  failedTools.push({ name: tool.name, error: error });
446
676
  }
447
677
  }
678
+ for (const tool of [...createdTools, ...refreshedTools]) {
679
+ for (const migration of migrateLegacyToolDirs(projectPath, [tool.value], 'after-generation')) {
680
+ if (hasMovableContent(migration)) {
681
+ console.log(chalk.dim(`Migrated ${describeLegacyMigration(migration)}: ${migration.from} → ${migration.to}`));
682
+ }
683
+ const kept = keptInPlaceNotice(migration);
684
+ if (kept)
685
+ console.log(chalk.dim(kept));
686
+ }
687
+ }
448
688
  return {
449
689
  createdTools,
450
690
  refreshedTools,
451
691
  failedTools,
452
692
  commandsSkipped,
693
+ skillsInvocableCommandSkips,
453
694
  removedCommandCount,
454
695
  removedSkillCount,
455
696
  };
@@ -467,6 +708,7 @@ export class InitCommand {
467
708
  }
468
709
  try {
469
710
  const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
711
+ FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
470
712
  await FileSystemUtils.writeFile(configPath, yamlContent);
471
713
  return 'created';
472
714
  }
@@ -477,9 +719,9 @@ export class InitCommand {
477
719
  // ═══════════════════════════════════════════════════════════
478
720
  // UI & OUTPUT
479
721
  // ═══════════════════════════════════════════════════════════
480
- displaySuccessMessage(projectPath, tools, results, configStatus) {
722
+ displaySuccessMessage(projectPath, tools, results, configStatus, copilot) {
481
723
  console.log();
482
- console.log(chalk.bold('OpenSpec Setup Complete'));
724
+ console.log(chalk.bold(results.failedTools.length > 0 ? 'OpenSpec Setup Incomplete' : 'OpenSpec Setup Complete'));
483
725
  console.log();
484
726
  // Show created vs refreshed tools
485
727
  if (results.createdTools.length > 0) {
@@ -495,17 +737,56 @@ export class InitCommand {
495
737
  const profile = this.profileOverride ?? globalConfig.profile ?? 'core';
496
738
  const delivery = globalConfig.delivery ?? 'both';
497
739
  const workflows = getProfileWorkflows(profile, globalConfig.workflows);
498
- const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
499
- const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0;
500
- const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0;
501
- if (skillCount > 0 && commandCount > 0) {
502
- console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
503
- }
504
- else if (skillCount > 0) {
505
- console.log(`${skillCount} skills in ${toolDirs}/`);
740
+ const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget);
741
+ if (!usesGlobalSkillTarget) {
742
+ const toolDirs = [
743
+ ...new Set(successfulTools
744
+ .map((tool) => tool.skillsDir)
745
+ .filter((skillsDir) => Boolean(skillsDir))),
746
+ ].join(', ');
747
+ const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery))
748
+ ? getSkillTemplates(workflows).length
749
+ : 0;
750
+ const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery))
751
+ ? getCommandContents(workflows).length
752
+ : 0;
753
+ if (skillCount > 0 && commandCount > 0) {
754
+ console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
755
+ }
756
+ else if (skillCount > 0) {
757
+ console.log(`${skillCount} skills in ${toolDirs}/`);
758
+ }
759
+ else if (commandCount > 0) {
760
+ console.log(`${commandCount} commands in ${toolDirs}/`);
761
+ }
506
762
  }
507
- else if (commandCount > 0) {
508
- console.log(`${commandCount} commands in ${toolDirs}/`);
763
+ else {
764
+ const skillTools = successfulTools.filter((tool) => shouldGenerateSkillsForTool(tool.value, delivery));
765
+ const skillCount = skillTools.length * getSkillTemplates(workflows).length;
766
+ if (skillCount > 0) {
767
+ const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))];
768
+ console.log(`${skillCount} skills in ${skillDirs.join(', ')}`);
769
+ }
770
+ const commandContents = getCommandContents(workflows);
771
+ const commandTools = successfulTools.filter((tool) => shouldGenerateCommandsForTool(tool.value, delivery));
772
+ const commandCount = commandTools.length * commandContents.length;
773
+ if (commandCount > 0) {
774
+ const commandDirs = [
775
+ ...new Set(commandTools.flatMap((tool) => {
776
+ const adapter = CommandAdapterRegistry.get(tool.value);
777
+ if (!adapter)
778
+ return [];
779
+ return commandContents.map((command) => {
780
+ const commandPath = adapter.getFilePath(command.id);
781
+ const absolutePath = path.isAbsolute(commandPath)
782
+ ? commandPath
783
+ : path.join(projectPath, commandPath);
784
+ return path.dirname(absolutePath);
785
+ });
786
+ })),
787
+ ];
788
+ console.log(`${commandCount} commands in ${commandDirs.join(', ')}`);
789
+ }
509
790
  }
510
791
  }
511
792
  // Show failures
@@ -516,12 +797,42 @@ export class InitCommand {
516
797
  if (results.commandsSkipped.length > 0) {
517
798
  console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`));
518
799
  }
800
+ if (results.skillsInvocableCommandSkips.length > 0) {
801
+ console.log(chalk.dim(`Commands skipped for: ${results.skillsInvocableCommandSkips.join(', ')} (uses skills)`));
802
+ }
519
803
  if (results.removedCommandCount > 0) {
520
804
  console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
521
805
  }
522
806
  if (results.removedSkillCount > 0) {
523
807
  console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
524
808
  }
809
+ // GitHub Copilot cloud files are opt-in — report what is actually on disk:
810
+ // list the managed files that now exist (never files we didn't write), flag
811
+ // any user-owned file we left untouched, note an opt-out cleanup, or (when
812
+ // skipped for want of a signal) say how to turn them on.
813
+ const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot');
814
+ if (copilotSucceeded && copilot.write) {
815
+ if (copilot.present.length > 0) {
816
+ console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`);
817
+ }
818
+ if (copilot.collisions.length > 0) {
819
+ console.log(chalk.dim(`Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` +
820
+ `install step by hand so the Copilot cloud agent can run openspec.`));
821
+ }
822
+ }
823
+ else if (copilotSucceeded && copilot.removed > 0) {
824
+ console.log(chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`));
825
+ }
826
+ else if (copilotSucceeded && copilot.skippedUndecided) {
827
+ console.log(chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'."));
828
+ }
829
+ // Show manual setup notes for tools that need extra configuration
830
+ for (const tool of successfulTools) {
831
+ const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote;
832
+ if (setupNote) {
833
+ console.log(chalk.yellow(`Setup required for ${tool.name}: ${setupNote}`));
834
+ }
835
+ }
525
836
  // Config status
526
837
  if (configStatus === 'created') {
527
838
  console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);
@@ -537,17 +848,80 @@ export class InitCommand {
537
848
  console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
538
849
  }
539
850
  // Getting started (task 7.6: show propose if in profile)
540
- const globalCfg = getGlobalConfig();
541
- const activeProfile = this.profileOverride ?? globalCfg.profile ?? 'core';
542
- const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
543
- console.log();
544
- if (activeWorkflows.includes('propose')) {
851
+ const activeWorkflows = this.getActiveWorkflows();
852
+ // When no tool got /opsx:* commands, point at the skill instead of a
853
+ // command that does not exist.
854
+ const activeDelivery = getGlobalConfig().delivery ?? 'both';
855
+ const commandsGenerated = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery));
856
+ const skillsGenerated = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery));
857
+ // Each hint line must be a usable instruction for the tool it serves.
858
+ // Tools that generated commands are told the command name their files
859
+ // answer to (/opsx:* when namespaced under opsx/, /opsx-* when the
860
+ // filename is the command); tools that only got skills are told their
861
+ // documented skill invocation (Kimi Code: /skill:openspec-*; Codex CLI:
862
+ // $openspec-*; others: /openspec-*). Tools that got no artifacts are
863
+ // covered by the configuration correction instead. When the selection
864
+ // disagrees, print one line per distinct instruction, labeled with the
865
+ // tools it applies to.
866
+ const startHintLines = (command) => {
867
+ const hintToTools = new Map();
868
+ for (const tool of successfulTools) {
869
+ let hint;
870
+ if (shouldGenerateCommandsForTool(tool.value, activeDelivery)) {
871
+ const transformer = getTransformerForTool(tool.value, activeDelivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value));
872
+ hint = `Start your first change: ${transformer ? transformer(command) : command} "your idea"`;
873
+ }
874
+ else if (shouldGenerateSkillsForTool(tool.value, activeDelivery)) {
875
+ const skillReference = getSkillReferenceTransformer(tool.value)(command);
876
+ // Tools with no slash surface (e.g. Rovo Dev) reference skills as
877
+ // prose ("the openspec-propose skill"); phrase the hint so it reads
878
+ // as an instruction rather than a dead command with an argument.
879
+ hint = usesNaturalLanguageSkillReferences(tool.value)
880
+ ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
881
+ : `Start your first change: ${skillReference} "your idea"`;
882
+ }
883
+ else {
884
+ continue;
885
+ }
886
+ hintToTools.set(hint, [...(hintToTools.get(hint) ?? []), tool.name]);
887
+ }
888
+ if (hintToTools.size === 0) {
889
+ // No successful tools: keep the generic command hint
890
+ return [`Start your first change: ${command} "your idea"`];
891
+ }
892
+ if (hintToTools.size === 1) {
893
+ return [[...hintToTools.keys()][0]];
894
+ }
895
+ return [...hintToTools.entries()].map(([hint, toolNames]) => `${hint} (${toolNames.join(', ')})`);
896
+ };
897
+ const printStartHints = (command) => {
545
898
  console.log(chalk.bold('Getting started:'));
546
- console.log(' Start your first change: /opsx:propose "your idea"');
899
+ for (const line of startHintLines(command)) {
900
+ console.log(` ${line}`);
901
+ }
902
+ };
903
+ console.log();
904
+ // delivery=commands with tools that only support skills: those tools get
905
+ // no artifacts at all, so print a per-tool configuration correction
906
+ // rather than leave them with a dead (or missing) instruction — even
907
+ // when other selected tools did get commands or skills.
908
+ const zeroArtifactTools = successfulTools.filter((tool) => !shouldGenerateSkillsForTool(tool.value, activeDelivery) &&
909
+ !shouldGenerateCommandsForTool(tool.value, activeDelivery));
910
+ if (zeroArtifactTools.length > 0) {
911
+ const names = zeroArtifactTools.map((tool) => tool.name).join(', ');
912
+ console.log(chalk.yellow(`No skills or commands were generated for ${names}: delivery is set to 'commands' but ` +
913
+ `${zeroArtifactTools.length === 1 ? 'it supports' : 'they support'} only skills. ` +
914
+ `Run 'openspec config set delivery both' to generate skills.`));
915
+ }
916
+ if (successfulTools.length > 0 && !commandsGenerated && !skillsGenerated) {
917
+ // Nothing was generated for any tool: the correction above is the
918
+ // whole story, so don't advertise an invocation that doesn't exist.
919
+ }
920
+ else if (activeWorkflows.includes('propose')) {
921
+ printStartHints('/opsx:propose');
547
922
  }
548
923
  else if (activeWorkflows.includes('new')) {
549
- console.log(chalk.bold('Getting started:'));
550
- console.log(' Start your first change: /opsx:new "your idea"');
924
+ printStartHints('/opsx:new');
551
925
  }
552
926
  else {
553
927
  console.log("Done. Run 'openspec config profile' to configure your workflows.");
@@ -556,10 +930,16 @@ export class InitCommand {
556
930
  console.log();
557
931
  console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
558
932
  console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
559
- // Restart instruction if any tools were configured
560
- if (results.createdTools.length > 0 || results.refreshedTools.length > 0) {
933
+ // Restart instruction if any tools were configured and got a surface
934
+ // (when nothing was generated there is nothing a restart would pick up);
935
+ // only mention commands when commands were actually generated. Not "slash
936
+ // commands": Amazon Q's generated files are prompt-library entries invoked
937
+ // with @, so a restart line promising slash commands would be wrong for it.
938
+ if ((results.createdTools.length > 0 || results.refreshedTools.length > 0) && (commandsGenerated || skillsGenerated)) {
561
939
  console.log();
562
- console.log(chalk.white('Restart your IDE for slash commands to take effect.'));
940
+ console.log(chalk.white(commandsGenerated
941
+ ? 'Restart your IDE for the new commands to take effect.'
942
+ : 'Restart your IDE for the new skills to take effect.'));
563
943
  }
564
944
  console.log();
565
945
  }
@@ -571,18 +951,19 @@ export class InitCommand {
571
951
  spinner: PROGRESS_SPINNER,
572
952
  }).start();
573
953
  }
574
- async removeSkillDirs(skillsDir) {
954
+ async removeSkillDirs(skillsRoot, skillsDir) {
575
955
  let removed = 0;
576
956
  for (const workflow of ALL_WORKFLOWS) {
577
957
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
578
958
  if (!dirName)
579
959
  continue;
580
960
  const skillDir = path.join(skillsDir, dirName);
961
+ if (!fs.existsSync(skillDir))
962
+ continue;
963
+ FileSystemUtils.assertPathWithin(skillsRoot, skillDir);
581
964
  try {
582
- if (fs.existsSync(skillDir)) {
583
- await fs.promises.rm(skillDir, { recursive: true, force: true });
584
- removed++;
585
- }
965
+ await fs.promises.rm(skillDir, { recursive: true, force: true });
966
+ removed++;
586
967
  }
587
968
  catch {
588
969
  // Ignore errors
@@ -597,7 +978,7 @@ export class InitCommand {
597
978
  return 0;
598
979
  for (const workflow of ALL_WORKFLOWS) {
599
980
  const cmdPath = adapter.getFilePath(workflow);
600
- const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
981
+ const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, cmdPath);
601
982
  try {
602
983
  if (fs.existsSync(fullPath)) {
603
984
  await fs.promises.unlink(fullPath);