@fission-ai/openspec 1.14.3 → 1.16.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 (68) hide show
  1. package/README.md +12 -10
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/completion.js +4 -2
  4. package/dist/commands/feedback.js +33 -20
  5. package/dist/commands/install-standalone.d.ts +27 -0
  6. package/dist/commands/install-standalone.js +150 -0
  7. package/dist/commands/schema.js +10 -3
  8. package/dist/commands/validate.js +33 -3
  9. package/dist/commands/workflow/instructions.js +15 -4
  10. package/dist/commands/workflow/new-change.js +12 -4
  11. package/dist/commands/workflow/status.js +5 -2
  12. package/dist/commands/workflow/templates.js +5 -2
  13. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  14. package/dist/core/artifact-graph/resolver.js +11 -0
  15. package/dist/core/available-tools.d.ts +2 -1
  16. package/dist/core/available-tools.js +8 -28
  17. package/dist/core/completion-tip.d.ts +23 -0
  18. package/dist/core/completion-tip.js +4 -4
  19. package/dist/core/completions/command-registry.js +15 -0
  20. package/dist/core/completions/installers/zsh-installer.d.ts +9 -1
  21. package/dist/core/completions/installers/zsh-installer.js +64 -19
  22. package/dist/core/config-schema.d.ts +1 -0
  23. package/dist/core/config-schema.js +1 -0
  24. package/dist/core/file-view.d.ts +13 -0
  25. package/dist/core/file-view.js +14 -0
  26. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  27. package/dist/core/github-copilot/cloud-agent.js +13 -9
  28. package/dist/core/global-config.d.ts +4 -0
  29. package/dist/core/global-config.js +5 -1
  30. package/dist/core/id.d.ts +8 -0
  31. package/dist/core/id.js +15 -0
  32. package/dist/core/legacy-cleanup.d.ts +11 -1
  33. package/dist/core/legacy-cleanup.js +15 -18
  34. package/dist/core/migration.d.ts +27 -1
  35. package/dist/core/migration.js +77 -29
  36. package/dist/core/parsers/code-fence.js +9 -1
  37. package/dist/core/parsers/requirement-blocks.d.ts +30 -0
  38. package/dist/core/parsers/requirement-blocks.js +22 -9
  39. package/dist/core/parsers/requirement-text.d.ts +14 -0
  40. package/dist/core/parsers/requirement-text.js +39 -0
  41. package/dist/core/parsers/spec-structure.js +1 -1
  42. package/dist/core/profile-sync-drift.d.ts +4 -3
  43. package/dist/core/profile-sync-drift.js +19 -16
  44. package/dist/core/project-config.d.ts +10 -0
  45. package/dist/core/project-config.js +26 -0
  46. package/dist/core/root-selection.d.ts +6 -0
  47. package/dist/core/root-selection.js +37 -0
  48. package/dist/core/shared/tool-detection.d.ts +12 -4
  49. package/dist/core/shared/tool-detection.js +29 -27
  50. package/dist/core/shared-skill-target.d.ts +12 -5
  51. package/dist/core/shared-skill-target.js +34 -23
  52. package/dist/core/standalone-tip.d.ts +6 -0
  53. package/dist/core/standalone-tip.js +38 -0
  54. package/dist/core/store/git.d.ts +22 -1
  55. package/dist/core/store/git.js +59 -4
  56. package/dist/core/templates/workflows/verify-change.js +4 -4
  57. package/dist/core/update-plan.d.ts +127 -0
  58. package/dist/core/update-plan.js +390 -0
  59. package/dist/core/update.d.ts +45 -1
  60. package/dist/core/update.js +279 -137
  61. package/dist/core/validation/validator.d.ts +27 -2
  62. package/dist/core/validation/validator.js +58 -4
  63. package/dist/core/version-check.d.ts +8 -1
  64. package/dist/core/version-check.js +48 -5
  65. package/dist/utils/executable-path.d.ts +5 -0
  66. package/dist/utils/executable-path.js +44 -0
  67. package/package.json +5 -4
  68. package/schemas/spec-driven/schema.yaml +1 -0
@@ -1,5 +1,6 @@
1
1
  import type { Delivery } from './global-config.js';
2
2
  import { ALL_WORKFLOWS } from './profiles.js';
3
+ import { type FileView } from './file-view.js';
3
4
  type WorkflowId = (typeof ALL_WORKFLOWS)[number];
4
5
  /**
5
6
  * Maps workflow IDs to their skill directory names.
@@ -8,7 +9,7 @@ export declare const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string>;
8
9
  /**
9
10
  * Returns tools that are configured via either skills or commands.
10
11
  */
11
- export declare function getConfiguredToolsForProfileSync(projectPath: string): string[];
12
+ export declare function getConfiguredToolsForProfileSync(projectPath: string, view?: FileView): string[];
12
13
  /**
13
14
  * Detects if a single tool has profile/delivery drift against the desired state.
14
15
  *
@@ -17,11 +18,11 @@ export declare function getConfiguredToolsForProfileSync(projectPath: string): s
17
18
  * - artifacts that should not exist for the selected delivery mode
18
19
  * - artifacts for workflows that were deselected from the current profile
19
20
  */
20
- export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery): boolean;
21
+ export declare function hasToolProfileOrDeliveryDrift(projectPath: string, toolId: string, desiredWorkflows: readonly string[], delivery: Delivery, view?: FileView): boolean;
21
22
  /**
22
23
  * Returns configured tools that currently need a profile/delivery sync.
23
24
  */
24
- export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[]): string[];
25
+ export declare function getToolsNeedingProfileSync(projectPath: string, desiredWorkflows: readonly string[], delivery: Delivery, configuredTools?: readonly string[], view?: FileView): string[];
25
26
  /**
26
27
  * Detects whether the current project has any profile/delivery drift.
27
28
  */
@@ -7,6 +7,7 @@ import { getConfiguredTools } from './shared/index.js';
7
7
  import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js';
8
8
  import { readSharedSkillTarget } from './shared-skill-target.js';
9
9
  import { FileSystemUtils } from '../utils/file-system.js';
10
+ import { DISK_VIEW } from './file-view.js';
10
11
  import { isLegacyCodexSkillEquivalentToCurrent } from './shared/skill-content-equivalence.js';
11
12
  import { hasGlobalSkillTarget, resolveToolSkillsDir, toolSupportsSkills, } from './shared/skill-paths.js';
12
13
  /**
@@ -32,8 +33,8 @@ function toKnownWorkflows(workflows) {
32
33
  /**
33
34
  * Returns tools that are configured via either skills or commands.
34
35
  */
35
- export function getConfiguredToolsForProfileSync(projectPath) {
36
- return getConfiguredTools(projectPath);
36
+ export function getConfiguredToolsForProfileSync(projectPath, view = DISK_VIEW) {
37
+ return getConfiguredTools(projectPath, view);
37
38
  }
38
39
  /**
39
40
  * Detects if a single tool has profile/delivery drift against the desired state.
@@ -43,7 +44,7 @@ export function getConfiguredToolsForProfileSync(projectPath) {
43
44
  * - artifacts that should not exist for the selected delivery mode
44
45
  * - artifacts for workflows that were deselected from the current profile
45
46
  */
46
- export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) {
47
+ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery, view = DISK_VIEW) {
47
48
  const tool = AI_TOOLS.find((t) => t.value === toolId);
48
49
  if (!tool || !toolSupportsSkills(tool))
49
50
  return false;
@@ -54,16 +55,16 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
54
55
  const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
55
56
  const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);
56
57
  const sharedTarget = tool.skillsDir
57
- ? readSharedSkillTarget(projectPath, tool.skillsDir)
58
+ ? readSharedSkillTarget(projectPath, tool.skillsDir, view)
58
59
  : undefined;
59
60
  for (const root of tool.legacySkillsDirs ?? []) {
60
61
  for (const workflow of knownDesiredWorkflows) {
61
62
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
62
63
  const legacySkill = path.join(projectPath, root, 'skills', dirName, 'SKILL.md');
63
- if (!fs.existsSync(legacySkill))
64
+ if (!view.exists(legacySkill))
64
65
  continue;
65
66
  const currentSkill = path.join(skillsDir, dirName, 'SKILL.md');
66
- if (!fs.existsSync(currentSkill) || sharedTarget !== toolId) {
67
+ if (!view.exists(currentSkill) || sharedTarget !== toolId) {
67
68
  return true;
68
69
  }
69
70
  try {
@@ -75,7 +76,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
75
76
  // remove the redundant legacy file even when version, line endings,
76
77
  // or supported invocation syntax changed. Materially divergent copies
77
78
  // stay in place without forcing an update on every run.
78
- if (isLegacyCodexSkillEquivalentToCurrent(fs.readFileSync(legacySkill, 'utf-8'), fs.readFileSync(currentSkill, 'utf-8'))) {
79
+ if (isLegacyCodexSkillEquivalentToCurrent(view.read(legacySkill), view.read(currentSkill))) {
79
80
  return true;
80
81
  }
81
82
  }
@@ -88,7 +89,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
88
89
  for (const workflow of knownDesiredWorkflows) {
89
90
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
90
91
  const skillFile = path.join(skillsDir, dirName, 'SKILL.md');
91
- if (!fs.existsSync(skillFile)) {
92
+ if (!view.exists(skillFile)) {
92
93
  return true;
93
94
  }
94
95
  }
@@ -98,7 +99,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
98
99
  continue;
99
100
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
100
101
  const skillDir = path.join(skillsDir, dirName);
101
- if (fs.existsSync(skillDir)) {
102
+ if (view.exists(skillDir)) {
102
103
  return true;
103
104
  }
104
105
  }
@@ -107,7 +108,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
107
108
  for (const workflow of ALL_WORKFLOWS) {
108
109
  const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
109
110
  const skillDir = path.join(skillsDir, dirName);
110
- if (fs.existsSync(skillDir)) {
111
+ if (view.exists(skillDir)) {
111
112
  return true;
112
113
  }
113
114
  }
@@ -116,7 +117,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
116
117
  for (const workflow of knownDesiredWorkflows) {
117
118
  const cmdPath = adapter.getFilePath(workflow);
118
119
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
119
- if (!fs.existsSync(fullPath)) {
120
+ if (!view.exists(fullPath)) {
120
121
  return true;
121
122
  }
122
123
  }
@@ -126,7 +127,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
126
127
  continue;
127
128
  const cmdPath = adapter.getFilePath(workflow);
128
129
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
129
- if (fs.existsSync(fullPath)) {
130
+ if (view.exists(fullPath)) {
130
131
  return true;
131
132
  }
132
133
  }
@@ -135,7 +136,7 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
135
136
  for (const workflow of ALL_WORKFLOWS) {
136
137
  const cmdPath = adapter.getFilePath(workflow);
137
138
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
138
- if (fs.existsSync(fullPath)) {
139
+ if (view.exists(fullPath)) {
139
140
  return true;
140
141
  }
141
142
  }
@@ -145,9 +146,11 @@ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkfl
145
146
  /**
146
147
  * Returns configured tools that currently need a profile/delivery sync.
147
148
  */
148
- export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools) {
149
- const tools = configuredTools ? [...new Set(configuredTools)] : getConfiguredToolsForProfileSync(projectPath);
150
- return tools.filter((toolId) => hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery));
149
+ export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools, view = DISK_VIEW) {
150
+ const tools = configuredTools
151
+ ? [...new Set(configuredTools)]
152
+ : getConfiguredToolsForProfileSync(projectPath, view);
153
+ return tools.filter((toolId) => hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery, view));
151
154
  }
152
155
  function getInstalledWorkflowsForTool(projectPath, toolId, options) {
153
156
  const tool = AI_TOOLS.find((t) => t.value === toolId);
@@ -70,6 +70,16 @@ export declare const MAX_CONTEXT_SIZE: number;
70
70
  * @returns Parsed config or null if file doesn't exist
71
71
  */
72
72
  export declare function readProjectConfig(projectRoot: string): ProjectConfig | null;
73
+ /**
74
+ * Warning-silent check for a config file that cannot be read or is not valid
75
+ * YAML at all. `readProjectConfig` ignores such a file with a warning, so
76
+ * `validate` uses this to fail instead and give CI a non-zero exit (#1892).
77
+ */
78
+ export declare function readConfigParseError(projectRoot: string): {
79
+ kind: 'read' | 'parse';
80
+ filePath: string;
81
+ message: string;
82
+ } | null;
73
83
  /**
74
84
  * Validate artifact IDs in rules against the artifacts of every available
75
85
  * schema. The `rules:` map is global, but each change can use a different
@@ -364,6 +364,32 @@ export function readProjectConfig(projectRoot) {
364
364
  return null;
365
365
  }
366
366
  }
367
+ /**
368
+ * Warning-silent check for a config file that cannot be read or is not valid
369
+ * YAML at all. `readProjectConfig` ignores such a file with a warning, so
370
+ * `validate` uses this to fail instead and give CI a non-zero exit (#1892).
371
+ */
372
+ export function readConfigParseError(projectRoot) {
373
+ const filePath = resolveConfigFilePath(projectRoot);
374
+ if (filePath === null) {
375
+ return null;
376
+ }
377
+ const firstLine = (error) => error instanceof Error ? error.message.split('\n')[0] : String(error);
378
+ let content;
379
+ try {
380
+ content = readFileSync(filePath, 'utf-8');
381
+ }
382
+ catch (error) {
383
+ return { kind: 'read', filePath, message: firstLine(error) };
384
+ }
385
+ try {
386
+ parseYaml(content);
387
+ return null;
388
+ }
389
+ catch (error) {
390
+ return { kind: 'parse', filePath, message: firstLine(error) };
391
+ }
392
+ }
367
393
  function configPathForWarnings(projectRoot) {
368
394
  return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml');
369
395
  }
@@ -79,6 +79,12 @@ export type RegisteredStoreInspection = {
79
79
  problems: string;
80
80
  };
81
81
  export declare function inspectRegisteredStore(id: string, storeRoot: string): Promise<RegisteredStoreInspection>;
82
+ /**
83
+ * Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
84
+ * the nearest local `openspec/` directory, the global `defaultStore`, then the
85
+ * registered-store hint (which names a store matching the repo's origin, if
86
+ * any) or an implicit root.
87
+ */
82
88
  export declare function resolveOpenSpecRoot(options?: ResolveOpenSpecRootOptions): Promise<ResolvedOpenSpecRoot>;
83
89
  export interface RootOutput {
84
90
  path: string;
@@ -26,6 +26,8 @@ import * as path from 'node:path';
26
26
  import { StoreError } from './store/errors.js';
27
27
  import { getStoreMetadataPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, validateStoreId, } from './store/foundation.js';
28
28
  import { getStoreRootForBackend } from './store/registry.js';
29
+ import { gitOriginUrl, repoNameFromGitUrl } from './store/git.js';
30
+ import { toKebabCase, isKebabId } from './id.js';
29
31
  import { inspectOpenSpecRoot } from './openspec-root.js';
30
32
  import { findRepoPlanningRootSync } from './planning-home.js';
31
33
  import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
@@ -251,6 +253,32 @@ async function resolveDefaultStoreRoot(id, globalDataDir) {
251
253
  throw error;
252
254
  }
253
255
  }
256
+ /**
257
+ * Git-remote store suggestion (#2076): the registered store whose id matches
258
+ * this repo's origin name, or null. Only ever named in the no-root error, never
259
+ * selected: a basename is not enough authority to redirect writes, because
260
+ * `company-a/api` and `company-b/api` both match a store called `api`.
261
+ */
262
+ async function suggestStoreFromGitRemote(startPath, registeredIds) {
263
+ // `git -C <path>` needs a directory; a file entry path would make the probe
264
+ // fail. canonicalDirectory resolves that.
265
+ const url = await gitOriginUrl(canonicalDirectory(startPath));
266
+ if (!url)
267
+ return null;
268
+ const repoName = repoNameFromGitUrl(url);
269
+ if (!repoName)
270
+ return null;
271
+ const candidateId = toKebabCase(repoName);
272
+ if (!candidateId || !isKebabId(candidateId))
273
+ return null;
274
+ return registeredIds.includes(candidateId) ? candidateId : null;
275
+ }
276
+ /**
277
+ * Resolve the OpenSpec root for a command, in precedence order: `--store <id>`,
278
+ * the nearest local `openspec/` directory, the global `defaultStore`, then the
279
+ * registered-store hint (which names a store matching the repo's origin, if
280
+ * any) or an implicit root.
281
+ */
254
282
  export async function resolveOpenSpecRoot(options = {}) {
255
283
  if (options.storePath !== undefined) {
256
284
  throw new RootSelectionError('--store-path is not supported. Register the path with openspec store register <path>, then select it with --store <id>.', 'store_path_not_supported', {
@@ -284,6 +312,15 @@ export async function resolveOpenSpecRoot(options = {}) {
284
312
  ? listStoreRegistryEntries(registry).map((entry) => entry.id)
285
313
  : [];
286
314
  if (registeredIds.length > 0) {
315
+ // Only spawn git on this failure path: a repo whose origin name matches a
316
+ // registered store gets that store suggested, never selected.
317
+ const suggested = await suggestStoreFromGitRemote(startPath, registeredIds);
318
+ if (suggested) {
319
+ throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. This repo's origin name matches the registered store "${suggested}". To use it, rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml so every command here uses it.`, 'no_root_with_registered_stores', {
320
+ target: 'openspec.root',
321
+ fix: `Rerun with --store ${suggested}, or add "store: ${suggested}" to openspec/config.yaml.`,
322
+ });
323
+ }
287
324
  throw new RootSelectionError(`No OpenSpec root found in the current directory or its ancestors. Registered stores: ${registeredIds.join(', ')}. Pass --store <id> to use one, or run openspec init to create a local root.`, 'no_root_with_registered_stores', {
288
325
  target: 'openspec.root',
289
326
  fix: `Rerun with --store <id> (registered: ${registeredIds.join(', ')}) or run openspec init.`,
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Shared utilities for detecting tool configurations and version status.
5
5
  */
6
+ import { type Delivery } from '../global-config.js';
7
+ import { type FileView } from '../file-view.js';
6
8
  /**
7
9
  * Names of skill directories created by openspec init.
8
10
  */
@@ -50,11 +52,11 @@ export declare function getToolsWithSkillsDir(): string[];
50
52
  /**
51
53
  * Checks which skill files exist for a tool.
52
54
  */
53
- export declare function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus;
55
+ export declare function getToolSkillStatus(projectRoot: string, toolId: string, view?: FileView): ToolSkillStatus;
54
56
  /**
55
57
  * Checks whether a tool has at least one generated OpenSpec command file.
56
58
  */
57
- export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean;
59
+ export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId: string, view?: FileView): boolean;
58
60
  /**
59
61
  * Checks whether command files for a tool on disk match current generated command contents.
60
62
  *
@@ -63,6 +65,8 @@ export declare function toolHasAnyConfiguredCommand(projectPath: string, toolId:
63
65
  */
64
66
  export declare function areCommandFilesUpToDate(projectRoot: string, toolId: string, options?: {
65
67
  workflows?: readonly string[];
68
+ /** The tree to read; the disk unless an update is planning. */
69
+ view?: FileView;
66
70
  }): boolean;
67
71
  /**
68
72
  * Gets the skill status for all tools with skillsDir configured.
@@ -72,18 +76,22 @@ export declare function getToolStates(projectRoot: string): Map<string, ToolSkil
72
76
  * Extracts the generatedBy version from a skill file's YAML frontmatter.
73
77
  * Returns null if the field is not found or the file doesn't exist.
74
78
  */
75
- export declare function extractGeneratedByVersion(skillFilePath: string): string | null;
79
+ export declare function extractGeneratedByVersion(skillFilePath: string, view?: FileView): string | null;
76
80
  /**
77
81
  * Gets version status for a tool by reading its skill files, falling back to a
78
82
  * command-content fingerprint for installs that have commands but no skills.
79
83
  */
80
84
  export declare function getToolVersionStatus(projectRoot: string, toolId: string, currentVersion: string, options?: {
81
85
  workflows?: readonly string[];
86
+ /** The tree to read; the disk unless an update is planning. */
87
+ view?: FileView;
88
+ /** Effective delivery; read from the global config when omitted. */
89
+ delivery?: Delivery;
82
90
  }): ToolVersionStatus;
83
91
  /**
84
92
  * Gets all configured tools in the project (configured via skills or commands).
85
93
  */
86
- export declare function getConfiguredTools(projectRoot: string): string[];
94
+ export declare function getConfiguredTools(projectRoot: string, view?: FileView): string[];
87
95
  /**
88
96
  * Gets version status for all configured tools.
89
97
  */
@@ -4,7 +4,6 @@
4
4
  * Shared utilities for detecting tool configurations and version status.
5
5
  */
6
6
  import path from 'path';
7
- import * as fs from 'fs';
8
7
  import { AI_TOOLS, OPENSPEC_SKILL_NAMES } from '../config.js';
9
8
  import { CommandAdapterRegistry, generateCommands } from '../command-generation/index.js';
10
9
  import { getCommandContents } from './skill-generation.js';
@@ -13,6 +12,7 @@ import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js';
13
12
  import { isSharedSkillTargetActive, hasLegacySkills, readSharedSkillTarget, reconcileSharedSkillTargets, } from '../shared-skill-target.js';
14
13
  import { shouldGenerateCommandsForTool, shouldGenerateSkillsForTool, resolveCommandSurfaceCapability, } from '../command-surface.js';
15
14
  import { getSkillCapableTools, resolveToolSkillsDir, toolSupportsSkills, } from './skill-paths.js';
15
+ import { DISK_VIEW } from '../file-view.js';
16
16
  /**
17
17
  * Names of skill directories created by openspec init.
18
18
  */
@@ -43,12 +43,12 @@ export function getToolsWithSkillsDir() {
43
43
  /**
44
44
  * Checks which skill files exist for a tool.
45
45
  */
46
- export function getToolSkillStatus(projectRoot, toolId) {
46
+ export function getToolSkillStatus(projectRoot, toolId, view = DISK_VIEW) {
47
47
  const tool = AI_TOOLS.find((t) => t.value === toolId);
48
48
  if (!tool || !toolSupportsSkills(tool)) {
49
49
  return { configured: false, fullyConfigured: false, skillCount: 0 };
50
50
  }
51
- if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId)) {
51
+ if (tool.skillsDir && !isSharedSkillTargetActive(projectRoot, toolId, view)) {
52
52
  return { configured: false, fullyConfigured: false, skillCount: 0 };
53
53
  }
54
54
  const skillsDirs = [
@@ -57,7 +57,7 @@ export function getToolSkillStatus(projectRoot, toolId) {
57
57
  ];
58
58
  let skillCount = 0;
59
59
  for (const skillName of SKILL_NAMES) {
60
- if (skillsDirs.some((skillsDir) => fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')))) {
60
+ if (skillsDirs.some((skillsDir) => view.exists(path.join(skillsDir, skillName, 'SKILL.md')))) {
61
61
  skillCount++;
62
62
  }
63
63
  }
@@ -70,14 +70,14 @@ export function getToolSkillStatus(projectRoot, toolId) {
70
70
  /**
71
71
  * Checks whether a tool has at least one generated OpenSpec command file.
72
72
  */
73
- export function toolHasAnyConfiguredCommand(projectPath, toolId) {
73
+ export function toolHasAnyConfiguredCommand(projectPath, toolId, view = DISK_VIEW) {
74
74
  const adapter = CommandAdapterRegistry.get(toolId);
75
75
  if (!adapter)
76
76
  return false;
77
77
  for (const commandId of COMMAND_IDS) {
78
78
  const cmdPath = adapter.getFilePath(commandId);
79
79
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
80
- if (fs.existsSync(fullPath)) {
80
+ if (view.exists(fullPath)) {
81
81
  return true;
82
82
  }
83
83
  }
@@ -101,6 +101,7 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
101
101
  const adapter = CommandAdapterRegistry.get(toolId);
102
102
  if (!adapter)
103
103
  return false;
104
+ const view = options?.view ?? DISK_VIEW;
104
105
  let workflows;
105
106
  if (options?.workflows) {
106
107
  workflows = options.workflows;
@@ -123,11 +124,11 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
123
124
  }
124
125
  for (const cmd of generatedCommands) {
125
126
  const cmdPath = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectRoot, cmd.path);
126
- if (!fs.existsSync(cmdPath)) {
127
+ if (!view.exists(cmdPath)) {
127
128
  return false;
128
129
  }
129
130
  try {
130
- const existingContent = fs.readFileSync(cmdPath, 'utf-8');
131
+ const existingContent = view.read(cmdPath);
131
132
  if (normalizeCommandContent(existingContent) !== normalizeCommandContent(cmd.fileContent)) {
132
133
  return false;
133
134
  }
@@ -143,7 +144,7 @@ export function areCommandFilesUpToDate(projectRoot, toolId, options) {
143
144
  continue;
144
145
  const cmdPath = adapter.getFilePath(workflow);
145
146
  const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectRoot, cmdPath);
146
- if (fs.existsSync(fullPath)) {
147
+ if (view.exists(fullPath)) {
147
148
  return false;
148
149
  }
149
150
  }
@@ -176,12 +177,12 @@ export function getToolStates(projectRoot) {
176
177
  * Extracts the generatedBy version from a skill file's YAML frontmatter.
177
178
  * Returns null if the field is not found or the file doesn't exist.
178
179
  */
179
- export function extractGeneratedByVersion(skillFilePath) {
180
+ export function extractGeneratedByVersion(skillFilePath, view = DISK_VIEW) {
180
181
  try {
181
- if (!fs.existsSync(skillFilePath)) {
182
+ if (!view.exists(skillFilePath)) {
182
183
  return null;
183
184
  }
184
- const content = fs.readFileSync(skillFilePath, 'utf-8');
185
+ const content = view.read(skillFilePath);
185
186
  // Look for generatedBy in the YAML frontmatter
186
187
  // The file format is:
187
188
  // ---
@@ -212,6 +213,7 @@ export function extractGeneratedByVersion(skillFilePath) {
212
213
  * command-content fingerprint for installs that have commands but no skills.
213
214
  */
214
215
  export function getToolVersionStatus(projectRoot, toolId, currentVersion, options) {
216
+ const view = options?.view ?? DISK_VIEW;
215
217
  const tool = AI_TOOLS.find((t) => t.value === toolId);
216
218
  if (!tool || !toolSupportsSkills(tool)) {
217
219
  return {
@@ -232,8 +234,8 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
232
234
  for (const skillName of SKILL_NAMES) {
233
235
  for (const skillsDir of skillsDirs) {
234
236
  const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
235
- if (fs.existsSync(skillFile)) {
236
- generatedByVersion = extractGeneratedByVersion(skillFile);
237
+ if (view.exists(skillFile)) {
238
+ generatedByVersion = extractGeneratedByVersion(skillFile, view);
237
239
  foundSkill = true;
238
240
  break;
239
241
  }
@@ -241,10 +243,10 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
241
243
  if (foundSkill)
242
244
  break;
243
245
  }
244
- const skillConfigured = getToolSkillStatus(projectRoot, toolId).configured;
245
- const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId);
246
+ const skillConfigured = getToolSkillStatus(projectRoot, toolId, view).configured;
247
+ const commandConfigured = toolHasAnyConfiguredCommand(projectRoot, toolId, view);
246
248
  const markerConfigured = Boolean(tool.skillsDir) &&
247
- readSharedSkillTarget(projectRoot, tool.skillsDir) === toolId;
249
+ readSharedSkillTarget(projectRoot, tool.skillsDir, view) === toolId;
248
250
  const configured = skillConfigured || commandConfigured || markerConfigured;
249
251
  // 2. Commands-only installs have no skill file to read a version from, so fall
250
252
  // back to comparing the generated command content. Deliberately skipped when
@@ -253,7 +255,7 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
253
255
  generatedByVersion = currentVersion;
254
256
  }
255
257
  if (!skillConfigured && !commandConfigured && markerConfigured) {
256
- const delivery = getGlobalConfig().delivery ?? 'both';
258
+ const delivery = options?.delivery ?? getGlobalConfig().delivery ?? 'both';
257
259
  if (!shouldGenerateSkillsForTool(toolId, delivery) &&
258
260
  !shouldGenerateCommandsForTool(toolId, delivery)) {
259
261
  generatedByVersion = currentVersion;
@@ -275,7 +277,7 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
275
277
  if (skillConfigured && commandConfigured) {
276
278
  let generatesCommands = true;
277
279
  try {
278
- generatesCommands = shouldGenerateCommandsForTool(toolId, getGlobalConfig().delivery ?? 'both');
280
+ generatesCommands = shouldGenerateCommandsForTool(toolId, options?.delivery ?? getGlobalConfig().delivery ?? 'both');
279
281
  }
280
282
  catch {
281
283
  generatesCommands = true;
@@ -298,24 +300,24 @@ export function getToolVersionStatus(projectRoot, toolId, currentVersion, option
298
300
  /**
299
301
  * Gets all configured tools in the project (configured via skills or commands).
300
302
  */
301
- export function getConfiguredTools(projectRoot) {
303
+ export function getConfiguredTools(projectRoot, view = DISK_VIEW) {
302
304
  const configured = AI_TOOLS
303
305
  .filter((t) => {
304
306
  if (!toolSupportsSkills(t))
305
307
  return false;
306
- return (getToolSkillStatus(projectRoot, t.value).configured ||
307
- toolHasAnyConfiguredCommand(projectRoot, t.value) ||
308
+ return (getToolSkillStatus(projectRoot, t.value, view).configured ||
309
+ toolHasAnyConfiguredCommand(projectRoot, t.value, view) ||
308
310
  (resolveCommandSurfaceCapability(t.value) === 'adapter-backed' &&
309
- hasLegacySkills(projectRoot, t)) ||
311
+ hasLegacySkills(projectRoot, t, view)) ||
310
312
  (Boolean(t.skillsDir) &&
311
- readSharedSkillTarget(projectRoot, t.skillsDir) === t.value));
313
+ readSharedSkillTarget(projectRoot, t.skillsDir, view) === t.value));
312
314
  });
313
- const activeProjectTools = new Set(reconcileSharedSkillTargets(projectRoot, configured.filter((tool) => tool.skillsDir)).map((tool) => tool.value));
315
+ const activeProjectTools = new Set(reconcileSharedSkillTargets(projectRoot, configured.filter((tool) => tool.skillsDir), view).map((tool) => tool.value));
314
316
  return configured
315
317
  .filter((tool) => tool.globalSkillsDir ||
316
- toolHasAnyConfiguredCommand(projectRoot, tool.value) ||
318
+ toolHasAnyConfiguredCommand(projectRoot, tool.value, view) ||
317
319
  (resolveCommandSurfaceCapability(tool.value) === 'adapter-backed' &&
318
- hasLegacySkills(projectRoot, tool)) ||
320
+ hasLegacySkills(projectRoot, tool, view)) ||
319
321
  activeProjectTools.has(tool.value))
320
322
  .map((tool) => tool.value);
321
323
  }
@@ -1,8 +1,9 @@
1
1
  import { type AIToolOption } from './config.js';
2
+ import { type FileView } from './file-view.js';
2
3
  /** Reads a valid-looking marker value without letting linked roots escape. */
3
- export declare function readSharedSkillTarget(projectPath: string, skillsDir: string): string | undefined;
4
+ export declare function readSharedSkillTarget(projectPath: string, skillsDir: string, view?: FileView): string | undefined;
4
5
  /** Whether a tool still has an allowlisted managed skill under an old root. */
5
- export declare function hasLegacySkills(projectPath: string, tool: AIToolOption): boolean;
6
+ export declare function hasLegacySkills(projectPath: string, tool: AIToolOption, view?: FileView): boolean;
6
7
  /**
7
8
  * Chooses the one selected tool that may render each physical skills root.
8
9
  * Command files are not part of this arbitration: tools that share a skills
@@ -13,18 +14,18 @@ export declare function hasLegacySkills(projectPath: string, tool: AIToolOption)
13
14
  * usable by every consumer of the shared tree. Codex is the best fresh owner
14
15
  * because its renderer includes both Codex and generic skill invocation forms.
15
16
  */
16
- export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[]): Set<string>;
17
+ export declare function resolveSharedSkillWriters(projectPath: string, tools: AIToolOption[], view?: FileView): Set<string>;
17
18
  /**
18
19
  * A shared skill root can only hold one rendered variant of each skill.
19
20
  * Keep the writer recorded so later updates do not infer every tool that
20
21
  * happens to use the same directory.
21
22
  */
22
- export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[]): AIToolOption[];
23
+ export declare function reconcileSharedSkillTargets(projectPath: string, tools: AIToolOption[], view?: FileView): AIToolOption[];
23
24
  /**
24
25
  * Returns whether a tool is the active writer for its physical skills root.
25
26
  * Non-shared roots are always active.
26
27
  */
27
- export declare function isSharedSkillTargetActive(projectPath: string, toolId: string): boolean;
28
+ export declare function isSharedSkillTargetActive(projectPath: string, toolId: string, view?: FileView): boolean;
28
29
  /**
29
30
  * The tool that already owns `toolId`'s shared skills root, when a DIFFERENT
30
31
  * one does. Returns the owner's tool id only when the root already carries an
@@ -40,5 +41,11 @@ export declare function sharedSkillRootOwner(projectPath: string, toolId: string
40
41
  * writing skills. See {@link sharedSkillRootOwner} for the ownership rules.
41
42
  */
42
43
  export declare function sharedSkillRootOwnedByOther(projectPath: string, toolId: string): boolean;
44
+ /**
45
+ * Path of the owner marker for a tool's shared skills root, or null when the
46
+ * tool has no shared root to mark. Lets a caller write the marker itself —
47
+ * `update` routes it through its write seam so a dry run can list it.
48
+ */
49
+ export declare function sharedSkillTargetMarkerPath(projectPath: string, toolId: string): string | null;
43
50
  export declare function writeSharedSkillTarget(projectPath: string, toolId: string): void;
44
51
  //# sourceMappingURL=shared-skill-target.d.ts.map