@fission-ai/openspec 1.14.3 → 1.15.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 (48) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/feedback.js +33 -20
  4. package/dist/commands/install-standalone.d.ts +27 -0
  5. package/dist/commands/install-standalone.js +150 -0
  6. package/dist/commands/schema.js +10 -3
  7. package/dist/commands/validate.js +10 -3
  8. package/dist/commands/workflow/instructions.js +15 -4
  9. package/dist/commands/workflow/new-change.js +12 -4
  10. package/dist/commands/workflow/status.js +5 -2
  11. package/dist/commands/workflow/templates.js +5 -2
  12. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  13. package/dist/core/artifact-graph/resolver.js +11 -0
  14. package/dist/core/available-tools.d.ts +2 -1
  15. package/dist/core/available-tools.js +8 -28
  16. package/dist/core/completion-tip.d.ts +23 -0
  17. package/dist/core/completion-tip.js +4 -4
  18. package/dist/core/completions/command-registry.js +15 -0
  19. package/dist/core/config-schema.d.ts +1 -0
  20. package/dist/core/config-schema.js +1 -0
  21. package/dist/core/file-view.d.ts +13 -0
  22. package/dist/core/file-view.js +14 -0
  23. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  24. package/dist/core/github-copilot/cloud-agent.js +13 -9
  25. package/dist/core/global-config.d.ts +4 -0
  26. package/dist/core/global-config.js +5 -1
  27. package/dist/core/legacy-cleanup.d.ts +11 -1
  28. package/dist/core/legacy-cleanup.js +15 -18
  29. package/dist/core/migration.d.ts +27 -1
  30. package/dist/core/migration.js +77 -29
  31. package/dist/core/profile-sync-drift.d.ts +4 -3
  32. package/dist/core/profile-sync-drift.js +19 -16
  33. package/dist/core/shared/tool-detection.d.ts +12 -4
  34. package/dist/core/shared/tool-detection.js +29 -27
  35. package/dist/core/shared-skill-target.d.ts +12 -5
  36. package/dist/core/shared-skill-target.js +34 -23
  37. package/dist/core/standalone-tip.d.ts +6 -0
  38. package/dist/core/standalone-tip.js +38 -0
  39. package/dist/core/templates/workflows/verify-change.js +4 -4
  40. package/dist/core/update-plan.d.ts +127 -0
  41. package/dist/core/update-plan.js +390 -0
  42. package/dist/core/update.d.ts +45 -1
  43. package/dist/core/update.js +279 -137
  44. package/dist/core/version-check.d.ts +8 -1
  45. package/dist/core/version-check.js +48 -5
  46. package/dist/utils/executable-path.d.ts +5 -0
  47. package/dist/utils/executable-path.js +44 -0
  48. package/package.json +4 -3
@@ -17,11 +17,22 @@ export class SchemaLoadError extends Error {
17
17
  this.name = 'SchemaLoadError';
18
18
  }
19
19
  }
20
+ let packageSchemasDirOverride;
21
+ /**
22
+ * Points the built-in schemas at another directory. The standalone binary has
23
+ * no package directory on disk, so it passes a function that extracts its
24
+ * embedded schemas; it runs only when a command first needs them.
25
+ */
26
+ export function setPackageSchemasDir(resolveDir) {
27
+ packageSchemasDirOverride = resolveDir;
28
+ }
20
29
  /**
21
30
  * Gets the package's built-in schemas directory path.
22
31
  * Uses import.meta.url to resolve relative to the current module.
23
32
  */
24
33
  export function getPackageSchemasDir() {
34
+ if (packageSchemasDirOverride)
35
+ return packageSchemasDirOverride();
25
36
  const currentFile = fileURLToPath(import.meta.url);
26
37
  // Navigate from dist/core/artifact-graph/ to package root's schemas/
27
38
  return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
@@ -5,6 +5,7 @@
5
5
  * for their configuration directories.
6
6
  */
7
7
  import { type AIToolOption } from './config.js';
8
+ import { type FileView } from './file-view.js';
8
9
  /**
9
10
  * Scans the project path for AI tool configuration directories and returns
10
11
  * the tools that are present.
@@ -13,5 +14,5 @@ import { type AIToolOption } from './config.js';
13
14
  * directories). Otherwise checks the project's `skillsDir`, or managed skill
14
15
  * files in the user's home directory for a global skill target.
15
16
  */
16
- export declare function getAvailableTools(projectPath: string): AIToolOption[];
17
+ export declare function getAvailableTools(projectPath: string, view?: FileView): AIToolOption[];
17
18
  //# sourceMappingURL=available-tools.d.ts.map
@@ -5,11 +5,11 @@
5
5
  * for their configuration directories.
6
6
  */
7
7
  import path from 'path';
8
- import * as fs from 'fs';
9
8
  import { AI_TOOLS } from './config.js';
10
9
  import { reconcileSharedSkillTargets } from './shared-skill-target.js';
11
10
  import { SKILL_NAMES } from './shared/tool-detection.js';
12
11
  import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js';
12
+ import { DISK_VIEW } from './file-view.js';
13
13
  /**
14
14
  * Scans the project path for AI tool configuration directories and returns
15
15
  * the tools that are present.
@@ -18,49 +18,29 @@ import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.j
18
18
  * directories). Otherwise checks the project's `skillsDir`, or managed skill
19
19
  * files in the user's home directory for a global skill target.
20
20
  */
21
- export function getAvailableTools(projectPath) {
21
+ export function getAvailableTools(projectPath, view = DISK_VIEW) {
22
22
  const available = AI_TOOLS.filter((tool) => {
23
23
  if (!toolSupportsSkills(tool))
24
24
  return false;
25
25
  if (tool.globalSkillsDir) {
26
26
  const skillsDir = resolveToolSkillsDir(projectPath, tool);
27
- return SKILL_NAMES.some((skillName) => fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')));
27
+ return SKILL_NAMES.some((skillName) => view.exists(path.join(skillsDir, skillName, 'SKILL.md')));
28
28
  }
29
29
  if (!tool.skillsDir)
30
30
  return false;
31
31
  if (tool.detectionPaths && tool.detectionPaths.length > 0) {
32
- // statSync without .isDirectory() — detection paths can be files or directories
33
- return tool.detectionPaths.some((p) => {
34
- try {
35
- fs.statSync(path.join(projectPath, p));
36
- return true;
37
- }
38
- catch {
39
- return false;
40
- }
41
- });
42
- }
43
- const dirPath = path.join(projectPath, tool.skillsDir);
44
- try {
45
- return fs.statSync(dirPath).isDirectory();
46
- }
47
- catch {
48
- return false;
32
+ // Presence, not type — detection paths can be files or directories
33
+ return tool.detectionPaths.some((p) => view.exists(path.join(projectPath, p)));
49
34
  }
35
+ return view.isDirectory(path.join(projectPath, tool.skillsDir));
50
36
  });
51
- const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
37
+ const activeProjectTools = new Set(reconcileSharedSkillTargets(projectPath, available.filter((tool) => tool.skillsDir), view).map((tool) => tool.value));
52
38
  const hasIndependentDetectionPath = (tool) => (tool.detectionPaths ?? []).some((detectionPath) => {
53
39
  // Skill roots still go through managed-content reconciliation below;
54
40
  // their mere existence is not an independent tool signal.
55
41
  if (detectionPath.endsWith('/skills'))
56
42
  return false;
57
- try {
58
- fs.statSync(path.join(projectPath, detectionPath));
59
- return true;
60
- }
61
- catch {
62
- return false;
63
- }
43
+ return view.exists(path.join(projectPath, detectionPath));
64
44
  });
65
45
  return available.filter((tool) => tool.globalSkillsDir ||
66
46
  hasIndependentDetectionPath(tool) ||
@@ -7,6 +7,29 @@ export interface CompletionTipOptions {
7
7
  */
8
8
  silent?: boolean;
9
9
  }
10
+ /**
11
+ * Read the global config exactly as it sits on disk.
12
+ *
13
+ * Deliberately NOT `getGlobalConfig()`: that merges in defaults, and writing the
14
+ * merged result back would stamp `profile`/`delivery` into a file the user never
15
+ * set them in. `migrateIfNeeded` treats a raw `profile` as "already migrated",
16
+ * so that stamp would permanently suppress the one-time profile migration and
17
+ * cost users their installed workflow skills.
18
+ *
19
+ * Returns null when the file exists but cannot be read or parsed — a config we
20
+ * cannot understand is left strictly alone rather than overwritten.
21
+ */
22
+ export declare function readRawConfig(): Record<string, unknown> | null;
23
+ /**
24
+ * Record the flag, re-reading the config first and replacing the file by rename.
25
+ *
26
+ * Deciding whether to show the tip costs a `ps` spawn and a stat, and a sibling
27
+ * `openspec` process can write the same file in that window — on a first run
28
+ * that is exactly when telemetry mints `anonymousId`. Re-reading here keeps the
29
+ * write down to this one key, and the rename keeps a reader from ever seeing a
30
+ * half-written config.
31
+ */
32
+ export declare function markConfigFlag(key: string): Promise<void>;
10
33
  /**
11
34
  * Print the completion tip once, the first time the CLI runs.
12
35
  * Never throws — a hint must not break a command.
@@ -76,7 +76,7 @@ async function decideTip() {
76
76
  * Returns null when the file exists but cannot be read or parsed — a config we
77
77
  * cannot understand is left strictly alone rather than overwritten.
78
78
  */
79
- function readRawConfig() {
79
+ export function readRawConfig() {
80
80
  const configPath = getGlobalConfigPath();
81
81
  if (!fs.existsSync(configPath)) {
82
82
  return {};
@@ -96,13 +96,13 @@ function readRawConfig() {
96
96
  * write down to this one key, and the rename keeps a reader from ever seeing a
97
97
  * half-written config.
98
98
  */
99
- async function markTipSeen() {
99
+ export async function markConfigFlag(key) {
100
100
  const configPath = getGlobalConfigPath();
101
101
  const current = readRawConfig() ?? {};
102
102
  // The shared atomic writer: randomized temp name, owner-only mode, temp file
103
103
  // removed on failure. A predictable `<config>.<pid>.tmp` at the default mode
104
104
  // is both guessable and world-readable once renamed over the config.
105
- await writeFileAtomically(configPath, JSON.stringify({ ...current, completionTipSeen: true }, null, 2) + '\n');
105
+ await writeFileAtomically(configPath, JSON.stringify({ ...current, [key]: true }, null, 2) + '\n');
106
106
  }
107
107
  /**
108
108
  * Print the completion tip once, the first time the CLI runs.
@@ -123,7 +123,7 @@ export async function maybeShowCompletionTip(options = {}) {
123
123
  const decision = await decideTip();
124
124
  // Record before printing: if the flag cannot be persisted, staying quiet
125
125
  // beats reprinting the tip on every future run.
126
- await markTipSeen();
126
+ await markConfigFlag('completionTipSeen');
127
127
  if (decision === 'show') {
128
128
  console.error(`\n${COMPLETION_TIP_MESSAGE}`);
129
129
  }
@@ -52,8 +52,23 @@ export const COMMAND_REGISTRY = [
52
52
  name: 'force',
53
53
  description: 'Force update even when tools are up to date',
54
54
  },
55
+ {
56
+ name: 'dry-run',
57
+ description: 'Report what the update would change without writing anything',
58
+ },
59
+ {
60
+ name: 'json',
61
+ description: 'With --dry-run, print the planned changes as JSON',
62
+ },
55
63
  ],
56
64
  },
65
+ {
66
+ name: 'install',
67
+ description: 'Install the standalone openspec, which runs without Node.js (latest release unless [version] is given)',
68
+ acceptsPositional: true,
69
+ positionals: [{ name: 'version', optional: true }],
70
+ flags: [],
71
+ },
57
72
  {
58
73
  name: 'version',
59
74
  description: 'Report the installed OpenSpec version and update availability',
@@ -20,6 +20,7 @@ export declare const GlobalConfigSchema: z.ZodObject<{
20
20
  enabled: z.ZodOptional<z.ZodBoolean>;
21
21
  }, z.core.$loose>>;
22
22
  completionTipSeen: z.ZodOptional<z.ZodBoolean>;
23
+ standaloneTipSeen: z.ZodOptional<z.ZodBoolean>;
23
24
  }, z.core.$loose>;
24
25
  export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
25
26
  /**
@@ -34,6 +34,7 @@ export const GlobalConfigSchema = z
34
34
  .optional(),
35
35
  // Runtime-managed (like telemetry.noticeSeen); not user-settable via CLI set.
36
36
  completionTipSeen: z.boolean().optional(),
37
+ standaloneTipSeen: z.boolean().optional(),
37
38
  })
38
39
  .passthrough();
39
40
  /**
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How a check reads the files it inspects. Normally the disk. `update
3
+ * --dry-run` passes the tree as it has planned it, so a step that reads what
4
+ * an earlier step wrote sees the same thing it would in a real update.
5
+ */
6
+ export interface FileView {
7
+ exists(filePath: string): boolean;
8
+ /** Throws, like `fs.readFileSync`, when the file is absent. */
9
+ read(filePath: string): string;
10
+ isDirectory(filePath: string): boolean;
11
+ }
12
+ export declare const DISK_VIEW: FileView;
13
+ //# sourceMappingURL=file-view.d.ts.map
@@ -0,0 +1,14 @@
1
+ import * as fs from 'fs';
2
+ export const DISK_VIEW = {
3
+ exists: (filePath) => fs.existsSync(filePath),
4
+ read: (filePath) => fs.readFileSync(filePath, 'utf-8'),
5
+ isDirectory: (filePath) => {
6
+ try {
7
+ return fs.statSync(filePath).isDirectory();
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ },
13
+ };
14
+ //# sourceMappingURL=file-view.js.map
@@ -28,6 +28,14 @@ export declare const COPILOT_CLOUD_FILES: {
28
28
  readonly setupSteps: string;
29
29
  readonly agent: string;
30
30
  };
31
+ /**
32
+ * Every change the cloud-file sync makes on disk. `update --dry-run` passes a
33
+ * recorder, so the plan comes from this same code rather than a description.
34
+ */
35
+ export interface CopilotCloudFileOps {
36
+ writeFile(filePath: string, content: string): Promise<void>;
37
+ removeFile(filePath: string): Promise<void>;
38
+ }
31
39
  /**
32
40
  * Reconcile Copilot cloud agent files in the project directory.
33
41
  * Creates missing files and refreshes recognized legacy generated files while
@@ -35,7 +43,7 @@ export declare const COPILOT_CLOUD_FILES: {
35
43
  *
36
44
  * @returns Object indicating which files were written.
37
45
  */
38
- export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
46
+ export declare function writeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<{
39
47
  setupStepsWritten: boolean;
40
48
  agentWritten: boolean;
41
49
  }>;
@@ -45,7 +53,7 @@ export declare function writeCopilotCloudFiles(projectPath: string): Promise<{
45
53
  *
46
54
  * @returns Number of files removed.
47
55
  */
48
- export declare function removeCopilotCloudFiles(projectPath: string): Promise<number>;
56
+ export declare function removeCopilotCloudFiles(projectPath: string, ops?: CopilotCloudFileOps): Promise<number>;
49
57
  /**
50
58
  * Read the persisted opt-in for Copilot cloud-file generation.
51
59
  *
@@ -242,10 +242,14 @@ function isLegacyCopilotCloudFile(relPath, content) {
242
242
  function isManagedCopilotCloudFile(relPath, content) {
243
243
  return isCurrentCopilotCloudFile(relPath, content) || isLegacyCopilotCloudFile(relPath, content);
244
244
  }
245
- async function reconcileCopilotCloudFile(fullPath, relPath) {
245
+ const DIRECT_CLOUD_FILE_OPS = {
246
+ writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
247
+ removeFile: (filePath) => fs.unlink(filePath),
248
+ };
249
+ async function reconcileCopilotCloudFile(fullPath, relPath, ops) {
246
250
  const currentContent = COPILOT_CLOUD_FILE_CONTENTS[relPath];
247
251
  if (!(await FileSystemUtils.fileExists(fullPath))) {
248
- await FileSystemUtils.writeFile(fullPath, currentContent);
252
+ await ops.writeFile(fullPath, currentContent);
249
253
  return true;
250
254
  }
251
255
  const existingContent = await FileSystemUtils.readFile(fullPath);
@@ -255,7 +259,7 @@ async function reconcileCopilotCloudFile(fullPath, relPath) {
255
259
  if (!isLegacyCopilotCloudFile(relPath, existingContent)) {
256
260
  return false;
257
261
  }
258
- await FileSystemUtils.writeFile(fullPath, currentContent);
262
+ await ops.writeFile(fullPath, currentContent);
259
263
  return true;
260
264
  }
261
265
  async function assertCreatableFilePath(filePath) {
@@ -313,7 +317,7 @@ async function classifyCopilotAgentReconciliation(agentPath, alternateAgentPath)
313
317
  *
314
318
  * @returns Object indicating which files were written.
315
319
  */
316
- export async function writeCopilotCloudFiles(projectPath) {
320
+ export async function writeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
317
321
  const setupStepsPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.setupSteps);
318
322
  const agentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_CLOUD_FILES.agent);
319
323
  const alternateAgentPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, COPILOT_AGENT_ALTERNATE_FILE);
@@ -323,13 +327,13 @@ export async function writeCopilotCloudFiles(projectPath) {
323
327
  await assertMissingOrRegularFile(agentPath);
324
328
  await assertMissingOrRegularFile(alternateAgentPath);
325
329
  const agentReconciliation = await classifyCopilotAgentReconciliation(agentPath, alternateAgentPath);
326
- const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps);
330
+ const setupStepsWritten = await reconcileCopilotCloudFile(setupStepsPath, COPILOT_CLOUD_FILES.setupSteps, ops);
327
331
  let agentWritten = false;
328
332
  if (agentReconciliation === 'reconcile') {
329
- agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent);
333
+ agentWritten = await reconcileCopilotCloudFile(agentPath, COPILOT_CLOUD_FILES.agent, ops);
330
334
  }
331
335
  else if (agentReconciliation === 'remove-managed') {
332
- await fs.unlink(agentPath);
336
+ await ops.removeFile(agentPath);
333
337
  }
334
338
  return { setupStepsWritten, agentWritten };
335
339
  }
@@ -339,7 +343,7 @@ export async function writeCopilotCloudFiles(projectPath) {
339
343
  *
340
344
  * @returns Number of files removed.
341
345
  */
342
- export async function removeCopilotCloudFiles(projectPath) {
346
+ export async function removeCopilotCloudFiles(projectPath, ops = DIRECT_CLOUD_FILE_OPS) {
343
347
  let removed = 0;
344
348
  const managedPaths = Object.values(COPILOT_CLOUD_FILES).map((relPath) => ({
345
349
  relPath,
@@ -354,7 +358,7 @@ export async function removeCopilotCloudFiles(projectPath) {
354
358
  if (!isManagedCopilotCloudFile(relPath, content)) {
355
359
  continue;
356
360
  }
357
- await fs.unlink(fullPath);
361
+ await ops.removeFile(fullPath);
358
362
  removed++;
359
363
  }
360
364
  }
@@ -28,6 +28,8 @@ export interface GlobalConfig {
28
28
  telemetry?: TelemetryConfig;
29
29
  /** Whether the first-run shell-completions tip has been shown. */
30
30
  completionTipSeen?: boolean;
31
+ /** Whether the one-time note about the standalone install has been shown. */
32
+ standaloneTipSeen?: boolean;
31
33
  }
32
34
  /**
33
35
  * Gets the global configuration directory path following XDG Base Directory Specification.
@@ -94,4 +96,6 @@ export interface SaveGlobalConfigOptions {
94
96
  * existing file it cannot parse unless `replaceUnreadable` is set.
95
97
  */
96
98
  export declare function saveGlobalConfig(config: GlobalConfig, options?: SaveGlobalConfigOptions): void;
99
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
100
+ export declare function serializeGlobalConfig(config: GlobalConfig): string;
97
101
  //# sourceMappingURL=global-config.d.ts.map
@@ -173,6 +173,10 @@ export function saveGlobalConfig(config, options = {}) {
173
173
  if (!fs.existsSync(configDir)) {
174
174
  fs.mkdirSync(configDir, { recursive: true });
175
175
  }
176
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
176
+ fs.writeFileSync(configPath, serializeGlobalConfig(config), 'utf-8');
177
+ }
178
+ /** The bytes `saveGlobalConfig` writes, for a caller that only plans the write. */
179
+ export function serializeGlobalConfig(config) {
180
+ return JSON.stringify(config, null, 2) + '\n';
177
181
  }
178
182
  //# sourceMappingURL=global-config.js.map
@@ -166,7 +166,17 @@ export interface CleanupResult {
166
166
  * @param detection - Detection result from detectLegacyArtifacts
167
167
  * @returns Cleanup result with summary of actions taken
168
168
  */
169
- export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult): Promise<CleanupResult>;
169
+ /**
170
+ * Every change cleanup makes on disk. `update --dry-run` passes a recorder, so
171
+ * the plan comes from this same code rather than a description of it.
172
+ */
173
+ export interface LegacyCleanupOps {
174
+ writeFile(filePath: string, content: string): Promise<void>;
175
+ removeFile(filePath: string): Promise<void>;
176
+ /** Removes a directory already known to be empty. */
177
+ removeEmptyDirectory(dirPath: string): Promise<void>;
178
+ }
179
+ export declare function cleanupLegacyArtifacts(projectPath: string, detection: LegacyDetectionResult, ops?: LegacyCleanupOps): Promise<CleanupResult>;
170
180
  /**
171
181
  * Generates a cleanup summary message for display.
172
182
  *
@@ -323,14 +323,14 @@ function legacyCommandDirForFile(file) {
323
323
  * or records what is left in it as kept. Never recursive: whatever remains was
324
324
  * not written by OpenSpec. Returns true when the directory was removed.
325
325
  */
326
- async function settleLegacyCommandDir(projectPath, dirPath, result) {
326
+ async function settleLegacyCommandDir(projectPath, dirPath, result, ops = DIRECT_CLEANUP_OPS) {
327
327
  const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
328
328
  const remaining = await readLegacyCommandDir(fullPath, []);
329
329
  if (!remaining) {
330
330
  return false;
331
331
  }
332
332
  if (remaining.others.length === 0) {
333
- await fs.rmdir(fullPath);
333
+ await ops.removeEmptyDirectory(fullPath);
334
334
  result.deletedDirs.push(dirPath);
335
335
  return true;
336
336
  }
@@ -493,15 +493,12 @@ export function isOnlyOpenSpecContent(content) {
493
493
  export function removeMarkerBlock(content) {
494
494
  return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
495
495
  }
496
- /**
497
- * Cleans up legacy OpenSpec artifacts from a project.
498
- * Preserves openspec/project.md (shows migration hint instead of deleting).
499
- *
500
- * @param projectPath - The root path of the project
501
- * @param detection - Detection result from detectLegacyArtifacts
502
- * @returns Cleanup result with summary of actions taken
503
- */
504
- export async function cleanupLegacyArtifacts(projectPath, detection) {
496
+ const DIRECT_CLEANUP_OPS = {
497
+ writeFile: (filePath, content) => FileSystemUtils.writeFile(filePath, content),
498
+ removeFile: (filePath) => fs.unlink(filePath),
499
+ removeEmptyDirectory: (dirPath) => fs.rmdir(dirPath),
500
+ };
501
+ export async function cleanupLegacyArtifacts(projectPath, detection, ops = DIRECT_CLEANUP_OPS) {
505
502
  const result = {
506
503
  deletedFiles: [],
507
504
  deletedFileReplacementLabels: {},
@@ -519,7 +516,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
519
516
  const content = await FileSystemUtils.readFile(filePath);
520
517
  const newContent = removeMarkerBlock(content);
521
518
  // Always write the file, even if empty - never delete user config files
522
- await FileSystemUtils.writeFile(filePath, newContent);
519
+ await ops.writeFile(filePath, newContent);
523
520
  result.modifiedFiles.push(fileName);
524
521
  }
525
522
  catch (error) {
@@ -545,10 +542,10 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
545
542
  if (!(await isGeneratedLegacyCommand(filePath))) {
546
543
  continue;
547
544
  }
548
- await fs.unlink(filePath);
545
+ await ops.removeFile(filePath);
549
546
  deleted.push(name);
550
547
  }
551
- if (!(await settleLegacyCommandDir(projectPath, dirPath, result))) {
548
+ if (!(await settleLegacyCommandDir(projectPath, dirPath, result, ops))) {
552
549
  result.deletedFiles.push(...deleted.map((name) => `${dirPath}/${name}`));
553
550
  }
554
551
  }
@@ -570,7 +567,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
570
567
  continue;
571
568
  }
572
569
  }
573
- await fs.unlink(fullPath);
570
+ await ops.removeFile(fullPath);
574
571
  result.deletedFiles.push(filePath);
575
572
  }
576
573
  catch (error) {
@@ -581,7 +578,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
581
578
  // file by file above; record what was left in it.
582
579
  for (const dirPath of partlyCleanedDirs) {
583
580
  try {
584
- await settleLegacyCommandDir(projectPath, dirPath, result);
581
+ await settleLegacyCommandDir(projectPath, dirPath, result, ops);
585
582
  }
586
583
  catch (error) {
587
584
  result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
@@ -595,7 +592,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
595
592
  continue;
596
593
  }
597
594
  try {
598
- await fs.unlink(filePath);
595
+ await ops.removeFile(filePath);
599
596
  result.deletedFiles.push(filePath);
600
597
  const promptMatch = globalPromptMatchesByPath.get(filePath);
601
598
  if (promptMatch?.replacementLabel) {
@@ -611,7 +608,7 @@ export async function cleanupLegacyArtifacts(projectPath, detection) {
611
608
  const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
612
609
  if (await FileSystemUtils.fileExists(agentsPath)) {
613
610
  try {
614
- await fs.unlink(agentsPath);
611
+ await ops.removeFile(agentsPath);
615
612
  result.deletedFiles.push('openspec/AGENTS.md');
616
613
  }
617
614
  catch (error) {
@@ -5,6 +5,8 @@
5
5
  * Called by both init and update commands before profile resolution.
6
6
  */
7
7
  import { type AIToolOption } from './config.js';
8
+ import { type Delivery, type GlobalConfig, type Profile } from './global-config.js';
9
+ import { type FileView } from './file-view.js';
8
10
  export interface LegacyToolRoot {
9
11
  /** Former tool root, e.g. '.kimi' */
10
12
  root: string;
@@ -41,11 +43,27 @@ export interface LegacyToolMigration {
41
43
  /** Whether this move needs the user's consent first */
42
44
  needsConsent: boolean;
43
45
  }
46
+ /** One file a legacy-root migration would move or drop. */
47
+ export interface LegacyMigrationFileOp {
48
+ toolId: string;
49
+ source: string;
50
+ destination: string;
51
+ disposition: 'move' | 'drop';
52
+ /** Whether an interactive run asks before this move */
53
+ needsConsent: boolean;
54
+ }
44
55
  /**
45
56
  * Reports the OpenSpec content sitting under each tool's legacy root, without
46
57
  * moving anything. Callers use this to ask before a move that needs consent.
47
58
  */
48
59
  export declare function findLegacyToolMigrations(projectPath: string, timing?: 'before-generation' | 'after-generation'): LegacyToolMigration[];
60
+ /**
61
+ * Lists, file by file, what `migrateLegacyToolDirs` would move or drop, without
62
+ * touching anything. Consent-gated roots are included: a non-interactive run
63
+ * moves them without asking. `view` decides what already sits at each
64
+ * destination, so a dry run can plan against files it has not written.
65
+ */
66
+ export declare function planLegacyToolMigrations(projectPath: string, timing: 'before-generation' | 'after-generation', toolIds: string[] | undefined, view: FileView): LegacyMigrationFileOp[];
49
67
  /**
50
68
  * Moves OpenSpec-managed skill directories (openspec-*) and command files
51
69
  * (opsx-*) from a tool's legacy root to its current one. When the destination
@@ -81,7 +99,7 @@ export declare function legacyMigrationNotice(migration: LegacyToolMigration): s
81
99
  * Scans installed workflow files across all detected tools and returns
82
100
  * the union of installed workflow IDs.
83
101
  */
84
- export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[];
102
+ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[], view?: FileView): string[];
85
103
  /**
86
104
  * Performs one-time migration if the global config does not yet have a profile field.
87
105
  * Called by both init and update before profile resolution.
@@ -91,5 +109,13 @@ export declare function scanInstalledWorkflows(projectPath: string, tools: AIToo
91
109
  * - If no profile field exists and no workflows are installed: no-op (defaults apply).
92
110
  * - If profile field already exists: no-op.
93
111
  */
112
+ export declare function computeProfileMigration(projectPath: string, tools: AIToolOption[], view?: FileView): ProfileMigration | null;
113
+ export type ProfileMigration = {
114
+ profile: Profile;
115
+ workflows: string[];
116
+ delivery?: Delivery;
117
+ };
118
+ /** The global config the one-time profile migration saves. */
119
+ export declare function applyProfileMigration(migration: ProfileMigration): GlobalConfig;
94
120
  export declare function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void;
95
121
  //# sourceMappingURL=migration.d.ts.map