@fission-ai/openspec 1.14.2 → 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 (66) hide show
  1. package/README.md +13 -9
  2. package/dist/cli/index.js +47 -0
  3. package/dist/commands/change.js +2 -1
  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/spec.js +2 -10
  9. package/dist/commands/validate.js +11 -4
  10. package/dist/commands/workflow/instructions.js +15 -4
  11. package/dist/commands/workflow/new-change.js +12 -4
  12. package/dist/commands/workflow/status.js +5 -2
  13. package/dist/commands/workflow/templates.js +5 -2
  14. package/dist/core/artifact-graph/outputs.js +204 -29
  15. package/dist/core/artifact-graph/resolver.d.ts +6 -0
  16. package/dist/core/artifact-graph/resolver.js +11 -0
  17. package/dist/core/available-tools.d.ts +2 -1
  18. package/dist/core/available-tools.js +8 -28
  19. package/dist/core/completion-tip.d.ts +23 -0
  20. package/dist/core/completion-tip.js +4 -4
  21. package/dist/core/completions/command-registry.js +15 -0
  22. package/dist/core/completions/completion-provider.js +1 -1
  23. package/dist/core/config-schema.d.ts +1 -0
  24. package/dist/core/config-schema.js +1 -0
  25. package/dist/core/file-view.d.ts +13 -0
  26. package/dist/core/file-view.js +14 -0
  27. package/dist/core/github-copilot/cloud-agent.d.ts +10 -2
  28. package/dist/core/github-copilot/cloud-agent.js +13 -9
  29. package/dist/core/global-config.d.ts +4 -0
  30. package/dist/core/global-config.js +5 -1
  31. package/dist/core/legacy-cleanup.d.ts +11 -1
  32. package/dist/core/legacy-cleanup.js +15 -18
  33. package/dist/core/migration.d.ts +27 -1
  34. package/dist/core/migration.js +77 -29
  35. package/dist/core/profile-sync-drift.d.ts +4 -3
  36. package/dist/core/profile-sync-drift.js +19 -16
  37. package/dist/core/shared/tool-detection.d.ts +12 -4
  38. package/dist/core/shared/tool-detection.js +29 -27
  39. package/dist/core/shared-skill-target.d.ts +12 -5
  40. package/dist/core/shared-skill-target.js +34 -23
  41. package/dist/core/specs-apply.js +23 -5
  42. package/dist/core/standalone-tip.d.ts +6 -0
  43. package/dist/core/standalone-tip.js +38 -0
  44. package/dist/core/templates/workflows/continue-change.js +18 -12
  45. package/dist/core/templates/workflows/explore.js +2 -2
  46. package/dist/core/templates/workflows/ff-change.js +8 -6
  47. package/dist/core/templates/workflows/propose.js +8 -6
  48. package/dist/core/templates/workflows/verify-change.js +4 -4
  49. package/dist/core/update-plan.d.ts +127 -0
  50. package/dist/core/update-plan.js +390 -0
  51. package/dist/core/update.d.ts +45 -1
  52. package/dist/core/update.js +279 -137
  53. package/dist/core/validation/validator.js +3 -3
  54. package/dist/core/version-check.d.ts +8 -1
  55. package/dist/core/version-check.js +48 -5
  56. package/dist/utils/executable-path.d.ts +5 -0
  57. package/dist/utils/executable-path.js +44 -0
  58. package/dist/utils/file-system.d.ts +1 -1
  59. package/dist/utils/file-system.js +1 -1
  60. package/dist/utils/interactive.d.ts +15 -0
  61. package/dist/utils/interactive.js +18 -0
  62. package/dist/utils/spec-discovery.d.ts +4 -4
  63. package/dist/utils/spec-discovery.js +7 -17
  64. package/dist/utils/spec-path-containment.d.ts +17 -0
  65. package/dist/utils/spec-path-containment.js +122 -0
  66. package/package.json +8 -4
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Displays artifact completion status for a change.
5
5
  */
6
- import ora from 'ora';
7
6
  import chalk from 'chalk';
8
7
  import { getChangeDir } from '../../core/planning-home.js';
9
8
  import { resolveRootForCommand, toPlanningHome, toRootOutput, withStoreFlag, isStoreSelectedRoot, findDeclaringProjectRoot, } from '../../core/root-selection.js';
@@ -33,7 +32,11 @@ export async function statusCommand(options) {
33
32
  if (!root) {
34
33
  return;
35
34
  }
36
- const spinner = options.json ? undefined : ora('Loading change status...').start();
35
+ let spinner;
36
+ if (!options.json) {
37
+ const { default: ora } = await import('ora');
38
+ spinner = ora('Loading change status...').start();
39
+ }
37
40
  try {
38
41
  const planningHome = toPlanningHome(root);
39
42
  const projectRoot = root.path;
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Shows resolved template paths for all artifacts in a schema.
5
5
  */
6
- import ora from 'ora';
7
6
  import path from 'path';
8
7
  import { resolveSchema, getSchemaDir, ArtifactGraph, } from '../../core/artifact-graph/index.js';
9
8
  import { FileSystemUtils } from '../../utils/file-system.js';
@@ -12,7 +11,11 @@ import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
12
11
  // Command Implementation
13
12
  // -----------------------------------------------------------------------------
14
13
  export async function templatesCommand(options) {
15
- const spinner = options.json ? undefined : ora('Loading templates...').start();
14
+ let spinner;
15
+ if (!options.json) {
16
+ const { default: ora } = await import('ora');
17
+ spinner = ora('Loading templates...').start();
18
+ }
16
19
  try {
17
20
  const projectRoot = process.cwd();
18
21
  const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
@@ -1,12 +1,29 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { expand } from 'brace-expansion';
4
+ import picomatch from 'picomatch';
5
+ import * as globby from 'tinyglobby';
4
6
  import { FileSystemUtils } from '../../utils/file-system.js';
5
7
  const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
6
8
  const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
7
- // braces <=3.0.3 (fast-glob > micromatch) has no depth guard (GHSA-vfj7-8cjw-p6xm),
8
- // so reject nesting no real artifact pattern needs before it reaches the parser.
9
+ const DUPLICATE_SLASH_RE = /(?!^)\/{2,}/gu;
10
+ // Deeply nested braces can overflow a recursive expander. Reject nesting no real
11
+ // artifact pattern needs before brace-expansion or tinyglobby see the pattern.
9
12
  const MAX_BRACE_NESTING = 16;
13
+ // Nesting depth alone does not bound the work: `{a,b}` repeated 20 times is
14
+ // only one level deep and expands to 1,048,576 patterns, which exhausts the
15
+ // heap. fast-glob never expanded that far because micromatch compiled the
16
+ // braces to a regex. No real artifact pattern needs more than a few hundred.
17
+ const MAX_BRACE_EXPANSIONS = 1024;
18
+ const ARTIFACT_GLOB_OPTIONS = {
19
+ absolute: true,
20
+ // Braces are expanded first so `{draft}` left behind stays a literal name.
21
+ braceExpansion: false,
22
+ // fast-glob migration: directory patterns must not grow a trailing `/**`.
23
+ expandDirectories: false,
24
+ followSymbolicLinks: true,
25
+ onlyFiles: true,
26
+ };
10
27
  function assertBraceNesting(pattern) {
11
28
  // Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
12
29
  // `{`, and a closer only closes the innermost group of its own kind.
@@ -39,6 +56,166 @@ function assertBraceNesting(pattern) {
39
56
  }
40
57
  }
41
58
  }
59
+ function isDynamicSegment(segment) {
60
+ return segment.includes('*') || segment.includes('?') || segment.includes('[')
61
+ || EXTGLOB_RE.test(segment);
62
+ }
63
+ /**
64
+ * Static directory prefix of an expanded pattern, equivalent to the `base`
65
+ * fast-glob's generateTasks used to reject `../` before walking.
66
+ */
67
+ function globBase(pattern) {
68
+ const segments = pattern.split('/');
69
+ if (segments.length <= 1)
70
+ return '.';
71
+ const base = [];
72
+ for (const segment of segments.slice(0, -1)) {
73
+ if (segment === '')
74
+ continue;
75
+ if (isDynamicSegment(segment))
76
+ break;
77
+ base.push(segment);
78
+ }
79
+ // An absolute pattern must stay absolute, or `path.resolve(changeDir, base)`
80
+ // rebases `/etc/*` inside the change directory and the containment check
81
+ // passes for a pattern that globs the filesystem root.
82
+ if (pattern.startsWith('/'))
83
+ return `/${base.join('/')}`;
84
+ return base.length > 0 ? base.join('/') : '.';
85
+ }
86
+ // brace-expansion 5 leaves the rest of a pattern literal when a non-expanding
87
+ // group such as `{draft}` comes before a range. Expand one real group at a time.
88
+ // NUL-prefixed so no `generates` value can forge them: `resolveArtifactOutputPath`
89
+ // runs `path.join` first, which rejects a NUL byte. A single-character sentinel
90
+ // such as U+0001 is a legal filename character, and a pattern containing one
91
+ // would come back out of the expander as a brace.
92
+ const LITERAL_BRACE_OPEN = '\u0000<';
93
+ const LITERAL_BRACE_CLOSE = '\u0000>';
94
+ function hasTopLevelExpansion(body) {
95
+ let depth = 0;
96
+ for (let index = 0; index < body.length; index += 1) {
97
+ const char = body[index];
98
+ if (char === '{')
99
+ depth += 1;
100
+ else if (char === '}')
101
+ depth = Math.max(0, depth - 1);
102
+ else if (depth === 0) {
103
+ if (char === ',')
104
+ return true;
105
+ if (char === '.' && body[index + 1] === '.')
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ function findTopLevelExpansion(pattern) {
112
+ const stack = [];
113
+ for (let index = 0; index < pattern.length; index += 1) {
114
+ const char = pattern[index];
115
+ if (char === '{') {
116
+ stack.push(index);
117
+ }
118
+ else if (char === '}' && stack.length > 0) {
119
+ const open = stack.pop();
120
+ if (open === undefined)
121
+ continue;
122
+ // Only the outermost group that actually expands. brace-expansion expands
123
+ // its nested groups in one linear pass. Peeling the innermost `{a,b}` first
124
+ // turns `{a,{a,{a,b}}}` into a binary tree, so a 16-deep pattern does not
125
+ // finish before the test timeout.
126
+ if (stack.length > 0)
127
+ continue;
128
+ const body = pattern.slice(open + 1, index);
129
+ if (!hasTopLevelExpansion(body)) {
130
+ // `{prefix{a,b}}` has no separator of its own, so the outer braces stay
131
+ // literal. Keep scanning inside them; a nested group may still expand.
132
+ const nested = findTopLevelExpansion(body);
133
+ if (!nested)
134
+ continue;
135
+ return {
136
+ pre: `${pattern.slice(0, open + 1)}${nested.pre}`,
137
+ body: nested.body,
138
+ post: `${nested.post}${pattern.slice(index)}`,
139
+ };
140
+ }
141
+ return { pre: pattern.slice(0, open), body, post: pattern.slice(index + 1) };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ function assertExpansionBudget(pattern, count) {
147
+ if (count <= MAX_BRACE_EXPANSIONS)
148
+ return;
149
+ // A pattern this wide can run to thousands of characters; quote only its start.
150
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
151
+ throw new Error(`Artifact output pattern expands to more than ${MAX_BRACE_EXPANSIONS} patterns: ${shown}`);
152
+ }
153
+ function expandArtifactBraces(pattern) {
154
+ if (pattern.includes('\u0000')) {
155
+ throw new Error('Artifact output pattern contains a NUL byte');
156
+ }
157
+ const pending = [pattern];
158
+ const finished = [];
159
+ while (pending.length > 0) {
160
+ const current = pending.pop();
161
+ if (current === undefined)
162
+ break;
163
+ const group = findTopLevelExpansion(current);
164
+ if (!group) {
165
+ finished.push(current.replaceAll(LITERAL_BRACE_OPEN, '{').replaceAll(LITERAL_BRACE_CLOSE, '}'));
166
+ continue;
167
+ }
168
+ const wrapped = `{${group.body}}`;
169
+ // `max` makes this budget the binding one. Without it `{1..1000000000}`
170
+ // allocates brace-expansion's own 100,000-result default before the check
171
+ // below rejects it. `max` truncates silently, so ask for one result more
172
+ // than the budget allows: a result that long means the pattern is over
173
+ // budget rather than fully expanded. A truncated group never looks literal,
174
+ // because its first alternative is not the group itself.
175
+ const remaining = Math.max(MAX_BRACE_EXPANSIONS - (pending.length + finished.length), 0);
176
+ const alternatives = expand(wrapped, { max: remaining + 1 });
177
+ if (alternatives.length === 1 && alternatives[0] === wrapped) {
178
+ pending.push(`${group.pre}${LITERAL_BRACE_OPEN}${group.body}${LITERAL_BRACE_CLOSE}${group.post}`);
179
+ continue;
180
+ }
181
+ // Checked before the push so the budget bounds memory, not just the result.
182
+ assertExpansionBudget(pattern, pending.length + finished.length + alternatives.length);
183
+ for (const alternative of alternatives) {
184
+ pending.push(`${group.pre}${alternative}${group.post}`);
185
+ }
186
+ }
187
+ const normalized = finished
188
+ .map((value) => value.replace(DUPLICATE_SLASH_RE, '/'))
189
+ .filter((value) => value.length > 0);
190
+ return normalized.length > 0 ? normalized : [pattern];
191
+ }
192
+ /**
193
+ * One directory level. picomatch matches the segment; readdir keeps symlink
194
+ * entries tinyglobby would drop when followSymbolicLinks is false.
195
+ */
196
+ function listDirectoryMatches(directory, segment) {
197
+ let entries;
198
+ try {
199
+ entries = fs.readdirSync(directory, { withFileTypes: true });
200
+ }
201
+ catch (error) {
202
+ if (error.code === 'ENOENT')
203
+ return [];
204
+ throw error;
205
+ }
206
+ const isMatch = picomatch(segment, {
207
+ dot: false,
208
+ nobrace: true,
209
+ noglobstar: true,
210
+ posix: true,
211
+ });
212
+ const names = [];
213
+ for (const entry of entries) {
214
+ if (isMatch(entry.name))
215
+ names.push(entry.name);
216
+ }
217
+ return names;
218
+ }
42
219
  function hasBraceExpansion(pattern) {
43
220
  const openings = [];
44
221
  for (let index = 0; index < pattern.length; index += 1) {
@@ -94,12 +271,16 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
94
271
  // `**` may consume no directory at all.
95
272
  assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
96
273
  }
97
- const matches = fg.sync(segment === '**' ? '*' : segment, {
98
- cwd: canonicalDir,
99
- onlyFiles: false,
100
- followSymbolicLinks: false,
101
- deep: 1,
102
- });
274
+ // `.` and `..` are not directory entries. Resolve them directly so a
275
+ // `**/../` pattern is confined before any glob runs.
276
+ if (segment === '' || segment === '.' || segment === '..') {
277
+ const candidate = path.resolve(canonicalDir, segment === '' ? '.' : segment);
278
+ const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
279
+ FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
280
+ assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
281
+ return;
282
+ }
283
+ const matches = listDirectoryMatches(canonicalDir, segment === '**' ? '*' : segment);
103
284
  for (const match of matches) {
104
285
  const candidate = path.join(canonicalDir, match);
105
286
  try {
@@ -139,27 +320,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
139
320
  return [];
140
321
  }
141
322
  }
142
- const normalizedPattern = FileSystemUtils.toPosixPath(generates);
143
- const globOptions = {
323
+ const normalizedPattern = FileSystemUtils.toPosixPath(generates).replace(DUPLICATE_SLASH_RE, '/');
324
+ // Expand braces before any filesystem access. tinyglobby has no generateTasks,
325
+ // and a `../` alternative is invisible until the braces are expanded.
326
+ const patterns = expandArtifactBraces(normalizedPattern);
327
+ for (const pattern of patterns) {
328
+ FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, globBase(pattern)));
329
+ }
330
+ for (const pattern of patterns) {
331
+ assertGlobDirectoryTraversal(changeDir, changeDir, pattern.split('/').slice(0, -1));
332
+ }
333
+ const matches = globby
334
+ .globSync(patterns, {
144
335
  cwd: changeDir,
145
- onlyFiles: true,
146
- absolute: true,
147
- // Preserve linked artifact directories; confine traversal and concrete matches.
148
- followSymbolicLinks: true,
149
- };
150
- // Task generation expands braces without accessing the filesystem. Validate
151
- // every task base before globbing, including paths introduced by expansion.
152
- const tasks = fg.generateTasks(normalizedPattern, globOptions);
153
- for (const task of tasks) {
154
- FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
155
- }
156
- for (const task of tasks) {
157
- for (const positivePattern of task.positive) {
158
- assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
159
- }
160
- }
161
- const matches = fg
162
- .sync(normalizedPattern, globOptions)
336
+ ...ARTIFACT_GLOB_OPTIONS,
337
+ })
163
338
  .map((match) => {
164
339
  const normalizedMatch = path.normalize(match);
165
340
  FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
@@ -8,6 +8,12 @@ export declare class SchemaLoadError extends Error {
8
8
  readonly cause?: Error | undefined;
9
9
  constructor(message: string, schemaPath: string, cause?: Error | undefined);
10
10
  }
11
+ /**
12
+ * Points the built-in schemas at another directory. The standalone binary has
13
+ * no package directory on disk, so it passes a function that extracts its
14
+ * embedded schemas; it runs only when a command first needs them.
15
+ */
16
+ export declare function setPackageSchemasDir(resolveDir: () => string): void;
11
17
  /**
12
18
  * Gets the package's built-in schemas directory path.
13
19
  * Uses import.meta.url to resolve relative to the current module.
@@ -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',
@@ -75,7 +75,7 @@ export class CompletionProvider {
75
75
  }
76
76
  // Fetch fresh data. The resolver (and the yaml and zod it parses schemas
77
77
  // with) loads only here: completing changes or specs never needs it, and
78
- // the artifact-graph barrel would also pull in fast-glob.
78
+ // the artifact-graph barrel would also pull in tinyglobby.
79
79
  const { listSchemas } = await import('../artifact-graph/resolver.js');
80
80
  const schemaNames = listSchemas(this.projectRoot);
81
81
  // Update cache
@@ -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
  *